什么是虚拟函数——C++ 开发人员应该如何充分利用?

Blog
Author:
Amir KirshAmir Kirsh
Published On:
12月 21, 2022
Estimated reading time:
1 minutes

目录

 

什么是虚拟函数?

虚拟函数是基类中声明的成员函数,且使用者期望在派生类中将其重新定义。

那么,在 C++ 中,什么是虚拟函数呢?

在 C++ 中,通常将虚拟函数用于实现运行时多态,该特性由 C++ 提供,适用于面向对象编程。我们将在下文更为详细地讨论运行时多态。

不论函数调用所使用的指针或引用类型如何,虚拟函数最为重要的工作是确保函数调用正确。

虚拟函数使用规则

C++ 虚拟函数必须遵循几个关键规则:

  • 在基类中使用 virtual 关键词来声明函数
  • 虚拟函数不能为静态函数
  • 为实现运行时多态,应使用指针或引用来访问虚拟函数
  • 对于基类和派生类而言,此类函数的原型应该相同(允许使用协变式返回类型,我们将在下文进行讨论)
  • 如果基类中含有虚拟函数,则应该使用虚拟析构函数,防止析构函数调用错误

用 C++ 运行虚拟函数的示例

让我们看看虚拟函数在 C++ 中的运行情况。

class Pet {
public:
    virtual ~Pet() {}
    virtual void make_sound() const = 0;
};

class Dog: public Pet {
public:
    virtual void make_sound() const override {
        std::cout << "raf raf\n";
    }
};

class Cat: public Pet {
public:
    virtual void make_sound() const override {
        std::cout << "mewo\n";
    }
};

int main() {
    Cat mitzi;
    Dog roxy;
    Pet *pets[] = {&mitzi, &roxy};
    for(auto pPet: pets) {
        pPet->make_sound();
    }
}

解释一下上述示例。

Pet 这是一个通用基类。但是我们仍然希望存在一个 make_sound 函数,这样,我们就能在不知道 pet 类型的情况下,在 pet 上调用 make_sound。仅在进行编译时,我们才能知道 pet 类型。因此,我们在基类中声明虚拟函数 make_sound,用 =0 来将其表示为由派生类实现的纯虚拟函数。

然后,再由 Dog 和 Cat 来真正实现该函数。实现函数期间,我们添加关键词 override,这样,编译器就能确保函数签名与基类中的签名相匹配。

在 main 中,我们可以在 Pet 指针上调用 make_sound,而无需在编译时知道该指针指向哪种 pet。我们会在运行时,根据实际存在的对象,实现所需函数。

我们必须要强调,这是一个非常简单的示例。我们也有其他解决方案应对这一简单示例(例如,为 pet’s sound 持有数据成员,并避免使用虚拟函数)。但我们想要展示虚拟函数的实现过程,因此不对其他解决方案进行额外展示。通常情况下,会使用虚拟函数为派生类中的不同行为建模,而相应行为不能用简单数据成员来建模。

协变式返回类型

我们提到过,若要实现虚拟函数,派生类函数的签名必须与基类中的签名相匹配。唯一允许的区别是在返回类型上,只要派生类的返回类型是基类返回的派生类型即可。让我们看看下面的示例:

class PetFactory {
public:
    virtual ~PetFactory() {}
    virtual Pet* create() const = 0;
} 

class DogFactory: public PetFactory {
public:
    virtual Dog* create() const override {
        return new Dog();
    }
}; 

class CatFactory: public PetFactory {
public:
    virtual Cat* create() const override {
        return new Cat();
    }
};

int main() {
    std::vector<Pet*> pets;
    DogFactory df;
    CatFactory cf;
    PetFactory* petFactory[] = {&df, &cf};
    for(auto factory: petFactory) {
        pets.push_back(factory->create());
    }
    for(auto pPet: pets) {
        pPet->make_sound();
    }
    for(auto pPet: pets) {
        delete pPet;
    }
}

在上述示例中,PetFactory 创建函数仅能知道它可以返回 Pet*,但使用协变式返回类型,DogFactory 和 CatFactory 则能知道更为具体的内容,这种虚拟函数的实现方式仍然行之有效。

C++ 中使用虚拟函数的优点

现在,如果您已经花费时间研究过 C++,可能会注意到,不需要由虚拟函数来重新定义派生类中的基函数。

但存在这样的巨大区别,使得虚拟函数不可或缺:虚拟函数覆写基类函数,从而实现运行时多态。

从本质上讲,多态指一个函数或对象以不同方式执行的能力,具体情况视使用方式而定。这属于面向对象编程的关键特性——结合其他众多特性,使得 C++ 作为编程语言而有别于 C 语言。

代码更为灵活、更为通用

这是贯穿所有多态程序的主要优点:根据运行时已知的调用对象,通过允许以不同方式执行函数调用,能使程序更为灵活而通用。如此一来,运行时多态便能从真正意义上使您的代码反映现实——特别是各场景中的对象(或人、动物、形状)并不总是以相同方式执行。

代码可复用

通过使用虚拟函数,我们可以将只应实现一次的通用操作和不同子类中可能有所不同的具体细节区分开来。试想以下示例:如果我们希望实现 prism 类层次结构,则需要在各派生类中分别计算基面积,但可以使用派生类实现基面积计算,从而在基类中实现体积函数。实现代码如下:

class Prism {
    double height;
public:
    virtual ~Prism() {}
    virtual double baseArea() const = 0;
    double volume() const {
        return height * baseArea();
    }
    // ...
};

class Cylinder: public Prism {
    double radius;
public:
    double baseArea() const override {
         return radius * radius * std::numbers::pi
    }
    // ...
};

契约式设计

术语“契约式设计”指如果代码设置有执行设计的契约,会比只通过文档来执行设计要好得多。虚拟函数,特别是纯虚拟函数,因其决定了在派生类中以不同方式重新实现特定操作的设计决策,可将其视为契约式设计工具。

虚拟函数的局限性

虚拟函数功能极为强大,但它们并非毫无缺点。开始使用虚拟函数前,您应该注意以下事项:

性能

无论是在运行时性能还是在内存方面,虚拟函数成本都要比普通函数高。

内存部分通常冗余,取决于实现方式,但最为常见的是每个对象都有一个额外内部指针。这并不是什么大问题,除非我们有数以百万计的小对象,这些小对象的额外指针可能会引起内存问题。

函数的运行时性能成本不是一次跳转而是两次跳转,或者如果可以内联函数,性能成本就是两次跳转而不是零次跳转。虚拟函数需要跳转到虚拟函数表,再跳转到函数本身。这种额外跳转增加了 CPU 指令缓存中指令未准备就绪的概率,因此,这两次跳转并非唯一成本。

最后,如果您需要实现多态,与其他替代方案相比,性能方面的额外成本通常也在情理之中。然而,若要将第一个虚拟函数添加到类中,通常需要考虑额外成本。

设计问题

继承,特别是虚拟函数,会引起设计问题。继承层次结构设计糟糕可能会导致类膨胀和类之间关系异常。

从构造函数和析构函数调用虚拟函数的规则也会影响您的设计。从构造函数和析构函数调用的任何虚拟函数都不是多态函数,这样一来,有时需要将操作从构造函数转移到 init 虚拟函数。

为避免糟糕设计,应切记继承和多态并非是应对任何问题的最佳解决方案。请观看 Sean Parent 的演讲”Inheritance is the Base Class of Evil“,深入了解相关内容。

难以调试,容易出错

讽刺的是,虚拟函数面临的挑战之一是缺乏弹性。

由于需要遵循调用流程,调试虚拟函数调用可能会变得稍显混乱。一般来说,遵循函数调用并不十分困难,但根据对象类型,在遵循隐藏调度方面,仍然需要进行额外工作。调试器会自行纠正错误,但决定断点位置可能会变得更加困难。

至于更容易出错,在某些情况下,不应调用虚拟函数的基类实现;而在某些情况下,应在开始时调用,有时也在结束时调用。由于忘记调用基类实现,或是在错误的地方、不需要的时候调用,使用虚拟函数极其容易出错。

虚拟函数的替代方案

仅使用数据成员

第一种替代方案是尝试并对基于简单数据成员的不同行为进行建模。如果不同类型的唯一区别是 sound,那就将其转换为数据成员,在构造时进行初始化,这样就没有问题了。但在许多情况下,行为更加复杂,需要不同的实现方式。

变体

另一种方案是使用 std::variant 和 std::visit,特别是待支持的不同类型已知,且列表不会太长时,二者可能相关。您可以点击此处此处阅读更多关于该方案的信息。

函数式编程

您可以传递待执行的操作,将其作为函数对象的 lambda,或者作为旧有 C 样式的函数指针,随后对其进行建模,而无需在类层次结构中对不同操作进行建模。通过该方法,您能将数据模型和可能想要执行的操作区分开来,这带来了极高灵活性。

静态多态

静态多态是一种基于模板的方法,用于获取多态动态,但基于编译时已知的实际想要使用的类型。例如,您可能希望代码同时支持 UDPConnection 和 TCPConnection,但在编译时,您可能想要知道使用 UDPConnection 或 TCPConnection 的具体流程。基于模板的静态多态可以实现更佳性能。

一些替代技术可能会导致项目编译时间变长。我们认为,特别是当您使用 Incredibuild 来加速构建时,这不会影响您的决策设计。首先选择合适的设计方案,然后使用正确工具来缩减编译时间即可。

Incredibuild 加速您的 C++ 虚拟函数

如果您想在不严重拖累编译速度和构建进程的情况下,从虚拟函数中受益,您就需要强大的计算能力作为后援。

Incredibuild 能够做到这一点。通过在虚拟机在本地网络上分配编译任务,Incredibuild 从根本上加快了 C++ 的编译速度。此外,Incredibuild 能与时下主流编译器和构建系统无缝集成,包括 Visual Studio、Qt Creator 和 Clang。

如此一来,虚拟函数便能具备极高的灵活性和效率,而无需花费时间来等待代码编译。

常见问题

  1. 什么是 C++ 的虚拟函数?

虚拟函数是基类中声明的成员函数,将在派生类中重新定义。在 C++ 中,使用虚拟函数来实现运行时多态。

  1. 虚拟函数存在哪些问题?

在运行时性能和内存使用方面,相比于普通函数,虚拟函数会造成更多影响。此外,虚拟函数会产生基于继承层次结构的设计问题,导致类膨胀和关系异常。最后,虚拟函数由于存在函数调用问题,往往难以进行调试。由于调用顺序的不可预测性,使用虚拟函数更容易引发错误。

  1. C++ 中,虚拟函数有何替代方案?

是的,为了实现更好的设计或者是获得更佳的性能,您可能要考虑一些替代方案。但鉴于 C++ 程序员普遍使用虚拟函数,您应将其视为工具包内的一项工具,必要时加以使用。

如果您选择了另一种替代方案,比如基于模板的静态多态,切勿让较长的编译时间影响您的设计方案。确保选用合适的工具来加速构建进程,如果您没有使用 Incredibuild,请了解我们的解决方案,看看 Incredibuild 在减少编译时间方面可实现的惊人效果。