c++如何理解多态与虚函数

前言

在学习 c++ 的虚函数这一块时,总有许多疑惑,诸如:

  • 多态有什么用?
  • 为何要用父类指针去调用子类函数?
  • 编译时多态与运行时多态有何区别?
  • … …

如果你跟我一样有这些疑惑,那么本文非常适合你。

  • 阅读本文之前你至少理解什么是 继承。
  • 本文从概念、语法层面讲解多态与虚函数,不会讲解在 c++ 中,它的底层是如何实现的。
  • 本文重点在解决上述几个问题,不会过多设计其 c++ 语法

1. 何为多态

多态,比较宽泛的定义为:

对于同一行为,不同的对象有不同的表现

比如 “买门票” :同样是买门票这一行为,但 普通人全价,学生半价,儿童免费。

将其定义放在程序中来看,相当于:同一函数,不同对象调用将返回不同结果。

说到这里,如果你没了解过 “运行时多态”,那么你可能第一反应是:函数重载。
没错,重载 也是多态的一种 ,它属于 编译时多态


1.1 编译时多态

在 c++ 中,“编译时”(静态)、“运行时”(动态)这两个词常常会被提起。

编译时多态,在编译时就能确定对象的行为,调用的是哪个函数。这通常通过 函数重载模板 等机制实现。

因为本文重点不在这里,所以编译时多态只是简单介绍

1.1.1 函数重载

在 C++ 中,编译器通过 函数签名 来区分不同的函数。

函数签名:由函数名称、参数列表(包括参数类型、参数顺序)组成。

也就是说,对于同名函数:

  • 如果仅仅是返回值类型不同,那么他们将被视为同一函数
  • 如果参数列表不同(包括参数类型、参数顺序),那么他们将被视为不同函数

1.1.2 模板

template <typename T>
void fun(T t);

那么在编译时,编译器就会推导出 T 的实际类型,使得模板实例化,生成相应的代码。

它允许程序员编写与类型无关的代码。


1.2 运行时多态

运行时多态性 允许程序在运行时根据对象的实际类型来调用相应的方法,而不是根据编译时引用的类型。

在 C++ 中,运行时多态常见于类的继承中:

通过父类的指针或引用,调用父类和子类中的同名函数时,根据所指向对象的类型,确定应调用哪个函数。

读完这句话,你可能有两个疑惑:

  1. 如何实现上述提到的运行时多态?(只是语法层面)
  2. 为什么要用父类的指针去调用子类的函数?直接通过对应的子类,自己调用自己的成员函数不行吗?

下面来一一解答:


1.2.1 虚函数

在一个类的成员函数前加上 virtual 关键字,那么这个函数被称为 虚函数,它能被子类重写,是实现运行时多态的重要手段。

  • 重写:在子类中定义一个与父类的虚函数名称相同的函数
  • 纯虚函数:只有声明,没有定义的虚函数,常在函数末尾加上 ‘= 0’ 来标识。它要求所有的子类都必须重写此方法
  • 有父类:
class Father
{
public:
   virtual void vfun() {  }  // 虚函数
   // virtual pvfun() = 0; -> 纯虚函数
};
  • 其子类为:
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++ 对象内存布局 有关,这里不展开

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 成员。

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; }
};

下面用一个全局方法来模拟买英雄这一行为,如果不采用父类指针,那么我们就需要多个重载函数:

void buy(LiBai* x) 	  { x->buy(); }
void buy(HuaMuLan* x) { x->buy(); }
void buy(HanXin* x)   { x->buy(); }
void buy(GuanYu* x)   { x->buy(); }

但是采用父类指针,只需要写一个:

void buy(Hero* x) { x->buy(); }

而且,倘若有一天出了新英雄 ChuangPu

class ChuangPu : public Hero
{
public:
	void buy() { your_money -= 1000; cout << "Buying ChuangPu" << endl; }
};

对于不采用父类指针的代码,除了添加上述代码,还需要加入函数:

void buy(ChuangPu* x) { x->buy(); }

但是采用父类指针的代码不需要修改全局函数 buy。

这还仅仅只是针对一个全局方法,倘若你的代码有许多类似的函数,那么修改代码的工作量很大

因此你也能看出:使用多态,能增加程序的可扩展性,即当程序需要修改或增加功能时,需要改动或增加的代码较少

说完这些,下面来看一些注意事项:


2. 注意

2.1 基类的析构函数应写为虚函数

我们知道,当一个对象的生命周期结束时,那么在回收这块内存时会先调用它的析构函数,以防内存泄漏。
现有如下的两个类:

Father
~Father()
Son
int* _s
Son(int)
~Son()

如果不将基类 Father 的虚构函数设为 虚函数:

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;
};

现在通过父类指针,用子类初始化:

int main()
{
    Father* s = new Son(1);
    delete s;
	return 0;
}

那么程序运行结果为:
在这里插入图片描述
是的,子类的析构函数没有被调用。

这是由于 delete 操作内部调用了 s 的析构函数,但是 s 的类型为 Father*,并且其析构函数不是虚函数,因此只会调用父类的析构函数。具体原因与 c++ 虚函数的底层实现有关(虚函数表),本文不涉及

那么将父类的析构函数设为虚函数,在运行得:
在这里插入图片描述
子类的析构函数也调用了。


2.2 构造函数不能设为虚函数

在上面的例子中,倘若你将 Son 类的构造函数设为虚构函数,编译代码时会报错:
在这里插入图片描述
其原因之一在于:调用时机的问题。
构造函数是在对象被创建时调用的,当对象被创建成功后,内存分配了,它的类型才能被确定。
但虚函数的调用是在运行时根据对象的实际类型来确定的,而上面提到,对象类型的确定发生在构造函数被调用之后。
如果将构造函数设为虚函数,不就相当于创建对象后才能调用构造函数嘛。两者矛盾。

当然,更具体的原因还是涉及到虚函数的底层实现:虚函数表


本文参考

  1. C++ 一篇搞懂多态
  2. C++——来讲讲虚函数、虚继承、多态和虚函数表
  • 12
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值