【CLR via C#(第3版)阅读笔记】类型基础
一、 所有的类型都从System.Object派生
//下面对于类的定义是等价//隐式派生自Object class Employee { }//显式派生自Object class employee : System.Object { }
System.Object的公共方法:
公共方法名称 说明 Equals 比较两个对象的“同一性”,同为true,否为false。 GetHashCode 返回对象的值的一个哈希码。 ToString 默认返回类型的完整名称(this.GetType().FullName),通常会出于调试的目的重写该方法。 GetType 返回从Type派生的一个对象的实例,属于非虚方法,可以防止被重写,C#作为强类型语言,GetType的非虚形式可以防止被破坏类型安全性。(非虚方法) MemberwiseClone 返回一个新的对象实例的引用,并且该新对象实例的实例成员与this对象完全一致。(非虚方法) Finalize 在垃圾回收器判断对象应该作为垃圾收集后,在对象被回收之前调用的方法。(虚方法) CLR要求所有的对象都用new操作符来创建。
new操作符所做的事情包括了:
1. 计算字节数 = 本类型及其基类型所需要的所有实例字段的字节数 + 类型对象指针 + 同步块索引;
2. 分配堆内存;
3. 初始化对象的“类型对象指针”和“同步块索引”;
4. 调用类型的实例构造器,并向其传入在new操作符中的对应参数,构造器也会自动生成代码调用基类构造器,最终调用System.Object。每个构造器都会负责初始化自己的实例字段;
5. 最后返回一个对象实例的引用。
文中还提到,CLR没有提供显示的类似delete这样的操作符,垃圾回收都是由垃圾回收机制自动检测之后释放内存的。
二、类型转换
CLR最重要的特性就是类型安全,在运行时,CLR总是能知道一个对象是什么类型的。
将一个对象转换为它的实际类型或者它的任何基类型,这是一种安全的隐式转换。
将基类型的对象转为派生类型则是一种显式的可能失败的可能在运行时导致失败的转换。
例如:派生类型A实例转Object实例,该Object转派生类型B实例(该B实例与A实例没有继承关系),则会导致失败。
在C#中使用is和as操作符实现类型转换:
操作符 特点 is 1. 永不抛出异常
2. 遇到null时返回false
as 1. 永不抛出异常
2. 转型失败时返回null
下面例子也说明了使用is实现转型时比使用as实现转型时耗费的性能更高:
//以下代码使用is进行转型操作,该代码进行了两步判断: //1. 判断o是否兼容Employee //2. CLR判断其确切类型,若不是当前所需类型,则进行继承层次的遍历,对每个基类型进行指定类型的核对 if(o is Employee) {Employee e = (Employee) o; }
//以下代码使用as进行转型操作,在使用as进行转型后,只需要判断结果是否为null即可 //这里最多只需遍历一次基类型去查找当下需要的类型 //需注意若转换完的结果直接拿来使用,可能会抛出System.NullReferenceException异常。 Employee e = o as Employee; if(e != null) {e.ToString(); }
三、命名空间和程序集
命名空间用于对相关的类型进行逻辑性分组。
例如:
命名空间System.Text定义了一组执行字符串处理的类型,命名空间System.IO定义了一组执行了I/O操作的类型。
CLR不关心命名空间是如何定义的,它访问类型时,是需要该类型的完整名称的,即命名空间.类名。
using 命名空间;的使用可以减少程序员的打字量和代码的可读性。//命名空间里可以使用命名空间 namespace CompanyName {public sealed class A{}namespace X{public sealed class B{...}} }
命名空间和程序集不一定是相关的。
同一个命名空间的不同类型可能是在不同程序集中实现的。一个程序集中也可能包含了不同的命名空间。
四、运行时的相互联系
当一个线程被创建时,它会被分配一个1MB大小的线程内存栈(栈大小取决于OS和设置),
之后的存储从高位内存向低位内存存储,即从内存地址数值较大的向内存地址数值较小的进行存储。
例子:
void M1() {String name = "Joe";<-当前执行位置M2(name);...return; }void M2(String s) { Int32 length = s.Length;Int32 tally;...return; }
void M1() {String name = "Joe";M2(name);<-当前执行位置...return; }void M2(String s) { Int32 length = s.Length;Int32 tally;...return; }
void M1() {String name = "Joe";M2(name);...return; }void M2(String s) { Int32 length = s.Length;Int32 tally;<-当前执行位置...return; }
如此循序运行,对内存进行入栈和出栈的操作。
上面的例子没有涉及到托管堆,托管堆也是运行时的重要内存位置,下面例子会加入托管堆的逻辑以及围绕CLR来进行讨论:
//假设当前进程已经启动,CLR已经被加载至其中,托管堆也已初始化,并且已经构建了线程栈 //我们将开始调用下面这个名为M3的方法 void M3() {Employee e;Int32 year;e = new Manager();e = Employee.LookUp("Joe");year = e.GetYearsEmployed();e.GenProgressReport(); }
当JIT编译器将M3的IL码转为机器码(即本地CPU指令)后,CLR会去确保方法内部所有的类型都已经被加载了,之后CLR会利用程序集的所有元数据,提取跟这些类型有关的信息,创建一些数据结构来表示这些类型本身。
//这里的M3已经编译完成了,并找出了所有的类型 //CLR确保定义了这些类型的所有程序集都被加载 //之后,CLR利用程序集中的元数据,在托管堆中创建对应的类型对象 void M3() {Employee e;Int32 year;e = new Manager();e = Employee.LookUp("Joe");year = e.GetYearsEmployed();e.GenProgressReport(); }
堆上面所有的类型对象(类型对象指的是该类型在堆上面的一个实例建立,可以理解为一个静态类本身)都会包括两个额外成员:类型对象指针和同步块索引。在这些类型对象里可以建立一些静态字段,类型对象自身会为这些静态字段分配内存,在每个类型对象的最后还会附带一个方法表,该方法表存了在该类型中定义的方法的每一个记录项。
在所有类型对象都已经创建后,运行方法,每当遇到局部变量时,CLR会将所有的局部变量初始化为null,或者零。但是,若直接读取一个尚未初始化的局部变量,C#会在运行时报告错误信息:使用了未赋值的局部变量。
//遇到了M3的局部变量 //开始在栈中分配内存 //CLR会自动为这些局部变量初始化值 void M3() {Employee e;Int32 year; <-当前程序执行位置e = new Manager();e = Employee.LookUp("Joe");year = e.GetYearsEmployed();e.GenProgressReport(); }
在运行中遇到类的对象实例化时,CLR会自动初始化内部类型对象指针,让它引用对应的类型对象。并且CLR会初始化同步块索引(同步块索引存储了该对象的哈希码、锁信息和其他运行时的信息),将所有的实例字段设为null或0。再调用类型的构造器(它本质上是可能修改某些实例数据字段的一个方法)。new操作符会返回Manager对象的内存地址。
//遇到初始化对象实例时 //在堆上新建一个对象 //CLR会自动初始化该对象的类型对象指针,同步块索引(在对象上存储与同步(锁)、哈希码以及某些运行时管理相关的额外信息),实例字段 //之后会调用类型的构造器 //new操作符也会返回对象的内存地址存放到变量e中 void M3() {Employee e;Int32 year; e = new Manager();<-当前程序执行位置e = Employee.LookUp("Joe");year = e.GetYearsEmployed();e.GenProgressReport(); }
调用一个静态方法时,CLR会定位到定义这个静态方法的类型对象,找到之后,JIT编译器在该类型对象中找到对应的该静态方法的记录项,如果有需要的话,就对该方法进行编译,之后再调用这些编译后的代码。
//开始调用静态方法Employee.LookUp(string)这个方法 //假设这个静态方法会返回一个对象,其类型为Manager //那么运行该方法就会在托管堆上面构造出一个新的Manager对象 //并且会对其对应的字段进行初始化,这里的Joe就是其中的一个参数信息 //最后返回一个该对象的地址,并存储在局部变量e中 void M3() {Employee e;Int32 year; e = new Manager();e = Employee.LookUp("Joe");<-当前程序执行位置year = e.GetYearsEmployed();e.GenProgressReport(); }
调用一个非虚实例方法时,JIT编译器会找到调用该方法的对象的类,如果在这个类里没有找到该非虚方法,那么就往上找本类型的基类型,直到找到为止。
之所以能够一直往上找本类型的基类型,是因为每个对象实则都有一个字段去存储它的基类型。找到方法对应的类型后,就在该类型的方法表里找到对应的记录项,接下来就跟找到了静态方法的调用方式一样,先判断是否需要JIT进行机器码的编译,在确保JIT对该方法的IL码进行编译后,执行该方法。
//当前开始调用非虚方法e.GetYearsEmployed() //由于e是Manager类型,而该方法是属于Employee类型的 //所以JIT会回溯层次结构(即查找本类型的父类型) //当找到了Employee类型后,JIT会对该类型中方法表的该方法的记录项 //进行必要的机器码编译,最后运行代码 //而e.GetYearsEmployed()的返回值会赋值给year void M3() {Employee e;Int32 year; e = new Manager();e = Employee.LookUp("Joe");year = e.GetYearsEmployed();<-当前程序执行位置e.GenProgressReport(); }
调用一个虚方法时,首先会定位到调用该方法的变量,然后根据该变量所指的地址找到在托管堆中的对象实例,再根据该实例的类型对象指针找到对应的类型对象,之后再该类型的方法表里找到对应的记录项,在此的操作也跟上方的没有其他区别了,就是让JIT进行必要的编译,然后执行对应的方法。
//假设e.GenProgressReport()是一个虚实例方法 //先是找到e所指的实例 //该实例中有一个表示类型对象指针的字段,通过该字段找到对应的类型对象 //接下来就是通过该类型对象中方法表的该方法的记录项 //进行必要的JIT编译,最后运行,若有返回值并且需要赋值就进行对应的赋值(显然此处不需要赋值) void M3() {Employee e;Int32 year; e = new Manager();e = Employee.LookUp("Joe");year = e.GetYearsEmployed();e.GenProgressReport();<-当前程序执行位置 }
可以注意到,一个类型在托管堆当中其实也存储着一个对象。当一个进程处于运行时,CLR会立即为MSCorlib.dll里的System.Type类创建一个较为特殊的对象。而其他的类型对象都可以视作它的“实例”,所以其他类型对象的类型对象指针会被初始化成System.Type的类型对象的引用,即其他的类型对象指针会指向System.Type。System.Object的GetType方法返回的是存储在指定对象的“类型对象指针”成员中的地址,即GetType方法是获取到类型对象指针所指的引用。