为什么MSFT C#编译固定的"数组到指针衰减"和"第一个元素的地址"不同?

Mic*_*zyk 25 .net c# compiler-construction il

.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);
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

如果你想跟随它,用"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
Run Code Online (Sandbox Code Playgroud)

这是方法生成的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
Run Code Online (Sandbox Code Playgroud)

这是做什么的Good:

  1. 获取buffer [0]的地址.
  2. 取消引用该地址.
  3. 使用该解除引用的值调用WriteLine.

这是'坏'的作用:

  1. 如果buffer为null,则为GOTO 3.
  2. 如果buffer.Length!= 0,则为GOTO 5.
  3. 将值0存储在本地插槽0中,
  4. GOTO 6.
  5. 获取buffer [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都会解释它的语义.你不能得到buffer来自的大小p,你不能打电话p.ToString(),所以为什么把它看作是一个对象呢?如果buffer为null,则显然存在编码错误,并且我相信如果Bad固定指针声明符处而不是在方法内部抛出异常会更有帮助.

所以它看起来比Good处理null更好Bad.空缓冲区怎么样?

buffer长度为0,Good将引发IndexOutOfRangeException固定指针说明符.这似乎是一种处理越界数组访问的完全合理的方法.毕竟,代码的&buffer[0]处理方式&(buffer[0])应该与显然相同IndexOutOfRangeException.

Bad以不同方式处理这种情况,并且再次不合需要 就像是的情况 buffer一样null,何时buffer.Length == 0,Bad直到p被取消引用时才抛出异常,并且在那时它抛出NullReferenceException,而不是IndexOutOfRangeException! 如果p永远不会被解除引用,那么代码甚至不会抛出异常.同样,这里的想法似乎是给出p"指向托管数组的指针"的语义含义.再说一次,我认为任何编写此代码的人都不会这么想p.如果投掷的代码将是非常有益的更IndexOutOfRangeException固定指针说明符,从而通知传入的阵列是空的,而不是显影剂null.

它看起来fixed(byte * p = buffer)应该编译成相同的代码fixed (byte * p = &buffer[0]). 另请注意,尽管buffer可能是任意表达式,但它的type(byte[])在编译时是已知的,因此代码Good可用于任意表达式.

编辑

实际上,请注意Bad实际执行错误检查buffer[0] 两次.它在方法的开头明确地执行,然后在ldelema指令处隐式再次执行.


所以我们看到GoodBad语义不同. Bad当我们的代码中存在错误时,它会更长,可能更慢,当然也不会给我们带来理想的异常,甚至在某些情况下甚至会失败.

对于那些好奇的人,规范的第18.6节(C#4.0)说,在这两种失败案例中,行为都是"实现定义的":

fixed-pointer-initializer可以是以下之一:

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

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

......其他情况......

最后一点,MSDN文档表明这两者是"等效的":

//以下两个作业是等价的......

修复(double*p = arr){/ ... /}

修复(double*p =&arr [0]){/ ... /}

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

似乎还有额外的努力来编写生成的代码路径Bad.编译后的代码Good适用于所有失败案例,与Bad非失败案例中的代码相同.为什么要实现新的代码路径而不是仅使用为其生成的更简单的代码Good

为什么这样实现?

Mic*_*eld 9

您可能会注意到,您包含的IL代码几乎逐行实现了规范.这包括明确实现规范中列出的两个异常情况,如果它们是相关的,并且包括它们不相关的代码.因此,编译器行为方式的最简单原因是"因为规范所说的".

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

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

没有相应团队的人员出现,我们真的不希望完全回答这些问题中的任何一个.但是,我们可以通过尝试遵循他们的推理来尝试回答第二个问题.

回想一下,在向固定指针初始化器提供数组的情况下,规范说明了这一点

如果数组表达式为null或者数组具有零元素,则fixed语句的行为是实现定义的.

由于实现可以自由选择在这种情况下做任何事情,我们可以假设,无论编译团队做什么最简单,最便宜的合理行为.

在这种情况下,编译器团队选择做的是" 在代码执行错误时抛出异常 ".考虑一下如果代码不在固定指针初始化器中并且考虑其他正在发生的事情,代码会做什么.在"好"示例中,您尝试获取不存在的对象的地址:null/empty数组中的第一个元素.这不是你实际可以做的事情,所以它会产生异常.在"坏"示例中,您只是将参数的地址分配给指针变量; byte * p = null是完全合法的陈述.只有当你尝试WriteLine(*p)发生错误时才会这样.由于固定指针初始化程序允许在此异常情况下执行任何操作,因此最简单的操作就是允许赋值发生,因为它没有意义.

显然,这两个陈述并不完全相同.我们可以通过标准对待它们的方式来区分它们:

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

两个产生相同的结果,只要数组中有一个元素,这是MSDN文档试图通过的点.提出有关为什么显式未定义或实现定义的行为以其行为方式起作用的问题并不能真正帮助您解决任何特定问题,因为您将来不能依赖它.(话虽如此,我当然很想知道思考过程是什么,因为你显然无法在内存中"修复"空值......)