C++作为面向对象的语言,三大特性之一多态在平时的编程中使用频率特别高。
本篇文章就来详细讲解一下多态。
什么是多态
不同的对象做相同的一件事会出现不同的状态,这就是多态。
举个列子:比如普通人买车票要全价购买,而军人只用半价,这就是多态的一种体现。
多态的定义及实现
多态的构成条件
想要实现多态,需要以下条件
- 通过基类的指针或者引用来调用子类对象的虚函数
- 被调用的函数必须是虚函数
虚函数:类成员函数前用 virtual 关键字修饰的函数
我们可以实际看看如何实现多态。
#include<iostream>using namespace std;class Person {
public:virtual void Ticket(){cout << "全价" << endl;}
};
class Student : public Person {
public:virtual void Ticket(){cout << "半价" << endl;}
};int main()
{Person* p1;Person p2;Student s1;p1 = &p2;p1->Ticket();p1 = &s1;p1->Ticket();
}
我们发现,通过基类的指针调用虚函数能够实现多态。
我们还能通过引用实现多态。
#include<iostream>using namespace std;class Person {
public:virtual void Ticket(){cout << "全价" << endl;}
};
class Student : public Person {
public:virtual void Ticket(){cout << "半价" << endl;}
};
void Func(Person& p)
{p.Ticket();
}int main()
{Person p;Student s;Func(p);Func(s);return 0;
}
多态的调用更关注的是指针指向的类型,和普通调用不同,普通调用更关注的是调用的类型。
虚函数的重写
在继承中,若是派生类继承了基类,那么基类的同名成员会和派生类的同名成员构成隐藏关系。
而在多态中, 这种虚函数之间的同名成员则称作重写(覆盖)。
比如上面的例子中, Student 类就重写了Person类的 Ticket 函数。
一般虚函数构成重写需要三个条件:函数名相同,返回值相同,参数相同。
只要基类和派生类有同名函数,且基类函数前加了virtual,那么派生类的函数也会成虚函数。
不过虚函数的重写也有例外。
协变
重写的虚函数的返回值不同,即基类的虚函数返回基类对象的指针,派生类返回派生类对象的指针时,即构成协变。
#include<iostream>using namespace std;class A{};
class B : public A {};class Person {
public:virtual A* Ticket(){cout << "全价" << endl;return nullptr;}
};
class Student : public Person {
public:virtual B* Ticket(){cout << "半价" << endl;return nullptr;}
};int main()
{Person* p1;Person p2;Student s1;p1 = &p2;p1->Ticket();p1 = &s1;p1->Ticket();return 0;
}
比如这里,Ticket 虚函数的重写就构成了协变。
Person 类的 Ticket 函数返回 A 类的指针,Student 类的 Tciket 函数返回 B类的指针。
而如果不是基类返回基类的指针,派生类返回派生类的指针,则不会构成协变,还会报错。
析构函数的重写
若是基类的析构函数是虚函数,那么派生类的析构函数无论加不加virtual 关键字修饰都会是虚函数。
虽然两个析构函数的名称不同,但是二者之间依旧构成重写。
析构函数构成重写的原因
编译器对析构函数做了特殊处理,所有析构函数都会在编译后转换为 destructor;
override 和 final
由于多态会导致函数重写,有时又会因为程序员的原因导致函数字母出错,从而无法重写函数,这类错误只能在运行的时候发现,因此C++提供了两个关键字用来检测函数是否重写。
- override : 检查派生类函数是否重写,没重写则报错。
- final : 修饰虚函数,表示该虚函数不能被重写,修饰普通函数,表示该函数不能被继承
override 示例
final 示例
抽象类
由于多态特性的存在,C++还有一个抽象类的概念。
概念:类中有一个函数是纯虚函数。包含纯虚函数的类就是抽象类,这种类无法实例化对象
纯虚函数:虚函数后面加上 =0 ,就是纯虚函数。
派生类继承了抽象类后也无法实例化对象,必须重写纯虚函数才能实例化对象。
像纯虚函数这样的函数体现了接口继承。
像图中的 Animal 类,Animal 肯定不能作为一个对象存在在自然界中,而 Cat 当然存在于自然中,C++的抽象类也是类似,无法实例化对象。
接口继承和实现继承
普通类的继承都是实现继承,派生类继承了基类的函数,能够使用基类的函数,而虚函数的继承则体现了接口继承,虚函数的存在是为了让派生类重写,达成多态。因此除非是为了实现多态,一般是不用虚函数的。
多态原理
一般编译器对于只有函数的空类只会给它一个字节的标记位,但是这里 A 类有一个虚函数,它的却有8字节的大小。
这是因为当一个类含有虚函数时,编译器就会给该类提供一个虚函数表,该类实例化的对象中都会有一个指针用来指向该虚函数表。
这个指针一般就在对象的头4个或8个字节中;
根据编译器的环境,这个指针大小可能为4或8字节大小。
而多态的原理就是建立在虚函数表之上的。
class A
{
public:virtual void Test1(){}virtual void Test2(){}void Test3(){}
};class B : public A
{
public:virtual void Test1(){}
};
int main()
{B b;A a;return 0;
}
我们写下以上的代码,然后通过监视窗口,可以看到,b对象和a对象都分别有一个虚函数表(_vfptr),其中 B 类 重写了 Test1 函数,因此它的虚表中 Test1 函数就是 B类中的Test1函数,而Test2 没有重写,因此就是 A 类中的 Test2 函数。
观察总结:
- 派生类生成虚表会先将基类虚表内容保存到派生类的虚表内容中。
- 若是派生类已经重写了基类的某个虚函数,则会将重写的虚函数覆盖到虚表中基类的虚函数。
- 而派生类自己新增的虚函数则按派生类的声明顺序新增到派生类虚表的最后。
从观察总结中延伸,我们就明白了多态的原理。
- 当基类的指针或引用的是派生类时,它的虚表指针指向的就是派生类的虚表,其中保存的虚函数已经覆盖或者重写完毕了。
- 而当基类指针或引用的是基类时,它的虚表指针指向的就是基类的虚表,其中保存的虚函数也是基类的虚函数。
- 这样就实现了一个对象去完成同一行为时展现的不同形态。
动态绑定和静态绑定
动态绑定:程序运行期间根据具体的类型确定行为,调用具体的函数。
静态绑定:编译期间,就已经确定了程序的行为。
而多态调用就是典型的动态绑定,普通调用就是静态绑定。
通过查看多态调用和普通调用的汇编代码就能发现,多态调用只有在运行的时候才会确定调用的是哪个函数。
通过反汇编也能够看到多态调用和普通调用不同。
普通调用直接就找到了函数位置,而多态调用在运行的时候才会去连接派生类的虚函数表,再通过虚函数表找到对应的函数位置。
单继承和多继承的虚函数表
单继承的虚函数表
class A
{
public:virtual void Test1(){}virtual void Test2(){}void Test3(){}
};class B : public A
{
public:virtual void Test1(){}virtual void Test3(){}virtual void Test4(){}
};
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()
{B b;A a;PrintVTable((VFPTR*)(*(int*)&b));PrintVTable((VFPTR*)(*(int*)&a));return 0;
}
在 x86 环境下查看虚函数表的内容。
其中 B 类重写了 A 类的 Test1 函数,Test2 函数未重写。
来看看 a 和 b 的虚函数有什么不同。
我们发现, B 类的 Test1 函数是重写后的地址, 而 Test2 函数的地址和 A 类的地址相同。
而 B 类后续的虚函数则添加到 B 类虚表的后面。
多继承的虚函数表
class A
{
public:virtual void Test1(){cout << "A::Test1()" << endl;}virtual void Test2(){cout << "A::Test2()" << endl;}void Test3(){cout << "A::Test3()" << endl;}
};class B
{
public:virtual void Test1(){cout << "B::Test1()" << endl;}virtual void Test2(){cout << "B::Test2()" << endl;}virtual void Test4(){cout << "B::Test4()" << endl;}
};class C : public A, public B
{
public:virtual void Test1(){cout << "C::Test1()" << endl;}virtual void Test3(){cout << "C::Test3()" << endl;}
};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()
{C c;PrintVTable((VFPTR*)(*(int*)&c));PrintVTable((VFPTR*)*(int*)((char*)&c + sizeof(A)));return 0;
}
我们发现,如果一个类继承的两个函数都有虚函数,那么派生类就会创立两个虚表,分别保存基类的虚函数并且进行了重写操作。
比如这里 C类的 Test1 函数就分别重写在 A类的虚表上和 B 类的虚表上。
而没有重写的Test2函数就被保存了下来。
总结
当一个类有虚函数时,编译器就会为这个类创建一个虚表,该类实例化的对象都共享这个虚表,每个对象的头4个(或8个)字节就保存了这个虚表的地址。
虚函数表存在只读代码段中,虚函数也是,而虚函数表指针是在类的构造函数中赋值的,因此类的构造函数不能为虚函数。
当一个类继承了一个有虚表的类时,通过基类的指针或引用来调用构成重写的虚函数时,就能实现多态。
当一个类继承多个有虚表的类时,该类就有多个虚表,有重写的虚函数就会分别添加到对应的虚函数,比如 A 类的虚表中有函数被派生类重写了,那就添加到 A 类的虚表中。而没有重写的虚函数会添加到继承顺序中第一个有虚表的基类的虚表中。