从编译的角度看对象

— Java语言描述

前言

我刚开始学Java的时候总觉得面向对象很神秘,摸不透。后来学习编译的时候,发现如果从底下往上

看,透过对象直接看汇编,看内存,一切都很清楚了。我这篇文章不是写给编程初学者看的,我假设

1. 你已经至少熟悉一种面向对象的语言(比如Java)并能熟练运用<!--[endif]-->

<!--[if !supportLists]-->2. 你对编译和操作系统的原理有基本的认识(起码知道函数调用栈,堆是什么吧)<!--[endif]-->

<!--[if !supportLists]-->3. 最好能熟悉Java,因为我全文都用Java语言描述<!--[endif]-->

面向对象有三大特征,封装(encapsulation),继承(inheritance),多态(polymorphism),

我将从编译的角度来着重分析继承和多态,封装性也会附带讨论一些。

从编译的角度看对象

— Java语言描述

第一节:类和对象,到底是什么?

计算机里的任何东西到了最后都是0101,类和对象也不例外。我们没有必要从机器码的层面来考察对象(也无法考察),先来看看类和对象在内存里究竟是什么。类定义好了以后先被编译

然后执行的时候被装入内存,在内存中的表示如下

说说对java对象的理解(从编译的角度看对象)(1)

这个内存结构相当重要,面向对象特性的实现就靠它了。每个Class都有一个Class Descriptor记录了这个类的所有信息,包括静态字段(Static fields),静态方法入口(static method),动态字段(dynamic field),动态方法入口(dynamic method),父类指针(super pointer)。Class Descriptor有可能是放在.text段中的,不过考虑到static field也是可写的,我认为放在heap里面比较合理。所有的方法都是可执行代码,

所以会被编译到代码段中。Descriptor并不包含任何函数的代码只包含指针指向属于这个类的函数列表。这

个函数列表也不包含函数的代码而是列出了所有函数的代码段地址。程序执行的时候会先找到这个函数列

表,然后通过[offset]找到特定函数的地址,然后跳到那个地址去执行代码。

类分析完了,下面来看看对象(也称为实例,Object)。类是模板,对象就是用这个模板创造出来的实

体。所有static的东西都是属于类的,所以对象不必关心。对象里面保存了动态字段和类指针(class pointer)。这里不太确定的是动态方法。既然类描述符里面已经了有动态方法列表的地址了,那么通过class pointer先找到class descriptor,然后就知道动态方法列表的地址了。的确是这样,不过出于性能考虑

我认为对象也会保存一份动态方法列表的地址。看到这里你可能会问,为什么Class Descriptor要保存动态

字段和动态方法入口呢,这些东西不是在实例里面都有吗?别急,我后文会有解释的,主要是为了方便子类调用父类的成员。

都说Java里面没有指针,其实是有的,只不过功能被弱化了,改称reference。首先可以肯定的是对象是放在堆上而不是栈上,如果没有没有指针的话程序运行的时候怎么拿到对象的数据呢?Java里面新建一个对象,和C里面malloc一块内存是差不多的,在堆上申请一块空间,返回一个指向这块空间的指针,保存

在栈上。我后面的讨论把reference或者指针都称作指针。

从编译的角度看对象

— Java语言描述

第二节:继承是如何实现的?

继承可以说是面向对象的精华之所在,有点玄。从我学Java那天起,我受到的教育是新建一个对象的

时候,所有他的父类都会被创建一遍,所以它拥有父类所有的字段和方法(我们先不谈private,public之

类,后文再解释)。这个说法其实是不确切的。我估计当时老师是为了方便我们理解才这么说的。严格来

说,一个对象被创建完以后,就只有它自己,没有他的爸爸,更没有爷爷。祖祖辈辈能被继承的东西都融

合到一个对象里面了,就是被创建的那个。先来看一段代码:

class S{

int a = 1;

int f(){

return a;

}

}

class A extends S{

}

class B extends S{

int b = 10;

int g(){

return b;

}

}

class C extends B{

int a = 2;

int f(){

return a b;

}

}

如果为上面的每个类都生成一个实例的话,它们的内存表示是这样的:

说说对java对象的理解(从编译的角度看对象)(2)

从上面的图中我们可以发现,字段是不能覆盖的,也就是如果子类声明了一个和父类中同名的字段,那么

这两个字段在子类的对象中都存在。而方法是可以覆盖的,如果子类的方法和父类中的某个方法的签名

(方法名,参数列表)一样的话,子类的方法的地址会替代父类方法的地址出现在子类的动态方法列表

中。为什么?直观的来说,字段就像是长相,继承下来就不能改了,而方法就像是思想,能用前人的,也

能用自己创新的。虽然这个比喻有点牵强,暂且凑或吧,详细的以后再说。图中的同名字段和方法前面加

了前缀,以示区别。在创建对象的时候,程序会先找父类,为父类的字段先申请空间,父类也会找父类的

父类,直到Object,这是一个递归的过程。这样创建的对象就包括了所有父类的字段,而且是按顺序排列

的,Object的字段在最前面。这个顺序很重要!后文会有解释。整个对象的创建是发生在运行的时候,而动

态方法列表的创建是发生在编译阶段。创建对象的时候只是添加了一个指针,指向早已存在的方法列表的

地址(这个东西是和类一起被装载进来的,所以一定先于对象存在)。我无法清楚的解释编译的时候是如

何生成方法列表的,这个问题很复杂。编译的时候有完整的代码树,想干啥都行。大体来说,父类一定是

先于子类编译的,在生成子类方法列表的时候,先copy父类的,然后和父类的方法比较一下,添加新的方

法,覆盖相同签名的方法。值得一提的是,和动态字段一样,方法列表也是有顺序的,父类的方法一定在

前面。Static的方法比较特殊,它们不能被覆盖,why?请允许我再次卖个关子。继承的本质就是这样了,虽然有很多问题暂时无法解释。现在基本能够解释为什么子类能调用父类的方

法,为什么父类不能调用子类的方法,为什么子类能用父类的字段等等。原因很简单啊,因为子类把父类

的东西统统复制了一遍,而父类根本不知道子类的存在。

从编译的角度看对象

— Java语言描述

第三节:构造函数究竟干了些什么?

大家都知道,对象是由构造函数创造的,那其中的过程是什么样的呢?写一段代码,单步跟踪执行一下

就全都出来了。例如下面一段代码:

class G{

int x = 0; // sentence 1

}

class T extends G{

int x = 21; // sentence 2

int y = 1; // sentence 3

public T(){

super(); // sentence 4

x = 22; // sentence 5

}

public static void main(String[] args) {

G g = new G(); // sentence 6

T t = new T(); // sentence 7

G t2 = new T(); // sentence 8

System.out.println(g.x); // sentence 9

System.out.println(t.x); // sentence 10

System.out.println(t2.x); // sentence 11

}

}

运行结果如下:

0

22

0

根据第三节中讲到的继承的原理,我们来跟踪一下程序每一步的执行。第一个被执行的是sentence 6,此时

JVM还不知道有G这个class,所以它会去classpath里面找,找到以后把G装载进来,这样我们就有了G的

class descriptor。然后去找G的构造函数,由于我们没有定义自己的构造函数,所以编译器用默认构造函数

替代了,这样G的默认构造函数就被调用。那这个默认构造函数做了些什么呢?第一件事就是找父类(因为

父类的字段必须得先构造),这里就是Object了。如果你单步跟踪的话你会发现跳到Object里面去了。在

Object里面做了什么呢?Object一个字段都没有,又跳回来了,执行sentence 1,创建一个int字段,也就是

在堆空间里面分配了一个int的空间,并把值赋为0。到这里,G的构造就完成了,返回刚刚分配的堆空间地

址给g,g是在栈上的。

下面执行sentence 7。类似的,JVM去找T,发现没有被装载,于是装载之。原本还要装载G的,由于前

面已经装载过了,所以这里就不用了。我们自己定义了构造函数,所以就不用默认的了。单步跟踪的话会

发现程序跳到了sentence 4,而不是去找父类的构造函数。为什么呢?因为sentence 4就是调用父类构造函

数啊!试着把这句话注释掉,重新执行,嘿嘿,有趣的事情发生了,程序不是先执行sentence 5,而是跳到

父类构造函数去了。由此可见,如果我们没有显示的调用父类构造函数的话,编译器会在T构造函数的第一

句,为我们自动加“super()”。这样就可以解释为什么父类的构造函数必须在子类构造函数的第一句被调

用。不明白?好吧,我再解释一下。因为父类的东西必须被先构造(要保证对象的字段在堆里面的顺

序),如果父类的构造函数不先执行的话,这个如何保证?好了,我们接着执行。这里又有一个有趣的事

情,sentence 4执行完毕以后,不是执行sentence 5,而是sentence 2!然后是sentence 3,最后才是

sentence 5。从结果来看,很容易解释,因为sentence 2和3是声明成员变量,而sentence 5是对成员变量赋

值,声明当然要在赋值前面。我们可以这么理解,Java在编译的时候,总会生成一个基本构造函数,这个

基本构造函数包含了成员变量的声明语句。而一个完整的构造函数事实上有三部分组成,第一部分调用父

类的构造函数,第二部分调用基本构造函数,第三部分就是我们写的构造函数代码了。这三个部分是严格

安顺序执行的。这样T就构造好了。

接下来来执行sentence 8,构造的过程和sentence 7完全一样。唯一不一样的就是最后的那个指针t2。其

实计算机里面的指针都是一样的(32位机就是4个byte的一块空间,保存了一个地址),这一点也可以从

Java里面类型的强行转换看出来,如果不一样的话,强行转换就不好实现了。所有的不一样都是编译时造

成的,编译时会检查指针的类型,不符合要求的会报错,以保证程序的正确性。换句话说,如果你能通过

种种手段骗过编译器,让int指针指向一个String也是没有问题的。至少这么做对C没有任何问题,Java可能

有问题,因为Java有运行时类型检查。这里Sentence 8返回的是一个T的指针,而t2的类型是G,因为G是T

的父类,根据Java的规则,父类的指针可以指向子类对象,所以这里没有问题。

到这里为止,所有对象构造完毕。栈上多了三个指针,堆里面多了三个对象。值得一提的是两个x,虽然

它们名字相同,但是子类的x不会覆盖父类的x,这个我们在第三节已经提到过了(别急,我马上就要解释这

个问题了)。可以想象到的是,编译好以后,这两个x的名字一定是不一样的或者说根本不存在什么名字,

只有[offset]。现在内存中的情况差不多是这样的

说说对java对象的理解(从编译的角度看对象)(3)

下面来分析一下输出的结果,第一个0一定没有什么问题。问题出在sentence 10和11上。我们已经知

道T有两个x,我们暂且称他们为G_x(图中标为x)和T_x。先看sentence 5,这里的x是哪个x?根据就近原则

(这个是规定,没有什么为什么),T要用x首先用的是T_x,所以这里的x是T_x。根据输出的结果,我们可

以看出,sentence 10的x是T_x,sentence 11的x是G_x。为了解释这个问题,我们得先解释为什么对象的

构造一定要严格按照父类在先,子类在后的顺序。在编译父类的时候,它并不知道子类的存在,所以父类

一定只知道G_x。我们假设G_x在父类对象中的offset是0。那么父类要用x就是去offset为0的地方去找。所

以在sentence 11中,虽然t2指向的实际上是T的实例,但是t2并不知道T的实例的内存结构,它只知道G的

实例的内存结构。现在编译器要用x,t2根据G的定义知道在 offset为0的地方有个x,于是就用它了。让我

们试想一下,如果对象的创建不按顺序来的话,这里offset为0的地方就不知道是什么东西了。所以对象的

创建顺序保证了父类声明的成员变量在子类中的offset是一致的,也就保证了父类指针在指向子类对象的

时候能够正常的工作。这可是面向对象的一大特点啊,和后面要讲的多态有异曲同工之处。理解了这个以

从编译的角度看对象

— Java语言描述

第四节:动态绑定三部曲

现在轮到我们的重头戏出场了,动态绑定。动态绑定又称为多态,是面向对象一个非常重要的元素,很多设计模式都是建立在动态绑定的基础上的。我将分三个部分来讲这个问题,第一部分是字段的绑定,第二部分是动态方法绑定的实现原理,第三部分谈谈动态绑定相关的优化。

<!--[if !supportLists]-->1. 字段的绑定

“字段绑定”这个说法是我自己想出来的,用来描述父类和子类有相同名字的字段时的情况。其实在

上一节“构造函数到底干了些什么”中已经提到了一些。先来看下面的一段代码:

class P{

int a = 1;

public void f(){

System.out.println(a);

}

}

class Q extends P{

int a = 2; // sentence 1

public static void main(String[] args) {

new P().f();

new Q().f();

}

}

先不谈执行结果,我们来看看Java是如何编译这段代码的。我们在前几节已经提到过,父类一定在子类之

前编译,所以在P编译的时候Java对Q一无所知。所以在编译方法f的时候这个a一定是P里面的a。如果不考

虑中间代码,我们假设Java直接编译成汇编代码,那么方法f里面一定会用到变量a的地址,比如说在offset

为0的地方。编译好以后,方法f的汇编代码就定下来了,是死的。由于Q没有覆盖方法f,所以Q和P调用的

是同一个f,也就是执行的同一段汇编。根据继承的原理,P和Q在offset为0的地方都有一个a且它们的值为

1,所以这段代码的输出是

1

1

这也解释了为什么对象实例化一定要按顺序进行:方法编译好以后就是死的,必须在运行的时候保证方法

中用到的成员变量出现在编译时候的那个地址,否则子类就没法执行父类的方法了!这样的话字段绑定就

遵循以下两个基本原则:

<!--[if !supportLists]-->a) 父类的方法或者父类的指针只会引用父类中定义的字段<!--[endif]-->

<!--[if !supportLists]-->b) 子类覆盖父类的方法,或者子类自定义的方法,或者子类指针直接引用,优先使用子类自己定义的

的字段(就近原则,上一节中有说明)。<!--[endif]-->

你一定在想,如果字段可以覆盖的话,这个问题不就解决了吗?非也!如果父类的字段可以被覆盖的话,

那么上面那段代码中的sentence 2就没有起到声明一个变量的作用(没有新请求一块空间),只是改变了原

有变量的值,这明显不符合逻辑嘛。

<!--[if !supportLists]-->2. 动态方法绑定的原理

按照第二节中讲的继承的本质,我们可以知道子类的方法是可以覆盖父类的方法的(不同于字段)。

方法的覆盖很简单,只要把method list中方法代码的地址改掉就可以了。所以,覆盖了父类的方法并不是

父类的方法不存在了,它和子类的方法代码同时存在于代码段中,我们可以super关键字来指明调用父类的

方法。只是子类的method list不再指向父类的方法而已。需要注意的是static,final,private的方法不

能被覆盖,我后面会解释的。下面来分析方法绑定和执行的过程。方法调用的基本过程是这样的,

object.method()这句调用会先去找到object,然后找到object的method list,然后根据函数method的

offset找到函数method的代码段地址。请看下面的代码:<!--[endif]-->

class G{

int x = 1;

int m1(){

return x m2();

}

int m2(){

return 1;

}

public static void main(String[] args){

T t = new T();

G g = new T();

System.out.println(t.m1()); // sentence 1

System.out.println(g.m1()); // sentence 2

System.out.println(g.m2()); // sentence 3

}

}

class T extends G{

int x = 21;

int m2(){

return x;

}

}

运行结果是

22

22

21

能想通吗?先来看G和T编译完以后都是什么样子的。由于G是父类,不知道T的存在,所以一切都很简单。

我们假设G编译好以后方法列表有n个方法,m1在列表的第三个,m2在列表的第四个。编译T的时候,编译

器先copy了一份G的method list,然后编译器发现T里面的m2和父类的签名相同(我们称这两个方法分别为

G_m2和T_m2),于是把method list里面第四条的内容替换为T_m2的地址。所以,m2虽然被覆盖了,但是

和m1一样,在method list里面的[offset]是和G相同的!这里同样涉及到字段绑定的问题,想不清楚的话回过

头看看前面的内容,我不重复了。

下面来分析运行时的情况。执行sentence 1的时候JVM根据t拿到T的method list,然后调用m1,由于T没

有覆盖m1,所以跳到G定义的m1去执行,执行的时候要用x,由于是G定义的函数,所以用G定义的x,值是

1。接下来m1要调用m2,编译m1的时候编译器知道在method list的第四条有m2的代码地址,于是去第四条

拿地址跳过去执行。问题是,这里的method list是G的呢还是T的。我们前面讲过新建一个子类对象的话内

存中只有子类对象,没有爸爸也没有爷爷,所以这里根据t拿到的method list当然是T的啊!所以JVM拿到的

m2的地址是T_m2的地址。既然是T_m2那么用到的x自然是T的x,值是21。所以sentence 1的输出是22。类

似的,sentence 2只不过把指针的类型换了,对象还是T的对象,整个执行的过程和sentence 1一摸一样,

结果当然一样。如果你上面的都理解了,sentence 3就很容易了。

现在来总结一下。在分析到底用了子类还是父类的方法或字段的时候只要牢记当前的对象究竟是子类对

象还是父类对象,不要被指针的类型迷惑。所谓的动态方法绑定也就是这样,编译的时候不是直接写入方

法的入口地址,而是只给对象的地址,留着空间来做动态绑定:具体的方法地址,在运行的时候再确定。

父类的指针只管去method list中特定的offset调用方法,比如例子中method list第四条的m2,至于这个list是

不是被修改过,这个方法究竟是谁实现的根本不在考虑的范围之内。而子类覆盖父类方法时offset的一致性

保证了这一点可以实现。

下面来解释一个以前遗留的问题:为什么Class Descriptor要保存动态字段和动态方法入口?在Java里

面,如果子类和父类的字段重名或者方法覆盖的话,通过加super关键字可以明确指定调用父类的成员。根

据字段绑定,和方法绑定的原理可以知道,子类虽然有父类的字段,但是却无法被调用到,并且父类的方

法根本就不在子类的method list里面,所以在对象这一级别上是无法实现这个功能的。怎么办?只能去找到

class descriptor,因为它包含了这个类的所有信息。在出现super调用的时候,编译器会根据当前的对象找

到当前的类,然后找到当前类的父类,父类的class descriptor保存了父类字段的offset和method list地址,这

样就通了。

<!--[if !supportLists]-->3. 动态绑定的优化

编译优化是一个很大的问题,我只关注一小点,动态绑定转静态。动态绑定虽然很强大,但是速度却

变慢了。要调用一个方法可不容易啊,绕了一大圈。而以前面向过程的语言,比如C,调用方法很简单,在

调用的地方直接写入被调用的方法地址,执行的时候直接跳过去,快很多,这些函数可以被看作是静态

的,因为它的地址是不变的。Java的编译器是很强大的,在不需要动态绑定的地方会自动的用静态的地址

来替换,看下面一段代码:

class C1{

int m1(){

return 1;

}

public static void main(String[] args){

boolean f = true;

C1 x; // sentence 1

if (f) {

x = new C2(); // sentence 2

}

else{

x = new C3(); // sentence 3

}

System.out.println(x.m1()); // sentence 4

}

}

class C2 extends C1{

int m2(){

return 2;

}

}

class C3 extends C1{

int m3(){

return 3;

}

}

class C4 extends C1{

int m1(){

return 4;

}

}

上面的四个类很简单,我不做分析。来看看main函数里面的情况。Sentence 1声明了一个C1的指针x,然后

是一个分支结构,x可能是C2的实例也可能是C3的实例。然后sentence 4调用m1。很明显,这是一个动态

绑定,父类指针,子类对象。但是我们仔细分析一下,发现只有C4覆盖了m1,但是这里的x不可能是C4的

实例,所以这里的m1只有可能是C1定义的那个m1,所以编译器在这里就会把C1_m1的地址直接写入不用

动态绑定了!

Java中的static关键字就是显式的告诉编译器,这个静态的。类似的还有被final修饰的方法,由于不

能被覆盖的唯一性,final的方法也是可以直接写入地址调用的。顺便说一下,private的方法是自动final

的,道理自己去想吧。严格的讲,我觉得静态的东西本不应属于面向对象编程的范畴,它是从面向过程的

编程继承下来的,完全没有对象的概念。C,Pascal中的一切都是静态的。所以Java也可以当面向过程的语

言用(向下兼容?),所有东西全部static,根本不存在对象。你可能会想,为什么static的东西不能覆

盖呢?根据类和对象的内存结构,从理论上来讲,是可以做到覆盖的,但这样做违背了静态的初衷,没有

意义。除此之外,static也为Java提供了更多的编程灵活性。

动态绑定看起来很玄,但是当你了解了其中原理之后一切都很容易了。关键就是对象的内存结构,从汇编的角度来看,面向对象和面向过程又有什么区别呢?

从编译的角度看对象

— Java语言描述

第五节:变量的生命周期和内部类

内部类为编程带来了灵活性,但也带来了不少疑惑。从内部类调用外部的局部变量就常常出现问题。

先来看一段代码:

public class ShowAnonymousClass extends JFrame {

Button myButton;

int count;

public ShowAnonymousClass() {

super("Inner Class Frame");

myButton = new Button("click me");

final TextArea myTextArea = new TextArea(); // line 1

final int temp = 0; // line 2

myButton.addActionListener(new ActionListener(){

public void actionPerformed(ActionEvent event){

count ;

myTextArea.setText("Clicked:" (count temp));

}

});

}

}

这里用了内部类来做事件处理。这里的ActionListener是一个内部类,它用到了ShowAnonymousClass的成

员变量count和构造函数的局部比变量myTextArea。光从变量的生命周期来看,在按按钮的时候这个构造函

数已经退出了,myTextArea和temp都应该已经不存在,为什么actionPerformed还可以正常执行呢?如果我

们把line 1和line 2的final去掉,编译就不通过。莫非是final改变了变量的生命周期?考察一下运行时内存的

情况,在函数内部定义的变量都是在栈上的,函数return以后栈空间被回收,如果final要改变生命周期的话

只有把final的东西拷贝到堆里面去。显然,拷贝到ShowAnonymousClass的空间中是不合理的,这几个字段

对ShowAnonymousClass根本没有用。所以比较合理的解释是拷贝到那个匿名类的空间里面去了。可以这

么理解,编译器遍完这段代码以后,编译器创建了这样一个类:

class Anonymous1 implements ActionListener{

TextArea myTextArea;

int temp;

ShowAnonymousClass sac;

public Anonymous1(ShowAnonymousClass sac, TextArea area, int t){

this.sac = sac;

this.myTextArea = area;

this.temp = t;

}

public void actionPerformed(ActionEvent event){

this.sac.count ;

myTextArea.setText("Clicked:" (this.sac.count temp));

}

}

这样的话myTextArea和temp的值就都传到内部类里面了,ShowAnonymousClass的实例也作为参数传了进去,这样内部类就能访问外部类的成员变量了。这里的final不是为了改变变量的生命周期,而是为了保持内部类的成员变量和外部方法的局部变量值的一致性。否则外部的方法对局部变量进行了改动,而内部类是无法获知是否改动,两边信息就不一致了。

从编译的角度看对象

— Java语言描述

第六节:this的奥秘

这个题目有点玄,说的简单一点就是一个隐藏参数的问题。先来看一个类:

public class Test {

public int a = 0;

public void test(int b){

int c = b 1;

c = a c;

System.out.println(c);

}

public static void main(String[] args){

Test obj1 = new Test();

Test obj2 = new Test();

Obj1.a = 1;

obj2.a = 10;

obj1.test(2);

obj2.test(3);

}

}

这个类很简单,我们现在有两个Test的实例。根据我们上面的讨论,这两个实例应该具有相同的内存结构和

方法列表。更进一步说,他们的方法列表应该是同一个。那么如果分别调用两个实例的test函数,执行的应

该是同一段代码。问题就在这里,既然执行的是同一段代码,这里的“a”是哪个a呢?实例obj1和obj2的a

的值是不一样的。函数test如何才能拿到正确的a呢?这是一个面向对象和面向过程不同的地方。面向对象

的成员函数,始终是以当前对象为主体的,而面向过程不是。从汇编的角度看,面向对象的函数执行过程

和面向过程的函数执行过程没有什么区别,都是那几个汇编指令。所以区别一定出在编译的时候。试想一

下,如果编译的时候把当前对象作为参数传入函数,那这个问题不就解决了吗?事实情况正是如此,所以

当我们编译好以后,test函数应该是这样的:

public void test(Test tis, int b){

int c = b 1;

c = tis.a c;

System.out.println(c);

}

调用的时候是这样的:

obj1.test(obj1, 2);

obj2.test(obj2, 3);

如果没有指定被调用函数的所属对象,那么默认对象就是“this”,比如我们为Test类添加一个构造函数:

public Test(){

test(3);

}

实际上编译好以后,这个函数是这样的:

public Test(){

this.test(this, 3);

}

编译器会自动在成员函数和成员变量的调用的时候添加“this”。从上面的例子我们可以看出,编译器会把

“this”作为一个默认参数会传入每一个成员函数,我们在成员函数里面使用的“this”关键字也就是参数列表中的那个

“tis”,不过构造函数是个例外。根据第三节的讨论,我们可以推测,构造函数中的“this”是在构造函数第一,第二阶段

完成以后生成的。原因很简单,构造函数第二阶段完成以后,对象的内存结构就定好了,指向这个对象的指针就可以开始

工作了。并且,构造函数返回的就是这个“this”。

,