C++ 中的多态性是面向对象编程中的一个重要概念,它允许在运行时选择不同的函数实现,以适应不同类型的对象。
多态的种类
编译时多态性(Compile-time Polymorphism):也称为静态多态性或早期绑定,指在编译时确定程序应该调用的函数或运算符版本的能力;主要通过函数重载(Function Overloading)和运算符重载(Operator Overloading)来实现。 运行时多态性(Runtime Polymorphism):也称为动态多态性或晚期绑定,指在程序运行时根据对象的实际类型来确定调用的函数版本的能力,主要通过虚函数(Virtual Functions)和继承来实现,在运行时根据对象的实际类型来确定调用哪个函数。 参数多态性(Parametric Polymorphism):也称为泛型编程(Generic Programming);是指一种通用的编程技术,它允许在编写代码时不指定具体的数据类型,而是以一般的方式编写代码,稍后根据需要使用具体的类型实例化代码。
关于函数重载、运算符重载和继承/虚继承在之前的文章就已经有阐述过了,接下去说一下运行时多态性中的虚函数。
虚函数
虚函数(Virtual Function)是在基类中声明为虚函数的成员函数,它的特点是可以被派生类重写(覆盖)。虚函数为实现运行时多态性提供了基础,它允许在派生类中重新定义基类的函数,并通过基类指针或引用调用时动态地选择调用哪个函数版本。
以下是一个简单的示例:
代码定义了一个基类 Animal
和一个派生类 Cat
,其中 Cat
是 Animal
的子类。每个类都有构造函数和析构函数,并且 Animal
类中定义了一个 eat()
函数,Cat
类中重写了这个函数。
//父类
class Animal
{
public:
Animal(){};
~Animal(){};
void eat(){
std::cout << "Animal Eat 函数" << std::endl;
};
};
//派生类
class Cat : public Animal
{
public:
Cat(){};
~Cat(){};
void eat(){
std::cout << "cat Eat 函数" << std::endl;
};
}
int main() {
Animal * catObj = new Cat;
catObj->eat();
system("pause");
return 0;
}
在 main()
函数中,创建了一个指向 Cat
对象的 Animal
指针 catObj
。这是因为派生类对象可以被赋值给基类指针,因为派生类对象包含了基类对象的所有成员。然后,通过这个指针调用 eat()
函数,因为这是一个Cat
对象所以在结果出来之前我会认为运行的时Cat
类中的eat()
方法,但事实上此时程序的输出内容为:
可以看到此时输出的内容时Animal
类中的eat()
函数而不是Cat
类中的eat()
函数,这是由于 eat()
函数在基类中被声明为非虚函数,而派生类中重新定义了这个函数,所以在运行时,尽管 catObj
指向的是 Cat
对象,但实际上调用的是基类 Animal
中的 eat()
函数,而不是派生类 Cat
中的版本。这是因为非虚函数的调用是静态绑定的,编译器在编译时就已经确定了调用的函数版本。
这个结果很明显时不符合我们的预期的,这个时候如果希望运行的是子类中的方法,那么此时我们可以将父类中的eat()
函数设置为虚函数:
在 C++ 中,将一个成员函数声明为虚函数的方法是在函数声明前面加上 virtual
关键字。
//父类
class Animal
{
public:
Animal(){};
~Animal(){};
//虚函数
virtual void eat(){
std::cout << "Animal Eat 函数" << std::endl;
};
};
//派生类
class Cat : public Animal
{
public:
Cat(){};
~Cat(){};
void eat(){
std::cout << "cat Eat 函数" << std::endl;
};
}
int main() {
Animal * catObj = new Cat;
catObj->eat();
system("pause");
return 0;
}
将父类中的eat()
方法设置为虚函数后,此时再进行程序的运行,得到的结果为:
因为 eat()
函数在基类中被声明为虚函数,而且派生类中重新定义了这个函数,所以在运行时,通过指向派生类对象的基类指针调用 eat()
函数时,实际上会调用派生类 Cat
中的版本。这是因为虚函数的调用是动态绑定的,会根据对象的实际类型来确定调用的函数版本。
虚函数的使用原理涉及到动态绑定(Dynamic Binding)和虚函数表(Virtual Function Table)。
动态绑定:
动态绑定是指在运行时确定应该调用的函数版本,而不是在编译时确定;当通过基类指针或引用调用虚函数时,实际调用的是对象的实际类型对应的函数版本。
虚函数表
虚函数表(Virtual Function Table,简称 vtable)是 C++ 实现运行时多态性的重要机制之一;每个含有虚函数的类都有一个虚函数表,其中存储了指向各个虚函数的指针,当对象被创建时,会包含一个指向正确虚函数表的指针,通过这个指针,程序能够在运行时根据对象的实际类型来确定调用的虚函数版本。
我们们可以描绘出虚函数表的示意图,以便更好地理解虚函数的工作原理。
虚函数表示例:
每个含有虚函数的类都有一个虚函数表,其中存储了指向各个虚函数的指针,其中的指针顺序与虚函数在类定义中的声明顺序相同。要注意:当一个虚函数在父类中声明为虚函数时,它会自动成为子类中的虚函数。
1.Animal类虚函数表
+----------------------------------------+
| 虚函数表 (Animal) |
+----------------------------------------+
| 指向 Animal::eat() 的指针 |
+----------------------------------------+
| 指向 类中某虚函数 的指针 |
+----------------------------------------+
| .... |
+----------------------------------------+
2.Cat类虚函数表
+----------------------------------------+
| 虚函数表 (Cat) |
+----------------------------------------+
| 指向 Cat::eat() 的指针 |
+----------------------------------------+
| 指向 类中某虚函数 的指针 |
+----------------------------------------+
| .... |
+----------------------------------------+
含有虚函数的类实例化的对象:对象的前四个字节存储空间存储的是指向虚函数表的指针(对指针取值即可获得到对应类虚函数表的地址)
Animal对象
+-----------------------+
| Animal 对象 |
+-----------------------+
| 指向虚函数表 (Animal) |
+-----------------------+
| 其他成员变量 |
+----------------------+
Cat对象
+-----------------------+
| Cat 对象 |
+-----------------------+
| 指向虚函数表 (Cat) |
+-----------------------+
| 其他成员变量 |
+-----------------------+
现在让我们来解释一下虚函数调用的过程:
int main() {
Animal * catObj = new Cat;
catObj->eat();
system("pause");
return 0;
}
-
创建对象:首先,我们创建了一个
Cat
类的对象,并将其地址赋给了一个Animal
类型的指针catObj
。由于eat()
函数在基类Animal
中声明为虚函数,因此在Cat
类的对象中,会包含一个指向正确虚函数表的指针,这个指针会指向Cat
类的虚函数表。 -
调用虚函数:当我们通过
catObj
指针调用eat()
函数时,编译器会根据指针所指向的对象的实际类型来决定应该调用哪个虚函数版本。然后,程序会使用对象中存储的虚函数表指针来找到正确的虚函数表。 -
查找函数指针:在找到了正确的虚函数表后,程序会在虚函数表中查找
eat()
函数对应的函数指针。由于Cat
类中重写了eat()
函数,因此在Cat
类的虚函数表中,指向Cat::eat()
函数的指针会被存储在相应位置上。 -
调用函数:最后,程序会通过找到的函数指针来调用
Cat::eat()
函数,输出 "cat Eat 函数"。
根据上面的虚函数的调用过程和原理我们也可以不使用->
调用符号,而通过手动地址寻找得到循行的函数。
int main() {
Animal * catObj = new Cat;
//手动调用虚函数
typedef void(*MyEat)();
MyEat myeat = (MyEat)*(int *)*(int *)catObj;
myeat();
delete catObj;
system("pause");
return 0;
}
1.*(int *)*(int *)catObj;
解释:
虚函数表指针通常位于对象的内存布局的开始位置(可能是第一个成员或对象的隐藏成员);
(int *)catObj
:这一步是将指向对象的指针 catObj
进行了类型转换,将其转换为 int*
类型指针
*(int *)catObj
:接着,我们对转换后的指针进行了解引用操作;根据 C++ 中的指针运算规则,解引用操作会取出指针所指向的虚函数表内存地址处的值。
(int *)*(int *)catObj
:将虚函数表内存地址转化为int*
类型指针,此时指针指向虚函数表内存地址。
*(int *)*(int *)catObj
:最后,我们对转换后的整数地址进行解引用操作,得到的是该地址存储的值,也就是Cat
类虚函数表中的第一个虚函数eat()
的函数指针地址。
因为我们通过上述方法获得到了虚函数的函数地址,所以此时需要使用一个函数指针去指向该地址,对该虚函数进行调用。
2.typedef void(*MyEat)();
解析:
这段代码定义了一个函数指针类型 MyEat
,它可以指向一个没有参数且返回类型为 void
的函数。
void(*MyEat)();:这是一个函数指针的声明。在 typedef 关键字后面,我们声明了一个名为 MyEat 的新类型,它是一个指向函数的指针。括号中的 *MyEat 表示这是一个指针类型,而括号外的 () 表示这个指针所指向的函数的参数列表。(如果指向的函数地址有参数,那么再进行函数指针类型定义的时候也需要跟上参数列表)
3.(MyEat)*(int *)*(int *)catObj;
解析:
此时我们将上述获得到的虚函数eat()
的函数指针地址(此时类型为整型)类型强制转化为函数指针类型MyEat
。
4.MyEat myeat = (MyEat)*(int *)*(int *)catObj;
解析:
并声明一个函数指针类型MyEat
对象myeat
,接着将该指针指向虚函数eat()
的函数指针地址。
5.myeat();
解析:
最后运行myeat()
函数获得最后的结果:
最后得到的结果也是cat
对象的eat()
方法。
再此处下断点,查看函数指针的地址值;
在反汇编窗口查看该地址值的相关汇编代码,可以看到该地址指向的就是Cat
类的Eat
函数。