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

目录

  • **前言**
  • [**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.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 类的构造函数设为虚构函数,编译代码时会报错:

其原因之一在于:调用时机的问题。

构造函数是在对象被创建时调用的,当对象被创建成功后,内存分配了,它的类型才能被确定。

但虚函数的调用是在运行时根据对象的实际类型来确定的,而上面提到,对象类型的确定发生在构造函数被调用之后。

如果将构造函数设为虚函数,不就相当于创建对象后才能调用构造函数嘛。两者矛盾。

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


本文参考

  1. C++ 一篇搞懂多态
  2. C++------来讲讲虚函数、虚继承、多态和虚函数表
相关推荐
张飞的猪9 天前
什么是多态?面向对象中对多态的理解
多态·oop
雨中豪杰ˇ17 天前
C++ 多态
c++·多态·final关键字·虚函数重写·override关键字·深入理解虚函数表
Trouvaille ~25 天前
【C++篇】虚境探微:多态的流动诗篇,解锁动态的艺术密码
c++·面试·性能优化·多态·面向对象编程·代码优化·虚函数
M-x_y1 个月前
C++多态
开发语言·c++·多态
weixin_632077631 个月前
c++抽象类 abstract class
开发语言·c++·多态
心怀花木1 个月前
【C++】多态
c++·多态
景天科技苑1 个月前
【Golang】Go语言接口与多态
开发语言·后端·golang·接口·多态·go语言接口·go语言多态
乔没乔见Joe1 个月前
在多态的方法调用中为什么会出现“左边编译左边运行”的现象?多态创建的对象到底是谁属于父类还是子类?通过深扒集合remove方法调用理解其原理
java·开发语言·多态·arraylist·collection集合·编译与运行·问题与报错
敲上瘾2 个月前
多态的使用和原理(c++详解)
开发语言·数据结构·c++·单片机·aigc·多态·模拟
GoppViper2 个月前
golang学习笔记28——golang中实现多态与面向对象
笔记·后端·学习·golang·多态·面向对象