C#与CLR学习笔记(4)—— 值类型与引用类型

目录

值类型与引用类型的关系

值类型与引用类型的使用区别

装箱与拆箱

造成装箱的其他情况

参考文献


值类型与引用类型的关系

在CLR(或者说CTS)中,引用类型包括以下几种:

  • 类类型(Class)
  • 接口类型(Interface)
  • 委托类型(Delegate)

所有的引用类型都直接或间接地继承自 System.Object 类。我们自己定义的类,如果没有明确基类,也是直接继承自 System.Object 的。

值类型包括两种:

  • 结构类型(Structure)
  • 枚举类型(Enumeration)

值类型的继承关系如下图所示:

上图中,实线箭头表示直接的继承关系,虚线并不是严格的继承关系,可以认为是一种归类关系。

值类型存在的意义在于,将小型的类型放到线程栈(而不是堆栈)上,利用值传递、无需垃圾回收等特点,起到提高执行效率的作用。所有的值类型都是一种结构体(Structure)或者枚举(Enumeration)。例如,我们来看内置的值类型 Sytem.Int32 的源码:

所有的值类型都是继承自 System.ValueType 类。由于与引用类型的行为不同,ValueType 类重写了 Object 类的 Equals 方法和 GetHashCode 方法。

值类型与引用类型的使用区别

这里主要以结构体为例讨论值类型的特点。对于枚举(Enumeration)类型,它比较特殊,本文不深入探讨它。

(1)声明与初始化

值类型在声明时,就会在线程栈中分配一个实例,并将字段初始化为0(引用类型的字段被初始化为 null)。但是对于引用类型,声明时仅在线程栈上创建一个 null 引用,不会在托管堆上创建一个实例。

因此,对于结构体,以下代码是合法的:

MyStruct  myStruct;

myStruct.A = 1;

myStruct.B = 2;

值类型 和 引用类型 都可以通过 new 关键字来声明并初始化一个对应的类型。

对于值类型的直接声明例如 int i = 0 ,实际上被编译器当做 System.Int32 i = new System.Int32() 来处理

但是,对于值类型,new 操作符做的工作与引用类型是不同的。对于值类型,会在线程栈上分配一个值类型的实例,并将所有字段都初始化为0。

对于值类型,使用 new声明并实例化 与直接声明,都会将字段初始化为0。两者区别是:编译器认为前者已经实例化;编译器认为后者没有实例化,若使用其字段会抛出异常。

(2)复制

引用类型的变量的复制,不会在托管堆上重新创建一个类型对象,而值类型变量的复制,会真正地在线程栈上覆制一个值类型的实例。因此,对于比较复杂的类型,不适合定义为值类型,因为在 变量复制、作为参数传递 等情景中会造成很大的性能和内存开销。这时可以将值类型作为 ref 参数来传递,这样就与引用类型一样快了,因为传递的是值类型在线程栈中的地址。

(3)使用 值类型 还是 引用类型?

上文中已经提过,值类型并不是一定能提供更好的性能。在设计自己的类型时,满足下面的所有条件时,才能考虑使用值类型:

  • 该类型具有基元类型(primitive type)的行为,即像 int, char 一样,十分简单。没有成员会修改类型的任何实例字段(即 类型是 不可变 immutable 的)。对于值类型,建议将全部字段标记为 readonly。
  • 该类型不需要继承自其他类型。
  • 该类型不派生出任何类型。
  • 类型的实例较小(推荐 < 16 Byte),或者虽然 > 16 Byte,但不会作为方法参数和返回值进行传递。

需要说明的是,值类型虽然不允许继承和派生,但它本身最终是派生于 System.Object 的,因此值类型可以调用 Object 类的所有方法。例如在结构体中可以重写 ToString() 方法。

(4)成员变量

值类型的字段可以都是public的,而对于类,推荐采用私有字段+共有属性的方式。

值类型中的方法不能是虚方法或者抽象方法,因为值类型不允许继承。

(5)构造函数

对于 Structure,编译器总是提供一个无参的默认构造函数,而且不允许替换(即,不允许自己定义一个无参的构造函数)。

此外,对于Structure,在定义其内部字段时,不允许提供默认值,因为默认的无参构造函数是一定会发生作用的,它把值类型的字段初始化为0,把引用类型的字段初始化为 null,提供默认值是没有意义的。

值类型的上述特点是与编译器和 CLR 有关的。与引用类型不同,为了提高性能,在实例化一个值类型时,CLR不会为值类型调用构造函数,除非显式地使用 new 调用自定义的有参的构造函数。

因此,当一个引用类型 R 拥有一个值类型 V 的字段时,如果我们在 R 的构造函数里不为 V 进行初始化,那么编译器并不会(像对待引用类型字段那样)生成代码来调用 V 的默认无参构造函数, CLR 仅仅是开辟内存并把 V 的字段设置为0。因此,为了不让开发者弄混值类型的无参构造函数在什么时候被调用,CLR 干脆直接禁止开发者为值类型定义无参构造函数。

(6)重写 Equals 方法

上文中提过,System.ValueType 类重写了 Equals 和 GetHashCode 方法。但是对于用户自定义的值类型来说,这俩方法的默认实现存在着性能问题。因此,微软官方推荐,在我们定义了一个值类型后,需要重写这两个方法,以及 == 和 != 等运算符。

装箱与拆箱

有时候,我们需要把值类型当做引用类型来使用,获取值类型实例的引用。例如:若一个方法,它需要一个 Object 对象(的引用)作为其参数,值类型要是想使用该方法,就必须将值类型转换成在托管堆中的对象,并获取该对象的引用。这就是装箱。

装箱,就是将值类型拷贝到托管堆中,使其变为一个引用类型。需要说明的是,装箱操作是自动进行的。编译器检测到需要进行装箱时,会自动生成装箱的IL代码。

拆箱是装箱的逆过程,它将托管堆中的对象拷贝到线程栈中。包含两个步骤:

(1)获取对象的各个字段的地址,这一步叫做 “拆箱”;

(2)将字段的值从堆中复制到栈的实例中。

拆箱需要我们显式地指明拆箱后的值类型。若类型不符合会抛出 InvalidCastException 异常。

装箱和拆箱会造成性能损失。由于装箱一般是隐式进行的,难以察觉,因此我们应该在编码中留意潜在的装箱操作,并尽量避免之。

例如,考察如下代码:

public static void Main() 
{
    Int32 v = 5;
    Object o = v;
    v = 123;
    Console.WriteLine(v + ", " + (Int32) o);
}

上面的代码会进行3次装箱,因为 Console.WriteLine() 方法的参数是 String引用类型,括号中的三部分需进行字符串拼接,将v和拆箱后的o装箱,所以最后一行造成了2次装箱。

可将最后一行修改为如下:

Console.WriteLine(v + ", " + o);

便可避免1次拆箱和1次装箱。这个案例的性能和内存的优化不是很明显,但是如果在大量循环中减少一些装箱和拆箱操作,将显著提升运行效率。

上述代码可以进一步优化:

Console.WriteLine(v.ToString() + ", " + o);

可以避免变量 v 的装箱操作。

还有一点需要说明。对比另一个案例:

public static void Main() 
{
    Int32 v = 5; 
    Object o = v;

    v = 123;
    Console.WriteLine(v);

    v = (Int32) o; 
    Console.WriteLine(v); 
}

注意,这段代码只进行了1次装箱。因为 Console.WriteLine(v) 中,v 没有进行装箱,因为 Console.WriteLine() 提供了参数为 Int32 型的重载(注意与上个案例的不同,上个案例的参数是个 String)。虽然这个重载内部可能会对 Int32 进行装箱,但这不是我们需要关心的,我们已经把 自己的 代码中的装箱次数降到了最少。

其实,很多类似的方法都实现了采用值类型作为参数的重载,目的就是为了减少 常用 值类型的装箱次数(若是你自己定义的值类型,则起不到这种效果,因为没有这样的重载啊)。

我们在定义自己的方法时,可将方法定义为泛型方法,这样就可以获取任何值类型而不必装箱。

造成装箱的其他情况

(1)当值类型重写了 ToString, Equals 等虚方法,且在其中调用了基类的实现,那么在调用基类实现时,就会进行装箱,因为基类方法要求 this 指针指向堆上的一个对象。

(2)当值类型调用非虚的继承的方法时(例如 GetType 和 MemberwiseClone ),无论如何都会造成装箱,因为这些方法由 Object 定义,方法要求 this 实参是指向堆对象的一个指针。 

(3)若值类型实现了某个接口,当值类型的实例要转换成这个接口类型时,这个实例也会进行装箱,因为 接口类型的变量 必须指向一个堆上的对象。可参考接口的相关内容,这里不作详细说明。

参考文献

[1] Common Type System

[2] 《C#高级编程》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章