我们接上集解锁C++继承的奥秘:从基础到精妙实践(上),继续深入探讨C++继承的多重继承的处理、虚函数与多态的应用,以及如何在复杂系统中有效利用继承来构建可维护且扩展性强的代码架构。通过系统的学习,你将对C++继承有更深入的理解,并能够在实际开发中灵活应用这些知识。
在C++中,多继承 是指一个类可以继承自多个基类。这是C++区别于其他语言(如Java)的一个特性。菱形继承(也叫“钻石继承”)是多继承中常见的一种继承结构,其中一个派生类通过不同路径继承了同一个基类。虚拟继承 是C++为解决菱形继承问题而提供的一个机制。
多继承是指一个派生类可以继承多个基类。派生类可以同时继承基类的所有属性和方法。在多继承的情况下,派生类从多个基类获得特性。如图分析单继承与多继承的区别:
示例:
#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
的成员,能够同时访问两个基类的方法。菱形继承(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
变量,通过不同路径访问会产生不同的结果。为了解决菱形继承中的冗余问题,C++提供了虚拟继承机制。通过虚拟继承,可以确保在菱形继承结构中,只存在一个基类的副本,而不是每条继承路径都创建一个基类的副本。
class Derived : virtual public Base { };
通过virtual
关键字声明的继承就是虚拟继承,虚拟继承确保在多条路径继承同一基类时,派生类中只保留一份基类的副本。
#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
,都是同一个值。在使用虚拟继承时,基类的构造顺序会发生变化。虚拟基类的构造会优先于其他非虚拟基类,并且由最终派生类负责调用虚拟基类的构造函数。
示例:
#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++的多继承中,指针偏移问题是指当使用基类指针指向派生类对象时,由于多继承导致内存布局复杂化,必须调整指针来正确访问派生类对象中的基类部分。这种指针偏移在多继承和虚拟继承中尤为明显。
在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
的部分。在此情境下,编译器会根据内存布局自动调整基类指针偏移,确保它们正确指向派生类中对应基类的部分。
当派生类对象被创建时,派生类对象会在内存中分配连续的空间,其中每个基类的数据成员按照继承顺序依次排列。例如:
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
部分。
在虚拟继承中,指针偏移更加复杂,因为虚拟基类只存在一个共享的实例。这意味着派生类对象中的虚拟基类部分可能不在派生类对象的开头,而是通过指针间接访问。
示例代码:
#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
对象的开始位置,而是存储在某个虚基类共享的部分。在汇编层面,指针偏移的处理体现在对象的内存布局和指针计算中。对于普通继承,指针的调整是通过编译时的偏移计算完成的。而对于虚拟继承,指针偏移的处理更加复杂,因为它涉及运行时的指针调整。
Base1* b1_ptr = &d;
在普通继承的情况下,编译器知道基类 Base1
在派生类 Derived
中的内存偏移量。因此,编译器会在生成汇编代码时,通过简单的加法计算出 b1_ptr
的实际地址。指针偏移是静态的。
在虚拟继承中,指针偏移不能仅通过简单的加法计算,因为虚拟基类的地址是在运行时通过 虚基指针(vbptr)
来确定的。虚基指针指向 虚基表(vbtable)
,虚基表中存储了虚基类的实际内存偏移量。通过查找 vbtable
,编译器可以在运行时计算出虚基类的地址,并进行指针调整。
在虚拟继承中,派生类通过 虚基表(vbtable)
来管理虚拟基类的实例。每个包含虚拟基类的派生类都有一个 虚基指针(vbptr)
,指向其虚基表。虚基表中记录了虚拟基类的偏移量,编译器通过该表来计算实际的内存地址。
汇编中的虚基表查找流程:
vbptr
**:从派生类对象中读取 vbptr
,该指针指向 vbtable
。vbptr
查找 vbtable
,获取虚基类的偏移量。虚拟继承 在C++中是一个用于解决菱形继承问题的机制,它的实现涉及底层的内存布局与对象模型。虚拟继承与普通继承的一个主要区别在于,虚拟继承需要通过虚基表(vtable) 或 指针调整 机制来处理基类的实例,而这些操作会影响对象的内存布局,并最终反映在编译后的汇编代码中。
下面将介绍虚拟继承与汇编之间的关系,特别是它如何影响内存布局、虚基表以及指针调整。
在普通继承中,派生类会直接包含基类的成员。基类的成员是直接复制到派生类对象中,内存布局上派生类包含基类的所有数据成员。
而在虚拟继承中,基类的实例不再直接内嵌在派生类中,而是被共享。这意味着在派生类中,不再是直接存储基类的成员,而是通过一个指向**虚基表(virtual table for base classes,vbtable)**的指针来访问基类的成员。
普通继承: 派生类直接内嵌基类,继承的所有基类数据成员按顺序排列。
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]
vbtable
),用于指示基类的实际存储位置。vbptr
间接访问。在虚拟继承中,C++编译器使用 虚基表 来解决多路径继承带来的二义性问题。虚基表类似于 虚函数表(vtable),用于记录虚拟基类的偏移量。每个包含虚拟继承的派生类都包含一个 虚基指针(vbptr),这个指针指向虚基表。
vbptr
指向的 vbtable
来确定虚基类的实际位置。虚基表结构示例
class A {
int a;
};
class B : virtual public A {
int b;
};
B 对象的内存布局:
B:
[vbptr] -> 虚基表(vbtable)
[b]
A::a(通过 vbptr 指向的位置访问)
从汇编的角度来看,虚拟继承会增加额外的指针操作,特别是在访问基类成员时。编译器在生成汇编代码时,会通过 vbptr
查找 vbtable
,然后根据偏移量计算出基类成员的位置。这些额外的指针解引用和偏移计算,反映在汇编指令中。
在虚拟继承的情况下,派生类对象中并不直接包含基类的成员。因此,编译器会生成额外的汇编代码,用于通过 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
的汇编代码与普通继承不同:
普通继承中,基类的成员直接嵌套在派生类中,访问时仅需通过固定的偏移量计算位置:
mov DWORD PTR [ebp-12], 5 ; 直接访问 a 的位置
虚拟继承中,需要先通过 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
。由于虚拟继承引入了额外的指针操作(通过 vbptr
和 vbtable
进行指针调整),它在性能和内存使用上有一些额外的开销:
vbptr
和 vbtable
进行间接访问,这会增加额外的指针解引用操作,可能导致性能下降,特别是在频繁访问基类成员时。vbptr
,虚基类的实际数据存储在 vbptr
指向的位置,而不是直接嵌入派生类对象中。尽管有这些开销,但虚拟继承可以有效解决菱形继承中的冗余问题,特别是在大型复杂系统中,虚拟继承提供了一种清晰且有效的继承关系管理方式。
在C++中,继承(Inheritance)和组合(Composition)是两种常见的类设计方式,用于在类之间建立联系和复用代码。它们都可以用于创建复杂的对象结构,但它们的应用场景、优势、劣势以及如何在类之间传递行为和属性方面有所不同。
组合 是一种类与类之间的关系,表示 “有一个”(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
。组合的优缺点:
选择继承还是组合,取决于具体的设计需求和类之间的关系。以下是一些基本的建议:
Car
是 Vehicle
的一种,所以可以使用继承。Car
拥有一个 Engine
,这是一种典型的组合关系。在现实世界的设计中,继承和组合可以混合使用。比如,一个类既可以通过继承来获取基类的功能,同时通过组合来使用其他对象的功能。
示例:
#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
的功能。这是一种继承和组合相结合的设计方式。在设计类结构时,常常提到的一条原则是:优先使用组合而非继承(Favor Composition over Inheritance)。这一原则的基础在于,组合比继承更加灵活,可以减少类之间的耦合,增强代码的扩展性和可维护性。
但是,继承也是必要的,尤其是在你需要利用多态性或构建清晰的层次结构时。因此,继承和组合并不是对立的,而是根据具体场景选择合适的工具。
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,17的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是17前进的动力!