25

.NET c# 编译器 (.NET 4.0)fixed以一种相当特殊的方式编译语句。

这是一个简短但完整的程序,向您展示我在说什么。

using System;

public static class FixedExample {

    public static void Main() {
        byte [] nonempty = new byte[1] {42};
        byte [] empty = new byte[0];
        
        Good(nonempty);
        Bad(nonempty);

        try {
            Good(empty);
        } catch (Exception e){
            Console.WriteLine(e.ToString());
            /* continue with next example */
        }
        Console.WriteLine();
        try {
            Bad(empty);
        } catch (Exception e){
            Console.WriteLine(e.ToString());
            /* continue with next example */
        }
     }

    public static void Good(byte[] buffer) {
        unsafe {
            fixed (byte * p = &buffer[0]) {
                Console.WriteLine(*p);
            }
        }
    }

    public static void Bad(byte[] buffer) {
        unsafe {
            fixed (byte * p = buffer) {
                Console.WriteLine(*p);
            }
        }
    }
}

如果您想继续,请使用“csc.exe FixedExample.cs /unsafe /o+”编译它。

这是该方法生成的 IL Good

好的()

  .maxstack  2
  .locals init (uint8& pinned V_0)
  IL_0000:  ldarg.0
  IL_0001:  ldc.i4.0
  IL_0002:  ldelema    [mscorlib]System.Byte
  IL_0007:  stloc.0
  IL_0008:  ldloc.0
  IL_0009:  conv.i
  IL_000a:  ldind.u1
  IL_000b:  call       void [mscorlib]System.Console::WriteLine(int32)
  IL_0010:  ldc.i4.0
  IL_0011:  conv.u
  IL_0012:  stloc.0
  IL_0013:  ret

这是该方法生成的 IL Bad

坏的()

  .locals init (uint8& pinned V_0, uint8[] V_1)
  IL_0000:  ldarg.0
  IL_0001:  dup
  IL_0002:  stloc.1
  IL_0003:  brfalse.s  IL_000a
  IL_0005:  ldloc.1
  IL_0006:  ldlen
  IL_0007:  conv.i4
  IL_0008:  brtrue.s   IL_000f
  IL_000a:  ldc.i4.0
  IL_000b:  conv.u
  IL_000c:  stloc.0
  IL_000d:  br.s       IL_0017
  IL_000f:  ldloc.1
  IL_0010:  ldc.i4.0
  IL_0011:  ldelema    [mscorlib]System.Byte
  IL_0016:  stloc.0
  IL_0017:  ldloc.0
  IL_0018:  conv.i
  IL_0019:  ldind.u1
  IL_001a:  call       void [mscorlib]System.Console::WriteLine(int32)
  IL_001f:  ldc.i4.0
  IL_0020:  conv.u
  IL_0021:  stloc.0
  IL_0022:  ret

这是做什么的Good

  1. 获取缓冲区[0]的地址。
  2. 取消引用该地址。
  3. 使用该取消引用的值调用 WriteLine。

这是“坏”的作用:

  1. 如果缓冲区为空,则转到 3。
  2. 如果 buffer.Length != 0,转到 5。
  3. 将值 0 存储在本地插槽 0 中,
  4. 转到 6。
  5. 获取缓冲区[0]的地址。
  6. 尊重该地址(在本地插槽 0 中,现在可能是 0 或缓冲区)。
  7. 使用该取消引用的值调用 WriteLine。

buffer既非空又非空时,这两个函数做同样的事情。请注意,在进行函数调用Bad之前,它只是跳过了几圈。WriteLine

buffer为 null 时,在固定指针声明符( )中Good抛出 a 。大概这是修复托管数组所需的行为,因为通常固定语句内的任何操作都将取决于被修复对象的有效性。否则为什么该代码会在块内?当传递一个空引用时,它会在块的开头立即失败,提供相关且信息丰富的堆栈跟踪。开发人员会看到这一点并意识到他应该在使用它之前进行验证,或者他的逻辑可能被错误地分配给. 无论哪种方式,清楚地进入一个带有NullReferenceExceptionbyte * p = &buffer[0]fixedGoodfixedbuffernullbufferfixednull托管数组是不可取的。

Bad以不同的方式处理这种情况,甚至是不受欢迎的。您可以看到在取消引用Bad之前实际上不会引发异常。p它以迂回的方式将 null 分配给持有的同一个本地槽p,然后在fixed块语句取消引用时抛出异常p

以这种方式处理null的优点是保持 C# 中的对象模型一致。也就是说,在fixed块内部,在p语义上仍然被视为一种“指向托管数组的指针”,当它为 null 时,不会导致问题,直到(或除非)它被取消引用。一致性很好,但问题是p 不是指向托管数组的指针。它是指向 的第一个元素的指针buffer,任何编写此代码 ( Bad) 的人都会将其语义解释为这样。你不能得到bufferfrom的大小p,也不能 call p.ToString(),那么为什么要把它当作一个对象来对待呢?在为空的情况下buffer,显然存在编码错误,我相信如果Bad将在固定指针声明符处抛出异常,而不是在方法内部。

所以看起来Good处理起来null比做的好Bad。空缓冲区呢?

buffer长度为 0 时,抛出Good固定指针声明符。这似乎是一种处理越界数组访问的完全合理的方法。毕竟,代码应该被以同样的方式对待,显然应该抛出。IndexOutOfRangeException&buffer[0]&(buffer[0])IndexOutOfRangeException

Bad以不同的方式处理这种情况,并且再次不受欢迎。就像 if bufferwere null, when buffer.Length == 0,在被取消引用Bad之前不会抛出异常的情况一样p,那时它会抛出NullReferenceException,而不是 IndexOutOfRangeException! 如果p从不取消引用,那么代码甚至不会抛出异常。同样,这里的想法似乎是赋予p“指向托管数组的指针”的语义含义。再一次,我认为编写此代码的任何人都不会这么想pIndexOutOfRangeException如果将代码放入固定指针声明器中,代码会更有帮助,从而通知开发人员传入的数组是空的,而不是null.

看起来fixed(byte * p = buffer)应该已经编译为与 was 相同的代码fixed (byte * p = &buffer[0])另请注意,即使buffer可能是任意表达式,它的类型 ( byte[]) 在编译时是已知的,因此其中的代码Good适用于任意表达式。

编辑

事实上,请注意, 的实现Bad实际上对buffer[0] 两次进行了错误检查。它在方法开始时显式执行,然后在ldelema指令处再次隐式执行。


所以我们看到GoodandBad在语义上是不同的。 Bad更长,可能更慢,并且当我们的代码中有错误时,当然不会给我们想要的异常,甚至在某些情况下失败的时间比它应该的要晚得多。

对于那些好奇的人,规范的第 18.6 节(C# 4.0)说在这两种失败情况下行为是“实现定义的”:

固定指针初始化器可以是以下之一:

• 代币“&” 后跟对非托管类型 T 的可移动变量(第 18.3 节)的变量引用(第 5.3.3 节),前提是类型 T* 可隐式转换为固定语句中给出的指针类型。在这种情况下,初始化程序计算给定变量的地址,并保证该变量在固定语句的持续时间内保持在固定地址。

• 具有非托管类型 T 元素的数组类型表达式,前提是类型 T* 可隐式转换为固定语句中给定的指针类型。在这种情况下,初始化程序计算数组中第一个元素的地址,并保证整个数组在固定语句的持续时间内保持在固定地址。如果数组表达式为空或数组有零个元素,则固定语句的行为是实现定义的。

...其他情况...

最后一点,MSDN 文档表明两者是“等价的”:

// 下面的两个赋值是等价的...

固定(双* p = arr){ / ... / }

固定 (double* p = &arr[0]) { / ... / }

如果两者应该是“等价的”,那么为什么对前一个语句使用不同的错误处理语义呢?

似乎还花费了额外的精力来编写Bad. 编译后的代码在所有失败情况下都可以正常工作,并且与非失败情况下Good的代码相同。Bad为什么要实现新的代码路径,而不是只使用为 生成的更简单的代码Good

为什么以这种方式实施?

4

2 回答 2

9

您可能会注意到,您包含的 IL 代码几乎逐行实现了规范。这包括在相关的情况下显式实现规范中列出的两个异常情况,在不相关的情况下包括代码。因此,编译器行为方式的最简单原因是“因为规范是这样说的”。

当然,这只会导致我们可能会问的另外两个问题:

  • 为什么 C# 语言组选择这样编写规范?
  • 为什么编译器团队选择了特定的实现定义的行为?

如果没有合适团队的人出现,我们真的不能完全回答这些问题中的任何一个。但是,我们可以通过尝试遵循他们的推理来尝试回答第二个问题。

回想一下,规范说,在将数组提供给fixed-pointer-initializer的情况下,

如果数组表达式为空或数组有零个元素,则固定语句的行为是实现定义的。

由于在这种情况下实现可以自由选择做它想做的任何事情,我们可以假设编译器团队做任何合理的行为是最容易和最便宜的。

在这种情况下,编译器团队选择做的是“在代码出错的地方抛出异常”。考虑如果代码不在固定指针初始化器中,它会做什么,并考虑还会发生什么。在您的“好”示例中,您试图获取一个不存在的对象的地址:空/空数组中的第一个元素。这不是您实际上可以做的事情,因此会产生异常。在您的“坏”示例中,您只是将参数的地址分配给指针变量;byte * p = null是一个完全合法的声明。只有当您尝试时才会WriteLine(*p)发生错误。由于固定指针初始化器在这种异常情况下允许为所欲为,最简单的做法就是允许分配发生,尽管它毫无意义。

显然,这两种说法并不完全等价。我们可以通过标准对它们的不同对待来说明这一点:

  • &arr[0]是:“令牌“&” 后跟一个变量引用”,因此编译器计算 arr[0] 的地址
  • arr是:“数组类型的表达式”,因此编译器计算数组第一个元素的地址,但需要注意的是,空数组或 0 长度数组会产生您所看到的实现定义的行为。

只要数组中有一个元素,这两者就会产生等效的结果,这是 MSDN 文档试图解决的问题。询问为什么明确未定义或实现定义的行为会以它的方式行事的问题并不能真正帮助您解决任何特定问题,因为您不能依赖它在未来成为真的。(话虽如此,我当然很想知道思考过程是什么,因为您显然无法“修复”内存中的空值......)

于 2012-08-03T22:14:16.370 回答
1

所以我们看到Good和Bad在语义上是不同的。为什么?

因为好的是案例1,坏的是案例2。

Good 没有分配“数组类型的表达式”。它分配“令牌“&” 后跟一个变量引用”,所以它是案例 1。Bad 分配“数组类型的表达式”使其成为案例 2。如果这是真的,则 MSDN 文档是错误的。

无论如何,这解释了为什么 C# 编译器会创建两种不同的(在第二种情况下是专门的)代码模式。

为什么案例 1 会生成如此简单的代码?我在这里推测:获取数组元素的地址可能与array[index]ref-expression 中使用的方式相同。在 CLR 级别,ref参数和表达式只是托管指针。表达式也是如此&array[index]:它被编译为不是固定而是“内部”的托管指针(我认为这个术语来自托管 C++)。GC 会自动修复它。它的行为就像一个普通的对象引用。

因此,案例 1 获得了通常的托管指针处理,而案例 2 获得了特殊的、实现定义的(不是未定义的)行为。

这并不能回答你所有的问题,但至少它为你的观察提供了一些理由。我有点希望 Eric Lippert 作为内部人士添加他的答案。

于 2012-08-03T22:10:55.513 回答