一、前言:一份代码看懂 C++ 继承
继承是 C++ 面向对象三大特性之一
很多人初学继承时能写出代码,却讲不清三种继承方式改了什么、对象在内存里怎么排、为什么会有二义性和数据冗余。本文拿一份课堂/练习代码从头到尾逐段拆解,把继承的 13 个核心知识点串成一条线:从"为什么要继承",到三种继承方式、is-a/has-a、赋值兼容、名字隐藏、默认成员函数,最后到最难的多继承、菱形继承与虚继承。
全篇文章的代码都来自同一条练习主线,每一步都配有底层的内存布局示意,帮助你建
立"对象在内存里长什么样"的直觉,而不是只记住语法。
二、为什么需要继承:消灭重复
看这份代码的起点:一开始我们写了 Student 和 Teacher 两个类,它们几乎一模一样地重复声明了姓名、地址、电话、年龄:
cpp
class Student {
public:
void identity() { cout << " void student identity() " << endl; }
void study() { cout << "void student study()" << endl; }
protected:
string _name; // 姓名
string _address; // 地址
string _tel; // 电话
int _age = 18; // 年龄
};
class Teacher {
public:
void identity() { cout << "void teacher identity()" << endl; }
void teaching() { cout << "void teacher teaching()" << endl; }
protected:
string _name; // 姓名
string _address; // 地址
string _tel; // 电话
string _title; // 职称
};
两个人都有身份认证、都有姓名地址电话,这些公共属性被复制粘贴了两遍。一旦要加一个字段(比如手机号),就得改两处,漏改一处就出 bug。这就是代码坏味道。
解决思路:把这些公共部分抽出来,单独成一个基类 Person,再让 Student、Teacher 去"继承"它。继承的含义是:派生类(Student/Teacher)自动拥有基类(Person)的成员,不用重复声明。
cpp
class Person {
public:
void identity() { cout << "void identity:" << _name << endl; }
protected:
string _name; // 姓名
string _address; // 地址
string _tel; // 电话
int _age = 18; // 年龄
};
class Student : public Person {
public:
void study() { cout << "void stude()" << endl; }
protected:
int _stuid; // 学号(Student 自己才有的)
};
class Teacher : public Person {
public:
void teaching() { cout << "void teaching()" << endl; }
protected:
string _title; // 职称(Teacher 自己才有的)
};
◆ 核心:继承让"公共部分只写一份",派生类只关心自己特有的那部分。这既减少了重复,也让"Student 是一种 Person"的语义变得明确。
三、三种继承方式:public / protected / private
继承方式决定"基类里的成员,到了派生类里变成什么访问级别"。同一个基类 Person,分别用 public、protected、private 三种方式继承,效果完全不同。代码里用注释开关逐一验证:
cpp
class Student : public Person {}; // 公有继承
class Student : protected Person {}; // 保护继承
class Student : private Person {}; // 私有继承
在 main 里分别调用 stu1.identity() 观察:
public 继承下可以正常调用;
protected 和 private 继承下,由于 identity() 在派生类外变成了 protected/private,外部无法访问,会编译报错。
完整的访问权限变化如下(基类各成员的访问级别 × 继承方式 → 派生类中可访问级别):

代码里还有一段专门演示基类私有成员在不同继承下的情况:
cpp
class Person {
public:
void Print() { cout << _name << endl; }
protected:
string _name; // 姓名
private:
int _age; // 年龄 ← 私有成员
};
class Student : public Person {
protected:
int _stunum; // 学号
};
◆ 三句话记住:public 继承基本"原样传递",protected 继承把基类的 public 降成 protected,private 继承把基类所有能见的成员都变成 private。基类的 private 成员无论怎样继承,派生类都不可见------私有是"只属于基类自己"的。
实际工程里几乎只用 public 继承。protected/private 继承会让"is-a"语义被破坏(见下一节),而且很难维护,初学阶段理解其作用即可,不要滥用。
四、is-a 与 has-a:继承还是组合?
代码最后给出了一个很经典的对照:Car 和 BMW/Benz 是 is-a 关系,而 Car 和 Tire 是 has-a 关系。
cpp
class Tire {
protected:
string _brand = "Michelin"; // 品牌
size_t _size = 17; // 尺寸
};
class Car {
protected:
string _colour = "白色"; // 颜色
string _num = "陕ABIT00"; // 车牌号
Tire _t1; Tire _t2; Tire _t3; Tire _t4; // 组合:4 个轮胎成员对象
};
class BMW : public Car { public: void Drive() { cout << "好开-操控" << endl; } }; // is-a
class Benz : public Car { public: void Drive() { cout << "好坐-舒适" << endl; } }; // is-a

判断口诀:问一句"B 是 A 的一种吗?"------BMW 是 Car 的一种,是 is-a,用继承;Tire 不是 Car 的一种,只是 Car 拥有轮胎,是 has-a,用成员对象(组合)。
代码里还有一个非常有意思的细节:stack 和 vector 的关系。
cpp
// 写法一:继承 ------ 既符合 is-a(stack 是一种受限 vector)
template<class T>
class stack : public vector<T> { ... };
// 写法二:组合 ------ 也符合 has-a(stack 内部"有一个" vector)
template<class T>
class stack {
public:
vector<T> _v; // 组合成员
...
};
◆ 同一个需求往往既能用继承也能用组合表达。现代 C++ 设计更推荐"组合优于继承":只有确实是"is-a"且派生类真正复用基类接口时才用继承,否则优先用组合,耦合更小。
五、赋值兼容规则:派生类 → 基类(切片)
因为派生类对象"包含"了基类子对象,所以 C++ 允许把派生类对象当作基类来用:派生类对象可以赋值给基类的指针、引用和对象。
cpp
class person {
protected:
string _name = "zs"; string _sex = "man"; int _age = 18;
};
class student : public person {
public:
int _no;
};
int main() {
student sobj;
person* pp = &sobj; // 派生类对象 → 基类指针(合法)
person& rp = sobj; // 派生类对象 → 基类引用(合法)
person pobj = sobj; // 派生类对象 → 基类对象(合法,切片)
// sobj = pobj; // 基类对象 → 派生类对象:编译报错!
return 0;
}
内存层面,这就是"切片"(slicing):把派生类对象拷贝给基类对象时,只拷贝基类子对象那一段,派生类新增的部分被"切掉"。所以通过基类指针/引用只能看到基类部分,看不到 student 的 _no。

◆ 为什么基类不能反向赋给派生类?因为派生类对象里还缺派生类新增的成员(比如 _no),编译器无法从基类对象里补出来,所以直接报错。
六、名字隐藏:同名成员与同名函数
继承里,派生类和基类可能出现同名成员。这时派生类的名字会把基类的同名名字"藏住"(隐藏/遮蔽),访问时默认用的是派生类的那个。
分两种情况:
情况 1:同名数据成员
cpp
class Person {
protected:
string _name = "小李子";
int _num = 111; // 身份证号
};
class Student : public Person {
public:
void Print() {
cout << " 姓名:" << _name << endl; // 用的是基类 _name
cout << " 身份证号:" << Person::_num << endl; // 显式指定基类
cout << " 学号:" << _num << endl; // 用的是派生类 _num
}
protected:
int _num = 999; // 学号
};
int main(){ Student s1; s1.Print(); return 0; }
这里 Student 和 Person 各有一个 _num。在 Student 内部直接写 _num,得到的是派生类自己的 999;想访问基类的 111,必须写 Person::_num。虽然能跑,但极其容易混淆------这就是隐藏的坑。
情况 2:同名成员函数
cpp
class A {
public:
void fun() { cout << "func()" << endl; }
};
class B : public A {
public:
void fun(int i) { cout << "func(int i)" << i << endl; } // 隐藏了 A::fun()
};
int main() {
B b;
b.fun(10); // 调 B::fun(int)
// b.fun(); // 编译报错:B::fun 隐藏了 A::fun(),无参版本找不到
// b.A::fun(); // 显式指定类域即可调用
return 0;
}
◆ 重要:函数隐藏跟"重载"不是一回事。重载是同一类里同名不同参;隐藏是派生类的函数把基类的同名函数全部遮住(即使参数不同也不会参与重载),必须用"基类名::函数名"才能调到被藏的版本。
七、继承下的默认成员函数:构造 / 拷贝 / 赋值 / 析构
继承时,派生类的构造函数、拷贝构造、赋值、析构都有自己的责任:要先处理基类那一份。看这份代码,它在每个函数里打印了调用痕迹:
cpp
class person {
public:
person(const string& name) : _name(name) { cout << "person()" << endl; }
~person() { cout << "~person()" << endl; }
person(const person& p) : _name(p._name) { cout << "person(const person& p)" << endl; }
person& operator=(const person& p) {
cout << "person& operator=(const person& p)" << endl;
if (this != &p) _name = p._name;
return *this;
}
protected:
string _name;
};
class student : public person {
public:
student(const char* name, int num)
: person(name) , _num(num) { cout << "Student()" << endl; }
student(const student& s)
: person(s) , _num(s._num) { cout << "Student(const Student& s)" << endl; }
student& operator=(const student& s) {
cout << "student& operator=(const student& s)" << endl;
if (this != &s) {
person::operator=(s); // 必须显式调用基类赋值(被隐藏了)
_num = s._num;
}
return *this;
}
~student() { cout << "~student()" << endl; }
protected:
int _num;
};
int main() {
student s1("jack", 18);
student s2(s1); // 拷贝构造
student s3("rose", 17);
s1 = s3; // 赋值
return 0;
四个要点:
- 构造:派生类必须先调用基类构造函数(在初始化列表里 person(name)),再初始化自己的成员。如果基类没有默认构造,必须显式传参,否则编译报错。
- 拷贝构造:同样要先调基类拷贝构造 person(s),把 s 当作基类引用传过去完成基类部分的拷贝。
- 赋值:由于派生类的 operator= 把基类的 operator= 隐藏了,必须显式写 person::operator=(s) 才能把基类部分赋好,否则基类部分不会被赋值。
- 析构:顺序与构造相反------先析构派生类部分,再自动析构基类部分("~student()"打印后紧接着打印"~person()")。
◆ 一句话总结:派生类所有"复制/构造"类操作,都要负责把自己"所包含的那份基类"也一并处理,不能只处理自己的新成员。
八、禁止继承:final 与私有构造
有时我们希望一个类不能被继承(比如某种不可派生的工具类)。C++ 提供了两种做法,对应 C++98 和 C++11。
cpp
// C++11:final 关键字,最直接
class base final {
public:
void func() { cout << "void base::func()" << endl; }
protected:
int _num = 5;
};
// class derive : public base { ... }; // 编译报错:不能从 final 类派生
// C++98:把构造函数设为 private
class base98 {
private:
base98() {} // 私有构造
protected:
int _num;
};
为什么私有构造能禁止继承?因为派生类实例化时"必须先调用基类构造函数";而基类的构造是 private,派生类访问不到,于是派生类根本无法完成构造,也就无法存在。同理,基类自身也不能在类外直接 new 一个对象(main 里 base b; 也会报错),这就把一个类彻底"锁死"成不可继承、不可直接实例化。
◆ final 更常用:它只禁止派生,不影响基类自己正常实例化;而"私有构造 + 静态工厂方法"是 C++98 时代用来实现"单例/不可实例化"类的手段。
九、友元与继承:友元不继承
友元函数可以访问类的私有/保护成员。但有个重要规则:友元关系不会被继承。基类的友元函数,不能自动访问派生类新增的私有成员。
cpp
class student; // 前置声明:因为 person 的友元要引用 student
class person {
public:
friend void display(const person& p, const student& s); // 友元能访问 person 私有
protected:
string _name;
};
class student : public person {
public:
friend void display(const person& p, const student& s); // 必须再声明一次!
protected:
int _stuNUM;
};
void display(const person& p, const student& s) {
cout << p._name << endl; // 访问 person 的 protected
cout << s._stuNUM << endl; // 若没在 student 里声明友元,这里报 C2248
}
代码注释里已经指出关键坑:一开始只在 person 里声明了 display 为友元,display 想访问 s._stuNUM(student 新增的 protected 成员)时,编译器报"error C2248: 无法访问 protected 成员"。原因就是友元不继承------person 的友元 ≠ student 的友元。解决办法:在 student 里也把它声明为友元。
还有一个工程细节:person 的友元函数签名里要用到 student 类型,但此时 student 还没定义,所以必须先写 class student; 前置声明,把顺序理清。
◆ 两个易错点:① 友元关系不随继承传递,派生类新增的私有成员需要重新声明友元;② 交叉引用类时要会用前置声明。
十、静态成员与继承:全局只有一份
静态成员属于"类本身",不属于任何对象。继承后,基类和派生类共享同一份静态成员------地址是同一个。
cpp
class person {
public:
string _name;
static int _count; // 静态成员
};
int person::_count = 0; // 类外定义
class student : public person {
protected:
int _stuNum;
};
int main() {
student s; person p;
cout << &p._name << endl; // 不同对象的普通成员,地址不同
cout << &s._name << endl;
cout << &p._count << endl; // 静态成员:地址一样!
cout << &s._count << endl;
// 公有静态成员,两个类域都能访问
cout << person::_count << endl;
cout << student::_count << endl;
return 0;
}
运行结果里,&p._count 和 &s._count 是完全相同的地址------说明 student 并没有再复制一份 _count,它和 person 共用同一份。这也是静态成员"跨所有对象全局唯一"的体现:哪怕是通过派生类访问,指的还是那一个静态变量。
◆ 注意:静态成员仍然遵循访问控制。若静态成员是 private,派生类通过 student:: 也访问不到;上面的 _count 是 public 才能这样用。
十一、多继承与二义性
一个类可以有多个直接基类,这叫多继承。但多继承最常见的坑就是"二义性"。
cpp
// person
// student teacher
// assistant
class person { public: string _name; };
class student : public person { protected: int _num; };
class teacher : public person { protected: int _id; };
class assistant : public student, public teacher {
protected:
string _majorCourse; // 主修课程
};
int main() {
assistant a;
// a._name = "peter"; // 编译报错:error C2385 对"_name"的访问不明确
a.student::_name = "xxx"; // 显式指定,能访问
a.teacher::_name = "yyy"; // 显式指定,能访问
return 0;
}
问题根源:assistant 同时从 student 和 teacher 继承,而 student 和 teacher 又都从 person 继承。于是 assistant 对象里出现了两份 person 子对象、两份 _name。直接写 a._name 时,编译器不知道该用哪一份,报"访问不明确"。

用 a.student::_name 或 a.teacher::_name 显式指定类域,可以消除二义,但"数据冗余"问题仍在------对象里确实躺着两份 _name、两份 person。这正是下一节要解决的。
十二、菱形继承与虚继承
上一节的继承结构(person → student/teacher → assistant)画出来正好是一个菱形,叫"菱形继承"。它有两个问题:二义性 + 数据冗余(同一份 person 被复制两份)。
cpp
// 普通菱形继承:冗余
class person { public: string _name; };
class student : public person { protected: int _num; };
class teacher : public person { protected: int _id; };
class assistant : public student, public teacher { protected: string _majorCourse; };
// 虚继承:让 person 只保留一份
class student : virtual public person { protected: int _num; };
class teacher : virtual public person { protected: int _id; };
class assistant : public student, public teacher { protected: string _majorCourse; };
int main() {
assistant a;
a._name = "peter"; // 虚继承后:唯一一份,不再二义,可直接访问
return 0;
}
把 student、teacher 对 person 的继承改为 virtual public,person 在整个 assistant 对象里就只有一份了。编译器通过"虚基类指针 vbptr"来定位这份唯一的 person:student、teacher 子对象各自保存一个 vbptr,它们都指向同一份 person 子对象。

虚继承还有一个隐蔽细节------谁负责初始化这份共享的 person?
cpp
class Student : virtual public Person {
public:
Student(const char* name, int num) : Person(name), _num(num) {}
protected: int _num;
};
class Teacher : virtual public Person {
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)
: Person(name3) // 最派生类负责初始化共享的 person
, Student(name1, 1)
, Teacher(name2, 2) {}
protected: string _majorCourse;
};
// 思考:Assistant a("张三","李四","王五"); 里 _name 到底是哪个?
// 答案:是最派生类显式传的 name3="王五",因为虚基类由最派生类(Assistant)初始化,
// Student/Teacher 里对 Person(name) 的调用会被忽略。
◆ 虚继承时,虚基类(person)由"最派生类"(assistant)负责初始化。上面代码注释里的思考题答案就是:_name 是"王五"(name3)。至于为什么两处 Student/Teacher 都写了 Person(name) 却没用------因为共享的虚基类只需初始化一次,最派生类说了算,这是虚继承和普通继承最大的行为差异。
十三、总结:一张表看完整条线
把这份代码里的知识点收拢成一张速查表:
|-------------------|----------------------------------------|------------------------|
| 知识点 | 关键结论 | 一句话记忆 |
| 为什么要继承 | 公共部分上提基类,消除重复 | is-a:学生/老师都是人 |
| 三种继承方式 | public 原样 / protected 降级 / private 私有化 | 工程只用 public 继承 |
| is-a vs has-a | "是 A 的一种"→继承;"拥有 B"→组合 | BMW 继承 Car,Car 组合 Tire |
| 赋值兼容 | 派生→基类指针/引用/对象合法(切片) | 基类不能反向赋给派生类 |
| 名字隐藏 | 派生类同名成员/函数遮蔽基类 | 要调基类用 基类名::成员 |
| 默认成员函数 | 构造/拷贝/赋值/析构都要先处理基类 | 复制必须带好"基类那一份" |
| 禁止继承 | final(C++11)或私有构造(C++98) | 锁死派生即锁死构造 |
| 菱形继承 | 普通版冗余+二义;virtual 虚继承去冗余 | 虚基类由最派生类初始化 |
继承这条线练到这里,你基本就把它打通了:从"为什么要继承"到三种继承方式,到 is-a/has-a 的选择,再到切片、隐藏、默认成员函数,最后攻下最难的多继承/菱形/虚继承。每一步都能落到内存布局上------这也正是 C++ 底层能力的关键:不仅知道怎么写,更知道对象在内存里长什么样。