在上一次的讨论中,我介绍了许多微软.NET平台公共语言运行时CLR (common language runtime) 中与类型有关的基本概念。其中重点讨论了如何从System.Object类型中派生出所有别的类型,以及程序员能够使用的多种强制类型转换机制(如C#操作符)。最后,我提到了编译器如何使用名字空间以及公共语言运行时CLR是如何忽略名字空间的。 在本文中,我们将继续上次类型基础的讨论。首先从介绍简单类型开始,然后迅速进入关于引用类型和数值类型的讨论。对所有的开发人员来说,熟练掌握引用类型和数值类型的应用差别尤其重要。在编写代码的过程中,如果对这两种类型使用不当会导致程序Bug并引起性能问题。
简单类型 某些常用的数据类型,许多编译器通过简单的语法就可以对它们进行处理。例如,在C#语言中,你可以使用下列语法来分配一个整型变量:
int a = new int(5);
但是我敢肯定,你会觉得用这样的语法来声明和初始化一个整型变量很笨拙。好在许多编译器(包括C#编译器)允许你使用下面的语法来代替:
int a = 5;
这就使代码的可读性更强。不论使用那一种语法,产生的中间语言时一样的。 凡编译器直接支持的数据类型称为简单数据类型。这些简单数据类型直接映射到基类库中存在的类型。例如C#中int类型直接映射到System.Int32。所以可以将下列两行代码与前面提到的两行代码是一样的:
System.Int32 a = new System.Int32(5); System.Int32 a = 5;
图一是C#中简单数据类型与基类库中有关类型的对应表(其它语言也会提供类似的简单数据类型)
引用类型和数数值类型 当从受管堆(managed heap)中分配对象时,new操作符返回对象的内存地址。通常将这个地址存储在一个变量当中。这种方式就是引用类型的变量,因为变量不包含实际对象的位,而是引用对象的位。 在处理引用类型时会有一些性能问题要考虑。首先,内存必须要从受管堆中分配,这样能强制垃圾回收。其次,引用类型总是通过指针来存取。所以每次引用堆中对象的成员时,为了实现期望的处理,必须要产生和执行收回指针的代码。这反而影响程序的大小和程序执行的速度。 除了引用类型外,实际的对象系统中还有轻量级的数值类型。数值类型对象不能在可回收垃圾的堆中分配,并且表示对象的变量不包含对象的指针,而是变量包含对象本身。因为变量包含着对象,处理对象也就不必考虑指针回收的问题,从而改进了性能。 图二中的代码说明了引用类型和数值类型差别。Rectangle类型的声明使用了结构,而没有使用更普通的类。在C#中,使用结构声明的类型是个数值类型,而使用类声明的是引用类型。其它语言可能用不同的语法来描述数数值类型和引用类型,例如C++中使用_value修饰符。 回顾前面讨论简单类型时提到过的代码行:
System.Int32 a = new System.Int32(5);
编译这个语句时,编译器发觉System.Int32是数值类型并优化产生的中间语言(IL)代码,以便使这个“对象”不从堆中分配;而将这个对象放到线程堆栈的局部变量a中。、 可能的情况下,应该使用数值类型而不要使用引用类型,这样做可以使应用程序的性能更好。尤其是在使用以下数据类型时,你应该将变量声明为数值类型:
* 简单数据类型。 * 不需要从其它类型继承的数据类型。 * 没有任何从它派生的数据类型。 * 类型对象不会作为方法参数经常性传递,这是因为它会导致频繁的内存拷贝操作,从而损害性能。这一点在下面有关框入和框出的讨论中将作更详细的解释。
数值类型的主要优点是他们不在受管堆中进行分配。但与引用类型比较,使用数值类型也有几个局限。以下是对数值类型和引用类型的一个比较。 数值类型对象有两种表示法:框出的形式和框入的形式。引用类型对象总是表示为框入形式。 数值类型从System.ValueType类型中隐含派生。这个类型提供的方法与System.ValueType定义的方法相同。但是,System.ValueType重载Equals方法,以便在两个对象实例字段匹配时返回true。此外,System.ValueType重载GetHashCode方法,以便在对象实例字段中使用有这些值参与的算法产生hash 代码值。当定义自己的数值类型时,强烈推荐你重载并提供外部的Equals 和GetHashCode方法实现。 因为使用数值类型作为基类时不能声明新的数值类型或新的引用类型,数值类型不应有虚函数,不能被抽象,并被隐含式封装(封装类型不能被用作新类型的基类)。 引用类型变量包含堆内存中对象的地址。在缺省情况下,引用类型变量被创建时被初始化为空(null),也就是说这个引用类型变量当前不指向有效对象。试图使用值为空的引用类型变量会导致NullReferenceException 异常。与之相对,对于数值类型变量来说,它总是包含潜在类型的值,在缺省情况下,这个数值类型所有成员被初始化零(zero)。当访问数值类型时就不可能产生NullReferenceException 异常。 当你将一个数值类型变量的内容赋值给另一个数值类型变量时,变量值被拷贝。当你将一个引用类型变量的内容赋值给另一个引用类型变量时,只是变量的内存地址被拷贝。 从以上的讨论中可以得出这样的结论,堆中的单个对象可以涉及两个以上的引用类型变量。这样就允许用作用在一个变量上的操作来影响被另一个变量引用的对象。另一方面,每一个数值类型变量都有其自己的对象数据拷贝,而且对其中一个数值类型变量的操作不会影响其它的数值类型变量。 运行时必须初始化数值类型以及不能调用其缺省构造函数的情形很少见,例如下面的情况下会发生这种事情,当非受管线程第一次执行受管代码时必须分配和初始化线程本地数值类型。在这种情况下,运行时不能调用类型的构造函数,但仍然保证所有成员被初始化为零或者为空。为此,推荐你不要对数值类型定义无参数的构造函数。实际上,C#编译器(以及其它编译器)会认为出错并不再编译代码。这个问题很少见,而且也不会发生在引用类型上。对于数值类型和引用类型的参数化构造函数没有这些限制。 因为框出的数值类型不在堆中分配,只要定义这个类型实例的方法不再是活动的,就可以很潇洒的为它们分配存储区域。也就是说框出的数值类型对象的内存被收回的时候是接收不到通知的。但是,框入的数值类型被当作垃圾收回时会有其Finalize方法调用。你绝不能用Finalize方法实现一个数值类型。象无参数构造函数一样,C# 认为这是一个错误而不再编译源代码。
框入与框出 在很多种情况下,把数值类型当作引用类型来使用便于问题的处理。假设你想创建一个ArrayList对象(它是在System.Collections名字空间中定义的类型)来存放一些点(Points)。参见图三。 代码中每次循环Point数值类型都被初始化,然后点被存储在ArrayList中。但是想一想,在ArrayList中实际存储的是什么呢?是Point结构还是Point结构的地址,仰或是别的什么东西?为了得到答案,你必须察看ArrayList的Add方法看看它的参数被定义成什么类型。在本段代码中,你可以看到Add方法是用以下方式被原型化的:
public virtual void Add(Object value)
显然,Add方法的参数是一个对象。而对象总是被看成一个引用类型。但实际上我在代码中传递的是一个p,它是一个Point数值类型。这段代码要运行,必须将Point数值类型转换为真正的堆受管对象,并且必须要能得到对这个对象的引用。 将数值类型转换为引用类型称为框入。其内部转换机制可描述为: 1、从堆中分配内存,内存大小等于数值类型所占内存加上附加的成为对象的内存开销,附加开销包括虚表指针和同步块指针所需的内存。 2、数值类型的位被拷贝到新分配的堆内存。 3、对象的地址被返回。此地址既是当前的引用类型。 某些语言的编译器,如C#,自动产生框入数值类型需要的的中间语言代码(IL),但是理解框入转换的内部机制以便了解代码量及性能问题是很重要的。 当Add方法被调用时,在堆中为Point对象分配内存。驻留在当前Point数值类型(p)中的成员被拷贝到新分配的Point对象。Point对象地址(引用类型)被返回,然后被传递到Add方法。这个Point对象将被保留在堆中直到它被当作垃圾收回。Point数值类型变量(p)可以被冲用或者被释放,因为A rrayList绝不会知道任何关于Point数值类型变量的信息。框入使类型得到统一,任 何类型的值基本上都能被作为一个对象来处理。 与框入相对,框出重新获得包含在对象中的数值类型(数据字段)的引用,其内部机制可描述为: 1、CLR(Common Language Runtime)首先保证引用类型变量不为空,并且它就是希望数值类型的框入值,如果这两个条件都不成立,则产生一个InvalidCastException异常。 2、如果类型确实匹配,则含在对象中的数值类型指针被返回,这个指针所指的数值类型不包含通常与真正的对象关联的开销:即虚表指针和同步块指针。 注意框入总是创建一个新对象并拷贝框出的的位到这个对象。而框出只是简单地返回一个框入对象的数据指针:不发生内存的拷贝。但是通常的情况是:代码会导致被框出的引用类型所指的数据被拷贝。 下面的代码示范了框入和框出::
public static void Main() { Int32 v = 5; // 创建一个框出的数值类型变量 Object o = v; // o 既是v的一个框入版本 v = 123; // 改变框出的值为123
Console.WriteLine(v + ", " + (Int32) o); // 显示 "123, 5" }
从上面的代码中你能想象有多少框入操作发生吗?你会惊奇地发现答案是3!让我们仔细分析一下代码以便真正理解所发生的事情。 首先创建的是一个Int32 框出的的数值类型v,初值为5。接着创建一个对象引用类型o并试图指向v。但是引用类型总是必须指向堆中的对象,所以C# 要产生相应的中间语言代码来框入变量v,并将v的框入版本的地址存储在o中。现在123是框出的并且引用的数据被拷贝到框出的数值类型v中,它不影响v的框入版本,所以框入版本保持它的值为5。注意这个例子示范了o是如何被框出(返回o中数据的指针),以及o中数据是内存被拷贝到框出的数值类型v。 现在调用WriteLine。它需要一个String 对象传给它,但你又没有String 对象,而是有三个已知项:一个Int32位框出数值类型v,一个串(“,”)以及一个Int32引用类型(或者说框入类型)o。它们必须被组合起来构成一个String。 为了构造String对象,C#编译器产生调用String对象静态Concat方法的代码。Concat方法有几种重载版本。它们实现的功能都一样,不同的只是参数个数不一样。如果要用三个已知项来格式化一个串,编译器将选择下面的Concat方法:
public static String Concat(Object arg0, Object arg1, Object arg2);
第一个参数是arg0,用来传递v。但v是框出的值参数,并且arg0是一个对象,所以v必须要被框入并且用arg0来传递框入的v的地址。第二个参数是arg1,它是字符串“,”的地址,即一个String对象的地址。最后一个参数是arg2,o(一个对象引用)被强制转换为Int32。它创建一个临时的Int32数值类型,这个数值类型接收当前被o引用的值的框出版本。这个临时的数值类型必须被再一次用arg2传递的内存地址框入。 一旦Concat被调用,它调用每一个指定对象的ToString方法并连结每一个对象的串值。然后从Concat返回的String对象被传递到WriteLine,从而显示最后的结果。 应该指出,如果用以下形式调用WriteLine,产生的中间代码(IL)会更有效:
Console.WriteLine(v + ", " + o); // 显示 "123, 5"
这行代码与前面的版本是一样的,只是将o前面“Int32”强制转换去掉了。它之所以更有效是因为o已经是一个对象的引用类型并且其地址被直接传递到Concat方法。从而即避免了一次框出操作也避免了一次框入操作。
下面是另一个框入和框出的例子: public static void Main() { Int32 v = 5; // 创建一个框出的数值类型变量 Object o = v; // o 既是v的框入版本
v = 123; // 改变框出的数值类型为123 Console.WriteLine(v); // 显示 "123"
v = (Int32) o; // 框出 o 到 v Console.WriteLine(v); // 显示 "5" }
在这段代码中,你计算了有多少框入操作吗?答案是一次。之所以只有一次框入操作是因为有一个接收Int32类型作为参数的WriteLine方法。
public static void WriteLine(Int32 value);
在两次WriteLine调用中,变量v(Int32框出数值类型)被用值传递。WriteLine可能在内部框入,而你无法控制它。重要的是你已经尽了最大努力并且从代码中排除了这次框入。 当你知道所写的代码会引起编译器产生大量的框入代码,如果转用手工方法框入数值类型的话,你将会得到更小更快的代码,如图四 C#编译器自动产生框入和框出代码。它使得编程更容易,但它对关心性能的程序员隐藏了开销。与C#语言一样,其它语言也可能隐藏框入和框出细节。但某些语言可能强制程序员显式地编写框入和框出代码。例如,C++受管扩展需要程序员显式地用__box操作符框入数值类型,框出操作是通过使用dynamic_cast.强制转换框入类型为与其等价框出类型。 最后要注意:如果一个数值类型不重载由System.ValueType定义的虚拟方法,那么这个方法只能在这个数值类型的框入形式上调用。这是因为只有这个对象的框入形式具有虚表指针。用数值类型直接定义的方法则可以这个值的框入和框出两个版本调用。
结论 在本文中讨论的概念对于.NET开发人员来说至关重要。你应该真正理解引用类型和数值类型之间的差别。同时还必须理解哪种操作需要框入,以及你所使用的编译器是否自动框入数值类型(象C#和Visual Basic)。如果是,你还应该了解编译器何时进行框入操作以及对代码有什么影响。对这些概念怎么强调都不过分,任何误解都会容易导致程序性能下降甚至是难以察觉的bugs。 |