摘要

本文将详细介绍C++中的继承和虚函数。本文不介绍基础的概念,只详细分析一些需要注意或难以理解的点。

Overview

本质上,为了实现代码复用,提出了继承和多态的概念。而为了实现继承,便有了虚函数的概念。为了实现多态,便有了运行时绑定(动态绑定)的概念。

虚函数的原理

C++编译器会在类中添加一个私有指针*__vptr,用来指向类自身的虚函数表。
通常会将虚函数指针存放在类的起始地址。多继承的环境下,就会存放多个虚函数表指针,指向不同基类的虚函数表。
虚函数表中存放虚函数的地址,其实就是一个数组中,存放函数指针。
在继承时,派生类继承了父类的虚函数表。表现为:

  1. 先复制基类的虚函数列表。
  2. 如果出现了重写,则替换虚函数表中的函数指针。
  3. 如果添加了新的虚函数,则追加在虚函数表的末尾
    此时便可以理解多态了,若指针指向的是基类对象,其实是在基类的虚函数表中调用函数,如果指向的是派生类对象,则在派生类的虚函数表中调用函数,以此来实现多态。

动态绑定

普通成员函数的调用是在编译时发生绑定。而虚函数的调用是在运行时才进行绑定。

1
2
3
4
5
6
class Base;
class A: public Base;
Base* b = new Base();
b->show();
Base* b = new A();
b->show();

基类指针指向派生类对象时,通过该指针调用虚函数,会根据指针所指对象的实际类型来调用相应的函数版本。

有一种情况下,我们可能需要回避动态绑定机制。例如,我们在派生类的虚函数中需要用基类中的函数版本,此时不能出现动态绑定,否则在调用该虚函数时,会被调用派生类的版本,从而导致无限递归。可以通过下面的方式避免:

1
double res = a->A::func();

override 和 final

如果我们不想让一个类被继承,那么可以用final关键字,final关键字在类名后面。例如:

1
2
3
class A final{};

class B final : base{};

override用来表示派生类中的某个函数需要被重写。override仅仅是用来提醒程序员需要进行重写。

1
2
3
4
5
6
7
8
9
class Base{
public:
virtual int func();
};

class A: public Base{
public:
virtual int func() override;
};

静态成员与继承

值得一提的是,静态成员在整个继承体系中都只存在唯一定义。也就是说,不论存在多少派生类,都只有唯一一个静态成员实例。静态成员同意遵循普通的访问控制方式,既可以通过基类调用,也可以通过派生类调用。

静态成员函数不可以是虚函数。因为静态成员不属于任何一个对象,不存在this指针。而虚函数需要通过this指针访问虚函数表。

纯虚函数

利用=0可以将一个成员函数标识为纯虚函数。纯虚函数不需要定义,纯虚函数所在的类是抽象类,抽象类不能创建对象。而抽象类的派生类必须对纯虚函数进行定义,否则仍然是抽象类。实际上,抽象类虽然不能创建对象,但是仍然可以定义构造函数,这用来实现抽象类内部自己的成员初始化过程。
例如:

1
2
3
4
5
6
7
8
9
class abstract_base{
public:
abstract_base() =default;
abstract_base(const std::string &str, double d):str(str),d(d){}
virtual int func(int size) const = 0;
protected:
std::string str;
double d;
};

访问控制

在类中,不仅仅成员有public、protect、private的访问控制权限,类在继承的时候也有相应的访问控制权限。
首先,C++类中的变量默认是private的。
对于成员变量的访问权限:
public: 所有人都可以访问
protect: 不能通过类的对象访问,只有派生类类的成员和友元可以访问。
private: 只有类内可以访问

值得注意的是,尽管protect成员可以在派生类或者友元中进行访问,但是也是只能访问派生类的成员,而不是基类的成员,这个需要注意。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Base{
protected:
int protected_mem;
};
class test: public Base{
friend void func(test &);
friend void func(Base &);
};

void func(test &t){
t.protected_mem = 0; //正确
}

void func(Base &b){
b.protected_mem = 0; //错误
}

接下来来看继承的访问控制:
其实对于继承的访问控制,不会改变类内的访问控制权限,只是改变类的对象的访问控制权限。
对于public继承,那么所以的访问控制权限不变。
但是对于protected继承,那么所有访问控制权限变成protect和private。
对于private继承,那么所有的访问控制权限变成private。
其实就是将访问控制权限进行降低,使得权限不超过继承标明的权限。
例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Base{
public:
int pb;
protected:
int pt;
private:
int pv;
};

class A:public Base;
A.pb; //正确
A.pt; //错误
A.pv; //错误

class B:protected Base;
B.pb; //错误
B.pt; //错误
B.pv; //错误

class C: private Base;
C.pb; //错误
C.pt; //错误
C.pv; //错误

拷贝移动和继承

首先,如果我们在类中定义了拷贝操作或者析构函数,那么编译器就不会为这个类生成合成的移动操作,即使是通过=default的形式使用了合成的析构函数。这是因为如果类中定义了拷贝操作或者析构函数,那么编译器就认为程序员要自己来控制对象的复制和释放,就不会再自动生成了,防止合成出一个不符合程序员意图的移动操作。

通常在继承关系中,都会定义虚析构函数,基类缺少移动操作会阻止派生类有合成的移动操作。所以如果确实需要使用移动操作,应该在基类中首先定义移动操作。

如果基类中的默认构造函数、拷贝操作或者析构函数是删除的或者不可访问的,那么派生类对应的成员就是被删除的。 因为派生类在赋值或者析构时无法为基类进行赋值或者析构。

通常情况下,派生类都会继承基类的构造函数,但是除了两种情况:

  1. 如果派生类定义的构造函数和基类的构造函数具有相同的参数列表,则该构造函数不会被继承。
  2. 默认、拷贝和移动构造函数不会被继承。