C++中的类: 继承和多态
继承和多态
继承机制的出现:
- 复用已有的目标代码。
- 对事物进行分类:
- 派生类是基类的具体化。
- 把事物(概念)以层次结构表示出来,有利于描述和解决问题。
- 增量开发的需要。
单继承
声明不能带基类列表:
1 | class UndergraduateStudent : public Student; // 错误声明 |
继承方式(public / protected / private)
语法:
1 | class Derived : 继承方式 Base { |
常见三种继承方式:
public公有继承(最常用,表示“是一种”关系)protected受保护继承private私有继承(更像“以……实现”的关系)
假设在基类 Base 中有:
1 | class Base { |
派生类中,这三种继承方式对“在派生类里的可见性”影响如下:
| 继承方式 | Base::pub 在 Derived 中 |
Base::prot 在 Derived 中 |
Base::priv 在 Derived 中 |
|---|---|---|---|
class D : public Base |
public |
protected |
不可访问 |
class D : protected Base |
protected |
protected |
不可访问 |
class D : private Base |
private |
private |
不可访问 |
注意:
- 基类的
private成员在派生类中始终不可访问(只能通过基类的public/protected成员间接访问)。 - “不可访问”是指:在派生类的成员函数里不能直接写
priv或this->priv等代码;
但派生类对象的内存中仍然包含这一成员,只是只能通过基类的接口(如Base::setPriv()这类函数)来间接读写它。
public 继承的典型例子
1 | class Student { |
id在派生类中仍为public,对象外部可以通过UndergraduateStudent实例访问。nickname在派生类中为protected,只能在Student/UndergraduateStudent的成员函数中访问。- 重写
virtual showInfo()为后续的多态打下基础。
初始化和销毁
派生类对象的初始化由基类和派生类共同完成。
构造函数的执行次序为基类的构造函数->派生类对象成员类的构造函数->派生类的构造函数。
析构函数的执行次序与构造函数刚好相反。
基类构造函数的调用:
- 缺省的话执行基类默认构造函数。
- 如果要执行基类的非默认构造函数,则必须在派生类构造函数的成员初始化表里指出。
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
28class A {
int x;
public:
A() { x = 0; }
A(int i) { x = i; }
};
class B : public A {
int y;
public:
B() { y = 0; }
B(int i) { y = i; }
B(int i, int j) : A(i) { y = j; }
};
B b1; // 执行A::A()和B::B()
B b2(1); // 执行A::A()和B::B(int)
B b3(0, 1); // 执行A::A(int)和B::B(int, int)
// 如果B的构造函数和A一致,可以直接使用using A::A;来继承A的构造函数
// 拷贝构造函数/移动构造函数
class C : public A {
...
// 显式调用A的拷贝构造函数/移动构造函数,如果你不写的话编译器会自动生成默认的移动/拷贝构造(满足条件时),里面也会自动去调用基类的那个构造函数
C(const C& other) : A(other) {} // 直接传参即可
C(C&& other) : A(std::move(other)) {} // 要将左值变为右值
}
虚函数
- 类型相容:
- 类与类之间的相容关系
- “类型相容->赋值相容”:如果类型相容,就可以做赋值/初始化。
- 对象赋值:
A a; B b; class B : public Aa = b合法吗,但是赋值后a只保留A的部分,b作为派生类B独有的那部分属性在a里不复存在。- 用“对象的身份发生变化 / 属于派生类的属性已不存在”来形容这个切片。
- 指针/引用赋值(不发生切片)
B* pb; A* pa = pb; class B : public AB b; A& a = b; class B : public A- 基类指针/引用可以指向派生类对象(类型相容);对象本身没变,仍然是一个
B,只是通过A*/A&的视角去看它。
1 | class A { |
虚函数绑定
前期绑定(Early Binding):
- 发生在编译时。
- 根据表达式的静态类型决定调用哪个函数。
- 效率高,但是多态性差。
- 非
virtual成员函数默认使用前期绑定。
动态绑定(Late Binding):
- 发生在运行时。
- 根据对象的实际类型(动态类型)决定调用哪个函数。
- 灵活性高(支持多态),效率略低(要查虚函数表)。
- 必须在基类函数前显示写
virtual。
1 | class A { |
限制:
- 如基类中被定义为虚成员函数,则派生类中对其定义的成员函数均为虚函数(都在虚函数表里)。
- 只有类的非静态成员函数可以是虚函数。
- 静态成员函数不能是虚函数(
static成员只有一份存储)。 - 构造函数不能是虚函数(构造函数会负责给虚函数创建虚函数表来进行绑定,如果构造函数都为虚函数则无法创建),析构函数可以(往往应当)是虚函数。
动态绑定的实现
1 | class A { |
- 每个含有虚函数的类,编译器通常会为其生成一张虚函数表(vtable):
A_vtable里放着指向A::f,A::g的函数指针;B_vtable里放着指向B::f,A::g的指针(f被覆盖,g继承)。
- 每个对象里“额外藏着一个指针”(通常称为
vptr),指向它所属类的vtable:- 若
p = &a;,则p里的vptr指向A_vtable; - 若
p = &b;,则vptr指向B_vtable。
- 若
- 调用
p->f()时,流程就是:
通过vptr找到vtable,然后在vtable的对应槽里取出函数指针再调用,从而在运行时选中A::f或B::f。

构造函数中的虚函数调用
1 | class A { |
- 在
A的构造函数中调用f():- 即使实际对象是
B,也只会调用A::f,不会调到B::f。 - 因为这时对象还处于“正在构造A部分”的阶段,动态类型还视为
A。
- 即使实际对象是
- 构造完成以后(例如通过
A* p = &b; p->f();),才会按实际类型B做动态绑定,调用B::f。
final和override
1 | struct B { |
override:告诉编译器“这个函数应该重写某个基类虚函数”。- 若签名对不上 / 基类没有对应虚函数,编译期报错,防止“写错函数原型导致没重写成功”的隐患。
final:- 作用在虚函数上:禁止派生类继续重写该虚函数。
- 作用在类上:该类不能再被继承。
访问控制
访问控制检查在编译期按静态类型来:
1 | struct B { |
友元和protected
友元关系不继承、不传递,也不靠“同名”共享。谁把你声明成friend,你就只能操作谁的私有/保护成员:
1 | class Base { |
纯虚函数和抽象类
纯虚函数(pure virtual):
1 | class AbstractClass { |
- 在函数原型后面写
=0,表示“这个函数在这个类里没有实现(或视作没有)”。 - 通常只给声明,不写函数体。
抽象类:
- 至少包含一个纯虚函数的类就是抽象类。
- 抽象类不能直接创建对象:
1
2AbstractClass a; // 错
AbstractClass* p; // 只能通过指针/引用使用 - 抽象类给派生类提供一个接口/框架,派生类必须实现这些纯虚函数。
例子:
1 | class Figure { |
纯虚函数和抽象类的应用非常广泛,常见的包括抽象工厂模式(Abstract Factory),和java的其实差不多。
虚析构函数
只要通过“基类指针删除派生类对象”,基类析构函数就应该是虚函数。
通过基类指针管理派生类对象:
1
2
3
4
5class B { ... };
class D : public B { ... };
B* p = new D;
delete p; // 这里安全吗?如果
~B()不是virtual:delete p;只会调用B::~B();D自己那部分资源(比如动态分配的内存)不会被释放;
有资源的例子
1
2
3
4
5
6
7
8
9
10class mystring { ... };
class B { ... };
class D : public B {
mystring* name = new mystring; // 在堆上申请资源
public:
~D() { delete name; }
};
B* p = new D;
delete p; // ?调到哪一个析构?如果
B::~B()不是虚函数:delete p;只执行B::~B(),D::~D()完全不会被调用,name永远没被delete,资源泄漏。
所以需要虚析构函数:
1 | class B { |
那么delete p;会根据对象实际类型D进行动态绑定:
- 先调用
D::~D()(释放name等D的资源); - 再调用
B::~B()(释放基类B的资源)。
私有继承
private继承表示“implemented‑in‑term‑of”(用来实现派生类),不是表示“is‑a”(public表示这样的关系,所以使用共有继承的时候要考虑清楚)。派生类可以重用基类的实现(包括protected成员、虚函数等),但并不把基类的接口暴露给类外用户。
1 | struct Base { |
默认参数值
虚函数是动态绑定的,但默认参数值是静态绑定的,所以“绝对不要在派生类里重新定义继承来的默认参数”。
1 | class A { |
多继承
定义:
1 | class Derived : 继承方式1 Base1, 继承方式2 Base2, ... { |
- 继承方式和访问控制都和单继承一致,派生类拥有所有基类的所有成员。
- 派生类包含每个基类的虚函数表的指针。
基类顺序和名冲突
基类顺序
1 | class A { int x; ... }; |
class D : B, C中B, C的顺序会决定:
- 构造/析构时调用基类构造/析构函数的次序(从左到右的顺序,和构造函数中的顺序无关,析构相反)。
- 基类子对象在
D对象内的存储布局顺序。
名冲突
当多个基类里有同名成员时,需要写成:
1 | d.B::x; // 明确用B里的x |
虚基类
如果B和C都从A公有继承,而D又继承B和C,那么D里会有两份A子对象(两份A::x)。
为了解决这个问题引入了虚基类:
1 | class A { ... }; |
- 原来
D有两份A(B::A和C::A),改成虚继承后,D只保留一份共享的A子对象,B和C都引用这同一份。 - 所以
D不再有B::x和C::x两份A::x,而是一份合并后的A::x。 - 虚基类的构造函数由“最派生类”负责调用,上例就是
D的构造函数负责构造那唯一的一份A; - 在构造顺序上,虚基类先于非虚基类构造。





