Inside C++ Object Model 读书笔记

Inside the C++ Object Model 读书笔记

Object Lessons

C++ 被称为 zero cost abstraction, 某种程度上,这代表着:

1
2
3
4
5
struct Point3D {
float x;
float y;
float z;
}

和你傻傻的在程序里单独维护 x y z,空间等开销是没有额外增加的。当然 Point3D 是一个很 Plain 的类。实现的时候,可能会有:

1
2
3
4
5
6
7
8
struct Point3D {
Point3D(float x, float y, float z): x_(x)
...
private:
float x_;
float y_;
float z_;
}

或者:

1
2
3
4
class Point3D: Point2D {
private:
float z_;
}

甚至带上泛型

1
2
3
4
5
6
7
8
9
template<typename edge_t>
class Point3D {
edge_t x, y, z;
}

template<typename edge_t, int dim>
class Point3D {
edge_t[dim] edges_;
}

下意识我们会觉得,运行时这些类的布局成本都是一样的。实际上,C++ 在 layout(空间)和 load/store (时间等)伤的成本是 virtual 引起的:

  • Virtual function: 支持高效的运行期多态
  • virtual base class (这个笔者之前用的很少,可能不太了解):被棱型等方式继承的 base class.

C++ Object Model

这本书的内容比较老,无法保证和最新的 clang/gcc/msvc 实现一样,不过它介绍的一些模型还是值得看的,姑且把它这个推导过程 copy 一下。

第一个介绍的模型是:

418F4DC7-51DC-4C7F-8F26-A3FA24ABFDE5

这个针对每个函数和成员都生成了对应的 Pointer/ 对应内存位置。所有的指向都是间接的,函数也有对应的指针。

书上写的第二个对象模型分为了函数的 table 和成员的 table,布局如下:

4DD61565-8EBE-4F05-A11F-165A71DE0AFF

这本书上最后介绍了当时 C++ 的 object model:

  1. 如图 1.2. 产生指向虚函数的指针,成为 virtual table (vtbl). vtbl 是以类为单位生成的。同时,一个动态的 type_info 也被生成,可以用于动态的 RTTI。
  2. 每个有 virtual 的类的对象 生成指向类的 vtbl 的指针 vptr.

对于 virtual 继承,本书提供了两种方案:

  • 子类的对象留出一定的内存位置,指向 base class 的地址
  • 生成一个 base class table

下图是逻辑上的模型。

C4C279FD-5CD9-420C-896E-9B8E64E975A4

这样引入了一定的间接性质(有点像 Python MRO 那套?)

对象模型对程序的影响

首先,对象模型会影响你需要重新编译、链接的东西,决定编译器会怎么生成对 vtbl 等操作。

此外,这本书里提到了The Politically Correct Struct:

1
2
3
4
5
6
7
8
struct BPlusTree {
// ... 成员信息
BTreeIndex index[];
}

struct mumble {
char index[1];
}

C 这样让 IndexBPlusTree 一起布局。在 C++ 中,你要注意:

1
2
3
4
5
6
7
8
9
10
11
12
struct BPlusTree {
public:

private:
...
BTreeIndex index[];
}

class stumble {
//...
char index[1];
}

这种情况下是否 不同section 中,index 还会出现在结构的尾部。

同时,你注意到了可能存在的不同布局和 ABI,为了 C/C++ 兼容(这是个很常见的需求),你可能要:

1
2
3
extern "C" {
// ... 写你封装的东西
}

考此来提供一定的保证(组合/继承?)

指针的类型

一些语言中会有 fat pointer 的存在,比如 Rust 的 Box<dyn Trait>, 这让这些指针有更多的信息。

C++ 只靠指针类型编译时的信息来决定,指针都是一个 word 的大小,具体可以参考这个回答:

Golang和Rust的胖指针与C++的指针指向虚表哪种设计更好? - F001的回答 - 知乎 https://www.zhihu.com/question/340855881/answer/791076420

38D54D51-BC06-4ABA-82A7-AE7A677EC9F3

The Semantics of Constructors

我不怎么会写 C++,但是我心目中,C++ 最重要的概念绝对包含 RAII。

the rule of five

同时,这里会涉及一些 RVO/NRVO 之类的优化:

一个不保证 NRVO 的例子)

Default Constructor

cppreference 介绍了它生成的条件。

default constructor 在默认的时候生成. 但它不会做多余的事:

1
2
3
4
5
6
class ListNode {
ListNode* prev;
int value;
}
// ...
ListNode v;

创建 v 的时候,prev value 不会在 default constructor 被初始化。这点是为了效率。

这里可以联系上述 cppreference 中的概念:

平凡默认构造函数是不进行任何动作的构造函数。所有与 C 语言兼容的数据类型(POD 类型)都是可平凡默认构造的。

1
2
3
4
5
6
class ListNode {
ListNode* prev;
int value;
another1_t another1;
another2_t another2;
}

假设 another1_t another2_t 有自己的 default constructor, 那么 ListNode 构造的时候会按顺序调用这些必要的信息,可能会生成:

1
2
3
4
5
6
ListNode::ListNode() {
another1_t::another1_t();
another2_t::another2_t();

// ... your default code...
}

类似上面的顺序

带 virtual function 的 class

编译器需要生成一个 vptr ,同时指向这个 class 的 vtbl.

Copy Constructor

传送门:https://zh.cppreference.com/w/cpp/language/copy_constructor

小传送门:https://zh.cppreference.com/w/cpp/language/copy_elision

1
2
3
4
5
6
7
class X {
X(std::string name, ...): name_(std::move(name)) {
// ...
}
private:
std::string name_;
}

(不考虑这个愚蠢的类怎么改)显然这里如果传参的话,一定会走 std::string 的拷贝构造函数。

在最原始的情况下,编译器会考虑 bitwise copy semantics,类似:

平凡复制构造函数

当下列各项全部为真时,类 T 的复制构造函数为平凡的:

  • 它不是用户提供的(即它是隐式定义或预置的),且若它被预置,则其签名与隐式定义的相同 (C++14 前);
  • T 没有虚成员函数;
  • T 没有虚基类;
  • T 的每个直接基类选择的复制构造函数都是平凡的;
  • T 的每个类类型(或类类型数组)的非静态成员选择的复制构造函数都是平凡的;

非联合类的平凡复制构造函数,效果为复制实参的每个标量子对象(递归地包含子对象的子对象,以此类推),且不进行其他动作。不过不需要复制填充字节,甚至只要其值相同,每个复制的子对象的对象表示也不必相同。

可平凡复制 (TriviallyCopyable) 对象,可以通过手动复制其对象表示来进行复制,例如用 std::memmove。所有与 C 语言兼容的数据类型(POD 类型)均为可平凡复制的。

当然,这不一定是用户需要的。讲一个最简单的例子:浅拷贝。下面这个例子你当然会考虑用 unique_ptr 之类的东西包住,但是就这么做示范的话,结果可能会很奇葩。

1
2
3
4
class x {
private:
resource_t* ptr_;
}

不平凡的时候,调用也是逐个按顺序进行的。

vptr 与行为

假设类型有一个虚成员函数,那么它理应拥有一个 vptr, 根据 cppreference, 它不会拥有平凡复制构造函数,因为它要对 vptr 的复制产生合法的结果:

1
2
3
4
5
6
7
8
9
10
class ZooAnimal {
public:
virtual void fn() { // ... }
}
class Bear: ZooAnimal {
void fn() override { // ...}
}

Bear b;
ZooAnimal s = b;

程序语义转换

(感觉这一节比较像在介绍 RVO/NRVO/Copy Elision)

上述地址介绍了所有相关的技术。注意 NRVO 不是强制的,所以 CppCoreGuidelines 有下列链接:

一个不保证 NRVO 的例子)

你可能防止默认的 move constructor 实现的话,如果 NRVO 不进行,优化的能力会减少。

member initialization list

list 中的初始化次序是 class 中的 member 声明顺序决定的。顺序 constructor, 逆序 destructor

聪明的编译器应该能告诉你出了问题,所以别担心hhh。

当然,如果你写出下面的代码,任何一本书都会阻止你:

1
2
3
4
5
6
7
8
class X {
public:
X(const Y& y) {
y_ = y;
}
private:
Y y_;
}

编译器会把初始化插入在 explicit 的代码之前。

Data 的语义

1
2
3
4
class X {};
class Y: public virtual X {};
class Z: public virtual X {};
class A: public Y, public Z {};

哦,还有一个很好玩的:

1
2
3
class H {
int s[];
}

下面让我们来看看对它们进行 sizeof 的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//
// Created by mwish on 2020/7/14.
//

#include <iostream>

class X {};
class DerivedX: public X {};
class Y: public virtual X {};
class Z: public virtual X {};
class A: public Y, public Z {};

class H {
int s[];
};

template <typename T>
void print_t_size() {
std::cout << __PRETTY_FUNCTION__ << ":" << sizeof(T) << '\n';
}

int main() {
print_t_size<X>();
print_t_size<DerivedX>();
print_t_size<Y>();
print_t_size<Z>();
print_t_size<A>();

print_t_size<H>();
}

输出:

1
2
3
4
5
6
void print_t_size() [T = X]:1
void print_t_size() [T = DerivedX]:1
void print_t_size() [T = Y]:8
void print_t_size() [T = Z]:8
void print_t_size() [T = A]:16
void print_t_size() [T = H]:0

实际上,可以考虑看看 ebo: https://zh.cppreference.com/w/cpp/language/ebo

为保证同一类型的不同对象地址始终有别,要求任何对象或成员子对象(除非为 [[no_unique_address]] ——见下文) (C++20 起)的大小至少为 1,即使该类型是空的类类型(即没有非静态数据成员的 class 或 struct)也是如此。

(也可以看看这个 FAQ: https://isocpp.org/wiki/faq/classes-and-objects#sizeof-empty)

所以 static_assert(sizeof(X) >= 1) 必定是成立的。

一个对象的大小额外开销(相对于C语言那样朴素的布局)在于:

这里继续说一下 ebo, 定义一下:

若空基类之一亦为首个非静态数据成员的类型或其类型的基类,则禁用空基优化,因为要求两个同类型基类子对象在最终派生类型的对象表示中必须拥有不同地址。

1
2
3
4
5
6
7
8
9
class Compond2X {
X x_;
uint32_t value;
};

class Derived3X: public X {
X x_;
uint32_t value;
};

输出:

1
2
3
4
5
6
7
// void print_t_size() [T = Derived2X]:4
// 应用了空基类优化
print_t_size<Derived2X>();
// 8
print_t_size<Compond2X>();
// 8
print_t_size<Derived3X>();

书上 3.1 节介绍了类和名称查找相关的信息,感觉书上写的一般,我也没看太懂,就不贴了。

Data Member Layout

当你 repr(C) 的时候,对象都是按顺序布局的,C++ 一定程度上有这个保证——只对同样访问权限的对象而言。

具体我找到了这个链接:https://stackoverflow.com/questions/36149462/does-public-and-private-have-any-influence-on-the-memory-layout-of-an-object

只能说如果你有依赖这样语义的操作,记得 static_assert.

static member 并不是对象的一部分,不参与对象的布局。

static member and name-mangling

你要是 g++/clang++ 编译过 C++ 代码大概就会知道的…