在检查null时,泛型函数是否隐式地将值类型转换为对象?

时间:2014-01-24 22:13:23

标签: c# reference null value-type

例如,以下代码演示了我的思路:

class Program
{
    static void Main(string[] args)
    {
        int i = 0;
        IsNull(i);  // Works fine

        string s = null;
        IsNull(s);  // Blows up
    }

    static void IsNull<T>(T obj)
    {
        if (obj == null)
            throw new NullReferenceException();
    }

}

还有以下代码:

int i = 0;
bool b = i == null;  // Always false

是否存在隐式对象强制转换?这样:

int i = 0;
bool b = (object)i == null;

3 个答案:

答案 0 :(得分:6)

xxbbcc的答案假设OP询问“为什么不等于0”,这可能就是问题的全部。另一方面,在泛型类型的上下文中,有关拳击的问题通常与通用类型通过避免装箱提供的性能优势有关。

在考虑这个问题时,IL可能会产生误导。它包含一个box指令,但这并不意味着实际将在堆上分配值类型的盒装实例。 IL“框”了该值,因为IL代码也是通用的;类型参数的类型参数的替换是JIT编译器的责任。对于不可为空的值类型,JIT编译器优化了装箱的IL指令并检查结果,因为它知道结果总是非空的。

我添加了对示例代码的Thread.Sleep调用,以便有时间附加调试器。 (如果在Visual Studio中使用F5启动调试器,则即使是发布版本,也会禁用某些优化)。这是Release build中的机器代码:

            Thread.Sleep(20000);
00000000 55                   push        ebp 
00000001 8B EC                mov         ebp,esp 
00000003 83 EC 0C             sub         esp,0Ch 
00000006 89 4D FC             mov         dword ptr [ebp-4],ecx 
00000009 83 3D 04 0B 4E 00 00 cmp         dword ptr ds:[004E0B04h],0 
00000010 74 05                je          00000017 
00000012 E8 AD 4B 6A 71       call        716A4BC4 
00000017 33 D2                xor         edx,edx 
00000019 89 55 F4             mov         dword ptr [ebp-0Ch],edx 
0000001c 33 D2                xor         edx,edx 
0000001e 89 55 F8             mov         dword ptr [ebp-8],edx 
00000021 B9 C8 00 00 00       mov         ecx,0C8h 
00000026 E8 45 0E 63 70       call        70630E70 

            int i = 0;
0000002b 33 D2                xor         edx,edx 
0000002d 89 55 F8             mov         dword ptr [ebp-8],edx 
            IsNull(i);  // Works fine
00000030 8B 4D F8             mov         ecx,dword ptr [ebp-8] 
00000033 FF 15 E4 1B 4E 00    call        dword ptr ds:[004E1BE4h] 

            string s = null;
00000039 33 D2                xor         edx,edx 
0000003b 89 55 F4             mov         dword ptr [ebp-0Ch],edx 
            IsNull(s);  // Blows up
0000003e 8B 4D F4             mov         ecx,dword ptr [ebp-0Ch] 
00000041 BA 50 1C 4E 00       mov         edx,4E1C50h 
00000046 FF 15 24 1C 4E 00    call        dword ptr ds:[004E1C24h] 
        }
0000004c 90                   nop 
0000004d 8B E5                mov         esp,ebp 
0000004f 5D                   pop         ebp 
00000050 C3                   ret 

请注意,调用指令对int和字符串有不同的目标。他们在这里:

            if (obj == null)
00000000 55                   push        ebp 
00000001 8B EC                mov         ebp,esp 
00000003 83 EC 0C             sub         esp,0Ch 
00000006 33 C0                xor         eax,eax 
00000008 89 45 F8             mov         dword ptr [ebp-8],eax 
0000000b 89 45 F4             mov         dword ptr [ebp-0Ch],eax 
0000000e 89 4D FC             mov         dword ptr [ebp-4],ecx 
00000011 83 3D 04 0B 32 00 00 cmp         dword ptr ds:[00320B04h],0 
00000018 74 05                je          0000001F 
0000001a E8 ED 49 6E 71       call        716E4A0C 
0000001f B9 70 C7 A4 70       mov         ecx,70A4C770h 
00000024 E8 2F FA E9 FF       call        FFE9FA58 
00000029 89 45 F8             mov         dword ptr [ebp-8],eax 
0000002c 8B 45 F8             mov         eax,dword ptr [ebp-8] 
0000002f 8B 55 FC             mov         edx,dword ptr [ebp-4] 
00000032 89 50 04             mov         dword ptr [eax+4],edx 
00000035 8B 45 F8             mov         eax,dword ptr [ebp-8] 
00000038 85 C0                test        eax,eax 
0000003a 75 1D                jne         00000059 
                throw new NullReferenceException();
0000003c B9 98 33 A4 70       mov         ecx,70A43398h 
00000041 E8 12 FA E9 FF       call        FFE9FA58 
00000046 89 45 F4             mov         dword ptr [ebp-0Ch],eax 
00000049 8B 4D F4             mov         ecx,dword ptr [ebp-0Ch] 
0000004c E8 DF 22 65 70       call        70652330 
00000051 8B 4D F4             mov         ecx,dword ptr [ebp-0Ch] 
00000054 E8 BF 2A 57 71       call        71572B18 
        }
00000059 90                   nop 
0000005a 8B E5                mov         esp,ebp 
0000005c 5D                   pop         ebp 
0000005d C3                   ret 

            if (obj == null)
00000000 55                   push        ebp 
00000001 8B EC                mov         ebp,esp 
00000003 83 EC 0C             sub         esp,0Ch 
00000006 33 C0                xor         eax,eax 
00000008 89 45 F8             mov         dword ptr [ebp-8],eax 
0000000b 89 45 F4             mov         dword ptr [ebp-0Ch],eax 
0000000e 89 4D FC             mov         dword ptr [ebp-4],ecx 
00000011 83 3D 04 0B 32 00 00 cmp         dword ptr ds:[00320B04h],0 
00000018 74 05                je          0000001F 
0000001a E8 ED 49 6E 71       call        716E4A0C 
0000001f B9 70 C7 A4 70       mov         ecx,70A4C770h 
00000024 E8 2F FA E9 FF       call        FFE9FA58 
00000029 89 45 F8             mov         dword ptr [ebp-8],eax 
0000002c 8B 45 F8             mov         eax,dword ptr [ebp-8] 
0000002f 8B 55 FC             mov         edx,dword ptr [ebp-4] 
00000032 89 50 04             mov         dword ptr [eax+4],edx 
00000035 8B 45 F8             mov         eax,dword ptr [ebp-8] 
00000038 85 C0                test        eax,eax 
0000003a 75 1D                jne         00000059 
                throw new NullReferenceException();
0000003c B9 98 33 A4 70       mov         ecx,70A43398h 
00000041 E8 12 FA E9 FF       call        FFE9FA58 
00000046 89 45 F4             mov         dword ptr [ebp-0Ch],eax 
00000049 8B 4D F4             mov         ecx,dword ptr [ebp-0Ch] 
0000004c E8 DF 22 65 70       call        70652330 
00000051 8B 4D F4             mov         ecx,dword ptr [ebp-0Ch] 
00000054 E8 BF 2A 57 71       call        71572B18 
        }
00000059 90                   nop 
0000005a 8B E5                mov         esp,ebp 
0000005c 5D                   pop         ebp 
0000005d C3                   ret 

看起来或多或少相同,对吧?但是如果你首先启动进程然后附加调试器,那么这就是你得到的结果:

            Thread.Sleep(20000);
00000000 55                   push        ebp 
00000001 8B EC                mov         ebp,esp 
00000003 50                   push        eax 
00000004 B9 20 4E 00 00       mov         ecx,4E20h 
00000009 E8 6A 0C 67 71       call        71670C78 
            IsNull(s);  // Blows up
0000000e B9 98 33 A4 70       mov         ecx,70A43398h 
00000013 E8 6C 20 F9 FF       call        FFF92084 
00000018 89 45 FC             mov         dword ptr [ebp-4],eax 
0000001b 8B C8                mov         ecx,eax 
0000001d E8 66 49 6C 70       call        706C4988 
00000022 8B 4D FC             mov         ecx,dword ptr [ebp-4] 
00000025 E8 46 51 5E 71       call        715E5170 
0000002a CC                   int         3 

不仅优化器删除了值类型的装箱,它还通过完全删除它来内联对值类型的IsNull方法的调用。从上面的机器代码来看并不明显,但是也引入了对IsNull的引用类型的调用。 call 706C4988指令似乎是NullReferenceException构造函数,而call 715E5170似乎是throw

答案 1 :(得分:5)

是的,obj被编译器装箱。这是为IsNull函数生成的IL:

.maxstack 8

IL_0000: ldarg.0
IL_0001: box !!T
IL_0006: brtrue.s IL_000e

IL_0008: newobj instance void [mscorlib]System.NullReferenceException::.ctor()
IL_000d: throw

IL_000e: ret

box指令是投射的地方。

编译器不知道有关T的任何具体内容,因此它必须假设它必须是object - .NET中所有内容的基本类型;这就是它obj框以确保可以执行空检查的原因。如果您使用type constraint,则可以向编译器提供有关T

的更多信息

例如,如果使用where T : struct,则IsNull函数将不再编译,因为编译器知道T是值类型而null不是值类型的值。< / p>

装箱值类型实例始终返回有效(非空)对象实例*,因此IsNull函数永远不会为值类型抛出。如果您考虑这一点,这实际上是正确的行为:数值0不是null - 值类型值不可能是null

在上面的代码brtrue.s非常像if(objref!=0) - 它不检查对象的值(装箱前的值类型值),因为在检查时,它不是一个位于堆栈顶部的值:它是位于顶部的盒装对象实例。由于该值(它实际上是一个指针)是非空的,因此null的检查永远不会恢复为真。

* Jon Hanna在评论中指出,对于default(Nullable<T>)来说这个陈述是正确的 - 这是正确的 - 对于任何null,此值返回T

答案 2 :(得分:2)

这里有两个答案都有价值,我会给phoog一个标记,以回答大多数人在询问这个问题时所遇到的实际问题(之前的变体已经出现)。但也存在不完整性。

有四种方法可以查看相关代码,而且这四种方法都非常重要,而且答案只有两种(尽管phoog对其中一种很重要)。

我将从目前忽略的问题部分开始:

  

还有以下代码:

int i = 0;`
bool b = i == null;  // Always false`
  

是否存在隐式对象强制转换?这样:

int i = 0;
bool b = (object)i == null;

嗯,是的,不是。这取决于我们正在考虑的水平,我们实际上必须在不同的时间在不同的水平上看它,所以说这不仅仅是迂腐。

C#是四件事:

  1. 它本身就是一种计算机语言。我们可以在其中进行推理,并检查是否符合其规则,以及根据这些规则意味着什么。
  2. 这是一种产生CIL的方式,CIL本身就是另一种语言,适用于上述语言。
  3. 通过CIL,它是一种在运行时或通过Ngen生成机器代码的方式,Ngen本身也是一种语言。
  4. 这是告诉计算机做某事的一种方式,这通常是练习的重点。
  5. 到目前为止,答案已经看了第2点和第3点,但全部图片都是四个。

    最重要的一点实际上是第1点和第4点。

    第1点很重要,因为C#是我们正在研究的所有语言之后,并且视图同事最有可能看到。由于编程部分地指示计算机做某事,并且部分地表达了一个人的意图(中级和高级编程语言首先是人,计算机第二),实际的源代码很重要。 / p>

    第4点很重要,因为这是我们最终的目标。这与查看机器代码的装配并不是一回事(正如phoog的答案所做的那样),因为机器代码不是最终的答案,关于进行了哪些更改和优化:

    1. CPU对自己做了优化。当分支出现时,这尤其重要。
    2. 当被认为纯粹作为理论语言时,两个程序集是等效的,它们对CPU缓存的处理程度可能不同。
    3. 当被认为纯粹作为理论语言时,两个程序集是等效的,可能会有不同之处在于,它会执行导致性能问题,不正确结果,异常或死亡屏幕的未对齐读取。
    4. 当被认为纯粹作为理论语言的两个组件相当于我的性能差异,因为一个使用CPU发生执行速度比另一个逻辑等效指令更快的指令。
    5. 依旧......
    6. 现在,所有这一切,在我们现在看的情况下,机器代码就我们需要了解机器的行为而言。一般而言,机器码每次都不是最终答案。尽管如此,phoog的回答并不是暗示而不是说明这里的影响;我只提到它,因为我的目标是写出不同的概念水平,其中phoog和xxbbcc都以不同的方式正确。

      返回我们的bool b = i == null代码,其中i的类型为int

      在C#null中定义为一个文字值,它是所有引用类型和可空值类型的默认值。它可以与参考相等的任何值进行比较 - 也就是问题&#34; X和Y是同一个实例&#34;可以询问null作为X的值,如果Y不是实例则答案为真,否则为假。

      要与值类型进行比较,我们必须对值类型进行处理,就像我们必须将值类型视为引用类型的任何情况一样。

      如果值类型是可以为null的值类型,并且它为null(HasValue返回false),则boxing会生成空引用。在所有其他情况下,装箱值类型会创建对堆上新对象的引用,类型为object,引用相同的值并可以取消装箱。

      因此,在C#的概念层面上的答案是&#34;是的,我被隐式地装箱以创建一个新对象,然后将其与null进行比较[因此将始终返回false]&#34;。

      在下一个级别,我们有CIL。

      在CIL中,null是一个具有自然字大小的值(32位进程中的32位,64位进程中的64位)全0的位模式(因此brfalsebrzerobrnull只是相同字节码的别名),这是托管指针,指针,自然整数和任何其他提供地址的方法的有效值。

      同样在CIL中,拳击是对等效的盒装类型进行的;它不只是object,而是boxed type of intboxed type of float等等。这对C#是隐藏的,因为它不是很有用(你不能这样做)任何具有这些类型的东西,除了你可以在object和unbox回到等效的未装箱类型的东西之外的东西),但更准确地在CIL中定义,因为它需要执行&#34;拳击怎么样才能在很多不同的类型上完成了?&#34;。

      CIL中的等效代码至少应为:

      ldc.i4.0                   // push the value 0 onto the stack.
      box [mscorlib]System.Int32 // pop the top value from the stack, box it as boxed Int32,
                                 // and push that boxed value onto the stack.
      ldnull                     // push null (all zeros) onto the stack
      ceq                        // pop the top two values onto the stack, if they are equal
                                 // push 1 onto the stack, otherwise push 0 onto the stack.
      //Instructions that actually act on "b" here, probably a stloc so it can be loaded as needed later.
      

      我说&#34;至少&#34;因为可能有一些加载和存储到相关方法的locals数组。

      因此,在CIL级别,答案也是&#34;是的,我被隐式地装箱以创建一个新对象,然后将其与null进行比较[因此将始终返回false]&#34;。

      然而,这实际上不是将要生成的CIL。在发布版本中,它将是:

      ldc.i4.0                   // push the value 0 onto the stack.
      //Instructions that actually act on "b" here, probably a stloc so it can be loaded as needed later.
      

      也就是说,它将优化代码,该代码总是对只产生错误的代码产生错误。即使在调试版本中,我们也可能会进行一些优化。

      但是当我在CIL中说用于比较整数和空值的代码时,我并不撒谎。确实如此,但是C#编译器可以看到这段代码是浪费时间,只需将代码加载到b中即可。实际上,如果b以后没有使用过,那么它可能会切断整个事情。 (相反,如果稍后使用i,它仍会在某个时刻加载0,而不是像上例中那样将其剪切掉。)

      这是我们第一次遇到编译器优化问题,现在是时候检查这意味着什么了。

      编译器优化归结为一个简单的观察;如果一段代码可以被重写为具有与从外部看到的相同效果的不同代码,但是更快和/或使用更少的内存和/或导致更小的可执行文件,那么只有一个白痴会抱怨如果你生产了更快/更小/更轻的版本。

      这个简单的观察变得复杂化了两件事。第一个是在更快的版本和更轻的版本之间做出选择时该怎么做。一些编译器提供了权衡这些选择的选项(大多数C ++编译器都有),但C#没有。另一个是从外面看到的&#34;&#34;意思?过去很简单&#34;产生的任何输出,与其他进程的交互,或对volatile *变量的操作&#34;。当你有多个线程时,它会变得有点复杂,其中一个线程正在执行垃圾收集,所有这些当然都是#34;外部&#34;彼此之间的关系,因为这使得优化(例如,如果它涉及重新排序)的情况的数量可能影响观察到的内容。不过,这些都不适用于此。

      C#编译器没有做很多优化,因为抖动总是要做很多事情,所以优化的缺点(1.所有工作都有机会发生错误,所以如果你不做你赢得的特定优化没有与该优化相关的错误.2。如果给定的优化将由下一层完成,那么优化的东西就越多,你可以混淆开发人员看到它就会变得更加重要

      不过,它确实 优化。

      实际上,它将优化整个部分。拿代码:

      public static void Main(string[] args)
      {
        int i = 0;
        if(i == null)
        {
          Console.WriteLine("wow");
          Console.WriteLine("didn't expect that");
        }
        else
        {
          Console.WriteLine("ok");
          Console.WriteLine("expected");
        }
      }
      

      编译它,然后将其反编译回C#,你得到:

      public static void Main(string[] args)
      {
        Console.WriteLine("ok");
        Console.WriteLine("expected");
      }
      

      因为编译器可以删除它知道永远不会被命中的整个代码段。

      因此,在C#和IL中,将值类型与null进行比较涉及装箱,C#编译器将删除这种无意义的无法实现并且实际上不会发生拳击。它也会发出警告CS0472,因为如果你在你的代码中放置了明显无意义的错误,那么你的想法可能会出现问题,你应该看看它并找出你真正想要做的事情。

      此时此值得考虑如果i属于int?类型会发生什么情况;可以装箱为空。仍然有一个优化:

      1. 大多数情况下,拳击和比较被HasValue字段的调用取代。这比拳击更有效。
      2. 有时,编译器可以(由于对所讨论的值的了解)优化甚至远离。
      3. (在此阶段,集会问题无关紧要,因为拳击和比较已被删除)。

        现在,如果我们有泛型方法(或泛型类的方法)接受值和引用类型参数的情况,那么C#编译器无法完成此优化,因为通用方法未实例化在编译时将其转换为特定的专用形式(与其他类似的C ++模板不同),但是在jitting时间。

        由于这个原因,产生的IL将始终包括装箱操作(除非有其他原因,即使在参考类型的情况下也可以优化它)。

        但是,抖动与装箱非可空值类型永远不会产生空值的事实有很多相同的知识,C#编译器使用我们的第一个例子。它在优化方面也比C#编译器更具攻击性。

        这是我们得到phoog在他们的答案中描述的行为:在为值类型类型参数生成的代码中,装箱操作被完全删除(使用引用类型参数,装箱操作基本上是 - 操作并删除)。检查将被删除,因为答案是已知的,并且实际上只有在该检查返回true时才会执行的整个代码段也将被删除。

        案例phoog没有检查是可以为值的类型。在这里,拳击和比较将至少被调用HasValue替换,而HasValue将被内联到读取结构中的内部字段。可能(如果已知该值永远不为空,或者如果它已知它始终为空)将被删除,以及将永远不会执行的一整段代码反正。

        <强>摘要

        您的问题背后还有两个更具体的问题,您可能对其中一个或两个感兴趣。

        问题1:我对C#如何作为一种语言感兴趣,我想知道就C#而言,将非可空值类型与值类型的空方框进行比较。

        答案1:是的,与null的比较只能通过引用类型完成 - 包括盒装值类型 - 因此总是有一个装箱操作。

        问题2:我有一个通用代码,它将一个值与null进行比较,因为我想只在它是引用类型或可空值类型时才能做某事,如果值相等则为为空。在比较的类型是值类型的情况下,我的代码是否会支付装箱操作的性能损失?

        答案2:否。在C#编译器无法优化其产生的IL的代码的情况下,抖动仍然可以。对于不可为空的值类型,整个装箱操作,比较和仅在与null的比较返回true时采用的代码路径将全部从生成的机器代码中删除,从而从计算机所做的工作中删除。此外,如果它是一个可以为空的值类型,那么装箱和比较将被替换为对值中字段的检查,以指示volatile是否为真。

        *请注意,{{1}}的这个定义与.NET中的定义有关,但与.NET中的定义不同,原因还在于如何更多地支持多线程执行使得它们如何复杂化是在20世纪60年代。