tags:
- Cpp
aliases:
- Dynamic Dispatch in C++
- Inheritance in C++ (Part II)
Virtual Dispatch in C++
Do this at first: Inheritance in C++
之前,我们学习了继承是怎么回事,还了解了派生类对基类同名函数的隐藏机制。由于函数名隐藏,你在派生类中“重写”的基类成员函数实际上并不算是“重写”。因为这时派生类看不到基类的同名函数。这里我们在深入学习一下函数名隐藏。
比如我们有下面的程序。在基类 Base
中,我们定义了一个 func()
和一个重载的 func(int)
。我们之后定义了两个 Base
的派生类 NoHiding
和 Derived
,NoHiding
不 “重写”基类成员函数,而 Derived
类对基类的 func()
进行重写。
#include <iostream>
class Base {
public:
void func() {
std::cout << "Base::func()" << std::endl;
}
void func(int i){ // Overloading of func()
std::cout << "Base::func(int)" << std::endl;
}
};
class NoHiding : public Base {};
class Derived : public Base {
public:
void func() { // Hiding every func() in the base class.
std::cout << "Derived::func()" << std::endl;
}
void func(float f) {
std::cout << "Derived::func(float)" << std::endl;
}
};
int main() {
Base b;
b.func();
b.func(10);
NoHiding n;
n.func(); // Call Base::func()
n.func(10); // Call Base::func(int)
Derived d;
d.func(); // Okay, call Derived::func()
d.func(10); // Okay, call Derived::func(float) an implicit conversion happened
return 0;
}
由于我们在派生类 Derived
中定义了和基类中同名的函数,所以基类中所有名为 func
的函数都会被隐藏。这时,我们在派生类中“重写”了 func()
并重载了一个 func(float)
。我们可能期望看到 d.func()
,但由于命名隐藏,基类的 func(int)
并不会被继承。我们观察一下输出:
Base::func()
Base::func(int)
Base::func()
Base::func(int)
Derived::func(float)
所以命名隐藏有什么好处?我们上面在派生类中重载了 func(float)
,如果没有命名隐藏,就可能导致函数调用的歧义。如 d.func(10)
会调用基类中的 Base::func(int)
而不大可能调用派生类中的 Derived::func(float)
。
我们说继承的意义之一就是代码的可重用性。这样看也没有提供上面可重用性的空间呀?基类成员函数都被隐藏掉了,还怎么重用?
这就引出了真正的多态——虚多态(又名动态多态)。
虚多态的核心就是虚函数。虚函数允许子类对该函数进行重写(override),引入了动态多态/运行时多态的概念。还是上面的例子,我们做一点调整,去掉类 NoHiding
的继承还有派生类中的 Derived::func()
,并且让派生类重写基类的 func(int)
。
#include <iostream>
class Base {
public:
virtual void func() {
std::cout << "Base::func()" << std::endl;
}
virtual void func(int i){ // Overloading of func()
std::cout << "Base::func(int)" << std::endl;
}
};
class Derived : public Base {
public:
void func(int i) override{
std::cout << "Derived::func(int)" << std::endl;
}
};
int main() {
Base b;
b.func();
b.func(10);
Derived d;
Base& d_ref = d;
Base* d_ptr = &d;
d_ref.func(); // Okay, call Base::func()
d_ref.func(10); // Okay, call Derived::func()
return 0;
}
输出:
Base::func()
Base::func(int)
Base::func()
Derived::func(int)
现在,那种多态的感觉又回来了。如果在派生类中没有给出重写实现,那就会调用基类默认的实现。大大提高了程序代码的重用。
虚多态的实现很容易,你要在派生类中重写哪个基类函数,你只用声明那个基类函数为 virtual
就可以了。由于其动态绑定机制,虚多态只能配合指针或引用使用(基类指针或引用,不然可能出现问题)。如下:
Base& d_ref = d;
Base* d_ptr = &d;
Base* d_ptr_heap = new(Derived);
d_ref.func(); // Okay, call Base::func()
d_ref.func(10); // Okay, call Derived::func(int)
d_ptr->func();
d_ptr->func(10);
d_otr_heap->func();
d_otr_heap->func(10);
可以看到,派生类对象并不只是在堆上创建,在栈上也可以创建派生类对象来实现动态多态。(只要满足动态绑定机制)
这里仍需要注意,尽管我们引入了虚多态,但命名隐藏是依然存在的。也就是说,即使基类函数是虚函数,派生类定义同名函数仍然会隐藏基类版本。这时,如果你使用派生类的指针或引用期望调用基类的虚函数,你会发现编译错误(被隐藏)。
Derived& d_ref = d; // Use base pointer
//d_ref.func(); // Not okay, no matching function
d_ref.func(10); // Okay, call Derived::func(int)
为什么?这是 C++ 的特性决定的。如果你不用指针或者引用来调用虚函数,就会发生早绑定(静态绑定)。而多态是通过晚绑定(动态绑定)实现的,所以我们常用基类指针或引用来调用虚函数。我们待会介绍什么是早绑定和晚绑定。
override
and final
Keyword派生类中重写的函数必须和基类中的虚函数类型完全匹配(函数名、参数列表、常量性)。在派生类中重写虚函数的时候可以使用 final
和 override
关键字。它们各有不同的作用。
override
关键字用于在派生类中重写基类中的虚函数。它告诉编译器该函数是用来重写基类中的虚函数的,如果没有正确匹配基类中的虚函数,编译器就会报错。override
可防止因拼写错误或参数不匹配导致的意外函数隐藏。
final
用于控制虚函数或类继承重写的行为。如果将 final
关键字用于虚函数,则表示该虚函数的重写到此结束,如果在派生类中重写该虚函数就会导致编译错误。如:
class Grandpa{
public:
virtual void func(){
std::cout << "In grandpa class." << std::endl;
}
};
class Father : public Grandpa{
public:
void func() override final {
std::cout << "In father class." << std::endl;
}
};
class Child : public Father{
public:
// void func(){} // The override of virtual function has been finished.
};
在虚多态中,基类的作用是制定一些规则(如派生类可以重写哪些函数)。但是基类不能干涉派生类重写函数的实现,这并不难理解。而如果我们想做的更绝一点,强制派生类实现基类中的虚函数(某些功能),我们就可以把这个虚函数变成纯虚函数。
因为纯虚函数定义了一组必须由派生类实现的函数,从而为派生类提供了一种规范和约束。因此,在 C++ 中,我们也常称纯虚函数为接口。
纯虚函数是通过在基类中定义虚函数,并在函数后添加 = 0
来声明的。一般而言,纯虚函数没有函数体。如:
class Base{
public:
virtual void func() = 0;
};
class Derived : public Base{
// Must implement the pure virtual function func()
void func() final{
// Impl...
}
};
其实接口是可以有函数体的,你可以在这里完成最初的一些初始化。
#include <iostream>
class Base{
public:
virtual void func() = 0;
};
void Base::func(){
std::cout << "In pure virtual function" << std::endl;
}
class Derived : public Base{
// Must implement the pure virtual function func()
void func() final{
Base::func(); // Explicit call to Base::func()
// Other impl...
}
};
当类中声明某函数为纯虚函数时,该类即为抽象类。因为往往接口不提供相关的实现,抽象类不能实例化。如果继承的派生类对接口不进行实现,那么派生类也会变为抽象类,即不能实例化。
在了解虚析构函数前,我们先写一段代码并观察其输出结果,我们会看到删除指向派生类对象的基类指针时,析构函数的调用是错误的。观察并思考一下为什么会出现这种现象。
#include <iostream>
class Base{
public:
Base(){ std::cout << "Base constructor." << std::endl; }
~Base(){ std::cout << "Base destructor." << std::endl; }
};
class Derived : public Base{
public:
Derived(){ std::cout << "Derived constructor." << std::endl; }
~Derived(){ std::cout << "Derived destructor." << std::endl; }
};
int main(){
Base* base = new Base();
delete base;
std::cout << "---------------------\n";
Derived* derived = new Derived();
delete derived;
std::cout << "---------------------\n";
Base* polymorph = new Derived();
delete polymorph;
return 0;
}
Base constructor.
Base destructor.
---------------------
Base constructor.
Derived constructor.
Derived destructor.
Base destructor.
---------------------
Base constructor.
Derived constructor.
Base destructor.
输出很奇怪,当我们删除 polymorph
时,我们发现虽然我们 new
了一个 Derived
类对象,但是删除对象的时候确没有调用基类的析构函数。为什么?很明显的是,在我们删除 polymorph
时,我们并没有正确调用派生类的析构函数。这是因为非虚函数都是静态绑定的。
静态绑定也称为早绑定,因为被静态绑定的函数调用通常在编译阶段就已确定。换句话说,对于非虚函数,编译器会在编译时决定调用的具体函数。静态绑定是 C++ 的默认函数调用机制,适用于非虚函数、全局函数和静态成员函数。
由于多态的实现需要用到运行时的虚表信息,而静态绑定通常上仅仅依赖编译时期的类型信息来决定调用那些函数,所以在虚多态的语境下,我们看到派生类对象没有被正确的析构。
{
Base* polymorph = new Derived(); // Calls Base::Base() -> Derived::Derived()
} // Since the pointer type is Base*, only Base::~Base() is called. Derived's destructor is not called due to the lack of a virtual destructor in Base.
{
Derived d; // Calls Base::Base() -> Derived::Derived()
Base& polymorph = d; // A Base type reference is created to reference a Derived object.
} // Only Base::~Base() is called because the destructor is not virtual, so Derived's destructor is skipped.
我们前面提到,要实现虚多态,我们需要用基类的指针或者引用。而在上面的代码构造派生类时,由于派生类继承了基类,所以会先构造基类。然而我们指向派生类对象的指针或引用是基类的指针或引用,所以在析构的时候只会析构基类对象而漏掉派生类对象。
当基类的析构函数声明为虚函数时,C++ 的虚多态会确保在删除一个指向派生类对象的基类指针时,调用的是派生类的析构函数。这是因为虚函数表会在运行时动态绑定到正确的析构函数,从而确保派生类的析构函数被调用。
如果你给基类的析构函数加上 virtual
关键字就好了。这样析构的时候会查去虚表,由于查虚表的过程是运行时发生的,所以称为晚绑定(动态绑定)。
那析构函数可以是纯虚的么?既然我们有多态,为什么不直接在派生类中把所有的资源给删除呢?这里我声明 Base::~Base() = 0;
为纯虚析构函数。
#include <iostream>
class Base{
public:
Base(){ std::cout << "Base constructor." << std::endl; }
virtual ~Base() = 0;
};
class Derived : public Base{
public:
Derived(){ std::cout << "Derived constructor." << std::endl; }
~Derived(){ std::cout << "Derived destructor." << std::endl; }
};
int main(){
Base* polymorph = new Derived();
delete polymorph;
return 0;
}
编译一下,GCC 告诉我们链接出错了,因为没有找到 Base::~Base()
的定义。在前面,我们了解了纯虚函数是可以有函数体的,我们补上函数体再进行编译。
#include <iostream>
class Base{
public:
Base(){ std::cout << "Base constructor." << std::endl; }
virtual ~Base() = 0;
};
Base::~Base(){
std::cout << "Base destructor." << std::endl;
}
class Derived : public Base{
public:
Derived(){ std::cout << "Derived constructor." << std::endl; }
~Derived(){ std::cout << "Derived destructor." << std::endl; }
};
int main(){
Base* polymorph = new Derived();
delete polymorph;
return 0;
}
Ok 了,没有错误了。而且可以运行。那为什么必须要类中析构函数的定义呢?
现在,我们终于走到虚多态是如何实现的这一步了。我们现来观察一个现象:
#include <iostream>
class nonVirtual{
public:
void func(){}
void func(int i){}
};
class Virtual{
public:
virtual void func(){}
virtual void func(int i){}
};
class Derived : public Virtual{
public:
void func(){}
void func(int i){}
};
int main(){
std::cout << "Sizeof nonVirtual: " << sizeof(nonVirtual) << std::endl;
std::cout << "Sizeof Virtual: " << sizeof(Virtual) << std::endl;
std::cout << "Sizeof Derived: " << sizeof(Derived) << std::endl;
return 0;
}
输出:
Sizeof nonVirtual: 1
Sizeof Virtual: 8
Sizeof Derived: 8
不难发现,当类里面没有数据成员,而且成员函数非虚时,类的大小为 1 字节(用于确保创建的对象有唯一的地址)。但如果定义虚函数,类的大小就会为 8 字节。这 8 字节即为虚指针(64 位机器),它是编译器为我们生成的成员变量,一般位于对象内存布局的最前面。
我们说虚多态由于动态绑定只能配合指针或引用使用。而动态绑定是通过虚表和虚表指针实现的。当我们使用 virtual
关键字时,C++就会帮我们创建一个 vtable 。vtable 会被存放到类定义模块的数据段中,而且所有相同类型的实例共享同一个 vtable 。
可能上面的话我们并不好理解,没关系,我们先用一个例子说明。如下,我们让将基类 我们定义基类 Animal
定义为了一个抽象类,为其他的动物提供函数/方法接口。Animal
和三个派生类,然后在 main
里面用基类指针指向 new
的派生类对象,在运行时动态调用派生类的实现。这就是动态时多态(Dynamic dispatch) 。
#include <iostream>
class Animal{
public:
virtual void Say() {
std::cout << "Dingggg~" << std::endl;
}
virtual void Whoami() {
std::cout << "I am an animal." << std::endl;
}
};
class Cat : public Animal{
void Say() override {
std::cout << "Meowwww~" << std::endl;
}
void Whoami() override {
std::cout << "I am a cat." << std::endl;
}
};
class Cow : public Animal{
public:
void Say() override {
std::cout << "Moooooo~" << std::endl;
}
};
class Pig : public Animal{
public:
void Say() override {
std::cout << "Oinnnnk~" << std::endl;
}
};
int main(){
Animal* animal = new Cat();
animal->Say();
animal->Whoami();
Animal* animal2 = new Cow();
animal2->Say();
animal2->Whoami();
delete animal;
delete animal2;
return 0;
}
Meowwww~
I am a cat.
Moooooo~
I am an animal.
派生类继承基类时,如果没有重载虚函数,虚表中的指针仍然指向基类的虚函数实现。也就造成了我们所看到的 I am an animal.
。虚表(vtable) 的存在是实现动态多态性和默认基类实现的核心所在。通过虚表,可以在运行时决定调用哪个具体的函数实现,我们现在就来了解它。
上面我们观察了一些现象,我们下面就来看看虚表和我们的代码在内存中是怎么样的。我们用一张图来说明一下。
在这个例子中,每个类都有一个自己的虚表,某个类的所有实例都会共享一个虚表,当类被实例化时,实例会有一个指向其类的虚指针(指向虚表的指针)。
虚表中包含类的所有虚函数的指针。我们的基类 Animal
有两个虚函数 Say()
和 Whoami()
,所以 Animal
类的虚表中会有两个指针分别指向 Say()
和 Whoami()
的实现。对于派生类中没有重载基类的某个虚函数,那么它的虚表中会包含指向基类 Animal
中相应虚函数的指针。
从上图中能清楚地看到:
Cat
类的虚表会有指向 Cat::Say()
和 Cat::Whoami()
的虚指针。Cow
类的虚表会有指向 Cow::Say()
和 Animal::Whoami()
的虚指针。Pig
类的虚表会有指向 Pig::Say()
和 Animal::Whoami()
的虚指针。请留意:这里并没有画栈这个内存中极其重要的一部分。在堆中的实例需要通过栈上的指针来进行寻址,完整的寻址过程如下:栈上指针 -> 实例中的虚指针 -> 虚表中的函数指针 -> 虚函数。这也是动态多态性较慢的原因之一(也就是为什么也叫动态多态为运行时多态)。
我们先来看看内存布局:
Mapped address spaces:
Start Addr End Addr Size Offset Perms
0x555555554000 0x555555555000 0x1000 0x0 r--p
0x555555555000 0x555555556000 0x1000 0x1000 r-xp .text
0x555555556000 0x555555557000 0x1000 0x2000 r--p .rodata
0x555555557000 0x555555558000 0x1000 0x2000 r--p .rodata
0x555555558000 0x555555559000 0x1000 0x3000 rw-p .data&bss
0x555555559000 0x55555557a000 0x21000 0x0 rw-p [heap]
0x7ffff7800000 0x7ffff7828000 0x28000 0x0 r--p libc.so.6
0x7ffff7828000 0x7ffff79bd000 0x195000 0x28000 r-xp libc.so.6
0x7ffff79bd000 0x7ffff7a15000 0x58000 0x1bd000 r--p libc.so.6
0x7ffff7a15000 0x7ffff7a16000 0x1000 0x215000 ---p libc.so.6
0x7ffff7a16000 0x7ffff7a1a000 0x4000 0x215000 r--p libc.so.6
0x7ffff7a1a000 0x7ffff7a1c000 0x2000 0x219000 rw-p libc.so.6
0x7ffff7a1c000 0x7ffff7a29000 0xd000 0x0 rw-p
0x7ffff7c00000 0x7ffff7c9a000 0x9a000 0x0 r--p libstdc++.so
0x7ffff7c9a000 0x7ffff7dab000 0x111000 0x9a000 r-xp libstdc++.so
0x7ffff7dab000 0x7ffff7e1a000 0x6f000 0x1ab000 r--p libstdc++.so
0x7ffff7e1a000 0x7ffff7e1b000 0x1000 0x21a000 ---p libstdc++.so
0x7ffff7e1b000 0x7ffff7e26000 0xb000 0x21a000 r--p libstdc++.so
0x7ffff7e26000 0x7ffff7e29000 0x3000 0x225000 rw-p libstdc++.so
0x7ffff7e29000 0x7ffff7e2c000 0x3000 0x0 rw-p
0x7ffff7ea1000 0x7ffff7ea5000 0x4000 0x0 rw-p
0x7ffff7ea5000 0x7ffff7ea8000 0x3000 0x0 r--p libgcc_s.so.1
0x7ffff7ea8000 0x7ffff7ebf000 0x17000 0x3000 r-xp libgcc_s.so.1
0x7ffff7ebf000 0x7ffff7ec3000 0x4000 0x1a000 r--p libgcc_s.so.1
0x7ffff7ec3000 0x7ffff7ec4000 0x1000 0x1d000 r--p libgcc_s.so.1
0x7ffff7ec4000 0x7ffff7ec5000 0x1000 0x1e000 rw-p libgcc_s.so.1
0x7ffff7ec5000 0x7ffff7ed3000 0xe000 0x0 r--p libm.so.6
0x7ffff7ed3000 0x7ffff7f4f000 0x7c000 0xe000 r-xp libm.so.6
0x7ffff7f4f000 0x7ffff7faa000 0x5b000 0x8a000 r--p libm.so.6
0x7ffff7faa000 0x7ffff7fab000 0x1000 0xe4000 r--p libm.so.6
0x7ffff7fab000 0x7ffff7fac000 0x1000 0xe5000 rw-p libm.so.6
0x7ffff7fbb000 0x7ffff7fbd000 0x2000 0x0 rw-p
0x7ffff7fbd000 0x7ffff7fc1000 0x4000 0x0 r--p [vvar]
0x7ffff7fc1000 0x7ffff7fc3000 0x2000 0x0 r-xp [vdso]
0x7ffff7fc3000 0x7ffff7fc5000 0x2000 0x0 r--p x86-64.so.2
0x7ffff7fc5000 0x7ffff7fef000 0x2a000 0x2000 r-xp x86-64.so.2
0x7ffff7fef000 0x7ffff7ffa000 0xb000 0x2c000 r--p x86-64.so.2
0x7ffff7ffb000 0x7ffff7ffd000 0x2000 0x37000 r--p x86-64.so.2
0x7ffff7ffd000 0x7ffff7fff000 0x2000 0x39000 rw-p x86-64.so.2
0x7ffffffdd000 0x7ffffffff000 0x22000 0x0 rw-p [stack]
0xffffffffff600000 0xffffffffff601000 0x1000 0x0 --xp [vsyscall]
打个断点,看看栈上指针 animal2
指向哪里:
(gdb) break 40
Breakpoint 2 at 0x55555555528a: file main.c, line 41.
(gdb) run
(gdb) print animal2
$6 = (Animal *) 0x55555556b2e0
这段空间是堆空间。我们创建的对象 Cow
就是堆上创建的,这个位置是对象的位置。由于例子中的类没有其他成员变量,所以它的在堆上只需要 8 字节存放虚指针,我们将堆上的元数据块一并打印出来。
(gdb) x/4xg 0x55555556b2d0
0x55555556b2d0: 0x0000000000000000 0x0000000000000021
0x55555556b2e0: 0x0000555555557cf0 0x0000000000000000
这里,上面一行(16 字节)是元数据,元数据中的 0x21
表示实际上堆内存申请了 32 字节(包括元数据)。虽然虚表指针只需要 8 字节,但由于 16 字节的对其需要,实际上申请了 32 字节。
animal2
指向 Cow
对象的虚指针应当指向虚表。而虚表是在 .rodata
段的。这里,我们看虚表指针是 0x555555557cf0
,这个位置刚好是 .rodata
段所在的位置。
Cow()
的虚表,就长这个样子:
(gdb) x/4xg 0x555555557ce0
0x555555557ce0 <_ZTV3Cow>: 0x0000000000000000 0x0000555555557d40
0x555555557cf0 <_ZTV3Cow+16>: 0x0000555555555428 0x000055555555536e
实际上,它和其他几个类的虚表是挨在一起的:
0x555555557d00 <_ZTV3Cat>: 0x0000000000000000 0x0000555555557d58
0x555555557d10 <_ZTV3Cat+16>: 0x00005555555553ac 0x00005555555553ea
0x555555557d20 <_ZTV6Animal>: 0x0000000000000000 0x0000555555557d70
0x555555557d30 <_ZTV6Animal+16>:0x0000555555555330 0x000055555555536e
0x555555557d40 <_ZTI3Cow>: 0x00007ffff7e1dc30 0x000055555555603b
0x555555557d50 <_ZTI3Cow+16>: 0x0000555555557d70 0x00007ffff7e1dc30
0x555555557d60 <_ZTI3Cat+8>: 0x0000555555556040 0x0000555555557d70
0x555555557d70 <_ZTI6Animal>: 0x00007ffff7e1cfa0 0x0000555555556048
我们来分析 Cow
类的虚表。他有四个条目,我们先来看前两个:0x000000000000
用于占位,0x555555557d40
是 RTTI 指针,指向 Cow
的类型信息(_ZTI3Cow
)。后面的两个条目就是虚函数指针,第一个指向 Cow::Say()
,第二个条目指向 Animal::Whoami()
。
回过头来再来看看这张图。我们有一个基类,三个派生类。其中,大家都会说自己的话。但是 Cow
和 Pig
并不知道自己是什么。所以它们复用基类的 void Animal::Whoami()
函数。
而且有没有发现,如果你不构造某个类对象,那它就不会生成虚表。这里,我们就没有 Pig()
的虚表。
他有四个指针条目,指向不同的位置。第一个条目是 0x00007ffff7e1dc30
。查看一下内存布局,这里是 动态库 libstdc++
所在的地方。存放着父类的类型信息。
第二个条目是 0x55555555603b
,指向 .rodata
,这是 Cow
的类型名称地址。
(gdb) x/s 0x000055555555603b
0x55555555603b <_ZTS3Cow>: "3Cow"
第三个条目是 0x0000555555557d70
,存放着虚基类的类型信息。是基类的 RTTI 指针。
0x555555557d70 <_ZTI6Animal>: 0x00007ffff7e1cfa0 0x0000555555556048
这里的第四个条目是其他父类信息。由于 Cow
只有一个父类,所以和第一个条目一致。
在 C++ 中,构造函数是不能被声明为虚函数的。因为虚多态依赖虚表,而虚表需要虚指针才能发挥作用。在构造函数执行时,对象尚未完全构造,虚指针还不存在。这时,要是构造函数是虚函数,那它是如何查找的虚函数表呢?
而且,虚表需要构造类对象才能产生。如果不构造类对象,就不会产生该类的虚表。
静态成员函数类似于带作用域的全局函数。它们属于类本身,而非具体的对象。即使你不创建对象,你依然可以使用静态成员函数。在上头我们提到虚多态依赖动态绑定,而动态绑定又依赖于虚表和虚指针。假如我们不构造对象,没有虚指针,如此,静态函数是如何实现动态绑定的?
(AI 总是偏向说静态成员函数没有 implicit this
。实际上大同小异,this
指针需要指向的还是构造对象时产生的虚指针。既然无法属于某个对象,那它自然也不能拥有自己的虚指针。)