1. 概览
较之以往任何一种开发语言来说,.NET在类型系统上的创新设计都是无与伦比的。强大的通用类型系统CTS(Common Type System)奠定了整个.NET体系的基石。这套类型系统是贯穿于.NET Framework和各种中间语言之间的。因此需要从两个方面来理解.NET的类型系统。
总体来说,.NET的类型是一种完全的面向对象的类型。它由最底层的object类型开始,逐步扩展,上面再分支为值类型Value Type以及引用类型Reference Type。由值类型由分支出基础值类型、用户定义值类型以及枚举类型。由引用类型分支出自描述类型、指针类型、接口类型。由自描述类型又分支出类 类型、数组类型等。
下图展示了.NET的类型体系分支:
这是一基于自演化的体系,由一个根类型逐渐分支。其结构体系完全符合自然发展规律,符合面向对象的思想。这种思想早在中国古代经典著作中 就奠定了理论基础,在《易经》中提到这样的思想:太极生两仪、两仪生四象、四象生八卦、八卦演万物。 而.NET的类型体系正是符合这种发展的思想观。它所带来的优势是不言而喻的:
架构清晰
整个树形架构划分明确,便于程序的设计,便于理解。
通用性强
这种明确的类型系统有效的保证了.NET实现的多语言开发,中间语言转换,统一编译的特性。
便于检测
正是基于它清晰的架构,便于在程序出现错误时,按不同的类型需求检测错误。
扩展性好
统一的设计保证了类型的可扩展性。
2. 从源头说起
前面已提到过,.NET类型系统全部来源于一个统一的基础即System.Object类型。它定义于.NET Framework下,在C#中对应的类型为object类型。.NET实行了一种语言架构分离的机制,它的基本类型并没有定义于语言中,而是内置 在.NET Framework内。这样的设计进一步保障了公共语言系统的成功及工作效率。而我们在语言中也可以方便的使用助记符来代替,例 如System.Object在语言中可使用object替换,System.Int32在语言中可使用int替换。值得一提的是,这种替换名字虽有细小的差别,但仍是基于基本类型的。因此并不像网上某些文章提到的会损失性能。因为其在代码编译之 前就会在MSIL中完成类型的转换。请看以下示例。
{
int intA = 123 ;
Console.WriteLine(intA.ToString());
}
这段程序描述了一个int型变量的定义。当该脚本转换成IL后,其代码如下:
其中红色区域为int型变量在转换后的类型,由此可见,它仍是.NET Framework中定义的基本类型。
System.Object中拥有几个最基本的方法,包括实例方法:
静态方法:
这几个简单的方法为object所有的分支类型所共有。其中使用最普遍的就是ToString()方法。用于返回对象的字符串形式。在调试程序时,经常会运用它来判断当前对象是否正确。获得当前值。< /p>
Equals和ReferenceEquals用于对对象的实例进行相等比较。
GetType:用于运行时获取对象的运行时类型。
GetHashCode用于获取对象的散列码。
除此之外还包括了MemberwishClone方法,用于实现对象实例的浅拷贝,它是base基类中的一个受保护级别的方法。
最后还有一个非常特殊的Finalize方法,用于垃圾回收时处理资源的清理工作。该方法无法在自定义类中显示重写。要实现它,只需为类定义析构函数即可。但要注 意,Finalize对系统的开销非常大,因此请尽量少的使用它。
3. 从内存结构谈起
以上简单的介绍了.NET类型系统的划分和设计基础。但要真正了解.NET类型的细节问题,就需要弄清楚类型在内存中的表现形式。因为类型最重要的作用,即它的核心价值就在于为应用程序的各个元素开辟相应的内存 空间,指定其运行的位置。合理分配的内存空间可保证程序稳定有序的运行,也是决定程序性能的一项硬指标。
.NET类型系统的设计源自JAVA,其数据在内存的存储区域被划分成两个不同的部分,堆栈区(Strack)和托管堆(Manage Heap),堆栈区用于存储值类型,而引用类型则依赖于托管堆。这个过程是这样进行的:
在32位的操作系统上,当用户执行编译好的应用程序时,操作系统会在内存中为程序创建一个进程,同时为其分配4GB的内存空间(此空间是通过内存地址映射实现的虚拟空间),这块空间即为托管堆区,一般引用类型的实际数据都存储在此,而在堆栈上存储的则是引用类型的地址指针。
3.1值类型
而对于值类型来说,通常是存储于线程的堆栈上。堆栈是一种先进后出,并从高地址向低地址扩展的数据结构,它是一块连续的内存的区域。在系 统分配时会被指定大小。若存储的数据超出了这块指定区域就会发生“溢出”错误。下图表明了堆栈在内存中的存储结构。< /p>
这个概念非常重要,理解了这一点,在后面谈到数据类型转换时的重重问题就可以迎刃而解了。打一个不恰当的比喻来说,堆栈就好比酒店内的房 间,不同类型、不同数量的客人被安排在不同大小的房间内,有单间、双人间、三人间、豪华间还有总统套房。酒店前台会根据客人的不同需要进行分配。这里的酒 店前台好比堆栈中的地址指针。此指针指向堆栈中下一个自由地址空间。
下面的程序使用.NET的指针,定义了3个int型变量,分别获取它们的内存地址和值,从结果可以看出,值类型的内存分配方式:
![](https://i-blog.csdnimg.cn/blog_migrate/0be121fa5b8988fbabbbc526af3b0fc0.gif)
![](https://i-blog.csdnimg.cn/blog_migrate/34031c708bfe702fe82d01ff5c6593aa.gif)
using System;
using System.Collections.Generic;
using System.Text;
namespace ConsoleApplication5
{
class Program
{
unsafe static void Main(string[] args)
{
int a = 1;
int* addInt = &a;
Console.WriteLine("指针地址为:{0},内容为{1}。", (uint)addInt, addInt->ToString());
byte b = 2;
byte* addByte = &b;
Console.WriteLine("指针地址为:{0},内容为{1}。", (uint)addByte, addByte->ToString());
decimal c = 3m;
decimal* addDecimal = &c;
Console.WriteLine("指针地址为:{0},内容为{1}。", (uint)addDecimal, addDecimal->ToString());
bool d = true;
bool* addBool = &d;
Console.WriteLine("指针地址为:{0},内容为{1}。", (uint)addBool, addBool->ToString());
}
}
}
运行结果如下:
这个程序很好的表明了值类型在地址中是如何进行存储的。4个值从第1242220的高地址位开始一直向低地址位延伸。每次根据数据类型的不同分配不同长度的内存单元,用于存储所需数据。当然地址的起始位置是根据系统当 前的资源情况而分配的。
另外我们还需要了解的是值类型的作用域也有严格的规定。值类型的作用域被规范在一个代码块中,例如上面的例子程序 中,a、b、c、d四个变量的作用域就只在main主函数中存在,当程序运行到主函数的最后一个}符号时,四个变量被依次释放内存,这一操作是由系统自动完成的,并不需要人为去进行干预。< /p>
当然有些情况下值类型的作用域也被延伸。例如使用ref或out来按引用传递参数时,值类型的作用域则可被扩展到程序块之外。
3.2引用类型
说完值类型,让我们再回到引用类型上。首先要了解,为什么需要引用类型。实际上,相对于引用类型来说,值类型的执行效率要高得多,并且后 者的内存开销也要比引用类型小。那么是不是仅需要它就行啦?我们前面也谈到,类型的核心价值就是提高程序的性能,这样看来引用类型似乎是违背了这一原 则。
事实是,我们不仅需要引用类型,而且它的作用往往比值类型显得更加重要。因为值类型虽使用简便,但最大的缺点就是受到语句块作用域的限 制,并且只能存储一些小的数据类型,以至于使用上欠缺灵活性。而引用类型克服了这些缺点,首先是它的存储位置被分为两个区域。它在堆栈上声明并被分配空 间,但此空间存储的仅是实际数据在托管堆中的地址的引用。真正的数据被存储在托管堆中,托管堆的内存存放类似于堆栈,但它有一个专门的工具来负责内存的清 理工作,这个工具就是垃圾收集器GC。垃圾收集器会定期检查堆栈中的数据占用情况,若发现不用的对象(有一种算法来负责),或用户提出了申请,则开 动GC,回收内存中相应的资源。
引用类型使用运算符new进行创建,方法如下:
Test test = new Test();
Test是类型的名称,这里可以是用户自定义类型、也可以是系统内置类型。这行语句与普通的值类型定义相比仅是等号右边有所不同,但它本身包含 了以下几个步骤。
3.2.1 声明类型,在堆栈开辟内存空间
等号左边和值类型一样,首先指明了数据的类型为Test类型,此时编译器将会在同一命名空间下查找是否存在Test类型,若没有则在引 用中查找是否有using指向不同命名空间下的这一对象,若不存在则返回一个错误提示:“找不到类型或或命名空间名称”。当然这一步骤会在源代码编译前就 完成。但也有一种情况就是编译成功后,系统中注册的动链意外丢失,也会造成编译后的错误。
若类型存在,则根据此类型的需要在内存的堆栈区开辟空间。因此,即使是引用类型,仍然需要消耗堆栈区的空间。和值类型不同的时,此时, 堆栈空间中存储的不是引用类型的数据,而是引用类型在托管堆中的地址。
3.2.2 在托管堆开辟内存空间
当运行到new操作符时,系统开始在托管堆上分配内存,用于存放引用类型的实际数据。New不仅是用于创建对象,还有一个重要的作用就 是调用类构造函数。在IL中,new被newobj命名所定义,但new并不是为引用类型所独有的。值类型也有使用new的情况,看下面的示例程 序。
![ContractedBlock.gif](https://i-blog.csdnimg.cn/blog_migrate/0be121fa5b8988fbabbbc526af3b0fc0.gif)
![](https://i-blog.csdnimg.cn/blog_migrate/34031c708bfe702fe82d01ff5c6593aa.gif)
using System;
using System.Collections.Generic;
using System.Text;
namespace ConsoleApplication1
{
class Program
{
static void Main(string[] args)
{
//使用new声明值类型
int intA = new int();
intA = 2;
Console.WriteLine(intA.ToString());
//声明结构体时,并不一定需要new操作符
StrTest strTest;
strTest.intA = 123;
Console.WriteLine(strTest.intA.ToString());
//引用类型必须用new实例化
ClaTest claTest = new ClaTest();
claTest.intA = 123;
Console.WriteLine(claTest.intA.ToString());
}
}
struct StrTest
{
public int intA;
}
class ClaTest
{
public int intA;
}
}
在此程序中,定义了一个int型的值类型,一个结构体,一个类。我们可以看出,在声明值类型时,也可以使用new操作符,也可以不使用 new。而声明引用类型时,必须使用new操作符,因为需要new为引用类型在托管堆中分配资源。但new并不为值类型在托管堆中开辟内存 区。
3.2.3 调用构造函数
new的最后一个作用就是调用类或结构体的构造函数。构造函数是与类名同名的方法成员,由类在初始化后自动运行,用来完成一些数据的初始化工 作。