前面简单介绍了重定义和重写,以及重写所涉及到的虚函数的基本概念,这里整理一下虚函数和动态多态的进阶内容。

C++使用虚函数机制来实现运行期的多态(动态多态),与之相对的是编译期的多态(静态多态), 对于C++来说,静态多态可以通过函数重载和泛型编程实现,而动态多态则通过虚函数实现。

在讨论的范围内,基类或派生类指针/引用的使用方法通常是完全等效的,示例主要以指针的使用为主。

为了简化讨论,我们不关注public,private,protected修饰的区别,无论是对类的方法还是继承关系的修饰,统一使用public。 我们不关注多继承、菱形继承以及虚继承等复杂情景,只考虑简单的单继承情景,不考虑模板类的处理。

虚析构函数

对于含有虚函数的类,非常建议将基类的析构函数也设置为虚函数,此时派生类的析构函数也全部会自动变为虚函数,这可以保证基类指针指向派生类时, 在销毁时可以正确调用派生类的析构函数。

考虑下面的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>

struct Base {
virtual void show() const { std::cout << "show, from Base class\n"; }

~Base() { std::cout << "Base destructor\n"; }
};

struct Derived : Base {
void show() const override { std::cout << "show, from Derived class\n"; }

~Derived() { std::cout << "Derived destructor\n"; }
};

void test(Base *base) {
std::cout << "test:\n";
base->show();
}

int main() {
Base *tmp = new Base();
test(tmp);
delete tmp;

Base *tmp2 = new Derived();
test(tmp2);
delete tmp2;

return 0;
}

运行结果如下

1
2
3
4
5
6
test:
show, from Base class
Base destructor
test:
show, from Derived class
Base destructor

可以发现两次析构都只调用了基类的析构函数,这是不安全的做法:可能导致派生类中的资源没有被正确释放,产生内存泄漏等。

如果我们将基类的析构函数设置为虚函数

1
2
3
4
5
struct Base {
virtual void show() const { std::cout << "show, from Base class\n"; }

virtual ~Base() { std::cout << "Base destructor\n"; }
};

其它代码保持不变,那么运行结果就会变为

1
2
3
4
5
6
7
test:
show, from Base class
Base destructor
test:
show, from Derived class
Derived destructor
Base destructor

可以发现此时对Base*指向的Derived对象先调用了~Derived(),然后调用~Base(),满足我们的期望。

纯虚函数与抽象类

在C++中,我们可以将虚函数声明为纯虚函数,纯虚函数的特点是没有函数体,只有函数声明。 在虚函数声明的结尾加上=0,即表明此函数为纯虚函数,这并不表示函数返回值为0,只是形式上的记号。

含有纯虚函数的类称为抽象类(Abstract Class),抽象类是无法实例化的(无法创建对象),因为它含有的纯虚函数没有函数体,无法被正常使用。 抽象类通常是作为基类,规定必要的接口形式,让派生类必须对继承的纯虚函数重写,派生类只有在实现了所有的纯虚函数之后,才能被正常实例化。(否则派生类仍然是抽象类)

虽然抽象类无法被构造和使用,但是抽象类指针还是可以正常使用的,它指向的只能是继承它的非抽象类对象。

例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>

struct Base {
virtual void show() const = 0;
};

struct Derived : Base {
void show() const override { std::cout << "show, from Derived class\n"; }
};

void test(Base *base) {
std::cout << "test:\n";
base->show();
}

int main() {
// Base *tmp = new Base(); // error

Base *tmp = new Derived();
test(tmp);
delete tmp;

return 0;
}

虽然纯虚函数的含义是没有实现的函数,但是我们仍然可以为纯虚函数提供定义,不过函数体必须定义在类的外部,并且此时抽象类自身仍然无法被实例化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>

struct Base {
virtual void show() const = 0;
};

void Base::show() const { std::cout << "show, from Base class\n"; }

struct Derived : Base {
void show() const override { Base::show(); }
};

void test(Base *base) {
std::cout << "test:\n";
base->show();
}

int main() {
// Base *tmp = new Base(); // error

Base *tmp = new Derived();
test(tmp);
delete tmp;

return 0;
}

这里在实现派生类时,我们显式调用了抽象基类提供的默认版本Base::show()

1
2
3
struct Derived : Base {
void show() const override { Base::show(); }
};

这只是一种避免代码重复的技巧,并不改变Base对象无法被创建的限制,Derived::show()并不会自动调用Base::show(),我们需要在代码中显式调用。

值得注意的是,如果把普通的纯虚函数show()改成纯虚的析构函数,由于派生类的析构函数会自动调用基类的析构函数,我们并不需要显式调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>

struct Base {
virtual ~Base() = 0;
};

Base::~Base() { std::cout << "~Base\n"; }

struct Derived1 : Base {};

struct Derived2 : Base {
~Derived2() override { std::cout << "~Derived2\n"; }
};

int main() {
// Base *tmp = new Base(); // error

std::cout << "test\n";
Base *tmp1 = new Derived1();
delete tmp1;

std::cout << "test\n";
Base *tmp2 = new Derived2();
delete tmp2;

return 0;
}

运行结果如下

1
2
3
4
5
test
~Base
test
~Derived2
~Base

虚函数表与虚表指针

注意到如果基类含有虚函数,派生类也必然含有同名的虚函数,无论派生类是否对其进行重写。 编译器在处理含有虚函数的类和对象时,进行了额外的配套处理以支持动态多态:

  • 对含有虚函数的类,编译器为其创建了一个虚函数表,记录了当前类型的所有虚函数的信息;(虚函数表在编译期创建,并写死在可执行文件中)
  • 对含有虚函数的实际对象,在内存中有额外的一个虚表指针(每一个对象都有一个指针),指向实际类型对应的虚函数表。(虚表指针在构造对象时自动创建)

在编译器的具体实现中,通常将虚表指针放置在对象所占据内存的首位,在内存分布的角度,派生类对象只是在基类对象后面加了一部分数据,截断前面的部分总是可以得到一个完整的基类对象。

语法标准好像没有对虚函数机制的实现进行具体规定,但是几大编译器选择的实现方案都是类似的。

下面的两个例子可以说明虚表指针的存在,首先不含虚函数的基类和派生类,大小是合理的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

struct Base {
// virtual ~Base() = 0;
int s = 0;
};

struct Derived1 : Base {
int t = 1;
};

int main() {
std::cout << sizeof(Base) << '\n'; // 4
std::cout << sizeof(Derived1) << '\n'; // 8

return 0;
}

然后是含有虚函数的基类和派生类,额外多了一个指针的大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

struct Base {
virtual ~Base() = 0;
int s = 0;
};

struct Derived1 : Base {
int t = 1;
};

int main() {
std::cout << sizeof(Base) << '\n'; // 16
std::cout << sizeof(Derived1) << '\n'; // 24

return 0;
}

对于派生类对象,虽然我们用派生类指针和基类指针都可以指向它,但是我们总是可以通过指针指向的内存中对象的虚表指针,获取对象的实际类型。 换言之,我们通过虚表指针可以在运行期获取类型的部分信息,这是动态多态实现的基础。

我们直接通过下面的示例来解释虚函数表的内容与更新方式,并不关心编译器的具体实现。 我们使用的虚函数表模型记录的是函数名称,但是实际应该是函数签名以解决虚函数之间的重载问题,并且编译器在二进制层面实际上记录的是函数入口的二进制地址。

假设基类Base定义了几个虚函数,它的虚函数表如下(顺序由定义先后决定)

1
Base::func  Base::hello  Base::test

Base的派生类Derived1重写了其中的hello函数,那么在虚函数表中将其替换掉

1
Base::func  Derived1::hello  Base::test

Base的派生类Derived2重写了其中的hellotest函数,那么在虚函数表中将两个都替换掉

1
Base::func  Derived2::hello  Derived2::test

进一步的,如果Derived2的派生类Derived3重写了其中的hello函数,那么在Derived2的虚函数表基础上将其替换掉

1
Base::func  Derived3::hello  Derived2::test

如果Derived2的派生类Derived4添加了新的虚函数run,那么在Derived2的虚函数表基础上添加即可

1
Base::func  Derived2::hello  Derived2::test  Derived4::run

虚函数的核心原理是:通过虚表指针,在运行时可以获取实际对象的类型所对应的虚函数表,通过查询虚函数表,可以得知目前应当调用的是哪一个版本的函数实现,进而实现动态多态。

对于与虚函数同名但是参数列表或其它信息不一致不同的同名非虚函数,是不会被记录到虚函数表的。顾名思义,只有虚函数才会被记录到虚函数表中。

动态绑定与静态绑定

虚函数的使用通常被认为是效率低下的,这并不是查询虚函数表的操作自身产生的,而是主要由不确定性产生的:

  • 普通的非虚函数调用过程(跳转到哪个函数入口)是明确写在二进制程序中的(称为静态绑定),编译器可以对包含函数调用的过程进行整体优化;
  • 但是虚函数的调用过程(跳转到哪个函数入口)是运行时查表确定的(称为动态绑定),编译器必须在函数调用前中断所有的优化操作。

绝大多数的普通函数调用都是静态绑定的,为了实现动态多态,C++专门支持了针对虚函数的动态绑定,在满足一定条件时才能触发。

回到最开始的例子

1
2
3
4
5
void test(Base *base) {
std::cout << "test:\n";
base->hello();
base->show();
}

其中

  • 调用语句base->hello()是静态绑定,因为hello()不是虚函数,直接会跳转到Base::hello()函数。
  • 调用语句base->show()是动态绑定,因为show()是虚函数,通过查表确定跳转到Base::show()还是Derived::show()

动态绑定(即查询虚函数表确定跳转的函数入口地址)仅发生在通过含有虚函数的类型指针或引用不含类型前缀的方式调用虚函数时,由于this指针的存在,在方法内部调用其它方法也可能属于这类情况。 满足这些条件时,由于我们无法在编译期确定指针所指向的实际对象的类型(既可能与指针指向的类型一致,也可能是它的派生类型), 因此无法确定实际希望调用的函数版本:由于调用的函数被标记为虚函数,很可能在派生类中有不同的实现,而虚函数的语义就是让我们根据实际对象类型来决定调用的版本。

动态绑定只是因为不确定缺省的调用版本,将绑定过程从编译时延迟到运行时的灵活做法,只要编译器可以确定调用版本,就会尽可能地避免动态绑定,例如下面两种情况:

  • 如果加上类型前缀<class>::,那么调用关系显然是明确的,可以直接绕过查表环节,保证为静态绑定,例如base->Base::show()是静态绑定,而base->Derived::show()会导致编译错误。

  • 如果使用对象以.的方式调用方法,那么调用关系是明确的:完全由对象类型决定对应的调用版本,保证为静态绑定。例如

    1
    2
    3
    4
    5
    Base a;
    a.show(); // a.Base::show()

    Derived d;
    d.show(); // d.Derived::show()

值得注意的是,在类的方法内部调用其它方法,由于本质还是通过this指针调用方法,也可能存在动态绑定行为,考虑下面的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <iostream>

struct Base {
virtual ~Base() = default;

virtual void func() { std::cout << "Base::func()" << std::endl; }

void call() { std::cout << "Base::call()" << std::endl; }

void test1() {
func();
call();
}

void test2() {
Base::func();
call();
}

virtual void test3() {
func();
call();
}

virtual void test4() {
Base::func();
call();
}
};

struct Derived : Base {
void func() override { std::cout << "Derived::func()" << std::endl; }

void call() { std::cout << "Derived::call()" << std::endl; }
};

int main() {
Derived d;

d.test1();
// Derived::func()
// Base::call()

d.test2();
// Base::func()
// Base::call()

d.test3();
// Derived::func()
// Base::call()

d.test4();
// Base::func()
// Base::call()
return 0;
}

这里我们测试了几种不同的情况:

  • 在基类的普通函数中,调用虚函数和普通函数
  • 在基类的普通函数中,调用加类型前缀的虚函数和普通函数
  • 在基类的虚函数中,调用虚函数和普通函数
  • 在基类的虚函数中,调用加类型前缀的虚函数和普通函数

结果表明:对于基类的方法,无论它自身是不是虚函数,在内部调用虚函数都会触发动态绑定,调用普通方法仍然是静态绑定,加上类型前缀则可以强制静态绑定。

补充

dynamic_cast

除了这里直接通过基类指针调用虚函数的用法,我们还可以通过dynamic_cast将基类指针安全地转换为派生类指针,并调用相应的方法。

dynamic_cast的原理同样是运行时信息typeinfo获取实际对象的类型,而typeinfo是虚函数表除了虚函数之外的附加信息(实践中可能存储在虚表的头部)。 在成功进行类型转换之后,我们对虚函数和非虚函数都可以根据转换后的类型调用最新的实现版本,例如将最开始的例子改为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>

struct Base {
virtual ~Base() = default;

void hello() const { std::cout << "hello, from Base class\n"; }

virtual void show() const { std::cout << "show, from Base class\n"; }
};

struct Derived1 : Base {
void hello() const { std::cout << "hello, from Derived1 class\n"; }

void show() const override { std::cout << "show, from Derived1 class\n"; }
};

struct Derived2 : Base {
void hello() const { std::cout << "hello, from Derived2 class\n"; }

void show() const override { std::cout << "show, from Derived2 class\n"; }
};

void test(Base *base) {
std::cout << "test:\n";

base->hello();

if (Derived1 *d1 = dynamic_cast<Derived1 *>(base)) { d1->show(); }
else if (Derived2 *d2 = dynamic_cast<Derived2 *>(base)) { d2->show(); }
else { base->show(); }
}

int main() {
Base *tmp = new Base();
test(tmp);
delete tmp;

Base *tmp1 = new Derived1();
test(tmp1);
delete tmp1;

Base *tmp2 = new Derived2();
test(tmp2);
delete tmp2;

return 0;
}

运行结果同上。

构造析构时的多态失效

我们需要理解并记住一个原则:在构造函数和析构函数中,不建议调用虚函数。 因为在这两种特殊函数中,当前对象的类型可能是在变化中的,基于虚函数表的动态多态是失效的:虚函数不是动态绑定的而是静态绑定的,虚函数的调用会直接取决于它所处的函数的类型。

这是因为构造和析构对派生类来说是特殊的过程: 构造派生类对象的过程就包括首先构造一个基类对象,构造基类时,虚表指针正在执行基类的虚函数表,因此调用的始终就是基类的版本, 这也是唯一合理的做法,因为派生类对象尚未存在,调用派生类的版本可能涉及到派生类专有数据的处理,这显然是不合适的, 只有完成了派生类对象的创建过程,才会同时把虚表指针指向派生类的虚函数表,这时调用的就是派生类的版本了。析构过程同理。

示例代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#include <iostream>

struct Base {
Base() {
std::cout << "constructor, from Base class\n";
hello();
show();
}

virtual ~Base() {
std::cout << "destructor, from Base class\n";
hello();
show();
}

void hello() const { std::cout << "hello, from Base class\n"; }

virtual void show() const { std::cout << "show, from Base class\n"; }
};

struct Derived1 : Base {
Derived1() {
std::cout << "constructor, from Derived1 class\n";
hello();
show();
}

~Derived1() override {
std::cout << "destructor, from Derived1 class\n";
hello();
show();
}

void hello() const { std::cout << "hello, from Derived1 class\n"; }

void show() const override { std::cout << "show, from Derived1 class\n"; }
};

struct Derived2 : Base {
Derived2() {
std::cout << "constructor, from Derived2 class\n";
hello();
show();
}

~Derived2() override {
std::cout << "destructor, from Derived2 class\n";
hello();
show();
}

void hello() const { std::cout << "hello, from Derived2 class\n"; }

void show() const override { std::cout << "show, from Derived2 class\n"; }
};

void test(Base *base) {
std::cout << "test:\n";
base->hello();
base->show();
}

int main() {
std::cout << "-----------------------------------------\n";

Base *tmp = new Base();
test(tmp);
delete tmp;

std::cout << "-----------------------------------------\n";

Base *tmp1 = new Derived1();
test(tmp1);
delete tmp1;

std::cout << "-----------------------------------------\n";

Base *tmp2 = new Derived2();
test(tmp2);
delete tmp2;

std::cout << "-----------------------------------------\n";

return 0;
}