Bootstrap

c++多态(深度刨析)

C++系列-----多态


前言

在开始学习多态之前,首先要掌握继承的概念、实现、原理,这篇文章可以帮助大家学习->继承详解

一、多态的概念

C++多态性(Polymorphism)是面向对象编程(OOP)的一个重要特性之一,它允许我们使用统一的接口来处理不同类型的对象。多态性使得程序更加灵活、可扩展并且易于维护。

通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态
举例:对于买票这个行为来说,相较于普通人学生可以买半价票,军人可以优先买票,这就是不同的对象去完成买票这个行为,的不同状态。

二、多态的定义及实现

2.1、多态构成的条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了
Person。Person对象买票全价,Student对象买票半价。
那么在继承中要构成多态还有两个条件:
1. 必须通过基类的指针或者引用调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。

在这里插入图片描述

下面我们对上述条件进行分析

2.1.1、虚函数

virtual:c++11,提供的关键字

虚函数:就是被virtual修饰的类成员函数

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};

这里BuyTicket()就是用关键字virtual修饰成的虚函数。

2.1.2、虚函数的重写

在介绍多态时,我们对继承中,成员函数的重定义(隐藏)作了介绍,要和这里区分开。

虚函数的重写(覆盖):要想完成虚函数的重写必须满两个条件:1、 是虚函数。2、三同,三同是指派生类中有一个跟基类虚函数返回值类型、函数名字、参数列表(类型),相同的成员函数,这样就称子类的虚函数重写了基类的虚函数。我们看如下例子:

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
};

上面的派生类Student 的 BuyTicket() 与Person 的 BuyTicket() 构成了重写。注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。

重写的两个例外:
1.协变

上面我们说,要构成重写必须满足三同,但是c++规定,在派生类重写基类虚函数时,与基类虚函数返回值类型不同可以不同。但是必须满足基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,这称为协变。

在这里插入图片描述
2、析构函数的重写

在讲解继承时我们就提到,编译器对析构函数进行的特殊处理,影响了析构函数在继承者的使用。那么它为什么要进行特殊处理呢?

class Person {
public:
    virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
    virtual ~Student() { cout << "~Student()" << endl; }
};
int main()
{
    Person* tmp = new Student;
}

在这里插入图片描述

来看这一句代码,它是否能实现多态呢?我们上面说的三同,但是析构函数并不能满足(函数名不同),而重写的原因,就是为了让它具备实现多态的条件 ,于是编译器将析构函数在编译阶段统一处理为:destructor()。下面我们来看一下即使,这个处理给我们在使用多态时造成了不便,为什么编译器依然要这样做。

class Person {
public:
	 ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
	 ~Student() { cout << "~Student()" << endl; }
};
 
int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;
 
	delete p1;
	delete p2;
	return 0;
}

上述代码,析构函数,不构成重写条件(不能实现多态调用)。执行结果:
在这里插入图片描述
我们想要将动态申请创建的 Person和Student对象释放掉,但是结果,并不如愿,这是为什么呢?我们在继承部分学过的赋值兼容规则,子类的对象,指针赋值给父类时,会发生切割、切片。p2指针只会指向属于父类的那一部分。所以时调用了父类的析构函数。 并不能正确的释放掉动态开辟的空间。
对上面的问题,编译器将它处理为析构,很容易就得到解决了.

2.2、C++11 override 和 final

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来检查会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写

  1. final:修饰虚函数,表示该虚函数不能再被重写

在这里插入图片描述

  1. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写译报错
    在这里插入图片描述

我破坏了重写条件,使Drive()不够成重写

2.3、重载、覆盖(重写)、隐藏(重定义)的对比

在这里插入图片描述

2.4、抽象类

概念:
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

抽象类不能实例化出对象:
抽象类不能实例化出对象

派生类如果不重写纯虚函数也无法实例化出对象
在这里插入图片描述

2.5、 接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。下方代码不仅可以将这个概念给体现出来,还可以帮我们检查,对上面的知识是否理解:

class A
{
public:
    virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
    virtual void test() { func(); }
};
class B : public A
{
public:
    void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{
    B* p = new B;
    p->test();
    return 0;
}

大家仔细思考一下,程序的执行结果是什么:
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
下面我们来分析一下:

B类型的指针p,指向了动态申请的B类型对象,使用p指针调用test()函数,我们知道类的成员函数存在一个this指针(test(A*this)),那么我们使用p指针调用test()函数,就相当于将使用子类类型的指针赋值给父类类型,这会发生切片,也就是说现在这个指针虽然是父类类型的,但是它指向的是子类对象中,父类的那一部分,这时通过它调用func()就会实现多态调用,调用的是子类重写的func()函数。看到这里可能就会有人选择D选项了,但是我们结合上面说的在进行多态调用时虚函数的重写是接口继承,所以它重写的只是父类中虚函数的实现,而void func(int val = 1) 接口是从父类中继承下来的(特别强调:只是在多态调用时使用的是继承接口,普通调用并不是), 所以结果是B.

三、多态的原理

3.1、虚函数表

class Base
{
public:
 virtual void Func1()
 {
 cout << "Func1()" << endl;
 }
private:
 int _b = 1;
};
int main()
{
	Base a;
	cout << sizeof(a) << endl;
	return 0;
}

程序运行的结果是什么呢?

在这里插入图片描述
在32位机器下,计算的a对象的大小是8,这是为什么呢?我们来看一下Base类型的对象中都存储了什么。
在这里插入图片描述
通过监视窗口可以看到a对象中不仅存储了,_b变量,还有一个_vfptr指针,而它指向的空间,存储了的是一个函数类型的指针。
这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么派生类中这个表放了些什么呢?

针对上面的代码我们做出以下改造:

  • 我们增加一个派生类Derive去继承Base。
  • Derive中重写Func1。
  • Base再增加一个虚函数Func2和一个普通函数Fun。

具体代码如下:

class Base
{
public:
    virtual void Func1()
    {
        cout << "Base::Func1()" << endl;
    }
    virtual void Func2()
    {
        cout << "Base::Func2()" << endl;
    }
    void Func3()
    {
        cout << "Base::Func3()" << endl;
    }
private:
    int _b = 1;
};
class Derive : public Base
{
public:
    virtual void Func1()
    {
        cout << "Derive::Func1()" << endl;
    }
private:
    int _d = 2;
};
int main()
{
    Base b;
    Derive d;
    return 0;
}

在这里插入图片描述

通过观察和测试,我们发现了以下几点问题:

1. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。
2. 基类b对象和派生类d对象虚表是不一样的(看虚表地址),这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
4. 虚函数表本质是一个存虚函数指针的函数指针数组,一般情况这个数组最后面放了一个nullptr(不同编译器在结尾处理方式不同)。
5. 总结一下派生类的虚表生成:
a.先将基类中的虚表内容拷贝一份到派生类虚表中
b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

这里所说的拷贝,是帮助我们在行为上理解,具体怎么操作,要看编译器底层处理方式。

3.2、多态的原理

上面分析了这个半天了那么多态的原理到底是什么?还记得这里Func函数传Person调用的Person::BuyTicket,传Student调用的是Student::BuyTicket,代码如下:

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person* p)
{
	p->BuyTicket();
}
int main()
{
	Person Mike;
	Func(&Mike);
	Student Johnson;
	Func(&Johnson);
	return 0;
}

通过上面对虚表的学习,我们也大概清楚了每个拥有虚函数的对象都有属于自己的虚表。而自己的虚表中存储的是自己的虚函数。在调用时,会到指针所指向的对象的虚表中找到对应的虚函数进行调用。 具体我们可看下图:

1. 下图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket。
2. 下图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket。
3. 这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
4. 反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调用虚函数。反思一下为什么?

在这里插入图片描述

下面我们来结合汇编语言来看一下:
在这里插入图片描述这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后到指向的对象的虚表中去找对应的虚函数。而对于普通调用,是在编译时已经从符号表确认了函数的地址,直接call 地址普通函数的调用。这就与静态绑定和动态绑定有关了。

3.3 、静态绑定与动态绑定``

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,
    比如:函数重载
  2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体
    行为,调用具体的函数,也称为动态多态。

四、单继承和多继承的虚函数表

这里主要介绍单继承,多继承包含菱形继承情况太过复杂

补充:
在这里插入图片描述从上图可以看到,同一类型的对象共用一张虚表。

4.1 、单继承的虚函数表

class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
private:
	int a;
};
class Derive :public Base {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
private:
	int b;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

对上述代码进行调试
在这里插入图片描述
从监视窗口中我们发现看不见func3和func4。是因为编译器对这里进行了处理。那么我们如何查看d的虚表呢?下面我们使用代码打印出虚表中的函数。

typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
	// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
	cout << " 虚表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}
int main()
{
	Base b;
	Derive d;
	VFPTR* vTableb = (VFPTR*)(*(int*)&b);
	PrintVTable(vTableb);
	VFPTR* vTabled = (VFPTR*)(*(int*)&d);
	PrintVTable(vTabled);
	return 0;
}

解释一下上述代码的思路:

  1. 取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存放虚函数指针的指针数组,这个数组最后面放了一个nullptr。
  2. 先取b的地址,强转成一个int*的指针(为了取对象的头4bytes,这里一定要主要观察自己的编译环境——————64位、32位)。
  3. 指针再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的。
  4. 再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
  5. 虚表指针传递给PrintVTable进行打印虚表。

我们来分析一下运行结果:

在这里插入图片描述
可以看到虽然我们无法在监视窗口看到,完整的存储信息,但是我们通过打印,证实了它的存在。

4.2 、多继承的虚函数表

class Base1 {
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int b1;
};
class Base2 {
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
		int b2;
};
class Derive : public Base1, public Base2 {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int d1;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
	cout << " 虚表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}
int main()
{
Derive d;
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb1);
	VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(vTableb2);
	return 0;
}

在这里插入图片描述
观察上图可以看出:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中

这篇文章还有很多没涉及到的知识,但是那些对我们的帮助并不大,所以就不打算继续写了。

总结

C++中的多态性主要有两种形式:静态多态(编译时多态)和动态多态(运行时多态)。静态多态通过函数重载(Function Overload)实现,即提供同名的不同函数版本,编译器根据传入参数的类型自动选择合适的函数。而动态多态则通过虚函数(Virtual Functions)和指针或引用来完成,如在基类指针或引用上调用实际子类的方法,这就是著名的虚函数表(VTable)机制。动态多态的关键在于派生类对基类虚函数的重写,当基类指针指向子类对象时,会调用子类的实现,这种灵活性让设计更模块化,代码更容易复用。

;