1. 继承的概念
C++ 中的继承是面向对象编程的一个核心概念,它允许我们创建一个新的类(称为子类或派生类),该类继承另一个类(称为基类或父类)的属性和行为。继承的基本目的是 代码重用和建立类之间的层次关系。
合理地运用继承机制,我们能够极大地简化代码编写过程,并清晰地构建出对象之间的层次结构。以人为例,无论是学生、老师、医生还是警察,他们都是具有共同特征,性别、年龄、住址等的个体。通过继承,我们可以在一个通用的“人”类中定义这些共性,然后从这个基础类派生出专门的子类,每个子类不仅继承了“人”类的属性和方法,还能根据自身的特点添加独有的属性和行为。这种面向对象的设计使得代码更加模块化、易于扩展和维护。
2. 继承的方式
C++ 中的继承方式包括三种,公有继承(public),私有继承(private),保护继承(protected),但是我们最常用的还是 公共继承(public)。不同的继承方式涉及到派生类对类中成员的访问权限,在这里利用表格简单介绍一下:
继承方式 | 子类中对父类成员的访问 | 外部对子类成员的访问 | 备注 |
---|---|---|---|
公有继承 (Public) | 公有 (Public) 和 保护 (Protected) | 公有 (Public) | 子类内部可以访问父类的非私有成员,外部可访问公共成员 |
保护继承 (Protected) | 公有 (Public) 和 保护 (Protected) | 公有 (Public)) | 子类内部可以访问父类的非私有成员,外部不能访问 |
私有继承 (Private) | 公有 (Public) 和 保护 (Protected) | 公有 (Public) | 子类内部可以访问父类的非私有成员,外部不能访问 |
看起来很繁琐但是这里可以简单总结为:除了私有成员,派生类在任何继承方式下,可以在内部访问基类的其他成员;外部访问权限为在继承方式和成员本身的访问权限取小的那一个,就比如继承方式为 public,但是该成员在基类的访问权限为protected,那么就取 protected,外部不可访问。
<切记>: 基类的对象(除了极少数个别例外)都会被继承在派生类中,你访问不到是因为访问权限的限制,而不是该成员不没被继承 !!!
3. 继承的格式
这里就使用 植物(Plant) 作为基类,水果(Fruit) 作为派生类:
// 植物
class Plant {
-------
};
// 水果
class Fruit : public Plant {
-------
};
在 Fruit 后面加上 :,之后是继承方式(public),最后是继承的对象 Plant。也可省去继承方式,但是默认的继承方式是 private。
还可以同时继承多个对象:
class A : public B, public C {}
3. 派生类中的成员和函数
3.1 同名变量 和 同名函数
基类和派生类可以存在同名变量或者是同名函数吗?如果可以的话,怎么调用呢(调用哪一个呢)?
答案是:可以的,两者之间是可以存在同名变量或函数的。调用的话,会就近原则,优先调用派生类自己的成员。
就以同名变量为例:
// 植物
class Plant {
public:
Plant(string color = "GREEN") {
_color = color;
}
string _color; // 颜色
};
// 水果
class Fruit : public Plant {
public:
Fruit(int price = 0) {
_price = price;
}
void setColor(string color){
_color = color;
}
private:
string _color;
int _price; // 价格
};
如果我们调用函数 setColor()
,默认会调用 Fruit 自己的 _color。但是如果我就是想要调用 Plant 的成员呢?那么,就需要指定一下,告诉编译器我要的是 Plant 的成员:Plant::_color = color
,成员函数也是这个道理。
3.2 派生类的构造函数
派生类的成员变量中除了包含自己所定义的,还可以简单认为包含一个隐藏的基类对象(这也是我们可以调用基类成员的原因)。所以编辑器在构造派生类对象时,会先在初始化列表调用基类的构造函数,再初始化自己的成员变量。
举个栗子:
// 植物
class Plant {
public:
Plant(string color = "GREEN") {
_color = color;
cout << "Plant()" << endl;
}
string _color; // 颜色
};
// 水果
class Fruit : public Plant {
public:
Fruit(string color = "RED", int price = 0)
:Plant(color)
{
_price = price;
cout << "Fruit()" << endl;
}
private:
int _price; // 价格
};
在这里初始化对基类成员初始化有点奇怪 Plant(color)
,有点像临时对象的用法。
我们初始化一个 Fruit 对象,结果打印了:
Plant()
Fruit()
这和我们预期的一样,先调用基类,再调用派生类的构造。
3.3 派生类的析构函数
和构造函数不同,在派生类中我们不需要显示的调用基类的构造函数。编译器会自动调用基类的析构函数,而且编译器会在最后才调用,原因是因为避免对基类成员有啥操作结果都析构了。所以我们一定不要显示调用基类的析构函数,避免二次析构报错!!!
举个栗子:
// 植物
class Plant {
public:
Plant(string color = "GREEN") {
_color = color;
cout << "Plant()" << endl;
}
~Plant() {
cout << "~Plant()" << endl;
}
string _color; // 颜色
};
// 水果
class Fruit : public Plant {
public:
Fruit(string color = "RED", int price = 0)
:Plant(color)
{
_price = price;
cout << "Fruit()" << endl;
}
~Fruit() {
cout << "~Fruit()" << endl;
}
private:
int _price; // 价格
};
初始化一个 Fruit 对象,最后打印:
Plant()
Fruit()
~Fruit()
~Plant()
可以看到析构函数和构造函数的顺序是相反的,基类先构造后析构,派生类后构造先析构。
3.5 总结
以上三个就是就是大家特别需要注意的点。还有些产生变化的函数,比如拷贝构造等,原理都是差不多的,先调用基类的什么什么,再调用派生类的什么什么,希望大家可以举一反三。
4. 向上类型转换(切片)
在 C++ 中,派生类对象可以赋值给基类对象,这称为向上类型转换或切片。就比如:
// 植物
class Plant {
------
};
// 水果
class Fruit : public Plant {
------
};
void test{
Plant p;
Fruit f;
p = f;
}
在这里我们将 Fruit 对象赋值给 基类对象,中间发生了切片操作,也就是将 f 中 Fruit 的成员给切除掉,只保留基类对象的成员,具体操作是:
那可以反过来吗?将基类对象赋值给派生类对象。答案肯定是不可以的!前者可以切片,你后者总不能添加一点吧。
所以说派生类对象是可以被基类对象的引用,指针所接受的。
5. 多继承
前者我们所举的例子都是基于单继承,但 C++ 也是可以多继承的。
在生活中,我们一个人可能会有多个身份,比如,你可以是老师,同时你也是你父亲的儿子,也是你儿子的父亲,所以说多继承看起来是非常符合生活的常规的。但同时,C++ 的多继承也埋下了颗雷,挖了一个坑。
5.1 多继承带来的菱形继承
什么是菱形继承呢?以我们上面一直使用的 植物 为例,以 植物 为基类我们派生出了 蔬菜 和 水果。大家知道西红柿吧,西红柿其实又是 蔬菜 也是 水果,所以可以同时继承两者:
// 植物
class Plant {
......
private:
string color;
};
// 水果
class Fruit : public Plant {
......
private:
int _price;
};
// 蔬菜
class Vegetable : public Plant {
......
private:
int _weight;
};
class Tomato : public Fruit, public Vegetable{
......
private:
int _size;
};
所以,它们的关系图是:
图像的话很形象地体现了 菱形 这一概念。
5.2 菱形继承带来的问题
经过上面的学习我们知道:通过继承,派生类中会存在基类的成员。蔬菜和水果都继承了植物,西红柿又继承了两者,所以西红柿保存了两份植物中的变量:
西红柿中保存了两份 _color,这会带来:
- 数据冗余 (同一个变量一份就够了)
- 数据二义性 (编译器不明确调用哪一个)
5.3 解决方案
解决方案一:
可以加上限定符访问。比如我想要访问 Vegetable 中的 _color,就可以是 Vegetable::_color
。
但是这种方法不推荐,因为描述一个特征的变量一个就好,第二个意义不大。
解决方案二:
采用虚继承的方法,具体的形式为:
// 植物
class Plant {
......
private:
string color;
};
// 水果
class Fruit : virtual public Plant {
......
private:
int _price;
};
// 蔬菜
class Vegetable : virtual public Plant {
......
private:
int _weight;
};
class Tomato : public Fruit, public Vegetable{
......
private:
int _size;
};
在继承公共基类时加上 virtual 关键词代表虚继承。这样的话,公共基类的成员变量就会只存储一份。
6. 拓展 — 虚继承背后的逻辑
前提:本机运行时的环境为 x86(32位),所以地址的大小为 4 字节。
这一节作为拓展,理解虚继承背后是怎么回事。如果忽略,同样不会影响你的使用。
6.1 实现逻辑
好的,我们现在重新构造简单的四个类并形成菱形继承的关系:
class A {
private:
int _a = 0;
};
class B : public A{
private:
int _b = 1;
};
class C : public A {
private:
int _c = 2;
};
class D : public B, public C {
private:
int _d = 4;
};
void test() {
B b;
C c;
D d;
}
现在我们通过调试看一看在内存中 d 内部存储了什么:
可以观察到确实 _a 存储了两份。
现在我们将继承改为虚拟继承:
class A {
private:
int _a = 0;
};
class B : virtual public A{
private:
int _b = 1;
};
class C : virtual public A {
private:
int _c = 2;
};
class D : public B, public C {
private:
int _d = 4;
};
void test() {
B b;
C c;
D d;
}
再次在内存中观察:
这次的话确实只存储了一个 a,但是 b, c 在原来存储 a 的位置上改换成了一串未知的数据。
这里也不让大家猜了,这个是数据是地址,我们通过找到该地址所在的位置:
b 中的地址寻址:
c 中的地址寻址:
我们一个一个看,对于 b 来说:
该数字为 16 进制数,转化为 10 进制为 20,该数就是内存中 b 到 a 的距离:
采用 c 验证我们的想法,将该数转化为十进制为 12,也是 c 到 a 的距离。
所以说该地址存储的是到派生类到基类的距离。
6.2 疑问 — 为什么直接放基类成员的地址?
为什么还需要使用一个指针,存储距离基类成员的距离?直接放基类成员的地址不好吗?
- 因为在这里我们的基类构造的十分简单,只有一个变量,如果他有很多变量呢?放很多地址吗?这样会大大增加占用的空间
- 这样做便于切片。就比如我使用 B* 来指向一个 D 的值,我们切片后,可以很便捷的根据指针存储的距离来访问基类的成员变量。
总结
在C++编程中,菱形继承是一种继承结构,其中类层次形成了一个菱形图案,一个类直接继承自两个不同的类,而这两个类又继承自同一个基类。这种继承模式通常会导致继承层次结构复杂化,增加代码维护的难度,因此通常应避免使用。