System.Object
CLR要求每个类型最终都从System.Object类型派生。由于所有类型最终都从System.Object派生,所以每个类型的每个对象都保证了一组最基本的方法。
公共方法 | 说明 |
---|---|
Equals | 如果两个对象具有相同的值,就返回true |
GetHashCode | 返回对象的值的哈希码。如果某个类型的对象要在哈希表集合(例如Dictionary)中作为键使用,类型应重写该方法。方法应该为不同对象提供良好分布(针对所有输入,GetHashCode生成的哈希值应该在所有整数中产生一个随机的分布) |
ToString | 默认返回类型的完整名称(this.GetType().FullName)。经常重写该方法来返回包含对象状态标识的String对象 |
GetType | 返回从Type派生的一个类型的实例,指出调用GetType的那个对象是什么类型。返回的Type对象可以和反射类配合,获取与对象的类型有关的元数据信息 |
受保护方法 | 说明 |
---|---|
MemberwiseClone | 这个非虚方法创建类型的新实例,并将新对象的实例字段设与this对象的实例字段完全一致。返回对新实例的引用 |
Finalize | 在垃圾回收器判断对象应该作为垃圾被回收之后,在对象的内存被实际回收之前,会调用这个虚方法。需要在回收内存前执行清理工作的类型应该重写该方法 |
new
CLR要求所有对象都用new操作符来创建。例如:StringBuilder sb = new StringBuilder("Hello World");以下是new操作符所做事情:
- 计算类型及其所有基类型(一直到System.Object)中定义的所有实例字段需要的字节数。堆上每个对象都需要一些额外的成员,包括“类型对象指针(type object pointer)”和“同步块索引(sync block index)”。CLR利用这些成员管理对象。额外成员的字节数要计入对象大小。
- 从托管堆中分配类型要求的字节数,从而分配对象的内存,分配的所有字节都设为零(0)。
- 初始化对象的“类型对象指针”和“同步块索引”成员。
- 调用类型的实例构造器,传递在new调用中指定的实参(上例就是Hello World)。大多数编译器都在构造器中自动生成代码来调用基类构造器。每个类型的构造器都负责初始化该类型定义的实例。
类型转换
CLR最重要的特效之一就是类型安全。在运行时,CLR总是知道对象的类型是什么。调用GetType方法即可知道对象的确切类型。由于它是非虚方法,所以一个类型不能伪装成另一个类型。
C#不要求任何特殊语法即可将对象转换为它的任何基类型,因为向基类型的转换被认为是一种安全的隐式转换。然而,将对象转换为它的某个派生类型时,只能进行显示转换,因为这种转换可能在运行时失败。例如:
可以发现,a被编译器明确报错警告了,因为编译器知道sb1的类型是StringBuilder类型,不属于A类或者A派生的任意类型。b则被编译器忽视了,因为sb2是一个Object,运行时CLR才会检查类型转换,发现sb2引用一个StringBuilder,不属于A类或者A派生的任意类型。此时CLR会禁止转型,并抛出System.InvalidCastException异常。
如果CLR允许这样的转型,就毫无类型安全性可言了,将出现难以预料的结果。类型伪装是许多安全漏洞的根源,它还会破坏应用程序的稳定性和健壮性。因此,类型安全是CLR极其重要的一个特点。
is和as
is检查对象是否兼容于指定类型,返回Boolean值true或false。注意,is操作符永远不会抛出异常。具体用法:
using System;
using System.Text;
public class Program
{
class A { }
static void Main(string[] args)
{
Object sb1 = new StringBuilder();
// 第一种使用方法
if(sb1 is A)
{
A a1 = (A)sb1;
}
// 第二种使用方法
if(sb1 is A a2)
{
// ...
}
}
}
CLR的类型检查增强了安全性,但无疑会对性能造成一定影响。这时因为CLR首先必须判断变量引用的对象的实际类型。然后,CLR必须遍历继承层次结构,用每个基类型去核对指定的类型。
as操作符的工作方式与强制类型转换一样,只是它永远不抛出异常---相反,如果对象不能转型,结果就是null。
public class Program
{
class A { }
static void Main(string[] args)
{
Object sb1 = new StringBuilder();
var a = sb1 as A;
if(a != null)
{
// ...
}
}
}
命名空间和程序集
对于编译器,命名空间的作用就是为“类型”名称附加以句号分割的符号,使名称变得更长,更可能具有唯一性。其次,命名空间能对相关的类型进行逻辑分组,开发人员可通过命名空间方便地定位类型。例如,System.Text命名空间定义了执行字符串处理的类型,而System.IO命名空间定义了执行I/O操作的类型。
检查类型定义时,编译器必须知道要在什么程序集中检查。这通过/reference编译器开关实现。编译器扫描引用的所有程序集(通过代码中使用的'using'语句或者直接使用的类型来触发),在其中查找类型定义。一旦找到正确的程序集,程序集信息和类型信息就嵌入生成的托管模块的元数据中。这样,当允许时加载这个模块时,它可以知道需要加载哪些外部程序集以及如何找到这些类型。(在一,二章有提及,可以用ildsam.exe查看元数据,能够看看引用元数据表中有这些类型和方法的元数据)。
using
C#编译器通过using指令,来方便程序员在使用类型时可以减少打字量,例如,下面2种写法是等价的:
using System.IO;
using System.Text;
public class Program
{
static void Main(string[] args)
{
FileStream fs = new FileStream();
StringBuilder sb = new StringBuilder();
}
}
public class Program
{
static void Main(string[] args)
{
System.IO.FileStream fs = new System.IO.FileStream();
System.Text.StringBuilder sb = new System.Text.StringBuilder();
}
}
如果编译器在源代码文件或者引用的任何程序集种找不到指定名称的类型,就会在类型名称前附加System.IO前缀,检查这样生成的名称是否与现有类型匹配。如果仍然找不到匹配项,就继续为类型名称附加System.Text.前缀。在前面例子中的两个using指令的帮助下,只需在代码中输入FileStream和StringBuilder这两个简化的类型名称,编译器就会自动将引用展开成System.IO.FileStream和System.Text.StringBuilder。这样不仅能极大减少打字,还增强代码可读性。
编译器对待命名空间的方式存在潜在问题:可能两个(或更多)类型在不同命名空间中同名。虽然Microsoft强烈建议开发人员为类型定义具有唯一性的名称。但有时非不为也,是不能也。例如我以下2个类型,分别来自不同的插件,它们都包含了ABC类。这时我们在使用ABC类无法在不添加完全限定名的前缀下使用。
namespace namespace1
{
public class ABC { }
}
namespace namespace2
{
public class ABC { }
}
幸好的是,C# using指令的另一种形式允许为类型或命名空间创建别名。如果只想使用命名空间的少量类型,不想它 的所有类型都跑出来“污染”全局命名空间,别名就显得十分方便。
using n1ABC = namespace1.ABC;
using n2ABC = namespace2.ABC;
public class Program
{
static void Main(string[] args)
{
n1ABC n1 = new n1ABC();
n2ABC n2 = new n2ABC();
}
}
这些消除类型歧义的方法都十分有用,但我们在更进一步,假如不幸的是,在A程序集中有NS.ABC类型,在B程序集也有NS.ABC类型,上面的手段就通通失效了,幸好,C#编译器提供了名为外部别名(extern alias)的功能。
假设你有2个dll文件“LibraryA.dll”和“LibraryB.dll”,它们都有一个相同的类型“Namespace.ClassName”,引用这2个dll后,我们可以在项目文件(.csproj)中为每个引用的程序集指定别名。
<ItemGroup>
<Reference Include="LibraryA">
<Aliases>LibA</Aliases>
</Reference>
<Reference Include="LibraryB">
<Aliases>LibB</Aliases>
</Reference>
</ItemGroup>
然后在代码文件的顶部使用“extern alias”指令来引用这些别名。
extern alias LibA;
extern alias LibB;
using System;
class Program
{
static void Main(string[] args)
{
LibA::Namespace.ClassName objA = new LibA::Namespace.ClassName();
LibB::Namespace.ClassName objB = new LibB::Namespace.ClassName();
Console.WriteLine("Using two different versions of the same library.");
}
}
命名空间和程序集的关系小结
命名空间和程序集不一定相关。特别是,同一个命名空间中的类型可能在不同程序集中实现。例如,System.IO.FileStream类型在MSCorLib.dll程序集中实现,而System.IO.FileSystemWatcher类型在System.dll程序集中实现。
同一个程序集也可能包含不同命名空间中的类型。例如,System.Int32和System.Text.StringBuilder类型都在MSCorLib.dll程序集中。
运行时的相互关系
图4-2展示了已加载CLR的一个Windows进程。该进程可能有多个线程。线程创建时分配到1MB的栈。栈空间用于向方法传递实参,方法内部定义的局部变量也在栈上。图4-2展示了线程的栈内存(右侧)。栈从高位内存地址向低位内存地址构建。图中现在已执行了一些代码,栈上有了一些数据(阴影部分)。现在,假定线程执行的代码要调用M1方法。
最简单的方法包含“序幕”(prologue)代码,在方法开始工作前对其进行初始化;还包含“尾声”(epilogue)代码,在方法做完工作后对其进行清理,以便返回至调用者。
序幕(Proloue):
- 保存上下文:保存当前的寄存器状态和返回地址,以确保函数执行完毕后能够正确返回调用点。
- 分配栈空间:为局部变量和其他临时数据分配必要的栈空间。
- 传递参数:将参数传递给函数。
- 初始化变量:初始化函数内部使用的局部变量。
尾声(Epilogue):
- 恢复上下文:恢复调用前的寄存器状态和基址指针。
- 释放栈空间:释放函数使用的栈空间。
- 返回调用者:将控制权返回给调用该函数的代码段。
在高级语言中,如C#语言,序幕和尾声代码都由编译器自动生成,汇编则需要手动实现。
M1方法开始执行时,它的“序幕”代码在线程栈上分配局部变量name的内存。如图4-3所示。
然后,M1调用M2方法,将局部变量name作为实参传递。这造成name局部变量中的地址被压入栈。M2方法内部使用参数变量s标识栈位置。另外,调用方法时还会将“返回地址”压入栈。被调用的方法在结束之后应返回至该位置。(图4-4)
M2方法开始执行时,它的“序幕”代码在线程栈中为局部变量length和tally分配内存,然后,M2方法内部的代码开始执行。最终,M2抵达它的return语句,造成CPU指令指针被设置成栈中的返回地址,M2的“栈帧展开”。恢复成图4-3的样子,之后M1继续执行M2调用之后的代码。
最终,M1会返回到它的调用者。这同样通过将CPU的指令指针设置成返回地址来实现(这个返回地址在图中未显示,但它应该刚好在栈中的name实参上方),M1的栈帧展开,恢复成图4-2。
围绕CLR讨论
假设有以下2个类定义:
class Employee
{
public int GetYearsEmployed() { }
public virtual string GetProgressReport() { }
public static Employee Lookup(string name) { }
}
class Manager : Employee
{
public override string GetProgressReport() { }
}
Windows进程已启动,CLR已加载到其中,托管堆已初始化,而且已创建一个线程(连同它的1MB栈空间)。线程已执行一些代码,马上要调用M3方法。
JIT编译器将M3的IL代码转换成本机CPU指令时,会注意到M3内部引用的所有类型,包括Employee,Int32,Manager以及String(因为"Joe")。这时CLR要确认定义了这些类型的所有程序集都已加载。然后,利用程序集的元数据,CLR提取与这些类型有关的信息,创建一些数据结构来表示类型本身。如图4-7所示,假设Int32和String类已创建好,这里不做演示。
如前面所言,堆上所有对象都包含两个额外成员:类型对象指针(type object pointer)和同步块索引(sync block index)。定义类型时,可以在类型内部定义静态类型字段。为这些静态数据字段提供支援的字节在类型对象自身中分配。还包含1,2章提到的方法表,Employee类型定义了3个方法(GetYearsEmployed,GetProgressReport和Lookup),所以Employee的方法表有3个记录项。同理,Manager类型只有1个记录项。
当CLR确认方法需要的所有类型都已创建,M3的代码已经编译之后,就允许线程执行M3的本机代码。M3的“序幕”代码执行时必须在线程栈中为局部变量分配内存,其次,还会自动将所有局部变量初始化为null或0。
然后,M3执行代码构造一个Manager对象。这造成在托管堆创建Manager类型的一个实例(对象)。生成的对象同样包含类型对象指针和同步块索引,除此之外,还包含必要的字节来容纳Manager类型及其基类(Employee和Object)定义的所有实例字段。任何时候在堆上新建对象,CLR都自动初始化内部的“类型对象指针”成员来引用和对象对应的类型对象(本例就是Manager类型对象)。此外,在调用类型的构造器之前,CLR会先初始化同步块索引,并将对象的所有实例字段都设为null或0。new操作符返回Manager对象的内存地址,该地址保存到变量e上(e在线程栈上)。
M3的下一行代码调用Employee的静态方法Lookup。调用静态方法时,CLR会定位与定义静态方法的类型对应的类型对象。然后,JIT编译器在类型对象的方法表中查找与被调用方法对应的记录项,对方法进行JIT编译(如果要的话,详情看第1章),再调用JIT编译好的代码。
本例假定返回的对象实际类型是一个Manager,Lookup函数结束后,返回该对象的地址,并将该地址保存到局部变量e中。注意,e不再引用第一个Manager对象(由于没有任何变量引用,它将是GC的主要目标)。
M3的下行代码调用Employee的非虚实例方法GetYearsEmployed。调用非虚实例方法时,JIT编译器会找到与“发出调用的那个变量(e)的类型(Employee)”对应的类型对象(Employee类型对象)。这时的变量e被定义成一个Employee。如果Employee类型没有定义正在调用的那个方法,JIT编译器会回溯类层次结构(一直回溯到Object),并在沿途的每个类型中查找该方法。(之所以能回溯,是因为每个类型对象中都有一个字段引用了它的基类型,base).
然后,JIT编译器在类型对象的方法表中查找引用了被调用方法的记录项,对方法进行JIT编译(如果用的话),再调用JIT编译好的代码。
M3的下行代码调用Employee的虚实例方法GetProgressReport。调用虚实例方法时,JIT编译器要在方法中生成一些额外的代码:方法每次调用都会执行这些代码。这些代码首先检查发出调用的变量,并跟随地址来到发出调用的对象。变量e当前引用是Manager对象。然后,代码检查对象内部的“类型对象指针”成员,该成员指向对象的实际类型(Manager)。然后,代码在类型对象的方法表中查找引用了被调用方法的记录项,对方法进行JIT编译(如果要的话),再调用JIT编译好的代码。
类型对象
上例中Employee和Manager类型对象都包含“类型对象指针”成员。这时由于类型对象本质上也是对象。CLR创建类型对象时,必须初始化这些成员。
CLR开始在一个进程中运行时,会立即为MSCorLib.dll中定义的System.Type类型创建一个特殊的类型对象。Employee和Manager类型对象都是该类型的“实例”。因此,它们的类型对象指针成员会初始化成对System.Type类型对象的引用。
当然,System.Type类型对象本身也是对象,内部也有“类型对象指针”成员。这个指针指向本身,因为System.Type类型对象本身也是一个类型对象的“实例”。
再提一句,System.Object的GetType方法返回存储在指定对象的“类型对象指针”成员中的地址。也就是说,GetType方法返回指向对象的类型对象的地址。