相关文章连接:
编程之基础:数据类型(二)
高屋建瓴:梳理编程约定
动力之源:代码中的“泵”
完整目录与前言
数据类型是编程的基础,每个程序员在使用一种平台开发程序时,首先得知道平台中有哪些数据类型,每种数据类型有哪些特点、又有着怎样的内存分配等。熟练掌握每种类型不仅有利于提高我们的开发效率,还能使我们开发出来的程序更加稳定、健全。.NET中的数据类型共分为两种:引用类型和值类型,它们无论在内存分配还是行为表现上,均有着非常大的差别。
关于对引用类型和值类型的定义,听得最多的是:值类型分配在线程栈中,而引用类型分配在堆中。这个定义并不准确(因为值类型也可以分配在堆中,而引用类型在某种场合也可以分配在栈中),或者说太抽象,它只是从内存分配的角度来区分值类型和引用类型,而对于内存分配,我们开发者是很难直观地去辨别。如果从代码角度来讲,.NET中的值类型是指"派生自System.ValueType的类型",而引用类型则指.NET中排除值类型在外的所有其它类型。下图3-1显示了.NET中的类型布局:
图3-1 类型布局
如上图3-1所示,派生自System.ValueType的类型属于值类型(图中虚线部分,不包括System.ValueType),所有其它类型均为引用类型(包括System.Object、System.ValueType)。在以System.Object为根的庞大"继承树"中圈出一部分(图中虚线框),那么该小部分就属于"值类型"。
注:以上对值类型和引用类型的解释似乎有些难以理解,为什么"根"是引用类型,而某些"枝叶"却是值类型?这是因为 .NET 内部对派生自 System.ValueType 的类型做了些"手脚"(这些对我们来讲是不可见的),使其跟其它类型(引用类型)具备不一样的特性。另外, .NET 中还有一些引用类型并不继承自 System.Object 类,比如使用 interface 关键字定义的接口,它根本不在"继承树"的范围之类,这样看来,像我们平时听见的"所有类型均派生自 System.Object 类型"的话似乎也不太准确,这些隐藏的不可告人的秘密都是 .NET 内部做的一些处理,大部分并没有遵守主流规律。
通常值类型又分为两部分:
1) 简单值类型 :包括类似int、bool、long等.NET内置类型,它们本质上也是一种结构体;
2) 复合值类型 :使用Struct关键字定义的结构体,如System.Drawing.Point等。复合值类型可以由简单值类型和引用类型组成,下面定义一个复合值类型:
1 //Code 3-1 2 3 struct MultipleValType 4 { 5 int a; //NO.1 6 object c; //NO.2 7 }
如上代码Code 3-1所示,MultipleValType类型包含两个成员,一个简单值类型(NO.1处),一个引用类型(NO.2处)。
值类型均默认派生自System.ValueType,又由于.NET不允许多继承,因此我们既不可以在代码中显示定义一个派生自System.ValueType的结构体,同时也不可以让某个结构体继承自其它结构体。
引用类型和值类型各有自己的特性,这具体表现在内存分配、类型赋值(复制)、类型判等几个方面。
本节开头就谈到,引用类型对象与值类型对象在内存中的存储方式不相同,使用new关键字创建的引用类型对象存储在(托管)堆中,而使用new关键字创建的值类型对象则分配在当前线程栈中。
注:堆和栈的具体概念请参见本书后面讲"对象生命期"的第四章。另外,使用类似" int a = 0; "这种方式定义的简单值类型变量,跟使用 new 关键字" Int32 a = new Int32(0); "效果一样。
下面代码显示创建一个引用类型对象和一个值类型对象:
1 //Code 3-2 2 3 class Ref //NO.1 4 { 5 int a; 6 Ref ref; 7 public Ref(int a,Ref ref) 8 { 9 this.a = a; 10 this.ref = ref; 11 } 12 } 13 struct Val1 //NO.2 14 { 15 int a; 16 bool b; 17 public Val1(int a,bool b) 18 { 19 this.a = a; 20 this.b =b; 21 } 22 } 23 struct Val2 //NO.3 24 { 25 int a; 26 Ref ref; 27 public Val2(int a,Ref ref) 28 { 29 this.a = a; 30 this.ref = ref; 31 } 32 } 33 class Program 34 { 35 static void Main() 36 { 37 Ref r = new Ref(0,new Ref(1,null)); //NO.4 38 Val1 v1 = new Val1(2,true); //NO.5 39 Val2 v2 = new Val2(3,r); //NO.6 40 } 41 }
如上代码Code 3-2所示,先定义了一个引用类型Ref(NO.1处),它包含一个值类型和一个引用类型成员;然后定义了两个值类型(NO.2和NO.3处),前者只包含两个简单值类型成员(int和bool类型),后者包含一个简单值类型和一个引用类型成员;最后分别各自创建一个对象(NO.4、NO.5以及NO.6处)。创建的三个对象在堆和栈中存储情况见下图3-2:
图3-2 堆和栈中数据存储情况
如上图3-2所示,值类型对象v1和v2均存放在栈中,而引用类型对象均存放在堆中。
通常程序运行过程中,线程会读写各自对应的栈(因此有时候我们称"线程栈"),也就是说,"栈"才是程序进行读写数据的地方,那么程序怎么访问存放在堆中的数据(对象)呢?这就需要在栈中保存一个对堆中对象的引用(索引),程序就可以通过该引用访问到存放在堆中的对象。
注:引用类型对象一般分为两部分:对象引用和对象实例,对象引用存放在栈中,程序使用该引用访问堆中的对象实例;对象实例存放在堆中,里面包含对象的数据内容,有关它们更详细介绍,请参见本书后面有关"对象生命期"的第四章。
我们知道,内存可以看作是一块具有连续编号的存储空间,编号有大有小,所以有高地址和低地址之分。如果以字节为单元进行编号,那么一块内存可以用下图3-3表示:
图3-3 内存结构
如上图3-3所示,从左往右,地址编号依次增大,左侧称为"低地址",右侧称为"高地址"。编号为0x01字节中存储数值为0x01,编号为0x02字节中存储数值为0x09,编号为0x03字节中存储数值为0x00,编号为0x04字节中存储数值为0x1a,每个字节中均可存放一个0~255之间的数值。那么这时候,如果我问你,图3-3中最左侧四个字节表示的一个int型整数为多少?你可能会这样去计算:0x01*2的24次方+0x09*2的16次方+0x00*2的8次方+0x1a*2的0次方,然后这样解释:高位字节在左边,低位字节在右边,将这样的一个二进制数转换成十进制数当然是这样计算。事实上,这种计算方法不一定正确,因为没有人告诉你高位字节一定在左边(低地址),而低位字节一定在右边(高地址)。
当占用超过一个字节的数值存放在内存中时,字节之间必然会有一个排列顺序,我们称之为"字节序",这种顺序会因不同的硬件平台而不同。高位字节存放在低地址,而低位字节存放在高地址(如刚才那样),我们称之为"Big-Endian";相反,高位字节存放在高地址,而低位字节存放在低地址,我们称之为"Little-Endian"。在使用高级语言编程的今天,我们大部分时间不用去在意"字节序"的差别,因为这些都有系统底层支撑模块帮我们判断完成。
.NET中的值类型对象和引用类型对象在内存中同样遵循"字节序"的规律,如下面一段代码:
1 //Code 3-3 2 3 class Program 4 { 5 static void Main() 6 { 7 int a = 0x1a09; 8 int b = 0x2e22; 9 int c = b; 10 } 11 }
如上代码Code 3-3所示,变量a、b、c在栈中存储结构如下图3-4:
图3-4 整型变量在栈中的存储结构
如上图3-4所示,图中右边为栈底(注意这里,通常情况下,栈底位于高地址,栈顶位于低地址)。依次将c、b和a压入栈,图中上部分为按"Big-Endian"的字节序存放数据,而图中下部分为按"Little-Endian"字节序存放数据。
前面讲到,new出来的值类型对象存放在栈中,new出来的引用类型对象存放在堆中(栈中有引用指向堆中的实例)。如果我们把栈中的值类型转存到堆中,然后通过一个引用访问它,那么这种操作叫"装箱";相反,如果我们把装箱后在堆中的值类型转存到栈中,那么就叫"拆箱"。下面代码Code 3-4表示装箱和拆箱操作:
1 //Code 3-4 2 3 class Program 4 { 5 static void Main() 6 { 7 int a = 1; //NO.1 8 object b = a; //NO.2 9 int c = (int)b; //NO.3 10 } 11 }
如上代码Code 3-4所示,NO.1定义一个整型变量a,它存放在栈中,NO.2处进行装箱操作,将栈中的a的值复制一份到堆中,并且使用b引用指向它,NO.3处将装箱后堆中的值复制一份到栈中,整个过程栈和堆中的变化情况见下图3-5:
图3-5 装/拆箱栈和堆中变化过程
如上图3-5所示,装箱时将栈中值复制到堆中,拆箱时再将堆中的值复制到栈中。
使用时间短、主要是为了存储数据的类型应该定义为值类型,存放在栈中,随着线程中方法的调用完成,栈中的数据会不停地自动清理出栈,再加上栈一般情况下容量都比较有限,因此,建议类型设计的时候,值类型不要过大,而把那种体积大、程序需要长时间使用的类型定义为引用类型,存放在堆中,交给GC统一管理。同时,拆装箱涉及到频繁的数据移动,影响程序性能,应尽量避免频繁的拆装箱操作发生。
注:图 3-5 中栈的存储是连续的,而堆中存储可以是随机的,具体原因参见本书后续有关"对象生命期"的第四章。
在面向对象的世界里,随处充满着"对象"的影子,那么怎么去判断对象的相等性呢?所谓相等,指具有相同的组成、属性、表现行为等,两个对象相等并不一定要求相同。.NET对象的相等性判断主要包括以下三个方面:
引用类型分配在堆中,栈中只存放对堆中实例的一个引用,程序只能通过该引用才能访问到堆中的对象实例。对引用类型来讲,只有栈中的两个引用指向堆中的同一个实例时,才能说这两个对象相等(其实是同一个对象),其余任何时候,对象都不相等,就算两个对象中包含的数据一模一样。用图3-6表示为:
图3-6 引用类型判等
如上图3-6所示,左边的a和b分别指向堆中不同的对象实例,虽然实例中包含相同的内容,但是它两不相等;右边的a和b指向堆中同一个实例,因此它们相等。
可以看出,对于引用类型来讲,判断两个对象是否相等很简单,直接判断两个对象引用是否指向堆中同一个实例,若是,则相等;其余任何情况都不相等。
注:熟悉 C/C++ 中指针的读者应该很清楚,两个不同的整型变量 a 和 b ,虽然 a 的值和 b 的值相等(比如都为 1 ),但是它们两的地址肯定不相等(参见前面讲到的"字节序")。 .NET 中引用类型判等其实就是比较对象在堆中的地址,不同的对象地址肯定不相等(就算内容相等)。另外, .NET 中的 String 类型是一种特殊的引用类型,它不遵守引用类型的判等标准,只要两个 String 包含相同的字符串,那么就相等, String 类型判等更符合值类型的判等标准。
简单值类型包括.NET内置类型,比如int、bool、long等,这一类的比较准则跟现实中所说到的"相等"概念相似,只要两者的值相等,那么两者就相等,见如下代码:
1 //Code 3-5 2 3 class Program 4 { 5 static void Main() 6 { 7 int a = 10; 8 int b = 11; 9 int c = 10; 10 } 11 }
如上代码Code 3-5所示,a和c相等,与b不相等。为了与引用类型判等进行区分,见下图3-7:
图3-7 简单值类型在栈中的存储情况
如上图3-7所示,假设按照"Big-Endian"的字节序排列,右边是栈底,程序依次将c、b以及a压入栈。我们可以看到,如果比较a和c的内容,"a==c"成立;但是如果比较a和c的地址,很明显,a的(起始)地址为0x01,而c的(起始)地址为0x09,它两的地址不相等。
简单值类型的比较只关注两者包含的内容,而不去关心两者的地址,只要它们的内容相等,那么它们就相等。复合值类型也是比较两者包含的内容,只是复合值类型可能包含多个成员,需要挨个成员进行一一比较,详见下一小节。
注:虽然笔者很不想在 .NET 的书籍中提到有关指针(地址)的话题,但是为了说明"引用类型判等"的标准与"值类型判等"的标准有何区别,还是稍微提到了指针。我们可以很容易对比发现,引用类型判等其实就是比较对象在堆中的地址,而对象在堆中的地址就是由栈中的引用来表示的,地址不同,栈中引用的值肯定不相等,把栈中引用想象成一个存储堆中地址的变量,完全可以用简单值类型的判等标准去判断引用是否相等。
前面讲过,复合值类型由简单值类型、引用类型组成。既然也是值类型的一种,那么它的判等标准和简单值类型一样,只要两个对象包含的内容依次相等,那么它们就相等。下面代码Code 3-6定义了两种复合值类型,一种只由简单值类型组成,一种由简单值类型和引用类型组成:
1 //Code 3-6 2 3 struct MultipleValType1 //NO.1 4 { 5 int _a; 6 int _b; 7 public MultipleValType1(int a,int b) 8 { 9 _a = a; 10 _b = b; 11 } 12 } 13 struct MultipleValType2 //NO.2 14 { 15 int _a; 16 int[] _ref; 17 public MultipleValType2(int a,int[] ref) 18 { 19 _a = a; 20 _ref = ref; 21 } 22 } 23 class Program 24 { 25 static void Main() 26 { 27 MultipleValType1 mvt1 = new MultipleValType1(1,2); //NO.3 28 29 MultipleValType1 mvt2 = new MultipleValType1(1,2); //NO.4 30 // mvt1 equals mvt2 return true; 31 MultipleValType2 mvt3 = new MultipleValType2(2,new int[]{1,2,3}); //NO.5 32 MultipleValType2 mvt4 = new MultipleValType2(2,new int[]{1,2,3}); //NO.6 33 //mvt3 equals mvt4 retturn false; 34 } 35 }
如上代码Code 3-6所示,创建两个复合值类型,一个只包含简单值类型成员(NO.1处),另一个包含简单值类型成员和引用类型成员(NO.2处),最后创建了两对对象mvt1和mvt2(NO.3和NO.4处)、mvt3和mvt4(NO.5和NO.6处),它们都存放在栈中。mvt1和mvt2相等,因为它两包含相等的成员(_a都等于1,_b都等于2),相反,mvt3和mvt4却不相等,虽然看起来它两初始化是一样的(_a都等于1,_ref都指向堆中一个int[]数组,并且数组中的值也相等),原因很简单,按照前面关于"引用类型判等"的标准,mvt3中的_ref和mvt4中的_ref根本就不是指向堆中同一个对象实例(即mvt3._ref!=mvt4._ref)。为了更好地理解这其中的区别,请见下图3-8:
图3-8 复合值类型内存分配
如上图3-8所示,创建的4个对象均存放在栈中,mvt1和mvt2包含相等的成员,因此它两相等,但是mvt3和mvt4包含的引用类型成员_ref并不相等,它们指向堆中不同的对象实例,因此mvt3和mvt4不相等。
对于值类型而言,判断对象是否相等需要按以下几个步骤:
(1)若是简单值类型,则直接比较两者内容,如int、bool等;
(2)若是复合值类型,则遍历对应成员:
1)若成员是简单值类型,则按照"简单值类型判等"的标准进行比较;
2)若成员是引用类型,则按照"引用类型判等"的标准进行比较;
3)若成员是复合值类型,则递归判断。
值类型判等是一个"递归"的过程,只要递归过程中有一次比较不相等,那么整个对象就不相等。详见下图3-9:
图3-9 值类型判等流程
(本章未完)