在面向对象编程中,多继承是一种强大的特性,但它可能会引发一些复杂的问题。尤其是在菱形继承结构中,这些问题尤为明显。在菱形继承中,可能会出现成员值重复和成员值不明确的问题,因为一个类通过多个路径继承了相同的基类,从而导致了继承的混乱。
ps:环境 x86 vs2022
菱形继承
由菱形继承来的D 到底要包含多少次 _a呢
我们可以很直观的看到,D中不仅包含B中A的部分还要包含C中A的部分,因为同时需要对 _a进行赋值,我们不限定类域的话,编译器根本不知道对哪个_a进行赋值,这就造成了二义性和数据冗余,同样我们可以观察地址来观察这个储存结构
虚拟继承
为了消除菱形继承数据冗余,二义性问题,我们可以使用虚继承(Virtual)的方式,虚继承确保菱形继承中的共同基类只被继承一次,从而避免了重复继承的问题。虚继承 vitrual的关键字需要加载菱形继承有相同继承基类的派生类那里。
既然我们说,虚继承解决了相同基类数据冗余的问题,那么到底是怎么解决的呢?不能只靠一张嘴说吧 !!
从普通监视窗口我们可以看出,无论我们使用B C的类域来限制进行赋值,最后赋值上的是第二次的赋值,很容易理解,在建设窗口中,我们直接可以看到基类 A也出来了,赋值同样是符合结果的,无论什么类域里面的 _a都是等于 2 ,问题是解决了,那么C++是如何实现这个操作呢?
通过第一层内存可以看到,之前存储 冗余数据 _a 的地方变成了一串数字看着像地址,我们可以找到这段地址查看编译器将之前存储数据的地方存储了什么
经过我们打眼一看,再通过读者你高超的数学水平,很容就看出,存储位置正是当前位置到冗余数据_a 的偏移量 ,那也就是说,我们之前存储冗余数据的地方存储的是当前位置到存储冗余数据的地址偏移量,也就是说通过地址来找到那个唯一的 _a。
那么我们知道了,virtual用来声明虚继承用来解决菱形继承问题,那么他还有其他的作用吗?
多态
现在我们假象一个类,用来描述动物的叫声,比如说,小狗 “ 汪汪汪 ” ,小猫 “ 喵喵喵 ” ....等;我们想要定义类和方法,让继承的类都必须重写这个方法,就类如 dog 类重写,将叫声输出为“ 汪汪汪”,那么这个函数就叫做虚函数,虚函数允许子类重写基类的函数,从而在运行时通过多态机制调用子类的具体实现。
class animal //基类
{
public:
virtual void call() = 0;
//基类可以自己实现自己的虚函数,
//也可以使用这种方法让子类必须重写这个方法
//这种函数叫做纯虚函数
};
这种类叫做抽象类,通常这种类用来定义重写函数,也可以叫做 “接口” ,override用来表示当前函数是重写的函数!
class dog : public animal
{
public:
void call() override
{
cout << "dog : 汪汪!" << endl;
}
};
class cat : public animal
{
public:
void call() override
{
cout << "cat : 喵~" << endl;
}
};
多态为什么叫做多态呢,就是因为如果我们正常使用派生类去基类进行类型转换,那么这给基类就是成为了基类,如果这时候我们用该基类调用某个我们实现的,相同函数名,不同输出的函数时,该基类就只会调用他所实现的那个函数,但是如果我们使用虚函数时,对基类赋值后,这个基类调用的函数应然是派生类的那个函数。
普通继承
class animal //基类
{
public:
void call()
{
cout << "你好" << endl;
}
};
class dog : public animal
{
public:
void call()
{
cout << "dog : 汪汪!" << endl;
}
};
class cat : public animal
{
public:
void call()
{
cout << "cat : 喵~" << endl;
}
};
多态
class animal //基类
{
public:
virtual void call() = 0;
};
class dog : public animal
{
public:
void call() override
{
cout << "dog : 汪汪!" << endl;
}
};
class cat : public animal
{
public:
void call() override
{
cout << "cat : 喵~" << endl;
}
};
基类转换后调用
void animal_call(animal& _animal)
{
_animal.call();
return;
}
int main()
{
dog D;
cat C;
animal_call(D);
animal_call(C);
return 0;
}
我们可以很清楚的看出来,这种差异,也就是基类调用的函数不一样 竟然不一样,那也就引出造成我们多态的两个必要条件了:构成虚函数重写 基类引用或者是指针调用虚函数
虚函数重写
第一部分:虚函数重写的基本规则
在C++中,虚函数重写必须满足以下条件:
- 函数名相同。
- 参数类型相同(包括参数的顺序和类型)。
- 返回值类型相同,除了某些例外情况(即协变返回类型)。
第二部分:virtual
用于实现多态
virtual
关键字用于修饰成员函数,以启用多态机制。基类中使用 virtual
修饰的函数,允许派生类重写该函数,并在运行时根据对象的实际类型调用正确的函数。这是多态的基础。
例外1:协变返回类型
C++支持协变返回类型,即在虚函数重写时,返回类型可以有所不同,但这种不同必须是父类或子类的指针或引用。其他类之间的类型差异不会被允许。
class Base {
public:
virtual Base* clone() const {
return new Base(*this);
}
};
class Derived : public Base {
public:
// 返回类型不同,但因为是 Base 的派生类,符合协变返回类型
Derived* clone() const override {
return new Derived(*this);
}
};
例外2:子类重写时可以不再使用 virtual
关键字
在基类中标记为 virtual
的函数,在所有派生类中都会自动保持虚拟函数的特性,因此在派生类中重写函数时,可以不再显式加上 virtual
关键字。然而,建议仍然加上 virtual
,因为这样可以使代码更加清晰,提醒自己或者团队该函数是在重写基类的虚函数。
多态调用和普通对象直接调用函数的区别
仍然使用上述代码,只不过调用方式发生改变,普通继承使用普通对象直接调用,而多态继续使用基类调用。让我们观察他们调用函数的区别
普通对象直接调用函数
我们可以看到,普通对象直接从类中找到函数地址进行call (调用)
但是让我观察多态调用使用的方法
多态调用
通过这段反汇编代码,我们可以大概能看出,基类引用或者指针调用的时候,并不是直接调用,而是通过在内存中找到派生类函数存储的位置,然后再去调用。同时,我们也可以从监视窗口看出一些端倪:
监视窗口中多出一个数组,里面存放的好像是我们的函数指针,那也就是说,当我们虚函数重写后,类里面的函数就会存放在一个数组里调用,当我们基类对派生类进行引用或者指针指向时,这些数组已经存在无法更改,那么当基类再去调用函数时,基类就会去这个函数指针数组里去调用函数,而这些函数指向的就是子类的那些函数。
我们通过&D 找到了我们在监视窗口中虚表的地址,再通过虚表的地址也就找到了函数指针,那么很容易看出虚表的结构
我们甚至可以通过指针类型的转换去调用虚表中的函数,这里就算了吧,演示一下实现过程就ok了,那么我们很容易就看出来,为什么基类去调用虚函数还是派生类重写的虚函数。
析构函数的多态
在面向对象编程中,常常会使用基类指针指向派生类对象。这时,当需要删除派生类对象时,如果基类的析构函数不是虚函数,则只会调用基类的析构函数,而不会调用派生类的析构函数。这可能会导致派生类的资源无法正确释放,进而引发内存泄漏等问题。 就像这样:
class Base {
public:
~Base() { // 基类析构函数不是虚函数
std::cout << "Base Destructor" << std::endl;
}
};
class Derived : public Base {
public:
~Derived() { // 派生类析构函数
std::cout << "Derived Destructor" << std::endl;
}
};
int main() {
Base* obj = new Derived(); // 基类指针指向派生类对象
delete obj; // 仅调用 Base 析构函数,而不会调用 Derived 析构函数
return 0;
}
那么如果这时候基类调用派生类的析构函数,不就解决问题了,根据我们上面对多态的描述,完美符合那就先用起来:
#include <iostream>
class Base {
public:
virtual ~Base() { // 基类析构函数是虚函数
std::cout << "Base Destructor" << std::endl;
}
};
class Derived : public Base {
public:
~Derived() override { // 派生类析构函数
std::cout << "Derived Destructor" << std::endl;
}
};
int main() {
Base* obj = new Derived(); // 基类指针指向派生类对象
delete obj; // 调用 Derived 析构函数,然后调用 Base 析构函数
return 0;
}
但是还有一个问题,两个类的析构函数名并不相同,为什么会构成虚函数呢?
通过汇编代码,我们可以看到,第一次的调用并不是直接调用 ~Derived 而是去调用了 destructor再去调用 ~Derived 这个析构函数,同时我们在监视窗口也查看虚表可以得到
那我们是不是可以这样理解,所有的析构函数(虚函数)都被处理成同名函数为了多态的析构 正常进行