0%

下面都是在 Linux 上测试,处理器架构为 x86-64,用的 Itanium C++ ABI

Caution

下面很多指针转整数用的是 long,实际上用 intptr_t 更严谨。

指向成员函数的指针:2 倍大小

在 64 位下是 16 字节,因为需要支持虚函数调用。指向成员函数的指针会保存两部分数据:

ptr:非虚函数则存储函数地址。虚函数则存储虚表中偏置加 1(1 plus the virtual table offset (in bytes) of the function, represented as a ptrdiff_t

adj:存储对 this 指针的调整量。

TL;DR 性能开销

在不开启优化选项时编译:

  1. 每次调用虚函数比普通函数多两次访存。
  2. 每次动态类型转换(除非类型精确命中)都是耗时的图搜索算法。
  3. 明确需要精确转换时用 std::type_indexstatic_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 的区别

  1. C++ 支持使用非常量表达式对全局或静态变量初始化 1。对于 static local 2 / thread_local 变量而言,这项功能需要在访问前检查变量是否已经完成初始化,thread_local 初始化不需要线程间同步,而 static local 变量的访问过程需要线程间的同步(__cxa_guard_acquire__cxa_guard_release
  2. C++ 的 thread_local 变量在函数作用域中自动具有 static 属性 3,而 C 要手动加。在 C 语言中,函数中的 thread_local 必须和 extern 或者 static 之一一起使用,例子为 https://godbolt.org/z/eKz71xh7a
  3. 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 模型)

在用户程序真正运行前插入一段静态变量初始化代码。

实际上复制构造函数的插入比较困难,比如在函数返回和传参的时候。由于 C 语言是按位复制(而不是按成员),cfront 会在函数调用前插入返回值变量声明,在函数中使用局部变量运算,并在返回时调用复制构造函数从局部变量复制成员到返回值处。

NRVO (Named Return Value Optimization) 则能省略返回时的复制构造:

关于返回值优化

RVO 的范畴很广,NRVO 是 RVO 的一种特例。

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{}; // {}初始化对聚合类有清零的效果
}

关于 access section

不同 access section 数据不保证按序布局。

我在 Compiler Explorer 上测试了 gcc 和 clang,他们都是忽略 access 权限,将各个 access section 的变量布局直接拼接在一起的。

注:同一个权限也可以是不同的 access section:

  1. 指针偏移本身有少量开销,若需要偏移则得先判空,这样才能保证空指针永远为空。
  2. 指针偏移对设计虚函数表带来了困难。例如 thunk 技术用来对 this 指针做适配再调用对应函数。
  3. 指针偏移让指向成员函数的指针携带了 this 偏移量,变成了胖指针。
  4. 多继承引入了菱形继承困境,进而又引入了虚拟继承。
  5. 虚拟继承使得 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() 代码就不行了!