为什么带有 T:class 约束的泛型方法会导致装箱?

12
为什么在将T限制为类的通用方法中,生成的MSIL代码会有装箱指令?
我感到很惊讶,因为既然T被限制为引用类型,生成的代码就不需要执行任何装箱操作。
以下是C#代码:
protected void SetRefProperty<T>(ref T propertyBackingField, T newValue) where T : class
{
    bool isDifferent = false;

    // for reference types, we use a simple reference equality check to determine
    // whether the values are 'equal'.  We do not use an equality comparer as these are often
    // unreliable indicators of equality, AND because value equivalence does NOT indicate
    // that we should share a reference type since it may be a mutable.

    if (propertyBackingField != newValue)
    {
        isDifferent = true;
    }
}

这里是生成的IL代码:

.method family hidebysig instance void SetRefProperty<class T>(!!T& propertyBackingField, !!T newValue) cil managed
{
    .maxstack 2
    .locals init (
        [0] bool isDifferent,
        [1] bool CS$4$0000)
    L_0000: nop 
    L_0001: ldc.i4.0 
    L_0002: stloc.0 
    L_0003: ldarg.1 
    L_0004: ldobj !!T
    L_0009: box !!T
    L_000e: ldarg.2 
    L_000f: box !!T
    L_0014: ceq 
    L_0016: stloc.1 
    L_0017: ldloc.1 
    L_0018: brtrue.s L_001e
    L_001a: nop 
    L_001b: ldc.i4.1 
    L_001c: stloc.0 
    L_001d: nop 
    L_001e: ret 
}

注意box !!T指令。

为什么会出现这个问题?

如何避免这种情况?


3
我链接的答案要点是,对引用类型进行装箱指令实际上是nop(无操作)。这使得编译器可以自由地发出装箱指令,而JIT可以删除用引用类型作为泛型类型参数创建的封闭构造类型的指令。在您的情况下(由于“T”被限制为引用类型),发出的两个装箱指令都不会执行。 - Andrew Hare
对于引用类型来说,这些操作将是无操作(no-ops),所以这并不是什么大问题,但我有点怀疑。你是使用/optimize+进行编译吗? - Pavel Minaev
谢谢Andrew。我确实搜索了泛型和装箱,但没有找到那个问题。我猜编译器不会为此实现任何特殊逻辑-因为装箱操作最终什么也没做。不确定这是否是正确的方法,但如果您想将其发布为答案,我会将其标记为已接受。干杯! - Phil
回答你的问题Pavel - 我认为不是这样的 - 这是从调试构建中生成的IL代码,并且项目设置中未选中“优化代码”复选框。 - Phil
4个回答

2
如果box指令的参数是引用类型,那么您不必担心任何性能降低,因为box指令不会执行任何操作。尽管box指令被创建出来仍然有点奇怪(可能是代码生成时的懒惰或容易设计)。

1

我不确定为什么会出现任何装箱。避免装箱的一种可能的方法是不使用它。只需重新编译而不使用装箱即可。例如:

.assembly recomp_srp
{
    .ver 1:0:0:0
}

.class public auto ansi FixedPBF
{

.method public instance void .ctor() cil managed
{

}

.method hidebysig public instance void SetRefProperty<class T>(!!T& propertyBackingField, !!T newValue) cil managed
{
    .maxstack 2    
        .locals init ( bool isDifferent, bool CS$4$0000)

        ldc.i4.0
        stloc.0
        ldarg.1
        ldobj !!T
        ldarg.2
        ceq
        stloc.1
        ldloc.1
        brtrue.s L_0001
        ldc.i4.1
        stloc.0
        L_0001: ret

}

}

如果您将其保存到文件recomp_srp.msil中,可以通过以下方式简单地重新编译:

ildasm /dll recomp_srp.msil

在我的端上运行时不需要装箱,一切正常:

        FixedPBF TestFixedPBF = new FixedPBF();

        TestFixedPBF.SetRefProperty<string>(ref TestField, "test2");

当然,我将其从protected更改为public,您需要将更改改回来并提供其余的实现。


虽然这确实是一个正确的解决方案(如果我要吹嘘的话,这个解决方案现在对于我来说已经变得非常容易,感谢我为追求神秘的混合C#/IL汇编而付出的艰苦努力),但我怀疑大多数人都不会准备从C#转到IL来解决这个bug。 - Glenn Slayden

0

我认为这是设计意图。您没有将T限制为特定类,因此最有可能将其向下转换为对象。这就是为什么您会看到IL包括装箱的原因。

我建议尝试使用where T:ActualClass的代码。


3
如果您要执行 T:ActualClass,为什么还要使用泛型? - Robert Harvey
因为您可以将T限制到更高的级别,例如iSomeInterface... - Chris Marisic
1
Chris,如果T是一个对象,那么在将其推入堆栈之前,它不就已经被装箱了吗?那么为什么还需要执行任何装箱操作呢?如果T是一个对象,我会期望==运算符检查引用相等性,因此也不需要进行装箱/拆箱操作。 - Phil
@RobertHarvey 为了解决这个持续了10年的争议,并确认这种不幸的C#行为在2019年仍然存在,即使将其限制在某个派生类“where T:ActualClass”中,也会发出不必要的装箱指令,而不是OP指定的“where T:class”,后者可能涉及一些“最小派生引用类型”(请注意- 不是从中理论上派生System.ValueType的System.Object)。 - Glenn Slayden

0

关于一些要点的跟进。首先,这个错误出现在一个有约束where T : class泛型类中的两种方法以及具有相同约束的泛型方法中(在通用或非通用类中)。它不会出现在使用Object而不是T的(除此之外完全相同的)非泛型方法中:

// static T XchgNullCur<T>(ref T addr, T value) where T : class =>
//              Interlocked.CompareExchange(ref addr, val, null) ?? value;
    .locals init (!T tmp)
    ldarg addr
    ldarg val
    ldloca tmp
    initobj !T
    ldloc tmp
    call !!0 Interlocked::CompareExchange<!T>(!!0&, !!0, !!0)
    dup 
    box !T
    brtrue L_001a
    pop 
    ldarg val
L_001a:
    ret 


// static Object XchgNullCur(ref Object addr, Object val) =>
//                   Interlocked.CompareExchange(ref addr, val, null) ?? value;
    ldarg addr
    ldarg val
    ldnull
    call object Interlocked::CompareExchange(object&, object, object)
    dup
    brtrue L_000d
    pop
    ldarg val
L_000d:
    ret

注意第一个示例中还有一些额外的问题。与其只是使用 ldnull,我们多了一个毫无意义地针对多余局部变量 tmp 的无用 initobj 调用。

然而,好消息是,在此暗示 这里,这些都不重要。尽管上述两个示例生成的 IL 代码存在差异,但 x64 JIT 为它们生成的代码几乎相同。以下结果是针对 .NET Framework 4.7.2 发布模式,并启用了 "未禁止优化"。

enter image description here


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接