C++ 继承:从基础到实战,彻底搞懂面向对象的 "代码复用术"
在面向对象编程(OOP)的世界里,"继承" 是实现代码复用的核心机制 ------ 就像现实中孩子会继承父母的特征,C++ 的子类也能 "继承" 父类的成员(变量 + 函数),再添加自己的独特功能。对于刚接触 OOP 的开发者来说,继承既是 "利器",也藏着不少容易踩坑的细节(比如菱形继承、隐藏与重载的区别)。
这篇文章会从 "概念→实战→避坑" 逐步拆解 C++ 继承,用通俗的语言 + 完整代码示例,帮你彻底掌握这一知识点,甚至应对笔试面试中的高频问题。
一、继承的基础:什么是继承?怎么用?
1.1 先搞懂:继承的 "本质" 是什么?
继承的核心是 **"复用已有类的代码,扩展新功能"**。
比如我们有一个Person
类(包含姓名、年龄和打印信息的函数),现在要定义Student
和Teacher
类 ------ 这两个类都需要 "姓名、年龄",没必要重复写,直接 "继承"Person
即可,再补充自己的独特成员(如学号、工号)。
看代码更直观:
// 父类(基类):Person
class Person {
public:
// 父类的成员函数:复用给子类
void Print() {
cout << "姓名:" << _name << endl;
cout << "年龄:" << _age << endl;
}
protected:
// 父类的成员变量:复用给子类
string _name = "peter"; // 姓名
int _age = 18; // 年龄
};
// 子类(派生类):Student,继承自Person
class Student : public Person {
protected:
int _stuid; // 子类新增的成员:学号
};
// 子类(派生类):Teacher,继承自Person
class Teacher : public Person {
protected:
int _jobid; // 子类新增的成员:工号
};
// 测试:子类能直接用父类的Print函数
int main() {
Student s;
Teacher t;
s.Print(); // 输出:姓名:peter,年龄:18(复用Person的Print)
t.Print(); // 同样复用,无需重复写代码
return 0;
}
1.2 继承的 "语法规则":3 个关键要素
要正确使用继承,必须掌握「继承方式」和「访问限定符」的搭配 ------ 这决定了父类成员在子类中的 "访问权限"。
(1)基本语法格式
class 子类名 : 继承方式 父类名 {
// 子类的成员(新增/重定义)
};
-
父类(基类):被继承的已有类(如
Person
); -
子类(派生类):新定义的类(如
Student
); -
继承方式:
public
(公有的)、protected
(保护的)、private
(私有的),默认继承方式:class
是private
,struct
是public
(建议显式写清,避免混淆)。
(2)访问权限的 "黄金表格"
父类成员(public
/protected
/private
)在子类中的权限,由「父类访问限定符」和「继承方式」共同决定,核心规则是:子类访问权限 = min (父类访问限定符,继承方式) (优先级:public > protected > private
)。
直接看表格更清晰:
父类成员类型 | public 继承 | protected 继承 | private 继承 |
---|---|---|---|
public 成员 | 子类 public | 子类 protected | 子类 private |
protected 成员 | 子类 protected | 子类 protected | 子类 private |
private 成员 | 子类中不可见 | 子类中不可见 | 子类中不可见 |
(3)3 个必须记住的结论
-
父类 private 成员永远 "不可见":不是没继承,而是语法禁止子类(无论类内还是类外)访问,相当于 "继承了但用不了";
-
protected 是为继承设计的 :如果父类成员不想被类外访问,但想让子类用,就定义为
protected
(这是protected
和private
的核心区别); -
实际开发优先用 public 继承 :
protected/private
继承的子类成员只能在类内用,扩展维护性差,几乎不用。
二、基类与派生类:对象的 "赋值转换" 规则
子类对象和父类对象之间能不能互相赋值?这里有个形象的说法叫 **"切片"(切割)** ------ 把子类中 "属于父类的部分" 切下来,赋值给父类对象 / 指针 / 引用。
2.1 允许的转换:子类 → 父类(切片)
子类对象可以直接赋值给父类的对象、指针、引用,无需强制转换:
class Person {
protected:
string _name; // 姓名
int _age; // 年龄
};
class Student : public Person {
public:
int _stuid; // 学号
};
void Test() {
Student sobj; // 子类对象
// 1. 子类对象 → 父类对象(切片:只赋值父类部分)
Person pobj = sobj;
// 2. 子类对象地址 → 父类指针(指向子类的父类部分)
Person* pp = &sobj;
// 3. 子类对象 → 父类引用(引用子类的父类部分)
Person& rp = sobj;
}
2.2 禁止的转换:父类 → 子类
父类对象不能直接赋值给子类对象 ------ 因为子类比父类多了成员(如_stuid
),父类没有这部分数据,无法填充子类的新增成员,语法直接禁止:
void Test() {
Person pobj;
Student sobj;
// sobj = pobj; // 报错:父类不能赋值给子类
}
2.3 危险的转换:父类指针 → 子类指针
父类指针可以通过强制转换 赋值给子类指针,但只有一种情况安全:父类指针原本指向的是子类对象(此时指针实际指向的是子类的父类部分,强制转换后能访问子类新增成员)。
如果父类指针指向的是父类对象 ,强制转换后访问子类成员会导致越界访问(父类对象没有子类成员的内存),非常危险:
void Test() {
Student sobj;
Person pobj;
Person* pp;
// 情况1:父类指针指向子类对象 → 强制转换安全
pp = &sobj;
Student* ps1 = (Student*)pp;
ps1->_stuid = 10; // 安全:pp实际指向子类,有_stuid内存
// 情况2:父类指针指向父类对象 → 强制转换危险(越界)
pp = &pobj;
Student* ps2 = (Student*)pp;
ps2->_stuid = 10; // 危险:pobj没有_stuid,越界访问内存
}
三、继承中的 "作用域":小心 "隐藏" 陷阱
基类和子类是独立的作用域 ,这会导致一个常见问题:隐藏(重定义) ------ 子类和父类有同名成员时,子类成员会 "屏蔽" 父类成员的直接访问。
3.1 成员变量的隐藏
子类和父类有同名成员变量时,子类中直接访问该变量,默认是子类的,父类的需要用父类名::
显式访问:
class Person {
protected:
string _name = "小李子";
int _num = 111; // 父类:身份证号
};
class Student : public Person {
public:
void Print() {
cout << "姓名:" << _name << endl; // 子类继承的_name
cout << "身份证号:" << Person::_num << endl;// 显式访问父类_num
cout << "学号:" << _num << endl; // 子类自己的_num
}
protected:
int _num = 999; // 子类:学号(与父类_num同名,隐藏父类)
};
void Test() {
Student s1;
s1.Print(); // 输出:姓名:小李子;身份证号:111;学号:999
}
3.2 成员函数的隐藏(易混淆点)
成员函数的隐藏规则更 "严格":只要函数名相同,就构成隐藏,不管参数列表、返回值是否相同(这和 "重载" 完全不同 ------ 重载要求同一作用域、参数列表不同)。
比如父类A
有fun()
,子类B
有fun(int)
,这两个函数是隐藏关系,不是重载:
class A {
public:
void fun() {
cout << "fun()" << endl;
}
};
class B : public A {
public:
// 函数名相同,构成隐藏(不管参数)
void fun(int i) {
A::fun(); // 显式访问父类fun()
cout << "fun(int i) → " << i << endl;
}
};
void Test() {
B b;
b.fun(10); // 调用子类fun(int),输出:fun();fun(int i) → 10
// b.fun(); // 报错:父类fun()被隐藏,需用A::fun()访问
}
3.3 避坑建议
实际开发中,永远不要在继承体系中定义同名成员------ 隐藏会导致代码可读性差、容易误调用,排查 bug 成本高。
四、派生类的 "默认成员函数":规则要记牢
C++ 类有 6 个默认成员函数(编译器会自动生成的函数),但派生类的默认成员函数有特殊规则 ------必须先初始化 / 清理父类部分,再处理子类部分。
重点关注 4 个核心函数:构造、拷贝构造、赋值重载、析构(取地址重载几乎不用,忽略)。
4.1 派生类的构造函数
-
规则 :派生类构造函数必须调用父类构造函数,初始化父类部分;
-
特殊情况 :如果父类没有 "默认构造函数"(无参、全缺省),必须在派生类构造函数的初始化列表中显式调用父类构造函数。
示例:
class Person {
public:
// 父类:带参构造(无默认构造)
Person(const char* name) : _name(name) {
cout << "Person(const char*)" << endl;
}
protected:
string _name;
};
class Student : public Person {
public:
// 子类构造:必须在初始化列表显式调用父类构造
Student(const char* name, int stuid)
: Person(name) // 先初始化父类
, _stuid(stuid) // 再初始化子类
{
cout << "Student(const char*, int)" << endl;
}
protected:
int _stuid;
};
void Test() {
Student s("jack", 1001);
// 输出顺序:Person(const char*) → Student(const char*, int)
}
4.2 派生类的拷贝构造函数
-
规则 :派生类拷贝构造必须调用父类拷贝构造函数,拷贝父类部分的数据;
-
注意:默认生成的派生类拷贝构造会自动调用父类拷贝构造,但如果自己实现,必须显式调用。
示例:
class Person {
public:
Person(const Person& p) : _name(p._name) {
cout << "Person(const Person&)" << endl;
}
protected:
string _name;
};
class Student : public Person {
public:
// 子类拷贝构造:显式调用父类拷贝构造
Student(const Student& s)
: Person(s) // 父类拷贝构造(s切片给Person)
, _stuid(s._stuid)
{
cout << "Student(const Student&)" << endl;
}
protected:
int _stuid;
};
void Test() {
Student s1("jack", 1001);
Student s2(s1); // 拷贝构造
// 输出:Person(const Person&) → Student(const Student&)
}
4.3 派生类的赋值重载(operator=)
-
规则 :派生类赋值重载必须调用父类赋值重载,否则父类部分的数据不会被赋值(浅拷贝问题);
-
注意 :赋值重载不会自动调用父类的,必须显式用
父类名::operator=
调用。
示例:
class Person {
public:
Person& operator=(const Person& p) {
if (this != &p) { // 防止自赋值
_name = p._name;
}
cout << "Person::operator=" << endl;
return *this;
}
protected:
string _name;
};
class Student : public Person {
public:
Student& operator=(const Student& s) {
if (this != &s) {
Person::operator=(s); // 显式调用父类赋值重载
_stuid = s._stuid;
}
cout << "Student::operator=" << endl;
return *this;
}
protected:
int _stuid;
};
4.4 派生类的析构函数
-
规则 1 :派生类析构函数会在自己执行完后,自动调用父类析构函数,保证 "先清理子类,再清理父类"(和构造顺序相反);
-
规则 2 :析构函数名会被编译器统一处理成
destructor()
,所以父类和子类的析构函数构成隐藏 (不加virtual
的情况下,后面多态会讲virtual
的作用)。
示例:
class Person {
public:
~Person() {
cout << "~Person()" << endl;
}
};
class Student : public Person {
public:
~Student() {
cout << "~Student()" << endl;
}
};
void Test() {
Student s;
// 析构顺序:~Student() → ~Person()(自动调用父类析构)
}
五、继承的 "特殊情况":友元和静态成员
5.1 友元不能继承
父类的友元函数 / 类,不能访问子类的私有 / 保护成员------ 友元关系是 "单向的",只针对父类,不传递给子类。
示例:
class Student; // 前置声明
class Person {
// 父类友元:Display可以访问Person的私有/保护成员
friend void Display(const Person& p, const Student& s);
protected:
string _name = "peter";
};
class Student : public Person {
protected:
int _stuid = 1001; // 子类保护成员
};
void Display(const Person& p, const Student& s) {
cout << p._name << endl; // 可以:访问父类保护成员
// cout << s._stuid << endl; // 报错:友元不能继承,无法访问子类保护成员
}
5.2 静态成员在继承体系中 "唯一"
父类定义的静态成员(static
),整个继承体系中只有一个实例------ 不管派生出多少子类,所有类和对象共用这一个静态成员(相当于 "全局变量",但属于类)。
示例:统计继承体系中对象的总数:
class Person {
public:
Person() { ++_count; } // 构造时计数+1
public:
static int _count; // 静态成员:统计对象总数
protected:
string _name;
};
// 静态成员必须在类外初始化
int Person::_count = 0;
class Student : public Person {};
class Graduate : public Student {}; // 子类的子类
void Test() {
Student s1, s2;
Graduate g1;
// 所有对象共用_count,总数=3
cout << "对象总数:" << Person::_count << endl; // 输出3
Student::_count = 0; // 子类也能修改,因为共用
cout << "对象总数:" << Person::_count << endl; // 输出0
}
六、继承的 "老大难":菱形继承与虚拟继承
这是 C++ 继承的 "痛点",也是面试高频考点 ------ 菱形继承是多继承的特殊情况,会导致数据冗余 和二义性,而虚拟继承是解决这一问题的方案。
6.1 先理清:单继承、多继承、菱形继承
-
单继承 :子类只有一个直接父类(如
Student → Person
); -
多继承 :子类有两个及以上直接父类(如
Assistant → Student + Teacher
); -
菱形继承:多继承的特殊情况 ------ 两个子类继承同一个父类,又有一个子类继承这两个子类(形成 "菱形" 结构)。
结构示意图:
Person(顶层父类)
/ \
Student Teacher(中间子类,都继承Person)
\ /
Assistant(底层子类,继承Student和Teacher)
6.2 菱形继承的 "坑":数据冗余 + 二义性
看代码示例,Assistant
对象会有两份 Person
的成员 (_name
),导致两个问题:
-
二义性 :直接访问
_name
时,不知道是Student
继承的还是Teacher
继承的; -
数据冗余 :两份
_name
占用额外内存,且逻辑上应该只有一份(一个助教也是一个人,只需要一个姓名)。
示例:
class Person {
public:
string _name = "peter"; // 顶层父类成员
};
class Student : public Person { protected: int _stuid; };
class Teacher : public Person { protected: int _jobid; };
// 菱形继承:Assistant继承Student和Teacher
class Assistant : public Student, public Teacher {
protected:
string _major;
};
void Test() {
Assistant a;
// a._name = "jack"; // 报错:二义性(Student::_name还是Teacher::_name?)
// 显式指定可以解决二义性,但无法解决数据冗余(仍有两份_name)
a.Student::_name = "jack";
a.Teacher::_name = "tom";
cout << a.Student::_name << " " << a.Teacher::_name << endl; // 输出jack tom
}
6.3 解决方案:虚拟继承(virtual)
在中间子类 (Student
和Teacher
)继承Person
时,加上virtual
关键字,即可解决菱形继承的问题。
(1)使用方式
只需修改中间子类的继承方式:
class Person {
public:
string _name = "peter";
};
// 中间子类:用virtual继承Person
class Student : virtual public Person { protected: int _stuid; };
class Teacher : virtual public Person { protected: int _jobid; };
// 底层子类正常继承
class Assistant : public Student, public Teacher { protected: string _major; };
void Test() {
Assistant a;
a._name = "jack"; // 正常:无歧义,_name只有一份
cout << a._name << endl; // 输出jack
}
(2)虚拟继承的 "原理"(通俗版)
虚拟继承的核心是:让中间子类(Student
、Teacher
)不再直接存储父类(Person
)的成员,而是通过 **"虚基表指针"** 指向 **"虚基表"**,虚基表中存储了 "父类成员相对于当前类的偏移量",通过偏移量找到唯一的父类成员。
简单理解:
-
中间子类(
Student
)多了一个 "虚基表指针"(指向虚基表); -
虚基表中存着 "到
Person
成员的距离"; -
底层子类(
Assistant
)通过两个中间子类的虚基表指针,找到同一份Person
成员,避免冗余和二义性。
不用深入底层内存细节,记住 "虚拟继承让顶层父类成员在底层子类中唯一" 即可。
(3)注意事项
只在菱形继承的中间子类中使用虚拟继承,其他场景不要用 ------ 虚拟继承会增加内存开销(虚基表指针)和计算开销(偏移量查找),没必要。
七、继承的 "终极思考":继承 vs 组合,该怎么选?
很多开发者滥用继承,导致代码耦合度高、难以维护。实际上,C++ 社区有个共识:优先使用组合,而非继承。
7.1 继承:is-a 关系(是一种)
继承体现的是 "is-a"(是一种)的逻辑 ------ 比如BMW
是一种Car
,Student
是一种Person
。
-
优点:直接复用父类代码,支持多态(后面讲);
-
缺点 :耦合度高(子类依赖父类实现),破坏父类封装(子类能访问父类
protected
成员,父类修改会影响所有子类),属于 "白箱复用"(子类知道父类内部细节)。
7.2 组合:has-a 关系(有一个)
组合体现的是 "has-a"(有一个)的逻辑 ------ 比如Car
有一个Tire
(轮胎),Phone
有一个Battery
(电池)。
-
优点:耦合度低(只需依赖被组合类的接口,不用知道内部细节),封装性好,属于 "黑箱复用"(被组合类的修改不影响组合类);
-
缺点:需要手动调用被组合类的接口,代码量略多。
7.3 选择原则
- 用继承的场景:
-
存在明确的 "is-a" 关系(如
BMW → Car
); -
需要实现多态(必须用继承 +
virtual
)。
- 用组合的场景:
-
存在 "has-a" 关系(如
Car → Tire
); -
没有明确的 "is-a" 关系,只是想复用代码;
-
追求低耦合、高维护性的场景(大多数业务场景)。
示例对比:
// 继承:BMW is a Car
class Car { /* ... */ };
class BMW : public Car { /* ... */ };
// 组合:Car has a Tire
class Tire { /* ... */ };
class Car {
protected:
Tire _tire; // 组合:Car有一个Tire
};
八、笔试面试高频题(附答案)
-
什么是菱形继承?菱形继承的问题是什么?
答:菱形继承是多继承的特殊情况:两个子类继承同一个顶层父类,又有一个底层子类继承这两个子类(形成菱形结构)。问题是数据冗余 (底层子类有两份顶层父类成员)和二义性(访问顶层父类成员时无法确定来源)。
-
什么是菱形虚拟继承?如何解决数据冗余和二义性?
答:在菱形继承的中间子类 (继承顶层父类的子类)中,用
virtual
关键字进行继承,即为菱形虚拟继承。解决原理是:中间子类通过 "虚基表指针" 指向 "虚基表",虚基表存储顶层父类成员的偏移量,让底层子类只保留一份顶层父类成员,从而解决数据冗余和二义性。 -
继承和组合的区别?什么时候用继承?什么时候用组合?
答:区别在于关系和耦合度:
-
继承是 "is-a" 关系,耦合度高(子类依赖父类实现,破坏封装),白箱复用;
-
组合是 "has-a" 关系,耦合度低(依赖接口,不依赖实现),黑箱复用。
使用场景:
-
继承:is-a 关系、需要多态时;
-
组合:has-a 关系、追求低耦合时(优先选择)。
九、总结
继承是 C++ 面向对象的核心,但也是一把 "双刃剑":用得好能大幅复用代码,用得不好会导致耦合高、bug 多。这篇文章从基础到复杂,帮你理清了继承的核心规则、避坑点和最佳实践,关键记住三点:
-
优先用
public
继承,避免同名成员导致的隐藏; -
远离菱形继承,万不得已时用虚拟继承;
-
优先选择组合而非继承,降低代码耦合度。
建议你动手写代码测试本文的示例,比如菱形继承的问题、虚拟继承的效果、构造析构的调用顺序,只有实践才能真正掌握~