多态

The only me is me, but are you sure the only you is you?

多态指在不同条件下表现出不同的状态,C++中有多种多态的实现方式,本文将对这些方式进行总结。

什么是多态?

多态指为不同数据类型的实体提供统一的接口。

graph TD;
    A[Polymorphism]-->B["Compile Time"]; 
    A-->C[Run Time];
    B-->D["Function Overloading"]
    B-->E["Operator Overloading"]
    C-->F["Function Overloading"]

一个不使用多态的例子(静态绑定)

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Account a;
a.withdraw(); // Account::withdraw();

Saving b;
b.withdraw(); // Saving::withdraw();

Checking c;
c.withdraw(); // Checking::withdraw();

Trust d;
d.withdraw(); // Trust::withdraw();

Account *p = new Trust(); // legal
p->withdraw(); // We plan to call Trust::withdraw(), but here the Account::withdraw() will be called

上面的代码在编译器的认知里,p就是一个Account指针,因此他会调用Account的方法(对于传递引用也是如此)。在这种情况下,如果我们需要调用Trust的函数,那么可能需要用if-else语句进行判断,会降低代码的抽象性.因此我们需要一些改进措施。改进如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Account a;
a.withdraw(); // Account::withdraw(); withdraw is virtual in Account

Saving b;
b.withdraw(); // Saving::withdraw();

Checking c;
c.withdraw(); // Checking::withdraw();

Trust d;
d.withdraw(); // Trust::withdraw();

Account *p = new Trust(); // legal
p->withdraw(); //Trust::withdraw();

上面的代码几乎和之前的没有区别,唯一的区别是Account中的withdraw被定义为了虚函数,编译器被告知不要在编译时解析函数,而是在运行时进行动态绑定。在运行时会检查p到底指向那个具体的类

多态的好处

  • 更抽象化思考
  • 让C++指定运行时调用的函数

不同的多态类型

编译期多态(静态多态)

C++通过函数重载的方式实现编译期多态,编译时编译器会根据参数列表自动匹配最合适的函数。

1
2
3
4
5
6
7
8
int Add(int first, int second)
{
return first + second;
}
double Add(double first, double second)
{
return first + second;
}

运算符重载

重载赋值运算符函数

在定义赋值运算符时,我们需要注意以下几点:

  • 返回引用类型确保连续赋值,否则如果函数返回值为void,那么无法通过编译(Effective C++条款10)
  • 传入参数为const 引用(节省空间,防止修改)
  • operator=中处理自我赋值
  • 异常安全性

前两条好解释,第三条看下例:

1
2
a[i] = a[j]; //如果i == j 潜在的自我赋值,如果i=j
*px = *py //如果px和py只想同一个东西

自我赋值不安全的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Bitmap{...};
class Widget{
private:
Bitmap* pb;
};

Widget& Widget::operator=(const Widget& rhs){
delete pb; //停止使用当前bitmap,释放空间
// 问题!! 如果是自我赋值,那么发生了自我销毁

pb = new Bitmap(*rhs.pb); //分配空间
//指针指向一个已经被删除的对象!!
return *this;
}

自我赋值安全但异常不安全的代码

1
2
3
4
5
6
7
Widget& Widget::operator=(const Widget& rhs){
if(this == &rhs) return *this;

delete pb;
pb = new Bitmap(*rhs.pb); //如果new 发生异常(内存不足或拷贝异常),那么pb将指向一块被删除的Bitmap
return *this;
}

一个安全的但是包含分支会导致效率降低的代码

1
2
3
4
5
6
7
8
9
Widget& Widget::operator(const Widget& rhs){
if(this != &rhs){
Widget temp(rhs); //复制一个对象然后将空间交换
Bitmap* pTemp = temp.pb;
temp.pb = pb;
pb = pTemp;
}
return *this;
}
重载++运算符

在重载++运算符前,我们先看下标准的前置和后置递增如何实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//前置递增
int& int::operator++()
{
*this +=1;
return *this;
}

后置递增
const int int::operator(int)
{
int oldValue = *this;
++(*this);
return oldValue;
}

运行时多态(基类指针或引用指向派生类对象)

实现方式

  • 继承
  • 基类指针或引用
  • 虚函数

基类指针

基类指针可以指向子类,因为子类是由基类派生出来的,本质上也是基类的一种,我们可以利用这个特性进行动态绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Account *p1 = new Account();
Account *p2 = new Saving();

p1->withdraw(); //Account::withdraw();
p2->withdraw(); //Saving::withdraw();

//当待处理的对象非常多时,我们可以这样写:
Account *p1 = new Account();
Account *p2 = new Saving();
...
Account *pn = new Trust();
Account *array[] = {p1,p2,...,pn}; //或者vector<Account *>
for(auto i=0;i<array.size();i++){
array[i]->withdraw(); //动态绑定的精髓就是用父类指针指向子类
}

虚函数(运行时动态绑定)

虚函数允许我们像处理基类一样处理子类,从而实现运行时多态。在了解虚函数之前,我们需要明确以下几点:

  • 重载的函数是静态绑定的(overload)
  • 重写的函数是动态绑定的(override)
  • 虚函数是一种overridden函数
普通虚函数和纯虚函数

虚函数可以分为普通虚函数和纯虚函数,普通虚函数父类可以实现,子类需要重新实现,而纯虚函数父类只是一个接口,必须依赖子类的实现。只有类的非构造析构非静态成员函数可以作为虚函数

1
2
3
4
5
6
// 普通虚函数
virtual void Eat(){
hungry--;
};
// 纯虚函数,包含纯虚函数的类叫纯虚类,不能构造对象
virtual void Eat()=0;

为何要使用纯虚函数?因为在有些情况下,基类本身生成对象是不合理的,比如交通工具可以派生出火车汽车,但是交通工具本身生成对象是不合常理的。

在派生类中重写虚函数
  • 函数参数列表和返回值必须和基类完全一致
  • virtual关键字非必须,但建议写上
  • 如果不提供重写的函数,那么会继承基类函数
1
2
3
4
5
class Checking : public Account {
public:
virtual void withdraw(double amount);
...
};
虚析构函数(防止内存泄漏)

当我们销毁多态对象,可能会发生一些问题:当派生类对象申请的内存空间通过基类指针被销毁时,同时基类中的析构函数为普通析构函数,那么我们的派生类对象可能按照不正确的顺序被销毁,或者有一部分根本就不会被销毁,产生了内存泄漏的问题。我们要确保派生类对象从正确的析构函数开始销毁(先销毁派生部分,再销毁基类部分,基类和子类中的析构函数都会被调用)。解决方法也很简单,一旦一个类有虚函数,那就把析构函数定义为虚函数。

1
2
virtual void withdraw();
virtual ~Account();
override关键字

函数的重载与重写一个是静态绑定一个是动态绑定,重写要求函数结构和返回值必须严格对应,我们有时候可能将两者混淆,因此C++11中提供了一个override关键字,强制保证重写。参考下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base{
public:
virtual void say_hello() const{
std::cout << "Hello - I am a base class object" << std::endl;
}
virtual ~Base(){}
};

class Derived : public Base{
public:
virtual void say_hello() { // ! ! ! 粗心!忘了写const,不再是函数重写,而是重载,编译没有错误,但是并不是我们所想的
std::cout << "Hello - I am a derived class object" << std::endl;
}
virtual ~Derived(){}
};

当我们用基类指针方式调用父类和子类的say_hello时,出现的是静态绑定,因此调用的都是基类的say_hello,改进如下:

1
2
3
4
5
6
7
8
class Derived : public Base{
public:
virtual void say_hello() override{ // 加了override却没有重写,编译器报错!
//正确应该为 virtual void say_hello() const override
std::cout << "Hello - I am a derived class object" << std::endl;
}
virtual ~Derived(){}
};

虚函数表

当我们的类包含虚函数时,会构建一个虚表,同时,如果一个类继承了一个有虚函数的类,他也会有一个虚表。虚表为一个指针数组,指向对应的虚函数。由于虚函数采用了虚表,调用时增加了一次内存开销。

graph LR
    subgraph A的虚表
        node0["0x401ED0"]
        node1["0x401F10"]
    end

    subgraph A的虚函数
        node2["A::vfun1"]
        node3["A::vfun2"]
    end

    subgraph A的非虚函数
        node4["A::fun1"]
        node5["A::fun2"]
    end
    node0-->node2
    node1-->node3
虚表的特点:
  • 构建于编译期,虚表的地址是固定不变的,执行期间不能新增或替换。
  • 属于类而非对象,意味着一个类只需要一个虚表供所有对象使用,对象有一个虚表指针,指向使用的虚表 *_vptr

图片名称

动态绑定

动态绑定是通过虚表和虚表指针实现的,例如,假设我们有如下类和及类间继承关系:

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
class A{
public:
virtual void vfun1();
virtual void vfun2();
void fun1();
private:
int m_data1;
int m_data2;
};

class B : public A{
public:
virtual void vfunc1();
void func1();
private:
int m_data3;
};

class C: public B {
public:
virtual void vfunc2();
void func2();
private:
int m_data1, m_data4;
};

其对象模型如下所示:

图片名称

三个类均有虚函数,因此每个类都含有虚表,每个类的每个对象都有虚表指针,A有两个虚函数,故其有两个虚表,分别指向A::vfunc1()A::vfunc2(),B继承于A,故B可以调用A的函数,但B重写了vfunc1(),故B的虚表的两个虚表指针分别指向A::vfunc2()B::vfunc1(),而C继承于B,故类C可以调用B的函数,但类C重写了vfunc2(),故C的两个虚表指针分别指向B::vfunc1()C::vfunc2()。(指向最近的继承的类的虚函数)。

下面,我们定义一个B对象,用A指针指向B

1
2
3
4
5
int main() 
{
B bObject;
A *p = & bObject;
}

由于p是类型为A*的指针,故p只能访问指向基类的部分,但是虚表指针虽然属于基类,但是指向派生类B的虚函数表,故p可以访问B的虚函数,这便是动态绑定的原理。

抽象类

在很多情况下,基类直接生成对象是不合理的,为了解决上述问题,我们引入了纯虚函数(virtual returnType func() = 0;),纯虚函数必须由子类进行实现,注意是必须实现

虚函数表的底层实现

在了解了虚函数的工作机制后,我们进一步看一下,虚函数在底层是如何实现的,为了了解其底层,我们有必要探寻C++在产生汇编以及机器码时的工作过程,这里推荐一个网站C++转汇编,如果使用g++也可以实现类似的功能,就是不太方便,目前还没找到比较好的IDE能够实现该功能2。在探寻底层概念时,我们先明确以下几点:

  • 在每个包含虚函数的类中,编译器秘密地放置了一个成为vpointer的指针,指向vtable

一段C++代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "stdio.h"

class Animal {
public:
virtual void name() { printf("I'm Animal\n"); }
};

class Cat : public Animal {
public:
virtual void name() override { printf("I'm Cat\n"); }
};

void func(Animal *animal) {
animal->name();
}

int main(void) {
func(new Animal());
func(new Cat());
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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
.LC0:     # local constant
.string "I'm Animal" #这些string都在.rodata段,即只读数据段
Animal::name():
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov edi, OFFSET FLAT:.LC0
call puts
nop
leave
ret
.LC1:
.string "I'm Cat"
Cat::name():
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov edi, OFFSET FLAT:.LC1
call puts
nop
leave
ret
func(Animal*):
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov rdx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call rdx
nop
leave
ret
Animal::Animal() [base object constructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov edx, OFFSET FLAT:vtable for Animal+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
nop
pop rbp
ret
Cat::Cat() [base object constructor]:
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call Animal::Animal() [base object constructor]
mov edx, OFFSET FLAT:vtable for Cat+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
nop
leave
ret
main:
push rbp
mov rbp, rsp
push rbx
sub rsp, 8
mov edi, 8
call operator new(unsigned long)
mov rbx, rax
mov QWORD PTR [rbx], 0
mov rdi, rbx
call Animal::Animal() [complete object constructor]
mov rdi, rbx
call func(Animal*)
mov edi, 8
call operator new(unsigned long)
mov rbx, rax
mov QWORD PTR [rbx], 0
mov rdi, rbx
call Cat::Cat() [complete object constructor]
mov rdi, rbx
call func(Animal*)
mov eax, 0
mov rbx, QWORD PTR [rbp-8]
leave
ret
vtable for Cat:
.quad 0
.quad typeinfo for Cat
.quad Cat::name()
vtable for Animal:
.quad 0
.quad typeinfo for Animal
.quad Animal::name()
typeinfo for Cat:
.quad vtable for __cxxabiv1::__si_class_type_info+16
.quad typeinfo name for Cat
.quad typeinfo for Animal
typeinfo name for Cat:
.string "3Cat"
typeinfo for Animal:
.quad vtable for __cxxabiv1::__class_type_info+16
.quad typeinfo name for Animal
typeinfo name for Animal:
.string "6Animal"

从上面的汇编代码中,我们可以看到其生成了类的虚函数表vtable,.quad代表产生一个64位的数值作为地址,不过仅从以上信息,我们并不能知道vtable存放在哪里,为了探究虚函数的位置,我们需要考虑其相对基址的偏移量

参考文献

0%