【C++进阶】多态:虚函数、重写/覆盖、纯虚函数与虚函数表底层原理
文章目录
1.多态的概念
多态(Polymorphism)的概念:多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态)。
编译时多态(静态多态)主要是函数重载和函数模板,通过传不同类型的参数可以调用不同的函数,通过参数不同达到多种形态。之所以叫编译时多态,是因为实参传给形参的参数匹配是在编译时完成的。一般把编译时归为静态,运行时归为动态。
运行时多态(动态多态):完成某个行为(函数),传不同的对象就会完成不同的行为,就达到多种形态。(比如买票这个行为,普通人是全价买票,学生为优惠买票)
2.多态的定义及实现
2.1 多态的构成条件
多态是一个继承关系下的类对象调用同一个函数产生了不同的行为。(比如Student继承了Person,Person对象全价买票,Student对象优惠买票)
2.1.1 实现多态的两个必须重要条件
- 必须是基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,并且完成了虚函数的重写/覆盖
说明:要实现多态效果,第一必须是基类的指针或者引用,因为只有基类的指针或者引用才能既指向基类对象又指向派生类对象;第二派生类必须对基类的虚函数进行重写/覆盖,重写或覆盖后,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到。
2.1.2 虚函数
类成员函数前面加 virtual 修饰,那么这个成员函数被称为虚函数。注意非成员函数不能使用 virtual 修饰
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票全价" << endl;
}
};
基类的 virtual 不能省略
派生类的虚函数可以不加 virtual(但不规范)
2.1.3 虚函数的重写/覆盖
虚函数的重写/覆盖:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数
注意:在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时,虽然也可以构成重写(因为基类的虚函数被继承后在派生类依旧保持虚函数属性),但这种写法不规范。
#include<iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-打折" << endl;
}
};
void Func(Person* ptr)
{
// 这里可以看到虽然都是 Person 指针 Ptr 在调用 BuyTicket
// 但是跟 ptr 没关系,而是由 ptr 指向的对象决定的
ptr->BuyTicket();
}
// 也可以使用引用
//void Func(Person& people)
//{
// people.BuyTicket();
//}
int main()
{
Person ps;
Student st;
Func(&ps);
Func(&st);
return 0;
}
2.1.4 多台场景下的一道选择题
以下程序的输出结果是什么()
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
#include<iostream>
class A
{
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
virtual void test() { func(); };
};
class B :public A
{
public:
void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main()
{
B* p = new B;
p->test();
return 0;
}
答案:B
因为在多态调用时,重写的虚函数是父类的声明部分+子类的实现部分,本质是重写虚函数的实现
如果是普通调用,输出结果就是 B->0

2.1.5 虚函数重写的一些其他问题
- 协变
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用时,称为协变。(可以是自己的类也可以是其他类,只要满足基类和派生类的关系即可)
#include<iostream>
using namespace std;
class A{};
class B : public A {};
class Person
{
public:
// 这里的 A* 可以换成 Person*
// 子类中 BuyTicket 的返回值可以换成 Student*
virtual A* BuyTicket()
{
cout << "买票-全价" << endl;
return nullptr;
}
};
class Student : public Person
{
public:
virtual B* BuyTicket()
{
cout << "买票-打折" << endl;
return nullptr;
}
};
void Func(Person* ptr)
{
ptr->BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(&ps);
Func(&st);
return 0;
}

- 析构函数的重写
基类的析构函数为虚函数,此时派生类的虚函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写。虽然基类和派生类析构函数的名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一成 destructor,所以基类的析构函数加了 virtual 修饰,派生类的析构函数就构成重写。
编译器会将析构函数的名称统一成 destructor,但不构成隐藏
隐藏:编译时决定,只看源代码中的函数名(析构函数源代码中函数名不同,所以不构成隐藏)
重写:编译时生成虚函数表,运行时通过虚函数表调用对应函数
下面的代码可以看到,如果 ~A() 不加 virtual ,那么 delete p2 时只调用 A 的析构函数,没有调用 B 的析构函数,就会导致内存泄露问题,因为 B 中在释放资源。
#include<iostream>
using namespace std;
class A
{
public:
virtual ~A()
//~A()
{
cout << "~A()" << endl;
}
};
class B : public A
{
public:
~B()
{
cout << "~B()->delete:" << _p << endl;
}
protected:
int* _p = new int[10];
};
int main()
{
A* p1 = new A;
A* p2 = new B;
delete p1;
delete p2;
return 0;
}
基类的析构函数加 virtual 时:
基类的析构函数不加 virtual 时:
可以看出,基类的析构函数如果不加 virtual,B中的资源就无法释放,就会造成内存泄露
所以基类中的析构函数建议设计为虚函数
2.1.6 override 和 final 关键字
C++对虚函数重写的要求比较严格,但有些情况下由于疏忽,比如函数名写错或参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果时才来 debug 会得不偿失,因此 C++11 提供了 override,可以帮助用户检测是否重写。
如果不想派生类重写这个虚函数,可以用 final 修饰。(在>=三层继承时,中间某层需要继承而其子类不需要,可以使用final 。基类/中间派生类的虚函数用 final 修饰后,后续所有派生类都不能再重写该虚函数)
// “Benz::Drive”: 包含重写说明符“override”的方法没有重写任何基类方法
class car
{
public:
// 这里的函数名为 Dirve,与派生类的函数名不同
virtual void Dirve()
{}
};
class Benz : public car
{
public:
virtual void Drive() override
{
cout << "Benz" << endl;
}
};
int main()
{
return 0;
}
2.1.7 重载/重写/隐藏的对比
重载/重写/隐藏都是函数名相同的函数之间的关系
3.纯虚函数和抽象类
在虚函数后面写上 =0,则这个函数为纯虚函数,纯虚函数不需要定义实现(可以定义但没必要),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写无法实例化出对象。
#include<iostream>
using namespace std;
// Car 包含纯虚函数,是抽象类
class Car
{
public:
virtual void Drive() = 0;
};
class Benz : public Car
{
public:
virtual void Drive()
{
cout << "Benz" << endl;
}
};
class BMW : public Car
{
public:
virtual void Drive()
{
cout << "BMW" << endl;
}
};
int main()
{
//编译报错:“Car”: 无法实例化抽象类
//Car car;
// 父类的指针指向子类,调用子类的虚函数
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
return 0;
}

4.多态的原理
4.1 虚函数表指针
下面编译为32位程序的运行结果是什么()
A. 编译报错 B.运行报错 C. 8 D. 12
#include<iostream>
using namespace std;
class Base
{
public:
virtual void func1()
{
cout << "func1()" << endl;
}
protected:
int _b = 1;
char _ch = 'x';
};
int main()
{
Base b;
cout << sizeof(b) << endl;
return 0;
}
运行结果:
上面题目的运行结果是12bytes,在对象 b 中,除了 _b 和 _ch 成员,还多了一个 __vfptr 放在对象的前面,对象中的这个指针叫做虚函数表指针(简称虚表指针,v 代表 virtual,f 代表function)。一个含有虚函数的类中至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放在这个类对象的虚函数表中,虚函数表也简称虚表。虚函数表是一个函数指针数组。

4.2 多态的原理
4.2.1 多态是如何实现的
通过下图可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数地址,而是运行时指向对应对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类的虚函数。
ptr 指向 Person 对象,调用的是 Person 的虚函数
ptr 指向 Student 对象,调用的是 Student 的虚函数
#include<iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
private:
string _name;
};
class Student :public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-打折" << endl;
}
private:
string _id;
};
class Soldier :public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-优先" << endl;
}
private:
string _codename;
};
void Func(Person* ptr)
{
// 这里可以看到虽然都是 Person 的指针 ptr 在调用 BuyTicket
// 但是和 ptr 没关系,是由 ptr 的对象决定的
ptr->BuyTicket();
}
int main()
{
Person ps;
Student st;
Soldier sr;
Func(&ps);
Func(&st);
Func(&sr);
return 0;
}
总结
多态的原理:指向谁调用谁
运行时,到指向对象(父类对象或切割出的子类中的父类对象)的虚函数表中找到对应的虚函数的地址进行调用
4.2.2 静态绑定与动态绑定
- 对不满足多态条件(指针或者引用调用+虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定
- 满足多态条件的函数调用是在运行时绑定,也就是在运行时指向对象的虚函数表中找到调用函数的地址,叫做动态绑定
4.2.3 虚函数表
- 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。(同类型对象虚表共用,不同类型对象虚表各自独立)
- 派生类由两部分组成:继承下来的基类和自己的成员。一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类成员也是独立的
- 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址
- 派生类的虚函数表中包含:(1)基类的虚函数地址,(2)派生类重写的虚函数地址完成覆盖,(3)派生类自己的虚函数地址 三个部分
- 虚函数本质是一个存虚函数指针的指针数组,一般情况这个数组后面放了一个 0x00000000标记(在 VS 中)
- 虚函数和普通函数一样,编译好后是一段指令,都是存在代码段的。只是虚函数的地址又存放在了虚表中
- 虚函数表的存储地址:C++标准没有规定。在 VS 中虚函数表是存放在代码段(常量区)的。
子类的虚表:
- 先拷贝父类的虚表
- 子类重写虚函数就覆盖虚表中对应虚函数的地址
- 子类新增的虚函数就添加到虚函数表的末尾
更多推荐


所有评论(0)