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

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

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 obj) { if (obj == null) throw new NullReferenceException(); } } 

还有以下代码:

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

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

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

是的, 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包装起来以确保可以执行空检查。 如果使用类型约束 ,则可以向编译器提供有关T更多信息。

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

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

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

* Jon Hanna在评论中指出这个陈述不是default(Nullable)是正确的 – 装箱这个值对任何T返回null

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

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

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

  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

这里的两个答案都有一个价值,我会给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. 这是告诉计算机做某事的一种方式,这通常是练习的重点。

到目前为止,答案已经看了第2点和第3点,但全部图片都是四个。

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

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

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

  1. CPU对自己做了优化。 当分支出现时,这尤其重要。
  2. 当被认为纯粹作为理论语言时,两个程序集是等效的,它们对CPU缓存的处理程度可能不同。
  3. 当被认为纯粹作为理论语言时,两个程序集是等价的,可能不同之处在于执行不对齐的读取会导致性能问题,错误结果,exception或死亡屏幕。
  4. 当被认为纯粹作为理论语言时,两个程序集相当于我的性能差异,因为一个使用CPU发生执行速度比另一个逻辑等效指令快的指令。
  5. 等等…

现在,总而言之,在我们现在看到的情况下,机器代码就我们需要了解机器的行为而言。 一般而言,机器码每次都不是最终答案。 尽管如此,phoog的答案不是暗示而不是说明这里的影响; 我只提到它,因为我的目的是写出不同的概念水平,其中phoog和xxbbcc都以不同的方式正确。

回到我们的bool b = i == null代码,其中iint类型。

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

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

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

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

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

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

同样在CIL中,拳击是对等效的盒装类型进行的; 它不仅仅是object ,而是boxed type of intboxed type of float等。这对C#是隐藏的,因为它不是很有用(除了你可以在object和unbox上做的那些事情之外,你不能对这些类型做任何事情相当于未装箱的类型),但在CIL中更精确地定义,因为它需要执行“如何在许多不同类型上完成装箱?”。

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. 

我说“至少”,因为可能有一些加载和存储到locals数组的相关方法。

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

但是,这实际上不是将要生成的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中说用于比较整数和null的代码时,我并没有说谎。 它确实如此,但C#编译器可以看到这段代码浪费时间,只需将代码加载到b 。 实际上,如果以后没有使用b ,它可能只会削减整个事物。 (相反,如果稍后使用它,它仍然会在某个时刻加载0 ,而不是像上面的例子那样将其删除)。

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

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

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

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,因为如果你在你的代码中放置了明显无意义的东西,你的想法可能会出现问题,你应该看看它并找出你真正想要做的事情。

在这一点上值得看看如果iint?类型会发生什么int? ; 可以装箱为空。 还有一个优化:

  1. 大多数情况下,拳击和比较被HasValue字段的调用所取代。 这比拳击效率更高。
  2. 有时编译器可以(由于对所讨论的值的了解)优化甚至远离。

(在这个阶段,组装问题无关紧要,因为拳击和比较已被删除)。

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

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

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

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

案例phoog没有检查是可以为值的类型。 在这里,最小的装箱和比较将被替换为对HasValue的调用,而HasValue又将内联读取结构中的内部字段。 可能(如果知道该值永远不为null,或者如果它已知它总是为null)将被删除,以及一段永远不会执行的代码。

摘要

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

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

答案1:是的,只能使用引用类型(包括盒装值类型)与null进行比较,因此始终存在装箱操作。

问题2:我有一个通用代码,它将一个值与null进行比较,因为我只想在它是引用类型或可空值类型的情况下执行某些操作, 并且该值等于null。 在比较的类型是值类型的情况下,我的代码是否会支付装箱操作的性能损失?

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

*请注意, volatile这个定义与.NET中的定义有关,但与之不同,原因还在于multithreading执行的支持程度如何与20世纪60年代的复杂程度相关。