对值类型和引用类型的理解
想必每一个学习编程的人都一定接触过值类型和引用类型,从我学习C和C++的时候,就开始听这两个概念。但是当时自己太年轻了,也没想过去了解什么是值类型,什么是引用类型,为啥会有两种类型之类的问题。随着毕业了,工作了,也逐渐了解里面的机制和用途。
就先拿一段代码做开场白吧。
1: class Program
2: {
3: static void Main(string[] args)
4: {
5: int valueType1 = 1;
6: ReferenceType referenceType1 = new ReferenceType();
7:
8: Console.WriteLine("valueType1's value is {0}, and argument of referenceType1 is {1}", valueType1, referenceType1.argument);
9:
10: int valueType2 = valueType1;
11:
12: Console.WriteLine("argument2 is {0}", valueType2);
13:
14: valueType2 = 2;
15:
16: Console.WriteLine("valueType2 is modified, the value of now is {0}. And value of valueType1 is {1}", valueType2, valueType1);
17:
18: ReferenceType referenceType2 = referenceType1;
19:
20: Console.WriteLine("argument of referenceType1 is {0}, argument of referenceType2 is {1}", referenceType1.argument, referenceType2.argument);
21:
22: referenceType2.argument = 3;
23:
24: Console.WriteLine("argument of referenceType1 is {0}, argument of referenceType2 is {1}", referenceType1.argument, referenceType2.argument);
25:
26: Console.ReadKey();
27: }
28: }
29:
30: class ReferenceType
31: {
32: public int argument = 1;
33: }
输出结果如下:
上面是个简单的例子,有些地方不妥当,不过能清楚的表达值类型和引用类型在使用和表现上的一些差别。代码的5、6行分别声明和初始化了值类型和引用类型。10行将alueType1赋值类另一个值类型变量,然后下面修改alueType2的值,输出后发现,修改alueType2的值对alueType1没有影响,这也符合我们的想法,我修改了哪个,哪个有变化,不修改,就不能有变化。但是下面的引用类型就不是这样了,原始输出值是修改了referenceType2之后,变成了,也就是两个引用类型都变化了。为什么是这样?下面就逐步来解释一下,然后说明引用类型和值类型的区别。
现在编程都是面向对象编程的,而当使用一个对象的时候,都要经历差不多以下的步骤:(CLR上的步骤)
- 在托管堆上分配内存
- 初始化对象成员,包括一些系统默认的,引用类型初始化为null,值类型初始化为0
其他还会有一些额外的步骤,比如可能在分配对象之前要对内存进行一次垃圾回收。
从以上可以看出,如果我仅仅需要一个简单的变量,比如说就是一个数字,按照面向对象编程的话也是需要走以上步骤的,这无疑就造成了很大的资源和时间浪费,而且这对CLR造成的压力也是很大的。所以,一种轻量型的类型就出现了,区别与引用类型,就是值类型。
下面还需要解释一下两种数据机构,堆栈和托管堆。上面说了,在CLR中,对象是分配到托管堆上的(顾名思义,这个托管堆不用程序员关注,已经被CLR托管了)。而栈是编译器直接操作的,存放的是我们写的有顺序的代码指令。
所以对象是不可以嵌入到代码指令的存储区域的,但是如果是轻量型的值类型,就可以嵌入到栈中了。这就造成,值类型是存储在栈中,对象(引用类型)是存储在堆上的。这就产生了一个问题,代码怎么知道堆上的哪个对象是它要操作的?所以栈中也会存储一个堆上的对象的一个引用,相当于一个占位符,但这个占位符里面存了一个地址,通过这个地址,代码就可以到堆上找到这个对象。而值类型也可以说有一个占位符,但占位符存的不是地址,就是它所代表的值。相对而言,值类型就省了一趟代码需要拿着地址去堆上找对象的时间,当然,也不需要通知堆你给我发配一个面积多大的地址,然后实例化的麻烦。
做个比喻:小区下面都有投递箱,如果某天你有一个快件,邮递员直接就可以把小件的快件(比如就一张纸)扔进你家的投递箱,回头你自己去拿就行。但是如果你邮了一个10立方米的东西,邮递员肯定不会给你送到投递箱里,估计就给你写个字条(上面写着物品名称,物品存放的仓库地点,联系人啥的)扔进投递箱,回头你自己去取吧,这么大的东西,不在我们派送范围内,来回路费还得自己报销。上面的投递箱就类似栈,大件物品存放的仓库相当于堆了。
继续,那为什么对alueType2改变值不会对alueType1造成影响,但是对referenceType2更新会造成referenceType1的变化?咱们来解释一个这个问题。int valueType2 = valueType1这句代码是将alueType1赋值给alueType2,因为alueType1存储的就是值(不是引用地址),所以它直接将1赋值给alueType2,然后它们俩就没关系了。表现在栈上,俩变量也是你是你,我是我的关系。但是ReferenceType referenceType2 = referenceType1这句代码,是将referenceType1代表的值赋值给referenceType2,但是referenceType1保存的是一个地址引用,也就是referenceType1实际在堆上的地址。referenceType1把自己的地址给了referenceType2,然后这两个变量关系就很密切了,因为它们俩指向的是同一个堆上的对象了,所以通过它们俩提供的任何一个地址修改指向的对象都造成对象永远被修改了。这就解释了我们上面的疑问。
其实里面还有一个地方,这个referenceType1是怎么拿到对象在堆上的地址的?是new的功劳。.NET中,new也是一个很有用的关键字(这个是废话),它在上面代码第六行的作用就是:先计算这个需要实例化的类型到底需要多大的空间,然后去申请这一块空间下来。申请完之后,就把类型所需要的字段填进去,然后先初步实例化(关于怎么实例化,里面会有不同的操作,不在本篇范围内)。最后整个都办完了,然后new就把这个对象的地址拿走,返回去声明的变量,这里就是referenceType。所以,对象变量,存储的是对象的指针,但这个指针是强类型的,不允许改变指针的类型。其实上面还有一个问题,就是在申请地址空间的时候,万一空间不够用咋办?内存都是有限制的,而且每个进程有4G的限制,但是一个进行下面不知道有多少AppDomain,所以不够用还是比较常见。CLR把堆给托管了,堆不够用,当然CLR负责给找地方了(其实还是可以通过一些代码在程序中间接控制堆,但是这个因为CLR原因,不是实时的)。CLR就是在这个时候执行垃圾回收操作(GC),把哪些前面占用了内存空间但是现在不用的对象给处理掉,然后再把所有的现在需要使用的对象给集中放到栈顶部,所以这就要移动很多对象(性能就降低了),腾出地方来在分配地址空间。
区别值类型和引用类型可以从一个字面的叫法上看出来,一般值类型都叫结构,比如枚举结构,Struct结构。对象都交什么什么类。
说明一下,上面讲到,对引用类型的更改会导致所有引用这个对象的引用的变量跟着更改,有一个例外,那就是String,String是引用类型。因为String使用的太频繁了,所以CLR对String进行了特殊化操作。举个例子:
String a = "kamong"; String b = a; b = "Dylan"; Console.WriteLine(“a:{0}", a); Console.WriteLine("b:{0}", b);
上面的输出是a:kamong b: Dylan
为什么会这样?因为CLR做了特殊处理:如果在声明一个String对象被两个变量引用,如果一个变量要改变这个对象,就会新复制一个样本,让需要更新对象的变量指向这个新的副本,不影响原来的对象。
还有就是值类型和引用类型都是派生自System.Object(.NET平台所有的类型最终也都是派生于System.Object),所以不要误以为System.Object是对象,就觉得值类型不是它的子类。但是因为有区别于引用类型的方法和机制,所以值类型也自己重新定义了很多方法和属性,比如Equals方法等。
值类型和引用类型之间还有比较基本的装箱和拆箱,有时间再说吧,先去吃饭去。