这里的两个答案都有其价值,我会给 phoog 打分,因为他们回答了大多数人在询问这个问题时所关心的实际问题(它的变体之前已经出现过)。但也有不完整的地方。
有四种方法可以查看相关代码,所有这四种方法都很重要,而答案只查看了两种(尽管 phoog 对彼此有很多暗示)。
我将从到目前为止被忽略的问题部分开始:
还有以下代码:
int i = 0;`
bool b = i == null; // Always false`
是否正在进行隐式对象转换?这样:
int i = 0;
bool b = (object)i == null;
嗯,是的,也不是。这取决于我们看的层次,实际上我们要在不同的时间从不同的层次看,所以说这不是学究。
C#有四样东西:
- 它本身就是一种计算机语言。我们可以对其进行推理并对其进行推理,并检查某事物是否遵循其规则,以及根据这些规则它意味着什么。
- 这是一种生成 CIL 的方式,CIL 本身就是另一种语言,适用于上述内容。
- 通过 CIL,它是一种在运行时或通过 Ngen 生成机器代码的方式,Ngen 本身也是一种语言。
- 这是一种告诉计算机做某事的方式,这通常是练习的重点。
到目前为止,答案只关注第 2 点和第 3 点,但完整的情况是看所有四个。
而最重要的点实际上是第 1 点和第 4 点。
第 1 点很重要,因为 C# 毕竟是我们正在研究的语言,也是同事最有可能看到的语言。由于编程部分是在指示计算机做某事,部分是在表达自己的意图(中高级编程语言首先是为人服务,其次是计算机),因此实际的源代码很重要。
第 4 点很重要,因为这毕竟是我们的最终目标。这与查看机器代码的汇编不同(正如 phoog 的回答所做的那样),因为机器代码并不是完成哪些更改和优化的最终答案:
- CPU 会自行进行优化。这在出现分支时尤其重要。
- 当纯粹作为一种理论语言考虑时,两个程序集在处理 CPU 缓存方面可能会有所不同。
- 当纯粹作为一种理论语言考虑时,两个程序集可能是等效的,因为其中一个执行未对齐的读取,这会导致性能问题、不正确的结果、异常或死屏。
- 当纯粹作为一种理论语言考虑时,两段程序集在性能上是等效的,因为其中一个使用 CPU 恰好比另一个逻辑等效指令执行得更快的指令。
- 等等……
现在,说了这么多,在我们现在看到的情况下,机器代码大约是我们需要寻找的关于机器行为的推理。但总的来说,机器代码并不是每次的最终答案。尽管如此,phoog 的回答并不是暗示而不是说明这里的影响。我之所以提到它,是因为我的目标是写关于 phoog 和 xxbbcc 以不同方式正确的不同概念级别。
回到我们的代码bool b = i == null,其中i 的类型是int。
在 C# 中,null 被定义为一个文字值,它是所有引用类型和可为空值类型的默认值。它可以与引用相等的任何值进行比较 - 也就是说,可以使用null 作为 X 的值来询问“X 和 Y 是否是同一个实例”的问题,如果 Y 不是实例,则答案为真,并且否则为假。
要与值类型进行比较,我们必须将值类型装箱,就像我们必须将值类型视为引用类型的任何情况一样。
如果值类型是可为空的值类型,并且为空(HasValue 返回 false),则装箱会产生空引用。在所有其他情况下,装箱值类型会创建对堆上新对象的引用,该类型为 object,它引用相同的值并且可以取消装箱返回。
因此,在 C# 概念级别的答案是“是的,i 被隐式装箱以创建一个新对象,然后将其与 null 进行比较 [因此将始终返回 false]”。
在下一个级别,我们有 CIL。
在 CIL 中,null 是一个自然字长(在 32 位进程中为 32 位,在 64 位进程中为 64 位)位模式全零的值(因此 brfalse, @ 987654332@ 和 brnull 都只是同一字节码的别名),这是托管指针、指针、自然整数和任何其他提供地址的方法的有效值。
同样在 CIL 中,装箱是对等效的装箱类型进行的;不仅仅是object,还有boxed type of int、boxed 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.
我说“至少”,因为可能会为相关方法从本地数组加载和存储一些内容。
因此,在 CIL 级别,答案也是“是的,i 被隐式装箱以创建一个新对象,然后将其与 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# 编译器可以看到这段代码是在浪费时间,只需将其替换为将 false 加载到 b 的代码即可。事实上,如果以后不使用b,它可能会删掉整个内容。 (相反,如果稍后使用i,它仍然会在某个时候将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,因为如果您在代码中添加了明显毫无意义的杂乱无章的内容,您的想法可能有问题,您应该查看它并弄清楚您真正想要做什么。
此时也值得看看如果i 是int? 类型会发生什么;可以装箱为空。仍然进行了优化:
- 大多数情况下,装箱和比较被调用
HasValue 字段所取代。这比拳击更有效率。
- 有时编译器甚至可以优化(由于知道相关值)。
(在这个阶段组装的事情已经无关紧要,因为拳击和比较已经被删除了)。
现在,如果我们有一个泛型方法(或泛型类的方法)同时接受值和引用类型参数的情况,C# 编译器无法完成这种优化,因为泛型方法没有实例化到它们的编译时特定的特殊形式(与其他类似的 C++ 模板不同),但在 jitting 时。
因此,生成的 IL 将始终包含装箱操作(除非有其他原因导致即使在引用类型的情况下也可以将其优化掉)。
不过,抖动与 C# 编译器在我们的第一个示例中所做的相同,即装箱一个不可为空的值类型永远不会产生空值这一事实。它在优化方面也比 C# 编译器更积极。
这是我们得到 phoog 在他们的回答中描述的行为的地方:在为值类型类型参数生成的代码中,装箱操作被完全删除(对于引用类型参数,装箱操作本质上是禁止的)操作,也被删除)。检查被删除,因为答案是已知的,并且实际上只有在检查返回 true 时才会执行的整个代码部分也被删除。
phoog 未检查的案例是可空值类型。在这里,装箱和比较至少将被替换为对HasValue 的调用,而这又将被内联到结构中内部字段的读取。可能(如果知道该值永远不会为 null,或者如果知道它始终为 null)将被删除,以及无论如何都不会执行的一整段代码。
总结
您的问题后面还有两个更具体的问题,您可能对其中一个或两个感兴趣。
问题 1:我对 C# 作为一种语言的功能感兴趣,我想知道就 C# 而言,是否将不可为空的值类型与该值类型的空框进行比较。
答案 1:是的,与 null 的比较只能使用引用类型(包括装箱值类型)进行,因此始终存在装箱操作。
问题 2:我有将值与 null 进行比较的通用代码,因为我只想在它是引用类型或可空值类型时执行某些操作,并且如果值等于 null。在比较的类型是值类型的情况下,我的代码会支付装箱操作的性能损失吗?
答案 2:不。在 C# 编译器无法优化其生成的 IL 中的代码的情况下,抖动仍然可以。对于不可为空的值类型,整个装箱操作、比较和代码路径仅在与 null 的比较返回 true 时才采用,都将从生成的机器代码中删除,从而从计算机所做的工作中删除。此外,如果它是一个可以为空的值类型,则装箱和比较将被替换为检查值中指示HasValue 是否为真的字段。
*请注意,volatile 的定义与 .NET 中的定义相关,但并不相同,原因也与对多线程执行的更大支持如何使事情变得复杂有关。 1960 年代。