值类型和引用类型
本篇笔记结合了《CLR Via C#》和《C# in Depth》两本书中讲述的值类型和引用类型的区别和特性、值类型的装箱和拆箱这两部分内容。
但我根据装箱部分的理解所整理出来的配图可能会有错误和遗漏,希望能有人来指正。
现实世界中的值和引用
报纸与值类型
先假设你正在读的是一份真正的报纸。为了给朋友一份,需要影印报纸的全部内容并交给他。届时,他将获得属于他自己的一份完整的报纸。
在这种情况下,我们处理的是值类型的行为。所有信息都在你的手上,不需要从任何其他地方获得。制作了副本之后,你的这份信息和朋友的那份是各自独立的。可以在自己的报纸上添加一些注解,他的报纸根本不会改变。
网址与值类型
再假设你正在读的是一个网页。你需要给朋友的就是网页的URL。这是引用类型的行为,URL代替引用。为了真正读到文档,必须在浏览器中输入URL,并要求它加载网页来导航引用。
另一方面,假如网页由于某种原因发生了变化,你和你的朋友下次载入页面时,都会看到那个改变。
在C#和.NET中,值类型和引用类型的差异与现实世界中的差别类似。
谁是值类型或引用类型
.NET中的大多数类型都是引用类型,除了以下总结的特殊情况,类是引用类型,而结构是值类型。特殊情况包括如下方面:
①数组类型是引用类型,即使元素类型是值类型(所以即便 int 是值类型, int[] 仍是引用类型);
②枚举(使用 enum 来声明)是值类型;
③委托类型(使用 delegate 来声明)是引用类型;
④接口类型(使用 interface 来声明)是引用类型,但可由值类型实现。
值类型和引用类型的基础知识
值类型
大多数表达式都有与其相关的静态类型。对于值类型的表达式,它的值就是表达式的值。
值类型都隐式密封,目的是防止将值类型用作其他引用类型或值类型的基类型。
虽然不能在定义值类型时为他选择基类型,但如果愿意,值类型可实现一个或多个接口。
引用类型
对于引用类型的表达式,它的值是一个引用,是new操作符返回对象内存地址——即指向对象数据的内存地址。
使用引用类型的四个事实
使用引用类型必须留意性能问题。首先要认清楚以下四个事实。
1.内存必须从托管堆分配。
2.堆上分配的每个对象都有一些额外成员,这些成员必须初始化。
3.对象中的其他字节(为字段而设)总是设为零。
4.从托管堆分配对象时,可能强制执行一次垃圾回收。
二者在内存中的区别
Point 类型可以实现为结构或类。
Point p1 = new Point(10, 20); Point p2 = p1;
左部分指出当 Point 是引用类型时所涉及的值,右部分展示了当Point 是一个值类型时的情形。
①在 Point 是引用类型的情况下,那个值是引用: p1 和 p2 都引用同一个对象。
②在 Point 是值类型的情况下, p1 的值是一个完整的数据,也就是 x 和 y 值。将 p1 的值赋给 p2 ,会复制 p1 的所有数据。
声明值类型的条件
除非满足以下全部条件,否则不应将类型声明为值类型
①类型没有提供会更改其字段的成员,也就是说该类型是不可变类型,建议将绝大多数的值类型的字段都编辑为readonly。
②类型不需要从其他任何类型继承,类型也不派生出其他任何类型。
③类型的实例较小(16字节或更小);或者类型的实例较大(大于16字节),但不作为方法实参传递,也不从方法返回。
值类型和引用类型的区别
值类型的主要优势是不作为对象在托管堆上分配。与引用类型相比,值类型也存在自身的一些局限。下面列出了二者的一些区别。
①值类型对象有两种表示形式:未装箱和已装箱。引用类型则总是处于已装箱形式。
②定义自己的值类型时应重写Equals和GetHashCode,并提供他们的显式实现。
③不应在值类型中引入任何新的虚方法。所有方法都不能是抽象的,所有方法都隐式密封。
④引用类型的变量包含堆中对象的地址。引用类型的变量创建时默认初始化为null。值类型的变量总是包含其基础类型的一个值,所有成员都初始化为0。
⑥值类型变量赋值给另一个值类型变量,逐字段地复制。引用类型赋值,只复制地址。
⑦未装箱的值类型不在堆上分配。一旦定义了该类型的一个实例的方法不再活动,
为他们分配的存储就会被释放,而不是等着进行垃圾回收。
值类型的装箱和拆箱
值类型比引用类型轻,是他们不作为对象在托管堆中分配,不被垃圾回收,也不通过指针进行引用。但许多时候都需要获取对值类型实例的引用。
举个例子
例如假定要创建ArrayList对象来容纳一组Point结构。
struct Point{ public Int32 x,y; } //测试类 ArrayList list = new ArrayList(); Point p;//分配一个Point,不再堆中分配 for(Int32 i = 0;i<10;i++){ p.x = p.y = i;//初始化值类型中的成员 list.Add(p);//对值类型装箱,将引用添加到ArrayList中 } //本例的Add方法原型 public virtual Int32 Add(Object value);
可以看出Add获取的是一个Object参数,也就是说Add获取对托管堆上的一个对象的引用来作为参数。
但代码传递的是Point是值类型。为了使代码正确工作,Point值类型必须转换成真正的、在堆中托管的对象,而且必须获取对该对象的引用。
装箱机制
将值类型转换成引用类型要使用装箱机制。下面总结了对值类型的实例进行装箱时发生的事情。
①在托管堆中分配内存。分配的内存量是值类型各字段所需的内存量,还要加上托管堆所有对象都有的两个额外成员所需的内存量。
②值类型的字段复制到新分配的堆内存。
③返回对象地址。现在该地址是对象引用,值类型成了引用类型。
在运行时,当前存在于Point值类型实例p中的字段复制到新分配的Point对象中。已装箱Point对象的地址返回并传给Add方法。
Point对象一直存在于堆中,直到被垃圾回收。Point值类型变量可被重用,因为ArrayList不知道关于他的任何事情。
在这种情况,已装箱值类型的生存期超过了未装箱值类型的生存期。
拆箱
假定要用以下代码获取ArrayList的第一个元素。
Point p=(Point) a[0];
获取ArrayList的元素0包含的引用或指针,试图将其放到Point值类型的实例p中。
为此已装箱Point对象中的所有字段都必须赋值到值类型变量p中,后者在线程栈上。CLR分两步完成复制。
①获取已装箱Point对象中的各个Point字段的地址。这个过程称为拆箱。
②第二步将字段包含的值从堆复制到基于栈的值类型实例中。
拆箱不是直接将装箱过程倒过来,其代价比装箱低得多。拆箱就是获取指针的过程,该指针指向包含在一个对象中的原始值类型(数据字段)。
指针指向的是已装箱实例中的未装箱部分。所以和装箱不同,拆箱不要求在内存中复制任何字节。往往紧接着拆箱发生一次字段复制。
已装箱值类型实例在拆箱时,内部发生这些事。
①如果包含“对已装箱值类型实例的引用”的变量变为null,抛出NullReferenceException异常。
②如果引用的对象不是所需值类型的已装箱实例,抛出InvalidCastException异常。
第二条意味着在对象进行拆箱时,只能转型为最初未装箱的值类型。
//错误 Int32 x=5; Object o=x;//对x装箱,o引用已装箱对象 Int16 y=(Int16)o;//抛出InvalidCastExcrption异常 //正确 Int32 x=5; Object o=x;//对x装箱,o引用已装箱对象 Int16 y=(Int16)(Int32)o;//先拆箱为争取类型,再转型