虚函数深入

(11 mins to read)

多态:单个接口(符号)提供不同行为。

多台Polymorphism在希腊语中就是多种形式。

函数重载是一种ad-hoc polymorphism,泛型是一种parametric polymorphism,虚函数则是subtyping polymorphism。

动态多态

多态类型:声明或继承了至少一个虚函数的类型。

对于多态类型,有些信息只能在运行时(比如父类和子类定义在不同的文件中,只可能借助LTO来确定,因为C++编译器以单个文件作为编译单元;更根本的是比如一个分支的走向依赖于IO输入,不可能在编译器确定)才能确定(实际类型、调用的具体函数)

单继承

多态类型的起始8个字节为虚表指针,指向实际的虚表地址(位于常量区)。

虚表包含RTTI信息(type_info,运行时类型信息,放在最开始以避免虚函数个数不确定的影响),以及按照声明顺序排布的各个虚函数的地址。

如果子类覆写了父类的虚函数,则子类的虚表的对应位置就是子类的函数地址,而不是父类的函数地址。具体地,在生成子类时,先调用父类的构造函数,此时虚表指针指向父类的虚函数表,接着调用子类的构造函数,此时再将虚表指针指向子类的虚函数表。

1
2
A* a = new B;
a->f(); // (a->vptr + offset(f)) --> B::f()

多继承

假设C继承了A和B,那么C中就会有两个虚表指针,分别位于基类A的起始地址和基类B的起始地址处。(其实也很显然,为了使用一个偏移去调用,无法合成一个虚表指针)这里基类A和B的排布按照继承时的声明顺序。

此时基类的偏移地址就不一定是0了,因此在虚函数表中还会存储该类的起始偏移地址(offset_to_top,统一起见,单继承也会保存这个信息)

当基类指针B指向子类C时,它实际指向位置是有一个offset的,此时不能直接加上C::f()的偏移地址,所以在虚函数表中记录时会减掉offset,称作Thunk C::f()

打印出内存模型和虚表模型:

1
2
clang++ -Xclang -fdump-record-layouts -fdump-vtable-layouts main.cpp
g++ -fdump-lang-class -c main.cpp

虚继承

缺点

  • 内存占用:多了一个虚表指针
  • cache miss:多了一次额外寻址
  • 无法内联:所以对于比较短的函数,虚函数调用的overhead相对较大

在现代编译器的优化(比如去虚拟化,在很多场景下虚函数调用只存在唯一的可能性)下,虚函数的开销是很微小的,没必要过度担忧。如果真的是虚函数调用成为了性能瓶颈,更应该考虑的是使用CRTP?更改代码设计,避免继承?

benchmark和编译器优化是有所冲突的。使用某种design,再做benchmark看这种design的overhead是否是acceptable的,而不是通过benchmark来决定design。

类型擦除

std::function

function不包含其中的函数类型的模板参数(函数指针、functor、lambda),实现方式是把这个类型信息藏在内部的一个子类的构造函数当中,然后再存一个父类指针进行擦除。这里其实就有一个虚函数调用在里面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct callable_base {
virtual Ret operator()(Args0 a0) = 0;
virtual struct callable_base *copy() const = 0;
virtual ~callable_base(){};
};
struct callable_base *base;

template < typename T >
struct callable_derived : public callable_base {
T f;
callable_derived(T functor) : f(functor) {}
Ret operator()(Args0 a0) {
return f(a0);
}
struct callable_base *copy() const {
return new callable_derived< T >(f);
}
};

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
2
3
variant::index();
std::get_if();
std::visit();

std::tuple

variant类型,类型直接通过模板参数类型表达。

1
2
std::get();
std::apply(); // 对tuple中的每个元素都应用函数f

优化

bound member function

-Wno-pmf-conversions

1
2
3
4
5
6
7
8
9
10
11
12
13
struct B {
virtual ~B();
virtual int vfoo(int) = 0;
};
typedef int (pf_t)(B*, int);
void frequent_call_vfoo(B* b) {
// 很多时候编译器可以自动优化,无需使用该技巧
// 有些时候编译器无法自动优化,该技巧就有用了
pf_t pf = (pf_t)(b->*(&B::vfoo)); // 提取虚函数指针
for (.....) {
pf(b, some_int); // equivalent to b->vfoo(some_int)
}
}

虚函数查表这个过程直接自己写到代码中了(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct draw : pro::dispatch<void()> {
template <class T>
void operator()(T& self) { self.draw(); }
};
struct shape : pro::facade<draw> {};
struct rectangle {
void draw() {
std::cout << "rectangle"<< std::endl;
}
};
struct circle {
void draw() {
std::cout << "circle" << std::endl;
}
};
std::vector<pro::proxy<shape>> shapes;
shapes.emplace_back(pro::make_proxy<shape>(rectangle()));
shapes.emplace_back(pro::make_proxy<shape>(circle()));
for (auto& p : shapes) {
p.invoke<draw>();
}

静态多态CRTP

原理是在第一阶段遇到类Dog的声明时才会隐式实例化Animal<Dog>,所以无需前向声明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <typename T>
class Animal {
public:
void move() {
// 注意:静态转换为具体类型,才能调用成员函数
auto& derived { static_cast<T&>(*this) };

derived.move_impl();
}
};
class Dog: public Animal<Dog> {
public:
void move_impl() {
std::cout << "Dog is running." << std::endl;
}
};
class Bird: public Animal<Bird> {
public:
void move_impl() {
std::cout << "Bird is flying." << std::endl;
}
};

可以利用友元将子类的move_impl()声明为private。

C++23 可以显示声明this参数,可以进一步去掉static_cast

1
2
3
4
5
6
// C++ 23 引入 this推导
// 不需要 static_cast<D&>(*this);
template<typename T>
void move(this T&& self) {
self.move_impl();
}

缺点

  • 大量模板实例化,产生代码膨胀