0%
📌 vtable
TL;DR 性能开销
在不开启优化选项时编译:
- 每次调用虚函数比普通函数多两次访存。
- 每次动态类型转换(除非类型精确命中)都是耗时的图搜索算法。
- 明确需要精确转换时用
std::type_index
和static_cast
会更快。
相关代码下载:
git clone -n --depth=1 --filter=tree:0 https://github.com/gcc-mirror/gcc.git
cd gcc
git sparse-checkout set --no-cone "libstdc++-v3/libsupc++/"
git checkout
虚表内存结构
代码
首先:在共享代码区域存储的虚表记录了特定类的信息。对象中的虚表指针其实是虚函数表指针,指向虚表中的一块位置。
📌 非自动变量初始化
C++ 和 C thread_local
的区别
- C++ 支持使用非常量表达式对全局或静态变量初始化 1。对于
static
local 2 /thread_local
变量而言,这项功能需要在访问前检查变量是否已经完成初始化,thread_local
初始化不需要线程间同步,而static
local 变量的访问过程需要线程间的同步(__cxa_guard_acquire
和__cxa_guard_release
)。 - C++ 的
thread_local
变量在函数作用域中自动具有static
属性 3,而 C 要手动加。在 C 语言中,函数中的thread_local
必须和extern
或者static
之一一起使用,例子为 https://godbolt.org/z/eKz71xh7a 。 - C 的
thread_local
在 C23 之前是个宏。
从代价上来看 C++ 的几种变量初始化
- 首先,不需要函数初始化的在编译期间就能完成工作,没有代价。所以以下讨论的都是通过函数或构造函数来初始化的变量。
- 其次,函数内 (static) thread_local 变量只需要在使用前检查一下,构造和使用都不用同步,代价很小。函数内 static 变量的构造和使用则需要线程之间同步。
- 函数外定义的普通变量和 thread_local 变量都不需要任何同步就能在静态初始化阶段完成初始化,使用时也不需要检查。
- 函数外定义的 inline 变量(C++17)在使用时不需要同步,但是在初始化的时候要检查是否已经初始化完成(为此有个 guard variable 标记)。见 https://godbolt.org/z/hYMjdbsxj ,这可能是因为 inline 变量可能被多个地方使用,每个地方都要提防重复初始化。
全局 (和 static) 变量
最外围定义域定义的变量,可以具有 static 属性也可以没有。
不支持非常量初始化表达式(C 模型)
编译器会抱怨:initializer element is not constant。
支持非常量初始化表达式,但只对对象生效(Cfront 1.0 模型)
在用户程序真正运行前插入一段静态变量初始化代码。
new 和 delete
new
用 new 申请的内存最少占用 1 个字节——尽管我们申请的可能是 0 个字节。
delete
/delete[]
delete 和 delete[] 都会归还空间,但是 delete[] 会询问元素数量,并析构数组中的每个元素,而 delete 只会析构一个元素。
尽管虚析构函数允许我们正常 delete 掉指向子类对象的基类指针,但是在基类指针上使用 delete[] 可能是有错的:
#include <cstdio>
struct A {
double a;
virtual ~A() {
puts("~A()");
fflush(stdout);
}
};
struct B : A {
double b;
~B() override {
puts("~B()");
fflush(stdout);
}
};
static_assert(sizeof(A) == 16);
static_assert(sizeof(B) == 24);
int main() {
A *array_of_A = new B[2];
delete [] array_of_A; // <-- 大问题!
}
按需合成构造函数
复制、默认构造都是按需生成的。对于平凡的情况不需要生成,只是在语意上满足“拥有构造函数”的含义。
x86-64 gcc 13.1 -std=c++20
struct Point {
int x;
int y;
Point() = default; // 即便显式声明了默认构造函数,也不会合成
};
int main() {
auto some_point = Point{}; // {}初始化对聚合类有清零的效果
}
多继承给 C++ 带来了哪些设计负担?
发表于:
分类于:
inside-the-cpp-object-model
- 指针偏移本身有少量开销,若需要偏移则得先判空,这样才能保证空指针永远为空。
- 指针偏移对设计虚函数表带来了困难。例如 thunk 技术用来对 this 指针做适配再调用对应函数。
- 指针偏移让指向成员函数的指针携带了 this 偏移量,变成了胖指针。
- 多继承引入了菱形继承困境,进而又引入了虚拟继承。
- 虚拟继承使得 vtable 中还要存储虚基类子对象的偏移。由于不同继承结构中 vtable 里虚基类子对象的偏移可能不同,又引入了 VTT,让子类调用基类构造函数时为基类的构造函数提供 vptr 参数。
多继承下的虚函数
多继承下重载签名相同的函数
结果是会把基类同签名的所有非 final 虚函数都重写了,而且实现方式相同。尽管基类的虚函数签名一样,但是他们没有关联性,所以在子类的虚表中占两个槽(slots)(一个槽是一个指针)。同样的,如果 Interface 中有虚函数 foo,而 A 和 B 都继承了 Interface,C 继承了 A 和 B。如果 A 和 B 没有虚拟继承 Interface,那么在 C 的对象调用函数 foo 时将出现 ambiguous 指代错误。如果 C 重写了 foo 函数,那么指代就还是明确的。或者,如果 A 和 B 都是虚拟继承自 Interface,那么也不会有编译错误。但这样通过指针/引用调用虚函数 foo 就需要先取虚基类子对象 this 的偏移,修改 this 之后再从 vptr 中读虚函数 foo,开销是 4 次访存(将虚拟继承和虚函数调用的代价累加起来了) 。
多继承下调用虚函数时修正 this 指针
为什么需要修正 this 指针?
重写签名相同的虚函数时,生成的函数代码只能对其中一个基类的虚函数做 this 修正。
#include <cstdio>
using namespace std;
struct A {
int x;
virtual void foo() { printf("A::foo() x=%d\n", x); }
};
struct B {
virtual void foo() { printf("B::foo()\n"); }
};
struct C : A, B {
void foo() override { printf("C::foo()\n"); }
};
int main() {
C c;
A *p = &c; // <--
p->foo();
A *q = new A;
q->foo();
B *r = new C; // <--
r->foo();
}
首先 C::foo()
代码只有一份(生成多份太浪费了),通过上面 p
指针调用 foo()
,会从 vtable 中选中 C::foo()
,然后用 C 的 this(这是因为 A 和 C 的 this 是重合的,不需要修改)。如果用 r
指针调用foo()
,则需要将 B 的 this 修改成 C 的 this,那么直接复用 C::foo()
代码就不行了!
多继承下的指针转换
要注意空指针是特殊情况: