目录
- **前言**
- [**1. 何为多态**](#1. 何为多态)
-
- [1.1 **编译时多态**](#1.1 编译时多态)
-
- [1.1.1 函数重载](#1.1.1 函数重载)
- [1.1.2 模板](#1.1.2 模板)
- [**1.2 运行时多态**](#1.2 运行时多态)
-
- [**1.2.1 虚函数**](#1.2.1 虚函数)
- [**1.2.2 为什么要用父类指针去调用子类函数**](#1.2.2 为什么要用父类指针去调用子类函数)
- [**2. 注意**](#2. 注意)
-
- [**2.1 基类的析构函数应写为虚函数**](#2.1 基类的析构函数应写为虚函数)
- [**2.2 构造函数不能设为虚函数**](#2.2 构造函数不能设为虚函数)
- **本文参考**
前言
在学习 c++ 的虚函数这一块时,总有许多疑惑,诸如:
- 多态有什么用?
- 为何要用父类指针去调用子类函数?
- 编译时多态与运行时多态有何区别?
- ... ...
如果你跟我一样有这些疑惑,那么本文非常适合你。
- 阅读本文之前你至少理解什么是 继承。
- 本文从概念、语法层面讲解多态与虚函数,不会讲解在 c++ 中,它的底层是如何实现的。
- 本文重点在解决上述几个问题,不会过多设计其 c++ 语法
1. 何为多态
多态,比较宽泛的定义为:
对于同一行为,不同的对象有不同的表现
比如 "买门票" :同样是买门票这一行为,但 普通人全价,学生半价,儿童免费。
将其定义放在程序中来看,相当于:同一函数,不同对象调用将返回不同结果。
说到这里,如果你没了解过 "运行时多态",那么你可能第一反应是:函数重载。
没错,重载 也是多态的一种 ,它属于 编译时多态
。
1.1 编译时多态
在 c++ 中,"编译时"(静态)、"运行时"(动态)这两个词常常会被提起。
编译时多态
,在编译时就能确定对象的行为,调用的是哪个函数。这通常通过 函数重载
与 模板
等机制实现。
因为本文重点不在这里,所以编译时多态只是简单介绍
1.1.1 函数重载
在 C++ 中,编译器通过 函数签名
来区分不同的函数。
函数签名:由函数名称、参数列表(包括参数类型、参数顺序)组成。
也就是说,对于同名函数:
- 如果仅仅是返回值类型不同,那么他们将被视为同一函数
- 如果参数列表不同(包括参数类型、参数顺序),那么他们将被视为不同函数
1.1.2 模板
cpp
template <typename T>
void fun(T t);
那么在编译时,编译器就会推导出 T 的实际类型,使得模板实例化,生成相应的代码。
它允许程序员编写与类型无关的代码。
1.2 运行时多态
运行时多态性
允许程序在运行时根据对象的实际类型来调用相应的方法,而不是根据编译时引用的类型。
在 C++ 中,运行时多态常见于类的继承中:
通过父类的指针或引用,调用父类和子类中的同名函数时,根据所指向对象的类型,确定应调用哪个函数。
读完这句话,你可能有两个疑惑:
- 如何实现上述提到的运行时多态?(只是语法层面)
- 为什么要用父类的指针去调用子类的函数?直接通过对应的子类,自己调用自己的成员函数不行吗?
下面来一一解答:
1.2.1 虚函数
在一个类的成员函数前加上 virtual
关键字,那么这个函数被称为 虚函数
,它能被子类重写,是实现运行时多态的重要手段。
- 重写:在子类中定义一个与父类的虚函数名称相同的函数
- 纯虚函数:只有声明,没有定义的虚函数,常在函数末尾加上 '= 0' 来标识。它要求所有的子类都必须重写此方法。
- 有父类:
cpp
class Father
{
public:
virtual void vfun() { } // 虚函数
// virtual pvfun() = 0; -> 纯虚函数
};
- 其子类为:
cpp
class Son1 : public Father
{
public:
void vfun() { cout << "Son1::vfun()" << endl; } // 重写了 Father::vfun()
};
class Son2 : public Father
{
public:
void vfun() { cout << "Son2::vfun()" << endl; } // 重写了 Father::vfun()
};
- 下面通过父类指针调用虚函数 vfun()
父类指针可以用子类指针初始化,反之不一定成立。具体原因与 c++ 对象内存布局 有关,这里不展开
cpp
int main()
{
Father* f0 = new Father();
Father* f1 = new Son1();
Father* f2 = new Son2();
f0->vfun();
f1->vfun();
f2->vfun();
return 0;
}
- 运行程序:
可以看到,使用父类指针去调用虚函数,那么在运行时,可以根据指针所指的实际对象,调用对应的函数。也就是说,通过 virtual
关键字,我们实现了运行时多态。
倘若把 Father::vfun() 的 virtual
关键字去掉,那么运行结果为
对比来看,去掉 virtual
后,即便父类指针指向不同类型,但是调用的函数仍然是父类的函数。
因此,从这个结果来看,也证实了 virtual
是实现运行时多态的重要手段。
那么,它有何用?解决下面的问题,那么这个问题也迎刃而解。
1.2.2 为什么要用父类指针去调用子类函数
【以王者荣耀游戏为例】
王者荣耀是一款 5v5 竞技游戏,其中有许多英雄,每个英雄 (hero) 有自己的价格 (_price),当你买了某个英雄时 (buy),那么你的金币 (money) 将会减少对应的数量。
下面用程序简单模拟这个过程:
创建基类 Hero:有虚函数 buy(),其有四个派生类都重写了基类的虚函数buy():LiBai、HuaMuLan、HanXin、GuanYu
为了代码简洁,就不添加 _price 成员。
cpp
int your_money = 1000;
class Hero
{
public:
virtual void buy() = 0;
};
class LiBai : public Hero
{
public:
void buy() { your_money -= 20; cout << "Buying LiBai" << endl; }
};
class HuaMuLan : public Hero
{
public:
void buy() { your_money -= 60; cout << "Buying HuaMuLan" << endl; }
};
class HanXin : public Hero
{
public:
void buy() { your_money -= 40; cout << "Buying Hanxin" << endl; }
};
class GuanYu : public Hero
{
public:
void buy() { your_money -= 70; cout << "Buying GuanYu" << endl; }
};
下面用一个全局方法来模拟买英雄这一行为,如果不采用父类指针,那么我们就需要多个重载函数:
cpp
void buy(LiBai* x) { x->buy(); }
void buy(HuaMuLan* x) { x->buy(); }
void buy(HanXin* x) { x->buy(); }
void buy(GuanYu* x) { x->buy(); }
但是采用父类指针,只需要写一个:
cpp
void buy(Hero* x) { x->buy(); }
而且,倘若有一天出了新英雄 ChuangPu
cpp
class ChuangPu : public Hero
{
public:
void buy() { your_money -= 1000; cout << "Buying ChuangPu" << endl; }
};
对于不采用父类指针的代码,除了添加上述代码,还需要加入函数:
cpp
void buy(ChuangPu* x) { x->buy(); }
但是采用父类指针的代码不需要修改全局函数 buy。
这还仅仅只是针对一个全局方法,倘若你的代码有许多类似的函数,那么修改代码的工作量很大
因此你也能看出:使用多态,能增加程序的可扩展性,即当程序需要修改或增加功能时,需要改动或增加的代码较少。
说完这些,下面来看一些注意事项:
2. 注意
2.1 基类的析构函数应写为虚函数
我们知道,当一个对象的生命周期结束时,那么在回收这块内存时会先调用它的析构函数,以防内存泄漏。
现有如下的两个类:
Father ~Father() Son int* _s Son(int) ~Son()
如果不将基类 Father 的虚构函数设为 虚函数:
cpp
class Father
{
public:
~Father()
{
cout << "~Father()" << endl;
}
};
class Son : public Father
{
public:
Son(int n) : _s{ new int(n) } { }
~Son()
{
delete _s;
cout << "~Son()" << endl;
}
private:
int* _s;
};
现在通过父类指针,用子类初始化:
cpp
int main()
{
Father* s = new Son(1);
delete s;
return 0;
}
那么程序运行结果为:
是的,子类的析构函数没有被调用。
这是由于 delete 操作内部调用了 s 的析构函数,但是 s 的类型为 Father*,并且其析构函数不是虚函数,因此只会调用父类的析构函数。具体原因与 c++ 虚函数的底层实现有关(虚函数表),本文不涉及
那么将父类的析构函数设为虚函数,在运行得:
子类的析构函数也调用了。
2.2 构造函数不能设为虚函数
在上面的例子中,倘若你将 Son 类的构造函数设为虚构函数,编译代码时会报错:
其原因之一在于:调用时机的问题。
构造函数是在对象被创建时调用的,当对象被创建成功后,内存分配了,它的类型才能被确定。
但虚函数的调用是在运行时根据对象的实际类型来确定的,而上面提到,对象类型的确定发生在构造函数被调用之后。
如果将构造函数设为虚函数,不就相当于创建对象后才能调用构造函数嘛。两者矛盾。
当然,更具体的原因还是涉及到虚函数的底层实现:虚函数表