本期我们学习的是 C++ 中的虚函数。
过去的几期,我们一直在讨论类、面向对象编程、继承这些内容,所有的这些内容,包括本期我们将要学习的虚函数,对整个面向对象的概念都非常重要。
虚函数能干什么呢?
虚函数允许我们在子类中重写方法。
假设我们有两个类 A 和 B,B 是 A 派生出来的,也就是 B 是 A 的子类。如果我们在 A 中创建一个方法,标记为 virtual,我们可以选择在 B 类中重写那个方法,让它做其他的事情。
像之前一样,我们通过一个例子来解释今天的知识点。
例子时间
我创建了两个类,一个是 Entity ,它唯一拥有的是一个名为 GetName 的公共方法,它会返回一个字符串,我们让它返回 “Entity”。
还有另外一个类 Player,它是 Entity 类的子集,我们增加了这个类的内容,存储了一个名字,它提供一个构造函数,允许我们指定一个名字;然后给它提供了一个叫 GetName 的方法,在这种情况下,它会返回这个名字,它的名字就是成员变量。
我们来看看如何使用上面这些设定。
我们在主函数中创造了一个 Entity,然后打印 GetName();再创建一个 Player,将这个 Player 命名为 “ganlan”,同样打印 Player 的名字。
我们可以不删除这些对象,因为程序终止后,它们自然就会被 delete 了,在这种情况下使用 delete 没什么用。
我们运行程序,结果看起来就是那样的,第一行 “Entity” ,第二行 “ganlan”。
然而,如果我们使用多态的概念,那么到目前为止我们在这里写的所有内容都有问题了。
如果我们指向一个 Player,却把它当作一个 Entity,就会遇到一些问题。
看看下面的操作。
我们创建一个名为 entity 的变量,它的类型为 Entity,它被赋值为 p,p 是上面的一个指向 Player 的指针,是一个 Player 类型,现在我却把它指向了 Entity,然后打印它的名字,你觉的结果是什么样呢?
出乎意外。
我们希望它还是Player,因为即使我们指的是这个 Entity,但它实际上是一个 Player。
可能更合适的例子是这个。
我有一个 PrintName 函数,参数是一个 Entity。
现在我们有了一个函数,它可以接受任何 Entity 类型的参数,你可以看到,我们不会得到任何的编译错误。
当我们试图将 p 传递给函数时,因为 p 是一个 Entity,Player 是 Entity,在函数里面我们做的就是调用 GetName 方法,我们期望的是,在主函数中调用的部分,参数为 Entity 类型时,GetName 用于 Entity,而参数为 Player 类型时,GetName 用于 Player。
然而,运行代码之后你会发现它打印了两次 Entity。
为什么会这样呢?
发生这种情况的原因时,在我们声明函数时,我们的方法通常在类内部起作用。然后当调用方法的时候,会调用属于该类型的方法。
我们看这个 PrintName 函数,它的参数是 Entity,这意味着当我们调用 GetName 函数时,如果是在 Entity 里面,那么它会从 Entity 类中找这个叫做 GetName 的函数。
就是这样。
然而,我们希望 C++ 能意识到一点:我在这里传递的 Entity 实际上是 Player,所以,请调用 Player 中的 GetName 函数。
这时候,虚函数就该出现了。
虚函数引入了一种叫做 Dynameic Dispatch(动态联编)的东西,它通常通过 V 表(虚函数表)来实现编译。
V 表就是一个表,它包含基类中所有虚函数的映射,这样我们可以在它运行时,将它们映射到正确的覆写(overwrite)函数。
简单起见,现在你只需要知道,如果你想覆写一个函数,必须将基类中的基函数标记为虚函数。
我们回到代码中继续看一下。
我在基类 Entity 类中 GetName 函数前面使用了 virtual 这个关键字,这可以告诉编译器,——嘿,为这个函数生成 V 表吧,这样,如果它被重写了,你可以指向正确的函数。
我们运行代码试试看。
我们得到了期望的结果。
现在,我们可以做的另一件事:使用在 C++11 引入的覆写函数标记的关键字 override。
这个不是必须的,无论有没有这个关键字,程序都会正常工作,但是我还是建议你这样做。因为首先这会让你的程序更具有可读性,阅读程序的时候我们可以知道这实际上是一个覆写的函数;它还可以帮助我们预防 Bug 的发生,比如拼写错误之类的,如果这个时候函数名称不小心写成小写了,我们就会得到一个错误,因为在基类中没有这样的函数可以覆写,或者如果我们试图覆写一个非虚函数,它也会给我们一个错误,这是可以帮助我们解决问题的方法。
最后的话
这就是虚函数的本质,但是很遗憾的一点是,虚函数并不是没有额外的开销的,有两种与虚函数相关的运行时成本。
首先,我们需要额外的内存来存储 V 表,这样我们就可以分配到正确的函数,包括基类中要有一个成员指针指向 V 表;其次,每次我们调用虚函数时,我们需要遍历这个表来确定要映射到哪个函数,这些是额外的性能损失。
由于这些成本,很多人不喜欢使用虚函数,但是我使用的过程中,没有遇到开销特别大的情况。就我个人而言,我经常使用它,可能在一些嵌入式平台上,CPU性能很差的时候需要避免使用虚函数。除此之外,我们真的不能说因为它影响性能而不去使用,因为它的影响真的很小,小到几乎可以忽略不计。
这就是虚函数,如果你有什么想法,欢迎在评论区交流,下期见。