当前位置:网站首页>万字长文带你了解多态的底层原理,这一篇就够了

万字长文带你了解多态的底层原理,这一篇就够了

2022-08-11 08:29:00 小钟hhh


前言:

本节中所有代码运行情况都是VS2019的x86环境下进行的。其它编译环境下,可能会有一些细节不一样。


一、多态的概念

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态

比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。同一个买票行为,不同的对象有不同的状态。


二、多态的定义及实现

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价

2.1 多态的构成条件
  1. 子类重写父类的虚函数。
  2. 必须是父类的指针或者引用去调用虚函数.

两个条件都需要有,缺一不可。

class Person
{
    
public:
	//虚函数
	virtual void BuyTicket()
	{
    
		cout << "正常排队-全价买票" << endl;
	}
protected:
	int _age;
	string _name;
};

class Student : public Person
{
    
public:
	//重写父类虚函数
	virtual void BuyTicket()
	{
    
		cout << "正常排队-半价买票" << endl;
	}
};

class Soidier : public Person
{
    
public:
	//重写父类虚函数
	virtual void BuyTicket()
	{
    
		cout << "优先排队-全价买票" << endl;
	}
};

void Func(Person* ptr)
{
    
	//多态 -- ptr指向父类对象调用父类虚函数,指向子类对象调用子类虚函数
	ptr->BuyTicket();
}

int main()
{
    
	Person ps;
	Student st;
	Soidier sr;
    
	Func(&ps);
	Func(&st);
	Func(&sr);
	return 0;
}

image-20220721090535235


2.2 虚函数的重写

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。所使用的关键字为virtual,和上节菱形虚拟继承的关键字一样,但是没有任何关系。

子类重写虚函数可以不用加virtual关键字,但是建议加上

就像下面Person和Student的Buyticket函数,Student中的Buyticket就是Person中虚函数的重写。

class Person
{
    
public:
	//虚函数
	virtual void BuyTicket()
	{
    
		cout << "正常排队-全价买票" << endl;
	}
protected:
	int _age;
	string _name;
};

class Student : public Person
{
    
public:
	//重写父类虚函数
	virtual void BuyTicket()
	{
    
		cout << "正常排队-半价买票" << endl;
	}
};

2.3 虚函数重写的两个例外

虚函数要求的三个(返回值类型,函数名,参数列表)都一样。但是会有一些例外。

  1. 协变(基类与派生类虚函数(返回值类型不同)

    派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变

//虚函数重写 -- 协变
 
//A和B构成父子类关系
class A{
    };
class B : public A{
    };

class Person
{
    
public:
	//虚函数,返回A指针
	virtual A* BuyTicket()
	{
    
		cout << "正常排队-全价买票" << endl;
		return new A;
	}
protected:
	int _age;
	string _name;
};

class Student : public Person
{
    
public:
	//重写父类虚函数,返回B指针
	virtual B* BuyTicket()
	{
    
		cout << "正常排队-半价买票" << endl;
		return new B;
	}
};


void Func(Person* ptr)
{
    
	//多态 -- ptr指向父类对象调用父类虚函数,指向子类对象调用子类虚函数
	ptr->BuyTicket();
}

int main()
{
    
	Person ps;
	Student st;
	
	Func(&ps);
	Func(&st);

	return 0;
}

image-20220721093615866


  1. 析构函数的重写(基类与派生类析构函数的名字不同)

    如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor

class Person
{
    
public:
	~Person()
	{
    
		 cout << "~Person()" << endl;
	}
};
class Student : public Person
{
    
public:
	~Student()
	{
    
		cout << "~Student()" << endl;
	}
};

//不是虚函数,两个析构构成隐藏关系
//是虚函数,两个析构函数构成重写关系

int main()
{
    
	//new 对象的特殊场景
	Person* p1 = new Person;
	Person* p2 = new Student;

	delete p1; //p1->destructor() + operator delete(p1)
	delete p2; //p2->destructor() + operator delete(p2)

	return 0;
}

不是虚函数时,结果为两个~Person(),都是调用父类的析构函数,因为构成的是隐藏关系。

image-20220721095357398

是虚函数时,为正常调用,p1调用父类的析构函数,p2调用子类的析构函数,构成多态。

image-20220721095602362


2.4 c++11override和final

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

  1. final: 修饰虚函数,表示该虚函数不能被重写。final还可以修饰类,表示这个类不能被继承。

    class Car
    {
          
    public:
    	virtual void Drive() final //加上final,不能被重写
        {
          }
    };
    class Benz :public Car
    {
          
    public:
    	virtual void Drive() // 无法重写父类虚函数,
    	{
          
    		cout << "Benz-舒适" << endl;
    	}
    };
    
    class A final // final修饰类,不能作为基类拿去继承
    {
          };
    class B : public A // 会报错,不能继承
    {
          };
    
  2. override: 检查派生类虚函数是否重写了某个基类的虚函数,如果没有重写编译报错

    class Car 
    {
          
    public:
    	virtual void Drive() {
          }
    };
    
    class Benz :public Car 
    {
          
    public:
    	virtual void Drive() override //必须被重写
    	{
           
    		cout << "Benz-舒适" << endl; 
    	}
    };
    

2.5 重载、覆盖(重写)、隐藏(重定义)的对比 (️)
  1. 重载
    • 两个函数在同一作用域。
    • 函数名相同、参数(个数/类型/顺序)不同。
  2. 覆盖(重写)
    • 两个函数分别在基类和派生类的作用域。
    • 两个函数必须是虚函数。
    • 函数返回值、函数名、参数列表都必须相同(协变例外,返回值可以不同)。
  3. 隐藏(重定义)
    • 两个函数分别在基类和派生类的作用域。
    • 函数名相同。
    • 两个基类和派生类的同名函数不构成重写就是隐藏(重定义)。

三、抽象类

3.1 概念

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

// 基类 - 抽象类 - 不能实例化出对象
class Car
{
    
public:
	virtual void Drive() = 0; // 纯虚函数,不需要实现它
};

// 派生类
class Benz :public Car
{
    
public:
	virtual void Drive() // 必须重写基类虚函数,派生类才能实例化出对象
	{
    
		cout << "Benz-舒适" << endl;
	}
};

int main()
{
    
	// 基类是抽象类,不能实例化出对象,但可以定义基类指针,用来实现多态
	Car* pBenz = new Benz;
	pBenz->Drive();

	return 0;
}

3.2 接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。


四、多态的原理( ️)

4.1虚函数表
// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
    
public:
	virtual void Func1()
	{
    
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};

通过观察测试我们发现b对象是8bytes,除了_b成员,还多一个**__vfptr**放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么派生类中这个表放了些什么呢?我们接着往下分析。

//针对上面的代码我们做出以下改造
//1. 我们增加一个派生类Derive去继承Base
//2. Derive中重写Func1
//3. Base再增加一个虚函数Func2和一个普通函数Func3 

class Base
{
    
public:
	virtual void Func1() //虚函数Func1
	{
    
		cout << "Base:Func1()" << endl;
	}
	virtual void Func2() //虚函数Func2
	{
    
		cout << "Func2()" << endl;
	}
	void Func3() //普通函数Func3
	{
    
		cout << "Func3()" << endl;
	}
private:
	int _b = 1;
};

class Derive : public Base
{
    
public:
	virtual void Func1() // 重写函数Func1
	{
    
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};

int main()
{
    
	Base b;
	Derive d;

	return 0;
}

image-20220721182850405

4.1.1虚函数表的一些性质(️)
  1. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在这部分的,另一部分是自己的成员。

  2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1的地址,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法

  3. Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。只有虚函数会放到虚表中,不是虚函数就不会放到虚表中

  4. 父子类无论是否完成虚函数重写,都有自己独立的虚表。一个类的所有对象共享一张虚表。

    image-20220721184151028
  5. 总结一下派生类的虚表生成:

    • 先将基类中的虚表内容拷贝一份到派生类虚表中。
    • 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数。
    • 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
  6. 这里还有一个很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。(️)

    注意:虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的。


4.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;
}

image-20220722105147181

可以观察到父类对象和子类对象都有一个虚函数表,存储各自虚函数的地址。但是看Func函数可以发现是Person的引用在调用。这是为什么呢?为什么不能是Person的对象来调用呢?


查看两次调用Func函数的反汇编代码

image-20220722110230030image-20220722110333766

对比两次调用,执行的指令时一样的,但是由于传过来的对象不同,找到的就是不同的虚表。


为什么父类对象就不行,只能指针和引用来调用?(重点理解️)

image-20220722111157626

可以观察一下子类对象赋值给父类对象,切片后,虚表的变化。

上图可以观察到虚表的地址是不会发生变化的,父类的对象还是用的父类的虚表,如果上面Func函数参数可以为Person对象,这就不对了,因为如果是对象就要求,是子类传过来就要求执行子类的虚函数,所以父类对象及必须存储的是子类的虚表,这样就不对了,父类对象存储有时父类的虚表,有时子类的虚表,编译器是没法识别的,上面说虚表时,也说到一个类的所有对象共享一张虚表。基于这样的一个原因,就只可以是父类的指针或者引用。


下面再介绍一下虚函数如何调用

  1. 随便写一个普通函数,查看汇编可以看到,调用f函数是直接调用,直接call就行。普通函数的调用,编译链接时确定地址
image-20220722112608441

image-20220722112333171

  1. 看看多态的函数是如何调用的,去虚表中寻找再确定的虚函数地址的。多态调用是运行时确定地址,去指向对象的虚函数表中找到虚函数的地址

image-20220722113051160


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

5. 单继承和多继承关系中的虚函数表(️)

5.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;
}

通过调试,可以观察到,派生类对象 d 中的虚表看不到虚函数 Func3 和 Func4,这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是它的一个小bug。

那么我们如何查看对象 d 的虚表呢?下面我们使用代码打印出虚表中的函数

虚函数表本质是一个存放虚函数指针的指针数组,一般情况这个数组最后面放了一个 nullptr。

// typedef一下函数指针vfptr
typedef void(*vfptr)();

void PrintVFT(void* vft[])
{
    
	printf("虚表地址__vfptr:%p\n", vft);
	for (size_t i = 0; vft[i] != nullptr; i++)
	{
    
		// 依次打印虚表各元素
		printf("vft[%d]:%p->", i, vft[i]);
		// 把虚表各元素由void*强转为函数指针类型后,赋值给函数指针f
		vfptr f = (vfptr)vft[i];
		// 调用函数,打印出显示的函数
		f();
	}
	printf("\n");
}

int main()
{
    
	Base b;
	Derive d;
    
// 思路:取出b、d对象的头4字节,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr
// 1.先取b的地址,强转成一个int*的指针 --> (int*)&b
// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针 --> *((int*)&b)
// 3.再强转成void**,得到存放void*类型(虚函数指针类型)数组首元素的地址,即指针的地址,所以是v** --> (void**)(*((int*)&b))
// 4.将虚表首元素地址(即虚表指针)传递给PrintVFT进行打印虚表
// 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的 - 生成 - 清理解决方案,再编译就好了。
    
	PrintVFT((void**)(*((int*)&b))); // 打印对象b的虚表
	PrintVFT((void**)(*((int*)&d))); // 打印对象d的虚表

	return 0;
}

image-20220722162909346

打印出来可以发现,在虚表中是存在func3和func4的,只是由于编译器的原因,导致监视窗口没有显示出来。


5.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; } //重写func1,注意两个父类中都有虚函数func1
	virtual void func3() {
     cout << "Derive::func3" << endl; }
private:
	int d1;
};

// 函数指针vfptr
typedef void(*vfptr)();

// 打印虚表,传入虚函数指针数组的地址(即虚表指针)
void PrintVFT(void* vft[])
{
    
	printf("虚表地址__vfptr:%p\n", vft);
	for (size_t i = 0; vft[i] != nullptr; i++)
	{
    
		// 依次打印虚表各元素
		printf("vft[%d]:%p->", i, vft[i]);
		// 把虚表各元素赋值给函数指针ptr
		vfptr ptr = (vfptr)vft[i];
		// 调用函数
		ptr();
	}
	printf("\n");
}

int main()
{
    
	Derive d;
    
    // 打印第一张虚表
    PrintVFT((void**)*((int*)&d));
	// 打印第二张虚表
    // 必须先强转成char*,然后加Base1大小个字节,再强转成int*,解引用,强转成void**
	PrintVFT((void**)*((int*)((char*)&d + sizeof(Base1))));
    
	return 0;
}

  1. 注意上面的虚函数func1,Derive类中重写了func1,但是两个父类中都有虚函数func1,那么是重写那个父类的呢?

    两个基类 Base1 和 Base2 中的虚函数 func1 都会被重写。因为要满足多态条件。

  2. 多继承体系,Derive 继承了两个基类,那么 Derive 对象中有几张虚表呢?-- 有两张。

image-20220722163931725

观察运行图可以看出:多继承子类的未重写的虚函数放在第一个继承基类部分的虚函数表中。

[注意]: 我们这里也可以观察到,两张虚表中,同一个Derive::func1函数,在两张虚表的地址不一样,但是当 Base1 或 Base2 指针指向 Derive对象时,调的都是 Derive 中的 func1,是同一个函数。具体原因和编译器设计有关。


5.3. 菱形继承&菱形虚拟继承

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。如果好奇心比较强的话,可以去看下面大佬的两篇链接文章。

C++ 虚函数表解析 | 酷 壳 - CoolShell)

C++ 对象的内存布局 | 酷 壳 - CoolShell

原网站

版权声明
本文为[小钟hhh]所创,转载请带上原文链接,感谢
https://blog.csdn.net/qq_52906742/article/details/126259987