C++ 虚函数与多态

  C++标准并没有规定虚函数的实现方式,只规定了虚函数需要在运行时动态分派函数。虚表是面向对象体系中常用来处理动态多态的,均衡了性能与用途,主流 C++ 编译器也都是采用的虚表。

目录
- 虚表
- 多继承与虚继承
- 虚函数的性能与开销
- 多态简介
- 静态与动态 static & dynamic
- 其他的话

虚表

  先讨论简单继承的情况。编译期会给每个有虚函数的生成一张虚表 (vtable),表内是指向每个虚函数的函数指针。每个类对象会包含一个虚表指针 (vptr),大小固定,虚表指针会指向该类的虚表。调用函数时,程序根据虚表指针指向的虚表来查找虚函数。

class Base
{
public:
    virtual void func1() {}
    virtual void func2() {}
};

class Derived1 : public Base
{
public:
    virtual void func1() override {}
};

class Derived2 : public Base
{
public:
    virtual void func2() override {}
};

如果将这段代码的虚表显式写出来(Visutal Studio 可以通过 /d1 reportAllClassLayout 命令来看类的 layout):

class Base
{
public:
    virtual void func1() {}
    virtual void func2() {}
private:
    // void **vptr;
    // vptr[0] = Base::func1(void);
    // vptr[1] = Base::func2(void);
};

class Derived1 : public Base
{
public:
    virtual void func1() override {}
private:
    // void **vptr;
    // vptr[0] = Derived::func1(void);
    // vptr[1] = Base::func2(void);
};

class Derived2 : public Base
{
public:
    virtual void func2() override {}
private:
    // void **vptr;
    // vptr[0] = Base::func1(void);
    // vptr[1] = Derived::func2(void);
};

vtable :

  虚表是存放在编译器指定的位置的,虚表的长度和虚函数的个数成正比,每个只有一个虚表。

  基类的虚表内存储的就是基类的函数,子类如果 override 了父类的某个函数,则子类虚表的函数指针改为指向子类重写的函数,没有重写的则仍然指向父类的函数。如果子类添加了新的虚函数,则直接在子类虚表中添加新的函数指针。

vptr:

  每个类的对象会被编译器插入一个虚表指针 vptr,这个指针和 this 不同,vptr 是存在于类对象内存里的,而 this 是由编译器管理的。在继承的时候,vptr 会自动指向继承类的虚表。

  构造的时候,先调用基类构造函数,再调用继承类的构造函数,vptr在这个过程中也会指向继承体系中不同类的虚表,析构时也同理。所以不能在构造或析构函数里调用虚函数。

多继承与虚继承

假如有这种情况

class A {};
class B1 : A {};
class B2 : A {};
class C : B1, B2 {};

  因为 C 有两棵继承树 A - B1 - C 和 A - B2 - C,所以 C 会有两份 A 的成员变量,并且在构造时会执行两次 A 的构造函数,析构同理。如果这个体系中有虚函数,更是难以处理。

   C++ 为我们提供了虚继承来解决这个问题。

class A {};
class B1 : virtual A {};
class B2 : virtual A {};
class C : B1, B2 {};

  这样编译器就可以避免 C 保存多份 A 的成员变量。C 也只会执行一次 A 的构造与析构函数。

  对于这种 菱形继承 下虚表的情况,各种编译器的实现不一,可能会用到多层虚表,也可能会在继承树足够简单的情况下合并成虚表。具体实现就不再做讨论。

虚函数的性能与开销

空间开销

  每个类多了一个虚表,存放函数指针数组,指针的个数与虚函数个数相同,这些会存放在程序的二进制文件里;每个对象多了一个函数指针 vptr,会占用运行时的内存。

存储数据较少,需要大量实例化的类,比如 Point,Vector,最好不要有虚函数,否则容量可能会翻倍。

时间开销

  通过指针找虚函数表会多了一次或数次内存寻址,看起来不会造成很大的时间开销。如果深入体系结构的话,虚函数本身的机制其实和体系结构是由一定冲突的。

  虚函数的 Cache 命中率不好。一般函数调用后指令可能就在函数地址附近,而虚函数很可能不在 cache 里,需要先在 cache 内载入虚函数表,导致指令和数据 cache 的命中率下降。虽然CPU 会预先预测程序的分支,但预测失败时会清掉整个流水线 (flush) 重来。虚函数内的地址本身就不确定,导致 CPU 预测难度较大,`lush 的次数多了,效率也就低了。

  不过,面向对象并不是面向性能,有一定的 tradeoff 是正常的。如果追求性能,需要按照面向数据 (Data Oriented Design) 的思路来设计程序,这又是另一个话题了。

多态简介

   在 C++ 中,虚函数是与多态分不开的,但在程序语言世界里,多态有更广义的解释。

   多态 (Polymorphism) 是程序语言的一种特性,可以概括为”一个接口,多个方法”。拥有多态性的程序会根据对象的类别来执行不同的动作。

维基百科给多态分了三类:

1. Ad hoc Polymorphism,一个接口,能接受定义过的一些类型,例如 C++ 中的重载
2. Parametric Polymorphism,参数化多态,一个接口,不限定类型,例如 C++ 中的模板
3. Subtype Polymorphism,子类型多态,面向对象中,根据继承关系来确定行为,例如 C++ 中的虚函数

   面向对象的三件套中,多态的目的是为了接口重用,封装是为了代码模块化,继承是为了扩展已存在的代码。其实每一个的实现都有不同的方法,虚函数,类,继承只是接触过最多的。

静态与动态 static & dynamic

静态多态与动态多态

   多态分为静态多态和动态多态,静态与动态分别指的是编译期 (Compile time) 和运行时 (Runtime)。所以也可以称为运行时多态和编译期多态。相应的,变量定义时的类型叫做静态类型,比如基类指针的类型,运行时该指针指向的内容的类型称为动态类型。

  在 C++ 中,静态多态包括 函数重载与运算符重载 (overload),以及泛型编程使用的模板 (template)。C++ 的动态多态主要是通过 虚函数 (virtual) 实现的。

静态绑定与动态绑定

名字 时间 别名
静态绑定 编译期 早绑定
动态绑定 运行时 晚绑定

   每个函数被编译好之后,都是有自己的地址的,函数的参数会与这个地址联系在一起,这个过程叫做绑定 (binding)。一般的函数地址和其参数是在编译时就绑定好了,称作静态绑定。而通过函数指针去调用一个函数,这个函数的地址会在运行时才和变量绑定到一起,这种情况被称作动态绑定,虚函数也是动态绑定的。

  动态绑定需要再通过一个指针去找函数地址,比静态绑定要多一步,会慢一点,但更加的灵活。在某些情况中,函数的声明和定义是分开的,例如在调用库时,只知道函数的声明,函数的具体实现已经被编译成二进制文件了,就只能在运行时进行解析用了哪个函数。这样,函数实现就可以单独更新,可以升级库文件而不更改代码。这就是动态多态带来的好处之一。

虚函数会让 inline 申请被忽略

  inline 函数本来是要在编译期被整合进目标码里的,虚函数使用函数指针去调用函数,编译器并不知道调用的函数在那,就无法优化 inline 函数了。

其他的话

  面向对象是为了实现多态而出现的,但同时也引入了继承与封装。封装与并行是有一定冲突的,封装要限制访问权限,而并行需要开放访问权限。而且,对象封装后的内存排列也不是 cache 友好的,封装是用 array of structure,而 CPU 或 GPU 更喜欢 structure of array

  面向对象与性能,并行是有一定冲突的。但对于程序语言来说, “no silver bullet”,没有一种程序语言能妥善处理所有的应用情景。根据应用情景去调节程序的设计才是该考虑的问题。

返回目录

猜你喜欢

转载自blog.csdn.net/weixin_43072157/article/details/82223492