快速小巧 C++

标题:Fast and Small C++ - When Efficiency Matters

日期:2024/12/17

作者:Andreas Fertig

链接:https://www.youtube.com/watch?v=rNl591__9zY

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。


好的。欢迎大家来到我的 CppCon 演讲:快速且小巧的 C++。看着这里的大屏幕,我本应该把它命名为“庞大的 C++”之类的。我是 Triosphatic 的 Andreas Fertig。我是 C++ 培训师,在全球范围内提供远程或现场服务。如果你感兴趣,请联系我。我还是一个叫做 C++ Insights 的工具的作者。如果你还没听说过它,在这次演讲中你至少会看到一次。我是德国人。你们中的一些人可能从我的姓氏就能看出来。我的姓氏是一个非常常见的德语单词。在德语中是一个形容词。它翻译成英语是 finished, ready, complete 或 completed(完成、就绪、完整或已完成)。所以总的来说,我会说这是一个积极、美好的名字,对吧?通常在积极的句子中使用它。你也可以用它表示稍微不同的意思,比如“我累了,我筋疲力尽了”,这就是我今天的意思,因为我的身体还在和时差作斗争,所以此刻我感觉有点 fertig(精疲力尽)。而且因为我经常做演讲,我开始更多地研究我的姓氏,并收集相关的故事。我注意到的是,我们最近在巴黎举办了夏季奥运会,对吧?这倒不是我有什么重大发现。我意识到的是,每当他们开始一场比赛时,他们都会喊出这个三部分的序列:Ready, steady, go(各就位,预备,跑)。

每当他们开始一场比赛时,他们一开始就是喊我的姓氏:Ready(就绪),这是一个翻译,对吧?太棒了。现在你知道“Ready”在德语中翻译成什么了吗?Fertig,对吧?那么,你准备好用德语做一遍了吗?

Ready, steady, go(各就位,预备,跑)。它翻译成德语是:auf die Plätze, fertig, los。所以我意识到,我们德国人需要三个词来启动一个三部分的序列。所以你必须非常仔细地听,以免跑得太快或游得太早。而我的姓氏排在第二位。语言很有趣,对吧?无论是口语还是编程语言。那么,让我们进入一个更简单的话题:C++。这才是你们来这里的目的,对吧?而不是上德语课。让我先告诉你们,我有好消息和坏消息要告诉你们。

我不是标准库开发者。

顺便说一下,这既是好消息也是坏消息。说它是坏消息是因为这意味着我只会展示标准库中的两三个东西。而且由于我自己没有实现或创建它们,我可能无法回答诸如“为什么是那样设计的?”之类的问题。我只能向你展示它是如何工作的。但这也是件好事,因为我不是标准库开发者意味着我向你们展示的是可读的代码。我的意思是,我们大多数人认为可读的代码,它不会以双下划线开头,也没有被“丑化”的名字。如果你可以直接使用标准库,那才是它的美妙之处。而如果你作为演讲者不是标准库开发者,对吧,你就可以做到这一点。所以我这里的标题是“压缩对”(compressed pair)。你们看到的是一个你们可能非常熟悉的结构。它是一个 unique_ptrunique_ptr 最常见的用例是你提供一个类型,即这个 unique_ptr 应该包装和管理的指针类型,这样我们就能拥有一个内存安全的构造。根据我的经验,另一个用例是提供一个自定义删除器(deleter),在析构时执行特殊操作,而不仅仅是 delete。这使我们能够将 unique_ptr 与一些我们从未真正为其设置 new 的东西一起使用。我这里的例子是使用 fopen 返回其文件对象。我把它存储在一个 unique_ptr 中,并说,当你销毁它时,请调用 fclose 而不是 delete。这样,无论何时我离开作用域,这里的 unique_ptr 都会管理我的文件句柄并关闭文件。完美。我很安全。这就是我喜欢的。非常好。现在,我做这个演讲是因为我喜欢为嵌入式领域编写软件。我做了很长时间,并且希望它要么快速,要么小巧,或者有时两者兼得。这意味着我的资源有限,只有几千字节的 RAM 或 ROM,我不想浪费其中的任何一点。看看你们在这里看到的代码,我想我们都希望这个 unique_ptr 的大小就是一个指针的大小,对吧?它应该存储一个指针。我们不想为它付出更多代价。然而,事实是,按照我在这里的做法,它的大小是两个指针。因为它必须存储一个指向我提供的删除器函数 fclose 的指针。这可能是有道理的,但我认为这很遗憾,因为 fclose 的地址在编译时和链接时是已知的。所以,为什么我要为此支付一个额外指针的代价,而且访问它还花费一点时间?我个人不想要这样。

那么,它能做什么,或者标准库做了什么来帮助我们解决这个问题?有一种优化技术与标准库没有特别关系。它被称为空基类优化(Empty Base Class Optimization, EBO)。它说的是,如果你有一个空的类,即它不包含任何非静态数据成员,那么它的本应大小为 0。但当我们开始用 sizeof 询问这个东西的大小时,它说大小为 1。因为在 C 和 C++ 中,它必须是可寻址的,这是规则。现在 EBO 说的是,如果我们有这样一个空的基类,并且我们从中派生,那么这个空基类不会增加派生类的大小。在我这里的例子中,我有一个空的基类 Base。它没有任何非静态数据成员,只有一个函数 fun。我在 Derived 中派生自它。Derived 带有一个 int32_t。我在这里给了你一个特定的类型。如果我做 sizeof 测试,我们可以看到 Derived 的大小等同于一个 int32_t 的大小。这就是 EBO 的作用。好处是,和往常一样,我仍然可以访问基类的成员函数。所以,在我创建的 Derived 对象上,我可以调用来自基类的成员函数 fun。这就是我们想要的基本技巧。我们希望能够从某个东西派生。不付出任何代价,但能够调用函数。

我们经常这样做,或者标准库在我们只使用一个类型的 unique_ptr 时总是这样做。因为它带有一个所谓的默认删除器(default deleter)。这个默认删除器碰巧是一个空类。它唯一携带的是一个调用操作符(call operator)或父操作符(parent operator),取决于我们想怎么称呼它。而那个东西在指针上调用 delete。所以,这是一种非常花哨的“delete”说法。然而,它给了我们想要的抽象。因为那个东西是空的。现在,这是一个简化版的 unique_ptr 实现。unique_ptr,这里的简化实现,是一个类模板。它带有一个类型 T。这是我们通常提供的类型。但也有第二个模板参数,它被默认设置为我刚才展示的 default_delete 类,并用我的类型 T 特化。现在,在内部,unique_ptr 不是存储一个普通的 T* 和一个潜在的指向删除器的指针,而是存储一个叫做 compressed_pair 的东西。它被特化为删除器的类型和一个 T*(我提供的类型)。其余的,然后,我们有一个简化的选项,用一个参数构造 unique_ptr。大概是我们想交给 unique_ptr 的存储空间。希望是之前通过 make_unique 分配的。我们也可以选择向构造函数传递两个参数:指针和指向删除器的指针。就像我们在 fclose 例子中看到的那样。在第一种情况下,m_pair 只是简单地通过传递指针来构造。在第二种情况下,我们传递删除器和指向它的指针。其余的,你知道流程,unique_ptr 是不可复制的。所以我删除了复制操作。我实现了移动操作。现在魔法发生在析构函数中。在析构函数中,我们访问 m_pair 的第二个元素(m_pair.second)。如果那个是非空的,这就是我们的指针。然后我们调用 m_pair.first(m_pair.second)。所以 m_pair.first 是我们的删除器,m_pair.second 是我们的指针。

compressed_pair 的实现可以看起来像这样。这是使用 C++20 的。你说 compressed_pair 是一个类模板。它接受两个模板参数 TU。我在我的类中从中创建两个数据成员:firstsecond。我用 C++20 的 [[no_unique_address]] 标记它们两个。使用 no_unique_address,意味着 firstsecond 如果它们是类或类型并且是空的,那么可能不占用存储空间。所以,如果你手上有 C++20,这是你可以做的更好的技巧。当我开始研究这个并为这个演讲做准备时,至少 Clang 的 libc++ 实现有一个更复杂的实现。他们实际上有两个不同的类模板。他们判断基类是否为空。我今天早上检查了。今天早上大约是 7 点左右。GitHub 显示在那个时候(现在可能 24 小时了),他们在 19 小时前改变了他们的实现。基本上,如果他们在 C++20 模式下,他们做的正是这个。好的。所以这是你能做到的最简单的方法。其要点是,我们试图利用这样一个事实:如果我们有一个空的基类并且可以直接在其上调用调用操作符,我们就不需要存储任何指向我们正在调用的函数的指针。

修改我最初的例子。这是在 C++20 中我喜欢的一个方便的做法。我可以说我有一个使用别名的模板。它接受一个类型名 T 和一个 C++17 顺序的非类型模板参数。称之为 delete_fn。我正在创建这个使用别名 unique_ptr_deleter。它指向一个 T 类型的 unique_ptr。现在它使用了另一个 C++20 的元素。它在一个 lambda 上使用 decltype。所以在 C++20 中,我们可以在未求值的上下文中使用无捕获的 lambda。而 decltype 就是这样一个上下文。所以我在这里说,常规 unique_ptr 的第二个参数是一个无捕获的 lambda 的 decltype。它只接受一个 T* 参数。在内部,它调用我作为第二个参数 delete_fn 传入的函数,作用于我传入的对象或对象的指针上。有了这个,你现在可以说你正在创建一个 unique_ptr_deleter<FILE> 类型。并将函数 fclose 提供给它。然后你只需说 fopen(...)。你不再需要说第二个参数。美妙之处在于我的 unique_ptr 现在缩小到一个指针的大小。这就是我想要的。但我仍然可以调用像 fclose 这样可用的函数,而无需为此支付额外的代价。很好,对吧?

我们能做得更好吗?

好的,你们还在消化午餐。我理解。你们想做得更好吗?所以答案是可以,我们可以做得更好。如果我们使用 C++23。因为在 C++23 中,我们可以有一个静态的调用操作符(static call operator)或静态的下标操作符(static subscript operator),这对这段代码不相关。这意味着我可以有一个其调用操作符是静态的 lambda。我通过将关键字 static 放入 lambda 中来声明这一点。这意味着,就像每个静态成员函数一样,它不会带有隐式的 this 指针。或者,正如 C++20 中所指的,隐式对象参数(implicit object parameter)。这样,你至少节省了一条汇编指令。可能多达三条。不是很多,但确实减少了一点。好的。

到目前为止是关于 unique_ptr 的。让我们谈谈标准库中的另一种类型。短字符串(small string)。或者一般的字符串。如果你要实现一个字符串,我认为很快就能想到类似这样的东西:好的,我们的字符串需要一个数据成员来告诉我们当前字符串中有多少个字符。因为我们是从堆上分配的,所以我们需要把信息存储在某个地方。我们还有更多的容量吗?所以实际容量是多少?大小(size)永远不能大于容量(capacity),但可能相等。然后我们需要一个指向数据的指针,我们就完成了。

然而,仅仅为了一个、两个、三个字符,或者一小部分字符就跑到堆上分配,可能会很慢。所以我们不想为此付出代价。这就是为什么发明了短字符串优化(small string optimization, SSO),说:好的,如果我们有一定数量的字符,我们称之为小量字符,我们就不去堆上分配。因为我们的字符串实现内部附带了一个小的字符数组作为数据成员。在我的代码中,这是 m_sso_buffer。大小是 16,所以我在这里可以存储 16 个字符,然后才需要去堆上分配。实际上是 14,抱歉,是 15,因为我需要减一个给空终止符(null terminator)。

因为我现在有两种状态,我可能处于短字符串模式或长字符串模式,我需要一个布尔值来指示我处于哪种模式。假设我们讨论的是 64 位架构,这个结构体(struct)加起来是 48 字节。

如果我们参考 C++ Insights,并启用“显示填充信息”(show padding information)转换,那么我们可以在这里得到确认。大小是 48 字节,结构体整体的对齐要求(alignment)是 8。我有三个数据成员:size, capacity, 和我的 m_data 指针。它们都是 8 字节宽。然后是我的 SSO 缓冲区。你可以看到它也被置零了,因为我使用了统一初始化(uniform initialization)。然后我有布尔值 bool,大小为 1,一切都很好。然后我看到:7 字节的填充(padding)。它们是必需的,因为我可以在这里构建一个字符串数组。然后这个数组中的第二个元素也必须对齐。而且它只能在 8 字节地址上对齐。这就是为什么我需要填充字节。这些填充字节很糟糕。它们损害了你的性能。因为你的缓存行大小通常是 64 字节左右。我刚刚为了绝对的无用浪费了七个。有时重新排列数据成员会有帮助。在这个特定情况下,我无法以任何方式重新排列来摆脱这里的填充字节。或者也许这也没关系,因为我会说 48 字节对于那个字符串实现来说无论如何都太大了,对吧?现在,作为一个练习,就在你们的脑海里,以及在 YouTube 上稍后观看的人,他们有机会在此时暂停视频。我没有那么多时间给你们。问题是,如果是你,你必须实现这样的短字符串优化,如果限制是字符串的总大小不能超过 24 字节,你会怎么做?因为我剩下的幻灯片都是基于这个的。这不是标准库在现实中实际做的。他们有更多的灵活性。但为了使它们具有可比性,我把所有东西都限制在 24 字节。所以想想你会怎么做?更具体的问题是,你会想优化什么场景?

因为那总是关键。现在让我们看看。 现在我的浏览器消失了。很好。 来吧。我们到了。好的。这给了你们一点时间来思考这个问题。当然,这是故意的。

所以,我喜欢展示的第一个实现是 libstdc++ 的实现。那是默认情况下随 GCC 一起发行的标准库。他们在那里的做法是,他们有一个 struct string。他们以一个指向数据的指针开始。然后他们开始玩技巧。然后他们在里面有一个联合体(union)。因为他们意识到一个事实:在短字符串缓冲区中,我们不一定同时需要容量(capacity)。如果我处于短字符串模式,我知道我拥有的容量。它是我为此保留的缓冲区的大小。它是固定的。固定的。固定的。固定的。固定的。所以,我需要关于我刚刚分配了多少动态内存或者我一般拥有多少的信息。所以,他们这里有一个联合体,覆盖了 capacity 和短字符串缓冲区(m_buff)。这意味着他们的短字符串容量是 8 个字符。减去空终止符的 1 个,使得我们可以在短字符串中存储 7 个字符。记住,GCC 的原始实现能够存储更多。这是因为他们在这里把所有东西都限制在总共 24 字节。然后我们会看到几个反复出现的成员函数,静态成员函数。我们这里有一个叫做 m_capacity 的。它只是给你一个指示,对于这个字符串类型,我能存储的字符串的最大大小是多少。那就是你的 size_t 的大小。SSO_CAPACITY,如我所说,是 m_buff 的大小减 1。所以在这个例子中是 7,因为我们必须存储空终止符。然后我们有一个函数,通过检查提供的字符串的长度是否小于 SSO_CAPACITY 来简单地检查东西是否能放进短字符串缓冲区。然后我们有神奇的 is_long 函数。它检查我们是否处于长字符串模式或短字符串模式。它通过比较 m_pointerm_buff 来做到这一点。如果它们不相等,就意味着我们处于长字符串模式。否则,我们处于短字符串模式。因为如果你再往下看,在默认构造函数中,所有东西都被初始化为短字符串模式。m_pointerm_buff 初始化。在我有一个接受指针和大小的构造函数的情况下,我们首先在初始化 m_pointer 时检查字符串是否能放入 SSO。如果是,那么像之前一样用 m_buff 初始化它。否则,分配动态内存,分配我们所需的大小。更新大小。为了对 C++ 类型系统友好,用 m_buff 初始化联合体(使其成为活动成员)。然后在构造函数体中,如果是长字符串模式,更新容量(capacity)。使其成为活动成员。因为它是一个不完整的实现,接下来,当然,我们会复制数据。否则,那个练习就没有意义了。然后我有字符串中三个可能最重要的成员函数:size(), data(), 和 capacity()。在这个实现中,size()data() 超级简单。对于 size(),我可以直接访问 m_size 成员。对于 data(),我访问 m_pointer。快速而简单。对于 capacity(),我得到一个分支。我必须检查当前激活的长模式(is_long)。然后返回 m_capacity。否则,返回 SSO_CAPACITY。所以,如我所说,我不知道最初的动机是什么。但看看代码,我们可以说,嗯,这个实现似乎优化了对 size()data() 的访问。可能,这些是非常频繁使用的操作。因为如果我创建一个字符串,我会读取它。我可能会多次读取它。这需要访问 data()。可能甚至一直访问 size()。因为我不想遍历空指针。我想事先知道字符串有多大。capacity() 是在我向字符串追加内容时才需要的。这可能不太常见。我不知道。对于你的系统,可能不同。但这里的代码看起来优化目标是追求 size()data() 的速度。

我们能做得不同吗?当然可以。我们有三种常见的标准库,对吧?所以,这是来自微软标准库的微软实现。他们以一个联合体开始。他们说,我们让指针和短字符串缓冲区相互覆盖。因为我们不需要同时使用两者。然后他们有两个 size_t 成员:m_sizem_capacity。他们做的技巧有点不同。那个版本的最大容量也是 size_t 的容量。SSO_CAPACITY 和之前一样,在这个实现中是 8 字节。所以你能存储的仍然是 7。fits_SSO() 和之前一样。现在 is_long() 是不同的检查。当然必须不同。它检查容量是否超过了短字符串缓冲区的容量。因为这是该实现知道它处于长字符串模式的方式。

我们讨论的是同一件事,对吧?但方法不同。现在,默认构造函数是一样的。短字符串模式是默认的。接受两个参数的构造函数这里。它初始化 m_buff。我们必须先让联合体的一个成员成为活动成员。然后我们设置大小(m_size)。然后我们设置容量(m_capacity)。在那里我们判断,我们是在短字符串模式还是长字符串模式?通过检查字符串是否能放入短字符串缓冲区。然后我们相应地设置它。然后像之前一样在构造函数体中,我们检查是否在长字符串模式?如果是,我们从堆上分配数据。然后我们会复制数据。这改变了三个成员函数 size(), data(), 和 capacity() 现在。它们也可以直接通过访问数据成员 m_size 来获取大小。data() 现在需要分支。他们必须检查是否处于长字符串模式。然后他们返回 m_pointer。或者在短字符串模式,他们返回一个指向 m_buff 的指针。他们就是这样做的。而 capacity() 现在走第一条路径(没有分支)。所以可能心里有不同的优化目标。不同的性能设定。这个优化了两个简单的?有人猜到了吗?我的意思是,你们确实没有时间,所以这完全公平。

那么,看看 libc++ 做了什么,它来自 Clang,顺便说一下,它也是这三个中最新的。他们做得完全不同。他们一开始就说,暂时忽略第一行,那个 static unsigned 的东西。他们在里面有两个结构体(struct)。称它们为 normalsso。我提到过我喜欢嵌入式开发吗?他们开始进行位操作(bit fiddling)。因为他们现在说,在这个结构体(normal)中,第一位,最高有效位(MSB),我们称之为 large。它只有 1 比特大小。后面是 capacity,它是一个 size_t,但技术上也是一个位域(bitfield),它的比特数是 size_t 的比特数减一。因为 large 需要 1 比特。然后我们这里有成员 size,当然是 size_t,以及指针 data。所以,在 sso 模式,他们做得有点不同。他们说我们有 unsigned 和 8 个 char,但现在仍然是第一位,最高有效位。我们称之为 large。后面是一个现在只有 8 比特宽的 size 成员,再减一。所以,在短字符串模式下存储大小(size)只有 7 比特可用,正如我们所见,对于存储像 8 或 16 这样的字符串似乎已经足够合理。然后他们引入了填充字节。我说过我不一定喜欢它们,但它们在这里是为了让两个结构体保持一致。因为在这些填充字节之后,我们可以说 largesizepadding 等于 64 位平台上 size_t 的大小。现在他们把他们的短字符串缓冲区以 char 数组 data 的形式放置。它的大小是 normal 结构体大小减去一个 size_t 的大小。考虑到 normal 结构体大小是 24 字节,而 size_t 有 8 字节,我们有 16 字节的短字符串缓冲区容量。减去空终止符的一个字节。在这个实现中,你可以在不得不去动态内存之前存储 15 字节的有效载荷(payload)。所以,这似乎是他们优化的目标:尽可能大的短字符串缓冲区。他们把这两个(结构体)打包进一个叫做 members 的联合体里,包含 largesmall。你可以看到它一页放不下了。所以在下一页幻灯片中,我们有通常的候选项。max_capacity()。现在这是 size_t 比特数的 2 次方减一。所以,与其他两个实现能处理的最大容量不一样。另一方面,我的 SSO_CAPACITY,如我所说,是 15 字节有效载荷。这相当好。is_long()。现在检查这个我创建的打包联合体中的 small 成员的 large 位。那个位是置位(set)还是未置位(not)?默认构造函数构造一个短字符串。接受两个参数的构造函数这里也从初始化短字符串开始。然后在构造函数体中检查。提供的字符串长度是否适合 SSO?如果是,我们在 SSO 模式下更新大小。如果不是,我们翻转 large 模式中的位。更新大小,然后我们会复制数据。对于三个成员函数 size(), data(), capacity()。这意味着它们现在每次都必须检查我们处于哪种模式。所以,实际上保证在每种模式下你都会得到一个分支。但是,你拥有最好的 SSO 容量。如我所说,这取决于你想优化什么。什么对你的实现很重要。你可以做不同的事情。记住,目标是总大小为 24 字节。到目前为止你已经看到了三个实现。假设我们想优化 SSO。最大化 SSO 容量。我们能做得更好吗?是的,我们可以。Facebook。不。好的。很好。也许我们稍后应该聊聊。

所以,你们接下来将看到的是我从另一个 CppCon 2016 的演讲中学到的东西。这是 Facebook 发布的字符串实现。对于 YouTube 观众,你们现在可以暂停,去找 Nicholas Ormrod 在 CppCon 2016 的演讲。它在下一张幻灯片上有链接。我真的很喜欢那个演讲。只有 30 分钟。值得一看。我现在要剧透一点了。所以,对观众说声抱歉。请不要离开。希望我的演讲也很棒。

所以,如你所见,Facebook 在这里的做法,看起来和我们刚刚看到的 libc++ 类似。他们有一个 struct normal 和一个 struct SSO。他们这里不做位操作(bit fiddling)。他们说 normal 有一个数据成员,是 char* datasize_t sizecapacity。这里的注释说 capacity 实际上减少了 1 字节。记住这一点。SSO 容量或 SSO 缓冲区,他们在这里创建的结构体(SSO),大小是 normal 的完整大小。完整的 24 字节。所以,他们此时不做位操作的东西。他们像我们在 libc++ 中看到的那样,把两者打包进一个联合体(union members)。max_capacity()size_t 最大容量的 2 次方减 1 字节。SSO_CAPACITY,他们现在说是短字符串缓冲区的大小减 1。因为我们得存储空终止符,对吧?所以,那将意味着总共 23 字节的有效载荷。

听起来棒极了。但他们怎么知道短字符串缓冲区里有多少字符呢?此时他们只有一个变量,叫做 data。所以,他们做的是,他们有一个叫做 getModeByte() 的函数。那个模式字节(mode byte)从这个 SSO.data 成员中取出最高有效字节(most significant byte)。它读取那个字节。在它的 is_long() 模式中,它检查这个最高有效字节(第 7 位)是否激活(active)。Facebook 字符串的原始实现知道三种模式:短字符串(small)、中字符串(medium)和长字符串(long)。中字符串模式是第 8 位。这就是为什么它这样安排。所以他们检查第 7 位是否激活。现在,像其他实现一样,他们在默认构造函数中初始化所有内容为短字符串缓冲区模式。如果我们来到接受一个字符指针和长度的构造函数,他们也默认初始化为短字符串模式。然后他们检查,它是否适合 SSO?如果是,这就是魔法发生的地方。他们说,给我短字符串数组的最高有效字节。在那个字节中,他们存储 SSO_CAPACITY 减去当前长度(len)。所以,他们不是存储这个字符串当前拥有的字节数,而是存储直到我们达到容量末尾还剩下的字节数。现在,这意味着他们计算的那个数字,你存储在 SSO 缓冲区中的字符串越大,这个数字就越趋向于零。直到最终达到零,当没有空间剩下时。而美妙之处在于,零恰好也是空终止符的值。所以,他们将字符串中的字符数(或剩余字符数)和空终止符合并在一个字节中。这就是为什么他们可以在短字符串中存储 23 字节的有效载荷。太棒了。

其他事情。constexpr。我总是很难告诉人们 constexpr 真的很棒。而这个现在是我能做的最好的例子。它来自一个咨询项目。我被叫进去。一个客户快用完了 RAM。他们有一个小型嵌入式设备,只有几千字节的 RAM。经过一点调查,我发现这个设备主要做的是在网络中发送消息,大多是字符串。其中一些是在编译时构造的。另一些是运行时生成的字符串。所以,我偶然发现了他们代码中的一个类。它叫做 fixed_string。它或多或少类似于我们即将为 C++26 标准化的东西。它是一个类模板。它接受一个大小 size_t N。这个 N 用于在这个 fixed_string 类中创建一个 char 数组。它还带有一个 size 来告诉这个字符串有多大。它有一个默认构造函数。它有一个接受 const char* 的构造函数,获取长度,复制所有内容,作为 size() 成员函数。以及一个将其转换为 string_view 的函数。注意我用了很多次的东西。你会这样使用它:好的,我在栈上某处创建一个 fixed_string。我命名那个东西的大小,并在编译时将我的字符串放入其中。我发现的是,毫不奇怪,开发人员有时有点懒,数字符并不是我们真正热衷的事情。所以,很多人做的是说,哦,是的,我需要那个字符串。哦,是的,50 个字符,应该够了,对吧?不管它是 24 还是 26,谁在乎呢,它能编译通过。所以他们过度分配了(over-allocated)。他们意识到了这一点,并提供了这个 make_string 函数,也在幻灯片上。它做了这个模板技巧(template rig),推导出数组的大小,并用该大小构造一个 fixed_string。所以它是一个完美裁剪大小的 fixed_string。当我看到这段代码时,我开始问,为什么不把它做成 constexpr?他们告诉我,不,那没有帮助。它已经是 static 了。它已经是 const 了。constexpr 能做什么?长话短说,我们争论了很久,我说服了他们,好吧,让我们试试看,对吧?能有多糟呢?我的意思是,只是尝试一下而已。这里是幻灯片上的代码。我可以把它放大一点。这是幻灯片上的代码。有一个变化。你可以看到我在这里有大写的 CONSTEXPR。这是一个宏。左边的编译器定义这个宏为空(nothing)。另一个编译器定义这个宏为 constexpr。明白吗?所以,这是这里的想法。两者在 -O0 下运行。所以我有两个不同的编译器运行。一个做 constexpr 操作。另一个像原来那样做。有人猜出在 -O0 下有什么区别吗?所以,我可以告诉你,在 -O0 下,原始实现生成了 209 行汇编代码。让我们忽略 Compiler Explorer 中有一些空行可能不计入的事实。我的 constexpr 版本在 -O0 下,不确定你是否猜到了,是 78 行汇编代码。但是,谁会用 -O0 呢,对吧?所以,让我们做一些合理的事情。两者都用 -O3。所以,-O3 下原始版本看起来好多了。42 行汇编代码。这是我们想看到的,对吧?那么,再来一次,为什么做 constexpr 操作?哦,是的。看看那个。是 31 行汇编代码。所以,仍然更少。也许不再那么显著了,但仍然更少。关键是,汇编代码的行数不是这里最重要的东西。让我带你看看这段代码。这是你不想要的。因为这段代码所做的是在运行时初始化你的静态全局对象,将 ROM 中的数据复制到 RAM 中。所以,这段代码意味着你再次在 RAM 中分配了 ROM 中已经存在的相同内容,然后你花费时间将其复制过去。所以,constexpr 和非 constexpr 版本在汇编行数上的区别在于,在右侧(constexpr 版本),这段代码不存在。所以,不仅它不在那里,而且这项工作也不需要做,你也没有用已经存在于 ROM 中的信息浪费你宝贵的 RAM。如果你想知道那东西有多棒,因为我认为它甚至更好。看看那个。GCC 显示得比… Clang 显示得比 GCC 好,但两者效果一样。在这个版本中,编译器完全理解了这个对象 fixed_string 的布局。第一个四字(quad)包含长度(length),这是我的 size_t,后面是字符串,即数组。这里的 .asciz 表示它是一个字符串,后面是,因为我过度分配了 28 个置零的字节,再后面,因为它没有最优对齐,是 6 个填充字节。所以编译器在这里完全理解了我这个对象的布局。你可以看到这里我的对象 x,当我使用它时,我在下面打印它,编译器知道它可以从 ROM 中获取它。这就是 constexpr 的力量。它不仅仅是说,哦,我很接近,或者它可能没有帮助。它真的能精简东西。

所以,长话短说,他们在最后一天把所有东西都塞进去了。这是我目前能向你展示的关于 constexpr 对你的代码意味着什么的最好例子。因为我确信我不想为左边编译器(非 constexpr)所做的事情付出代价。那只是浪费。这是少数情况之一,通常在我的经验中,这两者不会同时出现,但这次演讲的标题是“快速且小巧”(Fast and Small)。通常你必须决定是要快还是要小。这里两者兼得,这非常罕见。

好的,我们还有什么?有个东西叫 std::initializer_list,对吧?你可能知道,当你在使用统一初始化(uniform initialization)和像 std::vectorstd::liststd::map 或者你自己的数据类型时,你会用到它。除了简单地创建对象并传递给这些类型的构造函数之外,我也可以在函数栈上创建一个 initializer_list,并调用另一个函数,将这个列表传递给它。我在这里使用了一个简化版本,没有类和构造函数,我们通常会这样做,嗯,消除任何干扰。所以我这里有这几行代码。如果我们再次去 C++ Insights,它带有另一个转换,“显示 std::initializer_list”(show std initializer list)。所以如果我在这里做转换,那么以防你不知道,你的 std::initializer_list 在后台,编译器总是执行这个转换。有一个所谓的后备数组(backing array)。编译器创建一个 const 数组,其元素类型是 std::initializer_list 的底层数据类型,大小等同于 std::initializer_list 中的成员数量。并将你提供给 std::initializer_list 的所有元素复制构造到这个 const 数组中。真正的 std::initializer_list 只是一个指针和一个长度。在大多数实现中,至少,它们存储这个。所以当我们传递它时,它是一个非常廉价的数据类型,但它是一个非拥有(non-owning)的数据类型。这里的后备数组在栈上。它不在 std::initializer_list 内部。好的。这就是编译器所做的。非常好。我为什么要告诉你们这个,对吧?嗯,让我们暂时忘记 std::initializer_list。假设我们有一个不同的场景代码,老实说我从未写过。假设我有一个函数 receive。它接受一个 const int list[4],大小为 4 的数组。只是一个随机数。然后我有函数 fun 和之前一样。fun 在栈上创建一个大小为 4 的 const int list[4]。放几个值进去。调用 receive 并传入它。

它基本上就是一个 std::initializer_list,只不过不是 std::initializer_list,对吧?但它帮助我们更容易地推理这个问题。所以如果我在 Compiler Explorer 中看这个,顺便说一下,这个首先是用 -O3 运行的。值 3,4,5,6 是常量。它们在 ROM 中。在 fun 中,当我使用它们时,它们是 memmove。它们再次从 ROM 移动到 RAM。所以这有点像我们在 constexpr 例子中看到的,我们为了一些可能不想付的东西付出了代价。空间和时间。空间和时间。我不喜欢那样。不知道你们怎么想,但我不喜欢。

如果我们改变游戏规则会怎样?如果我们说… 把这个列表(list)设为 static,现在函数 fun 只消耗两行汇编代码,因为再次像在 constexpr 例子中一样,它可以引用 ROM 中已有的内容。如果没有 static,你必须为拷贝付出代价,其原因是,即使在 -O3 下,编译器优化掉这个 memmove 的唯一方式是它能证明 receive 不会递归地调用 fun,因为标准中有一个保证:如果你有递归,你在函数中创建的每个块局部变量(block local variable)在每次递归步骤中都有一个唯一的地址。这迫使编译器尽管可能有更好的知识,也必须将数据从 ROM 复制到栈上的特定区域用于这个单独的步骤。它无法在这里证明没有发生递归,因为我没有提供 receive 的实现,它只看到了前向声明(forward declaration),它此时没有机会。如果它能看见实现会更好,但仍然取决于它们是否认为它足够可证明以执行此优化。如果我们像这里一样把它设为 static,那就没问题了,编译器可以优化掉它,因为它知道在整个函数中它只存在一次,无论我们有多少次递归步骤。所以这等同于我们的 std::initializer_list,这就是我一开始提它的原因。

所以,如果我修改开头的例子,说:好的,对于 std::initializer_list,就像我原来那样,我现在告诉你的这一切对你有什么帮助?你无法控制后备数组(backing array)。在这种情况下让 std::initializer_list 变成 static 对你没有帮助,完全没用。而且如果你在创建 std::vector,你不是手动创建 std::initializer_list 的,所以你根本没有机会让它变成 static。现在,如果你在 GCC 中看这个例子,顺便说一下是 GCC 13.3,开了 -O3,我们可以看到,是的,它翻译成了我刚才展示的样子。编译器当然理解我们这里有常量,我们为 memmove 付出了代价,因为这是完全相同的场景:编译器必须考虑这个东西会被递归调用,所以它必须复制数据并必须保证我们有一个唯一的地址。然而,在这个特定例子中,编译器可以说:我知道所有的初始化器(initializers)在编译时都是已知的,如果我们稍微修改一下标准,给我们更多自由,说也许我们此时不需要后备数组的唯一地址,那就会好很多,对吧?

到目前为止我展示的所有例子都需要你编写代码,我想你们喜欢这样。而这里这个需要你“翻转开关”(flip the switch)。如果你切换到 GCC 14.2,你可以看到函数 fun 变得短多了,因为在 C++26 中,我们做了一个措辞更改(wording change),允许编译器在例如所有初始化器都在编译时已知的情况下,将这个后备数组设为 static。这可能会稍微改变你的程序,因为现在在一个递归中,你可能会看到 std::initializer_list 的指针始终相同,但这就是 C++26 所说的。如果你仔细看我在这里展示的内容,你可能发现我所说的可能与幻灯片不完全匹配,因为我启用了 C++17。我们在 C++26 中引入的内容被认为是一个缺陷报告(Defect Report, DR),它可以追溯性地(retroactively)应用到所有以前的标准。所以如果你的编译器想这样做,就像 GCC 那样,它们可以为所有以前的标准做这件事。所以这是一个你需要新编译器的变更,到目前为止我认为只有 GCC 实现了,至少 Clang 没有,而且你不必切换到新标准,你不必迁移到 26,它可以直接生效。有时升级编译器会有好事发生,对吧?不一定总是,但有时会。

好的,这就是我的内容了。希望你们喜欢。如果你们对某个主题更感兴趣,我正在举办一个会后研讨会(post conference workshop),我们还有一些座位空着,所以如果你们还想加入,可以报名。我们还有几分钟时间提问,所以在开放麦克风之前(我们在左边和右边为你们准备了麦克风),让我说一句:我感觉有点精疲力尽了(I’m fertig)。谢谢。

(观众鼓掌)

谢谢。谢谢你的演讲,非常有启发性。你能展示一下其中一个联合体吗?比如 Visual Studio 或 Clang 的版本?最后一个例子里的?

是的,嗯,其中一个联合体。两个联合体中的一个,所以让我看看,等等。我猜它们都是三个联合体,但 GCC 做法不同。所以要么 Visual Studio 要么 Clang,我对两者有相同的问题。所以你说的是哪个?

(指向幻灯片)

是的,这个不错。这个不错。

函数 getModeByte 或者对于 Visual Studio 是 is_long。每次读取联合体的特定版本,不管那里放了什么,从技术上讲,无论你放什么进去,总是读取联合体的那个特定版本,从技术上讲是不是未定义行为(UB)?你是指因为我总是访问联合体的 small 部分,对吧?我理解你想用位操作之类的东西,但从技术上讲,读取你未曾放置数据的那个联合体版本是不是未定义行为(UB)?

从技术上讲,是的,据我所理解是两件事。首先,如果你知道你的编译器允许这种行为,那就可以,因为 libstdc++ … 嗯,他们是库,但如果我要写类似的东西,我不会…我不能写。不仅仅是我理解的那样,我还听说一旦 std::string 变成 constexpr,你就遇到了问题,因为那时这就是未定义行为(UB),他们不得不修改它,但我没有时间深入研究。所以我看到的第一个 constexpr 版本,他们说我们总是去堆上分配,并且不检查 SSO 模式。我们知道是在常量求值(constantly evaluated),所以我们不做未定义行为(UB)的事情。但他们最近改变了它,以便在编译时也能拥有短字符串。但你是对的,我还没搞清楚。

使用 bit_castbit_cast 现在在 constexpr 中可用,使用 bit_cast 本质上是 memcpymemcpy 能解决这个问题吗?

不,我不这么认为,因为你仍然需要访问非活动成员。

(观众插话)但你知道,你不需要访问任何成员,你只需说 memcpy 24 位出来,查看位模式,然后查看那个位。

啊,我明白了,是的,是的,也许他们就是那样做的,是的,有可能。

(另一位观众)这正是我们做的。这就是我们做的。或者类似的东西,是的。

好的,所以记录一下,至少有一位参与者在这里正是这样做的。所以 memcpy 或者使用 bit_cast 从联合体获取一个值,然后安全地评估那个位。好的。是的,谢谢。

(演讲者)我不确定谁先说的。

好的,是的,所以谢谢你的见解,作为一个德国同胞,时差也让我感觉精疲力尽(fertig)。

(观众)很好。

我的问题也是关于短字符串优化的,因为你提到我们总是看的三个最终函数 size, capacitydata,它们可能有不同的优化目标,因为可能会得到分支,对吧?所以我的问题是,我们会得到一个分支吗?它们都应该像 CMOV(条件移动)指令一样,甚至是无分支编程,比如获取标志,做位掩码,但如果我们正确地做,那里不应该有分支,它应该坍缩成一条汇编指令。也许对于那些不需要做任何事情的,以及两三条指令给其他情况。所以它应该是可以忽略的。

(演讲者)它似乎远非那么简单。所以例如 GCC 的实现,我把它放上去,libstdc++,他们的 is_long 检查,他们似乎只比较 m_pointerm_buff。这比微软的版本更昂贵,微软的版本是比较两个整数,因为这里我们需要访问 m_buff 的地址(一个数据成员的地址)。而我看到的代码,当我比较时,至少与你的期望不符。我这么说吧。我理解似乎一切都应该整合在一起,但似乎仍然有代码差异,而这正是实现真正不同的地方。这可能源于年代,所以 libstdc++ 通常,我会说… 不,MSVC 更老,然后是 libstdc++,但他们不得不重写字符串实现,所以是的,他们可以加入不同的优化思路。

(观众)好的,谢谢。

(演讲者)不客气。

你好。谢谢你的演讲。

(观众)嗨。我想我的问题与 Fedor 的问题类似,但似乎所有真正酷的短字符串优化字符串不仅依赖联合体的事情,还实际依赖多个编译器扩展和非标准 C++ 行为。比如打包结构体(packed struct)甚至是位打包位域(bitpacking bitfield)技巧,这不符合标准,因为语言没有规定位域是按你写的顺序排列的。根据标准,那两个位可以在别的地方,但在实践中,大多数编译器可能把它们放在相同的位置。所以我的问题是,你能用标准 C++ 写一个这样的东西吗?因为它们似乎依赖许多非标准的技巧来实现所有真正酷炫的花招。

(演讲者)据我所知,如果我没有遗漏什么的话,libstdc++ 和微软的版本是符合标准的。他们没有做任何… 他们使用了联合体。他们有效地访问成员。所以他们没有做任何我知道的不符合标准的事情。

(观众)但它们只得到 7 个字节。它们只得到 7 个字节,对吧?它们不是那些得到 15、23 字节的酷炫版本。

(演讲者)所以从剩下的来看,如我所说,我不确定 libc++ 最近做了什么,因为 constexpr 使他们改变了实现。我认为 Facebook 的实现另一方面,它们也可以是符合的,因为它们基本上只是… 嗯,查看一个字节。所以我认为 libc++ 版本是我最不确定它是否符合标准的那个。

(观众)那我该去读 Facebook 的那个了。

(演讲者)完美。去做吧。

(观众)谢谢。

嗨。我一直在盯着这张幻灯片看,有什么东西让我困扰,我刚刚注意到是什么了。

(观众)完美。is_longm_pointer 不等于 m_buff。这在不太可能但可能的情况下会有一个 bug:如果你分配缓冲区的指针碰巧和你分配的容量大小相同。你可能会得到一个错误的肯定(false positive)。如果指针值恰好因为某种原因和你的容量相同,不太可能。假设指针值是 1,你分配了一个元素,那么两者都是 1,对吧?

(演讲者)是的。

(观众)是的。也许你应该为 libstdc++ 提交一个错误报告。我不知道他们怎么说。但是的,抓得好(good catch)。

(演讲者)谢谢。

好的。我看到没有更多问题了。那么祝你们会议剩下的时间愉快,再见。谢谢。

(观众鼓掌结束)