C++使用虚函数机制来实现运行期的多态(动态多态),与之相对的是编译期的多态(静态多态), 对于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
31
32
33
34
35
36
37
38
39
40
41
42
43
#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();
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
test:
hello, from Base class
show, from Base class
test:
hello, from Base class
show, from Derived1 class
test:
hello, from Base class
show, from Derived2 class

我们重点关注下面的函数

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

在这个示例中三次调用的hello()函数是一样的, 因为三次传入的都是基类指针,对象指针的类型直接决定了调用哪一个类型的hello()函数,也就是说三次都调用了基类的hello()函数, 调用关系在编译期即可确定。

但是与之不同的是,三次调用的show()函数是不一样的,分别调用了基类的show()函数和两个派生类的show()函数, 这是因为三次的基类指针指向的实际对象类型是不一样的(C++允许使用基类指针指向派生类对象), 对于虚函数的实际调用取决于指针指向的实际对象类型,实际对象类型只能在运行期确定,这导致实际调用关系在运行期才确定,这就是动态多态。

只有对虚函数的调用才能达到动态多态效果,对非虚函数的调用是无法达到的。

虚函数基础

基本使用

虚函数是一种特殊的类成员函数,如下的几类函数不能被定义为虚函数:

  • 友元函数,它不是类的成员函数
  • 全局函数
  • static静态成员函数,它没有this指针
  • 构造函数,拷贝构造函数,以及赋值运算符重载(可以但是一般不建议作为虚函数)

对于基类来说,在声明虚函数时(或在类中直接定义时)必须使用virtual前缀修饰,例如

1
2
3
4
5
struct Base {
virtual void show() const;
};

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

对于派生类可以重写同名的虚函数成员,在声明重写的虚函数时(或在类中直接定义时)建议使用virtual前缀修饰

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

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

派生类当然也可以选择不重写虚函数,此时对待虚函数和非虚函数是一样的,派生类会直接继承基类中的实现。

对虚函数的重写会形成动态多态,使用指针来调用函数时,实际调用的版本由指向的实际对象类型决定。对非虚函数的重写则是简单的函数重载,使用指针来调用函数时,实际调用的版本直接由指针类型决定。

在涉及到虚函数重写时,支持添加很多修饰词,例如

1
2
3
void show() const override;
void show() const final;
virtual void show() const;

这些修饰词的效果略有区别:

  • 对于在基类中的虚函数,在派生类中的同名函数自动成为虚函数,并不需要virtual前缀修饰,它没有任何效果;
  • 在派生类中重写时加上override后缀修饰,可以让编译器进行检查,确保当前正在对基类中的虚函数重写,避免低级错误;(主流做法,也是最建议的用法)
  • 如果我们使用final修饰词,可以让编译器进行检查,禁止当前类的派生类继续对此虚函数进一步重写,避免低级错误。

虚析构函数

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

考虑下面的例子

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" << std::endl; }
};

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

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

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" << std::endl; }
};

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

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) << std::endl; // 4
std::cout << sizeof(Derived1) << std::endl; // 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) << std::endl; // 16
std::cout << sizeof(Derived1) << std::endl; // 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()

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

没有同时达到这几个条件时,函数调用都是静态绑定:

  • 如果当前指针对应的类型不含有虚函数,显然无法调用虚函数,虽然它的派生类可能含有这个虚函数,但是不允许通过基类指针调用。
  • 如果调用的不是虚函数,即使基类和派生类实现了同名的非虚函数,但是这被视作普通的函数重载,调用关系是明确的:完全由指针类型决定,如果通过基类指针就只能调用基类实现的版本。
  • 如果不是通过指针或引用调用,而是通过.的方式调用,则无论调用的是不是虚函数,调用关系都是明确的:完全由对象类型决定,如果派生类实现了此方法,则调用派生类实现的版本,否则调用它所继承的版本。

除此之外,也可以通过加上::限定符来明确调用关系,直接绕过查表,例如base->Base::show()是静态绑定,即使它满足上述条件。

补充

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;
}