在面向对象编程语言中,一个类的构造函数和析构函数是两个特殊的成员函数,它们主要用于对象的创建和销毁,和对象的生命周期息息相关,因此它们有着特殊的含义。编译器对待它们和其它普通的成员函数不一样,在编译时会添加一些额外的代码来做一些专门用途的业务逻辑。本文介绍其中一个和虚函数调用有关的场景及其实现机制。
我们知道,C++为了实现面向对象的多态语义,设计了虚函数机制,具体地说,就是每个带有虚函数的类都会有一个虚函数表vtbl,在里面存放了各个虚函数的调用入口地址,在创建对象时,会为对象分配一个虚函数指针vptr,指向这个vtbl的位置。这样,当通过一个基类类型的引用方式来调用它的虚函数时,根据vptr所指向的虚函数表,得到虚函数的函数指针,来调用它的虚函数,虽然引用的声明类型是基类,但是实际调用的是所引用的子类类型的函数。也就是说,当使用子类对象的this指针来调用基类的虚函数时,因为this的真实类型是子类,它的vptr指向了子类的vtbl,程序运行时选择的是子类的虚函数,因为这个过程是发生在程序运行的时候,所以把这个过程称为动态绑定。
在一个类的成员函数中,当它调用本类中的其它虚函数时,是按照动态绑定实现的。那么,在构造函数和析构函数这两个特殊的成员函数中调用本类的虚函数时,是不是也是这样呢?
下面编一个小程序测试一下:
// 定义基类,它有两个虚函数
class A {
public:
A() {
puts("A::A()");
func1(); //在构造函数中调用虚函数
}
virtual ~A() {
puts("A::~A()");
func1(); // 在析构函数中调用虚函数
}
// 普通函数调用虚函数
void foo() {
puts("A::foo()");
func1();
}
virtual void func1() {
puts("A::func1() virtual");
}
};
// 派生类
class B : public A {
public:
B() {
puts("B::B()");
}
~B() {
puts("B::~B()");
}
virtual void func1() override {
puts("B::func1() virtual");
}
};
定义了两个类:A是基类,定义了一个虚函数func1,B是A的派生类,重写了这个虚函数。在A类的构造函数和析构函数中都调用了虚函数func1。
测试函数,让一个声明类型为A*的指针指向一个实际类型为B的对象,也就是指针的声明类型是基类,但实际指向它的派生类对象。
// 测试函数
void test() {
puts("construct B object");
A *p = new B(); // p指向B类对象
puts("invoke virtual function via base class pointer");
p->func1();
puts("destruct B object");
delete p;
puts("end.");
}
程序输出的log如下:
construct B object
A::A() // 先调用派生类B的基类A的构造函数
A::func1() virtual // 基类调用的仍然是自己的虚函数
B::B()
invoke virtual function via base class pointer
B::func1() virtual // 调用的是派生类对象的虚函数
destruct B object
B::~B() // 先析构派生类B对象
A::~A() // 再析构基类A对象
A::func1() virtual // 在基类中调用的仍是自己的虚函数
end.
从输出log中可以看出,当一个指针的声明类型是基类类型,指向的实际类型是它的子类对象时,如果在它的构造和析构函数中调用了虚函数,并没有实行多态机制,仍然会调用本类的虚函数。也就是,编译器并没有对它们进行动态绑定。
为什么会这样呢,这和C++中派生类对象创建和销毁时的顺序有关。
当一个构造派生类的对象时,构造顺序是先构造基类对象,再构造子对象。既然子对象构造在后,也就说在构造基类对象时,子类型对象还没有初始化,我们知道,对于有虚函数的多态类,对象中要额外存放一个虚函数指针vptr,它指向类的虚函数表,这个vptr就是在类的初始化构造时设置的。如果此时使用动态绑定话,派生类还没有初始化,它的vptr是一个野指针,也就无法通过vptr找到所需的虚函数。因此,此时无法实现多态机制,所以编译器只能选择基类版的虚函数。
同样,在析构派生类对象时,析构顺序是先析构子类对象,再析构基类对象,当调用基类的析构函数时,子类对象已经销毁了,也就是派生类对象的vptr可能已经无效了。因此,在此场景下,编译器也只能选择基类版本的虚函数。
当然,严格地说,应该是基类对象部分和子类对象部分,使用派生类创建的对象只有一个,为了行文方便,通称基类对象和子类对象。
那么,编译器又是怎么实现的呢?原来编译器在编译构造函数和析构函数时,会在函数的开始处,暗地里地插入一些代码,这些代码首先设置对象的vptr,让它指向该类的虚函数表vtbl的入口地址。显然,在随后对虚函数调用时,都是根据该类的vptr来动态绑定的,调用的自己的虚函数,除非又对vptr做了更新。当基类对象构造完成后,在调用派生类对象的构造函数时,同样,又进行了派生类对象的vptr设置,让它指向派生类的虚函数表vtbl。这样,当在后面进行虚函数调用时,都是绑定的派生类的虚函数。
我们通过编译器产生的汇编语言来看一下,编译器在编译构造函数和析构函数时,做了那些操作。
1、基类A的构造函数的汇编代码:
0x0000000000422370 <+0>: push rbp
0x0000000000422371 <+1>: mov rbp,rsp
0x0000000000422374 <+4>: sub rsp,0x20
0x0000000000422378 <+8>: mov QWORD PTR [rbp+0x10],rcx // rcx存放this指针
0x000000000042237c <+12>: mov rax,QWORD PTR [rbp+0x10] // rax所指位置存放vptr
0x0000000000422380 <+16>: lea rdx,[rip+0x6e269] # 0x4905f0 <_ZTV1A+16> // rdx存放vtbl的入口地址:0x4905f0
0x0000000000422387 <+23>: mov QWORD PTR [rax],rdx //调整vptr指向本类的vtbl
=> 0x000000000042238a <+26>: lea rcx,[rip+0x65c70] # 0x488001 <_ZStL19piecewise_construct+1>
0x0000000000422391 <+33>: call 0x419d08 <puts>
0x0000000000422396 <+38>: mov rcx,QWORD PTR [rbp+0x10]
0x000000000042239a <+42>: call 0x4222d0 <A::func1()>
0x000000000042239f <+47>: nop
0x00000000004223a0 <+48>: add rsp,0x20
0x00000000004223a4 <+52>: pop rbp
0x00000000004223a5 <+53>: ret
从中可以看出,在构造函数中,编译器首先添加了代码来初始化这个对象的vptr(汇编代码4-7行),然后再编译生成它的函数体中的代码。
2、派生类B的构造函数的汇编代码:
0x00000000004224c0 <+0>: push rbp
0x00000000004224c1 <+1>: push rbx
0x00000000004224c2 <+2>: sub rsp,0x28
0x00000000004224c6 <+6>: lea rbp,[rsp+0x80]
0x00000000004224ce <+14>: mov QWORD PTR [rbp-0x40],rcx // rcx存放this指针
0x00000000004224d2 <+18>: mov rax,QWORD PTR [rbp-0x40] // rax所指位置存放vptr
0x00000000004224d6 <+22>: mov rcx,rax
0x00000000004224d9 <+25>: call 0x422370 <A::A()> // 调用基类的构造函数
0x00000000004224de <+30>: mov rax,QWORD PTR [rbp-0x40]
0x00000000004224e2 <+34>: lea rdx,[rip+0x6e147] # 0x490630 <_ZTV1B+16> // rdx存放vtbl的入口地址:0x490630
0x00000000004224e9 <+41>: mov QWORD PTR [rax],rdx //调整vptr指向本类的vtbl
=> 0x00000000004224ec <+44>: lea rcx,[rip+0x65b4c] # 0x48803f <_ZStL19piecewise_construct+63>
0x00000000004224f3 <+51>: call 0x419d08 <puts>
0x00000000004224f8 <+56>: jmp 0x422515 <B::B()+85>
0x00000000004224fa <+58>: mov rbx,rax
0x00000000004224fd <+61>: mov rax,QWORD PTR [rbp-0x40]
0x0000000000422501 <+65>: mov rcx,rax
0x0000000000422504 <+68>: call 0x422430 <A::~A()>
0x0000000000422509 <+73>: mov rax,rbx
0x000000000042250c <+76>: mov rcx,rax
0x000000000042250f <+79>: call 0x40f5d0 <_Unwind_Resume>
0x0000000000422514 <+84>: nop
0x0000000000422515 <+85>: add rsp,0x28
0x0000000000422519 <+89>: pop rbx
0x000000000042251a <+90>: pop rbp
0x000000000042251b <+91>: ret
可见,在B类的构造函数中,因为B继承了A,先调用基类A的构造函数(第8行),A的构造函数执行完之后,再更新vptr,让它指向本类的虚函数表vtbl(汇编代码5、6、10、11行),最后才是函数体的代码。
3、基类A的析构函数的汇编代码:
0x0000000000422430 <+0>: push rbp
0x0000000000422431 <+1>: mov rbp,rsp
0x0000000000422434 <+4>: sub rsp,0x20
0x0000000000422438 <+8>: mov QWORD PTR [rbp+0x10],rcx // rcx存放this指针
0x000000000042243c <+12>: mov rax,QWORD PTR [rbp+0x10] // rax所指位置存放vptr
0x0000000000422440 <+16>: lea rdx,[rip+0x6e1a9] # 0x4905f0 <_ZTV1A+16> // rdx存放vtbl的入口地址:0x4905f0
0x0000000000422447 <+23>: mov QWORD PTR [rax],rdx //调整vptr指向本类的vtbl
=> 0x000000000042244a <+26>: lea rcx,[rip+0x65bb7] # 0x488008 <_ZStL19piecewise_construct+8>
0x0000000000422451 <+33>: call 0x419d08 <puts>
0x0000000000422456 <+38>: mov rcx,QWORD PTR [rbp+0x10]
0x000000000042245a <+42>: call 0x4222d0 <A::func1()>
0x000000000042245f <+47>: mov eax,0x0
0x0000000000422464 <+52>: test eax,eax
0x0000000000422466 <+54>: je 0x422472 <A::~A()+66>
0x0000000000422468 <+56>: mov rcx,QWORD PTR [rbp+0x10]
0x000000000042246c <+60>: call 0x470cf0 <_ZdlPv>
0x0000000000422471 <+65>: nop
0x0000000000422472 <+66>: add rsp,0x20
0x0000000000422476 <+70>: pop rbp
0x0000000000422477 <+71>: ret
同样,从中可以看出,在进入析构函数时,编译器首先就初始化这个对象的vptr(汇编代码4-7行),然后再执行其它的初始化逻辑代码。
4、派生类B的析构函数的汇编代码:
0x0000000000422550 <+0>: push rbp
0x0000000000422551 <+1>: mov rbp,rsp
0x0000000000422554 <+4>: sub rsp,0x20
0x0000000000422558 <+8>: mov QWORD PTR [rbp+0x10],rcx // rcx存放this指针
0x000000000042255c <+12>: mov rax,QWORD PTR [rbp+0x10] // rax所指位置存放vptr
0x0000000000422560 <+16>: lea rdx,[rip+0x6e0c9] # 0x490630 <_ZTV1B+16> // rdx存放vtbl的入口地址:0x490630
0x0000000000422567 <+23>: mov QWORD PTR [rax],rdx //调整vptr指向本类的vtbl
=> 0x000000000042256a <+26>: lea rcx,[rip+0x65ad5] # 0x488046 <_ZStL19piecewise_construct+70>
0x0000000000422571 <+33>: call 0x419d08 <puts>
0x0000000000422576 <+38>: mov rax,QWORD PTR [rbp+0x10]
0x000000000042257a <+42>: mov rcx,rax
0x000000000042257d <+45>: call 0x422430 <A::~A()>
0x0000000000422582 <+50>: mov eax,0x0
0x0000000000422587 <+55>: test eax,eax
0x0000000000422589 <+57>: je 0x422595 <B::~B()+69>
0x000000000042258b <+59>: mov rcx,QWORD PTR [rbp+0x10]
0x000000000042258f <+63>: call 0x470cf0 <_ZdlPv>
0x0000000000422594 <+68>: nop
0x0000000000422595 <+69>: add rsp,0x20
0x0000000000422599 <+73>: pop rbp
0x000000000042259a <+74>: ret
可见,在B类的析构函数中,先设置vptr指针,让它指向本类的vtbl(汇编代码4-7行),然后再调用基类A的析构函数(第12行),在A类的析构函数中,也是先设置vptr,让它指向本类的vtbl。
基于上面的分析,我们可以得出,如果在一个类的构造函数或析构函数中调用虚函数时,不是直接调用,而是间接调用的,例如,构造函数调用了一个非虚普通函数,如测试代码中的foo函数,在这个普通函数中又调用了一个虚函数,此时普通函数调用的仍然是构造函数所在类的虚函数,因为此时vptr指向的是构造函所在类的vtbl,仍然没有实行多态。
尽管构造函数和析构函数调用了虚函数,但是C++编译器并没有对它们采用动态绑定机制,而是静态绑定。因为构造函数设置vptr指向的是所在类的虚函数表,此时通过vptr所绑定的虚函数就是和构造函数同属一个类中的函数,编译器在编译阶段是知道虚函数的入口地址的,尽管通过动态绑定也能正确的进行函数调用,但为了性能优化,降低函数调用的成本,节省一次指针的间接寻址的开销,也就没有必要采用动态绑定;析构函数中绑定虚函数时同样也是如此。如汇编代码中“call 0x4222d0
不过,如果构造函数或析构函数通过间接方式来调用虚函数时,虽然没有实现多态机制,但虚函数仍然是动态绑定的。如果上面的例子中,在A的构造函数中,把调用func1()的语句换成调用foo(),而在foo函数又调用了虚函数func1,此时func1是动态绑定的,虽然func1仍然是A类中的版本。
总之,在C++语言中,如果在构造或析构函数中调用了虚函数,不会实现多态机制,都选择了构造函数所在类中的虚函数版本。如果是直接调用虚函数的形式,采用的是静态绑定机制,而间接调用虚函数时,采用的是动态绑定。
Java语言
不过,并不是所有的面向对象语言都是这样实现的,Java语言就不是。下面是Java的一段代码,在父类Base的构造函数中调用了virtual函数foo,而foo又被子类Derived重写了。在main函数中,在构造Derived对象时,先构造它的父类Base对象,在调用Base的构造函数时,会进行动态绑定,调用了Derived的foo方法:
public class BaseInvokeVirtual {
static class Base {
String x;
public Base(String s) {
x = s;
foo();
}
protected void foo() {
System.out.println("Base::foo");
}
}
static class Derived extends Base {
String m;
public Derived(String s) {
super(s);
m = s;
}
@Override
protected void foo() {
System.out.println("Derived::foo->" + m);
}
}
static public void main(String[] args) {
Base b = new Derived("1234");
}
}
如果运行这段代码,会输出下面的log信息:
Derived::foo->null
可见当在Base的构造函数中调用虚函数foo时,子类Derived对象还没有初始化,它的String类型的数据成员m,它的值此时为空指针。显然如果在foo中调用m对象的方法时,会抛出NullPointerException异常,因此,对于Java程序员,最好不要编写这样场景的代码,以免发生意外。