[C++](16)多态:虚函数,使用,多态的原理

概念

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

例子:比如买票这件事,不同的人去买是不一样的,成人买是全价,学生买是半价,军人享有优先权。

定义及实现

虚函数与重写(覆盖)

  • virtual 修饰的函数被称为虚函数。

  • 派生类中有与基类中的在返回类型、函数名、参数列表都完全相同的虚函数,则称派生类中的该虚函数重写(覆盖)了基类的虚函数。

  • 注意:参数列表相同是指参数的个数、类型以及类型的顺序相同。与参数名称,缺省值无关。

以买票为例,有以下继承关系,其中各写一个买票 BuyTicket 虚函数,派生类虚函数重写基类虚函数:

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

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

class Soldier : public Person
{
public:
    //虚函数
	virtual void BuyTicket()
	{
		cout << "Soldier:优先买票-全价" << endl;
	}
};

👆:派生类的虚函数的 virtual 也可以省略不写,因为先继承了基类函数接口声明,它依然是虚函数。但是我们为了规范,应该加上 virtual

注意

  • virtual 关键字只在声明时加上,在类外实现时不能加
  • staticvirtual 是不能同时使用的

多态构成条件

多态的实现有两个要求

  1. 派生类虚函数重写基类虚函数(重写:三同+虚函数)。
  2. 基类指针或者引用去调用虚函数。

第一点已经满足了,要满足第二点我们还要再实现一个函数:

void Pay(Person* ptr)
{
	ptr->BuyTicket();
}

也可以传引用:

void Pay(Person& ref)
{
	ref.BuyTicket();
}

注意:不可以直接传对象,否则就不满足第二点


最后是一个简单的菜单(传指针):

void test1()
{
	int option;
	Person p;
	Student st;
	Soldier so;
	do
	{
		cout << "请选择身份:";
		cout << "1.成人 2.学生 3.军人" << endl;
		cin >> option;
		switch (option)
		{
		case 1:
			Pay(&p);
			break;
		case 2:
			Pay(&st);
			break;
		case 3:
			Pay(&so);
			break;
		default:
			cout << "输入错误" << endl;
			break;
		}
	} while (option != -1);
}
//结果:
//请选择身份:1.成人 2.学生 3.军人
//1
//Person:买票-全价
//请选择身份:1.成人 2.学生 3.军人
//2
//Student:买票-半价
//请选择身份:1.成人 2.学生 3.军人
//3
//Soldier:优先买票-全价
//请选择身份:1.成人 2.学生 3.军人

虚函数重写的例外

协变

对于虚函数的返回值,如果是父子关系的类型的指针或引用,依然构成虚函数重写。

例子:

AB 是父子关系,Person 类和 Student 类中的虚函数返回类型虽然不同,但是依然构成虚函数重载,满足多态的条件。

class A
{};

class B : public A
{};

class Person
{
public:
	virtual A* f()
	{
		cout << "virtual A* Person::f()" << endl;
		return nullptr;
	}
};

class Student : public Person
{
public:
	virtual B* f()
	{
		cout << "virtual B* Student::f()" << endl;
		return nullptr;
	}
};

void test2()
{
	Person p;
	Student s;
	Person* ptr = &p;
	ptr->f();

	ptr = &s;
	ptr->f();
}
//结果:
//virtual A* Person::f()
//virtual B* Student::f()

接口继承

上文提到:派生类的虚函数的 virtual 也可以省略不写,因为先继承了基类函数接口声明,它依然是虚函数。但是我们为了规范,应该加上 virtual

这其实是个坑,如下题:


以下程序输出的结果是什么?( )

class A
{
public:
	virtual void func(int val = 1)
	{
		cout << "A->" << val << endl;
	}
	virtual void test()
	{
		func();
	}
};

class B : public A
{
public:
	void func(int val = 0)
	{
		cout << "B->" << val << endl;
	}
};

int main()
{
	B* p = new B;
	p->test();
	return 0;
}

A. A->0 B. B->1 C. A->1 D. B->0 E. 编译出错 F. 以上都不正确


答案:

B

解析:

B 继承了 A 类的 test 函数,所以可以调用,但是 test 函数内部的 this 指针依然是 A* 类型。pB* 类型,传给 this 会发生切割。然后再用 this 指针去调用 func 函数,也就是 this->func()this 是基类指针,func 构成虚函数重写,所以满足多态的两个条件。this 指向的是 B 类型对象,则调用 B 类里的虚函数,该虚函数继承了基类虚函数的接口,也就是说,B 类里的 func 的参数列表显式写出来的缺省值是没有意义的,是用来迷惑你的,真正的缺省值还是 1,最后打印 B->1,选 B

总结

  1. 派生类的虚函数继承是接口继承,virtual 和参数列表缺省值都会原样照搬基类的虚函数。
  2. 虚函数重写指的是重写函数实现。

扩展:

p->test(); 改成 p->func(); 结果是什么?( )

答案:

D

解析:

这里是直接调用,没有构成多态,也就没有接口继承,所以结果就是 B->0,选 D

析构函数的重写

上篇文章提到:基类和派生类的析构函数构成隐藏关系,由于多态的需要,析构函数名会被统一处理成 destructor

只要加上 virtual 满足三同。即可构成重写(覆盖)关系。

如下场景:

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

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

void test4()
{
	Person* p = new Student;
	delete p;
}
//结果:
//~Person()

p 虽然指向的是 Student 类型的对象,但是由于本身是 Person* 所以只会调用 Person 的析构函数,而调不到 Student 的析构函数,如果 Student 类内有动态分配空间,那么就会造成内存泄漏。

这里就需要多态调用析构函数:

给析构函数加上 virtual,构成函数重写,p 作为基类指针去调用

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

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

void test4()
{
	Person* p = new Student;
	delete p;
}
//结果:
//~Student()
//~Person()

建议:如果设计的一个类,可能会作为基类,其析构函数最好定义为虚函数。

C++11 的 final 和 override

final

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

该关键字写在虚函数后面

class Car
{
public:
	virtual void Drive() final //final函数
	{}
};

class Benz : public Car
{
public:
	virtual void Drive() //此处报错:无法重写“final”函数 "Car::Drive"
	{
		cout << "Benz" << endl;
	}
};
  1. 修饰类,表示不能被继承
class Car final	//final类
{
public:
	virtual void Drive()
	{}
};

class Benz : public Car	//此处报错:不能将“final”类类型用作基类
{
public:
	virtual void Drive()
	{
		cout << "Benz" << endl;
	}
};

override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错

该关键字写在派生类虚函数的后面:

class Car
{
public:
	void Drive() //未被virtual修饰
	{}
};

class Benz : public Car
{
public:
	virtual void Drive() override //此处报错:使用“override”声明的成员函数不能重写基类成员
	{
		cout << "Benz" << endl;
	}
};

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

img

抽象类

  • 在虚函数后面写上 =0,则这个函数为纯虚函数,包含纯虚函数的类叫做抽象类(也叫接口类)。
  • 抽象类不能实例化出对象派生类继承后也不能实例化对象,只有重写纯虚函数,派生类才能实例化出对象
  • 纯虚函数也可以实现函数体,但是没有意义,在基类中只给出声明,它的实现留给派生类去做
  • 纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

如下 Drive 函数是一个纯虚函数,Car 是一个抽象类:

class Car	//抽象类
{
public:
	virtual void Drive() = 0;	//纯虚函数
};

此时基类无法定义出对象:

void test5()
{
	Car c;	//此处报错:不允许使用抽象类类型 "Car" 的对象
}

但是 Car* 指针还是可以创建的。

例子:

class Car
{
public:
	virtual void Drive() = 0;
};

class BMW : public Car
{
public:
	virtual void Drive()
	{
		cout << "BMW" << endl;
	}
};

class Benz : public Car
{
public:
	virtual void Drive()
	{
		cout << "Benz" << endl;
	}
};

void test5()
{
	Car* pBMW = new BMW;
	Car* pBenz = new Benz;
	pBMW->Drive();
	pBenz->Drive();
}
//结果:
//BMW
//Benz

建议:对于不需要实例化对象的基类,其内部虚函数最好写成纯虚函数,规范其派生类虚函数重写。

多态的原理

虚函数表

class Base
{
public:
	virtual void Fun()
	{
		cout << "Fun()" << endl;
	}
private:
	int _b = 1;
};

sizeof(Base) 是多少?


答案:32位平台下是8,64位平台下是16。

其实除了 _b 成员,还多了一个虚函数表指针 __vfptr,虚函数表简称虚表。


下面对 Base 类增加一个虚函数 Fun2 和一个普通函数 Fun3Derive 类继承 Base,重写 Fun1。然后创建对象,观察监视窗口。

class Base
{
public:
	virtual void Fun1()
	{
		cout << "Base::Fun1()" << endl;
	}
	virtual void Fun2()
	{
		cout << "Base::Fun2()" << endl;
	}
	void Fun3()
	{
		cout << "Base::Fun3()" << endl;
	}
private:
	int _b = 1;
};

class Derive : public Base
{
public:
	virtual void Fun1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};

void test6()
{
	Base b;
	Derive d;
}

img

虚表指针 __vfptr 其实就是函数指针数组

可以发现,普通函数 Fun3 没有进入虚表。Derive 类里的 Fun1 完成了重写,d 的虚表中存的是重写后的 Derive::Func1,未被重写的 Fun2 存放的还是原来的。

由此得出:

  • 重写是语法层的概念,派生类对基类虚函数实现进行了重写。

  • 覆盖是原理层的概念,派生类的虚表拷贝基类虚表进行了修改,覆盖要重写的虚函数指针。

所以多态调用的实现,是依靠运行时,去指向的对象的虚表中查找调用函数的地址

对比多态调用和普通调用:

  • 多态调用:运行时决议——运行时确定调用函数的地址(查虚函数表)
  • 普通调用:编译时决议——编译时确定调用函数的地址

下面写一个多态调用和一个普通调用,对比二者的汇编代码

void test6()
{
	Base b;
	Derive d;
	Base* p = &d;

	p->Fun1();	//多态调用
	b.Fun3();	//普通调用
}
//结果:
//Derive::Func1()
//Base::Fun3()
	p->Fun1();	//多态调用
00B22C45  mov         eax,dword ptr [p]  //p存的是d对象的地址,这里就是将p移动到eax中
00B22C48  mov         edx,dword ptr [eax]  //[eax]就是取eax指向的内容,这里就是将d对象的虚表指针移动到edx
00B22C4A  mov         esi,esp  
00B22C4C  mov         ecx,dword ptr [p]  
00B22C4F  mov         eax,dword ptr [edx]  //[edx]就是取edx指向的内容,这里就是将虚表里的虚函数指针移动到eax
00B22C51  call        eax  //通过eax里的虚函数指针调用函数
00B22C53  cmp         esi,esp  
00B22C55  call        __RTC_CheckEsp (0B2131Bh)  
	b.Fun3();	//普通调用
00B22C5A  lea         ecx,[b]  
00B22C5D  call        Base::Fun3 (0B2151Eh)  //这里就是直接call,因为函数地址已经在编译时确认了

为什么派生类赋值给基类对象无法构成多态

对象切片的时候,派生类只会拷贝成员给基类对象,不会拷贝虚表指针。况且从逻辑上讲,基类对象本就应该调用基类里的函数,如果拷贝了虚表指针,基类对象调用的却是派生类函数,就发生混乱了。而指针和引用可以明确表示,自己指向的是哪一类的对象就调用哪一类的函数。

动态绑定和静态绑定

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

补充:VS监视窗口下的虚表

A 类有一个虚函数 Fun1B 类重写 Fun1,然后单独写了一个虚函数 Fun2。然后创建对象,观察监视窗口:

class A
{
	virtual void Fun1()
	{
		cout << "A::Fun1()" << endl;
	}
};
class B : public A
{
	virtual void Fun1()
	{
		cout << "B::Fun1()" << endl;
	}
	virtual void Fun2()
	{
		cout << "B::Fun2()" << endl;
	}
};
void test7()
{
	A a;
	B b;
}

img

按理说,虚函数指针应该进入虚表,B 类有两个虚函数,所以 B 的虚表应该有两个虚函数指针。但是监视窗口只显示了一个。还有一个虚函数指针去哪了?

打开内存窗口看看:

img

我们发现,它的下一个位置还存着一个地址 0x0059146f,它是虚函数 Fun2 的地址吗?

我们可以通过以下代码打印虚表并调用虚函数:

typedef void(*V_FUN)();

void PrintVFTable(V_FUN* a)
{
	for (size_t i = 0; i < 2; ++i)
	{
		printf("[%d]:%p->", i, a[i]);	//打印函数指针
		V_FUN f = a[i];
		f();	//调用函数
	}
}

void test7()
{
	A a;
	B b;
	PrintVFTable((V_FUN*)*(int*)&b); //这里传参要取b的前4个字节
}
//结果:
//[0]:00591479->B::Fun1()
//[1]:0059146F->B::Fun2()

👆原理

要打印 b 的虚表,首先就要获得 b__vfptr ,但是我们不能直接 b.__vfptr 去取;注意到它其实就是 b 的前4个字节,只能通过 *(int*)&b 取出,说明:b 不能直接转换成 int,而 &b 可以转换成 int*,然后解引用就可以获得 b 的前4个字节;最后将这 4 个字节转换成 V_FUN* 也就是函数指针数组类型进行传参。

V_FUN 是我们自己 typedef 出来的函数指针类型,方便接下来的使用;PrintVFTable 的形参 V_FUN* a 就是函数指针数组,内部使用for循环打印 a 数组的前两个函数指针并调用函数。

总结

由结果看出,它成功调用了 Fun2,确实是 Fun2 的地址,这说明,vs监视窗口看到的虚函数表不一定是真实的,可能被处理过。

补充:小题

虚表存在哪个区域?

  • 一个类对应一个虚表,同类型的不同对象存的虚表指针都指向这个虚表。

  • 所以虚表首先不可能在栈和堆,栈里是存函数栈帧的,虚表不会随函数的开始结束而开辟销毁,也不可能在堆区,因为它不是动态开辟的。

那么到底存在哪呢?我们下面来实验一下:

void test8()
{
	int a = 0;
	int* p = new int;
	static int b = 1;
	const char* str = "hello world";
	A aa;
	printf("栈区:%p\n", &a);
	printf("堆区:%p\n", p);
	printf("静态区(数据段):%p\n", &b);
	printf("常量区(代码段):%p\n", str);
	printf("虚表:%p\n", *(int*)&aa);
}
//结果:
//栈区:004FF650
//堆区:006B4160
//静态区(数据段):00D3D008
//常量区(代码段):00D3AF24
//虚表:00D3ADD0

虚表的地址和常量区更接近,说明虚表存在常量区(代码段)

多继承的虚函数

以下是一个多继承体系,基类 Base1Base2 分别有两个虚函数 fun1 fun2,派生类继承它俩,并重写 fun1 ,增加一个独有的虚函数 fun3。最后创建派生类对象然后通过监视窗口进行观察。

class Base1
{
public:
	virtual void fun1()
	{
		cout << "Base1::fun1()" << endl;
	}
	virtual void fun2()
	{
		cout << "Base1::fun2()" << endl;
	}
private:
	int b1;
};

class Base2
{
public:
	virtual void fun1()
	{
		cout << "Base2::fun1()" << endl;
	}
	virtual void fun2()
	{
		cout << "Base2::fun2()" << endl;
	}
private:
	int b2;
};

class Derive : public Base1, public Base2
{
public:
	virtual void fun1()
	{
		cout << "Derive::fun1()" << endl;
	}
	virtual void fun3()
	{
		cout << "Derive::fun3()" << endl;
	}
private:
	int d1;
};
void test8()
{
	Derive d;
}

img

d 里面存在两个虚表指针,分别指向从 Base1Base2 继承的虚表,并且它们俩的 fun1 都被重写了。

那么问题来了:

fun3 去哪里了?它的函数指针也应该被存进虚表啊。

上面补充过了,监视窗口不一定准,我们还是要自己打印虚表:

typedef void(*V_FUN)();
void PrintVFTable(V_FUN* a)
{
	cout << "虚表地址:" << a << endl;
	for (size_t i = 0; a[i] != nullptr; ++i)
	{
		printf("[%d]:%p->", i, a[i]);
		V_FUN f = a[i];
		f();
	}
	cout << endl;
}

👆:对打印虚表函数稍加改进:增加打印虚表地址,循环条件改成 a[i] != nullptr 因为在vs下,虚表末尾的下一位置一定为空。

通过监视窗口可以看出,d 的前4个字节一定是 Base1 类的虚表指针,Base2 的虚表指针距离 Base1 虚表指针 sizeof(Base1) 的长度,需要对 &d 转换成 char* 后进行偏移。

void test8()
{
	Derive d;
	PrintVFTable((V_FUN*)*(int*)&d);
	PrintVFTable((V_FUN*)*(int*)((char*)&d + sizeof(Base1)));
}
//结果:
//虚表地址:00139B94
//[0]:00131028->Derive::fun1()
//[1]:001312DA->Base1::fun2()
//[2]:001310A0->Derive::fun3()
//
//虚表地址:00139BA8
//[0]:0013111D->Derive::fun1()
//[1]:001310EB->Base2::fun2()

通过结果可以看出:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中

菱形继承

B Cfun 函数重写,虚拟继承防止数据冗余和二义性。

class A
{
public:
	virtual void fun()
	{}
	int _a;
};

class B : virtual public A
{
public:
	virtual void fun()
	{}
	int _b;
};

class C :virtual public A
{
public:
	virtual void fun()
	{}
	int _c;
};

class D : public B, public C//此处报错:“D” : “void A::fun(void)”的不明确继承
{
public:

};

但是报错了,这是怎么回事?

因为虚拟继承会将 D 的两个虚表指针变为一个虚表指针,但是这一个虚表指针应该指向 B 类型的虚表还是 C 类型的虚表是不明确的,所以会报错。

解决方式:在 D 内也重写 fun 函数:

//略。。。
class D : public B, public C
{
public:
	virtual void fun()
	{}
};

小问题

  1. inlinevirtual 可以同时修饰函数吗?

    答:可以,但是如果是多态调用,编译器会忽略这个 inline ,虚函数会放到虚表中。

  2. 静态成员函数可以是虚函数吗?

    答:不可以,因为静态成员函数没有 this 指针,它的调用可以不借助于对象,而直接使用 :: 调用。违背了多态的概念,无法用于实现多态。

  3. 构造函数可以是虚函数吗?

    答:不可以,因为虚函数存在虚表中,要调用就需要虚表指针。但是虚表指针是在构造函数初始化列表才初始化的。也就是说,要调用构造函数就需要虚表指针,但是不调构造函数没有虚表指针,这就矛盾了,最终导致对象无法被创建。

  4. 析构函数可以是虚函数吗?

    答:可以,并且最好把基类的析构函数定义成虚函数, 具体参考本章:定义及实现/虚函数重写的例外/析构函数的重写。

  5. 对象访问普通函数快还是虚函数快?

    答:对于普通对象的调用是一样快的,如果是指针或引用的调用,则调用普通函数快,因为其与虚函数构成多态,运行时调用虚函数需要去虚函数表中去找。

  6. 虚函数表是在什么阶段生成的,存在哪?

    答:在编译阶段生成,存在常量区(代码段)。

  • 8
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 6
    评论
好的,我可以为您介绍一下关于C++多态虚函数的实验。 首先,多态是指不同对象以不同的方式响应相同的消息的能力。在C++中,通过虚函数来实现多态虚函数是指在基类中被声明为虚函数的函数,在派生类中被重写后,在程序中使用基类指针或引用调用该函数时,会调用派生类中的函数。 下面是一个简单的多态虚函数的实验示例: ```c++ #include<iostream> using namespace std; class Shape { public: virtual void draw() { cout << "Drawing Shape" << endl; } }; class Circle: public Shape { public: void draw() { cout << "Drawing Circle" << endl; } }; class Square: public Shape { public: void draw() { cout << "Drawing Square" << endl; } }; int main() { Shape *s; Circle c; Square sq; s = &c; s->draw(); s = &sq; s->draw(); return 0; } ``` 运行结果为: ``` Drawing Circle Drawing Square ``` 在上述示例中,我们定义了一个基类Shape和两个派生类Circle和Square,分别重写了基类的虚函数draw()。在程序中,我们先定义了一个基类指针s,然后将其指向第一个派生类对象c,再调用s的虚函数draw(),此时会调用派生类Circle的draw()函数,因为指针实际上指向的是Circle类型的对象。接着,我们将指针s重新指向第二个派生类对象sq,再次调用s的虚函数draw(),此时会调用派生类Square的draw()函数。 通过这个实验,我们可以看到多态虚函数的特性,即通过基类指针或引用调用虚函数时,会根据实际对象的类型来调用相应的派生类函数。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

世真

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值