Lesson33 多态
33.1 指向派生对象基类的指针和引用
我们可以设置直接指向派生对象的指针和引用,这是符合直觉的。
1 | Derived derived{ 5 }; |
但在之前的学习中,我们知道派生类实际上是由基类部分和派生类部分组成的,那么我们可不可以设置一个指向派生类的基类指针或引用呢?
确实可以:
1 | Derived derived{ 5 }; |
您可以使用指向派生类对象的指针或引用来代替指向其基类的指针或引用。例如,您可以将指向派生类对象 derived
的指针或引用传递给接收基类 Base
的指针或引用的函数。
您不需要使用显式转换来实现这一点;编译器将执行隐式转换。您可以将派生类指针隐式转换为指向可访问且无歧义的基类。您还可以将派生类引用隐式转换为基类引用。
但别高兴过早。这两个引用和指针确实指向派生类,但它们仅仅指向派生类的基类部分。
换句话说,它们只能访问 Base
类以及 Base
类的父类(如果有)的成员,Derived
类的派生部分对它们是不存在的。
不能隐式地将基类对象的指针或引用转换为派生类对象的指针或引用。
为什么我们需要将指针或引用指向基类呢?直接指向派生对象不是更好?
首先,假设你需要一个非成员函数,获取从同一基类派生的派生类属性并打印。如果使用派生类,由于每个派生类的类型必定不同,所以你就需要写大量的重载函数来接收所有可能的派生类。但由于它们的基类相同,因此只需要写一个接收基类类型的指针 / 引用的函数即可。
另外,如果需要将派生类中同一意义的数据聚合成一个数组,使用派生对象是不可能的,因为类型不同。但由于它们具有同一基类,因此创建一个基类类型的数据即可。
但这又产生一个问题:基类的成员打印函数不能获取派生类的属性!
下一节我们就会介绍解决方案。
32.2 虚函数
虚函数 (virtual function) 是一种特殊的成员函数。当被调用时,它会解析为实际引用或指向的对象的实际类型的函数的最派生版本。换句话说,如果使用基类指针或引用处理派生类,对虚函数的调用将调用派生类中定义的行为。
虚函数的查找是自底向上的(从最底层向基类回溯),直到找到第一个实现的版本。如果中间类没有符合要求的重写,则会继续向基类查找,一直到继承链顶端。
为什么能做到 “自底向上”?这涉及到 “虚函数表” 概念,在之后解释多态原理时会介绍。
如果派生函数与基版本的函数具有相同的声明(名称、参数类型以及是否为 const)和返回类型,则认为它匹配。这被称为重写 (override)。
派生类函数的声明必须与基类虚函数的声明完全匹配,以便使用派生类函数。如果派生类函数具有不同的参数类型,程序可能仍然可以编译,但虚函数将不会按预期解析。
在正常情况下,虚函数及其重载的返回类型必须匹配,但有一个例外:协变返回类型。这就是为什么 cppreference 不要求重写虚函数的返回类型相同。
要使函数成为虚函数,只需在函数声明前放置 virtual
关键字即可。
1 | class Base |
此时,通过引用 rBase
调用 rBase.getName()
时,程序会向下寻找派生类中有没有 getName()
函数。在这里,程序会调用 Derived::getName()
虚函数解析仅在通过类对象的指针或引用调用成员函数时才起作用。直接在对象上调用虚成员函数(而不是通过指针或引用)将始终调用属于该对象相同类型的成员函数。
1 | C c{}; |
如果基类中的一个函数被标记为虚函数,那么在派生类中所有匹配重写形式的函数也将隐式地被认为是虚函数,即使它们没有明确地标记为虚函数。
反之则不成立 —— 派生类中重写的虚函数并不会隐式地使基类函数成为虚函数。
按照派生类的构造顺序,应避免在构造函数或析构函数中调用虚函数。因为构造基类时,派生类还未创建;析构基类时,派生类早已销毁:这两种情况都只会调用基类版本函数。
某些现代编译器可能会报告有关具有虚函数和可访问的非虚析构函数的错误。如果是这种情况,请向基类添加虚析构函数。
之后会讨论为何会出现虚析构函数。
因为虚函数仅对类对象的函数进行调用,所以不能将非成员或静态函数声明为虚函数。
非常罕见的情况下,您可能希望忽略函数的虚拟化。此时使用限定名称查找(使用作用域解析运算符 ::
显式指定)选择基类函数即可。
1 | // Calls Base::getName() instead of the virtualized Derived::getName() |
32.3 虚函数的属性
和关键字不同,指定符只在特定的上下文中具有特定意义,脱离使用场景就不是一个关键字。
32.3.1 override
指定符
正如上一节所提到的,一个派生类的虚函数只有在它的签名和返回类型完全匹配的情况下才被认为是重写。这可能导致意外的问题,即原本打算重写的函数实际上并没有重写。例如:
1 | class A |
这里的意图是使用虚函数来访问 B::getName1()
和 B::getName2()
。然而,由于 B::getName1()
接受不同的参数(short
而不是 int
),它不被认为是 A::getName1()
的重写。
实际上,这里 B::getName1()
会把 A::getName1()
遮蔽掉。在查找函数时程序不会查找到 A
类的虚函数。
派生类的具有相同名称但参数列表不同的函数不会重写基类的同名函数,而是隐藏它:当未限定名称查找检查派生类的范围时,查找会找到该派生类的函数定义,而不继续检查基类。(cppreference)
virtual function specifier 在这里提供的示例可以尝试分析一下。
My Answer
1 | d_as_d.f(); // Error: lookup in D finds only f(int) // 由于遮蔽,程序仅在 D 类中查找函数 |
更微妙的是,由于 B::getName2()
是 const
类型而 A::getName2()
不是,B::getName2()
也不被认为是 A::getName2()
的重写。
因此虚函数均解析为 A
基类版本的函数。
为了帮助判断虚函数是否被正确重写,可以使用 override
关键字,以告诉编译器该函数是重写。override
关键字放置在成员函数声明末尾(与函数级别的 const
相同的位置)。如果一个成员函数是 const
并且是重写,则 const
必须在 override
之前。
1 | class A |
如果一个标记为 override
的函数没有重写基类函数(或应用于非虚函数),编译器将标记该函数为错误。override
不是强制性的,强扭的瓜不甜。
override
确保该函数是虚函数,并且正在重写基类中的对应函数。如果这不是真的,程序将是不良形式(将生成编译时错误)。(cppreference)
因为使用 override
指定符没有性能损失,并且有助于确保你实际上覆盖了你认为要覆盖的函数,所有重写虚函数都应该使用 override
指定符进行标记。
此外,因为 override
指定符暗示了虚函数,所以不需要使用 virtual
关键字标记使用 override
指定符的函数。
32.3.2 final
指定符
有时我们不希望别人能够重写虚函数或从类继承的情况,可以使用 final
指定符来告诉编译器强制实现。
如果用户尝试重写或继承已被指定为 final
的函数或类,编译器将给出编译错误。
要限制用户重写函数,final
指定符写在 override
指定符之后:
1 | class B : public A |
在上面的代码中,B::getName()
重写了 A::getName()
,这是可以的。但 B::getName()
有 final
指定符,这意味着对该函数的任何进一步重写都应被视为错误。C::getName()
尝试重写 B::getName()
,因此编译器将给出编译错误。
要防止从类继承,final
指定符写在类名之后:
1 | class B final : public A // note use of final specifier here |
32.3.3 协变返回类型
有一种特殊情况,派生类的虚函数重写可以具有与基类不同的返回类型,但仍被视为匹配的重写。如果虚函数的返回类型是指针或某个类的引用,则重写函数可以返回派生类的指针或引用。这些被称为协变返回类型 (covariant return type)。
1 |
|
在上面的例子中,我们首先调用 d.getThis()
。由于 d
属于 Derived
类类型,这会调用 Derived::getThis()
,它返回一个 Derived*
。然后使用这个 Derived*
来调用非虚函数 Derived::printType()
。
然后我们调用 b->getThis()
。变量 b
是一个指向 Derived
对象的 Base
指针。Base::getThis()
是一个虚函数,因此会调用 Derived::getThis()
。尽管 Derived::getThis()
返回一个 Derived*
,但由于 Base
版本的函数返回一个 Base*
,返回的 Derived*
被转换为 Base*
。因为 Base::printType()
是非虚函数,所以调用 Base::printType()
。
换句话说,只有当你最初用 Derived
对象类型调用 getThis()
时,你才会得到一个 Derived*
。
C++ 无法动态选择类型,因此你总是得到和调用函数的实际版本匹配的类型。
协变返回类型通常用于以下情况:虚成员函数返回指向包含该成员函数的类的指针或引用(例如,Base::getThis()
返回一个 Base*
,而 Derived::getThis()
返回一个 Derived*
)。然而,这并非绝对必要。协变返回类型可以在任何情况下使用,只要重写成员函数的返回类型是从基虚拟成员函数的返回类型派生出来的。
32.3 虚析构函数
即使析构函数没有被继承,如果一个基类声明它的析构函数为虚函数 ,派生类的析构函数也总是重写它。这使得可以通过基类的指针删除多态类型的动态分配的对象。
在处理继承时,你应该将任何显式的析构函数设置为虚函数。
1 |
|
应该将所有析构函数都设置为虚函数吗?
如果基类的析构函数没有被标记为虚,那么如果程序员后来删除了一个指向派生对象的基类指针,程序就有内存泄漏的风险(只销毁了基类部分,但没有销毁派生类部分)。避免这种情况的一种方法是将所有析构函数标记为虚。但应该这样做吗?
我们建议以下做法:如果一个类没有明确设计为基类,那么通常最好没有虚成员和虚析构函数。该类仍然可以通过组合使用。如果一个类被设计为用作基类和 / 或具有任何虚函数,那么它应该始终具有虚析构函数。
如果您希望您的类可以被继承,请确保您的析构函数是虚拟的且公开的。
如果您不打算让您的类被继承,请将您的类标记为 final
。这将防止其他类继承它,同时不对类本身施加任何其他使用限制。
32.4 多态的概念与原理
多态常被视为封装和继承之后,面向对象编程的第三个支柱。它的英文 Polymorphism 是一个希腊词,指 “多种形态”。
在面向对象编程范式中,多态往往表现为 “一个接口,多个功能”,比如我们之前学的函数重载以及刚学的虚函数。
多态有两种形式:
- 动态多态(或编译时多态,compile-time polymorphism)由编译器在程序运行前解析。包括函数重载解析和模板解析
- 静态多态(或运行时多态,runtime polymorphism)在程序运行时解析。包括虚函数解析
接下来我们将探究虚函数的实现原理
32.4.1 早绑定和晚绑定
C++ 程序从 main()
的顶部开始,按顺序执行语句。当遇到函数调用时,执行点将跳转到被调用函数的开始处。CPU 是如何知道这样做呢?
在编译时,编译器将 C++ 程序中的每个语句转换为一条或多条机器指令。每条机器指令都有自己的唯一顺序地址。对于函数来说也是如此 —— 当遇到函数时,它将被转换为机器语言,并分配下一个可用的地址。因此,每个函数最终都会有一个唯一的地址。
我们的程序包含许多名称(标识符、关键字等)。每个名称都有一组相关属性:例如,如果名称代表一个变量,那么这个变量有类型、值、内存地址等……
在一般编程中,绑定 (binding) 是将名称与这些属性关联的过程。函数绑定(或方法绑定)是确定与函数调用关联的函数定义的过程。实际调用已绑定的函数的过程称为调度 (dispatching)。
- 早绑定
编译器遇到的大多数函数调用是直接函数调用。直接函数调用是直接调用函数的语句。
在 C++ 中,当直接调用非成员函数或非虚成员函数时,编译器可以确定应该将哪个函数定义与调用匹配。这有时被称为早绑定(early binding。或静态绑定, static binding),因为它可以在编译时执行。然后编译器(或链接器)可以生成机器语言指令,告诉 CPU 直接跳转到函数的地址。
1 | mov edi, 5 ; copy argument 5 into edi register in preparation for function call |
函数模板和重载函数的调用也可以在编译时解析,因此它们也是早绑定模式。
- 晚绑定
在某些情况下,函数调用可能只能在运行时解析。在 C++ 中,这有时被称为晚绑定(late binding。或在虚拟函数解析的情况下,称为动态调度,dynamic dispatch),意味着任何在函数调用实际发生时,编译器或链接器不知道实际被调用的函数。
在 C++ 中,获取后期绑定的一种方法是通过使用函数指针。
函数指针是一种指向函数而不是变量的指针类型。函数指针指向的函数可以通过在指针上使用函数调用运算符 ()
来调用。
根据晚期绑定的标准定义,要调用的函数的地址在编译 / 链接时是未知的,必须在运行时动态发现。
但在 C++ 中,术语 “晚绑定” 通常用于描述虚函数解析(这更准确地称为 “动态调度”),但实际上具有早绑定的特征,因为地址已经在编译时计算好了,只是编译时一定要。因此,“晚绑定” 在这里似乎有点名不副实。
1 |
|
通过函数指针调用函数也称为间接函数调用。在实际调用 funcPtr
时,编译器在编译时并不知道正在调用哪个函数(函数地址要压入栈中后调用,但编译时调用堆栈还未创建)。在运行时,程序通过函数指针指向的地址进行间接函数调用,调用该地址上存在的任何函数。
1 | { |
这里使用 RIP 相对取址模式,其中 rip
是下一条指令的地址,与之配套的是已知的偏移值。rip+displacement
就得到了实际地址。
现代编译器已经可以看出这里的猫腻,因此 -O
会直接在编译时直接寻址函数并存储到寄存器中以调用,不需要使用相对取址。但这依然是一个
晚绑定稍微低效一些,因为它涉及一个额外的间接层。在早绑定中,CPU 可以直接跳转到函数的地址。在晚绑定中,程序必须读取指针中持有的地址,然后跳转到该地址。这涉及一个额外的步骤,使其稍微慢一些。然而,晚绑定的优点是它比早期绑定更灵活,因为它在运行时才决定调用哪个函数。
32.4.2 虚表
C++ 标准没有规定如何实现虚函数,但编译器通常使用一种称为虚表 (the virtual table, VTable) 的晚绑定形式来实现虚函数。
虚表是一个用于以动态 / 晚绑定方式解析函数调用的函数查找表。在 C++ 中,虚函数解析有时被称为动态调度。
早绑定 / 静态调度 = 直接函数调用解析
晚绑定 = 间接函数调用解析
动态绑定 = 虚函数调用解析
虚表的工作原理:
- 首先,每个使用虚函数(或从使用虚函数的类派生)的类都有一个相应的虚表。这个表只是一个编译时由编译器设置的静态数组。虚表包含每个可以被该类对象调用的虚函数的一个条目。表中的每个条目是一个指向该类可以访问的派生函数的函数指针。
- 其次,编译器还会添加一个隐藏的指针,它是基类的成员,我们将称之为
*__vptr
。当创建一个类对象时,*__vptr
会自动设置,以便它指向该类的虚表。与this
指针不同,*__vptr
是一个真正的指针成员。因此,它会增加每个类对象分配的内存大小。这也意味着*__vptr
会被派生类继承。
1 | class Base |
因为这里有 3 个类,编译器将设置 3 个虚表:一个用于 Base
,一个用于 D1
,一个用于 D2
。
当创建一个类对象时, *__vptr
被设置为指向该类的虚拟表。例如,当创建类型为 Base
的对象时, *__vptr
被设置为指向 Base
的虚拟表。当创建类型为 D1
或 D2
的对象时, *__vptr
被设置为分别指向 D1
或 D2
的虚拟表。
现在,让我们来谈谈这些虚表是如何填充的。以上面的 example 为例
- 基类对象的虚表很简单。类型为
Base
的对象只能访问Base
的成员。Base
无法访问D1
或D2
的函数。因此,function1
函数的条目指向Base::function1()
,function2
函数的条目指向Base::function2()
。 D1
类的虚表稍微复杂一些。类型为D1
的对象可以访问D1
和Base
的成员。然而,D1
已经重写了function1()
,使得D1::function1()
比Base::function1()
更派生。因此,function1
函数的条目指向D1::function1()
。D1
没有重写function2()
,所以function2
函数的条目将指向Base::function2()
。D2
类的虚表与D1
类似,只是function1
函数的条目指向Base::function1()
,function2
函数的条目指向D2::function2()
。
用图表示为:
总结起来就是:每个类中的 *__vptr
指向该类的虚表。虚表中的条目指向允许该类对象调用的最派生版本的函数。
1 | int main() |
因为 dPtr
是一个基类指针,它只指向 d1
的 Base
类部分。然而 *__vptr
也位于类的 Base
部分,因此 dPtr
可以访问这个指针。最后,注意 dPtr->__vptr
指向 D1
的虚函数表!因此,尽管 dPtr
是 Base*
类型,它仍然可以访问 D1
的虚函数表。
如果通过 Compiler Explorer 这样的编译器平台查看汇编代码,可以看到编译器对虚表的处理:
1 | vtable for D1: |
编译器只为 D1
类创建了虚表,但没有专门给 Base
类创建虚表!
调用虚函数比调用非虚函数慢,原因有几个:首先,我们必须使用 *__vptr
来获取适当的虚表。其次,我们必须索引虚表以找到要调用的正确函数。然后我们才能调用该函数。因此,我们需要执行 3 个操作来找到要调用的函数,而普通间接函数调用需要 2 个操作,直接函数调用只需要 1 个操作。然而,在现代计算机上,这种额外的时间通常微不足道。
32.5 纯虚函数与接口类
纯虚函数 (pure virtual function, OR abstract function) 一种特殊的虚函数。它没有任何函数体(包括 {}
),仅作为占位符使用。纯虚函数由派生类定义。
要创建纯虚函数,在基类中将函数赋值为 0:
1 | virtual int getValue() const = 0; // a pure virtual function |
请不要在创建纯虚函数时写包括 {}
在内的任何函数体语句!C++ 标准不允许这样做!
Visual Studio 允许同时声明并定义且无法禁用。如果你在使用 Visual Studio,请记住这不是 C++ 标准并且具有很大的兼容性问题!
当我们向类中添加一个纯虚函数时,实际上是在说,“由派生类来实现这个函数”。
使用纯虚函数有两个主要后果:
-
首先,任何包含一个或多个纯虚函数的类都变成了抽象基类 (abstract base class),这意味着它不能被实例化。编译器无法处理一个只有声明但无定义的函数。
-
其次,任何派生类都必须为这个函数定义一个函数体,否则该派生类也将被视为抽象基类。
为什么需要纯虚函数?纯虚函数使得基类不能被实例化,并迫使派生类在实例化之前定义这些函数。这有助于确保派生类不会忘记重新定义基类期望它们定义的函数。这在需要将函数放入基类,但只有派生类知道它应该返回什么时非常有用。
1 | class Animal // This Animal is an abstract base class |
具有纯虚函数的类也应该有一个虚析构函数。
就像普通虚函数一样,可以使用基类的引用(或指针)来调用纯虚函数:
1 | int main() |
我们可以创建具有定义的纯虚函数:
1 | class Animal // This Animal is an abstract base class |
在这种情况下,speak()
仍然被视为纯虚函数,因为其后面跟着 “= 0
”(即使它已经被定义了),Animal
仍然被视为抽象基类(因此不能实例化)。任何从 Animal
继承的类都需要为 speak()
提供自己的定义,否则它也将被视为抽象基类。
这里的定义提供了基类虚函数的默认实现。与一般虚函数不同,纯虚函数仍会强制派生类提供自己的实现。但如果派生类只需要基类实现,那么直接调用即可:
1 | std::string_view Animal::speak() const |
当提供纯虚函数的定义时,必须单独提供,不能内联。
析构函数可以是纯虚函数,但必须给出定义,以便在派生对象被析构时可以调用。
为了保持一致性,抽象类仍然有虚表。抽象类的构造函数或析构函数可以调用虚函数,并且需要解析到正确的函数(在同一类中,因为派生类要么尚未构造,要么已经销毁)。
对于具有纯虚函数的类的虚表条目通常将包含一个空指针,或者指向一个错误处理函数 __cxa_pure_virtual
(有时这个函数被命名为 __purecall
)。
1 | vtable for Animal: |
32.5.1 接口类
接口类 (interface class) 是一个没有成员变量的类,其中的所有函数都是纯虚函数!当您想定义派生类必须实现的功能,但将派生类如何实现该功能的细节完全留给派生类时,接口类非常有用。
接口类通常以 I
开头命名。以下是一个示例接口类:
1 |
|
不要忘记为接口类写一个虚析构函数,以便在删除接口指针时调用适当的派生析构函数。
任何从 IErrorLog
继承的类都必须提供所有三个函数的实现才能被实例化。
您可以派生一个名为 FileErrorLog
的类,其中 openLog()
在磁盘上打开一个文件,closeLog()
关闭文件,writeError()
将消息写入文件。您还可以派生另一个名为 ScreenErrorLog
的类,其中 openLog()
和 closeLog()
不做任何操作,writeError()
在屏幕上弹出一个消息框打印消息。
1 |
|
现在调用者可以传递任何符合 IErrorLog
接口的类。如果他们想将错误记录到文件中,他们可以传递 FileErrorLog
的实例。如果他们想将其显示在屏幕上,他们可以传递 ScreenErrorLog
的实例。
接口类因其易于使用、易于扩展和易于维护而变得极其流行。事实上,一些现代语言,如 Java 和 C#,已经添加了一个 interface
关键字,允许程序员直接定义接口类,而无需显式地将所有成员函数标记为纯虚函数。
32.6 虚基类
在 Lesson32 中,我们介绍了多继承中的 “菱形继承” 问题。当时我们使用作用域解析暂时解决了这个问题。现在我们将使用多态来解决。
1 |
|
如果你创建一个 Copier
类对象,默认情况下,你将会有两个 PoweredDevice
类的副本 —— 一个来自 Printer
,一个来自 Scanner
。这不难理解
但有时您可能只想让一个 PoweredDevice
的副本被 Scanner
和 Printer
共享。
要共享基类,只需在派生类的继承列表中插入 virtual
关键字(形似 “虚继承”)。这样就创建了一个所谓的虚基类 (virtual base class),这意味着只有一个基类对象。这个基类对象在继承树中的所有对象之间共享,并且它只构造一次。例如:
1 | class PoweredDevice |
现在,当你创建一个 Copier
类对象时,每个 Copier
将只有一个 PoweredDevice
的副本,这个副本将由 Scanner
和 Printer
共享。
这又带来了一个问题:如果 Scanner
和 Printer
共享一个 PoweredDevice
基类,那么谁负责创建它?
答案是 Copier
。Copier
构造函数负责创建 PoweredDevice
。
1 |
|
输出:
1 | PoweredDevice: 3 |
PoweredDevice
只被构造一次。
这里还有一些细节:
-
首先,对于最派生类的构造函数,虚基类总是先于非虚基类被创建(
PoweredDevice
位于输出的第一行),这确保了所有基类在派生类之前被创建。 -
其次,请注意,
Scanner
和Printer
的构造函数仍然调用了PoweredDevice
的构造函数。当创建Copier
的实例时,这些构造函数调用会被简单地忽略;然而,如果我们创建Scanner
或Printer
的实例,这些构造函数调用就会被使用,并且适用正常的继承规则。 -
然后,如果一个类继承了一个或多个具有虚拟基类的类,则最派生类负责构造虚拟基类,即使在单继承的情况下也是如此:如果
Copier
单继承自Printer
,而Printer
虚继承自PoweredDevice
,Copier
仍然负责创建PoweredDevice
。 -
最后,所有继承虚拟基类的类都将拥有一个虚表,即使它们在非虚继承时通常不会有虚表。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20vtable for Copier:
0
0
typeinfo for Copier
8 -
8 -
typeinfo for Copier
VTT for Copier:
24 vtable for Copier+
in-Copier+24 construction vtable for Scanner-
in-Copier+24 construction vtable for Printer-
48 vtable for Copier+
construction vtable for Scanner-in-Copier:
0
0
typeinfo for Scanner
construction vtable for Printer-in-Copier:
8 -
0
typeinfo for Printer
由于 Scanner
和 Printer
都是从 PowerDevice
虚派生出来的,所以 Copier
将只有一个 PowerDevice
子对象。Scanner
和 Printer
都需要知道如何找到这个唯一的 PowerDevice
子对象,以便它们可以访问其成员(因为毕竟它们是从它派生出来的)。这通常是通过一些虚表魔法(实际上存储了每个派生类到 PowerDevice
子对象的偏移量)来实现的。