【问题标题】:Using C# 7.2 in modifier for parameters with primitive types使用 C# 7.2 in 修饰符处理原始类型的参数
【发布时间】:2018-11-19 12:42:11
【问题描述】:

C# 7.2 引入了in 修饰符,用于通过引用传递参数,并保证接收者不会修改参数。

这个article 说:

你不应该使用非只读结构作为 in 参数,因为它可能会对性能产生负面影响,并且如果结构是可变的,可能会导致模糊的行为

这对于 intdouble 等内置原语意味着什么?

我想在代码中使用in 来表达意图,但不以牺牲防御性副本的性能为代价。

问题

  • 通过 in 参数传递原始类型并且不制作防御性副本是否安全?
  • 其他常用的框架结构,例如DateTimeTimeSpanGuid、...是否被 JIT 视为readonly
    • 如果这因平台而异,我们如何确定在给定情况下哪些类型是安全的?

【问题讨论】:

  • 如果 JIT 制作了原始值类型的“防御性副本”以防止“变异”,那可能是一个错误,in 或没有 in。在所有情况下,这些副本都被证明是不必要的。 “常用”结构的问题更有趣,但我怀疑 JIT 根本不会做任何特殊的事情来防止复制,除非它是 readonly struct。你会注意到框架结构确实是being augmented to have this
  • 请注意,像 Int32 这样的“结构”几乎与 JIT 无关;它们名义上代表 BCL 中的类型,但这主要用于反射目的。 JIT 在最基本的层面上知道如何处理int。因此,Int32 是否标记为 readonly 与 JIT 无关(实际上,currently that's not marked readonly)。
  • 如果你只想表达意图,为什么不使用[In]之类的属性呢?无论如何,从调用方使用 in 修饰符是可选的。而且它也不显示在 IntelliSense 中(尽管我仍然使用 ReSharper 2017 并且不确定新版本是否显示它)。如果您真的想应用限制,则可以通过自定义 FxCop 规则声明属性...

标签: c# .net optimization c#-7.2 in-parameters


【解决方案1】:

快速测试表明,目前,是的,为内置原始类型和结构创建了防御性副本。

使用 VS 2017 编译以下代码(.NET 4.5.2,C# 7.2,发布版本):

using System;

class MyClass
{
    public readonly struct Immutable { public readonly int I; public void SomeMethod() { } }
    public struct Mutable { public int I; public void SomeMethod() { } }

    public void Test(Immutable immutable, Mutable mutable, int i, DateTime dateTime)
    {
        InImmutable(immutable);
        InMutable(mutable);
        InInt32(i);
        InDateTime(dateTime);
    }

    void InImmutable(in Immutable x) { x.SomeMethod(); }
    void InMutable(in Mutable x) { x.SomeMethod(); }
    void InInt32(in int x) { x.ToString(); }
    void InDateTime(in DateTime x) { x.ToString(); }

    public static void Main(string[] args) { }
}

使用 ILSpy 反编译时产生以下结果:

...
private void InImmutable([System.Runtime.CompilerServices.IsReadOnly] [In] ref MyClass.Immutable x)
{
    x.SomeMethod();
}

private void InMutable([System.Runtime.CompilerServices.IsReadOnly] [In] ref MyClass.Mutable x)
{
    MyClass.Mutable mutable = x;
    mutable.SomeMethod();
}

private void InInt32([System.Runtime.CompilerServices.IsReadOnly] [In] ref int x)
{
    int num = x;
    num.ToString();
}

private void InDateTime([System.Runtime.CompilerServices.IsReadOnly] [In] ref DateTime x)
{
    DateTime dateTime = x;
    dateTime.ToString();
}
...

(或者,如果您更喜欢 IL:)

IL_0000: ldarg.1
IL_0001: ldobj [mscorlib]System.DateTime
IL_0006: stloc.0
IL_0007: ldloca.s 0
IL_0009: call instance string [mscorlib]System.DateTime::ToString()
IL_000e: pop
IL_000f: ret

【讨论】:

  • 值得注意的是,对于在 64 位系统上运行的 64 位 CLR,所有原语(不包括 decimal)都是字大小或更小的。因此,加载参数的地址不应该比加载它的值便宜。即使没有制作防御性副本,我也不会费心将原始参数标记为in
  • @MikeStrobel:出于性能目的,你是对的。但是,OP 希望使用 in 进行文档记录(以记录该值将在方法中保持不变),这是一个不同的用例。
【解决方案2】:

从 jit 的角度来看,in 改变了参数的调用约定,使其始终通过引用传递。因此,对于原始类型(复制起来很便宜)并且通常按值传递,如果您使用in,调用方和被调用方都会产生少量额外成本。但是,没有制作防御性副本。

例如在

using System;
using System.Runtime.CompilerServices;

class X
{
    [MethodImpl(MethodImplOptions.NoInlining)]
    static int F0(in int x) { return x + 1; }

    [MethodImpl(MethodImplOptions.NoInlining)]
    static int F1(int x) { return x + 1; }

    public static void Main()
    {
        int x = 33;
        F0(x);
        F0(x);
        F1(x);
        F1(x);
    }
}

Main 的代码是

   C744242021000000     mov      dword ptr [rsp+20H], 33
   488D4C2420           lea      rcx, bword ptr [rsp+20H]
   E8DBFBFFFF           call     X:F0(byref):int
   488D4C2420           lea      rcx, bword ptr [rsp+20H]
   E8D1FBFFFF           call     X:F0(byref):int
   8B4C2420             mov      ecx, dword ptr [rsp+20H]
   E8D0FBFFFF           call     X:F1(int):int
   8B4C2420             mov      ecx, dword ptr [rsp+20H]
   E8C7FBFFFF           call     X:F1(int):int

注意因为in x 无法注册。

F0 & F1 的代码显示前者现在必须从 byref 中读取值:

;; F0
   8B01                 mov      eax, dword ptr [rcx]
   FFC0                 inc      eax
   C3                   ret

;; F1
   8D4101               lea      eax, [rcx+1]
   C3                   ret

如果 jit 内联,通常可以撤消此额外成本,但并非总是如此。

【讨论】:

  • 确实可以制作防御性副本,但前提是调用了潜在的变异成员。如果您在算术表达式中使用in int,则无需进行复制,但如果您调用Int32.ToString(),您将看到确实进行了复制。此外,副本是在参考位置制作的,例如,如果您拨打ToString() 两次,将制作两个单独的副本:每个呼叫站点一个。
  • 很公平。我真的在寻找由 in 方法的调用者制作的副本,而不是在调用其他东西时由方法本身制作的副本。
【解决方案3】:

使用当前的编译器,防御性副本确实似乎是为“原始”值类型和其他非只读结构制作的。具体来说,它们的生成方式与readonly 字段的生成方式类似:当访问可能会改变内容的属性或方法时。副本出现在每个调用站点给一个潜在的变异成员,所以如果你调用 n 个这样的成员,你最终会让 n 防御副本。与readonly 字段一样,您可以通过手动将原件复制到本地来避免多次复制。

看看this suite of examples。您可以查看 IL 和 JIT 程序集。

通过 in 参数传递原始类型并且不制作防御性副本是否安全?

这取决于您是否访问in 参数上的方法或属性。如果这样做,您可能会看到防御性副本。如果没有,您可能不会:

// Original:
int In(in int _) {
    _.ToString();
    _.GetHashCode();
    return _ >= 0 ? _ + 42 : _ - 42;
}

// Decompiled:
int In([In] [IsReadOnly] ref int _) {
    int num = _;
    num.ToString();    // invoke on copy
    num = _;
    num.GetHashCode(); // invoke on second copy
    if (_ < 0)
        return _ - 42; // use original in arithmetic
    return _ + 42;
}

其他常用的框架结构,例如 DateTime、TimeSpan、Guid,... 是否被 [编译器] 视为只读?

不,仍将在调用站点为这些类型的 in 参数上可能发生变异的成员制作防御性副本。然而,有趣的是,并非所有方法和属性都被认为是“潜在的变异”。我注意到,如果我调用默认方法实现(例如,ToStringGetHashCode),则不会发出防御性副本。但是,一旦我覆盖了这些方法,编译器就会创建副本:

struct WithDefault {}
struct WithOverride { public override string ToString() => "RO"; }

// Original:
void In(in WithDefault d, in WithOverride o) {
    d.ToString();
    o.ToString();
}

// Decompiled:
private void In([In] [IsReadOnly] ref WithDefault d,
                [In] [IsReadOnly] ref WithOverride o) {
    d.ToString();            // invoke on original
    WithOverride withOverride = o;
    withOverride.ToString(); // invoke on copy
}

如果这因平台而异,我们如何确定在给定情况下哪些类型是安全的?

嗯,所有类型都是“安全的”——副本确保了这一点。我假设您在问哪些类型会避免防御性副本。正如我们在上面看到的,它比“参数的类型是什么”更复杂?没有单一副本:副本在对 in 参数的某些引用处发出,例如,引用是调用目标的地方。如果不存在此类引用,则无需制作副本。此外,是否复制的决定取决于您调用的是已知安全或“纯”的成员,还是调用可能改变 a 值类型内容的成员。

目前,某些默认方法似乎被视为纯方法,编译器会避免在这些情况下进行复制。如果我不得不猜测,这是预先存在的行为的结果,编译器正在利用一些最初为readonly 字段开发的“只读”引用概念。正如您在下面看到的(或in SharpLab),行为是相似的。注意 IL 在调用WithDefault.ToString 时如何使用ldflda(加载字段按地址)将调用目标压入堆栈,但使用ldfldstlocldloca 序列在调用WithOverride.ToString 时将副本 压入堆栈:

struct WithDefault {}
struct WithOverride { public override string ToString() => "RO"; }

static readonly WithDefault D;
static readonly WithOverride O;

// Original:
static void Test() {
    D.ToString();
    O.ToString();
}

// IL Disassembly:
.method private hidebysig static void Test () cil managed {
    .maxstack 1
    .locals init ([0] valuetype Overrides/WithOverride)

    // [WithDefault] Invoke on original by address:
    IL_0000: ldsflda valuetype Overrides/WithDefault Overrides::D
    IL_0005: constrained. Overrides/WithDefault
    IL_000b: callvirt instance string [mscorlib]System.Object::ToString()
    IL_0010: pop

    // [WithOverride] Copy original to local, invoke on copy by address:
    IL_0011: ldsfld valuetype Overrides/WithOverride Overrides::O
    IL_0016: stloc.0
    IL_0017: ldloca.s 0
    IL_0019: constrained. Overrides/WithOverride
    IL_001f: callvirt instance string [mscorlib]System.Object::ToString()
    IL_0024: pop
    IL_0025: ret
}

也就是说,既然只读引用可能会变得更加普遍,那么可以在没有防御性副本的情况下调用的方法的“白名单”将来可能会增加。就目前而言,这似乎有些武断。

【讨论】:

    【解决方案4】:

    这对于 int、double 等内置原语意味着什么?

    没有什么,intdouble 以及所有其他内置“原语”都是不可变的。您不能对doubleintDateTime 进行变异。例如,一个不适合的典型框架类型是 System.Drawing.Point

    说实话,文档可能会更清晰一些; readonly 在这种情况下是一个令人困惑的术语,它应该简单地说类型应该是不可变的。

    没有规则可以知道任何给定类型是否不可变;只有仔细检查 API 才能给您一个想法,或者,如果幸运的话,文档可能会说明它是否存在。

    【讨论】:

    • 我知道原始值在实践中是不可变的。真正的问题是 JIT 是否以这种方式看待它们。
    • @DrewNoakes 你想多了。您有语言限制或建议;它说不要使用可变类型。你为什么要担心抖动?
    • 我的兴趣是防止防御性副本。无论是由 JIT 还是编译器完成,对性能的影响都是一样的。你读过我链接的文章吗?
    • @DrewNoakes 啊,对不起。好吧,我的猜测是,在更新框架并将原始类型标记为只读之前,行为将就好像不能保证这些类型是不可变的。我不完全确定的是,只读类型是否是一项重大更改。如果是,那么显然所有内置类型都必须保持原样,并且可能会为特定的框架类型实现一些特殊的编译器魔法。
    • 编译器已经在许多场景中对原语进行了特殊处理。感谢您的猜测,但我正在寻找一个明确的答案。
    猜你喜欢
    • 2019-03-31
    • 2020-04-03
    • 2012-08-25
    • 2018-05-23
    • 2019-05-11
    • 2020-03-13
    • 1970-01-01
    • 2019-03-20
    相关资源
    最近更新 更多