作为一名仍在不断学习的C++开发者,我深知继承机制是面向对象编程中最基础也最容易被误解的概念之一。在刚开始学习继承时,我也曾对切片问题、菱形继承等概念感到困惑。正是这些学习过程中的困惑和解决过程,促使我写下这篇总结,希望能帮助和我一样正在学习C++的朋友们少走一些弯路。
一、继承的基本概念
1.1 什么是继承?
想象你正在玩积木游戏。你有一套基础积木(比如长方形、正方形),现在你想搭建一个房子。如果每次都要从头开始拼墙、拼窗户会很麻烦。继承就像是在基础积木上添加新零件,让它变成"带窗户的墙积木",这样下次搭建时直接用这个新积木就行了。
在C++中,继承就是让新类(派生类)"继承"已有类(基类)的特性,然后添加自己独有的功能。比如:
class Animal { // 基类(父类)
public:
void eat() { cout << "吃东西" << endl; }
};
class Cat : public Animal { // 派生类(子类)
public:
void meow() { cout << "喵喵叫" << endl; }
};
- 这样,Cat类自动获得了eat()方法,不用重新编写,还能新增meow()方法。
1.2 为什么需要继承?
-
- 代码复用:避免重复编写相同的代码
-
- 扩展性:可以在不修改基类的情况下扩展功能
-
- 多态基础:为后续学习多态打下基础
-
- 层次清晰:类之间的关系一目了然
1.3 继承的基本语法
class 派生类名 : 访问修饰符 基类名 {
// 派生类新增的成员
};
- 访问修饰符可以是
public
、protected
或private
,它们决定了基类成员在派生类中的可见性。
二、三种继承方式详解
2.1 public继承(最常用)
class Student : public Person {
// Person的public成员在这里仍然是public
// protected成员仍然是protected
};
特点:
- 满足"is-a"关系(学生是人)
- 基类的public成员在派生类中仍然是public
- protected成员保持protected
适用场景:90%的情况下都应该使用public继承
2.2 protected继承(较少用)
class Student : protected Person {
// Person的public成员在这里变成protected
};
特点:
- 基类的public成员在派生类中变成protected
- 外部无法直接访问这些成员
使用场景:当你想限制基类成员的访问权限时
2.3 private继承(极少用)
class Student : private Person {
// Person的所有可访问成员在这里都变成private
};
特点:
- 基类的public和protected成员都变成private
- 几乎完全封闭了基类的接口
建议:优先考虑组合而不是private继承
2.4 访问权限总结表
基类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
public成员 | public | protected | private |
protected成员 | protected | protected | private |
private成员 | 不可见 | 不可见 | 不可见 |
重要规则:
-
- 基类的private成员在任何继承方式下都不可访问
-
- 最终访问权限 = min(成员在基类的访问权限,继承方式)
-
- class默认private继承,struct默认public继承(但建议显式指定)
三、继承中的特殊现象
3.1 名字隐藏(重定义)
- 当基类和派生类有同名成员时,派生类成员会"隐藏"基类成员:
class Father {
public:
void show() { cout << "我是爸爸"; }
};
class Son : public Father {
public:
void show() { // 隐藏了Father的show()
Father::show(); // 显式调用基类方法
cout << "我是儿子";
}
};
解决方法:使用作用域解析符::
显式指定
3.2 对象切片
- 当派生类对象赋值给基类对象时,会发生"切片":
class CakeBase { int flour; };
class CreamCake : public CakeBase { int cream; };
CreamCake wholeCake;
CakeBase slicedCake = wholeCake; // 只保留基类部分
为什么会发生切片?
- 这是因为派生类对象中包含完整的基类子对象,赋值时只拷贝基类部分。
如何避免切片?
-
- 使用指针或引用:
CakeBase* p = &wholeCake; // 不切片 CakeBase& r = wholeCake; // 不切片
-
- 使用虚函数(多态)
四、构造和析构的顺序
4.1 构造顺序
- 就像盖房子:
-
- 先打地基(基类构造)
-
- 再建上层(派生类构造)
-
class Base {
public:
Base() { cout << "基类构造"; }
};
class Derived : public Base {
public:
Derived() { cout << "派生类构造"; }
};
// 创建Derived对象时输出:
// "基类构造" → "派生类构造"
4.2 析构顺序
- 就像拆房子:
-
- 先拆上层(派生类析构)
-
- 再拆地基(基类析构)
-
~Derived() { cout << "派生类析构"; }
~Base() { cout << "基类析构"; }
// 销毁Derived对象时输出:
// "派生类析构" → "基类析构"
4.3 注意事项
-
- 派生类构造函数必须调用基类构造函数
-
- 如果基类没有默认构造函数,必须在派生类构造函数的初始化列表中显式调用
-
- 析构函数应该声明为virtual(多态情况下)
五、菱形继承与虚拟继承
5.1 什么是菱形继承?
- 这种继承结构会导致:
-
- 数据冗余:Assistant会有两份Person成员
-
- 二义性:无法确定访问的是哪个Person成员
-
5.2 解决方案:虚拟继承
class Person {};
class Student : virtual public Person {}; // 虚拟继承
class Teacher : virtual public Person {}; // 虚拟继承
class Assistant : public Student, public Teacher {};
虚拟继承的特点:
-
- 确保整个继承体系中只保留一份基类子对象
-
- 通过虚基表指针和虚基表实现
-
- 解决了数据冗余和二义性问题
5.3 虚拟继承的实现原理
-
- 编译器会为每个虚拟继承的类添加虚基表指针
-
- 虚基表中存储了到共享基类的偏移量
-
- 通过这个偏移量可以找到共享的基类子对象
六、继承 vs 组合
6.1 继承(is-a关系)
// "宝马是一种汽车"
class BMW : public Car {};
适用场景:
- 派生类确实是基类的特殊化
- 需要实现多态
- 严格的层次关系
6.2 组合(has-a关系)
// "汽车有一个发动机"
class Car {
Engine engine; // 组合
};
优势:
-
- 更低的耦合度
-
- 更高的灵活性
-
- 更好的封装性
6.3 如何选择?
优先使用组合的情况:
- 只是需要复用代码
- 不是严格的"is-a"关系
- 需要动态更换组件
必须使用继承的情况:
- 实现多态(虚函数)
- 严格的"is-a"关系
- 接口实现
七、尾言
- 由于个人水平有限,文中难免存在疏漏或表述不当之处。如果您在阅读过程中发现任何问题,或者有更好的实践建议,欢迎在评论区留言讨论。