文章内的所有调试都是在vs2022下进行的,
部分小细节可能因编译器不同存在差异。
文章目录
- 多态的定义和实现
- 概念引入
- 多态的构成条件
- 虚函数重写
- 通过基类的指针或者引用调用虚函数
- override和final
- 抽象类
- 概念
- 实现继承和接口继承
- 虚函数表
- 单继承中的虚表
- 打印虚表
- 多继承中的虚表
- 虚表的存储
- 多态的原理
- 几个小问题
多态的定义和实现
概念引入
对于一个火车票售票系统,
可能会有多重角色,
比如普通成人类、学生类、军人类、儿童类等等…
这些类可能都是从某个基类派生出来的,
而且每个类都有一个基本需求,就是买票,
所以对于同一个购票函数BuyTicket()
,
当不同的类去调用它时它应该执行不同的功能,
比如成人要全价卖票,学生可以半价买票,军人得优先买票…
所以怎样满足这一需求呢?
通过多态的机制。
所以多态其实就是不同继承关系的类实例化出来的对象去调用同一函数,
最终用同一个函数了执行不同的动作。
感觉其实有点儿函数重载的意味…
多态的构成条件
首先见一见多态是个什么情况。
这是一段代码:
class Person
{
public:
virtual void BuyTicket()
{ cout << "原价买票" << endl; }
};
class Student : public Person
{
public:
virtual void BuyTicket()
{ cout << "半价买票" << endl; }
};
class Solider : public Person
{
public:
virtual void BuyTicket()
{ cout << "优先买票" << endl; }
};
void QueryPriority(Person* p)
{ p->BuyTicket(); }
int main()
{
Person p;
QueryPriority(&p);
Student stu;
QueryPriority(&stu);
Solider solider;
QueryPriority(&solider);
return 0;
}
运行结果如下:
虚函数重写
在讲多态的构成条件之前要先引入一个虚函数的概念。
虚函数就是用virtual
修饰的函数,
在继承一文中已经初步见识过virtual
关键字了,
当时是用virtual
进行虚拟继承,
在菱形继承中避免数据二义性和冗余问题,
这里是用来修饰函数使之成为虚函数,
作为多态的构成条件之一。
所以构成多态的第一个条件是被调用的函数必须是虚函数,
在上面的例子中被调用的函数是BuyTicket()
函数,
所以它要定义成虚函数:
class Person
{
public:
virtual void BuyTicket()
{ cout << "原价买票" << endl; }
};
这样的话BuyTicket()
函数会被继承到派生类中,
我们当然可以不加virtual
,
直接在派生类中重载BuyTicket()
函数,
此时派生类中就有了两个BuyTicket()
函数,
一个是派生类中重载的,一个是基类的,
这两个函数是构成隐藏关系的。
于是就有了数据冗余和二义性…
而我们只想保留一份函数,
基类对象调用时执行基类中定义的行为,
派生类对象调用时执行派生类中定义的行为。
显然重载是完成不了这个任务的,
所以取而代之的就是重写:
class Student : public Person
{
public:
virtual void BuyTicket()
{ cout << "半价买票" << endl; }
};
class Solider : public Person
{
public:
virtual void BuyTicket()
{ cout << "优先买票" << endl; }
};
此前在基类和派生类中重名的成员变量或成员函数是构成隐藏/重定义关系的,
但那是对于普通函数而言,
满足一定条件的虚函数则是会构成覆盖/重写关系,
意味着在派生类中只有这么一个函数存在,
继承下来的基类的函数完全被覆盖掉了。
上面说的满足一定条件,
前提一定是虚函数,
此外除了函数名相同,
函数的返回值类型和参数列表也要相同。
但是返回值类型相同还有例外,
基类与派生类的虚函数返回值类型可以不相同,
但一定要是继承关系中基类或派生类的指针或引用,
举个简单的例子:
class Student : public Person
{
public:
virtual Student* BuyTicket()
{ cout << "半价买票" << endl; }
}
class Solider : public Person
{
public:
virtual Solider* BuyTicket()
{ cout << "优先买票" << endl; }
}
这种情况下仍然构成多态,
(这么鸡肋的写法应该没人用吧)…
以上就是构成多态的其一条件:
被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
通过基类的指针或者引用调用虚函数
诸如上面的多态调用:
Person p;
Person* ptr = &p;
ptr->BuyTicket();
Student stu;
ptr = &stu;
ptr->BuyTicket();
Solider solider;
ptr = &solider;
ptr->BuyTicket();
想要实现函数的多态调用,
首先函数一定是重写过的虚函数,
再就是要通过基类指针或者引用来调用。
至于为什么,在后面的多态原理细细阐述。
此前在继承一文中提出过一个问题:
如果这么定义了一个对象(此处省略类的定义):
int main() { A* p = new B; delete p; return 0; }
此时运行结果如下:
此时只调用了A的析构,
对B的部分成员并没有处理,
因此造成了内存泄漏!
那么现在就可以解决这个问题,
就是将析构函数定义成虚函数,
在析构时会多态调用B类的析构函数,
就不会发生内存泄漏了。
这里有一个细节,
前面提到函数构成重写的条件之一是函数名必须相同,
而析构函数显然不符合这个条件,
但为什么又能实现重写呢?
实际上编译器偷偷对析构函数进行了处理,
统一将析构函数处理成同名函数。
override和final
C++对函数重写的要求比较严格,
但是有些情况下由于疏忽,
可能会导致函数名字母次序写反而无法构成重写,
而这种错误在编译期间是不会报出的,
只有在程序运行时没有得到预期结果才来debug会得不偿失,
因此,C++11提供了override
和final
两个关键字,
可以帮助用户检测是否重写。
final
:修饰虚函数,表示该虚函数不能再被重写
class Person { public: virtual void BuyTicket() final { cout << "原价买票" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "半价买票" << endl; } };
override
: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
class Person { public: virtual void BuyTicket() { cout << "原价买票" << endl; } }; class Student : public Person { public: virtual void BuyTickte() override { cout << "半价买票" << endl; } };
上面故意错把派生类中的函数名写错了。
抽象类
概念
在虚函数的后面写上=0
,
则这个函数为纯虚函数。
包含纯虚函数的类叫做抽象类(也叫接口类),
抽象类不能实例化出对象。
派生类继承后也不能实例化出对象,
只有重写纯虚函数,派生类才能实例化出对象。
纯虚函数规范了派生类必须重写,
另外纯虚函数更体现出了接口继承。
class Person
{
public:
virtual void BuyTicket() = 0
{
cout << "原价买票" << endl;
}
void func()
{}
};
实现继承和接口继承
普通函数的继承是一种实现继承,
派生类继承了基类函数,可以使用函数,
继承的是函数的实现。
虚函数的继承是一种接口继承,
派生类继承的是基类虚函数的接口,
目的是为了重写,达成多态,
继承的是接口。
所以如果不实现多态,
不要把函数定义成虚函数。
虚函数表
64位地址太长,
所以为了方便观察,
下面统一换成32位地址。
现在定义一个基类:
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
然后定义一个Base
对象,
那这个对象的对象模型是怎样的呢?
相比于没有虚函数的普通对象多了一个_vfptr
:
看样子是一个数组,
数组元素都是指针,
那看来_vfptr
是一个指针数组:
这里显示它有一个元素,
这个元素存放的是Func1
函数的地址。
多出来的这个_vfptr
是虚函数表指针,
完整应该叫做visual function table pointer,
虚函数表我们一般简称为虚表,
注意和继承中的虚基表的概念区分开,
它存放的其实就是虚函数的地址,
注意虚函数并不存放在这儿,
虚函数和普通函数一样是存放在代码段的。
所以一个含有虚函数的类中都至少都有一个虚表指针。
单继承中的虚表
我们再进一步看在继承中虚表是怎样的,
在上面代码的基础上我们再写点东西:
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; }
virtual void Func4()
{ cout << "Derive::Func4()" << endl; }
private:
int _d = 2;
};
此时再看一下它们的对象模型有什么变化:
基类对象的结构还是原来那样,
只不过虚表中多了一个指向Func2
函数的指针,
Func3
并不在这儿,因为它不是虚函数。
派生类继承了基类的虚表,
但是存的指针有些变化,
可以看到_vfptr[0]
存放的是被重写后的Func1
的地址。
所以就可以下一个简单的结论:
派生类先继承基类的虚表,
如果派生类重写了基类中某个虚函数,
用派生类自己的虚函数覆盖虚表中基类的虚函数。
那派生类自己的虚函数呢?
按理来说派生类有虚函数,
实例化出来的对象也应该有一个虚基表,
但是这里好像并不是这样。
实际上,
派生类自己新增加的虚函数,
会按其在派生类中的声明次序增加到基类虚表的最后,
和基类共用一个虚表。
而这里继承的基类的虚表没有显示出派生类的虚函数,
这是编译器的监视窗口故意隐藏了这个函数,
也可以认为是他的一个小bug。
那么我们如何查看d
的虚表呢?
打印虚表
我们既然有虚表指针_vfptr
,
那我们肯定就有办法打印它指向的虚表的内容,
也就是各个虚函数的地址。
下面就来看一下怎样获取。
首先在对象的存储结构中虚表指针存放在最上面,
也就是对象头四个字节(64位指针是八个字节),
所以对象模型如下:
因为我们最终要访问的指针类型是函数指针,
所以我们可以先typedef
一下这个函数指针类型:
typedef void(*VFPTR)()
我们先取d的地址:&d
,
&d
此时的类型是Derive*
我们需要对其进行类型转换,
我们看到_vfptr
的类型是void**,
所以对其进行类型转换:(void**)&d
,
void*
在32位平台下是4个字节,64位平台下是8个字节,
所以对void**
解引用就可以访问头4/8个字节的空间。
然后对其解引用找到虚表:*(void**)&d
,
此时就拿到了虚表指针,但它的类型是void*
,
而虚表是一个函数指针数组,
所以我们再做一次类型转换就拿到了可以访问数组元素的虚表指针:(VFPTR*)(*(void**)&d)
,
所以我们就可以通过下标访问访问到函数指针:((VFPTR*)(*(void**)&d))[0] -> Derive::Func1()
,
我们还可以通过拿到的函数指针调用函数:((VFPTR*)(*(void**)&d))[0]()
。
所以现在我们可以通过下面的代码遍历虚表,
打印虚表中存放的函数指针,
并通过函数指针调用函数,
看看是哪个函数:
for (int i = 0; i < n; i++) // n是虚表中有几个函数指针
{
cout << ((VFPTR*)(*(void**)&d))[i] << "->";
((VFPTR*)(*(void**)&d))[i]();
}
这里有三个虚函数,所以n
就是3
,
运行结果如下:
验证了此前所说的。
我们还可以看一下基类对象的虚表:
多继承中的虚表
看下面的代码:
class Base1
{
public:
virtual void func1()
{ cout << "Base1::func1" << endl; }
virtual void func2()
{ cout << "Base1::func2" << endl; }
private:
int _b1 = 1;
};
class Base2
{
public:
virtual void func1()
{ cout << "Base2::func1" << endl; }
virtual void func2()
{ cout << "Base2::func2" << endl; }
private:
int _b2 = 2;
};
class Derive : public Base1, public Base2
{
public:
virtual void func1()
{ cout << "Derive::func1" << endl; }
virtual void func3()
{ cout << "Derive::func3" << endl; }
private:
int _d1 = 3;
};
此时派生类是继承了两个基类的派生类,
那它现在的对象模型是怎样的呢?
可以看到普通多继承的场景下它完整继承了两个基类的虚表指针,
两个基类中都有Func1
函数,
可以看到此时Func1
函数都被重写后的函数覆盖了。
那派生类它自己的虚函数呢?
这里直接给出结论:多继承派生类的虚函数放在第一个继承基类部分的虚函数表中。
所以第一个虚表中存放了三个函数指针,
分别指向Derive::Func1()
,Base1::Func2()
,Derive::Func3()
,
第二个虚表中存放了两个函数指针,
分别指向Derive::Func1()
,Base2::Func2()
。
所以对象模型如下:
可以通过打印虚表来验证一下。
这里直接对d
取地址可以拿到Base1::_vfptr
,
但是要怎么拿到Base2::_vfptr
呢?
我们可以直接让指针偏移sizeof(Base1)
个字节,
也就是把&d
改为(char*)&d + sizeof(Base1)
,
结果如下:
虚表的存储
我们现在再明确一下概念,
虚函数是函数,
和普通函数一样,
存放在代码段。
虚表是一个指针数组,
存放指向虚函数的指针。
而类实例化出来的对象中存放的是一个虚表指针,
是指向虚表的指针。
所以对象中虚表指针存放在哪是很明确的,
就看对象存放在哪,
对象在栈上,那它的虚表指针也在栈上,
对象在堆上,那它的虚表指针就在堆上。
那问题来了,虚表存在哪呢?
我们可以通过一个简单的比对来看看:
Derive d;
Derive* pd = new Derive;
cout << "栈: " << &d << endl;
Derive* pd = new Derive;
cout << "堆: " << pd << endl;
cout << "代码段: " << ((VFPTR*)(*(void**)&d))[0] << endl;
cout << "d的虚表地址: " << (void*)*(void**)&d << endl;
cout << "pd的虚表地址: " << (void*)*(void**)pd << endl;
栈、堆、代码段上的空间都是连续的,
我们我们可以将虚表的地址和它们进行比较:
通过对比可以发现虚表是存放在代码段的,
而且无论是临时对象还是动态开辟的对象,
都是共用一个虚表。
当一个c++程序编译成可执行程序之后,
此时虚表已经形成了,
和函数一样存放在代码段。
当我们实例化对象时,
对应的构造函数会对对象的虚表指针进行初始化,
将虚表的地址写入到虚表指针中,
所以虚表是编译完就有了的,
而虚表指针是运行时才有的。
多态的原理
前面我们看了虚函数表,
那这个虚函数表和多态调用有什么密不可分的关系吗?
下面以文章开头的那段代码为例进行讲解:
class Person
{
public:
virtual void BuyTicket()
{ cout << "原价买票" << endl; }
};
class Student : public Person
{
public:
virtual void BuyTicket()
{ cout << "半价买票" << endl; }
};
class Solider : public Person
{
public:
virtual void BuyTicket()
{ cout << "优先买票" << endl; }
};
void QueryPriority(Person* p)
{ p->BuyTicket(); }
int main()
{
Person p;
Student stu;
Solider solider;
QueryPriority(&p);
QueryPriority(&stu);
QueryPriority(&solider);
return 0;
}
首先我们有一个基类指针,
当我们使用这个指针去调用虚函数时,
会去访问这个基类指针指向的对象的虚表指针,
然后通过虚表指针找到虚表,
在虚表中找到对应的函数然后调用。
如果调用的函数不存在于虚表中,
则会发生报错,
最简单的就是使用基类指针去调用派生类自己定义的虚函数。
在文章虚表的存储部分最后说了,
对象的虚表指针是在构造函数中初始化的,是运行时才有的,
在程序运行期间,
根据具体拿到的类型确定程序的具体行为,
调用具体的函数,
这就是所谓的动态绑定,也叫动态多态。
我们可以通过汇编代码看一下普通调用和多态调用时的区别:
与动态绑定对应的是静态绑定,也叫静态多态,
我们常用的函数重载就是一种静态多态,
是在在程序编译期间确定了程序的行为。
几个小问题
-
内联函数(inline)可以是虚函数吗?
可以。
不过编译器就忽略inline属性,
这个函数就不再是inline,
因为虚函数要放到虚表中去。
-
静态成员可以是虚函数吗?
不能。
因为静态成员函数没有this指针,
使用类型::成员函数的调用方式无法访问虚函数表,
所以静态成员函数无法放进虚函数表
-
构造函数可以是虚函数吗?
不能。
因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
如果构造函数定义成虚函数,
那想调用构造函数就要去虚函数表中寻找,
而虚表指针还没有初始化,
就找不到构造函数了。
-
对象访问普通函数快还是虚函数更快?
首先如果是通过"对象.函数"的方式去调用,
是一样快的。
如果是指针对象或者是引用对象,
则调用的普通函数快,
因为调用虚函数时还需要先到虚函数表中去查找。