目录
先决条件
- 了解 C++ 中的多态 这个概念
本文中父类和基类同义,子类和派生类同义
多态,运行时多态与 virtual 关键字
在 C++ 中,要实现多态有两种大的方式,一种是编译时多态或者静态绑定,这是一种静态多态的实现方式,它主要包括了重载和模板两种方式;另一种是运行时多态或者叫做动态绑定和后期绑定,也称之为动态多态,它的实现方案就是利用虚函数来达到运行时多态的效果。
而虚函数在 C++ 中,通过 virtual
关键字,让其标注在函数上来告诉编译器这是一个虚函数。
多态,运行时多态与虚函数
那么什么是多态呢? 就是一个函数在不同的场景下,表现出不同的状态(行为,特性等)。
下面举两个例子,第一个例子比较偏生活。比如你是一家公司的 CEO,你手下有销售部,运营部,技术部三个部门,你对三个部门下达一个指令 干活了
,三个部门虽然接到的是相同的指令,但是实际上三个部分真正执行的是不同的任务,销售部的人去外面跑销售,运营部的人负责产品的运行与营销,技术部的人负责开发新产品和维护旧产品。
第二个例子则贴近编程语言本身。我们知道很多语言都有 +
操作符,当 +
操作符的两边是数字时,则完成数字的数学加法操作,而当 +
操作符的两边是字符串时,则有时(具体看该语言是否支持用 +
连接两个字符串)可以完成字符串的连接操作,如下是一个示例。
// 数字的加法操作
int main()
{
int a = 3;
int b = 5;
int c = a + b;
cout << c << endl; // 8
return 0;
}
// 字符串的连接操作
int main()
{
string firstName = "Yu ";
string lastName = "Zhang";
string name = firstName + lastName;
cout << name << endl; // Yu Zhang
return 0;
}
简单介绍了多态的概念,但本篇主要介绍的是运行时多态,所以现在再来说一下什么是运行时多态。
我们应该知道,一个 C++ 程序的执行分为预处理,编译,汇编,链接然后运行这样五个步骤,而程序真正运行(跑起来)是发生在运行这个步骤中,根据我们的需要,有时我们需要在这个阶段实现多态效果,就称之为运行时多态,在 C++ 中,我们通过虚函数来完成这个目的。
虚函数与 virtual 关键字
接下来,我们通过一个代码示例来演示虚函数如何实现运行时多态的。
在下面的例子中,我创造了一个类 人类
,有两个派生类(亚洲人
,欧洲人
)继承自人类
,每个类中都有一个同名,同参数列表的成员函数,叫做 YourRace()
用来打印调用的对象的种族。
此外,我们还创建了一个 SayYourRace(Human &h)
的非成员函数,它有一个用来接收 Human
类的参数,这样我们传入任何派生自 Human
的类对象就都可以打印了。
#include <iostream>
using namespace std;
#include <iostream>
using namespace std;
class Human
{
public:
void YourRace()
{
cout << "我们都是人类" << endl;
}
};
class Aisan : public Human
{
public:
void YourRace()
{
cout << "我是亚洲人" << endl;
}
};
class European : public Human
{
public:
void YourRace()
{
cout << "我是欧洲人" << endl;
}
};
// void SayYourRace(Human *h) 也是一样的效果
void SayYourRace(Human &h){
h.YourRace();
}
int main()
{
Aisan a;
European e;
SayYourRace(a); // 我们都是人类
SayYourRace(e); // 我们都是人类
}
上面的代码中,我们创建了两个对象 a,e 分别代表亚洲人和欧洲人,然后把它们分别传入 SayYourRace(Human &h)
函数中,我们显然期望它能够分别打印我是亚洲人和我是欧洲人这两句话,然而,事实胜于雄辩,最终控制台输出的两条语句都是我们都是人类。
为什么会这样?明明我们传入的都是 Aisan
和 European
的对象啊!!!
事实上,因为在 C++ 中,基类指针指向派生类的对象,默认情况下,C++ 采用静态绑定(编译时绑定,前期绑定),在这种情况下传入的 a 和 e 仍然被编译器视为 Human
的对象。
那么如果要解决这个问题,即想要调用派生类的 YourRace
函数,为了达到这个目的,我们需要在运行时再查看使用的对象,在代码上的做法是,在基类的 YourRace
函数前面增加 virtual
关键字即可,它会告诉编译器,这里执行动态绑定。
它的具体语法是这样的:virtual void YourRace(){...};
#include <iostream>
using namespace std;
class Human
{
public:
// 在基类的同名函数中加上 virtual 关键字
virtual void YourRace()
{
cout << "我们都是人类" << endl;
}
};
class Aisan : public Human
{
public:
void YourRace()
{
cout << "我是亚洲人" << endl;
}
};
class European : public Human
{
public:
void YourRace()
{
cout << "我是欧洲人" << endl;
}
};
void SayYourRace(Human &h){
h.YourRace();
}
int main()
{
Aisan a;
European e;
SayYourRace(a); // 我是亚洲人
SayYourRace(e); // 我是欧洲人
}
可以看到,本段代码仅仅是新增了一个 virtual
关键字,就实现了调用派生类成员函数的功能。
综上,这里我们总结一下动态多态实现的必要条件
- 一定是继承的场景下
- 子类方法覆盖父类的虚函数
- 在创建对象时,是父类指针指向子类对象
单独谈谈 virtual 关键字的作用
可以看到,增加了 virtual
关键字后,实际上,是在运行时才确定对象的类型,执行动态链接,然后绑定函数调用,所以这又叫动态绑定。
比如在上面的第二段代码中,ha
实际指向的对象是 a
,a
的类型是 Aisan
,所以最终调用的是 Aisan
类的 YourRace
函数。
在基类中使用 virtual
关键字,表明基类要求派生类重写这个函数。当使用指针或者应用调用虚函数时,调用被动态绑定到运行时才发生,也就是根据被绑定的对象的类型不同,执行具体的版本,就像上面所展示的那样。
【注】非虚函数的解析发生在编译过程。虚函数的解析发生在运行时。
其他内容
虚函数的使用规则
好的,上面我们已经谈过了虚函数,virtual 关键字的作用,下面来罗列一下它们使用时的注意事项,当然对于每个条款背后的细节,我不会在这里展示,有需要的朋友可以自行了解。
-
重载(overload),重写(override)与隐藏(hide),当我罗列出这三个词的时候,你能不能真正区别它们的含义和用法?
在本文中,与虚函数相关的是重写(override)。重写的本质是,函数参数列表和函数名完全一样,修改函数体的内容。
但是有时,不可避免派生类的同名函数中,我们会误写(比如新增一个参数),编译器无法检测出这个问题,只有运行时才会发现有问题,于是我们可以通过添加关键字
override
,来告诉编译器帮我们检查重写的函数有没有问题。 -
virtual
不能修饰静态成员 -
virtual
不能修饰构造函数,但可以修饰析构函数,并且用于多态目的的基类通常都应该定义一个virtual
的析构函数。(关于这一点,你可以查看《Effective C++》07 条款,获得更详细的内容。) -
派生类继承的基类里面的虚函数,在派生类中隐式的,也是虚函数,也就是说派生类不需要显式的手写一个
virtual
,但在**《C++ 面向对象高效编程》**一书中,作者还是推荐大家显式标注 -
虚函数不能使用模板,但普通的成员函数可以使用模板
class A
{
public:
// 这是错误的
template<typename T>
virtual void exampleWithVirtualFunc(T a) = 0;
// 这是正确的
template<typename T>
void exampleWithMemberFunc(T a)
{
// ...
}
}
虚函数使用建议
- 如果基类存在的目的是为了多态,那么最好将析构函数声明为
virtual
。这里的原因在于,我们实现多态的时候,需要用基类指针指向派生类对象,若不对析构函数进行virtual
,那么在删除(delete
)基类指针的时候,实际调用的是基类的析构函数(静态绑定),因而未删除实际的派生类的对象,造成内存泄漏 - 如果一个多态目的的类不使用 virtual 函数,表示它不应该被当做基类
【注】基类也可以有其它的目的,比如很多容器类的基类就不存在 virtual - 我们知道在多态场景下,析构函数最好是声明为虚函数,那么为什么构造函数不能声明为析构函数呢?实际上,有这样的逻辑,在构造函数阶段,构造函数会生成指向虚函数表的指针
vptr
,然后vptr
指向虚函数表,如果构造函数也是虚函数,那么就成了先有鸡还是先有蛋的问题了
虚函数与纯虚函数
- 描述:C++ 中的纯虚函数(或抽象函数)是我们没有实现的虚函数(即我们不想给它写点什么代码,因为它反正会被子类对象的同名函数重写)
- 注意点
- 只要类存在一个纯虚函数,那么这个类就被称为抽象类,抽象类是不能被实例化的,它的目的就是让子类继承,子类实现。其实和现实也一样,比如我定义了一个抽象的标准,这个抽象的标准并不能产生具体的产品,只有实际让工厂根据自身的情况生产了产品才叫做实例化
- 为什么抽象类不能实例化:因为抽象类包含纯虚函数,而纯虚函数没有函数体,并非一个完整的函数,无法调用,也无法分配内存空间
class Shape {
protected:
int width, height;
public:
Shape( int a=0, int b=0)
{
width = a;
height = b;
}
// 基类中的纯虚函数,它必须被派生类实现
virtual int area() = 0;
};
参考与拓展
- C++ virtual function【javatpoint】
- Virtual Function in C++【geeksforgeeks】
- 为什么静态static成员函数不能成为virtual虚函数
- 析构函数可以为virtual,构造函数则不能。原因?
- Why Destructors in C++ can be virtual but constructors cannot be virtual?
- 为什么要用基类指针指向派生类对象?
- 《Primer C++》
- 区别:编译时与运行时(Combile-time Vs Runtime)
- Virtual Destructor:为什么析构函数可以是,也最好是 virutal 的。
- override Keyword in C++ :virtual 与 override 搭配使用。
- 《Effective C++》
- 父类指针指向子类对象
- 《C++ 面向对象高效编程》
- 对象切割
- C++ Polymorphism with Example