前言
在学习完 C++ 的类和对象之后,这节课开始接触一个非常重要的内容------继承。
刚开始看到继承时,我觉得它的语法并不复杂,无非就是:
cpp
class Student : public Person
{
};
但真正学下来以后会发现,继承不仅仅是"让一个类使用另一个类的成员"这么简单,里面还涉及访问权限、对象转换、同名成员隐藏、派生类默认成员函数、多继承、菱形继承、虚继承等很多内容。
所以这篇博客主要对本节课中关于 C++ 继承 的知识进行一次系统整理。
一、继承的基本概念与访问权限
先来看一个比较简单的场景。
假设我们需要定义学生和老师两个类:
cpp
class Student
{
private:
string _name;
int _age;
int _stuId;
};
cpp
class Teacher
{
private:
string _name;
int _age;
string _job;
};
可以发现:
cpp
_name
_age
这两个成员在 Student 和 Teacher 中都存在。
如果以后还有更多身份,比如医生、工人等,每个类都重新写一遍这些公共属性,就会产生很多重复代码。
因此可以把公共部分提取出来:
cpp
class Person
{
protected:
string _name;
int _age;
};
然后让 Student 和 Teacher 继承 Person:
cpp
class Student : public Person
{
private:
int _stuId;
};
cpp
class Teacher : public Person
{
private:
string _job;
};
这里:
-
Person称为基类,也叫父类; -
Student、Teacher称为派生类,也叫子类。
所以我对继承最直接的理解就是:
把多个类共有的内容提取到一个基类中,然后让其他类在这个基类的基础上继续扩展。
继承的基本格式为:
cpp
class 派生类 : 继承方式 基类
{
};

C++ 一共有三种继承方式:
cpp
public
protected
private
而类本身的成员访问权限也有:
cpp
public
protected
private

这两个地方非常容易混。
可以先记住成员本身的权限:
public
类内、类外、派生类都可以访问。
protected
类内可以访问,派生类也可以访问,但是类外不能直接访问。
private
只有当前类内部可以直接访问,派生类不能直接访问。
注意:
基类中的 private 成员并不是从派生类对象中消失了,而是派生类没有权限直接访问。
再看三种继承方式。
public 继承
cpp
class Derived : public Base
{
};
基类中的:
cpp
public -> public
protected -> protected
private -> 不能直接访问
这是实际开发中最常见的一种继承方式。
protected 继承
cpp
class Derived : protected Base
{
};
结果:
cpp
public -> protected
protected -> protected
private -> 不能直接访问
private 继承
cpp
class Derived : private Base
{
};
结果:
cpp
public -> private
protected -> private
private -> 不能直接访问
我觉得比较好记的方法是:
继承方式相当于限制了继承之后能够达到的最高访问权限。

另外还要注意:
cpp
class B : A
{
};
如果是 class,默认采用**private 继承**。
而:
cpp
struct B : A
{
};
如果是 struct,默认采用**public 继承**。
不过平时写代码时最好还是把继承方式明确写出来。
二、派生类和基类之间的转换以及同名成员问题
继承里面有一个非常重要的特点:
public 继承情况下,派生类对象可以转换成基类对象、基类指针或者基类引用。
例如:
cpp
class Animal
{
public:
string _name;
};
class Dog : public Animal
{
public:
int _age;
};
创建一个 Dog:
cpp
Dog d;
可以这样写:
Animal a = d;
也可以:
Animal* p = &d;
还可以:
Animal& ref = d;
这三种情况都是允许的。
为什么可以这样做?
因为一个派生类对象本身就可以理解成:
cpp
派生类对象
=
基类部分
+
派生类自己新增的部分
(插入图片:Dog 对象的内存组成示意图。画一个大矩形表示 Dog,内部上半部分写"Animal 基类部分",下半部分写"Dog 自己新增的成员"。)
所以 Dog 对象中本来就包含一个 Animal 部分。
例如:
Animal a = d;
就是把 Dog 中属于 Animal 的那部分取出来。
这种现象也叫:
对象切片。
因为 Dog 中自己新增的:
_age

但是反过来就不能直接完成:
Animal a;
Dog d = a;
这是不允许的。
因为 Animal 中并没有 Dog 新增加的那部分成员。
所以可以简单记成:
派生类 -> 基类
通常可以
基类 -> 派生类
不能直接完成
这个知识点在以后学习多态的时候还会大量出现。
继承中另一个容易混淆的问题就是同名成员隐藏。
例如:
cpp
class Base
{
public:
void Func()
{
cout << "Base::Func()" << endl;
}
};
cpp
class Derived : public Base
{
public:
void Func(int x)
{
cout << "Derived::Func(int)" << endl;
}
};
这里:
Base::Func()
和:
Derived::Func(int)
虽然参数不同,但是它们并不是函数重载。
因为函数重载要求:
函数必须位于同一个作用域。
而这里一个属于 Base,一个属于 Derived。
这种情况叫做:隐藏。
派生类中只要出现了和基类同名的成员,就会把基类中的同名成员隐藏起来。
如果还想调用基类中的函数,可以指定作用域:
cpp
Derived d;
d.Func(10);
d.Base::Func();
成员变量也一样。
例如:
cpp
class Base
{
public:
int _num = 10;
};
class Derived : public Base
{
public:
int _num = 20;
void Print()
{
cout << _num << endl;
cout << Base::_num << endl;
}
};
输出:
20
10
所以实际写代码的时候,最好尽量避免基类和派生类中出现大量同名成员。
三、派生类的构造、拷贝、赋值与析构
派生类对象并不是只有自己定义的成员,它里面还有基类部分。
因此派生类在构造、拷贝、赋值和析构的时候,都需要同时处理:
基类部分
+
派生类自己的部分
例如:
cpp
class Person
{
public:
Person(string name)
: _name(name)
{
cout << "Person()" << endl;
}
protected:
string _name;
};
派生类:
cpp
class Student : public Person
{
public:
Student(string name, int id)
: Person(name)
, _id(id)
{
cout << "Student()" << endl;
}
private:
int _id;
};
这里:
cpp
Person(name)
就是负责初始化 Student 对象中的基类部分。
创建:
cpp
Student s("Jack", 1001);
构造顺序是:
cpp
Person
↓
Student
也就是说:
先构造基类,再构造派生类。
我觉得可以理解成盖房子:
先打地基
再盖上层
拷贝构造也是一样。
例如:
Student s1("Jack", 1001);
Student s2(s1);
可以写成:
cpp
Student(const Student& s)
: Person(s)
, _id(s._id)
{
}
这里:
cpp
Person(s)
负责调用基类的拷贝构造函数。
赋值运算符也要分别处理两个部分:
cpp
Student& operator=(const Student& s)
{
if (this != &s)
{
Person::operator=(s);
_id = s._id;
}
return *this;
}
为什么要写:
cpp
Person::operator=(s);
因为派生类自己的 operator= 会隐藏基类中的同名函数,所以要显式指定基类作用域。
析构顺序则和构造正好相反。
构造:
Base
↓
Derived
析构:
Derived
↓
Base
可以记成:
谁最后构造,谁最先析构。
就像搭积木一样,搭的时候先放下面,拆的时候先拆上面。

另外,如果一个基类以后要用于多态,一般会把析构函数写成虚函数:
virtual ~Base()
{
}
否则下面这种情况:
Base* p = new Derived;
delete p;
可能不能正确执行完整的派生类析构过程。
这一点后面学习多态时还会继续详细学习。
四、继承中的一些特殊规则
除了前面的基本继承规则,还有几个比较零散但比较重要的知识点。
首先是 final。
如果一个类不希望再被其他类继承,可以写:
cpp
class Config final
{
};
此时再写:
class MyConfig : public Config
{
};
就会编译报错。
所以:
cpp
final
放在类名后面表示:
这个类不能再继续被继承。
第二个是友元。
要记住:
友元关系不能继承,也就是说基类友元不能访问派⽣类私有和保护成员。
例如某个函数是 Base 的友元:
cpp
class Base
{
friend void Func(Base& b);
};
即使:
cpp
class Derived : public Base
{
};
这个函数也不会自动成为 Derived 的友元。
如果它想访问 Derived 自己的私有成员,还需要 Derived 单独声明。
cpp
class Student;
class Person
{
public:
friend void Display(const Person& p, const Student& s);
protected:
string _name; // 姓名
};
class Student : public Person
{
protected:
int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{
cout << p._name << endl;
cout << s._stuNum << endl;
}
int main()
{
Person p;
Student s;
// 编译报错:error C2248: "Student::_stuNum": ⽆法访问 protected 成员
// 解决⽅案:Display也变成Student 的友元即可
Display(p, s);
return 0;
}
还有 static 静态成员。
基类定义了static静态成员,则整个继承体系⾥⾯只有⼀个这样的成员。⽆论派⽣出多少个派⽣类,都只有⼀个static成员实例。
例如:
cpp
class Person
{
public:
static int _count;
};
int Person::_count = 0;
然后:
cpp
class Student : public Person
{
};
class Teacher : public Person
{
};
执行:
cpp
Student::_count++;
Teacher::_count++;
cout << Person::_count << endl;
结果为:
2
因为 static 成员属于整个类,而不是每一个对象单独保存一份。
在这个继承体系中,大家访问的是同一个 _count。
五、多继承、菱形继承与虚继承
前面接触的继承基本都是:
class Student : public Person
一个类只有一个直接基类,这叫:
单继承。

而 C++ 还支持一个类同时继承多个类。
例如:
cpp
class Singer
{
};
class Dancer
{
};
class Performer : public Singer, public Dancer
{
};
这就是:
多继承。

但是多继承会带来一个非常经典的问题------菱形继承。
例如:

代码:
cpp
class Person
{
public:
string _name;
};
class Student : public Person
{
};
class Teacher : public Person
{
};
class Assistant : public Student, public Teacher
{
};
这里 Student 有一个 Person 部分,Teacher 也有一个 Person 部分。
所以最终 Assistant 中就会存在:
两份 Person
从而带来两个问题:
数据冗余
访问二义性
例如:
cpp
Assistant a;
a._name = "Tom";
编译器不知道到底应该访问:
cpp
Student -> Person -> _name
还是:
Teacher -> Person -> _name
因此会报二义性错误。
虽然可以通过:
cpp
a.Student::_name = "Tom";
指定访问哪一份数据,但是对象中仍然存在两份 Person。

真正解决这个问题,需要使用:
虚继承。
把代码修改成:
cpp
class Student : virtual public Person
{
};
class Teacher : virtual public Person
{
};
此时:
cpp
class Assistant : public Student, public Teacher
{
};
Assistant 中的 Person 公共基类部分只会保留一份共享实例。
因此就解决了普通菱形继承中的:
数据冗余
访问二义性
多继承中还有一个比较有意思的现象。
例如:
cpp
class A
{
public:
int _a;
};
class B
{
public:
int _b;
};
class C : public A, public B
{
public:
int _c;
};
然后:
cpp
C obj;
A* p1 = &obj;
B* p2 = &obj;
C* p3 = &obj;
在常见的编译器对象布局中,可以理解成:
cpp
C 对象起始地址
↓
┌──────────────┐ ← C*、A* 通常指向这里
│ A 基类子对象 │
├──────────────┤ ← B* 指向这里
│ B 基类子对象 │
├──────────────┤
│ C 自己的成员 │
└──────────────┘
所以通常是:
cpp
C* -> 0x1000
A* -> 0x1000
B* -> 0x1000 + 偏移量
原因是 A 是 C 的第一个基类子对象,通常放在 C 对象起始位置 ;B 排在 A 后面,因此 C* -> B* 时编译器需要自动调整地址。
不过严谨地说,C++ 标准并不要求所有编译器必须严格采用这种具体内存排列。这是主流 ABI/编译器下最典型、也是我们学习多继承指针偏移时通常采用的模型。
拓展:IO库中的菱形虚拟继承

cpp
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_ostream : virtual public std::basic_ios<CharT, Traits>
{};
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_istream : virtual public std::basic_ios<CharT, Traits>
{};
六、继承和组合应该怎么选择?
学完继承之后,很容易产生一种想法:
只要两个类之间有代码可以复用,就使用继承。
其实这种想法并不完全正确。
除了继承之外,还有一种非常常见的代码复用方式:
组合。
判断什么时候使用继承,什么时候使用组合,我觉得可以通过两个关系来理解。
如果两个类之间是:
is-a
也就是:
A 是一种 B。
一般适合使用继承。
例如:
cpp
Dog is an Animal
Student is a Person
BMW is a Car
所以可以:
cpp
class BMW : public Car
{
};
如果两个类之间是:
has-a
也就是:
A 拥有一个 B。
一般更适合使用组合。
例如:
cpp
Car has an Engine
Computer has a CPU
那么可以写:
cpp
class Engine
{
};
class Car
{
private:
Engine _engine;
};
这里汽车"有一个"发动机,而不是汽车"是一种"发动机。

再比如实现一个栈。
如果直接继承 vector:
cpp
template<class T>
class Stack : public vector<T>
{
};
虽然代码复用了,但是 vector 本身具有:
cpp
insert
erase
operator[]
等很多接口。
而 Stack 按照数据结构的设计,真正应该提供的主要是:
cpp
push
pop
top
如果直接继承 vector,就会把很多不应该暴露的接口也暴露出去。
更合理的方式是:
这里就是:
Stack has a vector
采用组合更加合理。
所以在继承和组合都能够满足需求的时候,一般更加推荐优先考虑组合,因为组合的耦合程度通常会更低。
七、本节总结
这节课关于继承的内容虽然比较多,但是整体还是可以按照一条主线串起来:
为什么需要继承
↓
基类和派生类
↓
继承权限
↓
派生类向基类转换
↓
同名成员隐藏
↓
构造、拷贝、赋值、析构
↓
多继承和菱形继承
↓
虚继承
↓
继承与组合
我觉得学习继承最重要的并不是单纯记住:
cpp
class Derived : public Base
这行语法,而是理解一个派生类对象实际上包含:
基类部分
+
派生类自己的部分
理解这一点之后,很多知识都会顺下来。
比如为什么:
Derived -> Base
可以转换;
为什么派生类构造之前要先构造基类;
为什么析构时又要反过来;
为什么多继承时不同基类指针可能存在地址偏移;
这些问题其实都和对象内部的组成结构有关。
另外,这一节还让我开始接触到一个比较重要的面向对象设计思想:
is-a -> 继承
has-a -> 组合
继承并不只是为了少写几行代码,更重要的是表达不同类之间真正存在的逻辑关系。
后面继续学习 多态、虚函数和虚函数表 时,还会继续用到这一节关于继承的大量知识。