跳过正文
  1. Articles/

为什么说 C/C++ 编译器不保留元信息?

·3347 字·7 分钟· ·
ykiko
作者
ykiko
目录
Reflection - 这篇文章属于一个选集。
§ 2: 本文

首先什么是元信息?
#

来看下面一段python代码,我们希望能够根据传入的字符串来自动修改对应的字段值

class Person:
    def __init__(self, age, name):
        self.age = age
        self.name = name

person = Person(10, "xiaohong")
setattr(person, "age", 12)
setattr(person, "name", "xiaoming")
print(f"name: {person.name}, age: {person.age}") # => name: xiaoming, age: 12

setattrpython内置的一个函数,刚好可以实现我们的需求。根据输入的字段名,修改对应值。

如果想要在C++中实现应该怎么办呢?C++可没有内置setattr这种函数。代码示例如下。(暂时就先考虑可以直接memcpy的类型了,也就是trivially copyable的类型)

struct Person
{
    int age;
    std::string_view name;
};

// 名字 -> 字段偏移量,字段大小
std::map<std::string_view, std::pair<std::size_t, std::size_t>> fieldInfo = 
{
    {"age",  {offsetof(Person, age),  sizeof(int)}},
    {"name", {offsetof(Person, name), sizeof(std::string_view)}},
};

void setattr(Person* point, std::string_view name, void* data)
{
    if (!fieldInfo.contains(name))
    {
        throw std::runtime_error("Field not found");
    }
    auto& [offset, size] = fieldInfo[name];
    std::memcpy(reinterpret_cast<char*>(point) + offset, data, size);
}

int main()
{
    Person person = {.age = 1, .name = "xiaoming"};
    int age = 10;
    std::string_view name = "xiaohong";
    setattr(&person, "age", &age);
    setattr(&person, "name", &name);
    std::cout << person.age << " " << person.name << std::endl;
    // => 10 xiaohong
}

可以发现我们基本上自己实现了setattr这个函数,而且这样的实现似乎可以是通用的。只要为特定的类型提供属于它的fieldInfo就行了。这个fieldInfo里面存了字段名,字段的偏移量,字段的类型大小。它就可以被看做元信息,除此之外可能还有变量名,函数名,等等。这些信息不直接参与程序的运行,而是提供关于程序结构、数据、类型等方面的附加信息。元信息里面存的东西似乎也都是死套路,对于我们都是已知信息。因为它们就存在程序的源代码里面。那C/C++编译器提供这种功能吗?答案是:对于debug模式下的程序可能会保留一部分用于程序调试,而在release模式下什么都不会存。这样做的好处是很显然的,因为这些信息并不是程序运行起来必须要的信息,不保留它们可以显著减少二进制可执行文件的大小。

为什么这些信息是不必要的,什么时候需要?
#

接下来我会以C语言为例,将它的源码与二进制表示对应起来。看看执行代码究竟需要哪些信息?

变量定义
#

int value;

事实上变量声明并没有直接对应的二进制表示,它仅仅是告诉编译器需要分配一块空间来存储名为value的变量,究竟分配多大的内存则由它的类型决定。所以如果变量声明的时候类型大小是未知的,则会编译错误。

struct A;

A x; // error: storage size of 'x' isn't known
A* y; // ok the size of pointer is always konwn 

struct Node
{
    int val;
    Node next;
}; // error Node is not a complete type
// 其实意思就是定义 Node 类型的时候它的大小还是未知的

struct Node
{
    int val;
    Node* next;
}; // ok

相信你想到了这和malloc似乎有点像,的确如此。区别在于,malloc是在运行时的堆上分配内存。而直接的变量声明一般是在数据区或者栈上分配内存。编译器可能在内部会维护一个符号表,将变量名与它的地址映射起来,在你后续对这个变量进行操作的时候,实际上是对这块内存区域进行操作。

内置运算符
#

C语言内置的运算符一般直接和CPU指令直接对应,至于CPU是如何实现这些运算的,可以学习下数电相关知识。以x86_64为例,可能的对应如下

| Operator | Meaning | Operator | Meaning |
|----------|---------|----------|---------|
| +        | add     | *        | mul     |
| -        | sub     | /        | div     |
| %        | div     | &        | and     |
| \|       | or      | ^        | xor     |
| ~        | not     | <<       | shl     |
| >>       | shr     | &&       | and     |
| ||       | or      | !        | not     |
| ==       | cmp     | !=       | cmp     |
| >        | cmp     | >=       | cmp     |
| <        | cmp     | <=       | cmp     |
| ++       | inc     | --       | dec     |

赋值则可能是通过mov指令来完成的,比如

a = 3; // mov [addressof(a)] 3

结构体
#

struct Point
{
    int x;
    int y;
}

int main()
{
    Point point;
    point.x = 1;
    point.y = 2;
}

结构体的大小一般可以由特定规则算出从它的成员算出,往往要考虑内存对齐,而且是编译器决定的。例如 msvc。但总之在编译的时候结构体的大小就是已知的了,我们也可以通过sizeof获取类型或者变量的大小。那么这里的Point point变量定义就很好理解,类型大小已知,相对于在栈上分配了一块内存。

下面来关注一下结构体成员访问,事实上C语言有一个宏可以获取结构体成员相对于结构体起始地址的偏移量,叫做offsetof(就算我们获取不到,编译器里面也是会计算字段偏移量的,所以偏移量信息对编译器总是已知的)。例如在这里offsetof(Point, x)就是0offsetof(Point, y)就是4。所以上面的代码可以理解为

int main()
{
    char point[sizeof(Point)]; // 8 = sizeof(Point)
    *(int*)(point + offsetof(Point, x)) = 1; // point.x = 1
    *(int*)(point + offsetof(Point, y)) = 2; // point.y = 2
}

编译器同样可能会维护一个字段名->偏移量的符号表,字段名最终会替换为offset。也没有必要在程序中保留了。

函数调用
#

一般通过函数调用栈实现,这个太常见了,就不仔细说了。函数名最后会直接被替换为函数地址。

总结
#

通过上面的分析,相信你已经发现了,C语言中的符号名,类型名,变量名,函数名,结构体字段名等等信息都被替换成了数字,地址,偏移量等等。缺少了它们对程序运行并没有什么影响。所以选择把它们抛弃掉,减少二进制文件的大小。对于C++来说情况基本也是类似的,C++只会在一些特殊的情况下保留部分元信息,比如type_info,而且可以手动选择关闭掉RTTI从而确保不会产生这种信息。

那什么时候我们需要使用这些信息?显然最开始介绍的setattr是需要的。在程序调试的时候,我们得知道一个地址对应的变量名,函数名,成员名等等,方便我们调试,这时候我们也是需要的。当把结构体序列化为json的时候,我们需要知道它的字段名,我们也需要这些信息。把类型擦除成void*了之后,我们还是需要知道它实际对应的类型是什么,这时候我们也是需要的。总之,为了在运行期区分这串二进制内容倒是原本是什么东西的时候,我们就需要这些信息(当然在编译期想要利用这些信息进行代码生成,也是需要的)。

如何获取这些信息?
#

C/C++编译器并没有提供给我们接口让我们获取这些信息,但是前面已经说了,这些信息显然就在源代码里面啊。变量名,函数名,类型名,字段名。我们可以选择通过人工理解代码,然后手动去存储元信息。几千个类,几十个成员函数,可能写个几个月就好了吧。开玩笑的,或者我们可以写一些程序,比如正则表达式匹配之类的帮我们获取到这些信息?不过,其实我们有更好的选择来获取这些信息,那就是通过AST

AST(Abstract Syntax Tree)
#

AST是抽象语法树(Abstract Syntax Tree)的缩写。它是编程语言处理中的一种数据结构,用于表示源代码的抽象语法结构。AST是源代码经过解析器(parser)处理后的结果,它捕捉了代码中的语法结构,但不包含所有细节,比如空白字符或注释。在AST中,每个节点代表源代码中的一个语法结构,例如变量声明、函数调用、循环等。这些节点之间通过父子关系和兄弟关系连接,形成了一棵树状结构,这样的结构更容易被计算机程序理解和处理。如果你的电脑里面装了clang编译器,可以使用下面这个命令查看一个源文件的语法树

clang -Xclang -ast-dump -fsyntax-only <your.cpp>

输出如下,我筛选出了重要的信息,无关的已经被删除了

|-CXXRecordDecl 0x2103cd9c318 <col:1, col:8> col:8 implicit struct Point
|-FieldDecl 0x2103cd9c3c0 <line:4:5, col:9> col:9 referenced x 'int'
|-FieldDecl 0x2103e8661f0 <line:5:5, col:9> col:9 referenced y 'int'
`-FunctionDecl 0x2103e8662b0 <line:8:1, line:13:1> line:8:5 main 'int ()'
  `-CompoundStmt 0x2103e866c68 <line:9:1, line:13:1>
    |-DeclStmt 0x2103e866b30 <line:10:5, col:16>
    | `-VarDecl 0x2103e866410 <col:5, col:11> col:11 used point 'Point':'Point' callinit
    |   `-CXXConstructExpr 0x2103e866b08 <col:11> 'Point':'Point' 'void () noexcept'
    |-BinaryOperator 0x2103e866bb8 <line:11:5, col:15> 'int' lvalue '='
    | |-MemberExpr 0x2103e866b68 <col:5, col:11> 'int' lvalue .x 0x2103cd9c3c0
    | | `-DeclRefExpr 0x2103e866b48 <col:5> 'Point':'Point' lvalue Var 0x2103e866410 'point' 'Point':'Point'
    | `-IntegerLiteral 0x2103e866b98 <col:15> 'int' 1
    `-BinaryOperator 0x2103e866c48 <line:12:5, col:15> 'int' lvalue '='
      |-MemberExpr 0x2103e866bf8 <col:5, col:11> 'int' lvalue .y 0x2103e8661f0
      | `-DeclRefExpr 0x2103e866bd8 <col:5> 'Point':'Point' lvalue Var 0x2103e866410 'point' 'Point':'Point'
      `-IntegerLiteral 0x2103e866c28 <col:15> 'int' 2

或者如果你的vscode装了clangd这个插件,可以右键选择一块代码,然后右键show AST来看这块代码片段的ast。可以发现上面的确是把源码内容以树的方式呈现给我们了,既然是一颗树,我们就可以自由的遍历树的节点,然后筛选获取我们想要的信息。上面两例都是可视化的输出,通常情况下也会有直接的代码接口来直接获取。比如python内置就有ast模块来获取,C++一般是通过clang相关的工具来获取这些内容。如果想知道具体该如何使用clang工具,可以参考这篇文章

如果你好奇编译器究竟是如何把源代码变成ast的,你可以去学习一下编译原理前端的内容。

以何种方式存储这些信息?
#

这个问题听起来让人有些困惑,实际上这个问题可能只有C++程序员需要考虑

其实一切原因都是constexpr引起的。把信息下面这样存储起来

struct FieldInfo
{
    std::string_view name;
    std::size_t offset;
    std::size_t size;
}

struct Point
{
    int x;
    int y;
}

constexpr std::array<FieldInfo, 2> fieldInfos =
{{
    {"x", offsetof(Point, x), sizeof(int)},
    {"y", offsetof(Point, y), sizeof(int)},
}};

就意味着我们不仅仅能在运行期查询这些信息,还能在编译期查询这些信息

更有甚者,还可以存到模板参数里面去,这样的话连类型也能存了

template<fixed_string name, std::size_t offset, typename Type>
struct Field{};

using FieldInfos = std::tuple
<
    Field<"x", offsetof(Point, x), int>,
    Field<"y", offsetof(Point, y), int>
>;

这样无疑给了我们更大的操作空间,那么有了这些信息之后,下一步该做些什么?事实上我们可以选择基于这部分信息进行代码生成,相关的内容可以浏览系列文章中的其它小节。总目录的链接在下方:

Reflection - 这篇文章属于一个选集。
§ 2: 本文