一名.NET程序员给我发了一封邮件,讨论C#2.0的对象模型:
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
金老师:
您好!
我是一名.net程序员。拜读了您的“.net 2.0面向对象编程揭秘”这本书,受益匪浅。
您的书写得非常深入,经常让我有恍然大悟的感觉。很多堆积已久的问题迎刃而解。同时我也有了很多疑问。希望您能帮我解答。
1.您说子类会调用父类的构造函数。同时“子类对象集成了基类的实例字段”(P213)。“基类的实例字段”包括父类的private字段么?
2.Son son=new Son();
Father father=son;
这是您介绍的多态编程。子类实例变量赋值给父类变量。
这样的两句代码,在内存中将会发生什么呢?
对象的实例没有改变么?faher和son存的是同一个首地址?
这时father能调用的都是Father类型的字段和方法。那么father的类型表指针应该指向Father类型表,这样可以解释father调用父类的方法。那么字段呢?
father is Son
这又是如何实现的呢?
3.值类型在编译期已经在栈上分配好了内存
编译期还是IL指令,还没有转化为cpu可执行的二进制代码。这时内存是如何分配好的?
编译期为什么要分配内存呢?不是应该运行的时候才需要么?
4.您的书中在说明IL代码的时候多次提及计算堆栈。
您能把IL代码执行的基本原理告诉我吗?或者从哪里可以看到相关的资料。
5.对象实例存储在堆中,那么对象变量呢?是存储在线程堆栈中么?
同时我也发现了您的一个疏漏。
第183页 第10行 首先调用父类构造函数,再调用子类构造函数。
第211页 第6行 在构造函数中,先初始化自身的字段,在调用基类的构造函数。
这两句表达有误。
应该是:
先调用子类构造函数,通过子类构造函数调用父类构造函数。先执行父类构造函数的代码,初始化父类的字段,再回到子类初始化子类的字段。
我编写了这样两个简单的类
public class Father
{
protected string a;
public Father()
{
a = "a";
}
}
public class Son : Father
{
private string b;
public Son()
{
b = a;
}
}
子类的构造函数IL代码如下:
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
// 代码大小 17 (0x11)
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void ConsoleApplication1.Father::.ctor()
IL_0006: nop
IL_0007: nop
IL_0008: ldarg.0
IL_0009: ldc.i4.1
IL_000a: stfld int32 ConsoleApplication1.Son::b
IL_000f: nop
IL_0010: ret
} // end of method Son::.ctor
这段IL代码好很的证明了我的观点。
希望您能耐心的解答我的问题。谢谢
++++++++++++++++++++++++++++++++++++++++++
我的回复如下:
-----------------------------
1 “基类的实例字段”包括父类的private字段,但子类方法不能存取这个字段,这是由编译器在生成IL指令时保证的。
2 Son son=new Son();
Father father=son;
实际上,在内存中只有一个Son对象实例(放在堆中),但存在两个类型表(由CLR直接管理,当卸掉程序集时,这些类型表占用的资源被回收),赋值后,father和son存的是同一个首地址,指向在托管堆中的Son对象实例。
father is son不是在程序运行时实现的,而是在编译时实现的,由C#编译器直接将这个代码翻译为IL指令,此IL指令会根据你的代码生成对合适方法的调用指令。
当程序运行时,CLR直接装入的是翻译好的IL指令,而非C#代码,IL指令本身是没有“多态”特性的,因为它已经比较靠近底层,应该尽可能地简化以提高效率。
3 值类型在编译期已经在栈上分配好了内存
这句是错的,变量的内存分配是在程序运行时才进行的。是我的疏漏。
4 CLR可以看成是一个基于堆栈的虚拟计算机,这台机器运行的是IL汇编程序,凡是IL代码中所说的堆栈,都是指“计算堆栈(Evaluation Stack)”。在书的附录中有一个“MSIL基础教程”,其中介绍了相关的原理。有关IL的资料很少,国内可以看到的书就是《Inside Microsoft .NET IL Assembly》,千万别看中文版,我看译者肯定没弄明白其中的技术内容,译得一踏糊涂。要看就看英文版,但这本书阅读难度很大,作者是技术牛人,但作为一名作家,我认为不合格。本书中有关IL编程的介绍是我经过收集相关资料进行消化,并经实践检验之后写的,但管中窥豹,仅供参考。
5.你说得对:对象实例的数据存储在托管堆中,引用此对象的对象变量则是存储在线程堆栈中。
关于子类字段与父类字段初始化顺序的问题,我注意到你在子类构造函数中使用了基类的数据成员,因此才会导致先初始化基类数据成员,后初始化子类数据成员。这是一种特例。
事实上,如果子类字段与父类字段没有这种依存关系,C#编译器是按照以下顺序生成IL指令的:
new 子类对象时,子类构造函数被调用,在执行子类构造函数的代码时,先初始化子类的字段,然后再调用父类的构造函数初始化父类的字段。
如果子类字段依赖于父类字段的值,C#编译器在生成IL指令时,会先调用父类的构造函数初始化父类的字段,再调用子类构造函数初始化依赖于父类字段的这些字段。
我修改了一下你的代码,给子类和父类增加两个独立的字段:
public class Father
{
protected string a;
public int fatherFld=100;
public Father()
{
a = "a";
}
}
public class Son : Father
{
private string b;
private int sonFld = 200;
public Son()
{
b = a;
}
}
子类生成的IL代码如下:
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
// 代码大小 33 (0x21)
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldc.i4 0xc8
IL_0006: stfld int32 ConsoleApplication1.Son::sonFld
IL_000b: ldarg.0
IL_000c: call instance void ConsoleApplication1.Father::.ctor()
IL_0011: nop
IL_0012: nop
IL_0013: ldarg.0
IL_0014: ldarg.0
IL_0015: ldfld string ConsoleApplication1.Father::a
IL_001a: stfld string ConsoleApplication1.Son::b
IL_001f: nop
IL_0020: ret
} // end of method Son::.ctor
可以看到IL_0006句先初始化子类的字段,IL_000c再调用父类的构造函数初始化父类字段,再回过头来于IL_0015和IL_001a两句完成用父类字段初始化子类字段的工作。
父类构造函数IL代码如下:
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
// 代码大小 29 (0x1d)
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldc.i4.s 100
IL_0003: stfld int32 ConsoleApplication1.Father::fatherFld
IL_0008: ldarg.0
IL_0009: call instance void [mscorlib]System.Object::.ctor()
IL_000e: nop
IL_000f: nop
IL_0010: ldarg.0
IL_0011: ldstr "a"
IL_0016: stfld string ConsoleApplication1.Father::a
IL_001b: nop
IL_001c: ret
} // end of method Father::.ctor
注意一下基类object构造函数的调用是插在两个字段初始化指令中间的。我经过实验发现,C#编译器生成IL代码时对于字串类型字段的初始化总是在调用基类构造函数之后,而象int之类的字段,如果是独立的,其初始化指令总在调用基类构造函数指令之前。
为什么这样,只好去问问C#编译器的设计者了。
----------------------------
欢迎就此问题进行讨论。