多态:单个接口(符号)提供不同行为。
多台Polymorphism在希腊语中就是多种形式。
函数重载是一种ad-hoc polymorphism,泛型是一种parametric polymorphism,虚函数则是subtyping polymorphism。
¶动态多态
多态类型:声明或继承了至少一个虚函数的类型。
对于多态类型,有些信息只能在运行时(比如父类和子类定义在不同的文件中,只可能借助LTO来确定,因为C++编译器以单个文件作为编译单元;更根本的是比如一个分支的走向依赖于IO输入,不可能在编译器确定)才能确定(实际类型、调用的具体函数)
¶单继承
多态类型的起始8个字节为虚表指针,指向实际的虚表地址(位于常量区)。
虚表包含RTTI信息(type_info,运行时类型信息,放在最开始以避免虚函数个数不确定的影响),以及按照声明顺序排布的各个虚函数的地址。
如果子类覆写了父类的虚函数,则子类的虚表的对应位置就是子类的函数地址,而不是父类的函数地址。具体地,在生成子类时,先调用父类的构造函数,此时虚表指针指向父类的虚函数表,接着调用子类的构造函数,此时再将虚表指针指向子类的虚函数表。
1 | A* a = new B; |
¶多继承
假设C继承了A和B,那么C中就会有两个虚表指针,分别位于基类A的起始地址和基类B的起始地址处。(其实也很显然,为了使用一个偏移去调用,无法合成一个虚表指针)这里基类A和B的排布按照继承时的声明顺序。
此时基类的偏移地址就不一定是0了,因此在虚函数表中还会存储该类的起始偏移地址(offset_to_top
,统一起见,单继承也会保存这个信息)
当基类指针B指向子类C时,它实际指向位置是有一个offset的,此时不能直接加上C::f()的偏移地址,所以在虚函数表中记录时会减掉offset,称作Thunk C::f()
打印出内存模型和虚表模型:
1 | clang++ -Xclang -fdump-record-layouts -fdump-vtable-layouts main.cpp |
¶虚继承
¶缺点
- 内存占用:多了一个虚表指针
- cache miss:多了一次额外寻址
- 无法内联:所以对于比较短的函数,虚函数调用的overhead相对较大
在现代编译器的优化(比如去虚拟化,在很多场景下虚函数调用只存在唯一的可能性)下,虚函数的开销是很微小的,没必要过度担忧。如果真的是虚函数调用成为了性能瓶颈,更应该考虑的是使用CRTP?更改代码设计,避免继承?
benchmark和编译器优化是有所冲突的。使用某种design,再做benchmark看这种design的overhead是否是acceptable的,而不是通过benchmark来决定design。
¶类型擦除
¶std::function
function
不包含其中的函数类型的模板参数(函数指针、functor、lambda),实现方式是把这个类型信息藏在内部的一个子类的构造函数当中,然后再存一个父类指针进行擦除。这里其实就有一个虚函数调用在里面。
1 | struct callable_base { |
std::function_ref (C++26才有)
类似于string_view
,是无所有权的(所以不能传一个临时的lambda进去)。考虑一个函数它有一个静态变量,那么如果使用function
,它会执行拷贝,这样和传入的函数的静态变量是两份独立的。底层实现是一个void*
指针(指向函数)和一个函数指针(保存类型信息)。
¶std::any
any
没有模板参数,和std::function
一样是藏在内部的子类中。
1 | std::type_index(typeid(T)); |
¶std::variant
variant
的模板参数类型已经包含了它要存储的成员了。内部实现是嵌套的union。
1 | variant::index(); |
¶std::tuple
和variant
类型,类型直接通过模板参数类型表达。
1 | std::get(); |
¶优化
¶bound member function
-Wno-pmf-conversions
1 | struct B { |
虚函数查表这个过程直接自己写到代码中了(extract the pointer to the function and call it directly),可以优化频繁的虚函数调用。
本质上就是把一个成员函数通过增加this指针参数改成一个普通函数。
如果虚函数有很多参数,一般不需要这种技巧,因为传递参数也需要CPU时间,通过 vtab 获取虚函数地址的操作,在编译器和 CPU 的配合之下,很大程度上可以将它的延迟隐藏在传递参数的时间之内(CPU的指令级并行如多发射、pipeline等)。
¶final
为类或函数添加final可以帮助编译器推导更多信息,以避免某些虚函数调用,比如一个final类的指针显然不存在子类可以override它的虚函数。
¶C++20 constexpr 虚函数
在编译阶段允许可确定的虚函数调用可以用于替换某些CRTP场景,从而减少模板膨胀、代码编译时长,并提高可读性。
¶C++20 微软proxy
主要是dispatch、facade和proxy这3个类。
一个dispatch抽象了一个接口,若干个dispatch组合成一个facade,类似rust中的traits。proxy代替原本的指针和引用。
1 | struct draw : pro::dispatch<void()> { |
¶静态多态CRTP
原理是在第一阶段遇到类Dog的声明时才会隐式实例化Animal<Dog>
,所以无需前向声明。
1 | template <typename T> |
可以利用友元将子类的move_impl()
声明为private。
C++23 可以显示声明this参数,可以进一步去掉static_cast
。
1 | // C++ 23 引入 this推导 |
¶缺点
- 大量模板实例化,产生代码膨胀