C++之路:多态与虚函数
目录
- 什么是多态
- 动态多态的语法使用
- 多态的实现原理
- 纯虚函数与抽象类
- 虚析构函数
什么是多态
多态顾名思义,就是同一事物在不同条件下表现出多种形态。在前面的C++学习中我们提到过函数的重载(参考这篇博客),函数的重载本质上也可以看成一种多态(相同的函数名,不同的函数实现)。函数的重载又称静态多态,另外还有一个称为动态多态,是这篇介绍的重点。
请看下面这个例子,引入话题:
#include <iostream>
#include <cstring>
using namespace std; class Base_Class{
protected:int id;
public:Base_Class(int i=0){id = i;cout << "Base_Class constructor called." << endl;cout << "id = " << id << endl;}void print_id(){cout << "Base_Class id = " << id << endl;}~Base_Class(){cout << "Base_Class destructor called." << endl; } };class Derived_CLass1 : public Base_Class{public:Derived_CLass1(int i=0): Base_Class(i){cout << "Derived_CLass1 constructor called." << endl;}void print_id() {cout << "Derived_CLass1 id = " << id << endl;}~Derived_CLass1(){cout << "Derived_CLass1 destructor called." << endl; }};
int main() {Base_Class Base_obj(10);Derived_CLass1 Derived_obj(20);cout << "对于Base_obj和Derived_obj,分别调用print_id()方法:" << endl;Base_obj.print_id();Derived_obj.print_id();Base_Class *p = new Derived_CLass1(30);cout << "基类的指针指向派生类的对象,调用print_id()方法:" << endl;p->print_id();delete p; return 0;
}
忽略构造与析构函数打印的信息,print_id函数的输出为:
对于Base_obj和Derived_obj,分别调用print_id()方法:
Base_Class id = 10
Derived_CLass1 id = 20基类的指针指向派生类的对象,调用基类的print_id()方法:
Base_Class id = 30
第一个测试是基类和派生类各自定义为相应的对象(指针也一样),结果毋庸置疑对象各自调用了类内的print_id()方法,这个通过函数重载的机制很好理解(基类派生类相当于两个不同的作用域)。
第二个测试就显得很有意思了。当使用基类的指针指向派生类时,即使指针指向的是派生类,但调用的print_id()方法却是基类的。
基类的指针能指向派生类对象,而派生类的指针不能指向基类对象。因为派生类是基类的扩展,拥有比基类更多的变量与方法,用派生类指针指向基类对象会产生访问越界的问题。
如果想要在使用基类指针调用方法时,动态根据基类指针指向的对象调用对应的方法,就要使用到动态多态的特性了!
将基类和派生类的print_id()声明为虚函数,并调用动态多态的特性如下:
#include <iostream>
#include <cstring>
using namespace std; class Base_Class{
protected:int id;
public:Base_Class(int i=0){id = i;cout << "Base_Class constructor called." << endl;cout << "id = " << id << endl;}virtual void print_id(){cout << "Base_Class id = " << id << endl;}~Base_Class(){cout << "Base_Class destructor called." << endl; } };class Derived_CLass1 : public Base_Class{public:Derived_CLass1(int i=0): Base_Class(i){cout << "Derived_CLass1 constructor called." << endl;}virtual void print_id() {cout << "Derived_CLass1 id = " << id << endl;}~Derived_CLass1(){cout << "Derived_CLass1 destructor called." << endl; }};int main() {Base_Class *p = new Derived_CLass1(30);cout << "基类的指针指向派生类的对象,由于多态性,调用的是派生类的print_id()方法:" << endl;p->print_id();delete p; return 0;
}
输出结果为:
基类的指针指向派生类的对象,由于多态性,调用的是派生类的print_id()方法:
Derived_CLass1 id = 30
此刻我们发现,使用基类的指针指向派生类时,由于动态多态的特性,使得基类指针最终调用了派生类的print_id()方法。
总结:
多态分为两种类型:
-
静态多态(编译时多态):通过函数重载和模板实现,在编译期确定调用哪个函数。
-
动态多态(运行时多态):通过虚函数和继承实现,在运行时根据对象实际类型决定调用哪个函数。
动态多态的语法使用
动态多态的使用,首先要明确使用场景:**使用基类指针或者引用指向派生类的对象,并且派生类内重写了虚函数的实现 **。
语法要点为:
在实际使用过程中还需要遵从以下的规范:
- 在派生类中使用
virtual关键字 + 函数声明 + override
重写派生类虚函数。 - 构造函数不能是虚函数,并且基类的析构函数最好声明为虚函数。
多态的实现原理
多态是一种复杂机制,实现多态需要消耗额外的资源。我们从编译器的视角入手:
首先编译器观察到了一个类内定义有虚函数,那么编译器会为这个类生成一个虚函数表,这个表存储的内容是这个类内所有虚函数的地址(所有虚函数的地址按照声明的顺序排列,只要找到虚函数的地址就能执行函数)。对每个类,编译器都会生成一份虚函数表(vtable),也就意味着,类的所有对象都会共享同一份虚函数表。虚函数表被存储在程序的只读数据区域,而每个类中的虚函数的具体实现则被存进行程序段(这与普通函数一致)。
然后对于每个含有虚函数类的对象,编译器会在其内存空间的开始处添加一个指针变量,这个指针称为虚指针(vptr),指向虚函数表(vtable)。下面验证一下vptr的存在:
#include <iostream>using namespace std;class A {
public:A() {cout << "A()" << endl;}~A() {cout << "~A()" << endl;}
};class B {
public:B() {cout << "B()" << endl;}virtual ~B() {cout << "~B()" << endl;}
};int main() {cout << sizeof(A) << endl;cout << sizeof(B) << endl;return 0;
}
第一个类A没有任何虚函数,第二个类B的析构函数被定义为虚函数。输出的结果为:
1 #因为sizeof要求类最低就是1
8 #vptr占用了八个字节的存储空间
以上都是实现多态的准备工作,下面重点来了:
当编译普通成员函数时,编译器直接将其替换为函数的地址。当编译虚函数时,则将其替换为vptr + offset,由前面准备的内容可以知道,vptr + offset正是对应虚函数的地址。当程序运行时,执行到虚函数时会自动查找当前对象的vptr值(也就是虚函数表地址)根据实际指向的虚函数表来决定调用的是哪个函数。这就是所谓的运行时多态
当基类指针指向的是派生类对象时,基类指针虽静态类型为基类,但通过它访问的vptr实际指向的是派生类对象的虚函数表,因此调用的是派生类重构的方法。
纯虚函数与抽象类
纯虚函数(Pure Virtual Function)是 C++ 中实现抽象类和接口的核心机制,它强制派生类必须提供该函数的实现。如果一个类内包含了纯虚函数那么它就称为抽象类。抽象类不能实例化!并且抽象类的派生类也必须实现所有的虚函数。因此抽象类常用于定义接口规范。
纯虚函数的定义方式如下:
class Shape {
public:virtual double area() const = 0; // 纯虚函数
};
虚析构函数
故名思意,就是在虚构函数前添加virtual
关键字使之成为虚函数。这么做的意义何在呢?
虚析构函数主要用于解决基类指针指向派生类对象时的资源释放问题。我们知道,基类类型的指针即使指向了派生类,但其静态类型仍然是基类,此时若基类析构函数非虚,则通过该指针删除对象只会调用基类析构函数,导致派生类资源泄漏。将基类析构函数声明为虚函数可确保调用完整的析构链(先派生类后基类)
虚析构函数的原理是:虚析构函数通过动态绑定实现,在删除对象时,根据实际对象类型调用对应的析构函数。
因此,总结下来的规范就是:
- 任何可能被继承的基类都应声明虚析构函数
- 即使基类无需显式析构逻辑,也应添加空实现的虚析构函数(避免潜在风险)
下面举一个常犯的错误:
class Base { public: ~Base() {...} }; // 非虚析构
class Derived : public Base {...};
Base* obj = new Derived();
delete obj; // 仅调用Base::~Base()
由于Derived是存储在堆上的,必须手动释放。此时如果使用delete方法会默认调用父类的析构函数,从而造成子类的资源泄露。
正确的基类析构方式应该为:
class Base { public: virtual ~Base() {...} }; // 虚析构
至此关于面向对象编程的三大特性:封装、继承、多态都已介绍完毕。内容过多有,水平有限,很多地方未免考虑不周,有任何的疑问和错误欢迎大家给我留言指正!