文章目录
- 前言
- 🥐五、多继承,菱形继承和菱形虚拟继承
- 🧀5.1 多继承
- 🧀5.2 菱形继承
- 🧀5.3 虚拟继承(解决菱形继承问题)
- 5.3.1 虚拟继承的语法:
- 5.3.2 虚拟继承示例:
- 🧀5.4 虚拟继承的工作原理
- 5.4.1 继承路径的管理:
- 🧀5.5 虚拟继承中的构造顺序
- 🥐六、多继承的指针偏移问题
- 🧀6.1 普通多继承中的指针偏移问题
- 🧀6.2 指针偏移在内存中的表现
- 🧀6.3 虚拟继承中的指针偏移问题
- 🧀6.4 汇编视角下的指针偏移
- 6.4.1 普通继承的汇编:
- 6.4.2 虚拟继承的汇编:
- 🧀6.5 虚拟继承中指针偏移的机制
- 🥐七、虚拟继承与汇编之间的关系
- 🧀7.1 虚拟继承的内存布局
- 7.1.1 内存布局对比
- 🧀7.2 虚基表(vbtable)与指针调整
- 🧀7.3 汇编视角
- 7.3.1 汇编代码中的指针调整
- 7.3.2 普通继承的汇编代码:
- 7.3.3 虚拟继承的汇编代码:
- 🧀7.4 虚拟继承带来的开销
- 🥐八、继承与组合
- 🧀8.1 继承的优缺点:
- 🧀8.2 组合
- 🧀8.3 继承 vs 组合:如何选择?
- 🧀8.4 继承与组合的混合使用
- 🧀8.5 优先使用组合原则
- 结语
前言
我们接上集解锁C++继承的奥秘:从基础到精妙实践(上),继续深入探讨C++继承的多重继承的处理、虚函数与多态的应用,以及如何在复杂系统中有效利用继承来构建可维护且扩展性强的代码架构。通过系统的学习,你将对C++继承有更深入的理解,并能够在实际开发中灵活应用这些知识。
🥐五、多继承,菱形继承和菱形虚拟继承
在C++中,多继承 是指一个类可以继承自多个基类。这是C++区别于其他语言(如Java)的一个特性。菱形继承(也叫“钻石继承”)是多继承中常见的一种继承结构,其中一个派生类通过不同路径继承了同一个基类。虚拟继承 是C++为解决菱形继承问题而提供的一个机制。
🧀5.1 多继承
多继承是指一个派生类可以继承多个基类。派生类可以同时继承基类的所有属性和方法。在多继承的情况下,派生类从多个基类获得特性。如图分析单继承与多继承的区别:
示例:
#include <iostream>
using namespace std;
class Base1 {
public:
void show() {
cout << "Base1::show()" << endl;
}
};
class Base2 {
public:
void display() {
cout << "Base2::display()" << endl;
}
};
// Derived类同时继承Base1和Base2
class Derived : public Base1, public Base2 {
};
int main() {
Derived d;
d.show(); // 调用Base1的方法
d.display(); // 调用Base2的方法
return 0;
}
说明:
Derived
类继承了Base1
和Base2
的成员,能够同时访问两个基类的方法。- 多继承允许一个类具备多个基类的功能,但也可能带来复杂性,尤其是涉及同名成员时。
🧀5.2 菱形继承
菱形继承(Diamond Inheritance)是多继承的一种特殊情况。它发生在一个派生类通过多个路径继承同一个基类时,形成菱形结构:
在这种结构中,D
类通过B
和C
分别继承了基类A
。此时,D
类会有两个A
类的副本,造成数据冗余和不一致性的问题。这就是菱形继承问题。
示例:
#include <iostream>
using namespace std;
class A {
public:
int value;
A() { value = 10; }
};
// B和C类都继承A
class B : public A {
};
class C : public A {
};
// D类通过B和C同时继承A
class D : public B, public C {
};
int main() {
D d;
// d.value; // 错误!不明确的访问,D有两个A的副本
d.B::value = 20; // 通过B路径访问A
d.C::value = 30; // 通过C路径访问A
cout << "B::value = " << d.B::value << endl; // 输出: B::value = 20
cout << "C::value = " << d.C::value << endl; // 输出: C::value = 30
return 0;
}
说明:
D
类通过B
和C
分别继承了两个A
类的副本,造成了两个A::value
的独立实例。这意味着D
类中存在两个A::value
变量,通过不同路径访问会产生不同的结果。- 这种冗余会导致数据不一致和维护困难的问题,这就是菱形继承的主要问题。
🧀5.3 虚拟继承(解决菱形继承问题)
为了解决菱形继承中的冗余问题,C++提供了虚拟继承机制。通过虚拟继承,可以确保在菱形继承结构中,只存在一个基类的副本,而不是每条继承路径都创建一个基类的副本。
5.3.1 虚拟继承的语法:
class Derived : virtual public Base { };
通过virtual
关键字声明的继承就是虚拟继承,虚拟继承确保在多条路径继承同一基类时,派生类中只保留一份基类的副本。
5.3.2 虚拟继承示例:
#include <iostream>
using namespace std;
class A {
public:
int value;
A() { value = 10; }
};
// B和C通过虚拟继承A
class B : virtual public A {
};
class C : virtual public A {
};
// D通过B和C继承A,但只有一个A的副本
class D : public B, public C {
};
int main() {
D d;
d.value = 100; // D类中只有一个A的实例
cout << "D::value = " << d.value << endl; // 输出: D::value = 100
return 0;
}
说明:
- 通过
virtual
继承,D
类只继承了一个A
类的副本,无论通过B
还是C
访问A::value
,都是同一个值。 - 虚拟继承消除了冗余问题,避免了菱形继承中的数据不一致性。
🧀5.4 虚拟继承的工作原理
- 普通继承:在普通继承中,派生类每次从基类继承时都会复制一份基类的成员变量,派生类中会存在多个基类的副本。
- 虚拟继承:在虚拟继承中,编译器确保派生类中只保留基类的一份副本。所有通过虚拟继承的路径都会共享同一个基类副本。
5.4.1 继承路径的管理:
- 当派生类通过多个路径继承自虚拟基类时,派生类中的虚拟基类部分会被“合并”成一个。
- 这个机制避免了菱形继承中的歧义问题,但虚拟继承也增加了一些内存开销和复杂性。
🧀5.5 虚拟继承中的构造顺序
在使用虚拟继承时,基类的构造顺序会发生变化。虚拟基类的构造会优先于其他非虚拟基类,并且由最终派生类负责调用虚拟基类的构造函数。
示例:
#include <iostream>
using namespace std;
class A {
public:
A() { cout << "A constructor" << endl; }
};
class B : virtual public A {
public:
B() { cout << "B constructor" << endl; }
};
class C : virtual public A {
public:
C() { cout << "C constructor" << endl; }
};
class D : public B, public C {
public:
D() { cout << "D constructor" << endl; }
};
int main() {
D d;
return 0;
}
输出:
A constructor
B constructor
C constructor
D constructor
【说明】:
- 尽管
B
和C
都继承了A
,但A
只会被构造一次(虚拟继承)。 - 虚拟基类的构造函数由最派生类
D
负责调用,在构造B
和C
之前构造A
。
🥐六、多继承的指针偏移问题
在C++的多继承中,指针偏移问题是指当使用基类指针指向派生类对象时,由于多继承导致内存布局复杂化,必须调整指针来正确访问派生类对象中的基类部分。这种指针偏移在多继承和虚拟继承中尤为明显。
🧀6.1 普通多继承中的指针偏移问题
在C++中,一个类可以从多个基类继承。每个基类在内存中占据不同的区域。因此,当基类指针指向派生类对象时,指针可能需要调整才能正确地指向对应基类的内存位置。
示例代码:
#include <iostream>
using namespace std;
class Base1 {
public:
int x;
Base1() : x(1) {}
virtual void show() {
cout << "Base1::x = " << x << endl;
}
};
class Base2 {
public:
int y;
Base2() : y(2) {}
virtual void show() {
cout << "Base2::y = " << y << endl;
}
};
// Derived继承了Base1和Base2
class Derived : public Base1, public Base2 {
public:
int z;
Derived() : z(3) {}
void show() override {
cout << "Derived::z = " << z << endl;
}
};
int main() {
Derived d;
Base1* b1_ptr = &d; // Base1指针指向Derived对象
Base2* b2_ptr = &d; // Base2指针指向Derived对象
b1_ptr->show(); // 通过Base1指针访问,正确输出Base1的数据
b2_ptr->show(); // 通过Base2指针访问,正确输出Base2的数据
return 0;
}
解释:
Derived
类继承了Base1
和Base2
,因此派生类对象d
在内存中包含了Base1
和Base2
的成员。- 当我们将基类指针指向派生类对象时,
Base1* b1_ptr = &d
这种指针的赋值实际上是一个隐式转换,编译器会自动调整指针偏移,使其指向d
对象中的Base1
部分。 - 同样的,
Base2* b2_ptr = &d
会调整指针指向d
对象中的Base2
部分。
由于Derived
对象包含了Base1
和Base2
的两部分,指针指向派生类对象时,实际上指向了不同的内存位置:
b1_ptr
指向d
中Base1
的部分。b2_ptr
指向d
中Base2
的部分。
在此情境下,编译器会根据内存布局自动调整基类指针偏移,确保它们正确指向派生类中对应基类的部分。
🧀6.2 指针偏移在内存中的表现
当派生类对象被创建时,派生类对象会在内存中分配连续的空间,其中每个基类的数据成员按照继承顺序依次排列。例如:
Derived:
[ Base1::x ][ Base2::y ][ Derived::z ]
在Derived
类对象的内存布局中:
Base1::x
位于派生类对象的开头。Base2::y
紧随其后,位于Base1
之后。Derived::z
位于Base2
之后。
当Base1* b1_ptr = &d
时,指针b1_ptr
直接指向Derived
对象的开头,即Base1
部分。而当Base2* b2_ptr = &d
时,指针需要被偏移到Derived
对象的Base2
部分。
🧀6.3 虚拟继承中的指针偏移问题
在虚拟继承中,指针偏移更加复杂,因为虚拟基类只存在一个共享的实例。这意味着派生类对象中的虚拟基类部分可能不在派生类对象的开头,而是通过指针间接访问。
示例代码:
#include <iostream>
using namespace std;
class Base {
public:
int x;
Base() : x(1) {}
virtual void show() {
cout << "Base::x = " << x << endl;
}
};
class Derived1 : virtual public Base {
};
class Derived2 : virtual public Base {
};
class Final : public Derived1, public Derived2 {
public:
void show() override {
cout << "Final::show()" << endl;
}
};
int main() {
Final f;
Base* b_ptr = &f; // 基类指针指向派生类对象
b_ptr->show(); // 通过Base指针调用虚函数
return 0;
}
解释:
Derived1
和Derived2
虚拟继承了Base
,因此Final
类只有一个Base
的实例。- 当基类指针
Base* b_ptr = &f
被用来指向Final
类对象时,指针需要被调整到Final
对象中的Base
部分。这个调整是在运行时通过虚基表(vbtable)
完成的。 - 虚拟继承中的内存布局更加复杂,
Base
的成员并不是直接位于Final
对象的开始位置,而是存储在某个虚基类共享的部分。
🧀6.4 汇编视角下的指针偏移
在汇编层面,指针偏移的处理体现在对象的内存布局和指针计算中。对于普通继承,指针的调整是通过编译时的偏移计算完成的。而对于虚拟继承,指针偏移的处理更加复杂,因为它涉及运行时的指针调整。
6.4.1 普通继承的汇编:
Base1* b1_ptr = &d;
在普通继承的情况下,编译器知道基类 Base1
在派生类 Derived
中的内存偏移量。因此,编译器会在生成汇编代码时,通过简单的加法计算出 b1_ptr
的实际地址。指针偏移是静态的。
6.4.2 虚拟继承的汇编:
在虚拟继承中,指针偏移不能仅通过简单的加法计算,因为虚拟基类的地址是在运行时通过 虚基指针(vbptr)
来确定的。虚基指针指向 虚基表(vbtable)
,虚基表中存储了虚基类的实际内存偏移量。通过查找 vbtable
,编译器可以在运行时计算出虚基类的地址,并进行指针调整。
🧀6.5 虚拟继承中指针偏移的机制
在虚拟继承中,派生类通过 虚基表(vbtable)
来管理虚拟基类的实例。每个包含虚拟基类的派生类都有一个 虚基指针(vbptr)
,指向其虚基表。虚基表中记录了虚拟基类的偏移量,编译器通过该表来计算实际的内存地址。
汇编中的虚基表查找流程:
- 获取
vbptr
:从派生类对象中读取vbptr
,该指针指向vbtable
。 - 查找偏移量:通过
vbptr
查找vbtable
,获取虚基类的偏移量。 - 调整指针:将偏移量加到当前指针上,以正确访问虚基类的成员。
🥐七、虚拟继承与汇编之间的关系
虚拟继承 在C++中是一个用于解决菱形继承问题的机制,它的实现涉及底层的内存布局与对象模型。虚拟继承与普通继承的一个主要区别在于,虚拟继承需要通过虚基表(vtable) 或 指针调整 机制来处理基类的实例,而这些操作会影响对象的内存布局,并最终反映在编译后的汇编代码中。
下面将介绍虚拟继承与汇编之间的关系,特别是它如何影响内存布局、虚基表以及指针调整。
🧀7.1 虚拟继承的内存布局
在普通继承中,派生类会直接包含基类的成员。基类的成员是直接复制到派生类对象中,内存布局上派生类包含基类的所有数据成员。
而在虚拟继承中,基类的实例不再直接内嵌在派生类中,而是被共享。这意味着在派生类中,不再是直接存储基类的成员,而是通过一个指向**虚基表(virtual table for base classes,vbtable)**的指针来访问基类的成员。
7.1.1 内存布局对比
-
普通继承: 派生类直接内嵌基类,继承的所有基类数据成员按顺序排列。
class A { int a; }; class B : public A { int b; }; 内存布局: B: [a] [b]
-
虚拟继承: 虚基类的数据成员通过虚基表指针(
vbptr
)访问,基类在派生类中的位置是间接访问的。class A { int a; }; class B : virtual public A { int b; }; 内存布局: B: [vbptr] [b] | |------> [A::a]
- vbptr:一个指针,指向虚基表(
vbtable
),用于指示基类的实际存储位置。 - 虚基类成员不直接出现在派生类中,而是通过
vbptr
间接访问。
- vbptr:一个指针,指向虚基表(
🧀7.2 虚基表(vbtable)与指针调整
在虚拟继承中,C++编译器使用 虚基表 来解决多路径继承带来的二义性问题。虚基表类似于 虚函数表(vtable),用于记录虚拟基类的偏移量。每个包含虚拟继承的派生类都包含一个 虚基指针(vbptr),这个指针指向虚基表。
- vbptr:虚基指针,它是派生类中的一个指针,指向虚基表。
- vbtable:虚基表,它记录了虚拟基类在派生类对象内存中的偏移位置。每当访问虚基类成员时,编译器根据
vbptr
指向的vbtable
来确定虚基类的实际位置。
虚基表结构示例
class A {
int a;
};
class B : virtual public A {
int b;
};
B 对象的内存布局:
B:
[vbptr] -> 虚基表(vbtable)
[b]
A::a(通过 vbptr 指向的位置访问)
🧀7.3 汇编视角
从汇编的角度来看,虚拟继承会增加额外的指针操作,特别是在访问基类成员时。编译器在生成汇编代码时,会通过 vbptr
查找 vbtable
,然后根据偏移量计算出基类成员的位置。这些额外的指针解引用和偏移计算,反映在汇编指令中。
7.3.1 汇编代码中的指针调整
在虚拟继承的情况下,派生类对象中并不直接包含基类的成员。因此,编译器会生成额外的汇编代码,用于通过 vbptr
来间接访问虚基类成员。
class A {
public:
int a;
};
class B : virtual public A {
public:
int b;
};
int main() {
B obj;
obj.a = 5; // 访问虚基类 A 的成员
return 0;
}
如果我们通过编译器生成汇编代码(例如使用 g++ -S
),会看到访问 obj.a
的汇编代码与普通继承不同:
7.3.2 普通继承的汇编代码:
普通继承中,基类的成员直接嵌套在派生类中,访问时仅需通过固定的偏移量计算位置:
mov DWORD PTR [ebp-12], 5 ; 直接访问 a 的位置
7.3.3 虚拟继承的汇编代码:
虚拟继承中,需要先通过 vbptr
访问 vbtable
,计算出虚基类的偏移量,然后再访问基类成员:
mov eax, DWORD PTR [ebp-12] ; 读取 B 对象的 vbptr
mov ecx, DWORD PTR [eax+4] ; 读取 vbtable 中 A 的偏移量
mov DWORD PTR [ebp+ecx], 5 ; 通过偏移量访问 A::a
解释:
[ebp-12]
:表示对象B
的地址。[eax+4]
:通过虚基表指针vbptr
获取A
在派生类B
中的实际位置偏移量。[ebp+ecx]
:最终通过计算的偏移量访问A::a
。
🧀7.4 虚拟继承带来的开销
由于虚拟继承引入了额外的指针操作(通过 vbptr
和 vbtable
进行指针调整),它在性能和内存使用上有一些额外的开销:
- 性能开销:每次访问虚基类的成员时,必须通过
vbptr
和vbtable
进行间接访问,这会增加额外的指针解引用操作,可能导致性能下降,特别是在频繁访问基类成员时。 - 内存开销:派生类需要额外的空间存储
vbptr
,虚基类的实际数据存储在vbptr
指向的位置,而不是直接嵌入派生类对象中。
尽管有这些开销,但虚拟继承可以有效解决菱形继承中的冗余问题,特别是在大型复杂系统中,虚拟继承提供了一种清晰且有效的继承关系管理方式。
🥐八、继承与组合
在C++中,继承(Inheritance)和组合(Composition)是两种常见的类设计方式,用于在类之间建立联系和复用代码。它们都可以用于创建复杂的对象结构,但它们的应用场景、优势、劣势以及如何在类之间传递行为和属性方面有所不同。
🧀8.1 继承的优缺点:
- 优点:
- 简化代码:通过继承,派生类可以重用基类的代码。
- 易于维护:继承可以减少重复代码,方便对代码的集中管理和维护。
- 支持多态:基类的虚函数可以在派生类中实现不同的行为。
- 缺点:
- 强耦合:继承使基类和派生类之间紧密耦合,派生类依赖于基类的实现,这可能导致灵活性下降。
- 不灵活:如果基类发生改变,所有派生类都可能需要修改。
- 继承链过长:过多的继承层次可能导致代码的复杂度增加,理解和维护变得困难。
🧀8.2 组合
组合 是一种类与类之间的关系,表示 “有一个”(has-a)的关系。在组合中,一个类包含另一个类的对象作为成员变量。组合强调类的对象可以包含其他类的对象,并通过这些成员对象来实现某些功能。
组合示例:
#include <iostream>
using namespace std;
// 类:Engine
class Engine {
public:
void start() {
cout << "Engine started" << endl;
}
};
// 类:Car
class Car {
private:
Engine engine; // Car "有一个" Engine
public:
void startCar() {
engine.start(); // 调用 Engine 对象的方法
cout << "Car started" << endl;
}
};
int main() {
Car myCar;
myCar.startCar();
return 0;
}
解释:
Engine
类代表引擎的行为,它有一个start()
方法。Car
类并没有继承Engine
,而是将Engine
对象作为其成员变量。这表明Car
“有一个” 引擎。Car
的startCar()
方法通过调用Engine
对象的方法来启动引擎。
组合的特点:
- “有一个” 关系:
Car
“有一个”Engine
。 - 灵活性更强:组合可以在运行时动态地改变组合的对象,允许对象之间的灵活组合。
- 类之间的独立性:组合中的类彼此独立,不像继承那样产生紧密耦合。
组合的优缺点:
- 优点:
- 低耦合:类之间的耦合度较低,一个类的修改不会影响其他类。
- 灵活性:组合允许根据需要创建更灵活的类组合,能够动态地创建类的对象,易于扩展和维护。
- 单一职责原则:组合可以让每个类专注于自己的职责。
- 缺点:
- 代码可能更复杂:相比于继承,组合可能需要更多的代码来实现同样的功能(尤其是涉及多个类时)。
- 不支持多态:组合本身不能直接使用多态,不能在运行时通过基类指针访问派生类的重写方法。
🧀8.3 继承 vs 组合:如何选择?
选择继承还是组合,取决于具体的设计需求和类之间的关系。以下是一些基本的建议:
- 使用继承的场景:
- 当类之间存在**“是一个”**的关系时,使用继承。例如,
Car
是Vehicle
的一种,所以可以使用继承。 - 需要使用多态性,即在运行时通过基类指针调用派生类的实现时,继承是必需的。
- 需要复用基类的实现时。
- 当类之间存在**“是一个”**的关系时,使用继承。例如,
- 使用组合的场景:
- 当类之间存在**“有一个”**的关系时,使用组合。例如,
Car
拥有一个Engine
,这是一种典型的组合关系。 - 当需要在运行时灵活地组合不同的功能时,组合比继承更灵活。
- 当需要避免类之间的紧密耦合,或者需要减少类层次结构时,组合是更好的选择。
- 当类之间存在**“有一个”**的关系时,使用组合。例如,
🧀8.4 继承与组合的混合使用
在现实世界的设计中,继承和组合可以混合使用。比如,一个类既可以通过继承来获取基类的功能,同时通过组合来使用其他对象的功能。
示例:
#include <iostream>
using namespace std;
// 基类:Vehicle
class Vehicle {
public:
void start() {
cout << "Vehicle started" << endl;
}
};
// 类:Engine
class Engine {
public:
void start() {
cout << "Engine started" << endl;
}
};
// 派生类:Car
class Car : public Vehicle { // 继承Vehicle
private:
Engine engine; // 组合Engine
public:
void startCar() {
engine.start(); // 调用Engine对象的方法
start(); // 调用Vehicle基类的方法
cout << "Car is running" << endl;
}
};
int main() {
Car myCar;
myCar.startCar(); // 启动引擎并开始运行
return 0;
}
解释:
Car
类通过继承获取了Vehicle
的功能,并通过组合使用了Engine
的功能。这是一种继承和组合相结合的设计方式。
🧀8.5 优先使用组合原则
在设计类结构时,常常提到的一条原则是:优先使用组合而非继承(Favor Composition over Inheritance)。这一原则的基础在于,组合比继承更加灵活,可以减少类之间的耦合,增强代码的扩展性和可维护性。
- 继承 固定了类之间的关系,继承链过长会增加复杂度。
- 组合 允许类在运行时动态组合,减少了类之间的依赖关系。
但是,继承也是必要的,尤其是在你需要利用多态性或构建清晰的层次结构时。因此,继承和组合并不是对立的,而是根据具体场景选择合适的工具。
结语
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,17的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是17前进的动力!