📌 C++ 对象模型开销总结

下面都是在 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 指针的调整量。

指向成员函数的指针 调用函数时,需要在传入的 this 指针上添加偏置(在多继承中有用),偏置信息就存储在该指针内。下一节“调用虚函数的开销”也描述了如何在跨平台 C++ ABI 上装配一个 指向成员函数的指针。

调用虚函数:多访存 2 次

#include <cstdio>

struct BaseObject {
    virtual int f() { return 13; }
};

struct DerivedObject : BaseObject {
    int f() override { return 4000; }
};

int main() {
    BaseObject *p = new DerivedObject;

    // 访存两次得到虚函数(感觉 long 类型比 char*/void* 模拟汇编更加合适)
    long vptr = *(long *)p;
    long func = *(long *)(vptr + 0);
    
    // 把 this 指针作为第一个参数传入
    auto method = (int (*)(BaseObject *))func;
    int result = method(p);
    
    // 尽管也可以按照 Itanium C++ ABI 装配成员函数指针,
    // 但实际上调用时还是会动态查找,相当于上面的访存操作白做了。
    // int (BaseObject::*method)();
    // auto &manip = (long (&)[2])method;
    // manip[0] = (long)func;
    // manip[1] = 0; // single inheritance
    // int result = (p->*method)();

    printf("result: %d\n", result);
    // 打印结果:result: 4000
}

访问继承自虚基类的成员:多访存 2 次

需要多访存 2 次得到虚基类子对象的位置。

BaseObject *p = new DerivedObject;
// 编译期能计算出这个常量,该常量为负数且小于 -(int64_t)sizeof(long) * 2
long vptr_to_offset = ...; 
char *vptr = *(char **)p;                        // [1]
long offset = *(long *)(vptr + vptr_to_offset ); // [2]
char *subobject = p + offset;                    // 这一步不访存
// 省略:用虚基类子对象的指针访问其成员

当然,通过虚基类的指针访问它自己的成员,是不需要先计算偏置的。

调用继承自虚基类的虚函数:多访存 2 / 4 次

首先取虚函数的地址要 2 次访存。其次,如果该虚函数是在虚基类实现的,且继承链上没有覆盖实现,则需要将 this 指针对准到虚基类,获取偏置又要 2 次访存,所以一共是 4 次。(如果有覆盖实现,则对准 this 指针所用的偏置是编译期常量。如果覆盖了实现,但是实现中仍然使用了来自于虚基类的数据,则也得多 2 次访存来获取偏置。)

在使用虚基类的虚函数版本时,gcc 倾向于先获得虚基类的偏置,然后得到虚基类的 vptr,再通过这个 vptr 读取虚函数地址——尽管子类的 vtable 中也记录有虚函数。

按照上面的说法,如果虚基类提供的虚函数都是纯虚函数,且虚基类本身不带有数据,那么用于对齐 this 指针的 2 次访存代价是不需要付出的。

#include <cstdio>
#include <memory>

struct Dog {
  virtual void bark() = 0;
  virtual ~Dog() = default; // <-- 很重要
};

// 虚拟继承能够支持菱形继承,但代价是接口类的vptr不能被复用
struct GoldenRetriever : virtual Dog {
  void bark() override {
    puts("I'm golden!");
  }
};

int main() {
    // 这里有向基类的隐式类型转换,所以得有虚析构函数。
    // 只是恰好这个子类没有覆写析构函数而已,换个子类可能就不安全了。
    std::unique_ptr<Dog> dog = std::make_unique<GoldenRetriever>();
    dog->bark();
}
interface Dog {
  void bark();
}

class GoldenRetriever implements Dog {
  @Override
  public void bark() {
    System.out.println("I'm golden!");
  }

  public static void main(String[] args) {
    Dog dog = new GoldenRetriever();
    dog.bark();
  }
}

上面是 Java 和 C++ 中定义接口的不同之处。在 C++ 中,为了使得接口支持菱形继承,有必要加上 virtual 属性;Java 中对象只能单继承,因而没有这个问题。

Java 中接口继承接口用关键字 extends;C++ 中接口继承接口也得加上 virtual,因为接口概念虽然存在,但 C++ 里接口是类的子集,而不是 C++ 直接支持的类型。

std::type_info:静态访存 0 次,动态 2 次

⚠ 在多态类的指针或引用上使用 typeid 关键字才会用以下方式访问(虚拟继承得到了 vptr 也不行)。如果该类的类型完全确定,则在编译期可以直接确定地址。

在编译器内部实现中有两种方式可以读取 std::type_info *

BaseObject *p = new DerivedObject;

// vtable_prefix = { whole_object, whole_type, origin }
char *vptr = *(char **)p;                         // [1]
long offset = *(long *)(vptr - sizeof(long) * 2); // [2]
char *realp = p + offset;                         // 这一步不访存
char *realvptr = *(char **)realp;                 // [3]

// __class_type_info 继承于 std::type_info
__class_type_info *info = *(__class_type_info **)(realvptr - sizeof(long)); // [4]

或者:

BaseObject *p = new DerivedObject;

long vptr = *(long *)p;                         
__class_type_info *info = *(__class_type_info **)(vptr - sizeof(long));

前者访存 4 次,后者访存 2 次。如果类型正在构建中,这两个地址是不同的,否则应该相等,dynamic_cast 对这个特殊情况有专门的处理(两个都读,一共 6 次访存,可以看之后的小节)。如果是我们的代码动态使用,用到的是访存 2 次的版本!

#include <typeinfo>

struct A {
    int a;
    virtual void f() {}
};

struct B : A {
    int b;
};

int main() {
    A *p = new B;
    auto &info = typeid(*p);
}

比较 std::type_info / std::type_index:类型名(mangled)字符串比较

用 RTTI 比较类型等同于比较字符串。

编译期间只需要比较地址,但是编译期无论多慢都不影响运行性能所以显得没那么重要。

// 去掉了一些条件编译分支
_GLIBCXX23_CONSTEXPR inline bool
type_info::operator==(const type_info& __arg) const _GLIBCXX_NOEXCEPT
{
    if (std::__is_constant_evaluated())
      return this == &__arg;
    if (__name == __arg.__name)
      return true;
    return __name[0] != '*' && __builtin_strcmp (__name, __arg.name()) == 0;
}

std::type_index 包含了一个 std::type_info 的指针,比较运算符会转发到 std::type_info 的比较上,内联之后代价一样。

dynamic_cast / static_cast 向上转型:非零偏移需先判空

如果是向第一个基类转型,则地址没有偏移,直接赋值即可。如果是第二个或更后面的非空基类转型,则地址会发生偏移,需要判断指针是否为空再赋值:若为空则仍保持空;若不空则对指针做偏移运算。

dynamic_cast 向下转型:精确命中则快,否则是开销大的图搜索

static_cast 做向下转型没有运行时检查,用 dynamic_cast 做向上转型则效果等同于 static_cast所以一般说 dynamic_cast,关注的多是 dynamic_cast 的运行时检查。

首先检查类型是否正在构建,这个阶段会从两个地方读 type_info,检查其地址一致性。一共访存 6 次。如果地址不同则类型正在构建,不允许转换,返回 nullptr

接下来判断是否目的类型和当前真实类型完全相符,如果相符可以直接返回。多判断 src2dst 和偏置的相等性可能是冗余信息检查,以便避免失败时 type_info 中的字符串比较(个人想法)。

如果失败,则还需要考虑目的类是否为真实类型的基类。这会用到一个另外的虚函数,其结果还需要进行一些校验。这个过程就比较耗时了。

如果我们向下转型时要求类型必须精确匹配,则可以用比较 type_infostatic_cast 的方式减少开销。首先是我们读 type_info 往往不是在构造函数中,所以只需要 2 次访存而不是 6 次;其次是如果精确匹配失败可以直接返回,而不是进入接下来的图搜索步骤。