🥒虚函数和多态
2022-6-10
| 2023-8-2
0  |  阅读时长 0 分钟
type
status
date
slug
summary
tags
category
icon
password
Property

C++ 的动态绑定发生在虚成员函数通过基类的引用或指针调用时发生。由于直到运行时才知道哪个函数版本被调用,虚函数必须总是被定义。通常,不使用的函数时不需要提供定义的。然而,必须给每个虚函数提供定义而不管它有没有被使用,因为编译器无法知道一个虚函数是否被使用。
 
存在继承关系的类之间的转换:
  • 派生类到基类之间的转换仅被运用于指针或者引用类型
  • 没有隐式的从基类到派生类之间的转换
  • privateprotected继承的派生类有时不能执行派生类到基类的转换
由于自动转换只发生于指针和引用,绝大多数继承层级中的类隐式或显式地定义拷贝控制成员,因而,可以将派生对象拷贝、移动或赋值给基类对象。然而,这种拷贝、移动或赋值仅仅只处理派生对象中的基类部分,称之为裁剪(sliced down)
 

调用虚函数将在运行时解析

通过引用或指针调用虚函数时,编译器将生成代码可以在运行时决定具体调用哪个函数。被调用的函数是与指针或引用绑定的对象的动态类型一致的版本:
第一个调用中,item绑定到Quote对象上,从而print_total中的net_price调用Quote中的版本;第二个调用中,item绑定到Bulk_quote对象,调用Bulk_quote中定义的 net_price 
 
动态绑定只发生在虚函数通过指针或引用被调用的过程中。当虚函数在plain对象上(非引用非指针)调用时,调用的解析发生在编译期。
上面的base类型就是Quote,在编译期就可以决定调用Quote中定义的net_price 
 

多态

OOP 的关键思想是多态。指针或引用的静态类型和动态类型可以不一样是C++支持多态的基石。当通过基类的引用或指针调用函数时,无法知道到底调用的对象的类型是什么。对象可以是基类对象也可以是派生类对象。如果调用的函数是virtual的,那么决定调用哪个函数将推迟到运行时。调用的虚函数版本是指针或引用绑定的对象的类所定义的。
另一方面,调用非virtual函数将在编译期进行解析。同样,在plain对象上调用任何函数都是在编译期进行解析的。对象的类型是固定不可变的,不能做任何事从而使得其动态类型和静态类型不一样。因而,在plain对象上进行调用时在编译期决定调用对象的类所定义的版本。
虚函数在运行时进行解析只发生于当通过引用或指针进行调用时。只有在这种情况下,对象的动态类型才可能与其静态类型不一样。
父虚子非虚构成多态,父类为虚函数,子类继承其父的情况下,即使不声明virtual也能构成多态。
 

派生类中的虚函数

当派生类覆盖一个虚函数时,可以但不是必须提供virtual关键字,一旦一个函数被声明为virtual,它将在所有派生类中保持virtual。覆盖继承的虚函数的派生类函数必须在派生类中进行参数列表完全一致的声明。派生类中的覆盖的虚函数可以返回一个基类中的返回类型的子类型的指针或引用,如果不是则必须完全匹配。如:D 从 B 派生而来,B 中的虚函数返回 B* ,那么 D 中的覆盖的虚函数可以返回 D* ,这种返回类型需要可以执行派生类到基类的转型。如果 D 从 B private 派生而来,那么这种转换将不可见。
 

final 和 override 说明符

在派生类中可以定义与基类中的虚函数同名的函数,但其参数列表不一样,编译器将这个函数认为与基类中的函数是独立的。在这种情况下派生类中的版本并不覆盖基类中的版本。
新标准中可以在派生类的虚函数声明中指定override来明确表示是覆盖基类的虚函。这样就要求编译器帮助我们检查是否是真的覆盖了一个基类的虚函数,如果不是,编译器将拒绝编译:
 
可以将函数指定为final的,任何尝试覆盖一个被声明为final的函数将被认为是编译错误:
final的两个作用:
  • 让虚函数不能被重写
  • 让类不能被继承
 
finaloverride说明符需要放在参数列表(包括const和引用限定符)和后置返回类型之后。
 

虚函数和默认参数

与常规成员函数一样,虚函数可以有默认参数。当一个调用使用默认实参时,使用的默认值是调用此函数的对象的静态类型中所定义的默认值。也就是说,当通过基类的指针或引用进行调用时,使用基类中定义的默认实参。即使是绑定到派生类对象并且派生类的覆盖虚函数被调用,这个基类默认实参依然会被使用。派生类中的覆盖的虚函数被传递基类中定义的默认参数,如果派生函数依赖于不同参数,那么程序的行为可能会不一致。
如果虚函数使用了默认实参,通常应该总是在派生类和基类中使用相同的实参。
 

绕过虚函数机制

在一些情况下,希望阻止虚函数调用的动态绑定。通过使用作用域操作符可以调用虚函数的特定版本,这个函数调用解析发生在编译期:
通常,只有成员函数或友元函数中的代码应当使用作用域操作符来绕过虚函数机制。
需要这种绕过的最常见的场景是:当一个派生类虚函数调用基类的版本,基类的版本做了继承层级中所有类型都需要做的通用工作。派生类中定义的版本只需要做特定于该派生类的额外工作。如果派生虚函数在调用其基类版本时忽略了作用域操作符,这个调用将被解析为调用其本身,从而将是无限递归。
 
 

虚函数表

为了实现C++的多态,C++使用了一种动态绑定的技术。这个技术的核心是虚函数表。

类的虚表

每个包含了虚函数的类都包含一个虚表。当一个类(A)继承另一个类(B)时,类 A 会继承类 B 的函数的调用权。所以如果一个基类包含了虚函数,那么其继承类也可调用这些虚函数,换句话说,一个类继承了包含虚函数的基类,那么这个类也拥有自己的虚表。
类 A 包含虚函数vfunc1vfunc2,由于类 A 包含虚函数,故类 A 拥有一个虚表。
notion image
虚表是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。
虚表内的条目,即虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。

虚表指针

虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。
为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,*__vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。
notion image
上面指出,一个继承类的基类如果包含虚函数,那个这个继承类也有拥有自己的虚表,故这个继承类的对象也包含一个虚表指针,用来指向它的虚表。
 
 
 

利用虚表和虚表指针实现动态绑定

notion image
 
类 A 是基类,类 B 继承类 A,类 C 又继承类 B。
由于这三个类都有虚函数,故编译器为每个类都创建了一个虚表,即类 A 的虚表(A vtbl),类 B 的虚表(B vtbl),类 C 的虚表(C vtbl)。类 A,类 B,类 C 的对象都拥有一个虚表指针,*__vptr,用来指向自己所属类的虚表。
  • 类 A 包括两个虚函数,故 A vtbl 包含两个指针,分别指向A::vfunc1()A::vfunc2()
  • 类 B 继承于类 A,故类 B 可以调用类 A 的函数,但由于类 B 重写了B::vfunc1()函数,故 B vtbl 的两个指针分别指向B::vfunc1()A::vfunc2()
  • 类 C 继承于类 B,故类 C 可以调用类 B 的函数,但由于类 C 重写了C::vfunc2()函数,故 C vtbl 的两个指针分别指向B::vfunc1()(指向继承的最近的一个类的函数)和C::vfunc2()
  • 非虚函数的调用不用经过虚表,故不需要虚表中的指针指向这些函数。
 
 
假设定义一个类 B 的对象。由于bObject是类 B 的一个对象,故bObject包含一个虚表指针,指向类 B 的虚表。
现在,声明一个类 A 的指针p来指向对象bObject。虽然p是基类的指针只能指向基类的部分,但是虚表指针亦属于基类部分,所以p可以访问到对象bObject的虚表指针。bObject的虚表指针指向类 B 的虚表,所以p可以访问到 B vtbl。
使用p来调用vfunc1()函数时,会发生什么现象?
程序在执行p->vfunc1()时,会发现p是个指针,且调用的函数是虚函数,接下来便会进行以下的步骤:
  • 首先,根据虚表指针p->__vptr来访问对象bObject对应的虚表。虽然指针p是基类A*类型,但是*__vptr也是基类的一部分,所以可以通过p->__vptr可以访问到对象对应的虚表。
  • 然后,在虚表中查找所调用的函数对应的条目。由于虚表在编译阶段就可以构造出来了,所以可以根据所调用的函数定位到虚表中的对应条目。对于 p->vfunc1()的调用,B vtbl 的第一项即是vfunc1对应的条目。
  • 最后,根据虚表中找到的函数指针,调用函数。B vtbl 的第一项指向B::vfunc1(),所以 p->vfunc1()实质会调用B::vfunc1() 函数。
 
如果p指向类 A 的对象,情况又是怎么样?
aObject在创建时,它的虚表指针__vptr已设置为指向 A vtbl,这样p->__vptr就指向 A vtbl。vfunc1在 A vtbl 对应在条目指向了A::vfunc1()函数,所以 p->vfunc1()实质会调用A::vfunc1()函数。
可以把以上三个调用函数的步骤用以下表达式来表示:
通过使用这些虚函数表,即使使用的是基类的指针来调用函数,也可以达到正确调用运行中实际对象的虚函数。
把经过虚表调用虚函数的过程称为动态绑定,其表现出来的现象称为运行时多态。动态绑定区别于传统的函数调用,传统的函数调用我们称之为静态绑定,即函数的调用在编译阶段就可以确定下来了。
静态库:指的是链接的那个阶段链接的库。
动态库:程序运行起来后才加载,去动态库里找。
静态绑定:又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态。比如函数重载。
动态绑定:又称后期绑定(晚绑定),在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也成为动态多态。
 
什么时候会执行函数的动态绑定?这需要符合以下三个条件。
  • 通过指针来调用函数
  • 指针 upcast 向上转型(继承类向基类的转换称为 upcast,)
  • 调用的是虚函数
如果一个函数调用符合以上三个条件,编译器就会把该函数调用编译成动态绑定,其函数的调用过程走的是上述通过虚表的机制。
 
静态的多态(编译时):指的是函数重载。
  • C++
  • 继承抽象基类
    目录