5.2 引用类型和值类型
2012-01-03 11:25 iRead 阅读(275) 评论(0) 编辑 收藏 举报CLR支持两种类型:引用类型和值类型。虽然FCL中的大多数类型都是引用类型,但程序员用的最多的还是值类型。引用类型总是从托管堆上分配的,C#的new操作符会返回对象的内存地址—也就是指向对象数据的内存地址。使用引用类型时,必须注意到一些性能问题。首先考虑以下事实:
- 内存必须从托管堆上分配
- 对上分配的每个对象都有一些额外的成员,这些成员必须初始化
- 对象中的其他字节(为字段而设)总是设为零
- 从托管堆上分配一个对象时,可能强制执行一次垃圾收集操作
如果所有类型都是引用类型,引用程序的性能将显著下降。设想假如每次使用一个Int32值时,都进行一次内存分配,性能会受到多么大的影响!为了提升简单的、常用的类型的性能,CLR提供了名为“值类型”的轻量级类型。值类型的实例一般在线程栈上分配(虽然也可作为字段嵌入一个引用类型的对象中)。在代表值类型实例的一个变量中,并不包含一个指向实例的指针。相反,变量中包含了实例本身的字段。由于变量已经包含了实例的字段,所以为了操作实例中的字段,不再需要提领一个指针。值类型的实例不受垃圾回收器的控制。因此,值类型的使用缓解了托管堆中的压力,并减少了一个应用程序在其生存期内进行的垃圾回收次数。
.NET Framework SDK文档清楚地指出哪些类型是引用类型,哪些类型是值类型。在文档中查看一个类型时,任何称为“类”的类型都是引用类型。例如,System.Exception类、System.IO.FileStream类以及System.Random类都是引用类型。相反,文档将所有值类型都称为结构或枚举。例如,System.Int32结构、System.Boolean结构、System.Decimal结构、System.TimeSpan结构、System.DayOfWeek枚举、System.IO.FileAttributes枚举以及System.Drawing.FontStyle枚举都是值类型。
进一步研究文档,会发现所有结构都是抽象类型System.ValueType的直接派生类。System.ValueType本身又是直接从System.Object派生的。根据定义,所有值类型都必须从System.ValueType派生。所有枚举都从System.Enum抽象类型派生,后者又是从System.ValueType派生的。 CLR和所有编程语言都给予枚举特殊待遇。欲知枚举类型的详情,请参见第15章“枚举类型和位标志”。
虽然不能在定义自己的值类型时选择一个基类型,但如果愿意,一个值类型可以实现一个或多个接口。除此之外,所有值类型都是隐式密封的(sealed),目的是防止将一个值类型用作其他任何引用类型或值类型的基类型。例如,无法将Boolean,Char,Int32,UInt64,Single,Double,Decimal等作为基类型类定义任何新类型。(注:可以用基元类型来表示枚举的底层类型,详情请参见第15章“枚举类型和位标志”)
重要提示:对于许多开发人员(比如非托管C/C++开发人员),最初接触引用类型和值类型时都觉得有些不解。在非托管C/C++中,在声明了一个类型之后,使用该类型的代码会决定是在线程栈上还是在应用程序的堆中分配类型的一个实例。但在托管代码中,又要定义类型的开发人员决定在什么地方分配类型的实例,使用该类型的人对此并无控制权。
以下代码和图5-2演示了引用类型和值类型的区别:
//引用类型(由于使用了’class’)
class SomeRef {public Int32 x;}
//值类型(由于使用了’struct’)
struct SomeVal {public Int32 x;}
static void ValueTypeDemo(){
SomeRef r1 = new SomeRef(); //在堆上分配
SomeVal v1 = new SomeVal(); //在栈上分配
r1.x = 5; //提领指针
v1.x = 5; //在栈上修改
Console.WriteLine(r1.x); //显示”5”
Console.WriteLine(v1.x); //同样显示”5”
//图5-2的左半部分反映了执行以上代码之后的情况
SomeRef r2 = r1; //只复制引用(指针)
SomeVal v2 = v1; //在栈上分配并复制成员
r1.x = 8; //r1.x和r2.x都会更改
v1.x = 9; //v1.x会更改,但v2.x不变
Console.WriteLine(r1.x); //显示”8”
Console.WriteLine(r2.x); //显示”8”
Console.WriteLine(v1.x); //显示”9”
Console.WriteLine(v2.x); //显示”5”
//图5-2的右半部分反映了在执行上述所有代码之后的情况
}
在上述代码中,SomeVal类型是用struct来声明的,而不是用更常用的class。在C#中,用struct声明的类型是值类型,用class声明的类型是引用类型。可以看出,引用类型和值类型的区别是相当大的。在代码中使用类型时,必须注意是引用类型还是值类型,因为这会极大地影响在代码中表达自己意图的方式。
图5-2 图解代码执行时的内存分配情况
上述代码中有这样一行:
SomeVal v1 = new SomeVal(); //在栈上分配
因为这一行代码的写法,似乎要在托管堆上分配一个SomeVal实例。然而,C#编译器知道SomeVal是一个值类型,所以会生成相应的IL代码,在线程栈上分配一个SomeVal实例。C#还会确保值类型中所有字段都初始化为零。
上述代码还可以像这样写:
SomeVal v1;
这一行生成的IL代码也会在线程栈上分配实例,并将字段初始化为零。唯一的区别在于,如果使用new操作符,C#会”认为”实例已经初始化。以下代码更清楚地进行了说明:
//这两行代码能通过编译,因为C#认为v1的字段已初始化为0;
SomeVal v1 = new SomeVal();
Int32 a = v1.x;
//这两行代码不能通过编译,因为C#不认为v1的字段已初始化为0
SomeVal v1;
Int32 a = v1.x; //error CS0170:使用了可能未赋值的字段”x”
(注:产生上述编译错误的原因是C#强制要求方法内的变量必须在使用前确保显式初始化,请参见《.NET Essential》 Chapter 3 Type Basics 之Type Fundamentals)
设计自己的类型时,更仔细地考虑是否应该将一个类型定义成值类型,而不是定义成引用类型。某些时候,值类型都能提供更好的性能。具体地说,除非以下所有条件都满足,否则不应该将一个类型声明为值类型:
- 类型具有基元类型的行为。换言之,这是一个十分简单的类型,其中没有成员会修该类型的任何实例字段。若一个类型没有提供会更改其字段的成员,就说该类型是不可变(immutable)类型。事实上,对于许多值类型来说,我们都建议将它们的全部字段都标记为readonly(详情参见第7章“常量和字段”)。
- 类型不需要从其他任何类型继承。
- 类型也不会派生出其他任何类型。
类型实例的大小也应在考虑之列,因为在默认情况下,实参是以传值方式传递的,这会造成对值类型中的字段进行复制,从而损害性能。同样地,被定义为返回一个值类型的一个方法在返回时,实例中的字段会复制到调用者分配的内存中,从而损害性能。
所以,要将一个类型声明为值类型,除了要满足以上全部要求,还必须满足以下任何一个条件:
- 类型的实例较小(约为16字节或者更小)。
- 类型的实例较大(大于16字节),但不作为方法的实参传递,也不从方法返回。
值类型的主要优势在于它们不作为对象在托管堆上分配。当然,与引用类型相比,值类型也存在自身的一些局限。下面列出了值类型和引用类型的一些不同:
- 值类型对象有两种表示形式:未装箱(unboxed)形式和已装箱(boxed)形式,详情参见一节的讨论。相反,引用类型总是处于已装箱形式。
- 值类型是从System.ValueType派生的。该类型提供了与System.Object定义的相同的方法。然而,System.ValueType重写了Equals方法,能在两个对象的字段完全匹配的前提下返回true。除此之外,System.ValueType重写了GetHashCode方法。生成哈希码的时候,这个重写的方法所用的算法会将对象的实例字段中的值考虑在内。由于这个默认实现存在性能问题,所以在定义自己的值类型时,应该重写Equals和GetHashCode方法,并提供它们的显示实现。本章末尾会讨论Equals和GetHashCode方法。
- 由于不能将一个值类型作为基类型来定义一个新的值类型或者一个新的引用类型,所以不应再值类型中引入任何新的虚方法。所有方法都不能是抽象的,而且所有方法都隐式地为密封方法(也就是不可重写)。
- 引用类型的变量包含的是堆上的一个对象的地址。默认情况下,在创建一个引用类型的变量时,它被初始化为null,表明引用类型的变量当前不指向一个有效的对象。试图使用一个为null的引用类型变量,会抛出一个NullReferenceException异常。相反,值类型的变量总是包含其基础类型的一个值,而且值类型的所有成员都初始化为0。由于值类型的变量不是指针,所以在访问一个值类型时,不可能抛出一个NullReferenceException异常。CLR确实提供了一个特殊的特性,能为值类型添加”可空性”(nullability)标识。这个特性称为”可空”(nullable)类型,将在第19章”可空值类型”详细讨论。
- 将一个值类型的变量赋给另一个值类型变量,会执行一次逐字段的复制。将引用类型的变量赋给另一个引用类型的变量时,只复制内存地址。
- 基于上一条,两个或多个引用类型的变量能引用堆中的同一个对象,所以对一个变量执行的操作可能影响到另一个变量引用的对象。相反,值类型的变量是自成一体的对象,对一个值类型变量执行的操作不可能影响另一个值类型变量。
- 由于未装箱的值类型不在堆上分配,所以一旦定义了该类型的一个实例的方法不再处于活动状态,为它们分配的存储就会被释放。这意味着值类型的实例在其内存被回收时,不会通过Finalize方法接收到一个通知。
注意:事实上,为值类型定义Finalize方法是相当古怪的一个做法,因为只有在已装箱实例上才会调用该方法。有鉴于此,许多编译器(包括C#,C++/CLI和Visual Basic)都不允许为值类型定义Finalize方法。虽然CLR允许值类型定义一个Finalize方法,但当值类型的一个已装箱实例被垃圾回收时,CLR不会调用该方法。
CLR如何控制类型中的字段布局
为了提高性能,CLR能按照它所选择的任何方式来排列类型的字段。例如,CLR可以在内存中重新安排字段的顺序,从而将对象引用分为一组,同时正确排列和填充数据字段。然而,在定义一个类型时,针对类型的各个字段,你可以指示CLR是严格按照自己指定的顺序排列,还是采取CLR自己认为合适的方式重新排列。
为了指示CLR应该怎么做,需要为自己定义的类或结构应用System.Runtime.InteropServices.StructLayoutAttribute这个attribute。可以向这个attribute的构造器传递LayoutKind.Auto,让CLR自动排列字段;也可以传递LayoutKind.Sequential,让CLR保持你的字段布局;也可以传递LayoutKind.Explicit,利用偏移量在内存中显式排列字段。如果不为自己定义的类型显式指定StructLayoutAttribute,编译器会选择它自认为更好的布局。
注意,Microsoft C#编译器默认为引用类型(类)选择LayoutKind.Auto,为值类型(结构)选择LayoutKind.Sequential。显然,C#编译器的开发团队认为结构会经常用于与非托管代码进行互操作。为了顺利进行互操作,字段必须保持程序员定义的顺序。然而,假如创建的值类型不与非托管代码进行互操作,则应覆盖C#编译器的默认设定。下面是一个例子:
using System;
using System.Runtime.InteropServices;
//让CLR自动排列字段以增强这个值类型的性能
[StructLayout(LayoutKind.Auto)]
internal struct SomeValType{
private readonly Byte m_b;
private readonly Int16 m_x;
…
}
StructLayoutAttribute还允许显式指定每个字段的偏移量,这要求向其构造器传递LayoutKind.Explict。然后,要向值类型中的每个字段都应用System.Runtime.InteropServices.FieldOffsetAttribute这个attribute的一个实例,同时向attribute的构造器传递一个Int32值,指出字段第一个字节距离实例起始处的偏移量(以字节为单位)。我们经常用显式布局来模拟非托管C/C++中一个union因为可以让多个字段都起始与内存的同一个偏移位置。下面是一个例子:
using System;
using System.Runtime.InteropServices;
//开发人员显式排列这个值类型的字段
[StructLayout(LayoutKind.Explicit)]
internal struct SomeValType{
[FieldOffset(0)]
private readonly Byte m_b; //m_b和m_x字段在这个类
[FieldOffset(0)]
private readonly Int16 m_x; //型的实例中相互重叠
}
应该注意的是,在一个类型中,一个引用类型和一个值类型相互重叠是不合法的。在一个类型中,虽然允许多个引用类型在同一个起始偏移位置处相互重叠,但这是无法验证的(unverifiable)。如果顶一个类型,让其中的多个值类型相互重叠,则是合法的。但是,为了使这样的类型能够验证(verifiable),所有重叠的字节都必须能通过公共字段访问。