C++ 继承进阶梳理:权限、转换、隐藏和菱形继承
前言
我一开始学 C++ 继承时,很容易把它理解成"子类可以复用父类代码"。这个理解不能说错,但只记到这里会有两个问题。
第一个问题是代码设计上容易乱用继承。只要看到两个类有相同成员,就想抽一个基类出来,最后类之间的关系看起来很热闹,真正维护时却互相牵着走。
第二个问题是语法细节会突然变多:public、protected、private 继承到底怎么影响成员访问?派生类对象为什么可以赋给基类指针?同名函数为什么不是重载而是隐藏?菱形继承为什么会出现两份基类成员?
这篇文章就围绕一个主线来整理:
text
继承不是单纯少写几行代码,而是在表达一种类型关系。
只要这条线抓住,后面的权限、转换、对象模型和组合选择都会顺很多。
文章目录
- 前言
- 一、先判断继承表达的关系
- 二、继承方式会改变基类成员的访问级别
- 三、派生类到基类转换:能切上去,不能随便切回来
- 四、作用域隐藏:同名函数不一定是重载
- 五、派生类默认成员函数:基类部分也要被正确处理
- 六、继承类模板时,名字查找要更明确
- 七、友元和静态成员:一个不继承,一个全体系共享
- 八、多继承和菱形继承:语法能写,不代表值得设计
- 九、虚继承解决共享基类问题,但成本也更高
- 十、继承和组合怎么选
- 十一、复习时抓住这几句话
一、先判断继承表达的关系
继承最自然的用法,是表达 is-a 关系:
text
Student 是一种 Person。
Teacher 是一种 Person。
也就是说,派生类对象本身也是一种基类对象。这样设计时,基类负责放共同接口和共同状态,派生类负责扩展自己的特殊能力。

先看一个很小的例子:
cpp
#include <iostream>
#include <string>
#include <utility>
class CampusMember {
public:
explicit CampusMember(std::string name)
: name_(std::move(name)) {}
void checkIn() const {
std::cout << name_ << " check in\n";
}
protected:
std::string name_;
};
class Student : public CampusMember {
public:
Student(std::string name, int number)
: CampusMember(std::move(name)), number_(number) {}
void study() const {
std::cout << name_ << " study, number = " << number_ << '\n';
}
private:
int number_;
};
int main() {
Student student("Jack", 1001);
student.checkIn();
student.study();
}
编译运行:
bash
g++ -std=c++17 -Wall -Wextra -pedantic inherit_basic.cpp -o inherit_basic
./inherit_basic
输出为:
text
Jack check in
Jack study, number = 1001
这里我觉得要注意两点。
第一,Student : public CampusMember 不是在说"把 CampusMember 的代码复制一份到 Student 里",而是在说 Student 是一种 CampusMember。所以 Student 可以使用 CampusMember 的公共接口 checkIn()。
第二,派生类构造函数初始化列表中的 CampusMember(std::move(name)),是在初始化派生类对象里的基类部分。一个 Student 对象并不是凭空只有 Student 自己的成员,它里面还有一块 CampusMember 子对象。
二、继承方式会改变基类成员的访问级别
继承定义一般写成这样:
cpp
class Student : public CampusMember {
};
中间的 public 就是继承方式。它会影响基类的 public 和 protected 成员,到了派生类之后还能以什么级别继续被使用。
可以先记这张表:
| 基类成员原来的权限 | public 继承后 |
protected 继承后 |
private 继承后 |
|---|---|---|---|
public |
仍然是 public |
变成 protected |
变成 private |
protected |
仍然是 protected |
仍然是 protected |
变成 private |
private |
派生类不可直接访问 | 派生类不可直接访问 | 派生类不可直接访问 |
这张表也可以压成一句话:
text
基类 public/protected 成员在派生类中的访问级别,取二者中更严格的那个。
如果把访问限制从宽到严排一下:
text
public > protected > private
例如基类里的 public 成员,遇到 protected 继承后,就会变成 protected;遇到 private 继承后,就会变成 private。
这里容易忽略的是:基类的 private 成员也在派生类对象里面,只是派生类语法上不能直接访问它。它不是没被继承,而是被基类自己封装起来了。
所以如果一个成员不希望外部访问,但确实需要派生类使用,可以放到 protected。不过 protected 也不能乱用。它会让派生类知道基类内部细节,基类以后改动时,派生类更容易被影响。
还有两个小规则值得顺手记一下:
cpp
class Derived : Base {
// class 默认 private 继承
};
struct DerivedStruct : Base {
// struct 默认 public 继承
};
虽然有默认值,但实际写代码时最好显式写出继承方式。尤其是 C++ 基础阶段,少让读代码的人猜。
三、派生类到基类转换:能切上去,不能随便切回来
public 继承还有一个很重要的行为:派生类对象可以赋给基类指针或基类引用。
因为 Student 是一种 CampusMember,所以这段代码是合理的:
cpp
Student student("Jack", 1001);
CampusMember* memberPtr = &student;
CampusMember& memberRef = student;
基类指针和引用指向的是派生类对象中的"基类那一部分"。这个过程常被形象地叫作切片或切割。
不过这里要区分三种情况:
| 写法 | 是否可以 | 我怎么理解 |
|---|---|---|
| 派生类对象地址赋给基类指针 | 可以 | 指向派生类对象里的基类子对象 |
| 派生类对象绑定到基类引用 | 可以 | 引用派生类对象里的基类子对象 |
| 派生类对象赋给基类对象 | 可以,但会切片 | 只拷贝基类部分,派生类自己的成员被丢掉 |
| 基类对象直接赋给派生类对象 | 不可以 | 基类对象不一定有派生类那部分成员 |
示例:
cpp
CampusMember baseCopy = student;
这行能编译,但 baseCopy 已经是一个独立的 CampusMember 对象,只保留了 CampusMember 那部分数据。Student 自己的 number_ 不会跟着过去。
反过来就不能直接写:
cpp
// student = baseCopy; // 编译失败:基类对象不能直接赋给派生类对象
基类指针或引用如果要向下转回派生类,也不是随便强转就安全。只有当这个基类指针本来就指向某个派生类对象时,转回对应派生类才有意义。后面学多态时会遇到 dynamic_cast,它就是用来在运行期做更安全的识别。
这部分我会这样记:
text
向上转型自然成立,向下转型必须确认对象真实类型。
四、作用域隐藏:同名函数不一定是重载
继承体系中,基类和派生类各有自己的作用域。如果派生类里定义了和基类同名的成员,派生类成员会隐藏基类同名成员。
最容易误解的是成员函数。只要函数名相同,就会形成隐藏,不要求参数列表相同。
下面这段代码用 using Worker::print; 把基类同名函数引入派生类作用域,这样两个 print 才能一起参与调用选择:
cpp
#include <iostream>
class Worker {
public:
void print() const {
std::cout << "Worker::print()\n";
}
};
class Intern : public Worker {
public:
using Worker::print;
void print(int id) const {
std::cout << "Intern::print(" << id << ")\n";
}
};
int main() {
Intern intern;
intern.print();
intern.print(7);
}
输出为:
text
Worker::print()
Intern::print(7)
如果去掉 using Worker::print;,Intern::print(int) 会隐藏 Worker::print()。这时 intern.print() 找不到可调用的无参版本,代码会编译失败。
也可以在派生类成员函数里显式指定基类作用域:
cpp
Worker::print();
但更重要的是设计上尽量避免在继承体系里写容易混淆的同名成员。代码能通过作用域限定解决,不代表读起来舒服。
五、派生类默认成员函数:基类部分也要被正确处理
派生类对象里包含基类子对象,所以构造、拷贝、赋值和析构都要处理两部分:
text
基类部分
派生类自己的部分
看一个能直接观察顺序的例子:
cpp
#include <iostream>
#include <string>
#include <utility>
class Person {
public:
explicit Person(std::string name = "unknown")
: name_(std::move(name)) {
std::cout << "Person()\n";
}
Person(const Person& other)
: name_(other.name_) {
std::cout << "Person(const Person&)\n";
}
Person& operator=(const Person& other) {
std::cout << "Person::operator=()\n";
if (this != &other) {
name_ = other.name_;
}
return *this;
}
~Person() {
std::cout << "~Person()\n";
}
protected:
std::string name_;
};
class Student : public Person {
public:
Student(std::string name, int number)
: Person(std::move(name)), number_(number) {
std::cout << "Student()\n";
}
Student(const Student& other)
: Person(other), number_(other.number_) {
std::cout << "Student(const Student&)\n";
}
Student& operator=(const Student& other) {
std::cout << "Student::operator=()\n";
if (this != &other) {
Person::operator=(other);
number_ = other.number_;
}
return *this;
}
~Student() {
std::cout << "~Student()\n";
}
private:
int number_;
};
int main() {
Student first("Jack", 1001);
Student second(first);
Student third("Rose", 1002);
first = third;
}
这段代码里有几处值得专门看:
- 派生类构造函数要先调用基类构造函数,再初始化自己的成员。
- 派生类拷贝构造函数要调用基类拷贝构造函数,完成基类部分的拷贝。
- 派生类赋值运算符要显式调用
Person::operator=(other),否则只处理派生类自己的成员是不完整的。 - 析构时顺序反过来,先析构派生类部分,再自动析构基类部分。
运行时能看到大致顺序:
text
Person()
Student()
Person(const Person&)
Student(const Student&)
Person()
Student()
Student::operator=()
Person::operator=()
~Student()
~Person()
~Student()
~Person()
~Student()
~Person()
如果某个类不希望被继续继承,C++11 之后可以直接用 final:
cpp
class Token final {
};
// class SessionToken : public Token {}; // 编译失败
早期也可以把基类构造函数私有化来阻止外部派生类正常构造,但现在 final 更直接,表达意图也更清楚。
六、继承类模板时,名字查找要更明确
课件里还有一个细节我以前容易略过:如果基类本身依赖模板参数,派生类模板里直接写基类成员名,编译器不一定会去依赖基类里找。
下面这个例子里,StackLike<T> 继承自 BufferBase<T>。在 push() 和 top() 里访问基类成员时,我用了 this->:
cpp
#include <iostream>
#include <vector>
template <class T>
class BufferBase {
protected:
void append(const T& value) {
data_.push_back(value);
}
const T& back() const {
return data_.back();
}
private:
std::vector<T> data_;
};
template <class T>
class StackLike : public BufferBase<T> {
public:
void push(const T& value) {
this->append(value);
}
const T& top() const {
return this->back();
}
};
int main() {
StackLike<int> stack;
stack.push(10);
stack.push(20);
std::cout << stack.top() << '\n';
}
输出为:
text
20
这里的关键不是 StackLike 写得多像真正的栈,而是 this->append(value) 这个写法。因为 BufferBase<T> 依赖模板参数 T,编译器在模板定义阶段不会主动假设 append 一定来自这个依赖基类。
常见写法有两种:
cpp
this->append(value);
BufferBase<T>::append(value);
我更喜欢先记 this->,因为它读起来像"从当前对象的继承体系里找这个成员"。
顺便提醒一句:标准库的 std::stack 不是继承 std::vector 实现的,而是容器适配器。也就是说,哪怕某些代码可以用继承写出来,设计上也不一定应该继承。这个判断会在最后的"继承和组合"里再收回来。
七、友元和静态成员:一个不继承,一个全体系共享
继承里还有两个容易被混在一起的小点。
第一个是友元关系不能继承。某个函数是基类的友元,只代表它能访问基类的私有或保护成员,不代表它自动获得派生类的私有或保护成员访问权。
cpp
class Student;
class Person {
friend void display(const Person& person, const Student& student);
protected:
std::string name_;
};
class Student : public Person {
protected:
int number_;
};
void display(const Person& person, const Student& student) {
(void)person.name_;
// (void)student.number_; // 编译失败:友元关系不会从 Person 继承到 Student
}
如果 display 确实需要访问 Student 的保护成员,就要在 Student 里也声明它为友元。不要把"能看基类内部"理解成"能看整个继承体系内部"。
第二个是静态成员。基类里的 static 成员在整个继承体系里只有一份,不会因为派生出很多类就复制很多份。
cpp
#include <iostream>
#include <string>
class Person {
public:
std::string name_;
static int count_;
};
int Person::count_ = 0;
class Student : public Person {
};
int main() {
Person person;
Student student;
std::cout << std::boolalpha;
std::cout << (&person.name_ == &student.name_) << '\n';
std::cout << (&Person::count_ == &Student::count_) << '\n';
}
输出为:
text
false
true
普通成员属于每个对象,所以 person.name_ 和 student.name_ 地址不同。静态成员属于类这一层,Person::count_ 和 Student::count_ 指向同一份数据。
八、多继承和菱形继承:语法能写,不代表值得设计
单继承很好理解:一个派生类只有一个直接基类。
text
Student -> Person
多继承则是一个派生类同时继承多个直接基类:
text
Assistant -> Student
Assistant -> Teacher
多继承本身是 C++ 支持的语法,但它会让对象模型和访问规则复杂很多。先看一个地址偏移的例子:
cpp
#include <iostream>
class Base1 {
public:
int b1 = 1;
};
class Base2 {
public:
int b2 = 2;
};
class Derived : public Base1, public Base2 {
public:
int d = 3;
};
int main() {
Derived object;
Base1* p1 = &object;
Base2* p2 = &object;
Derived* p3 = &object;
std::cout << static_cast<void*>(p1) << '\n';
std::cout << static_cast<void*>(p2) << '\n';
std::cout << static_cast<void*>(p3) << '\n';
}
在常见实现里,Base1 子对象通常放在前面,所以 p1 和 p3 打印出来的地址可能一样;Base2 子对象在对象内部有偏移,所以 p2 可能是另一个地址。
这里不能死记某个固定地址关系,因为对象布局属于实现细节。但它能说明一个事实:多继承下,一个派生类对象内部可能有多个基类子对象,基类指针指向的不是同一个位置。
更麻烦的是菱形继承:
text
Person
/ \
Student Teacher
\ /
Assistant
如果 Student 和 Teacher 都普通继承 Person,那么 Assistant 里面会有两份 Person 子对象:一份来自 Student,一份来自 Teacher。
这会带来两个问题:
- 数据冗余:同一个
Assistant对象里有两份Person::name_。 - 访问二义性:直接访问
assistant.name_时,编译器不知道你要哪一份。
只能显式指定路径:
cpp
assistant.Student::name_ = "left";
assistant.Teacher::name_ = "right";
这个写法能消除二义性,但没有消除两份数据本身。
九、虚继承解决共享基类问题,但成本也更高
虚继承就是为了解决菱形继承中公共基类重复的问题。写法是在中间层继承公共基类时加上 virtual:
cpp
#include <iostream>
#include <string>
#include <utility>
class Person {
public:
explicit Person(std::string name)
: name_(std::move(name)) {}
std::string name_;
};
class Student : virtual public Person {
public:
Student(std::string name, int number)
: Person(std::move(name)), number_(number) {}
protected:
int number_;
};
class Teacher : virtual public Person {
public:
Teacher(std::string name, int id)
: Person(std::move(name)), id_(id) {}
protected:
int id_;
};
class Assistant : public Student, public Teacher {
public:
Assistant(std::string name, int number, int id)
: Person(name), Student(name, number), Teacher(name, id) {}
};
int main() {
Assistant assistant("Alex", 1001, 2001);
assistant.name_ = "Alex Lee";
std::cout << assistant.name_ << '\n';
}
输出为:
text
Alex Lee
加了虚继承后,Assistant 里只保留一份共享的 Person 子对象,所以可以直接访问 assistant.name_。
这里有个构造规则很关键:虚基类由最派生类负责初始化。上面 Assistant 的初始化列表里写了 Person(name),这才是最终那一份共享 Person 的初始化。Student(name, number) 和 Teacher(name, id) 里也写了 Person(...),但当它们作为 Assistant 的中间基类时,这部分不会决定最终虚基类的初始化。
这也是为什么我不太想把虚继承当成常规设计工具。它能解决菱形继承问题,但对象布局、构造顺序、理解成本都会上来。基础阶段先知道它解决什么问题,比急着研究底层实现更有用。
十、继承和组合怎么选
最后还是要回到文章开头那条线:继承表达 is-a,组合表达 has-a。

如果一个类"是一种"另一个类,继承比较自然:
text
Student 是一种 Person。
BMW 是一种 Car。
如果一个类"拥有"另一个类,组合更自然:
text
Car 拥有 Tire。
Computer 拥有 Disk。
组合示例:
cpp
#include <iostream>
#include <string>
#include <utility>
class Tire {
public:
explicit Tire(std::string brand)
: brand_(std::move(brand)) {}
void print() const {
std::cout << brand_ << " tire\n";
}
private:
std::string brand_;
};
class Car {
public:
explicit Car(std::string tireBrand)
: tire_(std::move(tireBrand)) {}
void checkTire() const {
tire_.print();
}
private:
Tire tire_;
};
int main() {
Car car("Michelin");
car.checkTire();
}
输出为:
text
Michelin tire
继承和组合都能复用代码,但它们暴露的信息不一样。
| 方式 | 关系 | 复用特点 | 维护感受 |
|---|---|---|---|
| 继承 | is-a |
派生类基于基类实现扩展 | 派生类更容易受基类变化影响 |
| 组合 | has-a |
通过成员对象完成功能组装 | 类之间依赖更清楚,封装更好 |
课件里提到继承有时被称为白箱复用,因为派生类能看到基类的部分内部细节;组合更像黑箱复用,组合类只通过对象接口使用能力,不需要知道内部实现。
所以我现在更愿意这样判断:
- 如果关系明确是
is-a,并且后面可能需要多态,继承是合理选择。 - 如果关系只是
has-a,用组合。 - 如果继承和组合都能做,优先想组合,除非继承能更准确表达类型关系。
这句话不是教条,但很适合防止"看见重复代码就继承"的冲动。
十一、复习时抓住这几句话
这篇继承内容比较多,我复习时不会从语法表开始背,而会先用几句话把主干搭起来。
第一,public 继承表达 is-a 关系。派生类对象可以当作基类对象使用,所以能自然转换成基类指针或引用。
第二,访问权限不是"继承后都能用"。基类 private 成员派生类不能直接访问;public/protected/private 继承方式会继续收紧基类成员在派生类里的访问级别。
第三,派生类对象里有基类子对象。构造时先基类再派生,析构时先派生再基类;拷贝构造和赋值运算符也要处理基类部分。
第四,同名成员会隐藏基类同名成员,成员函数只要名字相同就会隐藏,不要求参数列表相同。需要时用 Base::name 或 using Base::name。
第五,友元关系不会继承,静态成员在整个继承体系中共享一份。
第六,多继承会让对象模型变复杂,菱形继承会带来数据冗余和访问二义性。虚继承能解决共享基类问题,但理解和实现成本都更高,设计时尽量别主动走到这里。
最后还是那句我最想记住的话:
text
继承不是为了把代码拿过来用,而是为了把类型关系表达清楚。
如果只是"拥有一个对象",组合通常更稳;如果确实是"一种对象",再去使用继承,后面的多态、接口复用和对象转换才会更自然。