C++ 继承:从概念到实战
在面向对象编程中,“代码复用” 是永恒的追求。而继承,作为 C++ 实现类层次复用的核心机制,既能帮我们摆脱重复编码的困境,也可能因使用不当埋下维护隐患。今天这篇文章,就从实际问题出发,带你全面掌握继承的用法、避坑要点与实战决策,让你写继承代码时既高效又稳妥。
1. 初识
为什么需要继承?从一段冗余代码说起
先看一个常见场景:我们要定义Student(学生)和Teacher(老师)两个类,它们都需要 “姓名、地址、电话、年龄” 这些属性,以及 “身份认证(刷二维码进校园)” 的功能;同时,学生有 “学号” 和 “学习” 方法,老师有 “职称” 和 “授课” 方法。
如果不使用继承,代码会写成这样:
// 学生类
class Student
{
public:// 身份认证void identity() {}// 学习功能void study() {}
protected:string _name = "peter"; // 姓名string _address; // 地址string _tel; // 电话int _age = 18; // 年龄int _stuid; // 学号(独有)
};// 老师类
class Teacher
{
public:// 身份认证(与Student重复)void identity() {}// 授课功能void teaching() {}
protected:string _name = "张三"; // 姓名(重复)string _address; // 地址(重复)string _tel; // 电话(重复)int _age = 18; // 年龄(重复)string _title; // 职称(独有)
};不难发现,Student和Teacher中有大量重复代码 —— 属性重复、identity函数重复。一旦需要修改 “身份认证” 的逻辑(比如新增刷卡功能),就得同时改两个类;如果后续再加Doctor(医生)、Staff(职员)类等,重复工作会成倍增加。
这时候,继承的价值就体现出来了:我们可以把 “姓名、地址、身份认证” 这些公共属性和方法抽离到一个基类(比如Person)中,让Student和Teacher直接 “继承” 这个基类,只需要在各自类中实现独有的功能即可。
2. 继承基础
2.1 概念
核心概念:基类和派生类
基类(父类):被继承的公共类,比如上面提到的Person,封装了所有子类的共性。
派生类(子类):继承基类的新类,比如Student、Teacher,在基类基础上扩展独有特性。
继承关系:派生类与基类是 “is - a” 关系 —— 每个Student都是一个Person,每个Teacher也是一个Person。
用继承优化后的代码如下,冗余问题瞬间解决:
// 基类:Person(封装公共属性和方法)
class Person
{
public:// 身份认证(只写一次)void identity() {cout << "身份认证:" << _name << endl;}
protected:string _name = "张三"; // 姓名(公共属性)string _address; // 地址(公共属性)string _tel; // 电话(公共属性)int _age = 18; // 年龄(公共属性)
};// 派生类:Student(继承Person)
class Student : public Person
{
public:// 独有功能:学习void study() {}
protected:int _stuid; // 独有属性:学号
};// 派生类:Teacher(继承Person)
class Teacher : public Person
{
public:// 独有功能:授课void teaching() {}
protected:string _title; // 独有属性:职称
};// 主函数测试
int main()
{Student s;Teacher t;s.identity(); // 直接使用基类的identity方法t.identity(); // 直接使用基类的identity方法return 0;
}输出结果:

2.2 格式
派生类的定义格式很简单,核心是 “继承方式 + 基类名”:
class 派生类名 : 继承方式 基类名
{// 派生类的成员(独有属性和方法)
};其中,继承方式有三种:public(公有继承)、protected(保护继承)、private(私有继承),但实际开发中几乎只用public,原因后面会讲。
另外要注意一个细节:使用class关键字定义派生类时,默认继承方式是private;使用struct时,默认继承方式是public。为了代码可读性,建议显式写出继承方式,不要依赖默认规则。
2.3 访问权限
基类的成员(属性 / 方法)有public、protected、private三种访问限定符,继承方式会影响派生类对基类成员的访问权限。用一张表就能看明白(重点记public继承,其他两种几乎不用):

这里的 “不可见” 需要特别说明:基类的private成员会被继承到派生类对象中,但语法上限制派生类(无论在类内还是类外)都无法访问。如果基类成员想 “不让类外访问,但允许派生类访问”,就用protected—— 这也是protected限定符存在的核心意义。
基类的其他成员在派生类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
为什么不推荐protected和private继承?因为这两种继承方式下,基类的public成员会变成派生类的protected或private成员,导致派生类对象无法在类外调用基类的公共方法(比如identity),扩展性极差。实际开发中,public继承是唯一主流选择。
3. 继承类模板
类模板(如vector、list)是泛型编程的核心,而继承类模板,就是让一个模板类 “继承另一个模板类”,实现泛型层面的代码复用。最典型的例子就是stack(栈)与vector的关系 ——stack的底层可以用vector实现,通过继承vector直接复用其push_back、pop_back等接口,无需重复编码。
3.1 实现要点
继承类模板的语法与普通类继承类似,但需要注意模板参数的传递和基类成员的访问方式。直接看代码示例(以stack继承vector为例):
#include <vector>
using namespace std;// 命名空间:避免与标准库冲突
namespace nep
{// 继承类模板:stack<T> 继承 vector<T>template<class T>class stack : public std::vector<T> {public:// 栈的push接口:复用vector的push_backvoid push(const T& x) {// 关键:基类是类模板时,访问基类成员需显式指定类域(vector<T>::)// 原因:模板按需实例化,编译时可能无法确定push_back是否为基类成员vector<T>::push_back(x);// 若直接写 push_back(x),部分编译器可能报错(找不到标识符)}// 栈的pop接口:复用vector的pop_backvoid pop() {if (!this->empty()) { // this指针访问基类的empty(),也可写 vector<T>::empty()vector<T>::pop_back();}}// 栈的top接口:复用vector的back()const T& top() {return vector<T>::back();}// 栈的判空接口:复用vector的empty()bool empty() {return vector<T>::empty();}};
}// 测试代码
int main()
{nep::stack<int> st;st.push(1);st.push(2);st.push(3);// 遍历栈:先top再popwhile (!st.empty()) {cout << st.top() << " "; // 输出:3 2 1st.pop();}return 0;
}3.2 注意事项
1. 基类成员的访问方式:由于类模板是 “按需实例化”(只有用到的成员才会被编译),编译时编译器可能无法确定push_back、empty等成员是否来自基类模板,因此必须显式指定基类的类域(vector<T>::),否则可能编译报错。
2. 避免与标准库冲突:标准库中已有std::stack,因此自定义的stack建议放在独立命名空间(如bit)中,防止命名冲突。
3. 继承 vs 组合的选择:stack与vector的关系既符合 “is - a”(栈是一种特殊的vector),也符合 “has - a”(栈包含一个vector)。实际开发中,组合(stack中包含vector<T> _v成员)比继承更常用,因为组合耦合度更低,且能避免暴露vector的多余接口(如insert、erase,这些不符合栈的 “先进后出” 特性)。但继承类模板的写法,依然是理解模板复用的重要案例。
4. 基类和派生类的转换
public继承的派生类对象可以赋值给基类的指针/基类的引用。这里有个形象的说法叫切片或者切 割。寓意把派生类中基类那部分切出来,基类指针或引用指向的是派生类中切出来的基类那部分。
基类对象不能赋值给派生类对象。
基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针 是指向派生类对象时才是安全的。

class Person
{
protected:string _name; // 姓名 string _sex; // 性别 int _age; // 年龄
};
class Student : public Person
{
public:int _No; // 学号
};int main()
{Student s;// 1.派生类对象可以赋值给基类的指针/引用 Person* pp = &s;Person& rp = s;// 派生类对象可以赋值给基类的对象是通过调用后面会讲解的基类的拷⻉构造完成的 Person p = s;//2.基类对象不能赋值给派生类对象,这里会编译报错 s = p;return 0;
}5. 继承中的作用域
5.1 隐藏规则
1. 在继承体系中基类和派生类都有独立的作用域。
2. 派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。(在派生类成员函数中,可以使用 基类::基类成员 显示访问)
3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
4. 注意在实际中在继承体系里面最好不要定义同名的成员。
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
class Person
{
protected:string _name = "张三"; // 姓名 int _num = 111; // 身份证号
};class Student : public Person
{
public:void Print(){cout << " 姓名:" << _name << endl;cout << " 身份证号: Person::" << Person::_num << endl;cout << " 身份证号:" << _num << endl;cout << " 学号:" << _num << endl;}
protected:int _num = 999; // 学号
};int main()
{Student s1;s1.Print();return 0;
};输出结果:

5.2 相关场景
(1)A和B类中的两个func构成什么关系()
A.重载 B.隐藏 C.没关系
(2)下面程序的编译运行结果是什么()
A.编译报错 B.运行报错 C.正常运行
class A
{
public:void fun(){cout << "func()" << endl;}
};
class B : public A
{
public:void fun(int i){cout << "func(int i)" << i << endl;}
};int main()
{B b;b.fun(10);b.fun();return 0;
};(1)答案:B(隐藏)
解析:在继承体系中,基类 A 和派生类 B 的func函数名相同,但参数列表不同(A 的func无参,B 的func带int参数),这种情况属于隐藏关系。重载要求函数在同一作用域且参数列表不同,而这里是不同作用域(A 和 B 是父子类),因此不构成重载。
(2)答案:A(编译报错)
解析:派生类 B 中的func(int i)隐藏了基类 A 的func()。在main函数中调用b.func()时,编译器会认为 B 类中没有无参的func函数(因为被隐藏了),从而导致编译报错。若要调用基类的func(),需显式指定基类作用域,如b.A::func()。
6. 派生类的默认成员函数
6.1 四个常见默认成员函数
默认成员函数,默认的意思就是指我们不写,编译器会帮我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3. 派生类的operator = 必须要调用基类的operator = 完成基类的复制。需要注意的是派生类的operator = 隐藏了基类的operator = ,所以显示调用基类的operator = ,需要指定基类作用域。
4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5. 派生类对象初始化先调用基类构造再调派生类构造。
6. 派生类对象析构清理先调用派生类析构再调基类的析构。
7. 因为多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后续多态会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destructor(),所以基类析构函数不加virtual的情况下,派生类析构函数和基类析构函数构成隐藏关系。
class Person
{
public:Person(const char* name = "peter"): _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=(const Person& p)" << endl;if (this != &p)_name = p._name;return *this;}~Person(){cout << "~Person()" << endl;}
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) //这里能直接传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;
}输出结果:

6.2 实现一个不能被继承的类
方法1:基类的构造函数私有,派生类的构成必须调用基类的构造函数,但是基类的构成函数私有化以后,派生类看不见就不能调用了,那么派生类就无法实例化出对象。
方法2:C++11新增了一个final关键字,final修改基类,派生类就不能继承了。
// C++11的方法
class Base final
{
public:void func5() { cout << "Base::func5" << endl; }
protected:int a = 1;
private:// C++98的方法 /*Base(){}*/
};class Derive :public Base //提示:不能将final类类型用作基类
{void func4() { cout << "Derive::func4" << endl; }
protected:int b = 2;
};int main()
{Base b;Derive d;return 0;
}7. 友元与静态成员
7.1 继承与友元
友元关系不能继承,也就是说基类友元不能访问派生类私有和保护成员。
//Person 类中声明友元函数 Display 时,需要用到 Student 类的类型,
//但此时 Student 类还未定义,所以必须提前声明 Student 类,否则编译器会因 “未识别类型” 报错。
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;
}7.2 继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个派生类,都只有一个static成员实例。
class Person
{
public:string _name;static int _count;
};
int Person::_count = 0;
class Student : public Person
{
protected:int _stuNum;
};int main()
{Person p;Student s;// 这里的运行结果可以看到非静态成员_name的地址是不一样的 // 说明派生类继承下来了,父类对象、派生类对象各有一份 cout << &p._name << endl;cout << &s._name << endl;// 这里的运行结果可以看到静态成员_count的地址是一样的 // 说明派生类和基类共用同一份静态成员 cout << &p._count << endl;cout << &s._count << endl;// 公有的情况下,父类、派生类指定类域都可以访问静态成员 ++Person::_count;cout << Person::_count << endl;cout << Student::_count << endl;return 0;
}输出结果:

8. 多继承与菱形继承问题
8.1 继承模型
单继承:一个派生类只有一个直接基类时称这个继承关系为单继承
多继承:一个派生类有两个或以上直接基类时称这个继承关系为多继承,多继承对象在内存中的模型是,先继承的基类在前面,后面继承的基类在后面,派生类成员在放到最后面。
菱形继承:菱形继承是多继承的一种特殊情况。菱形继承的问题,从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题,在Assistant的对象中Person成员会有两份。支持多继承就一定会有菱形继承,像Java就直接不支持多继承,规避掉了这里的问题,所以实践中我们也是不建议设计出菱形继承这样的模型的。


8.2 多继承
多继承:一个派生类多个基类
多继承的定义格式很简单,多个基类用逗号分隔:
// 基类1:Student
class Student
{
protected:int _stuNum; // 学号
};// 基类2:Teacher
class Teacher
{
protected:int _teaId; // 教师编号
};// 派生类:Assistant(助教,既是学生也是老师)
class Assistant : public Student, public Teacher
{
protected:string _course; // 主讲课程
};8.3 菱形继承
当多继承形成 “菱形” 结构时,问题就来了。比如:
Person是基类,有_name属性;
Student和Teacher都继承Person;
Assistant继承Student和Teacher。
这时,Assistant对象中会存储两份Person的成员(一份来自Student,一份来自Teacher),导致两个问题:
1. 数据冗余:_name存储了两份,浪费内存;
2. 二义性:访问_name时,无法确定是来自Student还是Teacher,编译报错。
代码演示场景:
class Person
{
public:string _name; // 姓名
};class Student : public Person
{
protected:int _stuNum; //学号
};class Teacher : public Person
{
protected:int _teaId; //教师编号
};class Assistant : public Student, public Teacher
{
protected:string _course; //主修课程
};int main()
{Assistant a;a._name = "peter"; // 错误:二义性,无法确定是Student::_name还是Teacher::_namereturn 0;
}虽然可以通过 “显式指定基类作用域”(a.Student::_name = "peter")解决二义性,但数据冗余问题依然存在,不是根本解决方案。
8.4 虚继承
C++ 引入 “虚继承”(virtual关键字)来解决菱形继承的问题。核心思路是:让间接基类(比如Person)在整个继承体系中只存储一份实例,无论被继承多少次。
使用虚继承的修改很简单:在间接基类的继承处添加virtual关键字(即Student和Teacher继承Person时用虚继承):
class Person
{
public:string _name; // 姓名
};// 虚继承Person
class Student : virtual public Person
{
protected:int _stuNum;
};// 虚继承Person
class Teacher : virtual public Person
{
protected:int _teaId;
};// Assistant继承Student和Teacher(无需虚继承)
class Assistant : public Student, public Teacher
{
protected:string _course;
};int main()
{Assistant a;a._name = "peter"; // 正确:无二义性,_name只存一份return 0;
}注意:我们添加virtual关键字的时候,应添加在引发数据冗余的派生类后面,如:

这也是一个菱形继承,我们的virtual关键字应加载引发数据冗余的B类和C类后面,不可加在D类后面。
重要提醒:实际开发中,尽量不要设计菱形继承。虚继承会增加代码复杂度和性能损耗,同时菱形继承本身也会引发一系列复杂的问题(重点),能通过其他方式(比如组合)规避的,就不要用菱形继承。
8.5 多继承指针偏移问题
下面说法正确的是()
A. p1 == p2 == p3 B. p1 < p2 < p3 C. p1 == p3 != p2 D. p1 != p2 != p3
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main()
{Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;return 0;
}答案:C
解析:在多继承中,Derive 类的内存布局是先存储 Base1 的成员,再存储 Base2 的成员,最后存储自身的成员。
p1 是 Base1* 类型,指向 Derive 对象中 Base1 部分的起始地址,与 p3(Derive* 类型,指向整个 Derive 对象的起始地址)在数值上相等(因为 Base1 是第一个基类,起始地址和派生类整体起始地址一致)。
p2 是 Base2* 类型,指向 Derive 对象中 Base2 部分的起始地址,由于 Base1 占用了一定内存(int _b1 占 4 字节),所以 p2 的地址大于 p1 和 p3 的地址。
因此,p1 == p3 != p2,选项 C 正确。

9. 继承和组合
学到这里,你可能会问:除了继承,还有没有其他代码复用方式?当然有 —— 组合。很多时候,组合比继承更灵活、更安全,甚至被推荐为 “优先选择”。
9.1 继承与组合的核心区别
先明确两者的关系定义:
继承:is-a 关系(A 是 B 的一种),比如BMW是Car的一种,Student是Person的一种;
组合:has-a 关系(A 包含 B),比如Car包含Tire(轮胎),Phone包含Battery(电池)。
两者的核心差异可以用一张表对比:

9.2 适用场景
什么时候用继承,什么时候用组合?
看两个实际例子,就能明白两者的适用场景:
案例 1:Car 与 Tire(轮胎)—— 组合
Car需要Tire才能行驶,但Car不是Tire的一种,而是 “包含” 4 个Tire。这种场景用组合:
// 轮胎类
class Tire
{
protected:string _brand = "Michelin"; // 品牌size_t _size = 17; // 尺寸
};// 汽车类:组合轮胎(has-a)
class Car
{
protected:string _color = "白色"; // 颜色string _plate = "陕ABIT00"; // 车牌号Tire _t1, _t2, _t3, _t4; // 包含4个轮胎(组合)
};案例 2:Car 与 BMW(宝马)—— 继承
BMW是Car的一种,具备Car的所有属性(颜色、车牌号、轮胎),同时有自己的独有功能(比如 “操控好”)。这种场景用继承:
// 宝马类:继承Car(is-a)
class BMW : public Car
{
public:void Drive() {cout << "BMW:操控精准,好开!" << endl;}
};// 奔驰类:继承Car(is-a)
class Benz : public Car
{
public:void Drive() {cout << "Benz:座椅舒适,好坐!" << endl;}
};9.3 核心原则
我们记住一点:“优先使用组合,而不是继承”。原因很简单:组合的耦合度低,代码维护性好。当基类修改时,组合类几乎不受影响;而继承的派生类可能需要大量修改。
只有在以下两种场景下,才考虑使用继承:
1. 明确存在 “is-a” 关系(比如BMW是Car);
2. 需要实现多态。
如果一个场景既可以用继承,也可以用组合(比如stack和vector,stack既可以继承vector,也可以组合vector),优先选组合:
// stack和vector的关系,既符合is-a,也符合has-a
template<class T>
class stack : public vector<T>
{};// 组合实现stack(推荐)
template<class T>
class stack
{
public:void push(const T& x) { _v.push_back(x); } // 调用vector的接口void pop() { _v.pop_back(); }
private:vector<T> _v; // 组合vector,依赖其接口
};10. 总结
看到这里,你已经掌握了 C++ 继承的核心知识。最后用几句话总结,帮你巩固记忆:
1. 继承的价值:类层次的代码复用,解决冗余问题,体现 “is - a” 关系;
2. 基础用法:public 继承是主流,注意基类成员的访问权限,派生类需负责基类的初始化;
3. 避坑重点:同名成员会隐藏,默认成员函数要调用基类的,友元关系不继承;
4. 进阶问题:多继承易导致菱形继承,用虚继承解决,但尽量避免设计菱形结构;
5. 实战决策:优先用组合(低耦合),仅在 “is - a” 或需要多态时用继承。
继承是 C++ 面向对象的核心,但不是唯一的复用手段。合理搭配继承与组合,才能写出高效、易维护的代码。
结语
好好学习,天天向上!有任何问题请指正,谢谢观看!
