理解 C++ 中的继承

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 对象赋值给 基类对象,中间发生了切片操作,也就是将 fFruit 的成员给切除掉,只保留基类对象的成员,具体操作是:
在这里插入图片描述
那可以反过来吗?将基类对象赋值给派生类对象。答案肯定是不可以的!前者可以切片,你后者总不能添加一点吧。
所以说派生类对象是可以被基类对象的引用,指针所接受的。


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 中的地址寻址:
_b中的地址寻址
c 中的地址寻址:
在这里插入图片描述
我们一个一个看,对于 b 来说:
该数字为 16 进制数,转化为 10 进制为 20,该数就是内存中 ba 的距离:
在这里插入图片描述
采用 c 验证我们的想法,将该数转化为十进制为 12,也是 ca 的距离。
所以说该地址存储的是到派生类到基类的距离。

6.2 疑问 — 为什么直接放基类成员的地址?

为什么还需要使用一个指针,存储距离基类成员的距离?直接放基类成员的地址不好吗?

  • 因为在这里我们的基类构造的十分简单,只有一个变量,如果他有很多变量呢?放很多地址吗?这样会大大增加占用的空间
  • 这样做便于切片。就比如我使用 B* 来指向一个 D 的值,我们切片后,可以很便捷的根据指针存储的距离来访问基类的成员变量。

总结

在C++编程中,菱形继承是一种继承结构,其中类层次形成了一个菱形图案,一个类直接继承自两个不同的类,而这两个类又继承自同一个基类。这种继承模式通常会导致继承层次结构复杂化,增加代码维护的难度,因此通常应避免使用。

  • 23
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值