CIL - 变量之间的赋值

2018/06/29

在 C# 中,单个 = 表示变量赋值的意思,对于值类型来说,赋值相当于把值拷贝了一份,对于引用类型来说,赋值相当于把引用拷贝了一份,那在 CIL 中是如何实现这样的赋值功能呢?

public void CopyAndInit()
{
    int a = 12;
    int b = 2333;
    b = a;
    string c = "string";
    string d = "";
    d = c;
}

对应的 CIL

.method public hidebysig instance void
  CopyAndInit() cil managed
{
  .maxstack 1
  .locals init (
    [0] int32 a,
    [1] int32 b,
    [2] string c,
    [3] string d
  )

  IL_0000: nop

  IL_0001: ldc.i4.s     12 // 0x0c
  IL_0003: stloc.0      // a

  IL_0004: ldc.i4       2333 // 0x0000091d
  IL_0009: stloc.1      // b

  IL_000a: ldloc.0      // a
  IL_000b: stloc.1      // b

  IL_000c: ldstr        "string"
  IL_0011: stloc.2      // c

  IL_0012: ldstr        ""
  IL_0017: stloc.3      // d

  IL_0018: ldloc.2      // c
  IL_0019: stloc.3      // d

  IL_001a: ret

} // end of method Program::CopyAndInit

在这个 CopyAndInit 函数中,我们分别对值类型变量 b 与引用类型变量 d 进行了操作,尽管这两种变量类型不同,但是对他们进行赋值的 CIL 却几乎没什么差别。那引用类型与值类型在赋值阶段的差别究竟是哪里决定的呢?在前一篇文章中我提到了,ldstr 是把一个字符串的引用推入栈中,也就是说,引用类型变量初始化的时候开始,我们操作的都是对象的引用。所以,对于 CIL 而言,变量赋值的规则就变得更加简单了:

ldloc.x // 从本地变量列表中把值复制出来并推入栈中
stloc.x // 把栈尾的值存储到本地变量列表指定位置

带有类型转换的赋值

在 C# 中,一些数值类型之间的赋值操作是可以直接进行的,比如,我们可以将一个 int 类型的变量隐式转换后赋值给 long 类型变量,还有一种情况就是把一个 long 类型强制转换成 int 类型。隐式转换一般来说并不会损失精度,但是强制转换却有可能产生溢出。在 CIL 中,隐式转换并不会产生额外的代码,而强制转换由专门的 conv.x 系列操作符完成,并且,CIL 还提供了溢出检查功能,在强制转换发生溢出的时候,会产生异常。

public void ConvertNumbers()
{
    float a = 1.0F;
    double b = 1.0;
    a = 12;
    b = 1.2F;
    b = 12;
    a = (float) b;

    int x = 0;
    long y = 12;
    x = (int) y;
    checked
    {
        x = (int)y;
    }
}

对应的 CIL :

.method public hidebysig instance void 
  ConvertNumbers() cil managed 
{
  .maxstack 1
  .locals init (
    [0] float32 a,
    [1] float64 b,
    [2] int32 x,
    [3] int64 y
  )

  IL_0000: nop          

  IL_0001: ldc.r4       1
  IL_0006: stloc.0      // a

  IL_0007: ldc.r8       1
  IL_0010: stloc.1      // b

  IL_0011: ldc.r4       12
  IL_0016: stloc.0      // a

  IL_0017: ldc.r8       1.20000004768372
  IL_0020: stloc.1      // b

  IL_0021: ldc.r8       12
  IL_002a: stloc.1      // b

  IL_002b: ldloc.0      // a
  IL_002c: conv.r8      
  IL_002d: stloc.1      // b

  IL_002e: ldloc.1      // b
  IL_002f: conv.r4      
  IL_0030: stloc.0      // a

  IL_0031: ldc.i4.0     
  IL_0032: stloc.2      // x

  IL_0033: ldc.i4.s     12 // 0x0c
  IL_0035: conv.i8      
  IL_0036: stloc.3      // y

  IL_0037: ldloc.3      // y
  IL_0038: conv.i4      
  IL_0039: stloc.2      // x

  IL_003a: nop          

  IL_003b: ldloc.3      // y
  IL_003c: conv.ovf.i4  
  IL_003d: stloc.2      // x

  IL_003e: nop          

  IL_003f: ret          

} // end of method Program::ConvertNumbers

首先我们初始化了一个 float 类型的变量 a,虽然是使用 1 这个整形字面量来进行初始赋值,但在 CIL 中却是使用 ldc.r4 来推入栈中的。前面提到了,ldxx 一般表示把一个值推入栈尾,r4 表示 float32 类型,对应的还有 r8,表示 float64 类型。所以,CIL 在这一步没有使用任何转换操作,直接把 1 作为 float32 推入栈中了。接着,stloc.0 又把栈尾的值弹出并存入本地变量列表的第一个位置。接下来初始化 double b 也是差不多的过程,就不再赘述。

a 赋值 12 的时候同样也没有产生转换,因为产生的 CIL 中使用的是 ldc.r4 这个操作符,所以,12 是被当作一个 float32 类型处理的。

接下来出现了一个比较有意思的事,当我们给 double b 赋值 1.2 的时候,实际上产生的 CIL 是把 1.20000004768372 赋值给 b 。原因其实很简单,1.2 这个值没法使用 64 位精确的表示出来,所以,编译器就用了一个近似值来模拟,这也是在 C# 中尽量不要直接比较浮点数的原因。

从上面的几行代码中,我们可以发现,虽然字面值类型不同,但是却并没有强制类型转换的发生,这是由于编译器识别出来的我们提供的字面值可以直接作为目标类型使用。

b = a 这句代码在 C# 里面被称为隐式类型转换,即自动的把 floant32 转换成 float64 来使用,但在生成的 CIL 中,却出现前面提到的类型转换语句:

IL_002b: ldloc.0      // 把本地变量列表中第 1 个位置(a)的值复制出来并推入栈尾
IL_002c: conv.r8      // 将栈尾的值转换为 float64
IL_002d: stloc.1      // 将栈尾的值弹出并存入本地变量列表中的第 2 个位置(b)

这是因为在 CIL 中,浮点数的具体实现是依赖硬件的,实际在内存中是如何存储浮点数会因为硬件的不同而不同,所以单精度浮点数与双精度浮点数之间的转换需要使用 conv.r4conv.r8 来进行。

所以接下来 a = (float)b 生成的语句我们就可以大致推测出来:

IL_002b: ldloc.1      // 把本地变量列表中第 2 个位置(b)的值复制出来并推入栈尾
IL_002c: conv.r4      // 将栈尾的值转换为 float32
IL_002d: stloc.0      // 将栈尾的值弹出并存入本地变量列表中的第 1 个位置(a)

但是有一点需要注意,conv.r4conv.r8 都会忽略数据溢出的问题,这跟上面我们写的 C# 代码的表现是一致的。下面就会提到如何检测转换过程中是否发生了溢出。

int x = 0;
long y = 12;
x = (int) y;
checked
{
    x = (int)y;
}

当我们把一个 int 类型转换成 long 类型的时候通常不会对值产生影响,但是反过来,就有可能造成数据损失。在 C# 中,如果我们希望在转换过程中一旦发生了溢出就抛出一个异常可以使用 checked代码块。在上面的代码例子中,同样是把 y 强制转换成 int 类型,其产生的 CIL 是不同的。

IL_0037: ldloc.3      // y
IL_0038: conv.i4      
IL_0039: stloc.2      // x
IL_003a: nop          
IL_003b: ldloc.3      // y
IL_003c: conv.ovf.i4  
IL_003d: stloc.2      // x

conv.ovf.x 系列操作符的作用跟 conv.x 相同,唯一的不同是如果转换过程中产生了数据溢出,就会抛出一个 System.OverflowException

总结

  • 将字面量赋值给变量时,编译器会根据变量类型来对字面量进行一定的处理,避免一些类型转换发生。
  • conv.x 用来对数值类型进行强制转换,这种转换可能会造成数据丢失。
  • conv.ovf.x 除了能够转换数值类型之外,还能检查转换过程中是否发生溢出。