C#与CLR学习笔记(3)—— 类型

目录

1 基类型与类型转换

1.1 System.Object的方法

1.2 使用 new 创建对象的过程

1.3 类型转换

1.4 使用 is 和 as 进行类型转换

1.5 命名空间

2 类型、对象、栈、堆在运行时的相互关系

类型对象指针


1 基类型与类型转换

1.1 System.Object的方法

访问限制 方法 说明
public Equals  
public GetHashCode 返回对象值的哈希码。如果重写了Equals方法,GetHashCode方法一般也需要重写,因为哈希容器使用HashCode作为索引来查找对象。
public ToString 默认返回类型的完整名称 this.GetType().FullName
public GetType  
protected MenberwiseClone 浅表复制
protected Finalize 在被GC认为是垃圾后,在该对象的内存被实际回收之前,调用这个虚方法。需要在回收内存前执行清理工作的类型应重写该方法

1.2 使用 new 创建对象的过程

CLR 要求所有的对象都要用 new 操作符创建。new操作符会做如下工作:

(1)计算该类型及其所有基类型的所有实例字段所需要的字节数。每个对象在堆上还有有一些额外的空间开销,包括 “类型对象指针” 和 “同步索引块” ,CLR用他们来管理对象(见下文)。对象的大小包括了这些额外的成员开销。

(2)从托管堆中分配要求的字节数,分配的所有字节都设为0。

(3)初始化对象的 “类型对象指针” 和 “同步索引块” 成员(见下文)。

(4)调用类型的实例构造器,传入构造函数中的参数。编译器在构造函数中自动(在函数开头)生成代码来调用基类的默认无参构造函数。每个类型(基类)的构造函数负责初始化该类型定义的实例字段。最终,调用到最底层的 System.Object 的构造函数,Object 没有实例字段,因此它什么都不做,简单地返回。

如果基类有多个构造函数,我们也可以使用 :base() 显示地指定调用哪一个构造函数。

关于构造函数的执行顺序:构造函数的执行是从基类到子类从上到下一层层地执行的,因为子类构造函数没有实例化基类字段的参数,除非通过base()显示地继承父类的构造函数并传入参数。这个base()是在左花括号之前的,先于本身的构造函数执行。因此肯定是基类的构造函数先执行。

new 执行了上述操作后,返回一个引用(或指针),指向新建的对象。

1.3 类型转换

子类可以隐式地转化为其某个基类,这个不必说了,因为这个过程是类型安全的,没有信息的损失,CLR 仍会记住子类的实际类型。

但是一个基类可不可以转为它的子类呢?答案是不一定。要看这个基类地来源,如果它来源于子类,那么它就可以被显示的转为其子类,否则就不行。

在某些情况下,编译器并不能知道要转换的基类是不是其子类(例如基类作为参数时),因此能够编译通过,但在运行时,CLR会核实基类的身份,看它 “骨子里” 是不是要转成的子类,若不是,则抛出异常,因为确实无法转换呀!

看个例子:

定义一个基类 Employee,和一个子类 Manager:

public class Employee
{
    // 工号
    public int No { get; set; }
}

public class Manager : Employee
{
    // 手下的员工数量
    public int EmployeeNum { get; set; }
}

下面的代码中,有两处显示转换,将基类 Employee 转为 子类 Manager。这段编译能通过。但是运行时,第(1)处的显示转换能正常运行,(2)处的转换会抛出强制转换失败的异常。原因如上文所述,基类对象 e1 骨子里就是子类对象(e1 由子类对象隐式转换而来,隐式转换不会有信息损失,CLR 仍认识它是子类对象),而基类对象 e2 则是彻头彻尾的基类,自然无法转换为其子类。

Manager m0 = new Manager();
m0.EmployeeNum = 10;
Employee e1 = m0;
Employee e2 = new Employee();
Manager m1 = (Manager) e1; // (1) 正常执行
Manager m2 = (Manager) e2; // (2) 抛异常
Console.WriteLine($"m1.EmployeeNum: {m1.EmployeeNum}");
Console.WriteLine($"m2.EmployeeNum: {m2.EmployeeNum}");

但是,我有一个问题:从语法角度上讲,为什么显示转换需要显示地在被转换的类型前面用括号标明目标类型呢?这好像是多此一举,因为 CLR 会自动检查类型的 “真实类型”,如果类型相互符合,自动转就行(像隐式转换那样),如果不符合抛个异常就行了,没有必要让程序猿多写个括号和类型名吧。后来又一想,可能是为了语法的统一吧。对于值类型的强制转换,因为可能会造成精度损失,故要求程序猿写明目标类型以达到提醒的目的;另一方面,强制转换 本质上也是一种运算符(参见 自定义强制类型转换 相关内容),因此有必要写明运算符。 基类和子类之间的强制类型转换的含义与上两种情况的强制转换 本质上是不同的,而形式相同,我猜可能是为了统一语法规则吧。

1.4 使用 is 和 as 进行类型转换

为了确保基类转子类能安全的进行,我们可以先用 is 操作符来检查两个类型是否兼容,若是再去强制转型:

public void Transfer(Object o)
{
    if (o is Employee)
    {
        Employee e = (Emloyee) o;
    }
}

上述代码中,is 和 (Employee) 进行了两次对象o的类型检查,影响了执行效率。为此,C# 提供了 as 操作符,来简化编程,同时提高执行效率:

Employee e = o as Employee;
if (e != null)
{
    ... ...
}

上述代码中,CLR 只检查一次 o 是否兼容 Employee,如是, as 直接返回这个对象的非空引用;若否,as 返回 null。检查一个对象是否是 null 比检查对象类型快的多,因此,可以提高效率。

1.5 命名空间

命名空间并没有太复杂的含义,它仅仅是在类型名称前加上一些前缀,使得类型名称变长,来区分可能同名的类型。

命名空间和程序集没有关系。同一个命名空间可以在不同的程序集中实现

2 类型、对象、栈、堆在运行时的相互关系

前面的文章中已经大致讨论了 CLR 执行代码的大致过程。这里结合类型,详细讨论一下其工作原理。

首先,关于栈和堆的概念见这篇文章

下文以如下代码为例进行讨论:

internal class Employee 
{
    public Int32 GetYearsEmployed() { ... }
    public virtual String GetProgressReport() { ... }
    public static Employee Lookup(String name) { ... }
}

internal sealed class Manager : Employee 
{
    public override String GetProgressReport() { ... }
}

Windows 在执行一个托管程序集时,首先进程会加载CLR。进程也会创建多个线程。线程创建时会被分配 1M 的空间。

假设我们现在有一个方法 M3() 要执行,当前的内存状态为:

下面开始执行M3方法。

(1)CLR 在执行方法时,会检查方法引用的所有的类型。CLR 首先检查这些类型所在的程序集有没有被加载到进程中。

(2)然后,CLR 根据程序集的元数据,提取用到的类型的有关信息,在堆中创建对应的 System.Type 实例对象,存放类型信息。这个对象称为 “类型对象” 。类型对象中,包含有 类型对象指针(Type object pointer)、同步块索引(sync block index)、静态字段 以及 方法表

在初始化这个类型对象时,方法表中的每个方法是指向 JITCompiler 函数的,它负责对方法的代码进行 JIT 编译。

这时的内存状态:

(3)CLR 根据程序集元数据获取方法的 IL 代码,然后进行 JIT 编译(如果是第一次调用该方法),编译成本机代码,存放在内存中,然后,修改 堆中类型对象的方法表中的方法,使它指向编译后的这段本机代码。因此,JIT只需进行一次编译。(编译后的本机代码存放在哪里???暂时未知

(4)代码编译完之后,CLR开始执行代码。CLR 内部机制会在方法开头添加一些功能,来为方法创建一个栈帧,并将局部变量压入栈帧中。同时,CLR 自动将所有局部变量初始化为 0 或 null。(CLR是如何知道栈帧中局部变量的位置和类型的???JVM的栈帧模型

(5)然后,M3 创建了一个 Manager 对象。使用 new 操作符创建一个类型的实例时,会在堆上创建该类型的一个实例对象。这个实例对象也有 类型对象指针、同步块索引,此外还包含该类及其父类定义的所有实例字段。实际上,在调用类型的构造函数(本质上就是设置实例字段)之前,CLR 会初始化 同步块索引 以及 各个实例字段,将实例字段设为 0 或 nul,并且初始化该对象的 类型对象指针,将其指向对应的 类型对象(见上文第1章)。此时内存状态如下图所示:

         类型对象指针:指向类型对象的存储地址。

         同步块索引:与多线程情况下对象同步机制相关的变量,参考这篇文章

(6)调用静态方法 Lookup(string)。调用静态方法时,CLR会找到该静态方法所属类型的 类型对象,在其方法表中找到该方法,进行JIT编译(第一次调用),再调用编译好的代码。

假设 Lookup("Joe") 返回的是一个经理Manager对象,这时的内存状态为:

注意 e 虽然被声明为 Employee 这个父类,但 CLR 知道它的实际类型是 子类 Manager,该对象的 类型对象指针 指向的也是实际的 Manager类型对象。

(7)执行非虚实例方法 GetYearsEmployed()。调用非虚实例方法时,JIT 会找到 对象(e) 的被使用的类型(即声明的类型,而不是实际类型)对应的 类型对象,再从其方法表中找到该 非虚实例方法。如果从声明类型的方法表中找不到该方法,JIT 会 回溯 类型的层次结构,往上查找,一直找到 Object。之所以能这样回溯,是因为每个类型对象都有一个字段引用了他的父类型对象,这一点在图中没有显示。

本例中,调用非虚实例方法的对象 e 的使用类型(声明类型)是 父类 Employee,JIT 会调用 Employee 的相应方法。

(8)执行虚实例方法:下一行代码调用 Employee 的虚实例方法 GetProgressReport()。调用虚实例方法时,JIT 会在方法中生成一些额外的代码,这些代码会找到 对象e 的实例,然后检查该对象的 类型对象指针,通过该指针找到对象的实际类型,再从实际类型的方法表中查找该方法。

这里,虽然 e 是 Employee 类型,但最终执行的是 Manager 类型的 GetProgressReport 方法。

执行非虚实例方法 和 虚实例方法 查找的类型对象不同,原因在于:虚方法是子类可以重写的,由于可能存在隐式转换,因此应该找到对象的实际类型的方法,才符合虚方法重写的设计理念。

(9)执行完毕,该方法的栈帧将被销毁(栈帧展开),返回值被写入到 调用者方法的栈帧中。

类型对象指针

每个实例对象都有一个类型对象指针,它指向该对象实际的 类型对象(Type object)。上文提到,类型对象 也是个对象,而且本质上是个 System.Type 实例对象。

CLR 在一个进程中开始运行时,会为 System.Type 创建一个特殊的 类型对象,之后所有 类型对象 的 类型对象指针 都会指向它。其实,这个特殊的类型对象自己的 类型对象指针 也指向自己。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章