前言:继承这件事,比你想的要复杂
C++中的继承方式,有
public、protected和private三类。
如果你写过 Java 或 Python,你大概觉得继承不过就是 extends 一下,子类自动拥有父类的成员,天下太平。但在 C++ 里,继承是一个同时涉及访问控制、对象模型、构造析构顺序、内存布局的复杂话题。
这篇文章的目标是用代码和图示,把 C++ 继承的每一个角落讲清楚------从最基础的概念,到菱形继承的内存模型,再到什么时候该用继承、什么时候该用组合。
一、继承的本质:代码复用 + 多态的基础
1.1 一个再简单不过的例子
cpp
class Person {
public:
void identity() {
cout << "姓名:" << _name << endl;
}
protected:
string _name = "张三";
int _age = 18;
};
class Student : public Person {
public:
void study() {
identity(); // 直接用父类的方法
// 做学生该做的事...
}
protected:
int _stuId; // 学号
};
class Teacher : public Person {
public:
void teaching() {
identity(); // 同样复用父类方法
}
protected:
string _title; // 职称
};
这里 Student 和 Teacher 都是 Person 的派生类。它们自动拥有 Person 的 identity() 方法和 _name、_age 成员,无需重复写一遍。这就是继承最直接的价值:把公共部分抽取到基类,各派生类只关注自己独有的东西。
1.2 继承的语法格式
cpp
class 派生类 : 继承方式 基类 {
// ...
};
继承方式有三种:public、protected、private。这个关键字决定了基类成员在派生类中的访问权限如何变化。
二、继承方式与访问权限:一张表搞定
很多教程在这部分讲得云里雾里,其实核心就一张表:
| 基类成员访问权限 | public继承 |
protected继承 |
private继承 |
|---|---|---|---|
public |
派生类中为 public |
派生类中为 protected |
派生类中为 private |
protected |
派生类中为 protected |
派生类中为 protected |
派生类中为 private |
private |
派生类中不可见 | 派生类中不可见 | 派生类中不可见 |
无论哪种继承方式,基类的
private成员在派生类中永远不可直接访问。 但请注意------这些private成员确实被继承下来了,存在于派生类对象的内存空间中,只是语法层面不让你直接碰。
记住一个口诀:权限只降不升,private 永远不让你看见。
cpp
class Base {
public: int _pub = 1;
protected: int _pro = 2;
private: int _pri = 3; // 派生类永远看不到
};
class Derive : public Base {
public:
void test() {
cout << _pub << endl; // OK, 仍是 public
cout << _pro << endl; // OK, 仍是 protected
// cout << _pri << endl; // ERROR! private 不可见
}
};
实际工程中,99% 的场景用 public 继承就够了。 protected 和 private 继承属于"我知道有这么个东西,但基本不会故意去用"的知识点。
三、父子对象赋值转换:切片(Slicing)
这是 C++ 继承中最容易被忽视却又十分重要的特性。
cpp
class Person {
public:
string _name;
string _sex;
int _age;
};
class Student : public Person {
public:
int _stuNo; // 学号------父类没有的
};
3.1 子类可以"向上转型"给父类
cpp
Student sobj;
sobj._name = "小明";
// 这三种写法都是合法的
Person pobj = sobj; // (1) 子类对象赋值给父类对象------切片!
Person* pp = &sobj; // (2) 父类指针指向子类对象
Person& rp = sobj; // (3) 父类引用绑定子类对象
这里有一个重要的概念叫切片(Slicing) :把子类对象赋给父类对象时,子类独有的成员(_stuNo)会被"切掉",只保留父类那部分。
子类对象内存布局: [_name][_sex][_age][_stuNo]
↓ 切片
父类对象: [_name][_sex][_age]
指针和引用则不会切片------它们只是以"父类的视角"去看同一个对象,子类的部分仍然在内存中。
3.2 反过来不行------父类不能自动转成子类
cpp
Person pobj;
Student* ps = &pobj; // ERROR! 父类指针不能直接转为子类指针
这很好理解:父类有的东西子类都有(继承来的),但子类有的东西(学号)父类没有,让父类对象去"装"子类,缺的字段填什么?
3.3 dynamic_cast:谨慎的向下转型
cpp
Student sobj;
Person* pp = &sobj;
Student* ps1 = dynamic_cast<Student*>(pp); // 成功, pp实际指向Student
cout << ps1 << endl; // 非空指针
Person pobj2;
pp = &pobj2;
Student* ps2 = dynamic_cast<Student*>(pp); // 失败, pp实际指向Person
cout << ps2 << endl; // 空指针!
dynamic_cast 会在运行时检查------安全但有一定开销。如果只是想强制转换(你知道它确实指向子类),也可以用 static_cast。
四、继承中的作用域:同名不是重载,是隐藏
这是一个常见的"踩坑点"。
4.1 成员变量同名------就近访问,但要小心
cpp
class Person {
protected:
string _name = "小明"; // 父类的
int _num = 111; // 身份证号
};
class Student : public Person {
public:
void Print() {
cout << _num << endl; // 999------子类自己的 _num
cout << Person::_num << endl; // 111------显式指定父类的 _num
}
protected:
int _num = 999; // 学号------和父类成员同名!
};
int main() {
Student s;
s.Print(); // 输出: 999 \n 111
}
子类和父类有同名成员时,子类成员会"隐藏"父类成员 。如果想访问父类的,必须用 父类名::成员名。
4.2 成员函数同名------只要函数名相同,就是隐藏
这个坑更大:
cpp
class A {
public:
void fun() { cout << "fun()" << endl; }
};
class B : public A {
public:
void fun(int i) { cout << "fun(int i): " << i << endl; }
};
int main() {
B b;
// b.fun(); // ERROR! B::fun 隐藏了 A::fun
b.A::fun(); // 这样才能调用父类的无参版本
b.fun(1); // OK
}
很多人会误以为 A::fun() 和 B::fun(int) 构成重载 ,因为函数名相同、参数不同。错了! 重载的条件是两个函数在同一作用域中 。父子类是两个不同的作用域,所以这里的规则是隐藏(Hide),不是重载。
铁律:派生类和基类中有同名函数时,只要函数名相同(不看参数),基类的该函数就被隐藏。 想用基类版本,必须
基类名::函数名。
五、派生类的默认成员函数:继承体系下的构造、拷贝、赋值、析构
这一部分是面试高频考点,也是实际写代码最容易出 Bug 的地方。
先定义一个有完整六大函数(构造、拷贝构造、赋值、析构 + C++11 移动构造、移动赋值)的基类和派生类框架:
cpp
class Person {
public:
Person(const char* name = "xxx")
: _name(name) {
cout << "Person()" << endl;
}
Person(const Person& p)
: _name(p._name) {
cout << "Person(const Person& p)" << endl;
}
Person& operator=(const Person& p) {
cout << "Person operator=" << endl;
if (this != &p) _name = p._name;
return *this;
}
~Person() { cout << "~Person()" << endl; }
protected:
string _name;
};
5.1 构造函数:先父后子
派生类构造时,必须先调用基类构造函数,初始化继承来的成员,然后再初始化自己的成员。
cpp
class Student : public Person {
public:
Student(const char* name, int num, const char* addr)
: Person(name) // 显式调用基类构造函数------必须放在初始化列表最前面
, _num(num)
, _addr(addr) {
// 构造函数体
}
protected:
int _num;
string _addr;
};
如果初始化列表中没有显式调用基类构造函数,编译器会自动调用基类的默认构造函数 。如果基类没有默认构造函数?那就编译报错。
5.2 拷贝构造函数:别忘了"切片传参"
cpp
Student(const Student& s)
: Person(s) // 把子类对象 s 传给基类的拷贝构造------切片!
, _num(s._num)
, _addr(s._addr) {
// 如果有深拷贝资源,在这里处理
}
Person(s) 这里编译器把 Student& 自动切片成 const Person&,调用基类的拷贝构造函数。这一步很多人会漏掉,但漏掉的话编译器会自动调用基类的默认构造 ------你继承来的 _name 等成员就变成默认值了,这是 Bug。
5.3 赋值运算符重载:必须显式调用基类的 operator=
cpp
Student& operator=(const Student& s) {
if (this != &s) {
Person::operator=(s); // 必须显式调用!否则基类成员不会被赋值
_num = s._num;
_addr = s._addr;
}
return *this;
}
这里有个容易忘记的陷阱:派生类的 operator= 不会自动调用基类的 operator=(不同于构造函数)。如果你不写 Person::operator=(s) 这一行,基类那部分成员就保持原样,不会从 s 那里拷贝过来。另外,这里一定要标明作用域,否则会构成隐藏,导致子类的operator=不断循环调用自己,编译器会报stack overflow的错误。
5.4 析构函数:先子后父,自动调用
cpp
~Student() {
// 先释放子类自己的资源
// delete _ptr;
// 不需要显式调用 ~Person()------编译器会自动加上
// 析构顺序:先子后父(跟构造完全相反)
}
基类析构函数会被编译器在派生类析构函数体执行完毕后自动调用,确保析构顺序是"先子后父"。这和构造函数的"先父后子"完全对称。
记忆技巧:盖房子从上往下(先父后子构造),拆房子从下往上(先子后父析构)。
5.5 编译器默认生成的行为小结
cpp
class Student : public Person {
protected:
int _num = 1;
string _addr = "高新区";
};
// 上面什么都没写,编译器会生成什么?
如果派生类没有显式定义,编译器生成的默认行为是:
| 函数 | 编译器默认行为 |
|---|---|
| 默认构造 | 调用基类的默认构造函数,再对派生类成员做默认初始化 |
| 拷贝构造 | 调用基类的拷贝构造,再逐个拷贝派生类成员(浅拷贝) |
operator= |
调用基类的 operator=,再逐个赋值派生类成员(浅拷贝) |
| 析构函数 | 函数体为空,先逆序销毁派生类成员,之后自动调用基类析构函数 |
| 移动构造/赋值 (C++11) | 类似拷贝的规则,调用基类对应移动版本 |
如果派生类不涉及动态资源管理,编译器默认生成的版本就够用。一旦有
new/malloc出来的资源,就必须自己写深拷贝。
六、继承与友元、静态成员
6.1 友元关系不能继承
cpp
class Person {
friend void Display(const Person& p, const Student& s);
protected:
string _name;
};
class Student : public Person {
// 即使 Person 把 Display 当朋友,
// Display 也不能直接访问 Student 的 protected/private 成员
friend void Display(const Person& p, const Student& s); // 必须重新声明!
protected:
int _stuNum;
};
父类的友元不会自动成为子类的友元。如果想访问子类的私有/保护成员,需要在子类中重新声明友元。
6.2 静态成员------整个继承体系中只有一份
cpp
class Person {
public:
static int _count;
};
int Person::_count = 0;
class Student : public Person {
protected:
int _stuNum;
};
运行这段代码:
cpp
Person p;
Student s;
cout << &p._name << endl; // 普通成员的地址不同------每个对象各自一份
cout << &s._name << endl;
cout << &p._count << endl; // 静态成员地址相同------整个继承体系共享!
cout << &s._count << endl; // 四个输出全是同一个地址
cout << Person::_count << endl; // 都可以这样访问
cout << Student::_count << endl; // 派生类也可以直接访问
Person::_count++; // 这里改了
cout << s._count; // 那里也变了
静态成员属于"类"而非"对象",在整个继承层次结构中只有一份。 不管有多少派生类、多少个对象,静态变量在内存中只有一个实例。
七、多继承与菱形继承:C++ 最让人头疼的问题
7.1 多继承的基本语法
cpp
class Base1 { public: int _b1 = 1; };
class Base2 { public: int _b2 = 2; };
class Derive : public Base1, public Base2 {
public:
int _d = 3;
};
内存布局(32位环境为例):
Derive 对象内存:
[Base1::_b1] // 偏移 0
[Base2::_b2] // 偏移 sizeof(Base1)
[Derive::_d] // 偏移 sizeof(Base1) + sizeof(Base2)
多继承时,基类在内存中按声明顺序排列。这一点在有指针转换时很重要:
cpp
Derive d;
Base1* p1 = &d; // p1 指向 d 的首地址(Base1 的起始位置)
Base2* p2 = &d; // p2 指向 d 中 Base2 的起始位置(非首地址!)
cout << (void*)&d << endl; // 这三个地址可能不同!
cout << (void*)p1 << endl;
cout << (void*)p2 << endl; // p2 可能会比前两个多几个字节的偏移
7.2 菱形继承------噩梦的开始
cpp
class Person { public: string _name; };
class Student : public Person { public: int _num; };
class Teacher : public Person { public: int _id; };
class Assistant : public Student, public Teacher {
public:
string _majorCourse;
};
继承关系图:
Person
/ \
Student Teacher
\ /
Assistant
问题来了: Assistant 的对象中有两份 Person 的成员 ------一份从 Student 路径来,一份从 Teacher 路径来。
cpp
Assistant a;
// a._name = "peter"; // ERROR! 对 _name 的访问不明确------编译器不知道你要哪个 _name
a.Student::_name = "小明"; // 必须这样指定
a.Teacher::_name = "小红";
两个 _name?这显然浪费空间、违反直觉。如果把 Person 当成一个"人"的抽象,一个助教当然只有一个名字。这就是菱形继承的数据冗余和二义性问题。
7.3 虚继承------解决菱形问题的钥匙
cpp
class Student : virtual public Person { // 注意 virtual
public:
Student(const char* name, int num)
: Person(name), _num(num) {}
protected:
int _num;
};
class Teacher : virtual public Person { // 注意 virtual
public:
Teacher(const char* name, int id)
: Person(name), _id(id) {}
protected:
int _id;
};
class Assistant : public Student, public Teacher {
public:
Assistant(const char* name1, const char* name2, const char* name3)
: Student(name1)
, Teacher(name2)
, Person(name3) // 注意:必须显式调用虚基类构造函数!
{}
};
关键变化:
Student和Teacher在继承Person时加了virtual关键字Assistant的构造函数中必须显式调用虚基类Person的构造函数
不加第2条会怎样?编译器不会帮你自动推断用哪个 Person------你自己必须说清楚。
虚继承让内存中只有一份 Person 的数据 ,Student 和 Teacher 共享同一份。二义性问题解决了。
7.4 虚继承的底层实现(简化版)
虚继承不是魔法,它通过一张**虚基类表(vbtable)**来实现:
虚继承下 Assistant 的内存布局(简化):
+---------------------------+
| Student 部分 |
| _vbptr_stu ------> vbtable(记录 Person 部分的偏移量)
| _num |
+---------------------------+
| Teacher 部分 |
| _vbptr_tea ------> vbtable(记录 Person 部分的偏移量)
| _id |
+---------------------------+
| Assistant 自己的成员 |
| _majorCourse |
+---------------------------+
| Person 部分(只有一份!) |
| _name |
+---------------------------+
虚基类 Person 的数据被放在了整个对象的最末尾。Student 和 Teacher 各有一个虚基类表指针(_vbptr),指向各自的虚基类表,表中记录了当前对象起始位置到 Person 部分的偏移量。这样无论通过 Student* 还是 Teacher* 去访问 _name,都能通过 vbtable 偏移找到唯一的那份 Person 数据。
八、C++11 的 final:拒绝被继承
如果你希望某个类不能被继承 ,C++11 提供了 final 关键字:
cpp
class Base final {
// ...
};
// class Derive : public Base {}; // ERROR! Base 被标记为 final,不能继承
简洁明了。比 C++98 时代的"把构造函数设为 private"的 方式优雅多了。
九、继承 vs 组合:is-a 还是 has-a?
这是面向对象设计中一个经典的设计决策。
9.1 什么时候用继承?
cpp
// is-a 关系:学生"是"一个人
class Student : public Person { ... };
继承表达的是 is-a(是一个)关系。 学生是一个人,教师是一个人,可以复用 Person 的属性和行为。
9.2 什么时候用组合?
cpp
// has-a 关系:车"有"引擎,但车不是引擎
class Engine { ... };
class Car {
Engine _engine; // 组合
};
组合表达的是 has-a(有一个)关系。
9.3 一个反面案例------Stack 不应该继承 vector
cpp
// 错误示范:Stack is-a vector?不是!
template<class T>
class Stack : public vector<T> {
public:
void push(const T& x) { push_back(x); }
void pop() { pop_back(); }
const T& top() { return back(); }
bool empty() { return vector<T>::empty(); }
};
Stack<int> st;
st.push(1);
st.push_back(2); // Stack 不应该有 push_back!但它有!
st[0] = 100; // Stack 不应该有随机访问!但它有!
Stack 继承 vector 之后,把 push_back、operator[] 等不属于栈语义的操作全都暴露了出来。这是设计灾难------你的 Stack 不是一个受限的 LIFO 容器,而是一个 vector 的别名。
正确的做法是用组合:
cpp
template<class T>
class Stack {
public:
void push(const T& x) { _con.push_back(x); }
void pop() { _con.pop_back(); }
const T& top() { return _con.back(); }
bool empty() { return _con.empty(); }
private:
vector<T> _con; // 组合------vector 是实现细节,不暴露给用户
};
设计原则:能用组合就用组合,只有在明确的 is-a 关系时才用继承。
十、总结:一张图回顾全部要点
C++ 继承知识体系
│
├── 基本概念
│ ├── 继承方式:public(最常用)/protected/private
│ └── 访问权限变化:只降不升,private 永远不可见
│
├── 父子对象关系
│ ├── 向上转型(切片):天然支持,子→父
│ ├── 向下转型:需要 dynamic_cast,不安全
│ └── 同名隐藏:只要函数名相同就隐藏,不构成重载
│
├── 派生类六大默认函数
│ ├── 构造:先父后子,初始化列表调用基类构造
│ ├── 拷贝构造:Person(s) 切片传参
│ ├── operator=:必须显式调用 Person::operator=(s)
│ ├── 析构:先子后父,自动调用
│ └── 移动构造/赋值 (C++11):类似上述规则
│
├── 特殊关系
│ ├── 友元:不被继承
│ ├── 静态成员:整个继承体系共享
│ └── final (C++11):禁止被继承
│
├── 高级话题
├── 多继承:按声明顺序排列基类
├── 菱形继承:数据冗余 + 二义性
├── 虚继承:通过 vbtable 实现共享基类
└── 继承 vs 组合:is-a 用继承,has-a 用组合
C++ 的继承体系远比 Java 和 Python 要复杂和精细。这种复杂性不是语言设计上的缺陷,而是 C++ 赋予程序员对内存布局和访问控制的完全自主权的必然代价。理解了"为什么 C++ 这么设计",你就能写出既高效又健壮的继承体系。
欢迎在评论区留言讨论~