C++多态与析构函数

多态(Polymorphism)是面向对象(Object-Oriented,OO)思想”三大特征”之一,其余两个分别是封装(Encapsulation)继承(Inheritance)

多态#

让我们首先查看下面的代码:

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
#include <cstdio>
using namespace std;

class People {
public:
People(const char* name, int age);
void print() const;
protected:
const char* m_name;
int m_age;
};

People::People(const char* name, int age) : m_name(name), m_age(age) {}
void People::print() const {
printf("name: %s, age: %d\n", m_name, m_age);
}

class Student : public People {
public:
Student(const char *name, int age, float score);
void print() const;
private:
float m_score;
};

Student::Student(const char *name, int age, float score) : People(name, age), m_score(score) {}
void Student::print() const {
printf("name: %s, age: %d, score: %g\n", m_name, m_age, m_score);
}

int main()
{
People* p = nullptr;
p = new People("ZhangSan", 25);
p->print();
delete p;

p = new Student("LiSi", 14, 87.5f);
p->print();
delete p;

// 代码还可以这样写
// People *p = new Student("Lisi", 14, 87.5f)
// p虽然是指向People类的指针,但是有了多态后,调用的print()函数却是Student类的
return 0;
}

// result
name: ZhangSan, age: 25
name: LiSi, age: 14
Program ended with exit code: 0

我们直观上认为,如果指针p指向了派生类对象,那么就应该使用派生类的成员变量和成员函数,这符合人们的思维习惯;

但是本例的运行结果却告诉我们,当基类指针 p 指向派生类 Student 的对象时,虽然使用了 Student 的成员变量,但是却没有使用它的成员函数,导致输出结果不伦不类,不符合我们的预期;问题就是这个:

通过基类指针只能访问派生类的成员变量,却不能访问派生类的成员函数。

为了消除这种尴尬,让基类指针能够访问派生类的成员函数,C++ 增加了虚函数(Virtual Function);使用虚函数非常简单,只需要在函数声明前面增加virtual关键字;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class People {
public:
People(const char* name, int age);
virtual void print() const;
protected:
const char* m_name;
int m_age;
};

class Student : public People {
public:
Student(const char *name, int age, float score);
virtual void print() const override;
private:
float m_score;
};

// result
name: ZhangSan, age: 25
name: LiSi, age: 14, score: 87.5
Program ended with exit code: 0

基类指针指向基类对象时就使用基类的成员(包括成员函数和成员变量),指向派生类对象时就使用派生类的成员;

换句话说,基类指针可以按照基类的方式来做事,也可以按照派生类的方式来做事,它有多种形态,或者说有多种表现方式,我们将这种现象称为多态

C++提供多态的目的是:可以通过基类指针对所有派生类(包括直接派生和间接派生)的成员变量和成员函数进行“全方位”的访问,尤其是成员函数;如果没有多态,我们只能访问成员变量;

前面我们说过,通过指针调用普通的成员函数时会根据指针的类型(通过哪个类定义的指针)来判断调用哪个类的成员函数,但是通过本节的分析可以发现,这种说法并不适用于虚函数,虚函数是根据指针的指向来调用的,指针指向哪个类的对象就调用哪个类的虚函数;

借助引用实现多态#

修改上面的main函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main()
{
People p("ZhangSan", 25);
Student s("LiSi", 14, 87.5);

People &rp = p;
People &rs = s;
rp.print();
rs.print();

return 0;
}
// result
name: ZhangSan, age: 25
name: LiSi, age: 14, score: 87.5
Program ended with exit code: 0

不过引用不像指针灵活,指针可以随时改变指向,而引用只能指代固定的对象,在多态性方面缺乏表现力,所以以后我们再谈及多态时一般是说指针;本例的主要目的是让读者知道,除了指针,引用也可以实现多态;

虚函数的注意事项#

  1. 只需要在虚函数的声明处加上virtual关键字,函数定义处不加

  2. 可以只将基类中的函数声明为虚函数,当派生类中出现参数列表相同的同名函数时,自动成为虚函数。

  3. 当在基类中定义了虚函数时,如果派生类没有定义新的函数来覆盖(override)此函数,那么将使用基类的虚函数。

  4. 构造函数不能是虚函数;对于基类的构造函数,它仅仅是在派生类构造函数中被调用,这种机制不同于继承;也就是说,派生类不继承基类的构造函数,将构造函数声明为虚函数没有什么意义。

  5. 析构函数函数有必要声明为虚函数,因为当父类的指针或者引用指向派生类时,如果析构函数非虚,则派生类的析构函数不会被调用,造成内存泄露,具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。所以,为了防止这种情况的发生,C++中基类的析构函数应采用virtual虚析构函数。

    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
    class People {
    public:
    People(const char* name, int age);
    virtual void print() const;
    // 只有定义了虚析构函数,才会调用子类的析构函数
    virtual ~People()
    { cout << "call ~People()\n";}
    protected:
    const char* m_name;
    int m_age;
    };
    People::People(const char* name, int age) : m_name(name), m_age(age) {}
    void People::print() const {
    printf("name: %s, age: %d\n", m_name, m_age);
    }

    class Student : public People {
    public:
    Student(const char *name, int age, float score);
    void print() const override;
    ~Student()
    {cout << "call ~Student()\n"; }
    private:
    float m_score;
    };

    Student::Student(const char *name, int age, float score) : People(name, age), m_score(score) {}
    void Student::print() const {
    printf("name: %s, age: %d, score: %g\n", m_name, m_age, m_score);
    }

纯虚函数和抽象类#

在C++中,可以将虚函数声明为纯虚函数,语法格式为:virtual 返回值类型 函数名(函数参数) = 0;纯虚函数没有函数体,只有函数声明,在虚函数声明的结尾加上=0,表明此函数为纯虚函数;

最后的=0并不表示函数返回值为0,它只起形式上的作用,告诉编译系统“这是纯虚函数”;

包含纯虚函数的类称为抽象类(Abstract Class);之所以说它抽象,是因为它无法实例化

,也就是无法创建对象;原因很明显:

  • 纯虚函数没有函数体
  • 不是完整的函数
  • 无法调用
  • 也无法为其分配内存空间;

抽象类通常是作为基类,让派生类去实现纯虚函数;派生类必须实现纯虚函数才能被实例化。

要注意的是:

  1. 一个纯虚函数就可以使类成为抽象基类,但是抽象基类中除了包含纯虚函数外,还可以包含其它的成员函数(虚函数或普通函数)和成员变量;
  2. 只有类中的虚函数才能被声明为纯虚函数,普通成员函数和顶层函数均不能声明为纯虚函数。
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
#include <cstdio>

using namespace std;

class Line {
public:
Line(float len);
virtual ~Line() {}
virtual float area() const = 0;
virtual float volume() const = 0;
protected:
float m_len;
};

Line::Line(float len) : m_len(len) {}

class Rectangle : public Line {
public:
Rectangle(float len, float width);
virtual float area() const override;
protected:
float m_width;
};

Rectangle::Rectangle(float len, float width) : Line(len), m_width(width) {}
float Rectangle::area() const { return m_len * m_width; }

class Cuboid : public Rectangle {
public:
Cuboid(float len, float width, float height);
virtual float area() const override;
virtual float volume() const override;
protected:
float m_height;
};

Cuboid::Cuboid(float len, float width, float height) : Rectangle(len, width), m_height(height) {}
float Cuboid::area() const { return 2 * (m_len*m_width + m_len*m_height + m_width*m_height); }
float Cuboid::volume() const { return m_len * m_width * m_height; }

class Cube : public Cuboid {
public:
Cube(float len);
virtual float area() const override;
virtual float volume() const override;
};

Cube::Cube(float len) : Cuboid(len, len, len) {}
float Cube::area() const { return 6 * m_len*m_len; }
float Cube::volume() const { return m_len * m_len * m_len; }

int main() {
Line *p = nullptr;

p = new Cuboid(3, 4, 5);
printf("Cuboid[3, 4, 5]: area = %g, volume = %g\n", p->area(), p->volume());
delete p;

p = new Cube(5);
printf("Cube[5]: area = %g, volume = %g\n", p->area(), p->volume());
delete p;

return 0;
}
// RESULT
Cuboid[3, 4, 5]: area = 94, volume = 60
Cube[5]: area = 150, volume = 125

本例中定义了四个类,它们的继承关系为:Line –> Rectangle –> Cuboid –> Cube;

Line 是一个抽象类,也是最顶层的基类,在 Line 类中定义了两个纯虚函数 area() 和 volume();

在 Rectangle 类中,实现了 area() 函数;所谓实现,就是定义了纯虚函数的函数体;但这时 Rectangle 仍不能被实例化,因为它没有实现继承来的 volume() 函数,volume() 仍然是纯虚函数,所以 Rectangle 也仍然是抽象类;

直到 Cuboid 类,才实现了 volume() 函数,才是一个完整的类,才可以被实例化;

可以发现,Line 类表示“线”,没有面积和体积,但它仍然定义了 area() 和 volume() 两个纯虚函数;这样的用意很明显:Line 类不需要被实例化,但是它为派生类提供了“约束条件”,派生类必须要实现这两个函数,完成计算面积和体积的功能,否则就不能实例化;

在实际开发中,你可以定义一个抽象基类,只完成部分功能,未完成的功能交给派生类去实现(谁派生谁实现);这部分未完成的功能,往往是基类不需要的,或者在基类中无法实现的;虽然抽象基类没有完成,但是却强制要求派生类完成,这就是抽象基类的“霸王条款”;

抽象基类除了约束派生类的功能,还可以实现多态;指针 p 的类型是 Line,但是它却可以访问派生类中的 area() 和 volume() 函数,正是由于在 Line 类中将这两个函数定义为纯虚函数;如果不这样做,后面的代码都是错误的;我想,这或许才是C++提供纯虚函数的主要目的;

虚函数 vs 纯虚函数,如何选用?#

  1. 当基类中的某个成员方法,在大多数情形下都应该由子类提供个性化实现,但基类也可以提供缺省备选方案的时候,该方法应该设计为虚函数。
  2. 当基类中的某个成员方法,必须由子类提供个性化实现的时候,应该设计为纯虚函数。

虚函数表-虚函数的实现#

虚函数是用虚函数表实现的(virtual table)。首先,每个使用虚函数的类都有其自身的虚函数表,其实虚函数表就是编译器在编译阶段设置的一个静态数组。其中包含了每一个可以被类的实例调用的虚函数的入口,也就是指向虚函数的指针。

其次,编译器也在基类中设置了一个隐藏指针*__vptr,当类被实例化时,*__vptr被自动创建,指向该类的虚函数表。和作为函数的参数传入的*this指针不同的是,*__vptr是一个真实的指针。

借用侯捷课程的一个图,可以很好的概括虚函数的底层实现:

如上图所示,定义了三个类,A、B和C,B继承于A,C继承于B,A中有两个虚函数,B中有一个,C中也有一个。

编译器将A的对象a在内存中分配如上图所示,只有两个成员变量m_data1和m_data2,与此同时,由于A类有虚函数,编译器将给a对象分配一个空间用于保存虚函数表,这张表维护着该类的虚函数地址(动态绑定),由于A类有两个虚函数,于是a的虚函数表中有两个空间(黄蓝空间)分别指向A::vfunc1()和A::vfunc2();同样的,b是B类的一个对象,由于B类重写了A类的vfunc1()函数,所以B的虚函数表(青色部分)将指向B::vfunc1(),同时B继承了A类的vfunc2(),所以B的虚函数表(蓝色部分)将指向父类A的A::vfunc2()函数;同样的,c是C类的一个对象,由于C类重写了父类的vfunc1()函数,所以C的虚函数表(黄色部分)将指向C::vfunc1(),同时C继承了超类A的vfunc2(),所以B的虚函数表(蓝色部分)将指向A::vfunc2()函数。

同时上图也用C语言代码说明了编译器底层是如何调用这些函数的:

1
(*(p->vptr)[n])(p) // 函数指针调用

这便是面向对象继承多态的本质。

下面让我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base
{
public:
FunctionPointer *__vptr;
virtual void function1() {};
virtual void function2() {};
};

class D1: public Base
{
public:
virtual void function1() {};
};

class D2: public Base
{
public:
virtual void function2() {};
};

编译器为这三个类分别创建了三个虚函数表:一个给基类Base,一个给D1,一个给D2,同时编译器创建了一个指向基类Base的函数指针*__vptr

由于这里只有两个虚函数,所以每个虚函数表中只有两个入口,一个指向function1(),一个指向function2()。下面就让我们对每个类的虚函数表进行一下分析:

  1. Base类型的对象只能访问Base的成员函数,这样结果就是function1()的入口指向了Base::function1(),同理,function2()的入口指向了Base::function2()
  2. D1类型的对象可以访问Base和D1的成员函数,这样结果就是function1()的入口指向了D1::function1(),同理,function2()的入口指向了Base::function2()
  3. 同理,D2类型的对象可以访问Base、D1、D2的成员函数,这样结果就是function1()的入口指向了Base::function1(),同理,function2()的入口指向了D2::function2()

下面是虚函数表的作用图示:

VTable

了解了大概的原理,让我们来继续通过实例看一下当父类的对象通过指针或者引用指向子类会发生什么:

1
2
3
4
int main()
{
D1 d1;
}

当我们创建了一个D1类型的对象时,指针*__vptr会指向类D1的虚表。

紧接着,让我们创建一个base类型的指针指向d1:

1
2
3
4
5
6
7
int main()
{
D1 d1;
Base *dPtr = &d1;

return 0;
}

我们注意到,指针dPtrBase类型的,它只能指向d1Base部分,但是我们发现,*__vptr

在Base的部分,所以dPtr拥有对于该指针的访问权限。同时dPtr->__vptr指向的是D1的虚表,所以,即使指针dPtr的类型是Base,但它依然可以访问d1的虚表。

当我们调用成员函数时:dPtr->function1()

1
2
3
4
5
6
7
8
int main()
{
D1 d1;
Base *dPtr = &d1;
dPtr->function1();

return 0;
}

首先,程序发现function1()是一个虚函数,其次,程序通过dPtr->__vptr来访问D1的虚函数表;再次,它通过虚函数表来查找应该访问function()1的哪个版本,此时在虚函数表中,被设置为了D1::function1(),因此,dPtr->function1()被解析成了D1::function1()。原理还是非常简单的。