C/C++ 现代语言与底层 · 第 三 篇

值语义的账:move/拷贝/SSO/emplace_back

2026-10-1316,447 字37 张配图

本文由@溯光 出品 站点:https://yyy3d.online/blog/ | 本系列:https://yyy3d.online/blog/#topic-cpp-language-foundations 值语义账本总览——拷贝/move/编译器消除/容器优化/类型分类五层脉络 图 值语义账本总览——拷贝/move/编译器消除/容器优化/类型分类五层脉络 @ 本系列绘制


背景与边界

本系列已发表篇目: 1. 【C/C++ 现代语言与底层】(一)对象布局、对齐与 padding:sizeof 的真相 2. 【C/C++ 现代语言与底层】(二)分配器解剖:malloc → jemalloc/mimalloc → pmr arena 3. 本篇(三)值语义的账:move/拷贝/SSO/emplace_back

全系列共 12 篇,分四波连载。完整内容总览见系列首页。

上一篇讲的是「内存从哪来、什么时候还」——也就是分配的账。这一篇接着问另一半:对象造出来之后,搬它要付什么代价?拷贝它呢?传参呢?返回值呢?

你可能听过很多说法:"移动语义零开销""RVO 把拷贝优化掉了""emplace_back 比 push_back 快"——但这些说法哪些是真的、哪些有条件、哪些在什么编译器选项下才成立?std::move 真的不花钱吗?const& 和 && 重载到底选哪个?return 一个大对象,到底有没有拷贝?

这些问题属于值语义(value semantics)——不是"内存从哪来",而是"内存里的东西怎么搬、怎么借、怎么偷"。它跟分配是两本账:分配问的是"向谁要",值语义问的是"要几份"。

本篇的方法跟上一篇一样:同一份探针、多个编译选项、逐成本拆开量。 所有"代价多少"的结论都有本机实测支撑,所有"规范怎么说"的结论都引标准原文或官方文档。

0.1 一个钩子:push_back 到底调了几次构造

先看一个最简单的问题。把一个 std::string 塞进 vector,到底会触发几次构造函数、几次析构函数?

std::vector<std::string> v;
v.push_back("hello world");

答案是:不一定。 可能是 1 次、2 次、甚至 3 次——取决于你传的是左值还是右值、开不开优化、字符串长度有没有超过 SSO 阈值、vector 有没有扩容。

下面这张图是本机跑出来的计数探针输出。同样是 push_back 一个字符串,四种写法,构造/析构次数完全不同:

钩子实测:一个 std::string 被 push_back 进 vector 的构造/析构次数探针输出——四种写法代价截然不同
题图01 钩子实测:一个 std::string 被 push_back 进 vector 的构造/析构次数探针输出——四种写法代价截然不同@ 本系列本机自跑(MSVC 19.44 / g++ 16.2.0)

看到这张表的第一反应通常是"怎么差这么多"——而这恰恰是本篇要算清的账:同样一句 push_back,背后可能有完全不同的成本结构。 你以为只是"加一个元素",编译器可能在帮你做拷贝、做移动、做消除、做扩容——每一步都有代价,每一步都有可能被省掉。

0.2 为什么值语义值得单独讲一篇

因为它是每天都在收费的那种知识,只不过账单藏在编译器背后。

  • 它决定了你的函数传参到底贵不贵:传值 vs 传 const& vs 传 &&,在不同类型上代价天差地别。一个 int 传值比传引用还快,一个 std::string 传值可能多一次 malloc。
  • 它决定了容器操作的真实成本:push_back、insert、erase、扩容——这些操作的代价全取决于元素类型的值语义特性。移动构造是 noexcept 还是可能抛异常,直接决定 vector 扩容时用 move 还是 copy。
  • 它决定了你写的"优化"到底是不是优化:很多人喜欢在 return 后面加 std::move,觉得"反正移动更快"——但这反而可能阻止 NRVO,让代码变慢。
  • 它决定了标准库为什么长这样:std::string 的 SSO、emplace_back 的存在、std::swap 的 ADL 规则——这些设计都不是语法糖,而是值语义账本上的"优化项"。

一句话:值语义不是语法糖,是一份性能契约。 契约签了,你就得付账——但付多少、什么时候可以免单、哪些项目你以为付了其实没付,这就是本篇要算的。

0.3 本篇不涉及什么

老规矩,边界写在前面:

  • 不涉及分配器内部实现——那是 ep02 的事。本篇只在"拷贝代价"层面提及分配开销,不深入 malloc/free 的实现。
  • 不涉及对象布局与 padding——那是 ep01 的事。本篇用到 sizeof,但不展开对齐和填充规则。
  • 不涉及模板元编程与 type traits 的实现——只使用 is_trivially_copyable_v 等结果,不讲 SFINAE 和模板推导机制。
  • 不展开完美转发与引用折叠的细节——点到为止,不把 std::forward 的所有分支全铺开讲。
  • 不涉及异常机制的底层实现——那是 ep09 的事。本篇只讲 noexcept 对移动语义的影响,不讲零成本异常模型。
  • 不涉及 Unicode 与编码——std::string 是字节串,编码问题不展开。
  • 不全面讲解 std::string_view——只在必要时提及,不做专题。
  • 工具链基线:本机 MSVC 19.44(VS 2022)与 msys64 g++ 16.2.0,双平台 x64。libc++ 数据来自官方文档,未本机验证。

OK,账本摊开,从第一笔开始算。


一、一句话点题:值语义不是语法糖,是一份性能契约

1.1 本章问句:值语义到底"值"在哪?

先问一个最基础的问题:C++ 为什么默认是值语义?为什么不像 Java 那样默认引用语义?

答案跟性能有关。值语义意味着编译器知道对象是独立的——拷贝之后两份互不影响。 这份"独立性"是编译器能做很多优化的前提:它可以把对象存在寄存器里、可以消除冗余拷贝、可以用 memcpy 代替拷贝构造函数。引用语义做不到这些,因为两个引用可能指向同一个对象,改一个另一个也变——编译器必须假设所有指针都别名(aliasing),优化空间大大缩小。

但值语义也有代价:每次拷贝都是一次完整的复制。 一个带堆内存的对象,拷贝一次就要 malloc + memcpy + 析构——这笔账不小。所以 C++ 的做法是:默认给你值语义的确定性,同时给你一整套工具来控制拷贝的代价——移动语义、copy elision、SSO、emplace_back……这些不是"语法便利",而是值语义契约下的成本控制手段。

值语义账本总览——拷贝/move/编译器消除/容器优化/类型分类五层脉络
图 3-102 值语义账本总览——拷贝/move/编译器消除/容器优化/类型分类五层脉络@ 本系列绘制

这张图就是本篇的账本结构。从上往下算:先算拷贝本身要花什么(第二章),再算 move 能省多少(第三章),再算编译器替你免了多少(第四章),再算容器和标准库帮你优化了多少(第五、六章),最后算类型本身的属性决定了什么(第七、八、九章)。

1.2 契约的三方签字

跟 ep01 的 sizeof 一样,值语义这份契约也是三方签的:

  • 语言标准:规定了拷贝构造、移动构造、析构的语义;规定了什么情况下编译器可以省略拷贝;规定了 trivially copyable 的判定条件。
  • 标准库实现:std::string 的 SSO 怎么做、vector 扩容用 move 还是 copy、emplace_back 怎么转发——这些是标准库的实现选择,直接决定了你实际付多少钱。
  • 你写的代码:你的类有没有自定义拷贝构造?移动构造标没标 noexcept?返回值写不写 std::move?用 push_back 还是 emplace_back?——这些是你自己在契约上的选择。

这三方里,最容易出问题的是第三方。很多人写了移动构造却忘了标 noexcept,结果 vector 扩容时根本不用;很多人到处用 emplace_back 代替 push_back,其实传的是同一个对象,根本没区别;很多人返回值加 std::move,反而阻止了 NRVO。

值语义的账,大头往往不是"编译器没优化",而是"你以为优化了、其实没有"。 这也是本篇为什么要做实测——感觉不可靠,只有计数器和反汇编说了算。

1.3 五个反直觉结论预告

在进入正文之前,先把本篇要推翻的五个常见误解列在这里。读到对应章节时你可以回来打勾:

  1. std::move 本身不移动任何东西——它就是个 static_cast,零运行时开销。真正偷资源的是移动构造函数。
  2. 短字符串的 move 不比 copy 快——SSO 让数据存在对象内部,没法偷指针,只能 memcpy,移动退化为拷贝。
  3. return std::move(local) 反而更慢——它阻止了 NRVO,把"零拷贝"变成了"一次移动"。
  4. emplace_back 不是万能的——传对象时和 push_back 一样快,传构造参数时才有收益,还有坑。
  5. 移动构造不标 noexcept = 白写——vector 扩容时会选拷贝而不是移动,因为移动失败了没法回滚。

这五条里,只要有一条你之前不知道,这篇就值了。


二、拷贝的账本:构造/赋值/析构的三板斧

2.1 本章问句:一次拷贝到底调用了几个函数?

很多人对"拷贝"的理解就是"复制一份"。但在 C++ 里,拷贝不是一个动作——它是构造 + 赋值 + 析构三板斧的组合。不同的场景下,组合方式完全不同。

先看一个最简单的探针类。给它的每个特殊成员函数都打上计数器,就能精确看到每次操作到底调了谁:

struct Counted {
    static inline int ctor = 0;
    static inline int dtor = 0;
    static inline int copy_ctor = 0;
    static inline int copy_assign = 0;
    static inline int move_ctor = 0;
    static inline int move_assign = 0;

    std::string data;

    Counted() { ++ctor; }
    Counted(const char* s) : data(s) { ++ctor; }
    ~Counted() { ++dtor; }
    Counted(const Counted& other) : data(other.data) { ++copy_ctor; }
    Counted& operator=(const Counted& other) {
        data = other.data;
        ++copy_assign;
        return *this;
    }
    Counted(Counted&& other) : data(std::move(other.data)) { ++move_ctor; }
    Counted& operator=(Counted&& other) {
        data = std::move(other.data);
        ++move_assign;
        return *this;
    }
};

用这个探针,就能把每种场景下的"账目"精确算出来。先看最基础的三个场景:

拷贝三板斧计数面板——按值传参、拷贝赋值、析构各触发几次(探针类双编译器实测)
图 3-203 拷贝三板斧计数面板——按值传参、拷贝赋值、析构各触发几次(探针类双编译器实测)@ 本系列本机自跑(MSVC 19.44 / g++ 16.2.0)

从这张表里能读出几个关键事实:

  • 按值传参:调用 1 次拷贝构造函数(在参数位构造),函数返回后调用 1 次析构。代价 = 1 次深拷贝 + 1 次析构。
  • 拷贝赋值:a = b,a 和 b 都已存在。调用 1 次拷贝赋值运算符。代价 = 1 次深拷贝(释放旧资源 + 拷贝新资源)。
  • 按值返回:这里先不展开,第四章讲 copy elision 时细说——你以为是 1 次拷贝构造 + 1 次析构,编译器可能一次都不调。

2.2 拷贝的真实代价:从 string 深拷贝看起

光看计数不够,还得看每次拷贝到底花了多少时间。拿 std::string 来说,一次深拷贝的代价是分配器级别的——要 malloc 一块新内存、memcpy 过去、原字符串析构时还要 free。对于几十字节的短字符串可能感觉不到,但对于几 KB 甚至更大的字符串,这几笔开销加起来就很可观了。

把 std::string 的拷贝赋值反汇编拉出来看看(MSVC,x64,Release),这是最直接的证据——编译器到底生成了什么指令,一看便知:

std::string 深拷贝反汇编——operator= 内部的 malloc + memcpy 调用链
图 3-304 std::string 深拷贝反汇编——operator= 内部的 malloc + memcpy 调用链@ 本系列本机自跑(MSVC 19.44 / dumpbin /disasm)

可以看到,一次长字符串的拷贝赋值至少要走这几步:

  1. 判断是否自赋值(或者用 copy-and-swap 间接保证)
  2. 释放旧内存(如果原字符串是堆分配的)
  3. 分配新内存(malloc / operator new)
  4. 拷贝数据(memcpy / memmove)
  5. 更新大小和容量

这里面最贵的是第 2 和第 3 步——malloc/free 的代价远比 memcpy 大(回链 ep02)。一个 100 字节的字符串,memcpy 只要几纳秒,但 malloc/free 可能要几十甚至上百纳秒。

这也是为什么移动语义这么重要:如果能"偷"指针而不是重新分配,就能省掉 malloc/free 这两笔大头。

2.3 Rule of Five / Rule of Zero

说到自定义拷贝构造,就不得不提 Rule of Five(五之法则):

如果你自定义了析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符中的任何一个,你通常应该把五个全写了。

标准在 [class.copy.ctor] 和 [class.copy.assign] 两节中分别规定了拷贝/移动构造函数和拷贝/移动赋值运算符的语义与合成规则。为什么自定义了一个就要全写?原因很简单:如果你需要自定义析构函数(比如要释放资源),那默认的拷贝构造一定是错的(浅拷贝会导致 double free)。既然拷贝要自己写,移动也得考虑——所以五个全写。

但比 Rule of Five 更重要的是 Rule of Zero(零之法则):

如果你不需要自定义任何一个特殊成员函数,那就一个都别写——让编译器自动合成。编译器合成的版本通常是最优的(trivial 的话能直接 memcpy),而且不会错。

说句实在话,大多数类都应该遵循 Rule of Zero。 你不需要自己写析构函数——用 std::unique_ptr、std::vector、std::string 这些已经管理好资源的类型当成员就行。它们替你管资源,你的类就自动拥有了正确的拷贝/移动/析构语义,而且编译器还能做更多优化。

2.4 拷贝 vs 移动:先看一眼差距

在进入移动语义之前,先给大家看一个直观的对比:长字符串的拷贝构造 vs 移动构造,耗时差多少?

拷贝 vs 移动性能基准——长/短字符串的拷贝构造与移动构造耗时对比
图 3-405 拷贝 vs 移动性能基准——长/短字符串的拷贝构造与移动构造耗时对比@ 本系列本机自跑(MSVC 19.44 / std:c++20 /O2)

这是本机用 Google Benchmark 跑出来的结果(越过 SSO 的长字符串,1KB 左右)。可以看到:

  • 拷贝构造:~180 ns/op(要 malloc + memcpy)
  • 移动构造:~4 ns/op(只交换三个指针/大小/容量)

差了 40 多倍。这就是移动语义的价值——当对象持有堆资源时,移动比拷贝便宜一个数量级。

但这里有个前提:越过 SSO 的长字符串。 如果是短字符串(比如 "hello"),移动和拷贝的差距会小很多——甚至完全一样。这个反直觉结论放到第五章 SSO 那一节细说。

最后补一张栈帧示意图,帮大家建立"拷贝发生在哪"的空间感:

栈上对象布局示意——值语义拷贝发生在栈帧之间,参数传递和返回值都涉及深拷贝
图 3-506 栈上对象布局示意——值语义拷贝发生在栈帧之间,参数传递和返回值都涉及深拷贝@ 本系列本机绘制

图 栈上对象布局示意——拷贝发生在栈帧之间 @Wikimedia Commons,File:Printf_stack_padded_2.svg,CC BY-SA 3.0

这张图是经典的栈帧示意。函数调用时,参数和返回值在栈帧之间传递——每一次按值传递,都是一次拷贝(或移动)。理解了这个空间感,后面讲 RVO 和 move 时你就会更容易代入。


三、move 的真相:右值引用、std::move 与移动构造

3.1 本章问句:std::move 到底移动了什么?

先给答案:std::move 什么都不移动。

这是本篇第一个反直觉结论,也是最重要的一个。很多人以为 std::move 是一个"把对象搬走"的函数——它把资源从一个对象移到另一个对象,所以叫 move。但 std::move 只是一个类型转换——它把左值变成右值引用,仅此而已。

真正"偷资源"的是移动构造函数和移动赋值运算符。std::move 的作用只是让重载决议选中移动版本,而不是拷贝版本。它是一个信号,不是一个动作。

为了把这件事讲透,得先从 C++ 的值分类体系说起——因为右值引用这个东西,只有放进值分类的完整框架里才能真正理解。

3.2 值分类全景:五分类体系

C++11 之后,值分类(value categories)从传统的"左值/右值"二分法,变成了五分类体系。标准在 [basic.lval] 这一节给出了完整定义:

值分类五分类全景——lvalue / rvalue / xvalue / prvalue / glvalue 关系与区别
图 3-607 值分类五分类全景——lvalue / rvalue / xvalue / prvalue / glvalue 关系与区别@ 本系列绘制

五个分类,两两组合:

  • lvalue(左值):有身份、不可移动。比如变量名 x、解引用指针 *p、字符串字面量 "hello"。
  • prvalue(纯右值):无身份、可移动。比如数字字面量 42、临时对象 std::string("hi")、函数返回值(C++17 后语义变了,第四章细说)。
  • xvalue(将亡值):有身份、可移动。比如 std::move(x) 的结果——它有名字(有身份),但它被标记为"可以被偷"(可移动)。

然后两个组合概念:

  • glvalue(广义左值) = lvalue + xvalue("有身份"的那一类)
  • rvalue(右值) = prvalue + xvalue("可移动"的那一类)

为什么要搞得这么复杂?因为移动语义需要区分"能不能偷"——右值引用 T&& 只能绑定到 rvalue(prvalue 或 xvalue),不能绑定到 lvalue。这样重载决议就能自动区分"可以偷资源的对象"和"不能偷的对象"。

右值引用这套体系最早由 N1690 提案引入 C++11,核心目的就是让移动语义在语言层面有可靠的类型保障——不用靠约定和注释,编译器自己就能区分"能偷的"和"不能偷的"。

说人话:lvalue 是"别人还在用的,不能偷";rvalue 是"没人要了,可以偷"。 std::move 干的事就是把一个 lvalue 变成 xvalue——告诉编译器"这个我不要了,你偷吧"。

3.3 std::move 的真面目:就是一个 static_cast

很多人第一次看到 std::move 这个名字,会以为它"动"了什么东西——也许在内存之间搬数据,也许做了什么优化。但 std::move 什么都不搬,它唯一的作用就是把一个左值转换成右值引用——告诉编译器"这个值你可以偷资源了"。标准在 [utility] 一节给出了它的定义,原句贴出来:

标准原句面板:[utility] 中 std::move 的定义 = static_cast<T&&>(t)

图 08 标准原句面板:[utility] 中 std::move 的定义 = static_cast(t) @ ISO C++ 标准草案(eel.is/c++draft)

翻译成代码,std::move 大概长这样:

template<class T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
    return static_cast<std::remove_reference_t<T>&&>(t);
}

就这一行。一个 static_cast,没有任何函数调用、没有任何循环、没有任何内存操作。

它在运行时生成零条 CPU 指令。

不信?看反汇编。下面是 std::move(x) 在 MSVC Release 模式下生成的汇编代码:

std::move 反汇编证明——零指令,纯 static_cast 类型转换,无运行时开销
图 3-709 std::move 反汇编证明——零指令,纯 static_cast 类型转换,无运行时开销@ 本系列本机自跑(MSVC 19.44 / dumpbin /disasm)

看到了吗?std::move(x) 编译之后——什么都没有。它纯粹是编译期的类型转换,运行时零开销。

这就是第一个反直觉结论的核心:std::move 本身不移动任何东西。 它只是给编译器发了一张"可以偷"的通行证。至于偷不偷、怎么偷,那是移动构造函数的事。

3.4 移动构造函数:真正偷资源的地方

移动构造函数(move constructor)长这样:T(T&& other) noexcept。它的任务是把 other 的资源偷过来,把 other 置为空壳。

以 std::string 为例,移动构造大概做三件事:

  1. 把自己的指针/大小/容量设为 other 的值(偷资源)
  2. 把 other 的指针设为 nullptr(或指向内部 SSO 缓冲),大小设为 0(置空壳)
  3. 不分配内存、不拷贝数据

整个过程是 O(1) 的——交换几个指针和整数,跟字符串长度无关。这就是移动比拷贝快的根本原因:长字符串的拷贝是 O(n) + 一次分配,移动永远是 O(1) + 零分配。

用探针类验证一下。给同一个对象,传左值和传右值(std::move 之后),分别调用了什么:

移动构造计数探针——传入右值时移动构造 vs 传入左值时拷贝构造的次数差
图 3-810 移动构造计数探针——传入右值时移动构造 vs 传入左值时拷贝构造的次数差@ 本系列本机自跑(MSVC 19.44 / std:c++20 /O2)

结果很清楚:

  • 传左值 f(x):调用拷贝构造(1 次深拷贝)
  • 传右值 f(std::move(x)):调用移动构造(1 次指针交换)
  • 传临时对象 f(T{}):调用移动构造(因为临时对象本身就是 prvalue)

这里还有一个重要细节:std::move 之后的对象处于"有效但未指定"状态(valid but unspecified)。你可以析构它、可以给它赋值,但你不能依赖它的值。标准只保证它处于一个"可以安全销毁或赋值"的状态,至于具体是什么值——不同实现可能不一样。

3.5 move 语义的资源流向

最后给大家一张示意图,把拷贝和移动的资源流向放在一起对比:

move 语义资源流向示意——偷指针 vs 深拷贝的代价对比
图 3-911 move 语义资源流向示意——偷指针 vs 深拷贝的代价对比@ 本系列绘制

左边是拷贝:源对象的资源不动,在目标地址重新分配一份,把数据拷过去。两个对象各自独立,各有各的资源。

右边是移动:源对象的资源被"偷"到目标对象,源对象变成空壳。没有分配、没有拷贝——只是所有权的转移。

这张图值得多看两眼。它解释了为什么移动语义必须配合右值引用:只有当你确定源对象马上要销毁、没人再会用它的时候,你才能偷它的资源。 右值引用就是这个"确定"的类型保证——它只能绑定到即将销毁的对象(临时对象)或者被显式标记为"可以偷"的对象(std::move 的结果)。


四、编译器的礼物:copy elision 与 RVO/NRVO

4.1 本章问句:返回一个大对象,到底有没有拷贝?

先给答案:可能一次都没有。

这就是 copy elision(拷贝消除)——编译器有权(C++17 起在某些情形下是必须)省略拷贝和移动构造函数,直接在目标地址构造对象。这份"编译器的礼物",有时候比 move 还狠——直接跳过整个对象的构造/析构周期,连移动构造都不用调。

这一章就来算这笔账:copy elision 有哪几种、什么时候触发、C++17 之后什么变成了保证、什么还只是优化、以及那个经典的坑——return std::move(local) 为什么反而更慢。

4.2 Copy elision 的三种情形

标准在 [class.copy.elision] 一节列出了三种可以省略拷贝/移动的情形:

标准原句面板:[class.copy.elision] 三种可省略拷贝情形 + C++17 保证性消除

图 12 标准原句面板:[class.copy.elision] 三种可省略拷贝情形 + C++17 保证性消除 @ ISO C++ 标准草案(eel.is/c++draft)

三种情形分别是:

  1. return 语句中的自动对象(RVO / NRVO):函数返回一个局部变量或临时对象时,可以直接在返回值地址构造。
  2. throw 操作数:throw 一个局部对象时,可以直接在异常对象地址构造。
  3. 临时对象的拷贝:用临时对象初始化另一个同类型对象时,可以直接合并。

其中最常见、也最有用的是第一种——返回值优化。它又分两小类:

  • RVO(Return Value Optimization):返回一个临时对象,比如 return std::string("hello");
  • NRVO(Named Return Value Optimization):返回一个有名字的局部变量,比如 std::string s = "hello"; return s;

4.3 C++17 保证性拷贝消除

C++17 引入了一个重大变化:prvalue(纯右值)不再是一个对象,而是"对象的初始化器"。 这是由 P0135R1 提案引入的,它把 copy elision 从"可选优化"变成了"语义保证"。P0008R1 是更早的奠基性提案,首次系统提出了"保证性拷贝消除"的完整框架。

什么意思?看这段代码:

std::string make_string() {
    return std::string("hello world");  // 返回临时对象(prvalue)
}

std::string s = make_string();  // 用返回值初始化 s

在 C++17 之前,这段代码理论上需要两次拷贝/移动:一次是把临时对象移到返回值,一次是把返回值移到 s。编译器可以把它们都优化掉(copy elision),但移动构造函数必须存在——哪怕最后没被调用,语法上也得有。

C++17 之后不一样了。return std::string("hello world") 返回的不是一个对象,而是一个"初始化器"。它直接用来初始化 s——连移动构造函数都不需要存在,因为从语义上就没有"移动"这一步。

这是一个语义级别的变化,不是优化——即使你开 -O0(完全不优化),它也会发生。

用探针类验证一下。开 -O0(g++),看 RVO 的构造次数:

RVO/NRVO 构造计数对比——开优化后 0 次拷贝/移动(探针类双编译器实测)
图 3-1013 RVO/NRVO 构造计数对比——开优化后 0 次拷贝/移动(探针类双编译器实测)@ 本系列本机自跑(MSVC 19.44 / g++ 16.2.0)

可以看到:

  • RVO(返回临时对象):即使 -O0,也只有 1 次构造——这是 C++17 保证性消除的效果。
  • NRVO(返回命名变量):-O0 下有 1 次移动构造,-O2 下消除了——因为 NRVO 仍然是可选优化,不是保证的。

这就是 RVO 和 NRVO 的关键区别:RVO 在 C++17 后是保证的,NRVO 仍然是编译器可选的优化。

4.4 -fno-elide-constructors:关掉礼物看账本

如果你想看到"完整的账本"——也就是 copy elision 发生之前理论上应该有多少次拷贝/移动——可以用 GCC/Clang 的 -fno-elide-constructors 选项,强制关掉 copy elision。

来对比一下:同一段代码,开不开 copy elision,构造次数差多少。

-fno-elide-constructors 前后对比——关掉 copy elision 后构造次数变化
图 3-1114 -fno-elide-constructors 前后对比——关掉 copy elision 后构造次数变化@ 本系列本机自跑(g++ 16.2.0 / -O2)

关掉 copy elision 之后,原本 1 次构造的代码,可能变成 2 次、3 次甚至更多。这张图直观地展示了 copy elision 替你省了多少钱——而且这些都是"免费"的,你什么都不用做,编译器自动帮你省。

但 MSVC 没有 -fno-elide-constructors 这个选项。MSVC 的 copy elision 行为跟优化级别挂钩——/Od(Debug)下 NRVO 基本不做,/O2(Release)下会做。MSVC 有一个 /Zc:copyEllision- 选项可以关闭复制消除,但只影响部分情形,不能完全等同于 GCC 的 -fno-elide-constructors。具体行为需要实测,不能靠猜(MSVC 侧写"实测"不写"规范规定")。

4.5 反直觉:return std::move(local) 反而更慢

现在讲第二个反直觉结论:在函数里返回局部变量时,写 return std::move(local); 比写 return local; 更慢。

为什么?因为 return local; 可以触发 NRVO——编译器把 local 直接构造在返回值地址上,零拷贝。但如果你写 return std::move(local);,std::move(local) 的结果是 xvalue,不是一个"自动对象的名字"——编译器没法把它当成同一个对象来做 NRVO,只能调用移动构造函数——多了一次移动。

用地址实测来证明这一点。如果 NRVO 发生了,那么局部变量的地址应该等于返回值的地址——因为它们本来就是同一个对象。

RVO 地址同一性实测——局部变量地址 = 返回值地址,证明零拷贝
图 3-1215 RVO 地址同一性实测——局部变量地址 = 返回值地址,证明零拷贝@ 本系列本机自跑(MSVC 19.44 / std:c++20 /O2)

从这张图可以读出:

  • return local;:局部变量地址 == 返回值地址 → NRVO 生效 → 零拷贝
  • return std::move(local);:局部变量地址 ≠ 返回值地址 → NRVO 失效 → 多一次移动构造

这是一个非常经典的坑。很多人学了移动语义之后,觉得"加个 move 肯定更快",结果在返回值上画蛇添足,反而阻止了更彻底的优化。

记住:返回局部变量时,直接 return local; 就好。 编译器比你更懂怎么优化。

最后提一句,C++23 之后可能会有变化——提案 P2025R2 试图为 NRVO 也提供保证性消除,但截至 C++23 还没有进入标准。目前(C++20/23)NRVO 仍然是"编译器尽量做,但不保证"。


五、SSO 三家对账:libstdc++ / libc++ / MSVC

5.1 本章问句:短字符串的 move 真的比 copy 快吗?

先给答案:不一定。短到能放进 SSO 缓冲的字符串,move 和 copy 一样快——或者说一样慢。

这是本篇第三个反直觉结论,也是很多人不知道的:std::string 内部有一个小缓冲,短字符串直接存在对象里,不用向堆上分配。这种优化叫 SSO(Small String Optimization,短字符串优化)。

SSO 的存在意味着:对短字符串来说,数据就在对象内部,没有"堆资源"可以偷——移动的时候没法交换指针,只能把整个缓冲 memcpy 过去。这时候 move 等于 copy。

但 SSO 更有意思的地方在于:三家标准库(libstdc++ / libc++ / MSVC)的实现完全不同——缓冲大小不一样、判断方式不一样、内存布局不一样。这一章就来三家对账,一家一家看。

5.2 先看一个数字:sizeof(std::string)

先从最简单的问题开始:一个 std::string 对象本身占多少字节?这个数字直接反映了标准库设计者对 SSO 缓冲大小的取舍——缓冲大了浪费栈空间,缓冲小了又享受不到 SSO 的好处。三家标准库在这里做出了完全不同的选择:

两家 sizeof(std::string) 对照表——MSVC vs libstdc++(本机实测 x64)
图 3-1316 两家 sizeof(std::string) 对照表——MSVC vs libstdc++(本机实测 x64)@ 本系列本机自跑(MSVC 19.44 / g++ 16.2.0)

本机实测(x64):

  • MSVC:32 字节
  • libstdc++(GCC):32 字节
  • libc++(LLVM):24 字节(数据来自官方文档,未本机验证)

三家都是 x64,结果却不一样——libc++ 比另外两家小 8 字节。为什么?因为它们的内部布局和 SSO 策略不同。

接下来逐家拆解。

5.3 libstdc++:15 字节 SSO,指针判断

libstdc++ 的 std::string 布局大致是这样的(x64):

{
  char*  _M_p;                  // 8 字节——指向数据
  size_t _M_string_length;      // 8 字节——当前长度
  union {
    char _M_local_buf[16];      // 16 字节——SSO 缓冲(15 字符 + 1 null)
    size_t _M_allocated_capacity; // 8 字节——堆上时的容量
  }
}

总大小:8 + 8 + 16 = 32 字节。

SSO 的判断方式是:_M_p == _M_local_buf——如果数据指针指向内部缓冲的起始地址,就是 SSO 模式;否则就是堆模式。

SSO 容量:15 个字符 + 1 个 null 终止符 = 16 字节。也就是说,长度 ≤ 15 的字符串存在栈上(对象内部),不需要 malloc。

libstdc++ 的实现源码在 libstdc++-v3/include/bits/basic_string.h,有兴趣的可以去看 GCC 的 GitHub mirror。注意 GPL 许可——只链接不搬运,这里就不放源码片段了。

5.4 libc++:22 字节 SSO,位标记判断

libc++ 的实现更激进一些。它的 string 对象只有 24 字节,但 SSO 容量反而更大——22 个字符。

libc++ 官方文档明确记录了这一设计:整个对象 24 字节,用 size 字段最高位做 SSO 标记,从而把数据缓冲做到 22 字节。怎么做到的?它用了一个更巧妙的布局:把整个 string 对象的 24 字节全部拿来当数据缓冲,然后用 size 字段的最高 bit 来标记"是不是 SSO 模式"。

说起来有点绕,简单讲就是:

  • SSO 模式:24 字节的对象里,前 23 字节存数据(22 字符 + 1 null),最后 1 字节存 size(高 1 位是 SSO 标记,低 7 位存长度)。
  • 堆模式:8 字节指针 + 8 字节 size + 8 字节 capacity = 24 字节,跟传统布局一样。

因为用了位标记,所以它能把 SSO 缓冲做到 22 字节——比 libstdc++ 多 7 个字符,对象体积还小 8 字节。代价是判断和访问时要做位操作,稍微复杂一点。

libc++ 的实现源码在 LLVM 仓库里,Apache-2.0 许可,可以直接看。Howard Hinnant(libc++ string 的设计者)在 2010 年 LLVM 开发者大会上有一个演讲专门讲这个设计,有兴趣可以找来看看。

5.5 MSVC:16 字节 SSO,容量判断

MSVC 的 string 布局又是另一种思路。根据 Old New Thing 官方博客的拆解(《Inside STL: The string》),MSVC 的布局大致是:

官方博客面板:Old New Thing《Inside STL: The string》MSVC string 布局
图 3-1417 官方博客面板:Old New Thing《Inside STL: The string》MSVC string 布局@ Old New Thing / Microsoft

MSVC 的 string 对象也是 32 字节(x64),SSO 缓冲是 16 个字符(加上 null 终止符的话是 17?不对——具体阈值需要实测)。

MSVC 的判断方式跟另外两家都不一样:它用 capacity 来判断。如果 capacity ≤ _BUF_SIZE - 1(也就是 15 或 16,取决于怎么算),就是 SSO 模式;否则就是堆模式。标准在 [string.capacity] 一节规定了 capacity() 和 reserve() 的语义,但具体 SSO 怎么实现、缓冲多大,完全是标准库的实现选择。

说"取决于怎么算"是因为 MSVC 的 SSO 阈值在中文材料里说法不一——有人说是 15,有人说是 16。这个必须实测,不能信二手资料。本机跑出来的结果是:

std::string capacity 增长曲线——每次扩容的跳变点与增长倍数
图 3-1518 std::string capacity 增长曲线——每次扩容的跳变点与增长倍数@ 本系列本机自跑(MSVC 19.44 / std:c++20)

从这张 capacity 增长曲线可以清楚看到 SSO 的阈值——长度在某个值以下时,capacity 保持不变(就是 SSO 缓冲的大小);超过这个值之后,capacity 突然跳到一个更大的数(第一次堆分配)。

MSVC 本机实测:15 个字符及以下是 SSO,第 16 个字符开始上堆。也就是说,MSVC 的 SSO 容量跟 libstdc++ 一样是 15 个字符(+ 1 null = 16 字节缓冲)。但 MSVC 的缓冲是 16 字节的 _Buf,容量字段在 union 外面——布局不同,结果一样。

注意:以上 MSVC 结论均为本机实测(MSVC 19.44,x64,Release)+ 官方博客印证。MSVC STL 的源码托管在 GitHub 上(Apache-2.0 类似许可),可以直接查阅。MSVC 没有发布完整的 STL 实现规范,所以不写"规范规定",只写"实测"。

5.6 三家布局对比

把三家放在一起看:

三家 SSO 内存布局对比——libstdc++ / libc++ / MSVC / folly fbstring
图 3-1619 三家 SSO 内存布局对比——libstdc++ / libc++ / MSVC / folly fbstring@ 本系列绘制
实现 sizeof SSO 容量 判断方式 许可
libstdc++ (GCC) 32 字节 15 字符 指针比较(_M_p == _M_local_buf) GPL(只链接不搬运)
libc++ (LLVM) 24 字节 22 字符 size 字段最高位标记 Apache-2.0
MSVC STL 32 字节 15 字符 capacity 比较 Apache-2.0 类似许可

三家各有取舍:libstdc++ 简单直接、MSVC 用 capacity 判断更省一个字段、libc++ 最激进(对象最小、SSO 最大)但有位操作开销。没有绝对的"谁更好",取决于你的使用场景——短字符串多的话 libc++ 更划算,长字符串多的话差别不大。

5.7 反直觉:SSO 内的 move = copy

最后回到本章开头的问题:短字符串的 move 真的比 copy 快吗?

来看反汇编——一个长度为 5 的字符串(远低于 SSO 阈值),移动构造生成什么代码:

SSO 内 move = copy 反汇编——短字符串移动退化为 memcpy,无指针可偷
图 3-1720 SSO 内 move = copy 反汇编——短字符串移动退化为 memcpy,无指针可偷@ 本系列本机自跑(MSVC 19.44 / dumpbin /disasm)

看到了吗?SSO 内的字符串移动,调用的是 memcpy,不是指针交换。

原因很简单:数据就在对象内部,没有指针可以偷——你不能把一个对象内部的数据"移"到另一个对象内部,只能拷过去。移动构造和拷贝构造成本完全一样。

这就是第三个反直觉结论:不是所有的 move 都比 copy 快。 对短字符串(SSO 内)来说,move 和 copy 一样快——或者说,没有"偷资源"这回事,因为根本没有堆资源可偷。

这个结论的实际意义很大:很多人写代码时喜欢到处加 std::move,觉得"反正不亏"。但对短字符串、小对象来说,move 并没有什么收益——它跟 copy 是一样的。真正能从 move 中获益的是那些持有大量堆资源的对象(长字符串、大 vector、自定义资源句柄)。


六、emplace_back 的真实收益:push_back 差在哪

6.1 本章问句:emplace_back 一定比 push_back 快吗?

先给答案:不一定。取决于你传的是什么。

这是本篇第四个反直觉结论。很多人学了"现代 C++"之后,养成了一个习惯:把所有 push_back 都换成 emplace_back,觉得"emplace 更快"。但在很多场景下,两者一模一样快——甚至 emplace_back 还有坑。

这一章就来算清楚:emplace_back 到底快在哪、什么时候快、快多少、以及什么时候不该用它。

6.2 push_back 的两个重载

在讲 emplace_back 之前,得先把 push_back 搞清楚。C++11 之后,std::vector::push_back 有两个重载版本——一个接收左值引用(拷贝),一个接收右值引用(移动)。标准在 [vector.modifiers] 一节定义了这两个重载的语义。理解这两个重载的行为,是理解 emplace_back 差在哪的前提:

void push_back(const T& value);   // 【1】拷贝进去
void push_back(T&& value);        // 【2】移动进去(C++11 起)

你传左值,它调用【1】,把对象拷贝进 vector;你传右值,它调用【2】,把对象移动进 vector。就这么简单。

那 mplace_back 呢?它是个模板——cppreference上给出了完整的签名和说明:

template<class... Args>
reference emplace_back(Args&&... args);

它接受任意参数包(完美转发),然后直接在 vector 尾部的内存上调用 T 的构造函数——也就是 placement new。

用一张图来看两者的区别——push_back 走的是"先在外面造对象、再搬进去、最后把临时的销毁"的路线,emplace_back 走的是"把参数直接传进去、在目的地原位构造"的路线,两条路径经过的中间步骤数完全不同:

emplace_back 参数转发链——完美转发 + placement new,直接在容器内存构造
图 3-1821 emplace_back 参数转发链——完美转发 + placement new,直接在容器内存构造@ 本系列绘制

push_back 的路径是:构造临时对象 → 移动/拷贝进容器 → 销毁临时对象。

emplace_back 的路径是:直接在容器内存中构造对象。

少了"临时对象的构造 + 析构"这两步——理论上更快。但"理论上"和"实际"之间,往往有差距。

emplace_back 这类"原位构造"接口最早由 N2680 提案引入 C++11,提案名叫"Placement Insert"——核心就是把参数直接转发到容器内存里构造对象,省去中间临时对象。

6.3 什么时候 emplace_back 真的更快

emplace_back 有收益的前提是:你传的是构造参数,不是 T 本身。

看这个例子:

std::vector<std::string> v;

// 【1】push_back:先构造临时 string,再移动进 vector
v.push_back(std::string(100, 'a'));

// 【2】emplace_back:直接在 vector 内存中构造 string
v.emplace_back(100, 'a');

【1】的流程:1 次构造(临时对象) + 1 次移动(进 vector) + 1 次析构(临时对象)。 【2】的流程:1 次构造(直接在 vector 内存中)。

差了一次移动构造 + 一次析构。对长字符串来说,移动构造很便宜(指针交换),所以差距不大;但对构造代价很大的类型来说,差距就明显了。

用探针类实测一下:

emplace_back vs push_back 构造计数对比——传构造参数时省一次移动+析构
图 3-1922 emplace_back vs push_back 构造计数对比——传构造参数时省一次移动+析构@ 本系列本机自跑(MSVC 19.44 / std:c++20 /O2)

很清楚:传构造参数时,emplace_back 只有 1 次构造,push_back 有 1 次构造 + 1 次移动 + 1 次析构。这就是 emplace_back 的核心收益。

有一个细节:如果字符串在 SSO 内,移动构造的代价几乎等于拷贝(第五章讲过)。这时候 emplace_back 省掉的"一次移动"其实就是省掉了一次 memcpy——差距非常小,可能只有几纳秒。

真正拉开差距的是越过 SSO 的长字符串、或者构造代价很大的自定义类型。用 Google Benchmark 跑了三组对比:

emplace_back vs push_back 性能基准——传构造参数时的加速比实测
图 3-2023 emplace_back vs push_back 性能基准——传构造参数时的加速比实测@ 本系列本机自跑(MSVC 19.44 / std:c++20 /O2)

从这张基准测试图可以读出:

  • 短字符串(SSO 内):emplace_back 比 push_back 快约 10–15%——差距很小,因为移动 = 拷贝。
  • 长字符串(越过 SSO):emplace_back 比 push_back 快约 20–30%——省掉了一次移动构造(指针交换 + 空壳析构)。
  • 构造代价大的自定义类型:emplace_back 优势更明显——省掉的不仅是移动,还有临时对象的分配和释放。

6.4 什么时候两者一样快

反过来,如果你传的就是 T 类型的对象,那 emplace_back 和 push_back 基本一样:

std::string s = "hello world";

v.push_back(s);              // 【1】拷贝构造
v.emplace_back(s);           // 【2】也是拷贝构造

v.push_back(std::move(s));   // 【3】移动构造
v.emplace_back(std::move(s));// 【4】也是移动构造

这四种写法,【1】和【2】都是一次拷贝构造,【3】和【4】都是一次移动构造——没有任何区别。

为什么?因为当你传的是 std::string 类型时,emplace_back 的参数包就是一个 std::string,它会调用 std::string 的拷贝/移动构造函数来构造新元素——跟 push_back 的拷贝/移动构造是同一个函数。

所以结论很简单:传构造参数用 emplace_back,传对象用 push_back——两者一样快,选哪个都可以。

很多人"全面替换 push_back 为 emplace_back"的做法,其实大部分场景下没有任何收益——只是代码变得更难读了。

6.5 emplace_back 的坑

emplace_back 不仅不是万能的,还有几个坑,踩一个就翻车:

坑 1:explicit 构造函数

push_back 用的是拷贝初始化(copy initialization),emplace_back 用的是直接初始化(direct initialization)。两者的区别在于:explicit 构造函数在拷贝初始化中不参与重载决议,但在直接初始化中参与。

什么意思?看代码:

struct MyInt {
    explicit MyInt(int x) : value(x) {}
    int value;
};

std::vector<MyInt> v;

v.push_back(42);     // 编译错误!explicit 构造函数不能用于隐式转换
v.emplace_back(42);  // OK!直接初始化,调用 explicit 构造函数

push_back(42) 需要把 42 隐式转换成 MyInt,但构造函数是 explicit 的,所以编译失败。emplace_back(42) 是直接用 42 构造 MyInt,explicit 构造函数也可以调——所以编译通过。

这不是 bug,是设计——emplace_back 就是用来直接构造的,explicit 构造函数当然可以用。但如果你从 push_back 迁移到 emplace_back 时没注意到这个区别,可能会引入意外的行为(本来想阻止隐式转换,结果 emplace_back 允许了)。

坑 2:initializer_list 推不出来

你可能想这么写:

std::vector<std::vector<int>> v;
v.emplace_back({1, 2, 3});  // 编译错误!

为什么?因为 {1, 2, 3} 是一个 initializer_list,但 emplace_back 的参数是模板参数包 Args&&... args——编译器没法从 {1, 2, 3} 推导出 std::initializer_list<int> 来。

push_back 就没这个问题:

v.push_back({1, 2, 3});  // OK,push_back 知道参数类型是 vector<int>&&,
                          // {1,2,3} 可以隐式构造一个临时 vector<int>

emplace_back 的解决方案是显式指定:

v.emplace_back(std::initializer_list<int>{1, 2, 3});  // 可以,但很丑

这是 emplace_back 的一个经典坑——因为它是模板,所以 initializer_list 的推导会失败。

把这些坑的编译输出贴出来,大家可以直观感受一下:

emplace_back 的坑——explicit 构造函数、initializer_list 与完美转发冲突
图 3-2124 emplace_back 的坑——explicit 构造函数、initializer_list 与完美转发冲突@ 本系列本机自跑(MSVC 19.44)

6.6 怎么选

最后给一个简单的选择规则:

  1. 如果你要传的是构造参数(比如两个 int 构造一个 pair),用 emplace_back——省一次移动/拷贝。
  2. 如果你要传的就是 T 类型的对象,用 push_back——一样快,代码更清晰。
  3. 如果你不确定,用 push_back——保守选择,不会踩坑。
  4. 注意 explicit 构造函数和 initializer_list——这两个是 emplace_back 最容易翻车的地方。

记住:emplace_back 不是"更快的 push_back",它是"直接构造的 push_back"。 它的价值在于省去临时对象,而不是比 push_back 更高级。


七、平凡的力量:trivially copyable / trivial / POD

7.1 本章问句:什么类型的拷贝是 memcpy?

先给答案:trivially copyable(可平凡拷贝)的类型。

这是值语义账本里最狠的一刀:如果一个类型是 trivially copyable,编译器可以直接用 memcpy 代替拷贝构造函数——拷贝 = 一次内存拷贝,没有任何函数调用、没有任何额外开销。

但 C++ 里"平凡"的分级比你想的复杂。很多人只知道 POD,但 POD 只是最严格的一档。在它上面还有 trivial type、trivially copyable、standard-layout 等更细的分类。这一章就来把这些概念理清楚,看看它们各自意味着什么、以及编译器到底能做什么优化。

7.2 四个概念、三档分类

先把四个关键概念列出来,然后看它们的关系:

  1. trivially copyable(可平凡拷贝):拷贝/移动构造、拷贝/移动赋值、析构函数,全都是平凡的(trivial)。意思是:拷贝这个类型跟 memcpy 一样安全。
  2. trivial type(平凡类型):trivially copyable + 有平凡的默认构造函数。意思是:连默认构造都不用做什么,内存清零就行。
  3. standard-layout(标准布局):所有非静态数据成员同一访问控制、没有虚函数虚基类、第一个成员不是基类。意思是:布局跟 C 语言的 struct 一样规则。
  4. POD(Plain Old Data):trivial + standard-layout。意思是:跟 C 语言的 struct 完全一样——可以直接 memcpy、可以直接 memset、可以直接跟 C 代码交互。

它们的关系用一张文氏图表示:

trivial 谱系文氏图——POD ⊂ trivial ⊂ trivially copyable 的包含关系
图 3-2225 trivial 谱系文氏图——POD ⊂ trivial ⊂ trivially copyable 的包含关系@ 本系列绘制

从图上可以看出:

  • POD 是最小的集合:它同时是 trivial 和 standard-layout。
  • trivial type 比 POD 大一圈:它不要求 standard-layout。
  • trivially copyable 是最大的集合:它只要求拷贝/移动/析构是平凡的,不要求默认构造是平凡的、也不要求布局标准。
  • standard-layout 跟 trivially copyable 有交集但互不包含:有些类型是 standard-layout 但不是 trivially copyable(比如有自定义拷贝构造的简单 struct),有些类型是 trivially copyable 但不是 standard-layout(比如有 private 和 public 混合成员的 trivial struct)。

用标准库的 type_traits 来验证一下。给几个典型类型,看看它们各自的属性:

标准在 [meta] 一节定义了完整的 type_traits 库,is_trivially_copyable_v、is_trivial_v、is_standard_layout_v、is_pod_v 这些编译期常量都可以直接拿来用。而 [basic.types.general] 一节则给出了 trivially copyable 类型的正式定义和语义保证。

type_traits 编译期断言面板——is_trivially_copyable 等四档类型属性实测
图 3-2326 type_traits 编译期断言面板——is_trivially_copyable 等四档类型属性实测@ 本系列本机自跑(MSVC 19.44 / std:c++20)

从这张表可以读出:

  • int:四档全 true——最纯粹的 POD。
  • struct { int x; double y; }:四档全 true——简单的 C 风格 struct。
  • std::array<int, 10>:四档全 true——array 是 POD 的(它包装了一个 C 数组)。
  • std::unique_ptr<int>:trivially copyable = false,因为它有自定义的移动构造和析构。
  • struct { int x; private: int y; }:standard-layout = false(混合访问控制),但如果拷贝/析构都是平凡的,trivially copyable 仍然是 true。

7.3 什么叫"平凡的"(trivial)?

说"平凡的",到底是什么意思?标准给出的条件是:

  • 是编译器隐式生成的(不是用户自定义的)
  • 没有虚函数、没有虚基类
  • 所有非静态成员和基类的对应函数也是平凡的

说人话:平凡的函数什么都不做(或者只做逐成员的简单拷贝/初始化),编译器可以直接用 memcpy / memset 代替它。

这就是 trivially copyable 的核心价值:编译器可以把拷贝构造函数替换成 memcpy。 没有函数调用、没有循环展开、没有各种检查——就是一条最朴素的内存拷贝指令。

用反汇编来证明。看两个 struct 的拷贝:

trivially copyable 类型的拷贝反汇编——直接 memcpy / mov 指令,无函数调用
图 3-2427 trivially copyable 类型的拷贝反汇编——直接 memcpy / mov 指令,无函数调用@ 本系列本机自跑(MSVC 19.44 / dumpbin /disasm)

左边是 trivially copyable 的 struct(只有两个 int),右边是有自定义拷贝构造函数的 struct。对比很明显:

  • trivially copyable:拷贝直接用几个 mov 指令或者 memcpy,没有函数调用。
  • 非 trivially copyable:拷贝要调用拷贝构造函数,有函数调用开销,函数内部还可能做各种额外的事。

这还只是两个 int 的小对象。如果是大数组、大 struct,差距会更大——trivial 的拷贝就是一条 memcpy,编译器还能做向量化;非 trivial 的拷贝要逐个调用成员的拷贝构造函数,开销是 O(n) 的函数调用。

7.4 POD 在 C++20 被弃用了

说一个很多人不知道的事:POD 这个概念在 C++20 起被弃用了。

提案 P3247R2《Deprecate the notion of trivial types》明确提议弃用 "trivial type" 和 "POD type" 这两个概念,改用更细粒度的 type traits(is_trivially_copyable_v、is_standard_layout_v、is_trivially_constructible_v 等)。

提案面板:P3247R2《Deprecating POD》弃用 POD 的核心论据
图 3-2528 提案面板:P3247R2《Deprecating POD》弃用 POD 的核心论据@ P3247R2 / open-std.org

为什么要弃用?原因有两个:

  1. POD 太粗了。大多数时候,你需要的只是"这个类型可以 memcpy"(trivially copyable),而不需要它同时是 trivial 和 standard-layout。POD 把两个独立的属性绑在一起,用起来不方便。
  2. 名字有误导性。"POD" 暗示"像 C 一样的数据",但很多不是 POD 的类型也可以 memcpy、也可以跟 C 交互。POD 这个名字传递了错误的直觉。

所以现代 C++ 的做法是:忘掉 POD,用更细粒度的 type traits。 你需要什么就查什么:

  • 能不能 memcpy?查 std::is_trivially_copyable_v<T>
  • 布局是不是 C 兼容?查 std::is_standard_layout_v<T>
  • 默认构造是不是什么都不做?查 std::is_trivially_default_constructible_v<T>

不要一上来就 is_pod_v——那是旧时代的遗物了。

7.5 实际意义:你能用它做什么

trivially copyable 有什么实际用处?举几个常见场景:

场景 1:优化容器操作

std::vector 扩容时,如果元素是 trivially copyable 的,就可以直接用 memcpy 搬元素,不用逐个调用移动构造函数——快得多。std::copy 对 trivially copyable 的类型也有特化版本,直接 memcpy。

场景 2:字节级序列化

如果你要把一个对象写到文件里、或者通过网络发出去,trivially copyable 的对象可以直接 memcpy 到字节缓冲区,读回来再 memcpy 回去——行为是定义良好的。但注意:这只在同一个平台、同一个编译器、同一个编译选项下成立。跨平台的话,字节序、对齐、padding 都可能不一样。

场景 3:写优化的泛型代码

如果你在写一个库,可以用 if constexpr (std::is_trivially_copyable_v<T>) 来派发:trivial 的类型走 fast path(memcpy),其他类型走通用路径(逐个拷贝构造)。标准库就是这么做的。

但也要注意:trivially copyable 不等于 "可以随便 memcpy"。 比如一个含指针的 struct,如果指针指向堆内存,即使 struct 本身是 trivially copyable 的,memcpy 之后两个对象的指针指向同一块内存——浅拷贝的问题依然存在。trivially copyable 只保证"拷贝这个对象的字节表示是安全的",不保证"拷贝之后语义正确"。这一点不能搞混。


八、值语义的两个暗坑:noexcept 与自赋值安全

8.1 本章问句:写了移动构造就一定会用吗?

先给答案:不一定。如果你的移动构造函数没标 noexcept,vector 扩容时根本不用它——宁愿用拷贝。

这是本篇第五个反直觉结论,也是最容易踩的坑。很多人辛辛苦苦写了移动构造函数,结果忘了标 noexcept——等于白写。std::vector 扩容的时候,一看移动构造可能抛异常,立马转头去用拷贝构造——因为它要保证 strong exception guarantee(强异常保证)。

这一章就来讲值语义里两个最隐蔽的暗坑:noexcept 与移动语义的交互,以及自赋值安全。

8.2 暗坑一:noexcept 与 vector 扩容

先讲第一个坑,也是最常见的一个:移动构造不标 noexcept,vector 扩容时不用它。

为什么?因为 std::vector 扩容时,需要把旧元素搬到新的缓冲区里。搬的过程中,如果某个元素的移动构造抛出了异常——那怎么办?已经搬过去的元素已经被"偷空"了,再搬回来也不一定能成功(搬回来可能又抛异常)。这就违反了 strong exception guarantee(强异常保证)——操作失败时,程序应该处于"什么都没发生"的状态。

所以标准库的做法是:如果移动构造函数是 noexcept 的,就用 move;否则用 copy。 因为拷贝构造即使抛异常,原对象还完好无损——大不了把新缓冲区释放掉,旧的那些元素一个没动,符合强异常保证。

标准在 [container.requirements.general] 一节中规定了容器的异常安全要求,std::vector 的 push_back 和扩容操作必须提供强异常保证——这也是为什么它宁可退回到拷贝,也不敢用可能抛异常的移动。而 noexcept 的语义规则定义在 [except.spec] 一节,编译器据此推断函数是否可能抛出异常。

用探针类实测一下。写两个类,一个移动构造标 noexcept,一个不标,然后看 vector 扩容时用的是 move 还是 copy:

noexcept 扩容实测——移动构造带 noexcept vs 不带时 vector 扩容的拷贝/移动选择
图 3-2629 noexcept 扩容实测——移动构造带 noexcept vs 不带时 vector 扩容的拷贝/移动选择@ 本系列本机自跑(MSVC 19.44 / g++ 16.2.0)

结果非常清楚:

  • 移动构造是 noexcept 的:扩容时全部用 move——N 个元素,N 次移动构造,N 次析构。
  • 移动构造不是 noexcept 的:扩容时全部用 copy——N 个元素,N 次拷贝构造,N 次析构。原对象一个没动。

对于持有堆资源的类型(比如 std::string、std::vector),拷贝构造的代价比移动构造大一个数量级(回链第二章的基准测试)。辛辛苦苦写的移动构造,就因为忘了一个 noexcept,完全没用上。

这就是为什么标准库里所有的移动构造和移动赋值默认都是 noexcept 的(只要所有成员的移动都是 noexcept 的,编译器自动推断)。你自己写的移动构造,一定要记得标 noexcept——除非你确定它可能抛异常(这种情况极少)。

std::string 的移动构造在所有主流实现里都是 noexcept 的,所以 vector 扩容时一定用 move。这个坑主要针对自定义类型——你自己写的移动构造,编译器不会自动帮你加 noexcept(如果你自定义了,就需要你自己声明)。

8.3 暗坑二:自赋值安全

第二个坑比较隐蔽:a = a 会不会崩?

你可能会说,谁会写 a = a 这么蠢的代码?但在实际项目中,自赋值可能以更隐蔽的方式出现:

// 两个指针可能指向同一个对象
*p = *q;

// 容器里的元素可能和自己重叠
v[i] = v[j];  // i == j

// 函数返回值可能和参数是同一个对象
obj = obj.process();  // process() 返回 *this 的引用

这些情况下,赋值运算符的左值和右值是同一个对象。如果你的拷贝赋值运算符是这么写的:

T& operator=(const T& other) {
    delete[] data;                    // 【1】释放自己的资源
    data = new int[other.size];       // 【2】分配新资源
    memcpy(data, other.data, ...);    // 【3】拷贝数据——但 other.data 已经被【1】释放了!
    return *this;
}

自赋值时,other 就是 *this。【1】把 data 释放了,【3】再去读 other.data——就是读已经释放的内存,UB(未定义行为)。

这就是自赋值安全(self-assignment safety)的问题。

8.4 两种解决方案

解决自赋值安全的经典方案有两种:

方案一:自赋值检查

T& operator=(const T& other) {
    if (this == &other) return *this;   // 自赋值,直接返回
    delete[] data;
    data = new int[other.size];
    memcpy(data, other.data, ...);
    return *this;
}

简单直接——先判断是不是自己,是的话直接返回。代价是多了一个分支,但自赋值发生的概率很低,分支预测几乎总是正确的,实际开销可以忽略。

方案二:copy-and-swap(拷贝并交换)

T& operator=(T other) {       // 【1】按值传参,让编译器帮你做拷贝
    swap(*this, other);       // 【2】交换自己和 other 的资源
    return *this;
}                               // 【3】other 析构,释放原来的资源

这个写法更优雅——它天然是自赋值安全的(因为先拷贝了一份),同时也天然是异常安全的(如果拷贝抛异常,还没到 swap 那一步,*this 完好无损)。

但它的代价是:总是多一次移动构造(参数是按值传递的,需要构造一个临时对象)。如果 move 是 noexcept 且很便宜,这个代价可以接受;但如果类型很重、或者 move 不便宜,自赋值检查可能更高效。

用探针类实测三种写法的行为(裸写法 / 自赋值检查 / copy-and-swap):

自赋值安全测试——裸写法 / 自赋值检查 / copy-and-swap 三种方案对比
图 3-2730 自赋值安全测试——裸写法 / 自赋值检查 / copy-and-swap 三种方案对比@ 本系列本机自跑(MSVC 19.44 / std:c++20 /O2)

从这张表可以看出:

  • 裸写法:自赋值时 double free(UB)——崩了。
  • 自赋值检查:自赋值时直接返回,零开销。
  • copy-and-swap:自赋值时也会多一次拷贝 + 一次 swap + 一次析构——能跑,但浪费。

实际项目中怎么选?我的建议是:优先用 copy-and-swap,因为它同时解决了异常安全和自赋值安全,写法简洁不容易错。只有在性能极度敏感、且自赋值发生概率极低的场景下,才考虑自赋值检查的写法。

8.5 移动赋值也要考虑自赋值

移动赋值运算符也有自赋值问题。

a = std::move(a);  // 语法上合法,你说会不会崩?

写起来奇怪,但确实可能发生(比如在泛型代码里)。移动赋值的标准写法也要考虑自赋值:

T& operator=(T&& other) noexcept {
    if (this != &other) {
        // 释放自己的资源,偷 other 的资源
        delete[] data;
        data = other.data;
        size = other.size;
        other.data = nullptr;
        other.size = 0;
    }
    return *this;
}

或者用 swap 的写法(更简洁):

T& operator=(T&& other) noexcept {
    swap(*this, other);
    return *this;
}

swap 写法天然自赋值安全(自己跟自己 swap 等于什么都没做),而且异常安全——推荐。

8.6 std::swap 的三次 move

说到 swap,顺便提一下 std::swap 的经典实现:

template<class T>
void swap(T& a, T& b) {
    T tmp = std::move(a);   // 【1】移动构造 tmp
    a = std::move(b);       // 【2】移动赋值 a
    b = std::move(tmp);     // 【3】移动赋值 b
}

就是三次 move。如果 T 的移动构造和移动赋值都是 noexcept 的,swap 也是 noexcept 的。

std::swap 三次移动构造示意——三次 O(1) 指针交换完成交换
图 3-2831 std::swap 三次移动构造示意——三次 O(1) 指针交换完成交换@ 本系列本机绘制

这也是为什么移动构造要标 noexcept 的另一个原因:你的类型能不能高效地 swap,取决于 move 是不是 noexcept。 如果 move 可能抛异常,swap 也可能抛异常——很多标准库算法(比如 std::sort)会优先用 noexcept 的 swap,否则退化成拷贝。

std::swap 还会通过 ADL(Argument Dependent Lookup)找用户自定义的 swap 函数。如果你的类型有更高效的 swap 实现(比如只交换几个指针),可以在同一个命名空间里写一个自己的 swap 函数——标准库会优先用你的版本。


九、真实库的取舍:Abseil Cord / Folly fbstring

9.1 本章问句:std::string 为什么不做得更激进?

先给答案:因为 std::string 是"通用最优",不是"场景最优"。

前面看了三家标准库的 std::string 实现——libstdc++ 15 字节 SSO、libc++ 22 字节 SSO、MSVC 15 字节 SSO——它们都走的是"SSO + 堆分配"的二元策略。但工业界还有一些更激进的字符串实现,比如 Facebook 的 folly::fbstring 和 Google 的 absl::Cord。

这一章就来看看这些真实项目里的字符串库做了什么取舍、为什么 std::string 不这么做、以及值语义的"最优实现"到底取决于什么。

9.2 folly::fbstring:三层策略、23 字节 SSO

先看 Facebook 的 folly::fbstring(Facebook String)。Folly 官方文档明确介绍了 fbstring 的三层设计与实现细节。它的核心创新是三层策略:

  1. SSO(0–23 字节):小字符串直接存在对象内部。
  2. 堆分配(24–254 字节):中等字符串在堆上分配,跟 std::string 类似。
  3. COW(255 字节以上):大字符串用写时复制(Copy-On-Write),只读时共享同一份数据。

而且它的 SSO 容量是 23 字节——比 libc++ 的 22 字节还多 1 字节,对象体积同样是 24 字节(x64)。怎么做到的?它用了更激进的位布局技巧,把每一个 bit 都榨干了。

来实测一下(本机引入 Folly 头文件):

各实现字符串大小与 SSO 容量对比——std::string / folly fbstring / libc++
图 3-2932 各实现字符串大小与 SSO 容量对比——std::string / folly fbstring / libc++@ 本系列本机实测 + 官方文档数据

从实测数据看:

  • sizeof(folly::fbstring) = 24 字节(跟 libc++ 一样,比 std::string 小 8 字节)
  • SSO 阈值 = 23 个字符(比 libc++ 还多 1 个)

但 fbstring 最有意思的不是 SSO,而是第三层 COW。COW(Copy-On-Write)的意思是:当你拷贝一个大字符串时,不立刻深拷贝,而是让两个对象共享同一份数据,引用计数 +1。只有当其中一个对象要修改数据时,才真正拷贝一份。

这在"读多写少"的场景下非常高效——大字符串的拷贝几乎零开销。但 COW 也有代价:

  • 引用计数的原子操作开销:每次拷贝/析构都要原子操作增减引用计数,多线程下有竞争。
  • 写时拷贝的延迟:第一次写入时要做一次完整拷贝,延迟不可预测。
  • 与 std::string 接口不兼容:data() 返回非 const 指针就会触发 COW 分离,跟标准 string 的语义有微妙差别。

正因为这些代价,std::string 没有采用 COW 策略——标准需要的是"通用场景下的可预测性能",而不是"某些场景下特别快"。GCC 的旧版 string(CXX03 ABI)曾经是 COW 的,但在 C++11 之后也换成了 SSO 实现,就是因为 COW 在多线程和某些使用模式下问题太多。

9.3 absl::StrCat:预计算长度,一次分配

再看 Google Abseil 库的字符串拼接。Abseil 官方文档里有专门的 Strings Guide,详细介绍了 absl::StrCat、absl::StrAppend 等工具的设计与用法。普通的字符串拼接是这样的:

std::string result = a + b + c + d;

这行代码会产生多少次分配?答案是:3 次。a + b 产生一个临时,加 c 又产生一个,加 d 再产生一个。每次 operator+ 都要分配一次内存——如果字符串很长,代价不小。

absl::StrCat 的做法不一样:它先遍历所有输入,预计算总长度,然后一次分配,最后顺序填充。总共只有 1 次分配。

来实测一下性能差距:

absl::StrCat vs operator+= 拼接性能基准——预计算长度一次分配 vs 多次 realloc
图 3-3033 absl::StrCat vs operator+= 拼接性能基准——预计算长度一次分配 vs 多次 realloc@ Abseil 官方文档 + 社区 benchmark 数据

从基准测试结果看:

  • operator+= 多次拼接:每拼一次可能就 realloc 一次(取决于当前 capacity 和增长策略)。
  • absl::StrCat:一次分配、顺序写入——快得多,尤其是拼接的字符串数量多的时候。

这其实不是什么高深的技巧——就是"先算账、再动手"。但它反映了一个重要的设计哲学:值语义的代价,很多时候不在"拷贝数据",而在"分配内存"。 减少分配次数,比优化拷贝速度收益更大。

absl::StrAppend 也是同样的思路——预计算增量长度,一次性 reserve,然后追加。比反复 += 少很多次 realloc。

9.4 absl::Cord:分块 rope 结构

最后看一个完全不同思路的字符串类型:absl::Cord。

Cord 根本不是 SSO 思路,也不是连续内存的思路。它是一个分块的 rope 结构——字符串数据存在多个独立的块(chunk)里,用指针串起来。

这种设计的特点是:

  • 大字符串拼接 O(1):拼两个 Cord 只要加一个节点,不用拷贝数据。
  • 随机访问 O(log n):需要遍历块链表找位置,比连续内存慢。
  • 子串操作高效:取子串可以共享数据块,不用拷贝。
  • 迭代器不简单:因为不是连续内存,迭代器要处理块边界。

Cord 是专为大字符串、频繁拼接的场景优化的——比如日志系统、序列化、协议处理。这些场景下,字符串往往很大、拼接操作很多,连续内存的 string 每次拼接都要 realloc + memcpy,代价很高。Cord 的分块结构完美解决了这个问题。

但 Cord 的代价也很明显:随机访问慢、接口复杂、内存碎片更多。它不是 std::string 的替代品,而是补充——在特定场景下用 Cord,大多数场景下用 string。

9.5 横向对比:四种字符串类型

把 std::string、folly::fbstring、absl::Cord 放在一起对比一下:

字符串类型体积与适用场景对照表——std::string / string_view / Cord 等
图 3-3134 字符串类型体积与适用场景对照表——std::string / string_view / Cord 等@ 本系列本机实测 + 官方文档数据
类型 对象体积(x64) 短字符串策略 大字符串策略 适用场景
std::string(libstdc++/MSVC) 32 字节 SSO(15 字符) 堆分配 通用场景
std::string(libc++) 24 字节 SSO(22 字符) 堆分配 通用场景(更激进的 SSO)
folly::fbstring 24 字节 SSO(23 字符) 堆分配 + COW(>255B) 读多写少的大字符串
absl::Cord 约 48 字节 小对象内联(inline) 分块 rope 结构 大字符串、频繁拼接

没有谁"最好"——只有谁"更适合你的场景"。

std::string 之所以是标准,就是因为它是"通用最优"——大多数场景下表现不错,没有明显的短板。它没有 COW(因为多线程下有坑),没有分块(因为随机访问要快),SSO 大小也选了一个保守值(15 字节,大多数短字符串能覆盖,同时对象不会太大)。

而 Folly 和 Abseil 这些库,因为是特定公司(Facebook、Google)在特定场景下使用的,所以可以做更激进的取舍——反正它们知道自己的使用模式。

这也是值语义的一个核心启示:最优的实现取决于使用模式。 没有万能的"最快字符串",只有最适合你场景的那个。写代码的时候,先搞清楚你的数据长什么样、怎么用,再选工具——而不是反过来。


十、收束:值语义自检清单 + 交给 ep04 的问题

10.1 全篇回顾:五笔账

到这里,值语义的账本已经全部摊开算了一遍。快速回顾一下:

  1. 拷贝的账(第二章):拷贝不是一个动作,是构造 + 赋值 + 析构三板斧。深拷贝的代价主要在 malloc/free。
  2. move 的账(第三章):std::move 不移动任何东西,它只是个 cast。真正偷资源的是移动构造函数。
  3. 编译器的免单(第四章):copy elision(尤其是 C++17 保证性的 RVO)比 move 还狠——直接零拷贝。
  4. 容器的优化(第五、六章):SSO 让短字符串免分配,emplace_back 让构造少走一步。
  5. 类型的属性(第七、八章):trivially copyable 直接 memcpy,noexcept 决定 vector 用不用 move。

五笔账加起来,就是一个完整的值语义成本模型。

10.2 值语义自检清单

最后给大家一张可以直接抄走的自检清单。写代码的时候,拿出来过一遍,能踩的坑基本就都避开了:

值语义自检清单卡——六条可直接抄走的检查项
图 3-3235 值语义自检清单卡——六条可直接抄走的检查项@ 本系列绘制

清单六条:

  1. Rule of Zero 优先:能不用自定义特殊成员函数就不用。用 std::string、std::vector、std::unique_ptr 管资源,你的类自动拥有正确的值语义。
  2. 移动构造标 noexcept:如果你写了移动构造函数,一定要加 noexcept——否则 vector 扩容时不用它,等于白写。
  3. emplace_back 别乱用:传构造参数用 emplace_back,传对象用 push_back。两者一样快的时候,选代码更清晰的那个。
  4. 知道你的 SSO 阈值:你用的标准库,string 的 SSO 阈值是多少?15 还是 22?短字符串场景下,move 和 copy 一样快,别白费功夫。
  5. 返回值别加 std::move:返回局部变量时直接 return local;,加 move 反而阻止 NRVO。编译器比你更懂优化。
  6. 赋值运算符要自赋值安全:要么自赋值检查,要么 copy-and-swap。别让 a = a 把程序搞崩。

就这六条,每条都是用实际踩过的坑换来的。

10.3 全篇读数总表

把本篇的关键实测数据汇总成一张表,方便大家查阅:

项目 MSVC 19.44(x64) g++ 16.2(x64) 备注
sizeof(std::string) 32 字节 32 字节 libc++ 为 24 字节(官方文档)
SSO 阈值 15 字符 15 字符 libc++ 为 22 字符(官方文档)
长字符串拷贝 vs 移动 ~40x 差距 ~45x 差距 1KB 字符串,越过 SSO
RVO(C++17 保证) 1 次构造 1 次构造 即使 -O0 也成立
NRVO(-O2) 1 次构造 1 次构造 Debug 模式下不一定
return std::move(local) 2 次构造 2 次构造 阻止 NRVO,多一次 move
emplace_back vs push_back(长串) 快 20–30% 快 25–35% 传构造参数时的差距
emplace_back vs push_back(短串) 快 10–15% 快 10–15% SSO 内,移动 = 拷贝
vector 扩容(noexcept move) N 次移动 N 次移动 强异常保证下的选择
vector 扩容(非 noexcept move) N 次拷贝 N 次拷贝 宁愿拷贝也不冒风险

注:以上均为本机实测值,不同编译器版本、不同优化级别、不同硬件可能有差异。仅供参考,你的环境请自己量。

10.4 交给 ep04 的钩子:编译期的账

这一篇算的都是运行时的账——构造几次、拷贝几次、移动几次、分配几次、几纳秒。

但值语义还有另一本账——编译期的账。

你有没有想过:emplace_back 的模板参数推导、std::move 的类型萃取、is_trivially_copyable_v 的编译期计算、std::forward 的引用折叠——这些在编译期花了多少时间?一个大项目里,成千上万次模板实例化,编译时间都花在哪了?

更关键的是:值语义的"零成本抽象",在编译期真的是零成本吗?

交给 ep04 的钩子——运行时的账算完了,编译期模板的账呢?
图 3-3336 交给 ep04 的钩子——运行时的账算完了,编译期模板的账呢?@ 本系列绘制

这些问题就是 ep04(编译期账本:模板实例化与编译时间的去处) 要回答的。用跟这一篇同样的方法——探针、实测、逐项对账——把编译期的成本也算清楚。

到时候你会发现,很多"运行时零开销"的东西,在编译期是有代价的。而这些代价,在大项目里可能相当可观——模板实例化占了 C++ 编译时间的相当大一部分。

值语义的账,运行时算完了。编译时的那一半,ep04 见。


资源清单

下篇预告

【C/C++ 现代语言与底层】(四)编译期账本:模板实例化与编译时间的去处——emplace_back 的模板推导花了多久?std::move 的类型萃取有编译开销吗?值语义的"零成本抽象",在编译期真的是零成本吗?用编译探针把每一笔编译期的账都算清楚。

希望能对大家有所帮助。下一篇见。


本文由@溯光 出品 站点:https://yyy3d.online/blog/ | 本系列:https://yyy3d.online/blog/#topic-cpp-language-foundations