现实世界的值与引用
当我在读一份纸质报纸,为了给朋友一个副本,需要影印报纸的全部内容并交给他。这时,他将获得属于他自己的一份完整副本。这是值类型的行为。制作了副本之后,我的信息副本和朋友的副本是各自独立的。可以在自己的报纸上添加一些注解,他的报纸根本不会改变。
再来假定我正在读的是一个网页。与前一次相比,这一次,唯一需要给朋友的就是网页的URL。这是引用类型的行为。假如网页由于某种原因发生了变化,我和朋友下次载入页面时,都会看到那个改变。
在 C# 中,类(使用 class 来声明)是引用类型,而结构(使用 struct 来声明)是值类型。以及如下情况:
- 数组类型是引用类型,即使元素类型是值类型(所以 int [] 仍是引用类型,即使 int 是值类型);
- 枚举(使用 enum 来声明)是值类型;
- 委托类型(使用 delegate 来声明)是引用类型
- 接口类型(使用 interface 来声明)是引用类型,但可由值类型实现。
类型的值
- 对于值类型的表达式,它的值就是表达式的值。
- 对于引用类型的表达式,它的值是一个引用,而不是该引用所指代的对象。
所以,表达式 String.Empty 的值不是一个空字符串,而是对空字符串的一个引用。
在平常的讨论中,甚至在一些专业的文档中,经常混淆这一区别。例如,你可能这样描述:“String.Concat 的作用是返回一个字符串,该字符串将所有参数都连接到了一起。” 如果在这里使用非常精确的术语,既花时间又分散注意力。只要每个人都理解返回的只是一个引用,就没有问题。
这是存储了两个整数 x 和 y 的一个 Point 类型。这个类型可以实现为结构或类。
Point p1 = new Point(10, 20);
Point p2 = p1;
左边的部分指出当 Point 是一个类(引用类型)时所涉及的值,右边的部分展示了当 Point 是一个结构(值类型)时的情形。即比较值类型和引用类型的行为,尤其是涉及赋值操作时。
在两种情况下,p1 和 p2 在赋值后都有相同的“值”。然而,在 Point 是引用类型的情况下,那个“值”是引用:p1 和 p2 都引用同一个对象。在 Point 是值类型的情况下,p1 的值是一个“点”的完整的数据,也就是这个“点”的 x 和 y 值。将 p1 的值赋给 p2,会复制 p1 的所有数据。
变量的值是在它声明时的位置存储的。局部变量的值总是存储在栈 (stack) 中,实例变量的值总是存储在实例本身存储的地方。引用类型实例(对象)总是存储在堆 (heap) 中,静态变量也是。
两种类型的另一个差异在于,值类型不可以派生出其他类型。这将导致的一个结果就是,值不需要额外的信息来描述值实际是什么类型。把它和同引用类型比较,对于引用类型来说,每个对象的开头都包含一个数据块,它标识了对象的实际类型,同时还提供了其他一些信息。永远都不能改变对象的类型---执行简单的强制类型转换时,运行时会获取一个引用,检查它引用的对象是不是目标类型的一个有效的对象。如果有效,就返回原始引用;否则抛出异常。引用本身并不知道对象的类型--所以同一个引用“值”可用于(引用)不同类型的多个变量。例如下面的代码:
Stream stream = new MemoryStream();
MemoryStream memoryStream = (MemoryStream)stream;
第1行创建一个新的 Memorystream 对象,并将 stream 变量的值设为对那个新对象的引用。
第2行检查 stream 的值引用的是一个 MemoryStream (或派生类型)对象,并将 Memorystream 的值设为相同的值。
误区1:“结构是轻量级的类”
这个误解存在着多种形式。有人认为值类型不能或不应有方法或其他有意义的行为--它们应作为简单的数据转移类型来使用,只应该有 public 字段或简单的属性。对于这种说法,一个非常典型的反例就是 Datetime 类型:它作为值类型来提供是很有道理的,因为它非常适合作为和数字或字符相似的一个基本单位来使用。另外,它也理应被赋予对它的值执行计算的能力。换个角度来看这个问题,是数据转移类型一般都是引用类型。总之,具体应该如何决定,应取决于需要的是值类型的语义,还是引用类型的语义,而不是取决于这个类型简单与否。还有一些人认为值类型之所以显得比引用类型“轻”,是因为性能。事实是在某些情况下,值类型很“能干”--它们不需要垃圾回收,不会因类型标识而产生开销,也不需要取值这一步操作。但在其他方面,引用类型显得更“能干” -- 在传递参数、赋值、将值返回和执行类似的操作时,只需复制 4 或 8 字节(要看运行的是 32 位还是 64 位CLR),而不是复制全部数据。假定 ArrayList 是一个所谓“纯的”值类型,那么将一个ArrayList表达式传给一个方法时,就得复制它的所有数据。几乎在所有情况下,性能问题都不是根据这种判断来决定的。瓶颈从来都不是想当然的,在根据性能进行设计之前,需要衡量不同的选择。
值得注意的是,将这两者相结合也不能解决问题:类型(不管是类还是结构)拥有多少方法并不重要,每个实例所占用的内存不会受到影响。(代码本身会消耗内存,但这只会发生一次,而不是每个实例都发生。)
误区2:“引用类型在堆上,值类型在栈上”
第一句是正确的 -- 引用类型的实例总是在堆上创建的。但第二句就有问题了。首先,变量的值是在它声明的位置存储的。所以,假定一个类中有一个 int 类型的实例变量,那么在这个类的任何对象中,该变量的值总是和对象中的其他数据在一起,也就是在堆上。只有局部变量(方法内部声明的变量)和方法参数在栈上。对于 C#2 及更高版本,很多局部变量并不完全存放在栈中。
误区3:“对象在C#中默认是通过引用传递的”
这个误区是由于对术语的理解出现了偏差。
假如以引用传递的方式来传送一个变量,那么调用的方法通过更改其参数值,来改变调用者的变量值。现在请记住,引用类型变量的值是引用,而不是对象本身。不需要引用来传递参数本身,就可以更改该参数引用的那个对象的内容。例如,下面的方法更改了涉及的对象 StringBuilder 的内容,但调用者的表达式引用的仍然是之前的那个对象。
void AppendHello(StringBuilder builder)
{
builder.Append("hello");
}
调用这个方法时,参数值(对某 StringBuilder 的一个引用)是以值传递 (pass by value) 的方式传递的。如果想在方法内部更改 builder 变量的值--如执行 builder=null; 语句,调用者看不见这个改变(这样更改的是 builder 值的一个副本),刚好跟错误认识相反。
有趣的是,在这种有误说法中,不仅“引用传递”的说法有误,而且“对象传递”的说法也存在问题。无论是引用传递还是值传递,对象本身永远不会被传递。涉及一个引用类型时,要么以“引用传递”的方式传递变量,要么以“传值”的方式传递参数值(引用)。最起码,这回答了“当 null 作为一个传值参数的值来使用时会发生什么”的问题。假如传递的是对象,这个时候就会出问题,因为没有一个对象可供传递。相反,null 引用会采用和其他引用一样的“值传递”的方式传递。
// 通俗以为
public static void Call()
{
// 1. 在栈上分配 4 个字节内存,存储变量sb
// 2. 在堆上开辟内存,存储 实例对象 new StringBuilder("111")
// 3. sb中的值 是实例的地址
StringBuilder sb = new StringBuilder("111");
Console.WriteLine(sb.ToString()); // 111
AppendHello(sb); // 传递的是值的副本,即实例的地址
Console.WriteLine(sb.ToString()); // 111hello
}
装箱和拆箱
有的时候,我们就是不想用值类型的值,就是想用一个引用。之所以会发生这种情况,有多种原因。C# 和 .NET 提供了一个名为装箱 (boxing) 的机制,它允许根据值类型来创建一个对象,然后使用对这个新对象的一个引用。在接触实际的例子之前,先来回顾两个重要的事实
- 对于引用类型的变量,它的值永远是一个引用;
- 对于值类型的变量,它的值永远是该值类型的一个值。
基于这两个事实,来看下面 3 行代码:
int i = 5;
object o = i;
int j = (int)o;
这里有两个变量:i 是值类型的变量,o 是引用类型的变量。将 i 的值赋给 o 有道理吗? o 的值必须是一个引用,而数字 5 不是引用,它是一个整数值。实际发生的事情就是装箱:运行时将在堆上创建一个包含值 (5) 的对象(它是一个普通对象)。o 的值是对该新对象的一个引用。该对象的值是原始值的一个副本,改变 i 的值不会改变箱内的值。
第 3 行执行相反的操作——拆箱。必须告诉编译器将 object 拆箱成什么类型。如果使用了错误的类型 (比如 o 原先被装箱成 unit 或者 long,或者根本就不是一个已装箱的值),就会抛出一个 InvalidCastException 异常。同样,拆箱也会复制箱内的值,在赋值之后,j 和该对象之间不再有任何关系。
上面已经简单明了地解释了装箱和拆箱。剩下的唯一的问题就是要知道装箱和拆箱在什么时候发生。拆箱一般是很明显的,因为要在代码中明确地显示一个强制类型转换。装箱则可能在没有意识到的时候发生。上例展示的是一个简单的版本(第 2 行代码)。但是,为一个类型的值调用 Tostring、Equals 或 GetHashcode 方法时,如果该类型没有覆盖这些方法,也会发生装箱。另外,将值作为接口表达式使用时——把它赋给一个接口类型的变量,或者把它作为接口类型的参数来传递——也会发生装箱。例如,IComparablex = 5; 语句会对数字 5 进行装箱。之所以要留意装箱和拆箱,是由于它们可能会降低性能。一次装箱或拆箱操作的开销是微不足道的,但假如执行千百次这样的操作,那么不仅会增大程序本身的操作开销,还会创建数量众多的对象,而这些对象会加重垃圾回收器的负担。同样,这种性能损失通常也不是大问题,但还是应该引起注意,因此如果关心的话,可以对拆装箱带来的影响进行测试。