本章内容:
1.所有类型都从System.Object派生
2.类型转换
3.命名空间和程序集
4.运行时的相互关系
4.1 所有类型都从System.Object派生
“运行时”要求每个类型最终都从System.Object类型派生。以下类型定义完全一致。
class Employee{ } // 隐式派生自Object
class Employee : System.Object { } //显式派生自Object
由于所有类型最终都从System.Object派生,所以每个类型的每个对象都保证了一组最基本的方法。
公共方法 说明
Equals 如果两个对象具有相同的值,就返回true(详情5.3.2节)
GetHashCode 返回对象的值的哈希码。
ToString 默认返回类型的完整名称(this.GetType().FullName)。但经常重写该方法来返 回包含对象状态表示的String对象。例如核心类型(Boolean和Int32)重写该方法 来返回它们的值的字符串表示。另外,经常出于调试的目的而重写该方法;调 用后获得一个字符串,显示对象各字段的值。
GetType 返回从Type派生的一个类型的实例,指出调用GetType的那个对象是什么类 型,返回的Type对象可以和反射类配合,获取与对象有关的元数据信 息。 GetType是非虚方法,目的是防止类重写该方法,隐瞒其类型,进而破坏 类型安全性
此外,从System.Object派生的类型能访问
受保护方法 说明
MemberwiseClone 这个非虚方法创建类型的实例,并将新对象的实例字段设与this对象的 实例字段完全一致,返回对新实例的引用
Finalize 在垃圾回收器判断对象应该作为垃圾被回收之后,在对象的内存被回 收之前,会调用这个虚方法。需要在回收内存前执行清理工作的类型 应重写该方法。
CLR要求所有对象都有new操作符创建
new操作符所做事情:
1.计算类型及其所有基类型(一直到System.Object,虽然它没有定义自己的实例字段)中定义的所有实例字段需要的字节数,堆上每个对象都需要一些额外的成员(overhead成员,或者说”开销成员“),包括”类型对象指针“(type object pointer)和”同步块索引“(sync block index)。CLR利用这些成员管理对象,额外成员的字节数要计入对象大小。
2.从托管堆中分配类型要求的字节数,从而分配对象的内存,分配的所有字节都设为0。
3.初始化对象的”类型对象指针“和”同步块索引“成员。
4.调用类型的实例构造器,传递在new调用中指定的实参。大多数编译器都在构造器中自动生成代码来调用基类构造器。每个类型的构造器都负责初始化该类型定义的实例字段,最终调System.Object的构造器,该构造器什么也不做,简单的返回。
new执行了所有这些操作之后,返回指向新建对象一个引用(或指针)。
没有和new操作符对应的delete操作符;没有办法显示的释放对象分配的内存,CLR采用了垃圾回收机制,能自动检测到一个对象不再使用或访问,并自动释放对象的内存。
4.2 类型转换
CLR最重要的特性之一就是类型安全。在运行时,CLR总是知道对象的类型是什么。调用GetType方法即可知道对象的确切类型。由于它是非虚方法,所以一个类型不可能伪装成另一个类型。
开发人员经常需要将对象从一个类型转换为另一种类型。CLR允许将对象转换为它的(实际)类型或者它的任何基类型。以下C#代码演示了向基类型和派生类型的转换
//该类型隐式派生自System.Object
internal class Employee{
}
public sealed class Program{
public static void Main(){
//不需要转型,因为new返回一个Employee对象
//而Object是Employee基类型
Object o = new Employee();
//需要转型,因为Employee派生自Object。进行强转。
Employee e = (Employee) o;
}
}
这个例子展示了需要做什么,才能让编译器顺利编译这些代码。在运行时,CLR检查转型操作,确定总是转换为对象的实际类型或者它的任何基类型。例如,以下代码虽然能通过编译,但会在运行时抛出InvalidCastException异常:
class Employee{
}
class Manager :Employee{
}
public class Program{
public static void Main(){
Manager m = new Manager();
PromoteEmployee(m);
DataTime c = new DataTime(2018,2,22);
PromoteEmployee(c); //运行时抛出异常
}
public static void PromoteEmployee (Object o){
//编译器在编译时无法准确获知对象o引用的是什么类型,因此编译器允许代码通过编译
//但在运行时,CLR知道了o引用的是什么类型,在每次执行转型的时候,会核实对象的
//类型是不是Employee类型或者从Employee派生的任何类型
Employee e = (Employee) o;
}
}
使用C#的is和as操作符来转型
4.3 命名空间和程序集
命名空间对相关的类型进行逻辑分组,开发人员可通过命名空间方便的定位类型。例如,System.Text命名空间定义了执行字符串处理的类型,而System.IO命名空间定义了执行IO操作的类型。
public void Main(){
System.IO.FileStream fs = new System.IO.FileStream ("...");
System.Text.StringBuilder sb = new System.Text.StringBuilder ("...");
}
像这样写代码很繁琐,应该有一种简单直接引用FileStream和StringBuilder类型,减少打字量。C#通过using指令提供这个机制。
using System.IO;
using System.Text;
public void Main(){
FileStream fs = new FileStream ("...");
.StringBuilder sb = new StringBuilder ("...");
}
C#的using指令是可选的,如果愿意,完全可以输入类型的完全限定名称。C#的using指令指示编译器尝试为类型名称附加不同的前缀,直至找到匹配项
重要提示 CLR对”命名空间“一无所知。访问类型是时,CLR需要知道类型的完整名称(可能是相当长的、包含句点符号的名称)以及该类型的定义具体在哪个程序集中。这样”运行时“才能加载正确程序集,找到目标类型,并对其进行操作。
在前面的示例代码中,编译器需要保证引用的每个类型都确实存在,而且代码调用确实存在的方法,向方法传递正确数量的实参,保证实参具有正确类型,正确使用方法返回值等等。如果编译器在源代码文件或者引用的任何程序集中找不到指定名称的类型,机会再类型名称附加System.IO.前缀,检查这样生成的名称是否与现有类型匹配。如果任然找不到匹配项,就继续为类型名称附加System.Text.前缀。在前面例子中的两个using指令的帮助下,只需要在代码中输入FileStream和StringBuilder这两个简化的类型名称,编译器会自动将引用展开成System.IO.FileStream和System.Text.StringBuilder。
检查类型定义时,编译器必须要知道在什么程序集中检查。第2.3章讲过,这通过/reference编译器开关实现。百年一起扫描引用的所有程序集,在其中查找类型定义,一旦找到正确的程序集,程序集信息和类型信息就嵌入生成的托管模块的元数据中,为了获取程序集信息,必须将定义了被引用类型的程序集传给编译器。c#编译器自动在MSCorLib.dll程序集中查找被引用类型,即使没有显示告诉它要这么做,MSCorLib.dll程序集包含所有核心Framework类型(FCL)类型定义。
4.4 运行时的相互关系
图4-2展示了已加载CLR的一个Windows进程。该进程可能有多个线程。线程创建时会分配到1MB的栈。栈空间用于向方法传递实参,方法内部定义的局部变量也在栈上。图4-2展示了线程的栈内存(右侧)。栈从高位内存向低位内存地址构建。图中线程已执行了一些代码,栈上已经有一些数据了。现在,假定线程执行的代码要调用M1方法。
图 4-2 一个线程的栈,当前准备调用M1方法最简单的方法包含”序幕“(prologue)代码,在方法开始做工作前对其进行初始化;还包含”尾声“(epilogue)代码,在方法做完工作后对其进行清理,以便返回至调用者。M1方法开始执行时,它的序幕代码在线程栈上分配局部变量name的内存,如图4-3所示。
图4-3 在线程栈上分配M1的局部变量然后,M1调用M2方法,将局部变量name作为实参传递。这造成name局部变量中的地址被压入栈(参见图4-4)。M2方法内部使用参数变量s标识栈位置(注意,有的CPU架构用寄存器传递实参以提高性能,但这个区别对于当前讨论来说并不重要)。另外,调用方法时还会将”返回地址“压入栈。被调用的方法在结束之后应返回至该位置。
图4-4 M1调用M2时,将实参和返回地址压入线程栈M2方法开始执行时,它的序幕代码在线程栈中为局部变量length和tally分配内存,如图4-5所示。然后,M2方法内部的代码开始执行。最终,M2抵达它的return语句,造成CPU的指令指针被设置成栈中的返回地址,M2的栈帧展开,恢复成图4-3的样子。之后,M1继续执行M2调用之后的代码,M1的栈帧将准确反映M1需要的状态。
栈帧:代表当前线程的调用栈中的一个方法调用。执行线程的过程中,进行的每个方法调用都会在调用栈中创建并压入一个StackFrame。
展开:unwind一般翻译成“展开”,但这并不是一个很好的翻译。wind和unwind源于生活。把线缠到线圈上称为wind,从线圈松开成为unwind。同样地,调用方法时压入栈帧,成为wind,方法执行完毕,弹出栈帧,称为unwind。把这几张图的线程看成一个线圈,就很容易理解了。
图 4-5 在线程栈上分配M2的局部变量最终,M1会返回到它的调用者。这同样通过将CPU的指令指针设置成返回地址来实现(这个返回地址在图中未显示,但它应该在栈中的name变量上方),M1的栈帧展开,恢复成图4-2的样子。之后,调用M1的方法将继续执行M1调用之后的代码,那个方法的栈帧将准确放映它需要的状态。
围绕CLR讨论。如下类定义
class Employee{
public Int32 GetYearsEmployed() {...}
public virtual String GetProgressReport() {...}
public static Employee Lookup(String name) {...}
}
class Manager : Employee{
public override String GetProgressReport() {...}
}
Windows进程已启动,CLR已加载到其中,托管堆已初始化,而且已创建一个线程。线程已执行了一些代码,马上调用M3方法。状态如图4-6。
图 4-6 CLR已加载到进程中,堆已初始化,线程栈已创建,马上调用M3方法JIT编译器将M3的IL代码转换成本机CPU指令时,会注意到M3内部引用的所有类型,包括Employee,Int32,Manager,String。这时CLR要确认定义了这些类型的所有程序集都已加载。然后。利用程序集的元数据,CLR提取与这些类型有关的信息,创建一些数据结构来表示类型本身。图4-7展示了Employee和Manager类型对象使用的数据结构。
图 4-7 Employee和Manager类型对象在M3被调用时创建本章前面讲过,堆上所有对象都包含两个额外成员:类型对象指针和同步块索引。如图,Manager和Employee都有这两个成员。定义类型时,可在类型内部定义静态数据字段。为这些静态数据字段提供资源的字节在类型对象自身中分配。每个类型对象最后都包含一个方法表。在方法表中,类型定义的每个方法都有对应的记录项。Employee定义了三个方法,所有方法表有三个记录项。Manager中有一个记录项。
当CLR确认方法所需要的所有类型对象都已创建,M3的代码编译完成后,就允许线程执行M3的本机代码。M3的序幕代码执行时必须在线程栈中为局部变量分配内存,如图4-8所示。CLR自动将所有局部变量初始化为null或0。(试图访问未显示初始化的局部变量,会报错)
图 4-8 在线程栈上分配M3的局部变量然后,M3执行代码创建了一个Manager对象。这造成在托管堆上创建Manager类型的一个实例(也就是一个Manager对象),如图4-9所示。
图 4-9 分配并初始化Manager对象可以看出,和所有对象一样,Manager对象也有类型对象指针和同步块索引。该对象还包括必要的字节来容纳Manager类型定义的所有实例字段,以及容纳由Manager任何基类定义的所有实例字段。任何时候在堆上新建对象,CLR都自动初始化内部的“类型对象指针”成员来引用和对象对应的类型对象。此外,在调用类型的构造器(本质是可能修改某些实例字段的方法)之前,CLR会先初始化同步块索引,并将对象的所有实例字段设为null或0.new操作符返回Manager对象的内存地址,该地址就保存到变量e中。
M3的下一行代码是调用Employee的静态方法Lookup。调用静态方法时,CLR会定位与定义静态方法的类型对应的类型对象。然后,JIT编译器在类型对象的方法表中查找与被调用方法对应的记录项,对方法进行JIT编译,再调用JIT编译好的代码。在Lookup方法内部,假设参数“chen”在数据库查询到是Manager,就在堆上构造一个新的Manager对象,返回该对象的地址。该地址保存到局部变量e中。如图4-10。
图 4-10 Employee的静态方法Lookup初始化Manager对象注意,e不再引用第一个Manager对象。事实上,由于没有变量引用该对象,所以它是垃圾回收的主要目标,垃圾回收机制将自动回收(释放)该对象占用的内存。
M3的下一行代码调用Employee的非虚实例方法GetYearsEmployee。调用非虚实例方法时,JIT编译器会找到与“发出调用的那个变量(e)的类型(Employee)”对应的类型对象(Employee类型对象)。这时的变量e被定义成一个Employee。如果Emoloyee类型没有定义正在调用的那个方法,JIT编译器会回溯类层次结构(一直回溯到Object),并在沿途的每个类型中查找该方法。之所以能这样回溯,是因为在每个类型对象都有一个字段引用了它的基类型,这个信息在图中没有显示。
然后,JIT编译器在类型对象的方法表中查找引用了被调用方法的记录项,对方法进行JIT编译,再调用JIT编译好的代码。本例假定方法返回值为5,这个整数就会保存到局部变量year中。如图4-11.
图 4-11 Employee的非虚实例方法GetYearsEmployee调用后返回5M3的下一行代码是调用Employee的虚实例方法GetProgressReport。调用虚实例方法时,JIT编译器要在方法中生成一些额外的代码;方法每次调用都会执行这些代码。这些代码首先检查发出调用的变量(e),并跟随地址来到发出调用的对象。变量e当前引用的是代表“chen”的Manager对象。然后,代码检查对象内部的“类型对象指针”成员,该成员指向对象的实际类型。然后,代码在类型对象的方法表中查找引用了被调用方法的记录项,对方法进行JIT编译,再调用JIT编译好的代码。由于e引用一个Manager对象,所以会调用Manager的GetProgressReport实现。如图4-12.
图 4-12 调用Employee的虚实例方法GetProgressReport,最终执行Manager重写的版本注意,如果Employee的Lookup方法发现chen是Emoloyee而不是Manager,Lookup在内部构造一个Employee对象,它的类型指针将引用Employee类型对象,最终执行的也将是Employee的GetProgressRrport实现,而不是Manager的。
Employee类型对象和Manager类型对象都包含“类型对象指针”成员,这是由于类型对象本质上也是对象。CLR创建类型对象时,必须初始化这些成员,初始化成什么呢?CLR开始在一个进程中运行时,会立即为MSCorLib.dll中定义的System.Type类型创建一个特殊的类型对象。Employee和Manager类型对象都是该类型的“实例”。因此,它们的类型对象指针成员会初始化成对System.Type类型对象的引用,如图4-13.
图 4-13 Employee和Manager类型对象是System.Type类型的实例当然,System.Type类型对象本身也是对象,也有类型对象指针成员。这个指针指向它本身,因为System.Type类型对象本身是一个类型对象的“实例”。
System.Object的GetType方法返回存储在指定对象的"类型对象指针“成员中的地址。也就是说,GetType方法返回指向对象的类型对象的指针。这样就可以判断系统中任何对象(包括类型对象本身)的真实类型。
网友评论