石家庄做外贸网站建设网站开发文档怎么写

张小明 2025/12/28 2:29:57
石家庄做外贸网站建设,网站开发文档怎么写,iis如何做网站,excel网站做链接➡ 函数 API 的设计对性能的影响#xff0c;往往比函数内部逻辑更大。 很多人谈性能时#xff0c;只想着#xff1a; 算法复杂度分支、循环SIMD 或微架构优化 函数 API 设计本身就可能决定性能的上限。 为什么 API 设计比函数逻辑影响更大#xff1f; 原因与现代 CPU、…➡ 函数 API 的设计对性能的影响往往比函数内部逻辑更大。很多人谈性能时只想着算法复杂度分支、循环SIMD 或微架构优化函数 API 设计本身就可能决定性能的上限。为什么 API 设计比函数逻辑影响更大原因与现代 CPU、编译器、动态指令执行、代码体积密切相关。要说明几个事实① 微基准microbenchmark不可靠不能真实反映性能低层级的小函数 benchmark抖动大噪声、cache 状态不可控和大型工程代码行为完全不同所以用微基准判断“这个函数快不快”往往是错觉。要比较函数设计benchmark ≠ 好工具。② 更可靠的指标是”dynamic instruction count”动态指令计数动态指令计数意味着程序实际执行了多少条指令。这比时间可靠因为和 CPU 频率、turbo、cache 抖动无关同一段设计会在任何 CPU 上保持类似趋势能真实反映“设计本身导致的开销”所以主张我们比较函数设计就看产生的指令数量。③ 用非常简单的例子就能比较指令数量为了更纯粹检查设计影响例子越简单越好因为编译器生成的机器码越可控越能看出 API 设计差异导致的指令数量变化重点已经从“函数逻辑”转向了设计如何影响编译器能否优化。④ 大型程序的机器码巨大 → I-cache 成为性能瓶颈引用 BOLT 的研究大型应用的 machine code 可能是几十 MB 甚至上百 MBCPU 的 L1 I-cache 通常只有 32KB64KB代码远远超出缓存 → CPU 必须不断从内存拉指令甚至 30% 的时间都浪费在“取指令”不是执行指令换句话说代码越大 → I-cache 压力越大 → 性能下降。而函数 API 设计不佳例如大量模板展开难以内联过多抽象层useless wrapper不必要的指针间接访问会让代码显著变大。⑤ 因此我们只讨论非内联函数因为 inline 会使函数逻辑消失变成“展开”调用成本消失性能高度依赖上下文而我们想讨论函数边界存在情况下API 设计导致的真实性能差异。⑥ inline 的效果不可预测引用 ISO C wiki 的观点inline 可能让代码变快也可能变慢可能让可执行文件变大也可能变小可能造成 thrashing也可能避免 thrashing也可能完全没有影响。所以 inline 并不是“性能银弹”也不是讨论 API 的可靠方式。这进一步支持必须研究 API 本身而不是指望 inline。⑦ 真实大型应用实例Firefox 函数规模分布引用 Firefox157,946 个函数比 127 字节大167,404 个函数比 127 字节小说明大型应用有大量小函数和大量大函数代码规模庞大、复杂API 的设计对最终机器码形状影响极大全段的核心思想总结这一整段内容要传达1. 函数 API 的设计会决定生成的机器码大小与质量。2. 机器码大小会影响 I-cache从而显著影响性能。3. benchmark 不可靠更可靠的是动态指令数量。4. 因为 inline 不可预测所以研究重点是非内联函数。5. 大型工程的代码体积巨大坏 API 会带来真实性能灾难。最终理解式总结一句话性能不仅来自函数内部做了什么更来源于函数“如何设计”、让编译器能生成什么样的机器码。深度理解C 机器码生成的复杂性这一段的核心主题是➡ C 的机器码生成高度依赖 ABI、平台和编译器 → 复杂得惊人。1.C 标准本身不规定机器码、ABI、调用约定C 标准只规定语言语义类型行为作用域规则模板、类、函数等的语法和逻辑但是完全不规定函数如何在机器层面调用参数如何传递寄存器栈返回值如何传递哪些寄存器由 caller 保存、哪些由 callee 保存name mangling符号修饰对象在内存的布局细节这些内容完全由ABI (Application Binary Interface)决定。2.C 的 ABI 由不同生态定义 → 多体系结构多平台多版本机器码生成不会只看 C 标准。流程类似C Standard ↓ C ABI不同平台选择不同如 Itanium ABI、MS ABI ↓ 系统 ABISystem V gABI / Microsoft ABI 等 ↓ 具体平台的 psABIPlatform-specific ABI例如 ARM64、x86-64、iOS、Android因此不同平台的 C 程序之间调用规则不同参数位置不同对象布局不同寄存器保存规则不同最终导致同一段 C 代码在不同平台/编译器下会产生完全不同的机器码。3.作者列举各平台 ABI 编译器组合说明复杂程度作者列出了常见的 CPUOSABI编译器矩阵CPU 架构ABI编译器典型平台ARMv8-ASystem V ABIClangiPhone / Android / Apple M1x86–64System V ABIGCCLinux / old Macx86–64Microsoft ABIMSVCWindowsARMv7-ASystem V ABIClang旧 iPhone / 低端 Androidx86 (IA-32)System V ABIGCC旧 Linuxx86 (IA-32)Microsoft ABIMSVC旧 Windows每种组合都会生成不同的机器码格式。4.说明机器码生成真的非常复杂作者强调The things are complicated.意思是想理解“C 如何生成机器码”需要面对 ABI、体系结构、编译器、操作系统的组合爆炸。换句话说这是硬核工程问题不是 C 标准可以涵盖的。每个平台对函数调用的要求都不同。编译器必须遵循平台 ABI所以不同平台生成的机器码必然不同。5.所以我们需要“简单的规则”来指导在不同平台下写可优化代码因为体系结构太复杂作者希望找到跨平台都有效的通用、简单 C 规则guidelines。于是引入C Core Guidelines也就是说不用直接研究所有平台 ABI只需遵循稳定的 C 编码原则 → 编译器就更容易生成好机器码。总结性理解这一页想表达的深度含义是① C 的机器码生成不由标准决定而由 ABI 和平台决定。② 不同平台 ABI 编译器组合非常多导致机器码生成复杂度极高。③ 为了驾驭这复杂性我们需要统一的、跨平台的 C 编码原则C Core Guidelines。https://godbolt.org/z/ea9M3G94s核心理解为什么「返回值」比「输出参数」更好特别是对std::unique_ptr这一页的主题来自C Core Guidelines F.20F.20对于用于输出的值优先使用返回值而不是输出参数如引用。1.语义与可读性返回值更明确引用参数容易误用使用返回值时std::unique_ptrintvalue_ptr();// 很清晰返回一个指针而输出参数版本voidoutput_ptr(std::unique_ptrintdst);dst的语义不清晰是 input是 in-out还是纯 output必须看文档或函数体才知道。所以语义可读性 → 返回值完胜。2.关键点编译器生成的机器码告诉我们「API 设计会影响性能」展示两个代码** 返回std::unique_ptrint正确 API 设计**std::unique_ptrintvalue_ptr(){returnnullptr;}✘ 使用输出引用参数糟糕 API 设计voidoutput_ptr(std::unique_ptrintdst){dstnullptr;}逻辑几乎一样把一个 unique_ptr 设为 null。但机器码却完全不同深度理解为什么机器码不一样因为std::unique_ptr的析构函数必须删除旧指针输出参数版本dst nullptr必须考虑“原本的 dst 里面是不是有旧指针要不要 delete”因此编译器必须生成额外代码来检查、删除旧资源test rax, rax je skip_delete mov rcx, rax jmp operator delete而返回值版本返回一个新unique_ptr对象内部没有旧资源不需要 delete不存在输入 → 输出的指针覆盖问题所以编译器可以生成更短、更纯粹的机器码。各平台机器码对比理解重点下面不是而是解释每个平台机器码揭示了同一个本质返回值路径不需要 delete而输出参数路径需要 delete → 机器码更多、更慢。▶ ARM64Apple/Android/iPhone返回值版本str xzr, [x8] ret只有 1 条指令写 0。输出参数版本ldr x0, [x0] ; 读旧指针 str xzr, [x8] ; 设为 null cbz x0, skip_delete b operator delete明显多了读取旧值判断是否为 null可能调用 delete▶ x86-64 Linuxgcc返回值版本mov [rdi], 0 mov rax, rdi ret输出参数版本mov rax, [rdi] ; load old ptr mov [rdi], 0 ; set null test rax, rax je skip mov rdi, rax jmp operator delete同样多了 delete 逻辑。▶ Windows x64MSVC同样模式返回值简单设 0输出参数load → test → maybe delete → store无论平台、ABI、编译器输出参数 always 导致额外分支和潜在的 delete返回值 always 更干净结论性理解这一页内容想让读者理解** API 设计选择返回值 vs 输出参数直接决定机器码的复杂度。**** 返回值路径更简单 → 更短的机器码 → 更少分支 → 可内联 → 性能更佳。**** 输出参数会让编译器必须防御性处理旧值 → 对强 RAII 类型特别糟糕。**尤其是像std::unique_ptrstd::vectorstd::stringstd::optional自定义 RAII 资源这些类型都有析构逻辑所以输出参数永远会让机器码变复杂。最终理解总结一句话对于有资源语义RAII的类型返回值总是比输出参数更高效、更安全、更清晰因为编译器不需要处理“旧值可能持有资源”的情况。https://godbolt.org/z/G9aPehqM1返回std::unique_ptr与通过引用输出std::unique_ptr在调用点call site生成的机器码为什么会不同以及这一差异意味着什么。一、理解重点调用点call site为什么也会生成不同代码两个调用版本返回值版本std::unique_ptrintvalue_ptr();intvalue_ptr_call(){autoptrvalue_ptr();// 返回值return*ptr;}✘ 输出参数版本voidoutput_ptr(std::unique_ptrintdst);intoutput_ptr_call(){std::unique_ptrintptr;output_ptr(ptr);// 输出参数return*ptr;}二、最重要的理解两者在调用点都必须做「析构检查 delete」调用点必须确保局部变量ptr在函数结束前被正确析构。对于std::unique_ptr来说析构 ≈ 如果内部指针非空 → 调用operator delete也就是说value_ptr_call()在函数结束时必须析构ptroutput_ptr_call()在函数结束时也必须析构ptr这两个函数末尾都需要读出内部指针判断是否为 null如果不是 null → 删除返回值所以两者末尾都包含load pointer test pointer if not zero → call delete这就是为什么你看到ARM64ldr→cbz→bl deletex86:mov→test→call delete三套编译器都一样。三、那两者有什么不同关键区别在输出参数版本必须先清空指针store 0返回值版本不需要先清空指针我们分析关键动作返回值版本调用value_ptr()返回的unique_ptr放到局部变量ptr函数结束时析构ptr这里不存在「ptr 原本可能有值」的问题。✘ 输出参数版本调用前ptr是默认初始化内部值为未初始化或 null调用output_ptr(ptr)函数内部执行dst nullptr这一步必须先删除旧值因为dst可能非空因此输出参数调用路径必须包含调用 output_ptroutput_ptr 内部执行检查旧指针 → delete → 写 0调用点在作用域结束时再次执行析构 delete最关键总结输出参数版本会产生双重 delete 检查路径流程如下● 返回值版本 delete 流程调用点只需 delete 一次value_ptr() → 返回新 ptr → 调用点析构delete● 输出参数版本 delete 流程两层 delete 检查output_ptr(ptr) ↓ 内部为 dst nullptr: 如果 dst 原本有值 → delete 然后设为 null ↓ 调用点析构delete即使最终值是nullptr这两个路径仍必须保持因为编译器不能假设用户没有写奇怪的代码。四、核心理解总结最重要使用返回值时唯一的可能 delete 是调用点的析构 delete。使用输出参数时delete 检查出现两次一次在 output_ptr 内部一次在调用点析构。这就导致返回值版本 1 次 delete 检查✘ 输出参数版本 2 次 delete 检查这是所有平台机器码的本质区别。五、编译器无法优化掉第二次 delete 检查这是因为返回值路径中编译器知道 value_ptr() 返回的是一个全新的对象该对象不可能包含旧资源。输出参数路径中编译器必须假设「传入的 unique_ptr 可能是非空」不能优化掉 delete 检查。因此 API 设计直接影响编译器的优化能力。六、总结为一句话返回std::unique_ptr的 API 更好因为可以保证对象从无到有编译器能生成最小化的资源管控代码。而输出参数 API 无法保证原值为空导致调用点也需要加入额外的 delete 检查逻辑。代码本体回顾std::unique_ptrintvalue_ptr();voidoutput_ptr(std::unique_ptrintdst);intvalue_ptr_call(){autoptrvalue_ptr();//- return by valuereturn*ptr;}intoutput_ptr_call(){std::unique_ptrintptr;//Has to be destroyed if output_ptr throwsoutput_ptr(ptr);//- output parameterreturn*ptr;}第一部分value_ptr_call的逻辑与汇编高层逻辑value_ptr()返回一个完整的对象std::unique_ptrint。编译器使用返回值优化RVO或寄存器/内存区域返回值。编译器负责在栈上准备一块空间让value_ptr()把unique_ptr填进去。对应汇编已给出逐行解释value_ptr_call push rbx ; 保存 rbx按 ABI 规定需要保存 sub rsp, 16 ; 栈上留 16 字节用于临时存储 unique_ptr lea rdi, [rsp8] ; rdi (用于接收 unique_ptr 的栈槽) call value_ptr() ; 调用返回值写入[rsp8]~[rsp15] mov rdi, QWORD PTR [rsp8] ; rdi ptr.get() mov esi, 4 ; esi sizeof(int) mov ebx, DWORD PTR [rdi] ; ebx *ptr 载入返回值 call operator delete(void*) ; 调用 unique_ptr 析构删除指针 add rsp, 16 mov eax, ebx ; 返回值存到 eax pop rbx ret详细解析含机制说明1. 返回值布局当函数返回一个大小为 8 字节的unique_ptrint时返回值被放在调用者提供的内存中。caller stack : [ r s p 8 ] ; → ; 存 unique_ptr 内的指针值 \text{caller stack}:[rsp 8] ;\to; \text{存 unique\_ptr 内的指针值}caller stack:[rsp8];→;存unique_ptr内的指针值value_ptr()是这样写入的unique_ptr ┌──────────────┐ │ T* pointer │ ← 写到 [rsp8] └──────────────┘2. 读值*ptr执行mov rdi, QWORD PTR [rsp8] ; 加载指针 mov ebx, DWORD PTR [rdi] ; *ptr等价于 Cinttmp*ptr;3. 析构 unique_ptrmov esi, 4 call operator delete(void*)这里esi 4表示sizeof(int)rdi ptr.get()即delete ( ptr.get() ) \text{delete}( \text{ptr.get()} )delete(ptr.get())这是unique_ptrint的析构函数执行的事情。关键点value_ptr_call中unique_ptr 的寿命只持续到函数结束所以它会自动 delete 返回的指针。第二部分output_ptr_call的逻辑与汇编高层逻辑调用者必须先构造一个空的 unique_ptr作为 output 参数。output_ptr(ptr)会写入一个新指针。若output_ptr抛异常ptr的析构必须正确运行即便它是空的也必须析构。汇编逐行解释output_ptr_call push rbx sub rsp, 16 mov QWORD PTR [rsp8], 0 ; 初始化 ptr nullptr lea rdi, [rsp8] ; rdi ptr call output_ptr(std:unique_ptr) mov rdi, QWORD PTR [rsp8] ; 载入 ptr.get() mov esi, 4 mov ebx, DWORD PTR [rdi] ; *ptr call operator delete(void*) ; 析构 ptr add rsp, 16 mov eax, ebx pop rbx ret深度解释1. 输出参数形式必须先构造对象mov QWORD PTR [rsp8], 0等价于 Cstd::unique_ptrintptr;// 初始为 nullptr为什么必须这样因为output 参数是引用函数内部会认为你已经构造好了一个合法的std::unique_ptr如果没有初始化引用将悬空如果output_ptr()抛异常必须对这个ptr调用析构而析构要求其内容是合法状态nullptr 是合法状态2. 调用 output 函数lea rdi, [rsp8] ; rdi ptr call output_ptr(...)被调用函数会写入ptr new value \text{ptr} \text{new value}ptrnew value3. 使用指针载入返回值mov rdi, QWORD PTR [rsp8] mov ebx, DWORD PTR [rdi]即intx*ptr;4. 析构 ptrcall operator delete(void*)与返回值版本一样析构时自动 delete 内部指针。why 两种版本汇编几乎一样两者最终都要执行解引用*ptr析构 ptrdelete 指针唯一不同之处返回值版本调用前不需要初始化因为value_ptr()会直接写入完整对象。输出参数版本调用前必须ptr n u l l p t r \text{ptr} nullptrptrnullptr因为可能抛异常必须从已初始化状态进入。这就是为什么多了mov QWORD PTR [rsp8], 0小总结含公式返回值版本等价于unique_ptr_call: p ← value_ptr() return ∗ p \text{unique\_ptr\_call: } p \leftarrow \text{value\_ptr()} \ \text{return } *punique_ptr_call:p←value_ptr()return∗p输出参数版本等价于p ← nullptr output_ptr ( p ) return ∗ p p \leftarrow \text{nullptr} \ \text{output\_ptr}(p) \ \text{return } *pp←nullptroutput_ptr(p)return∗p汇编唯一结构性差异output 参数版本必须先构造 p n u l l p t r \text{output 参数版本必须先构造 } p nullptroutput参数版本必须先构造pnullptr背景问题为什么“输出参数out-parameter”不好你的代码intoutput_ptr_call(){std::unique_ptrintptr;// Default constructed hereoutput_ptr(ptr);// output parameterreturn*ptr;}C Core Guidelines特别是 ES.20强调ES.20: Always initialize an object“总是初始化你的对象”。因为未初始化的对象可能被使用 →未定义行为UB初始化分散在多处导致可读性差输出参数很难保证异常安全refactoring 时容易遗漏初始化点为何 output 参数需要默认构造对于voidoutput_ptr(std::unique_ptrintdst);调用端必须写std::unique_ptrintptr;// 默认构造内部 pointer nullptroutput_ptr(ptr);如果不这么做dst成为引用到未构造对象→UB。返回值版本不需要默认构造intvalue_ptr_call(){autoptrvalue_ptr();// 返回值写入到完整对象return*ptr;}这种形式由编译器保证value_ptr() 一定返回一个完全初始化的对象 \text{value\_ptr() 一定返回一个完全初始化的对象}value_ptr()一定返回一个完全初始化的对象所以调用处不必先构造。总结这两种模式的差异输出参数必须默认构造因为函数参数类型是T (引用) T\ \quad\text{(引用)}T(引用)引用必须绑定到一个完全构造好的对象。返回值不需要默认构造因为返回值机制保证调用者创建的空间 ; ← ; 被调用者负责填充完整对象 \text{调用者创建的空间} ;\leftarrow; \text{被调用者负责填充完整对象}调用者创建的空间;←;被调用者负责填充完整对象C 编译器在返回路径上检查所有执行路径都必须初始化返回对象 \text{所有执行路径都必须初始化返回对象}所有执行路径都必须初始化返回对象deferred_construction 问题解释为了避免std::string s;// 默认构造read_strings(in,s);一些库会引入deferred_constructionstd::stringoutput;read_strings(in,out(output));它的目的避免在调用前发生默认构造default construction但这并不能完全解决问题。deferred_construction 的 Pros / Cons 解释### Pros(1) 避免默认构造这一点成立。调用前内部 T 未构造 \text{调用前内部 T 未构造}调用前内部T未构造所以避免了 “先默认构造 → 再被赋值” 的成本。✖ Cons —— 为什么仍然不够好(1) 异常安全仍然需要处理stack unwind即使你延迟构造当read_strings()抛出异常时外面仍然需要判断对象是否已经构造在离开作用域时调用析构这意味着需要额外的状态bool flag : object_initialized \text{bool flag} : \text{ object\_initialized }bool flag:object_initialized(2) 需要额外 bool 标记对象是否被构造必须知道对象是否已经构造c o n s t r u c t e d t r u e / f a l s e constructed true/falseconstructedtrue/false是否被初始化不止一次是否可能有未初始化路径(3) 用户有责任确保不调用两次初始化例如autooutdeferred_constructionT{};foo(out);bar(out);// 第二次初始化 — 出错这是危险的。核心结论真正能解决问题的是“返回值”你文中最终一句话Any C compiler checks that every execution path in a function ends with a return statement. We just need to return by value这是本质返回值有编译器强制保证∀ execution path , ; return value 必须被初始化 \forall \text{ execution path},; \text{return value 必须被初始化}∀execution path,;return value必须被初始化输出参数则没有这种保证。返回值模型的优势数学表达1. “对象初始化点唯一”返回值版T ; f ( ) 对象 T 的构造一定发生在 f() 内部一次且仅一次 T ; f() \ \text{对象 T 的构造一定发生在 f() 内部一次且仅一次}T;f()对象T的构造一定发生在f()内部一次且仅一次输出参数版f ( T o u t ) T 的构造不在 f 内调用方必须保证 out 已构造 f(T\ out) \ \text{T 的构造不在 f 内调用方必须保证 out 已构造}f(Tout)T的构造不在f内调用方必须保证out已构造2. “异常安全自然成立”返回值版自动满足no initialization before and no leaking on exception \text{no initialization before} \ \text{and no leaking on exception}no initialization beforeand no leaking on exception3. 调用方无需默认构造返回值版autopvalue_ptr();// p 必定被完全构造输出参数版T p;// 必须默认构造f(p);// 再次初始化4. 保证到达 return 即完成初始化compiler ensures that returned object is fully initialized \text{compiler ensures that returned object is fully initialized}compiler ensures that returned object is fully initialized这是 C 的语言级别保证不是库层封装能提供的。总结你提出的问题都指向同一个最终答案用返回值return-by-value完全解决初始化、异常安全、重复初始化、对象生命周期等问题。输出参数模式需要默认构造 → 不符合 ES.20deferred_construction虽能避免默认构造但增加额外状态 flag异常路径管理不能防止重复初始化不如返回值安全最终结论简要**返回值设计return-by-value是现代 C 推荐的唯一正确方式。你已经被说服output parameter 是坏主意现在的问题是“返回值return-by-value到底如何工作尤其是对 C 抽象类型如std::unique_ptr会不会带来开销”C Core Guidelines 给出答案C Core Guidelines — F.26F.26: Useunique_ptrTto transfer ownership where a pointer is needed**理由**返回或传递所有权时unique_ptr是最便宜、最安全的方式。为什么“最便宜”你的汇编证明了返回智能指针几乎就是“写一个 0 到内存里”这么简单。对比返回 raw pointer vs 返回 unique_ptr示例int*raw_ptr(){returnnullptr;// 返回原生指针}std::unique_ptrintsmart_ptr(){// 返回智能指针returnnullptr;}ARMv8-A Clang 18.1.0 输出raw_ptr()mov x0, xzr ret解释xzr是 ARM 的零寄存器总是 0返回寄存器x0 0等价于returnnullptr;smart_ptr()str xzr, [x8] ret解释在 ARM System V ABI 下复合类型如unique_ptr采用sret结构返回机制调用者会把 **“返回对象的存储地址”**放进寄存器x8str xzr, [x8]即[ return_slot ] 0 [\text{return\_slot}] 0[return_slot]0也就是unique_ptrintresult;result.pointernullptr;// 写入返回槽x86-64 GCC 14.2 输出raw_ptr()xor eax, eax ret解释eax 0返回原生指针smart_ptr()mov QWORD PTR [rdi], 0 mov rax, rdi ret解释在 SysV x86-64 ABI 下返回值可能通过 “隐藏指针” 传入rdimov [rdi], 0*(return_slot) 0 \text{*(return\_slot)} 0*(return_slot)0mov rax, rdirax return_slot \text{rax} \text{return\_slot}raxreturn_slot即返回一个对象引用C ABI 的要求。x64 MSVC (VS 19.40)raw_ptr()xor eax, eax ret一样返回 0。smart_ptr()sub rsp, 24 ; 栈上准备返回对象空间 xor eax, eax ; eax 0 mov QWORD PTR [rcx], rax ; *(rcx) 0 mov rax, rcx ; 返还地址 add rsp, 24 ret说明MSVC 也使用隐藏参数rcx作为 return-slot 指针写入unique_ptr的内部指针为 0等价于 Cstd::unique_ptrintresult(nullptr);returnresult;为什么返回 unique_ptr 这么便宜你看到的所有指令都可以概括为*(return_slot) 0 \text{*(return\_slot)} 0*(return_slot)0也就是往内存写一个 8 字节的零。无论 ARM、GCC、MSVC 都在做这一点。没有分配没有构造复杂对象没有深拷贝没有引用计数unique_ptr 不需要真正的成本是1 次内存存写 ( 可能的 r e t u r n − s l o t r e t ) \text{1 次内存存写} \quad ( 可能的 return-slot ret)1次内存存写(可能的return−slotret)为什么输出参数反而更差因为 output 参数需要默认构造一个对象T o u t ; 必须先构造 T\ out;\quad \text{必须先构造}Tout;必须先构造之后使用 out 参数进行“赋值式初始化”out T ( value ) \text{out} T(\text{value})outT(value)如果函数抛异常调用方必须负责清理 out因此 output 参数的生命周期default ctor ⏟ ∗ 不必要 → write/assign ⏟ ∗ 后续赋值 → destructor ⏟ 可能异常路径 \underbrace{\text{default ctor}}*{\text{不必要}} \rightarrow \underbrace{\text{write/assign}}*{\text{后续赋值}} \rightarrow \underbrace{\text{destructor}}_{\text{可能异常路径}}default ctor​∗不必要→write/assign​∗后续赋值→可能异常路径destructor​​而返回值的生命周期ctor once → move (可省略) → destructor \text{ctor once} \rightarrow \text{move (可省略)} \rightarrow \text{destructor}ctor once→move (可省略)→destructor更短更简单更安全。关键观察返回 unique_ptr 与返回原生指针的成本几乎一样返回int*写寄存器1 指令返回unique_ptrint写内存1 指令两者都极快。你看到的这些汇编证明了现代 ABI 针对小型 POD 类如 unique_ptr做过高度优化。返回值的代价几乎等同于返回基本类型。核心结论“返回值”模型非常便宜并且经过 ABI 特化return-by-value ≈ 存一个机器字到内存/寄存器 \text{return-by-value} \approx \text{存一个机器字到内存/寄存器}return-by-value≈存一个机器字到内存/寄存器“输出参数”不仅更复杂还更不安全并且需要默认构造额外赋值异常安全处理手工管理生命周期返回 unique_ptr 是现代 C 的最佳实践没有额外成本没有额外责任没有异常陷阱编译器能优化到极致往往消除一切多余动作https://godbolt.org/z/ExaoKfT1z背景F.26 说 unique_ptr 是“最便宜的安全方案”但 NOT freeC Core Guidelines — F.26Useunique_ptrTto transfer ownership when a pointer is neededReason:Usingunique_ptris the cheapest way to pass a pointersafely.其中的关键是“cheapest safe way” ≠ “zero cost”unique_ptr 是一个包装类型wrapper type就像你的INT包装 intstd::chrono包装整数表示的时间Units 库包装数值Safe integers包装 intLanguage bindings包装外语对象指针所有这些包装类型本质上都是“小型结构体small struct”因此在 ABI 层面它们都自动会触发SRET (structure return) ABI \text{SRET (structure return) ABI}SRET (structure return) ABI这意味着返回值不会放在寄存器里除了 POD小类型而是由调用者提供 “return slot”函数通过写内存来返回结构体内容这一点在所有 CPU 架构上都一致。INT 的代码证明了任何包装类型都不是“free”你的INTstructINT{intvalue;INT(intvalue0):value{value}{}INT(INTsrc):value{src.value}{}INToperator(INTsrc){...}INT(INTconstsrc):value{src.value}{}INToperator(INTconstsrc){...}~INT(){}};注意它只有一个int成员析构/拷贝/移动都 trivial但是仍然是一个struct因此 ABI 将它当作UDTuser-defined type不能像int那样放在寄存器返回。即int → reg-return INT → sret-return \text{int → reg-return} \ \text{INT → sret-return}int → reg-returnINT → sret-return看实际汇编INT_seconds vs int_seconds1. ARMv8-A Clang 18.1int_seconds()mov w0, #60 ret解释w 0 w0w0 返回寄存器返回立即数 60INT_seconds()mov w9, #60 str w9, [x8] ret解释调用者把返回对象地址放入x 8 x8x8函数写入*(return_slot) 60 \text{*(return\_slot)} 60*(return_slot)60然后返回2. x86-64 GCC 14.2int_seconds()mov eax, 60 ret寄存器返回。INT_seconds()mov DWORD PTR [rdi], 60 mov rax, rdi ret解释调用者传入 return slot 的地址在rdi写内存[ r d i ] 60 [rdi] 60[rdi]60再把rdi返回ABI 要求3. MSVC x64INT_seconds()mov DWORD PTR [rcx], 60 mov rax, rcx ret完全一样return slot rcx写入 60返回 rcx4. ARMv7-A (32-bit)INT_seconds()mov r1, #60 str r1, [r0] bx lrreturn slot 在 r0将 60 写在 r0 指向的内存位置5. x86 (32-bit) GCCmov eax, DWORD PTR [esp4] mov DWORD PTR [eax], 60 ret 4return slot 地址在栈上的 [esp4]写*(return-slot) 60核心规律总结公式返回内建类型intreturn-reg value \text{return-reg} \text{value}return-regvalue返回包装类型INT、unique_ptr、chrono、units etccallee receives a return-slot pointer p *(p) constructed-object-content return p \begin{aligned} \text{callee receives a return-slot pointer } p \ \text{*(p)} \text{constructed-object-content} \ \text{return } p \end{aligned}callee receives a return-slot pointerp*(p)constructed-object-contentreturnp​其机器意义至少一次内存写入有时一次寄存器复制因此 NOT free但非常便宜成本与大小无关只要结构体小无需默认构造无需输出参数不会有异常问题符合现代 C 设计方式为什么 unique_ptr 仍然是 “cheapest safe way”因为unique_ptr 的唯一数据是一个指针8 bytes返回它只需要1 次内存写 ∗ ( r e t u r n s l o t ) ptr \text{1 次内存写} *(return_slot) \text{ptr}1次内存写∗(returns​lot)ptr这与返回INT写 4 bytes返回std::chrono::seconds写 8 bytes返回safeint写 4 bytes完全相同。即所有“小的包装类型”的返回开销都一样而 unique_ptr 提供了“独占所有权语义”这是 raw pointer 无法提供的所以 unique_ptr 是 “最便宜的安全方式”核心结论非常简明扼要结论 1unique_ptr 不是 free因为它是一个 struct返回它需要写 memory ≠ 写寄存器 \text{写 memory} \ne \text{写寄存器}写memory写寄存器结论 2unique_ptr 的成本与 INT 一样因为两者都是一个成员小型 trivially movable struct走 SRET ABI返回时写一个 machine word结论 3unique_ptr 始终比 raw pointer 稍贵因为 raw pointermov eax, 60 retunique_ptrmov [rdi], 0 mov rax, rdi ret多一次内存写。但这是为安全付的极小代价。结论 4unique_ptr 的抽象代价与规模无关无论包装int指针chronounitssafe int返回成本都是1x memory store \text{1x memory store}1x memory storehttps://godbolt.org/z/Tddd4E6hs问题的核心为什么INT_seconds()返回一个INT会变成“隐藏参数 写内存”Itanium C ABI 规定Itanium C ABI几乎所有类 Unix 平台 Clang/GCC都遵守在3.1.3.1 Non-trivial Return Values中规定如果一个函数返回的类型是“在调用语义上非平凡non-trivial”的类那么调用者会向被调用函数传入一个隐藏指针参数指向返回值要构造的位置被调用者必须在这个地址中构造返回对象。用公式表示callee ( r e s u l t p t r , a r g s . . . ) ⇒ construct object at ∗ r e s u l t p t r \text{callee}(result_ptr, args...) \Rightarrow \text{construct object at } *result_ptrcallee(resultp​tr,args...)⇒construct object at∗resultp​tr因此返回一个 class 与返回一个 PODint、double不同编译器不能把INT直接放进寄存器返回而是必须“就地构造”——写内存。C 语言层面的背景什么是 Trivial ClassC 标准定义一个 class 是 “trivial” 的当且仅当它是trivially copyable它拥有至少一个trivial 的默认构造函数如果两者都满足它就是 trivial。为什么你的INT不是 trivial你的版本中structINT{intvalue;INT(intvalue0):value{value}{}~INT(){}};关键问题自定义了析构函数即使它是空的~INT(){}只要用户声明了析构函数它就不是 trivial destructor于是默认构造函数不再 trivial类型也不再 trivial类型也不是 trivially copyable因此根据 Itanium ABI必须以“隐藏return指针 就地构造”方式返回。这就是为什么INT_seconds()生成的是写内存代码例ARMv8 clangINT_seconds(): mov w9, #60 str w9, [x8] ret解释x8是 ABI 规定的“隐藏返回指针” →[ x 8 ] [x8][x8]的位置就是返回对象存放位置函数把60写入[ x 8 ] [x8][x8]没有把对象放进寄存器返回而int版本mov w0, #60 ret只用一个寄存器完全不用内存。所有平台都表现一致x86-64 gccINT_seconds(): mov DWORD PTR [rdi], 60 mov rax, rdi ret隐藏返回指针在rdi→[ r d i ] [rdi][rdi]写值rax rdi是 ABI 规定的返回值地址C ABI 要求MSVC也使用类似策略mov DWORD PTR [rcx], 60 mov rax, rcx ret隐藏返回指针在rcx。ARMv7、x86-32 等也都是相同模式所有平台统一行为非平凡类型的返回值必须写入调用者提供的内存地址。对比如果INT是 trivial会怎样你的类如果改成structINT{intvalue;};即无用户定义构造函数无用户定义析构函数无复制/移动构造那么它是trivial且trivially copyable此时编译器会把它当成一个int来处理→直接用寄存器返回不会写内存不会用隐藏指针INT_seconds()会变成mov w0, #60 ret和int完全一致。精髓总结最重要返回类型是否 trivialABI 返回策略结果inttrivial放在寄存器中返回最快struct { int v; }trivial放在寄存器中返回和 int 一样快自定义构造/析构的 class非 trivial通过“隐藏返回指针”返回 → 写内存必须写内存不能寄存器返回std::unique_ptrT非 trivial一样是隐藏指针策略small wrapper但成本更高于 raw pointer你看到的所有编译器差异本质原因只有一个你的INT不是 trivial所以所有 ABI 都必须用“隐藏返回指针 就地构造”。而不是因为 unique_ptr 或 INT 本身“慢”而是ABI 强制这样做。https://godbolt.org/z/f6s1P96TxThe problem更深入的根本原因你现在要理解的关键点是Itanium C ABI 判断一个类型是否“non-trivial for the purposes of calls”与C 标准判断一个类型是否 trivially copyable是两个不同标准。它们虽然相关但绝对不等价。1. Itanium C ABI 的关键规则决定返回方式ABI 规定一个类型在函数调用语义上被认为是 non-trivial如果它有一个非平凡的non-trivial拷贝构造、移动构造或析构函数或者它的所有拷贝/移动构造函数都被删除用公式表达 ABI 的判断条件is_non_trivial_for_calls ( T ) ( T . copy_ctor not trivial ) ∨ ( T . move_ctor not trivial ) ∨ ( T . dtor not trivial ) ∨ ( T . all copy/move ctors deleted ) \text{is\_non\_trivial\_for\_calls}(T) (T.\text{copy\_ctor not trivial}) \lor \\ (T.\text{move\_ctor not trivial}) \lor \\ (T.\text{dtor not trivial}) \lor \\ (T.\text{all copy/move ctors deleted})is_non_trivial_for_calls(T)(T.copy_ctor not trivial)∨(T.move_ctor not trivial)∨(T.dtor not trivial)∨(T.all copy/move ctors deleted)只要满足任意一项ABI 就认为这个类型不能用寄存器返回只能callee ( r e s u l t _ p t r ) → construct at ; ∗ r e s u l t _ p t r \text{callee}(result\_ptr) \to \text{construct at };*result\_ptrcallee(result_ptr)→construct at;∗result_ptr2. C 标准的 Trivially Copyable 与上面不同C 对 trivial copyability 的要求更严格除了构造函数还要求赋值运算符也是 trivial也就是说C 的判定是trivially_copyable ( T ) ⇒ trivial ctor/dtor ∧ trivial copy/move assign \text{trivially\_copyable}(T) \Rightarrow \text{trivial ctor/dtor} \land \\ \text{trivial copy/move assign}trivially_copyable(T)⇒trivial ctor/dtor∧trivial copy/move assign但 ABI根本不关心 assignment operator。最关键差异总结ABI 只关心构造和析构是为了决定调用时是否需要隐藏的 return pointerSRET。C 标准的 triviality 还额外要求 trivial assignment但这对 ABI 不重要。现在回到你的例子去掉析构函数的INT你写的版本structINT{intvalue;INT(intvalue0):value{value}{}};性质没有用户自定义析构函数 → 析构 trivial默认生成 copy/move ctor → trivial存储布局与int完全相同对 ABI 来说构造和析构全部 trivial所以ABI 认为它是 trivial-for-calls。于是→不需要隐藏返回指针→可以像返回 int 一样直接用寄存器返回结果验证所有平台都会寄存器返回ARMv8 clangINT_seconds(): mov w0, #60 ret→ 与int_seconds完全相同。x86-64 gccINT_seconds(): mov eax, 60 ret→ 直接寄存器返回$eax$。ARMv7 clangINT_seconds(): mov r0, #60 bx lr→ 寄存器$r0$返回。为什么 MSVC x64 依然使用隐藏返回指针你观察到mov DWORD PTR [rcx], 60 mov rax, rcx这是因为MSVC x64 ABI 与 Itanium ABI 不同MSVC从不对非 POD 的返回使用寄存器。哪怕类型是 trivial也一样用 “sret 返回地址” 的方式。这是ABI 的差异不是 C 的差异。所以它总是callee ( R C X r e t u r n p t r ) \text{callee}(RCX return_ptr)callee(RCXreturnp​tr)并写入mov DWORD PTR $$[rcx]$$, 60x8632-bit平台的进一步差异根据 SysV ABI / MSVC ABI32-bit 下结构体通常无法用寄存器返回所以许多平台仍然使用 sret 机制所以你看到 32-bit 下依然是mov eax, DWORD PTR [esp4] ; return pointer mov DWORD PTR $$[eax]$$, 60 ret 4这是 ABI 决定的。全面总结最重要的部分对 Itanium ABIGCC/Clang、Linux、macOS、ARM类型trivial-for-calls?返回方式intYes寄存器struct INT { int value; }Yes寄存器struct INT { int value; ~INT(){} }No隐藏返回指针std::unique_ptrNo隐藏返回指针std::stringNo隐藏返回指针对 MSVCWindows x64类型返回方式PODint, struct{int}寄存器“用户定义类型”永远使用隐藏返回指针MSVC x64 对用户类型更保守。简洁一句话总结Itanium ABI只要类型的构造函数和析构函数是 trivial就允许用寄存器返回。你的新 INT 恰好满足所有 trivial 条件因此和 int 等价。MSVC x64 不遵守 Itanium 规则它把几乎所有 class 当成不能寄存器返回因此仍然使用隐藏返回指针。https://godbolt.org/z/qeq9rEE8T1. 代码回顾你现在的类型是structINT{intvalue0;};这个类型没有自定义构造函数没有析构函数没有用户定义的复制或移动构造/赋值完全是POD-like平凡类型因此C 生成的所有特殊成员函数全部是trivial。2. ABI 会如何处理这个类型根据 Itanium C ABI 的规则如果一个类型在构造、移动、复制、析构上都是 trivial则它是 “trivial for the purposes of calls”→ 可以直接通过寄存器返回。你这里的INT构造 trivial拷贝构造 trivialmove 构造 trivial析构 trivial因此INT is trivial-for-calls \text{INT is trivial-for-calls}INT is trivial-for-calls意味着return INT ≡ return int \text{return INT} \quad\equiv\quad \text{return int}return INT≡return int最重要的结果你的 INT 类型在所有遵循 Itanium ABI 的平台上将完全等价于 int 返回。返回方式、指令、寄存器全部一样。3. 编译器输出解析现在我们解释你给出的每段汇编。ARMv8 clang 18.1int_seconds(): mov w0, #60 ret INT_seconds(): mov w0, #60 ret解释ARMv8 的整数返回使用寄存器w 0 w0w0两个函数完全相同表示INT被当成纯粹的 int 包装没有任何额外成本x86-64 gcc 14.2int_seconds(): mov eax, 60 ret INT_seconds(): mov eax, 60 ret解释x86-64 使用寄存器e a x eaxeax返回 32-bit 数据两者完全一致INT没被视为类而是被优化成等价于 intMSVC x64关键区别INT INT_seconds(void): mov eax, 60 ret解释MSVC x64 以前对“用户定义类型”会强制使用 SRET隐藏返回指针但在这个测试中它优化掉了 SRET 机制。为什么因为这个类型没有构造函数没有析构函数是 literal type是 POD完全等价于原始类型所以 MSVC 判断I N T fits into a register ⇒ return via e a x INT \text{ fits into a register} \Rightarrow \text{return via } eaxINTfits into a register⇒return viaeax这是 MSVC 新版本的一个优化行为。ARMv7 clang 11.0.1mov r0, #60 bx lrARMv7 返回 int 用r 0 r0r0同样的代码意味着I N T ≡ i n t INT \equiv intINT≡intx86-32 gcc 14.2 (-m32)INT_seconds(): mov eax, DWORD PTR [esp4] mov DWORD PTR $$[eax]$$, 60 ret 4解释32-bit ABI 与 64-bit ABI 完全不同在 x86-32 上大多数非基本类型结构都使用隐藏返回指针SRET即函数实际签名被转换为voidINT_seconds(INT*return_slot);所以返回值地址存放在$$[esp4]$$函数写入数据到这个地址使用ret 4清理隐藏参数这是因为 x86-32 ABI 不是 Itanium ABI不会因为 trivial 就寄存器返回结构体。MSVC x8632-bitINT INT_seconds(void): mov eax, 60 retMSVC x86 与 GCC x86 不同MSVC 认为 4 字节的结构体可以直接通过寄存器返回所以它用e a x 60 eax 60eax60返回结构体。4. 关键结论最重要在 Itanium ABILinux/macOS/Clang/GCCI N T trivial-for-calls ⇒ return in register INT \text{trivial-for-calls} \Rightarrow \text{return in register}INTtrivial-for-calls⇒return in register因此INT_seconds()与int_seconds()产生完全相同的代码。在 MSVC x64 现代版本MSVC x64 通常对用户类使用 SRET但对于 trivially constructible 的 POD并且大小≤ 8 \le 8≤8字节它会优化为寄存器返回。所以也变成e a x 60 eax 60eax60在 x86-32ABI 规则不同平台是否寄存器返回 trivially-small struct?GCC x86 (SysV ABI)否必须 SRETMSVC x86可以寄存器返回最后一句话总结当一个类型没有自定义构造函数、析构函数、拷贝/移动函数时它成为完全 trivial 的 POD。在大多数平台Itanium ABI, ARM, GCC/Clang中这种类型完全等价于一个普通整数可以直接通过寄存器返回没有任何开销。在 x8632-bit等老 ABI 上规则不同不影响结论本身。https://godbolt.org/z/c6GbTdjc7总体主题1. armv8-a System V ABI规则 1大于 16 字节的复合类型必须走内存If the argument type is a Composite Type that is larger than 16 bytes, then the argument is copied to memory.解释如果一个类型的大小 16 1616bytes那么参数要放在内存中。返回值也一样因为规则 2返回值使用与参数相同的寄存器规则the result is returned in the same registers as would be used for such an argument.即“返回值如何返回” “这个类型如果作为参数会放在哪里”。规则 3小于等于 16 字节且 trivial 的 composite可以放寄存器Otherwise… The address… shall be passed in x8.如果类型太复杂比如非 trivial要用隐藏指针x8传递返回地址。结构体的内容会写到该地址中而不会放在 x0、x1 这些寄存器中。2. x86-64 System V ABI核心点只要 C 类型对 “calls” 是 non-trivial就无法寄存器传递If a C object is non-trivial for the purpose of calls … it is passed by invisible reference… in %rdi…即若类型是 “non-trivial for the purpose of calls”则只能通过隐藏指针传入或返回。这个标准来自 Itanium C ABI一个类型是 non-trivial for calls 当且仅当它有非 trivial 的拷贝构造、移动构造、析构或者它的拷贝/移动构造被删除trivial 并且分类为 INTEGER 才能用寄存器返回If the class is INTEGER, the next available register %rax, %rdx is used例如一个只包含i n t intint的结构体且 trivial会被拆分到整数寄存器rax、rdx。3. x86-64 Microsoft ABI微软的要求更严格To return a user-defined type by value in RAX, it must have length 1, 2, 4, 8, 16, 32, or 64 bits.It must also have no user-defined constructor, destructor, or copy assignment operator.This definition is essentially the same as a C03 POD type.解释类型大小必须是1 , 2 , 4 , 8 , 16 , 32 , 64 1, 2, 4, 8, 16, 32, 641,2,4,8,16,32,64bits 之一不允许有用户自定义 ctor、dtor、copy assignment本质要求C03 POD非常原始的数据载体只有满足这些条件结构体才允许通过 rax 返回否则会通过隐藏指针返回。4. armv7-a System V小于等于 4 字节可以直接用 r0 返回A Composite Type not larger than 4 bytes is returned in r0.Example:struct { int x; };size 4 → in r0大于 4 字节使用隐藏指针A Composite Type larger than 4 bytes… is stored in memory at an address passed as an extra argument.如果结构体是 8 bytes例如{int a; int b;}必须走内存。5. x86 System V (32-bit)Some fundamental types and all aggregate types are returned in memory.解释fundamental如 int, float→ 可以在 eax 返回aggregate如 struct/class→全部用内存返回不走寄存器这点与 x86-64 截然不同6. x86 Microsoft ABI (32-bit)微软的规则比 System V 稍宽松Return values are returned in EAX, except for 8-byte structures, which are returned in EDX:EAX.1, 2, 4 bytes → eax8 bytes → edx:eaxlarger → 用隐藏指针但Structures that are not PODs will not be returned in registers.你必须是 POD 才能走寄存器。综合表格寄存器返回的最大尺寸Architecture / ABI可以用寄存器返回的 Composite 最大大小额外条件armv8-a System V≤ 16 bytestrivial for callsarmv7-a System V≤ 4 bytestrivial for callsx86-64 System V≤ 16 bytestrivial for calls INTEGER 分类x86 System V基本不能aggregate 都要用内存x86-64 Microsoft1–8 bytes或特殊更大 bit必须是 C03 PODx86 Microsoft1–8 bytes8 bytes→EDX:EAX必须是 POD最终总结要想让结构体走寄存器它必须同时满足(1) 足够小某些 ABI 是≤ 16 ≤16≤16bytes有些是≤ 4 ≤4≤4bytes有些更严格。(2) 足够 trivial特别重要Composite types are required to be trivial to be returned in registers. \text{Composite types are required to be trivial to be returned in registers.}Composite types are required to be trivial to be returned in registers.在 Itanium C ABI大多数 Unix 平台采用中“trivial for the purpose of calls” 的定义与“trivially copyable” 不同不需要 trivial copy/move assignment只需要 trivial ctor/move ctor/dtor所以structINT{intvalue;};因为无用户定义 ctor/dtor是 aggregate大小 4 字节→ 在所有实现里都被视为 trivial for calls→ 所以能放到寄存器里返回。C.20如果可以避免定义默认操作就尽量避免If you can avoid defining default operations, doReason: It’s the simplest and gives the cleanest semantics.Note: This is known as “the rule of zero.”Approved详细理解核心思想如果一个类型class/struct可以不显式定义以下五个默认操作默认构造析构拷贝构造拷贝赋值移动构造移动赋值那么就不要自己定义它们。这条规则被称为Rule of Zero \text{Rule of Zero}Rule of Zero即“零法则”让编译器生成所有默认操作。为什么要尽量不定义默认操作1.最简单、最干净的语义编译器生成的默认操作遵循语言规则逐成员拷贝逐成员移动自动生成 noexcept如果成员是 noexcept语义完全明确、行为一致且可预测相比之下手写的默认操作很容易出错例如忘记拷贝某个成员不小心做昂贵的深拷贝错误的异常规范打破资源管理语义2.避免违反类的资源所有权逻辑现代 C 的最佳实践是资源由 RAII 类型如 s t d : : u n i q u e p t r 负责用户的类型只是组合这些 RAII 成员。 \text{资源由 RAII 类型如 } std::unique_ptr \text{负责用户的类型只是组合这些 RAII 成员。}资源由RAII类型如std::uniquep​tr负责用户的类型只是组合这些RAII成员。这样类本身不需要管理资源不需要写析构也不需要写拷贝/移动逻辑。3.避免意外的行为变化一旦你自己定义了任何一个默认操作例如~Widget(){}编译器就不会自动生成移动操作这会带来性能下降例如容器 push_back 退化到 copy缺少移动导致“复制成本意外变大”避免手写默认操作可以保持类行为简单、统一。什么时候可以遵循 Rule of Zero当你的类是值类型配置对象数据容器结构体或简单组合例如structPoint{intx;inty;};这个类型完全不需要自定义:析构copy/move赋值编译器自动生成即可而且是最佳行为。示例推荐写法Rule of ZerostructFileConfig{std::string path;intflags0;};你不需要写任何默认操作。std::string负责资源管理小字符串优化、拷贝、移动等。你的类保持干净与一致的语义。反例不要这样写下面是“违反 Rule of Zero”的写法structBad{std::string s;Bad(){}// 手写默认构造没必要~Bad(){}// 手写析构会禁用移动};由于自己写了析构会导致编译器不再生成移动构造 / 移动赋值性能变差行为不一致小结总结公式Rule of Zero 表达为If RAII members manage resources, your class should define zero special members. \text{If RAII members manage resources, your class should define zero special members.}If RAII members manage resources, your class should define zero special members.形式资源由成员管理不由类管理类本身不需要定义特殊成员函数让编译器自动生成一切主题总结std::chrono::seconds 的返回值表现得像一个 trivial POD但却“不够 trivial”在不同 ABI 下会出现截然不同的生成代码。原因是s t d : : c h r o n o : : d u r a t i o n R e p , P e r i o d std::chrono::durationRep, Periodstd::chrono::durationRep,Period是否能在寄存器返回取决于R e p RepRep是否是支持寄存器返回的类型duration 是否满足ABI 关于 trivialcall-trivial类型的要求ABI 对 “Composite Type” 的大小限制如 ARM ≤16 bytes, x86 ≤8 bytes 等首先std::chrono::seconds 的本质是什么usingsecondsstd::chrono::durationint64_t;它本质上就是structduration{int64_trep;// 唯一的数据成员};所以sizeof(std::chrono::seconds) 8并且static_assert证明static_assert(std::is_same_vint64_t,std::chrono::seconds::rep);➜ 它看起来和i n t 6 4 t int64_tint64t​完全一样只是包了一层 struct。但 ABI 并不是判断“看起来像 POD”而是使用call-trivial的定义Itanium ABIGCC/Clang 用定义A type is non-trivial for the purposes of calls if it hasa non-trivial ctor/dtor/copy/moveOR all copy/move ctors are deletedstd::chrono::duration满足无自定义析构默认构造可用拷贝/移动均为 default因此std::chrono::seconds is trivial for calls \text{std::chrono::seconds is trivial for calls}std::chrono::seconds is trivial for calls这意味着在 Itanium C ABIarm64 Linux, x86-64 Linux它是一个 trivial 8-byte composite type所以只要大小不超过 ABI 的寄存器上限就可以放进寄存器返回你看到的结果Linux/GCC/Clang正是这个原因armv8-a clang 18chrono_seconds(): mov w0, #60 ret完全等同于int_seconds(): mov w0, #60 retx86-64 gcc 14mov eax, 60 ret也是完全一样。➜std::chrono::seconds 被编译器当成一个 8 字节的 trivial 类型放进整数寄存器返回。 但为什么 MSVC 表现不同MSVC 的 ABIMicrosoft x64 ABI规定To return a user-defined type in RAX,it must be 1, 2, 4, 8, 16, 32, or 64 bitsand must be a C03 POD type.也就是说 MSVC 使用的是更古老更严格的 POD 定义MSVC: trivial 必须满足 C03 POD \text{MSVC: trivial 必须满足 C03 POD}MSVC: trivial必须满足C03 POD而std::chrono::duration不是“真正的 C03 POD”因为它有模板类型别名constexpr 成员函数非 aggregate直到 C17 有 aggregate 改进因此 MSVC 的 ABI 决定不允许在寄存器返回这个类型于是你看到 MSVC 总是使用隐藏指针mov QWORD PTR [rcx], 60 mov rax, rcx retarmv7 / x86 32-bit结构体不能寄存器返回armv7 ABI寄存器 r0 只有 32-bit8-byte composite 类型必须用隐藏指针返回x86 32-bit ABI结构体除 fundamental type 外全部走内存返回所以你看到mov eax, DWORD PTR [esp4] mov DWORD PTR [eax], 60 mov DWORD PTR [eax4], 0 ret 4这正是经典的 “hidden return pointer”。关键信息总结表架构ABIstd::chrono::seconds 返回方式原因armv8-a (Linux)System V寄存器返回≤16 bytes composite trivialx86-64 (Linux)System V寄存器返回≤16 bytes INTEGER classMSVC x64Microsoft ABI隐藏指针返回必须是 C03 PODduration 不是armv7-aSystem V隐藏指针返回4 bytes compositex86 (SysV)32-bit ABI隐藏指针返回struct 全部 memory returnx86 MSVC 32Microsoft ABI隐藏指针返回struct 全部 memory return最终结论$$\text{Composite types must be trivial under the ABI rules to return in registers.}$$而 “trivial” 的定义因 ABI 而异。为什么 std::chrono::seconds 在 Linux 上表现良好因为 GCC/Clang 的 ABI 使用了现代 C 的 trivial 语义std::chrono::duration ⇒ trivial for calls \text{std::chrono::duration} \Rightarrow \text{trivial for calls}std::chrono::duration⇒trivial for calls结构大小s i z e o f ( d u r a t i o n i n t 6 4 t ) 8 ≤ 16 sizeof(durationint64_t) 8 \le 16sizeof(durationint64t​)8≤16因此不需要隐藏指针不需要内存写入直接使用$x0$ /e a x eaxeax 返回https://godbolt.org/z/E5e1nGY94详细理解Can we do something about it?我们是否可以对这些标准库行为做点什么●std::chrono在 Windows 上要做到“最优效率”必须放弃封装解释Windows 的底层时间 API 有一些非常高效的系统调用例如 QueryPerformanceCounter 等。但std::chrono作为一个标准库组件必须把具体平台 API封装起来以提供统一的跨平台类型。这种封装的代价是无法完全利用 Windows 特定的、效率最高的表示方式或结构布局。换句话说为了跨平台抽象std::chrono必须牺牲部分性能。●std::chrono不能为了 ARMv7-A 优化就让自己的内部类型比int64_t更小含义ARMv7-A 是 32-bit 架构。如果为了让 ARMv7-A 上更快它可能希望使用一个32 3232位的表示类型来做时间计数。但是std::chrono::duration必须保证跨平台一致性因此标准强制要求使用至少64 6464位的整数通常是i n t 6 4 t int64_tint64t​。所以不能因为某个平台的架构较小就改成i n t 3 2 t int32_tint32t​—— 这会导致 ABI、可移植性等严重问题。std::pair 与 std::tuple 的问题●std::pair的复制与移动构造函数在标准中是 defaulted意思是标准强制要求std::pair的 copy/move ctor 是 default。不允许实现者按情况选择 trivial 或 non-trivial。结果就是实现者无法做一些“结构体类型的优化”。● 从 C17 起若成员是 trivially destructible则std::pair也才变成 trivially destructible解释std::pairT, U的析构函数是否 trivial本来应该取决于T TT和U UU。但在 C17 之前标准没有规定这一点。C17 才修正了这个规则。这属于 ABI 破坏因为类型特性变了但实际影响似乎很小。●std::pair的 copy/move 赋值运算符只有在 MSVC 上是 trivial即$operator(const\,std::pair\)$和$operator(std::pair)$在 MSVC 上是 trivial assignment operators但在 GCC、Clang 上不是影响不是 trivial → 意味着不能用std::memcpy、std::bit_cast安全处理它。例如$std::bit\_caststd::pairint,int(bytes)$是不合法的在 GCC/Clang。$memcpy(p2, p1, sizeof(p1))$也不合法。●std::tuple永远不是 trivially move constructible原因std::tuple的实现太复杂包含递归继承、模板展开、多层基类。即使所有元素类型都是 trivially move constructiblestd::tuple的 move constructor 依然不是 trivial。这对性能尤其是在 SIMD / POD 优化、序列化、bit_cast有影响。Can we do something about it?作者的结论不要使用std::pair尤其不要使用std::tuple理由不够 trivially constructible / assignable / destructible。无法进行低层优化例如$memcpy$$std::bit\_cast$POD 类型优化结构复杂类型特性弱编译器难以优化。使用 named struct自定义命名结构体优势可读性更好能够命名字段例如structPoint{intx;inty;};比std::pairint,int好读太多。性能更好编译器能自动使其变成 trivial 类型POD。赋值、构造、析构都很可能成为 trivial。可以进行$memcpy$、$std::bit\_cast$等底层优化。ABI 更清晰没有隐藏的 template machinery。总结一句使用自定义 struct 是现今 C 中在可读性与性能间最好的折中方式。https://godbolt.org/z/r7McGEb8o详细理解Can we do something aboutstd::unique_ptr?这里在探讨我们是否能够让std::unique_ptr的返回更快、更高效答案是其实不需要做任何特别的优化因为编译器已经帮我们做了最优的事情。示例代码解析#includememorynamespacedetail{int*smart_ptr_impl(){returnnullptr;}}// namespace detail[[always_inline]]std::unique_ptrintsmart_ptr(){returnstd::unique_ptrint{detail::smart_ptr_impl()};}解释smart_ptr_impl()返回一个裸指针这里是nullptr。smart_ptr()创建一个std::unique_ptrint并返回它。重点返回值是 prvalue会触发 C17 的 Guaranteed RVO。Return Value Optimization (RVO) 与 Copy Elision自 C17 起的规则引用内容的核心思想自 C17 起prvalue纯右值在表达式中并不会立即创建对象当它最终需要存储到某个地方时它会直接在最终位置构造。例如returnstd::unique_ptrint{p};不会创建临时对象然后再 move而是编译器让调用者准备好返回值的存储空间。被调用函数直接在这块空间“现场构造”返回值。没有中间对象没有移动没有复制。为什么这样高效需要 ABI 的支持Itanium C ABI 规定非 trivial 返回类型需要隐式“输出参数”引用内容简化说明Itanium ABI 3.1.3.1 Non-trivial Return Values如果返回类型是一个 “对调用而言非平凡non-trivial” 的类编译器会让调用者(caller)预先分配存放返回值的空间。然后将这个空间的地址作为一个隐藏的第一个参数传给被调用者(callee)。callee 在这个地址上直接构造返回对象。用简单的形式表达 ABI 的行为C 代码std::unique_ptrintsmart_ptr();ABI 视角下的真实函数签名被等价地转换为voidsmart_ptr(std::unique_ptrint*return_slot);也就是说$return\_slot$是由caller准备的。smart_ptr 在$return\_slot$指向的内存上直接构造unique_ptrint。这其实就是一种由编译器自动生成的、更安全的 output parameter并且只有当类型必须用这种方式构造时才会采用。为什么是“non-trivial”才需要输出参数定义简化如果一个类型的返回无法通过寄存器传递或构造/析构非 trivial则为 non-trivial。std::unique_ptrT不是 trivial因为它有自定义析构行为deleter。因此ABI 会自动采用“输出参数”策略。这就是为什么返回std::unique_ptr非常高效它不会产生额外的移动、构造或复制。“It’s an output parameter done right by the compiler”这句话的核心意思编译器自动为你把“输出参数”做到最好没有语法负担你直接用return保留语义安全不像手写 output parameter 容易出错最优性能避免复制/移动只有当类型需要时才启用也就是说你不用写这种低级、脆弱的代码voidfoo(std::unique_ptrintout);编译器自动帮你实现了更正确、更高效的版本。总结对std::unique_ptr你能做的优化已经由编译器帮你做了返回unique_ptr是零成本的zero-cost。C17 的 prvalue 规则保证Guaranteed RVO。ABI 通过隐式输出参数让返回值直接在最终位置构造。无复制、无移动、无临时对象。结论放心返回std::unique_ptr它本身已经是最优方式。RVO: inserting a function result into a container将函数返回值插入容器时的 RVO 行为你给出的代码#includeoptionalstructlarge{large();large(large);largeoperator(large);large(largeconst);largeoperator(largeconst);~large();};largemake_large();std::optionallargeoptional_large(){returnstd::optionallarge{make_large()};}核心问题为什么std::optionallarge{make_large()}里没有触发 RVO而是出现了额外的 move 和 destructor为什么无法做到完美的 RVO因为这不是简单的l a r g e → l a r g e large \to largelarge→large而是l a r g e → optionallarge large \to \text{optionallarge}large→optionallarge这意味着$make\_large()返回一个$large$对象但它无法直接构造进optionallarge的内部存储位置因为optionallarge本身必须先构造空状态然后它内部的large按需要构造emplace也就是说构造std::optionallarge的步骤与构造large的步骤并不是同一个对象的构造链条因此不能将 make_large() 的返回值直接构造到 optional 的内部 buffer所以无法使用 Guaranteed Copy Elision (C17 RVO)汇编结果揭示了构造流程编译器的策略在三种平台上完全一致标准流程你给出的所有汇编都对应这个流程在栈上构造一个临时的l a r g e largelarge即 make_large() 的返回值放在一个临时缓冲区调用large(large)将此临时对象搬到optional的内部 buffer 里move-construct into optional \text{move-construct into optional}move-construct into optional将 optional 的 engaged 标志设为 1o p t i o n a l . h a s v a l u e 1 optional._has_value 1optional.h​asv​alue1调用~large()销毁栈上的临时对象这就是为什么你看到large(large)发生了一次large::~large()也发生了一次在所有平台上完全一致。为什么 optional 不能更快要实现 RVOm a k e l a r g e ( ) → o p t i o n a l l a r g e make_large() \to optionallargemakel​arge()→optionallarge必须满足“目标对象的最终存储位置必须已知”可惜optionallarge的内部 buffer 并不是直接暴露给构造函数的构造 optional 时它必须先构造 empty optional再构造内部 T所以 RVO 会失败。有趣的是构造optionalT时的 move 是“必然的”并不是因为编译器不够聪明而是因为语义就要求optional 的内部成员large是延迟构造 (lazy construction)optional 必须先变成“有值状态”然后才能构造 T因此插入过程固有两步状态切换disengaged → engaged \text{disengaged} \to \text{engaged}disengaged→engagedT对象构造只能通过 move/copy/emplace三大平台的汇编差异解读简化armv8-a clang临时 large 位于$x29 #31栈偏移调用 make_large() → 存放在此再 move 到 optional再 destroy temporaryx86-64 gcc临时 buffer 在rbp rsp 15类似 move-construct → destroy temporaryMSVC临时 buffer$T1[rsp]调用 make_large → 再移动到 optional → 再销毁三家模式完全一样临时 → move 到 optional → 销毁临时结论将函数返回值塞入std::optionalT时不会触发 RVO除非 T 是 trivial 且 optional 优化得更激进但目前不是因此必然发生1 次 movelarge(large)1 次析构large::~large()如果你想规避这一点方法只有一种让 optional 在容器内部直接构造元素使用std::optionallargeoptional_large(){returnstd::optionallarge{std::in_place,/* constructor args */};}或者std::optionallargeo;o.emplace(/*args*/);returno;但如果你必须从make_large()返回这种 move 无法避免。1) 核心问题为什么std::optionalT{make_large()}不触发 RVOstd::optional提供了一个模板构造函数templateclassUTconstexproptional(Uvalue);当你写returnstd::optionallarge{make_large()};发生的类型推导与求值顺序摘要make_large()是一个prvalue返回large的表达式。模板构造U value对U做类型推导U largeU为large一个转发引用形态。于是make_large()的结果被materialize物化成为value这个函数参数在 optional 构造函数的栈上然后构造函数将value转发到 optional 的内部存储通常是std::move(value)导致一次额外的move和随后对栈上临时的析构。换句话说这里并不是 “prvalue 直接构造到 final storage”那是 C17 的 prvalue 语义能做到的场景因为模板构造函数把 prvalue 接收为一个参数迫使物化发生在构造函数的参数处从而阻断了跨类型的直接就地构造RVO/NRVO机会。用公式表述问题make_large() (prvalue) → optional constructor U materialized as parameter → forward internal-storage \text{make\_large() (prvalue)} \xrightarrow{\text{optional constructor } U\\} \text{materialized as parameter} \xrightarrow{\text{forward}} \text{internal-storage}make_large() (prvalue)optional constructorU​materialized as parameterforward​internal-storage2) 影响范围这是一个普遍问题影响到所有接受U或T通用引用并把参数转发进容器包装类型构造器的 API例如std::optionalstd::variantstd::vector::push_back(T)不过这不是模板构造push_back 有 overloadsstd::map::insert(value_type)/emplace以及库里很多“完美转发”的构造/插入函数所以这是一个广泛存在的性能陷阱 —— 当你把一个 prvalue 直接包进容器的转发构造器时prvalue 很可能会先被物化成参数再 move 进存储从而产生不必要的 move / 析构。3) 有没有语言/编译器级别的解决方案super-elider 等你列出的文章super-elider/ “rvalues redefined”思路是让编译器可以做跨类型、跨函数模板参数的消除——即允许把 prvalue 直接构造到目标存储即便它被传给了一个接收U的模板构造函数。这相当于扩展 C17 的 prvalue 消除能力现在只有当最终目标类型与表达式类型“匹配”或在某些直接返回/初始化场景时才有 guaranteed elision。现实情况这些想法是可行的优化理论上能做但需要语言规范或 ABI 的支持或非常大胆的编译器优化可能会影响 observable behavior例如异常传播、析构次数、调试信息等。因为影响 ABI/语义边界标准库和主流编译器不能随便改。所以短期内你不能指望标准库或编译器在所有平台安全地自动为你做这种跨类型 elision。结论超消除是“有解”的研究方向但不是短期内人人可用的便捷做法除非你在自己的项目里用特定编译器/开关并接受潜在风险。4) 可行的实用解决方案立即可用下面按从可读、可维护到更 hacky 排序给出推荐方案与代码。A — 优选使用 in-place 构造 /emplace/std::in_place如果你能把构造参数直接传给T而不是传一个T的 prvalue在可选容器内部就能直接原位构造从而避免 move// 如果 make_large() 只是封装了一些构造参数优先用returnstd::optionallarge{std::in_place,arg1,arg2,...};// 或者std::optionallargeo;o.emplace(arg1,arg2,...);returno;缺点当make_large()是一个复杂函数只能调用它获得T你不能直接用in_place除非你能把make_large的实现拆成构造参数。B — 使用lazy/ deferred-invocation wrapper实践中很有效把要延迟计算的make_large()用一个“小可调用对象”包起来并让容器构造器触发调用在目标存储上构造。你在问题里已经给出lazy样式templateclassFunctionstructlazy{Function function;operatorstd::invoke_result_tFunction(){// 转换成目标类型returnfunction();}};templateclassFunctionlazy(Function)-lazystd::decay_tFunction;用法returnstd::optionallarge{lazy{make_large}};效果为什么有效optional的模板构造会把lazy{make_large}作为U的实例不是largeU的类型不是large而lazy有一个operator large()这会在optional构造中直接调用conversion operator编译器将其结果直接就地构造到 optional 的内部在许多实现与 ABI 下这样可以实现无移动的就地构造。你给出的汇编证明lazy方案确实在主流编译器上消除了移动见你贴的结果。优点不改变库简单小巧移植性高直接在调用处控制是否延迟构造缺点 / 注意事项lazy必须被设计为返回T或能转换成Toperator T()要是非模板且返回 prvalue才能让编译器直接就地构造。可能与模板重载或完美转发构造发生匹配优先级问题optional(U)会把lazy当作U而不是当作要转发的T这是我们想要的但在复杂的重载环境需小心。lazy{make_large}必须捕获一个可调用实体函数指针、lambda注意闭包的生命周期。lazy的operator T()可能会被不期望地隐式调用影响重载解析可以考虑把 conversion operator 标记explicitC11/20的影响需测试。对调试/栈跟踪可能有少量影响多了一层可调用。总的来说这是在现有标准/编译器上最实用的技巧之一。你提供的汇编示例显示它在 arm64/x86-64/MSVC 上都能达到消除 move 的效果视具体实现而定。C — 如果你控制 make_large 的实现改为直接接受输出位置placement最手工但最确定的方式是// change make_large to construct in caller-provided storage:voidmake_large_inplace(large*out){new(out)large(/*...*/);}std::optionallargeoptional_large(){std::optionallargeo;make_large_inplace(std::addressof(*o.emplace()));returno;}或直接提供make_large(args...)的参数而在 optional 里调用emplace(args...)。优点零拷贝、最明确。缺点要改函数接口侵入性强、难以维护。D — 用std::optionallarge o make_large();并不解决注意std::optionallarge o make_large();通常等价于optional{make_large()}并不会规避物化问题仍可能被参数化构造吸收因此并非通用解决办法。5) 实战代码示例lazy 的 推荐实现带细节// simple lazy wrapper (safer with decay)templateclassFstructlazy{F f;lazy(Ff_):f(std::forwardF(f_)){}// Make conversion explicit to avoid accidental conversions if desired:operatorstd::invoke_result_tF(){returnf();}};// deduction guidetemplateclassFlazy(F)-lazystd::decay_tF;// 使用std::optionallargelazy_optional_large(){returnstd::optionallarge{lazy{[](){returnmake_large();}}};// 或 return std::optionallarge{ lazy{ make_large } }; // 函数指针或引用捕获}注意事项为了避免隐式转换带来的重载二义性考虑把operator T()标记为explicit但这可能会阻止某些自动场景需要测试。如果make_large是函数名而非 lambdalazy{make_large}会捕获函数指针或引用要确保生命周期无问题。6) 其它现实考量与建议Preferemplace/in_placewhen possible。如果你能把T的构造参数搬移出来用in_place最简洁且高效。如果库函数返回T如 make_large且你不能改接口lazy是最实用的 non-invasive 技巧。谨慎使用 perfect-forwarding constructors in public APIs。这类模板构造器templateU optional(U)) 很便利但会阻止某些跨类型的就地构造优化。库设计者在提供这种构造时需要权衡这也是为什么标准库如此设计的原因——通用性优先。不要在性能关键路径上依赖编译器意外的消除。明确使用emplace或 lazy 可以让行为可预测。监测 ABI 与平台差异不同编译器/ABI 的优化细节可能不同务必在目标平台上做实际测量。7) 总结简短问题原因optional(U)把 prvalue 作为参数接收导致 prvalue 被物化为构造函数参数从而阻断跨类型的就地构造RVO引入一次move和一次临时析构。解决方向语言/编译器层面的「超消除」是研究方向但短期不可用。实用解决法可立即采用首选in_place/emplace如果你有构造参数。若不能使用lazy/deferred-call wrapper非侵入式、跨编译器有效。最激进改写被调用函数让它支持就地构造placement API。https://godbolt.org/z/PYq6KTPKh一、F.20对于“输出out”值优先使用返回值而不是输出参数原文要点If you can avoid output parameters, prefer return values. A return value is self-documenting; aTcould be in-out or out-only and therefore is liable to be misused.详细理解可读性 / 语义清晰T f()明确表明函数“生成并返回一个T”。而void f(T)语义模糊是读写in-out还是写出out-only调用者难以从函数签名立即判断。异常安全与生命周期返回值结合 RVO/移动语义通常能保证异常安全并且让对象在正确的位置构造输出参数需要调用者先准备对象若被调用函数抛异常需要考虑已构造对象的析构/回滚。性能自 C11 起移动语义很便宜自 C17 起Guaranteed copy elisionprvalue 直接构造更进一步地降低了返回大对象的成本。因此返回值不再像历史上那样昂贵。例外场景当返回的对象是运行时大小的range或者需要先分配缓存再写入例如std::ranges::transform写入已经分配的缓冲区时返回值不可行或不高效。这时输出参数是合理的你解耦了内存分配由调用者负责和数据处理由函数负责提高可重用性与控制权。建议 API 形式可读且高效使用带语义的 wrapper 来标注 out / inout例如你引用的ac::out/ac::inouttransform(src,ac::out{dst},f);// 明确 dst 是 outsort(ac::inout{range});// 明确 inout 语义autoysort(x);// 返回值版本可读且简洁这样既保留就地写入能力又提升了可读性与自文档性。二、F.16对于“in”参数按值传递廉价类型其他按const原文要点Pass cheaply-copied types by value; otherwise pass byconst. Both signal non-mutation and allow initialization by rvalues.“廉价拷贝”如何判断经验阈值两到三个机器字machine words通常是合适的界限。机器字在 64-bit 系统上通常为 8 bytes。更精确的考虑因素ABI 上是否能把该类型放寄存器传递返回例如 x86-64 SysV 对 ≤16 bytes 的简单聚合有寄存器路径ARM64 对 ≤16 bytes 也有寄存器路径但还要看 field classification。如果能寄存器传递按值通常低成本。用一个简单的“成本模型”表达示意cost_copy ( T ) ≈ { O ( 1 ) if sizeof ( T ) ≤ k × word_size O ( n ) otherwise \text{cost\_copy}(T) \approx \begin{cases} O(1) \text{if } \text{sizeof}(T) \le k \times \text{word\_size} \\ O(n) \text{otherwise} \end{cases}cost_copy(T)≈{O(1)O(n)​ifsizeof(T)≤k×word_sizeotherwise​其中k kk常取 1–2 或 2–3视平台而定。三、示例对比int按值 vsint const按引用你给的两段汇编清楚地展示了差别按值时参数已经位于寄存器直接使用按引用时函数收到的是指针/地址必须做一次内存加载间接访问。示例 Cboolvalue_is_zero(intx){returnx0;}boolref_is_zero(intconstx){returnx0;}armv8-a clang简化value_is_zerocmp w0, #0 cset w0, eq ret说明x已在寄存器w0中直接比较即可。ref_is_zeroldr w8, [x0] cmp w8, #0 cset w0, eq ret说明x0是引用的地址先从内存[*x0]读取到寄存器再比较 → 额外内存访问。x86-64 gcc简化value_is_zerotest edi, edi寄存器上的快速操作ref_is_zeromov eax, DWORD PTR [rdi]从内存读取再测试MSVC 类似寄存器 vs 内存结论对像int这类小型标量按值比按const更高效因为能避免一次内存间接加载。四、最后一句话的含义把常量 1 放寄存器 vs 放栈并传地址你最后的总结Here, we just put constant 1 into a register and call the function.Below, we put constant 1 on the stack and pass its address, and after the function call we restore the stack.这段话描述的是两种传参策略的真实差别按值传参更优常量1直接放入返回/参数寄存器如 ARM 的w0或 x86 的edi函数体直接使用寄存器中的值。没有内存访问调用开销小。caller: m o v r e g , 1 → call \text{caller: } \mathtt{mov\ reg, 1} \quad\to\quad \text{call}caller:movreg,1→call按引用传参更慢要把常量写到栈上或者放到某个内存位置然后把这个内存地址的指针传入函数被调用函数需要解参从地址读出值。因此产生了额外的栈操作 内存读写以及在调用前后需要恢复栈指针等开销。caller: p u s h 1 ; l e a r d i , [ r s p ] ; call \text{caller: } \mathtt{push\ 1}\ ;\ \mathtt{lea\ rdi,\ [rsp]}\ ;\ \text{call}caller:push1;leardi,[rsp];call因此当参数是常量或临时值且类型小且廉价复制时按值传递不仅语义更清晰而且在机器码级别更高效更少内存访问更少指令。五、实用建议工程层面默认策略对标量与小 POD整型、指针、少量字按值对大对象或复杂资源按const。这既符合 F.16也在汇编层面高效。输出语义优先用返回值F.20只在需要预先分配缓冲区或避免动态分配时才用输出参数若使用输出参数用ac::out/ac::inout此类带语义包装的类型让签名自解释。测量导向在性能关键路径上针对目标平台armv7/arm64/x86_64/MSVC做实际测量ABI 差异会影响阈值比如是否能寄存器传递 16 bytes。写清 API若必须用引用作为输出参数在接口或名称上明确如bool try_read(Foo out)或void compute_out(Foo out)或使用类型包装以防误用。如果你愿意我可以帮你根据某个目标平台详细给出“按值 vs 按const”的字节/寄存器阈值建议将你项目中若干函数签名自动检查并给出“应该改为按值 / 改为按const/ 改为返回值”的具体建议https://godbolt.org/z/fzshqhd851) 示例回顾 —— 有额外函数调用时的区别代码voidsome_extra_function();boolvalue_extra_function(intx){intconstcopyx;some_extra_function();returncopyx;}boolref_extra_function(intconstx){intconstcopyx;some_extra_function();returncopyx;}两处的关键差别是参数传递方式按值int x与按引用int const x。语义差异最关键的点按值调用时会把实际实参的值拷贝一份到被调用函数的局部参数x中。函数体内对x的观察仅限于这个局部副本与调用者处的原对象无别。即使some_extra_function()在其它地方修改了调用者那块内存比如通过全局指针、或别的线程本函数的x不会被改变。按引用x是调用者原对象的别名别名 地址。因此如果some_extra_function()能够直接或间接访问并修改那个地址上的数据那么x可能会被改变此时copy在调用开始时的快照与调用结束时的x可能不相等。结论语义层面value_extra_function返回truecopy x的概率更高因为x是局部副本不受some_extra_function()修改调用者内存的影响。ref_extra_function的返回依赖于some_extra_function()是否修改了被引用的对象 —— 结果可能为false。汇编 / ABI 角度汇编说明了什么arm64 / x86-64 / MSVC 汇编显示相同事实按值版本value_extra_function编译器把参数放在寄存器符合 ABI函数体仅在需要时保存极少量栈或寄存器调用some_extra_function后直接返回常量1示例中copy x编译器证明为常真于是直接返回 true。关键局部x在寄存器/栈上是独立的不用再次从调用者内存读取。按引用版本ref_extra_function编译器把引用实现为地址传入一个指针/地址函数需要在进入时把*x载入寄存器以做copy在调用some_extra_function()之后再次从内存[*addr]读取当前值做比较这在汇编里体现为两次加载/一次保存地址寄存器。换句话说引用版本涉及内存间接访问并且需要保存传入的地址以便在调用后重新读取这既增加代码复杂度也增加了内存访问/寄存器压力。更直白的运行时剧情一个场景例子假设调用者这么写intglobal_x1;value_extra_function(global_x);// 参数按值ref_extra_function(global_x);// 参数按引用如果some_extra_function()做了global_x 2;value_extra_function局部x 1副本copy 1返回copy x→true.ref_extra_functionx是global_x的别名copy 1调用后x 2返回1 2→false.这正反映了按值隔离副本与按引用暴露原对象的本质区别。2) “完美转发并非完美”——两层含义语义与性能Perfect forwarding在模板里把参数转发给另一个函数同时保留它的 value/category左值/右值信息。常见用法是std::forwardArgs(args)...。目的是尽量避免不必要的拷贝而使用移动。你给的例子make_uniquetemplateclassT,class...Argsstd::unique_ptrTmake_unique(Args...args){returnstd::unique_ptrT(newT(std::forwardArgs(args)...));}语义上“不是完美”的地方参数本身是引用forwarding reference模板参数Args...在模板参数推导时形成转发引用forwarding/reference collapsing rules。无论传入的是 lvalue 还是 rvalue都把参数作为引用接收。在函数体内这些参数是命名的引用即左值表达式因此如果在没有std::forward的情况下直接传给下游函数会被当作左值处理引发拷贝而非移动。这就是为什么我们需要std::forward来恢复“右值性”。转发引用是引用 → 可能阻止某些 ABI/传参优化当模板函数接受Args...时被调用者内部参数是引用类型即函数接收地址这在某些场景下会导致调用者必须把临时/常量物化到内存并传入地址即把值放到栈上然后把地址传入从而不能利用寄存器传递。这会增加内存操作和阻断某些寄存器级别的优化例如之前int的按值传递可直接放寄存器而按引用则需要放到内存并传地址。因此在某些 ABI / 调用链中转发引用会导致额外的物化/内存操作间接“破坏”了本来可能的零拷贝路径例如在希望直接在目标位置构造的时候阻止了就地构造。对 RVO 的影响需要谨慎区分两种情形RVO或 C17 的 guaranteed elision通常指的是把某个 prvalue 直接构造在返回者的目标存储处避免中间临时。当你把参数以转发引用形式接收然后将该参数“转发”到另一个位置时或把 prvalue 传入 template constructorU编译器可能会先物化 prvalue 为构造函数的参数从而阻止跨类型就地构造之前讨论过optional{make_large()}的情形。换言之转发引用作为函数参数会把某些 prvalue 物化为参数 (materialization)这会阻断某些场合下的消除/就地构造优化RVO/NRVO。请注意make_unique的new T(std::forwardArgs(args)...)本身并不是“返回对象的 RVO”问题它是直接在堆上构造对象并返回指针但是如果你把Args的推导或转发与其他返回语义结合例如把一个 prvalue 转发到optional的构造上面那类物化问题就会出现。因此“完美转发破坏 RVO”是对一类场景的总结而不是对所有情况都成立的绝对结论。3) 几个具体的危险与工程建议危险 1引用参数允许调用路径修改被引用对象语义安全若你的函数依赖于“参数在整个调用期间不变”就不要用引用尤其是非const引用。即使是const也可能被some_extra_function()修改如果该函数有方式访问原对象地址。规则当你需要参数不可变并且是小类型按值传递更安全又更快。危险 2完美转发可能带来隐式物化与额外内存操作性能对于小类型如int,struct { int a,b; }等优先考虑按值传递不要盲目用模板Args...去捕获一切。如果确实需要通用转发例如 perfect_factory了解代价对传入的 prvalue 很可能被物化对传入的 lvalue转发也可能保持左值语义。建议的替代策略工程实践对小、廉价复制的类型按值传递F.16。这既高效又安全。对需要就地构造的大对象提供in_place/emplace/ placement API或显式提供make_xxx_inplace而不是依赖万能转发构造器。设计库接口时慎用templateU optional(U)这类完美转发构造如果不可避免请为常见场景提供in_placeoverload 或emplace替代或提供lazy/ deferred wrapper前面讨论过。如果暴露 factory API如make_unique只是为了转发构造参数那通常 OK但要意识到make_unique把对象T直接在堆上构造new T(...)并不会产生返回值物化问题本身但参数的传递方式仍然可能带来不必要的拷贝/内存。若目标是优化寄存器传递/消除内存设计 API 时可考虑“按值接收并std::moveinto place”或提供两套重载按值版 按const版 / rvalue 引用版以便编译器更容易做出高效代码。使用std::forward正确但仍需测量完美转发解决了语义保留 value category但它不会魔法式消除所有中间物化实际可见代码效率仍需在目标平台测量验证。4) 具体示例把原则应用到你写的两个函数上如果你希望保证copy x在函数体内不受some_extra_function影响写成按值更明确boolvalue_extra_function(intx){// safe: x is local copyintconstcopyx;some_extra_function();// cannot change local xreturncopyx;// always true here if x unchanged}如果你需要能观察调用者对象是否被修改比如想检测并响应外部并发/side-effect写成按引用boolref_extra_function(intconstx){intconstcopyx;some_extra_function();// might change callers objectreturncopyx;}两者都合理取决于你想要的语义。API 设计上把语义写清楚注释或命名非常重要。5) 小结要点回顾按值 vs 按引用按值为调用体提供隔离副本安全、常常更快按引用允许函数观察/响应对原对象的修改必要时使用但有语义风险与性能成本。完美转发不是万能钥匙它保留值类别但参数在函数内仍是引用会引入引用语义的成本可能阻止寄存器传递、引发物化、破坏某些消除优化。工程建议对小类型按值、对大类型按const或提供in_place/emplace并在库 API 处用明确的inout/outwrapper 避免责义模糊。对性能关键路径必须在目标平台上测量。https://godbolt.org/z/4r946xh8T一、问题回顾核心概念简短Itanium ABI 与大多数 Unix 平台的 GCC/Clang 实现相关如果复合类型composite大小 ≤ 16 bytes 且满足“call-trivial”分类规则编译器可能把它用寄存器传递若16 bytes则调用者必须在内存中拷贝并传入指向这块内存的指针即“pass by memory”。x86-64 System V对结构体按 8-byte 分块eightbyte分类如果超过两个 eightbyte或某些 eightbyte 类型为 SSE 等则改为内存传递。总体上可寄存器传递的复合类型通常 ≤ 16 bytes但细节由 field-class 决定。x86-64 Microsoft ABI更严格只有大小为1 , 2 , 4 , 8 , 16 , 32 , 64 1,2,4,8,16,32,641,2,4,8,16,32,64bits 且满足 C03 POD 条件的类型才可寄存器否则按引用/内存。armv7-a / x86 (32-bit)等老 ABI 更苛刻armv7 允许 ≤4 bytes 寄存器x86 SysV 多数复合类型在栈上传。用公式表示常见阈值示意arm64 (System V) : 可寄存器传递 if size ≤ 16 bytes x86-64 SysV : 可寄存器传递 if #eightbytes ≤ 2 and field-class ok MSVC x64 : must be C03 POD size fits single reg pattern \begin{aligned} \text{arm64 (System V)}:\quad \text{可寄存器传递 if size} \le 16\ \text{bytes}\\ \text{x86-64 SysV}:\quad \text{可寄存器传递 if \#eightbytes} \le 2 \text{ and field-class ok}\\ \text{MSVC x64}:\quad \text{must be C03 POD \ size fits single reg pattern} \end{aligned}arm64 (System V):x86-64 SysV:MSVC x64:​可寄存器传递if size≤16bytes可寄存器传递if #eightbytes≤2and field-class okmust be C03 POD size fits single reg pattern​二、为什么std::spanT可以安全按值传递std::spanT的典型布局多数实现是structspan{T*ptr;// pointersize_t len;// length};→两字段总共 2 × pointer_size在 64-bit 即 16 bytes。在常见 ABIarm64 / x86-64 SysV / MSVC x64中2 * 8 16bytes ≤ arm64 的 16 字节阈值且通常能按寄存器传递两个寄存器或一个结构分类为两个 eightbytes。x86-64 SysV两 个 eightbyte整型类也可寄存器传递%rdi/%rsi 或后续 regs所以开销几乎与(ptr, size)两个独立参数相同。你的实际编译示例也证明了raw_back与span_back生成几乎同样的汇编span 只是先从 struct 的寄存器/内存加载 ptr/len 再做索引因此span按值传递几乎无额外开销。结论std::span两字在 64-bit 平台上应该优先按值传递—— 它语义明确、可读性好、并且 ABI 层面高效。三、为什么mdspan/ 三字段结构可能会被传成“按内存”你给的mdspan2三字段ptr, width, height大小是3 * 8 24bytes在 64-bit。对比 ABIarm64 System V24 bytes 16 bytes →caller 会将该结构拷贝到内存并传入指向它的指针即按内存传递。因此函数体内需要一次额外的内存拷贝由 caller 完成或额外的内存间接访问略增开销。x86-64 SysV超过两个 eightbytes3 eightbytes → 通常按内存传递或至少会用栈。MSVC x64size(24) 不符合单寄存器规则 → 也会改为按内存/引用。armv7 / x86 (32bit)更早就会使用内存/栈传递。你在汇编中看到的差异正是这点mdspan_back2在某些配置下需要先把字段从内存加载出来mov rdx, [rcx16]等或调用者在栈中准备数据并传入指针。关键点总结3 个 8 字节字段24 bytes ⇒ 在多数 ABI 上会触发“pass-by-memory” \text{3 个 8 字节字段24 bytes} \Rightarrow \text{在多数 ABI 上会触发“pass-by-memory”}3个8字节字段24 bytes⇒在多数ABI上会触发“pass-by-memory”四、实际性能含义工程视角两字段的span按值传递 → 寄存器/直接访问 → 低指令数、无额外内存拷贝和ptr,size作为两个参数几乎等价。三字段的mdspan可能在 caller 端被拷贝到一块临时内存并传入其地址callee 会对该内存进行一次或多次加载。这会带来额外的一次内存写caller 把结构写到临时callee 需要再从这块内存读取字段内存读或在某实现中先将其转寄存器多一步对热路径来说这些都会有可测量的开销尤其在 tight loops 或频繁调用时五、设计与 API 建议实务清单优先传值spanT类型两字段推荐签名void f(std::spanT const s)。可读、跨平台高效。在 64-bit 平台std::span通常恰好是寄存器友好16 bytes。对mdspan或三字段/更多字段的轻量矩阵/视图类型要小心如果类型通常为 3 字段24 bytes不要盲目按值传考虑改为constint mdspan_back(const mdspan2 s)以避免 caller-side memory-copy。或者把布局改成两字段例如ptrstride把width,height合并成一个size或把维度放到外部结构以保证 ≤16 bytes。若希望兼顾 API 可读性与性能采用下面策略之一对外接口用span/mdspan语义清晰但在文档中注明mdspan可能因 ABI 被按内存传递或者提供两个重载mdspan按值用于便利场景少调用频率以及const mdspan版本用于高频路径。编译器会根据调用上下文选择或者提供in_place/emplace/ direct accessors以便在内层循环避免多余的调用开销。确保类型是 trivial / standard-layout能被编译器轻松分类复合类型若满足“call-trivial”条件无用户自定义 dtor/ctor 等编译器更容易将其归类为可寄存器传递的类型。避免不必要的非平凡成员。测量而非盲信在目标平台尤其是需要支持 armv7、arm64 与 Windows/MSVC上用微基准验证。ABI 不同会带来可测差异。六、实用快速判断表参考类型大小 (64-bit)常见 ABI 行为建议≤ 16 bytes2 pointers通常寄存器传递arm64 / x86-64 SysV / MSVC按值传span17–24 bytes3 pointers多数 ABI 改为内存传递考虑改为const或重构为两字段 24 bytes一定内存/栈传递用const或专门的 placement API七、举例结论针对你给出的代码你给的raw_back与span_back在主流 64-bit 平台生成相同或几乎相同的汇编说明std::span很适合按值传。mdspan_back23 字段在 arm64 / x86-64 示例中表现出“先从结构读取字段再计算偏移”的额外步骤说明mdspan3 字段在一些 ABI 上要额外加载/拷贝。若关注高频调用性能把mdspan改为const mdspan在许多实现上能避免 caller-side 拷贝或将维度/stride 合并以使结构回到 ≤16 bytes。八、一句话工程建议最终使用std::spanT按值——安全且高效。对mdspan/ 三字段视图要谨慎如果你在乎每次调用的开销hot path用const或将结构改为两字段/更小的布局若是冷路径按值带来更好可读性也能接受。总是测量不同 ABI/编译器在边界处16 bytes / 2 eightbytes / MSVC rules表现不同实际测量才是最终判定标准。总结表不同 ABI 中复合类型Composite Types的寄存器传递规则Architecture ABIComposite types returned in registersComposite types passed in registersarmv8-a System V≤ 16 bytes≤ 16 bytesarmv7-a System V≤ 4 bytes≤ 16 bytesx86-64 System V≤ 16 bytes≤ 16 bytesx86 System Vfundamental onlySIMD onlyx86-64 Microsoft1,2,4,8 bytes, C03 POD1,2,4,8 bytesx86 Microsoft1,2,4,8 bytes, C03 PODnot even fundamentalx86 __fastcall Microsoft1,2,4,8 bytes, C03 PODfundamental only最后一句Composite types are required to be “trivial” to get into registers!详细理解这些 ABI 规则到底表达了什么1. “Composite type”复合类型是什么意思复合类型包括structclassarraystd::pair/std::tuple等任意包含多个字段的聚合类型aggregate不包括算术类型int、double、void*枚举类型指针核心ABI 会限制哪些类型可以放进寄存器传递ABI 规定复合类型如果太大→必须通过内存传递复合类型如果不是 trivial→必须通过内存传递也就是说要想让一个类型以寄存器形式传参或返回它必须大小不超过某个 ABI 规定的阈值如 arm64/x86-64 为16 1616bytes。类型必须是trivial平凡类型不带构造/析构/虚表/复制控制。何谓 “trivial” 类型C 中一个类型是 trivial当它没有用户定义的构造函数没有用户定义的析构函数没有虚函数没有虚表指针默认复制/移动操作所有成员也是 trivial例如structA{intx;floaty;};// trivial但下面就不是structB{~B();// 用户定义的析构函数 → 非 trivial};表格逐行解释armv8-a System V (AArch64 / Linux / macOS M1/M2/M3)返回值能放进寄存器的复合类型大小 ≤16 bytes参数能放进寄存器的复合类型大小 ≤16 bytes即一个$struct{ int a; double b; }$大小 12 bytes可以在寄存器传递一个$struct{ int a; int b; int c; int d; int e; }$20 bytes必须走栈或走指针传递条件必须是 trivial。armv7-a System V32bit ARM返回值阈值更小返回值 ≤4 bytes参数 ≤16 bytes因为 32-bit 寄存器只有 4 bytes所以超过4 44bytes 的复合类型无法以返回值寄存器返回。x86-64 System VLinux、macOS规则与 arm64 一样均是 ≤16 bytes但特殊规则更多如结构字段会分类到INTEGER/SSE8-byte slots例如structV{doublex,y;};// 16 bytes → 放入寄存器 xmm0 返回x86 System V32bit x86参数主要通过栈传递只有 SIMD 类型的前几个参数能放进 mm 寄存器复合类型基本无法在寄存器中传递。x86-64 Microsoft ABIWindowsWindows 的规则更严格只允许1,2,4,8 bytes 或 trivial POD类型放入寄存器超过 8 bytes 的 struct 必须通过引用传递不会放在寄存器例如structS{inta;intb;};// 8 bytes → 可以放进寄存器structT{inta;intb;intc;};// 12 bytes → 必须用引用传递x86 Microsoft (32bit Windows)更严格连 fundamental 都不放寄存器所有 struct 通过栈传递相当于最保守的 ABI。x86 __fastcall Microsoftfastcall前两个参数用寄存器其余走栈但struct 仍必须通过内存传递为什么“trivial”如此重要复合类型要想放入寄存器要满足size ≤ ABI limit and type is trivial \text{size} \le \text{ABI limit} \quad \text{and} \quad \text{type is trivial}size≤ABI limitandtype is trivial这是因为寄存器传递是按 raw bytes 做 bitwise copy如果类型非 trivial需要调用构造/析构、移动操作ABI 无法在寄存器中执行这些语义只能通过内存 调用复制操作来保证 C 语义因此一个带析构函数的 struct即使只有 4 bytes也不能放寄存器。例如structX{~X(){}inta;};大小 4 bytes但仍然走内存。这意味着什么对性能敏感的代码应倾向 trivial 小 struct如std::span两个指针16 bytestrivial→ 可在 arm64/x86-64 直接通过寄存器传递带析构函数的类型如 std::string, std::vector绝不会寄存器传参ABI 差异会导致跨平台行为不同例如同一个结构体在 Linux x86-64 可寄存器返回在 Windows x86-64 必须走引用返回这正是 C ABI 复杂性的根源。结论最后一句的含义Composite types are required to be “trivial” to get into registers!含义一个复合类型想要用寄存器传参或返回除了尺寸要满足 ABI 限制外它必须是 trivial。也就是说不能有构造函数不能有析构函数不能有虚函数所有成员也必须 trivial只有这样ABI 才能把它当作“一块原始内存”直接拷贝到寄存器。https://godbolt.org/z/bez7c5PMKhttps://godbolt.org/z/EcfanMoYf主题Empty parameter空参数为何“不免费”它的用途是什么C Core Guidelines 的 F.24 谈到spanT随后你给出的内容说明虽然某些类型可以是“空的”Empty Type / Empty Parameter但它们并不是“零开销”或“免费”的抽象。我们会从以下示例开始理解函数谓词传给 STL 算法Tag dispatch“access token”访问令牌最后分析为什么空类型仍然要消耗寄存器或要传参Empty parameter典型使用场景在 C 中一个空 structstructmt19937{};它大小为1 11为了满足 C object 必须有地址的要求但因为它是 trivial 且 empty所以 ABI 会将它作为一个“空标记tag”传递。这是 C 中常见的三类用途1.谓词 / 函数对象传给 STL 算法例如std::ranges::find_if(range,predicate);std::ranges::transform(input,output,unary_op);即使predicate是空类型它仍然是一个参数 → 会占用寄存器。也就是说即使对象是空的传参依然要在 ABI 层面占一个位置。2.Tag dispatch标签分发示例templateclassInputIter,classDiffvoidadvance(InputIteriter,Diff n,input_iterator_tag);这里input_iterator_tag通常是一个空 struct例如structinput_iterator_tag{};这个标签不占用空间但 ABI 会把它当成一个参数传递用来选择不同的重载版本。这是经典的 C tag dispatch 技巧编译器根据不同的 tag 调用不同函数tag 是空的但参数依然存在3.Access token访问令牌为了模拟 Java 的 package-privatevoidinternal_api(access_token);其中access_token是一个空类型仅在库内部可构造。作用让 API 只能从库内部调用空参数作为权限检查。为什么说 Empty Type “not free”因为ABI 必须传递参数不管它是否为空。即使类型为空仍然会消耗一个寄存器或者在 Windows ABI 中仍然要走栈仍然是函数签名的一部分仍然影响 overload resolution 和 linkage示例tag dispatch 的汇编分析你给出的示例intraw_rand();structmt19937{};inttagged_rand(mt19937);intraw_rand_call(){returnraw_rand();}inttagged_rand_call(){returntagged_rand(mt19937{});}armv8-a clang 18.1.0raw_rand_call()b raw_rand()tagged_rand_call()b tagged_rand(mt19937)注意虽然mt19937{}是空类型但 ABI 仍然要把它作为参数进行“标记”tag。x86-64 gcc 14.2raw_rand_call(): jmp raw_rand() tagged_rand_call(): jmp tagged_rand(mt19937)即空类型并不会导致额外加载内存但仍然改变函数签名→ 必须 jmp 到另一个入口MSVC它更严格一些Windows ABI 无空类型优化tagged_rand_call(): xor ecx, ecx ; 空 struct 作为参数放入 RCX jmp tagged_rand(mt19937)注意这里即使 struct 是空的仍然必须在寄存器 ECX 中传递一个值通常为 0这是 Windows ABI 的规定所有 struct 都要占用参数寄存器或栈位置这就是Empty parameter 也不是 free 的。深度理解为什么 ABI 必须传递空类型因为编译器必须保证函数签名一致调用位置和被调用位置 ABI 匹配empty type 必须保持 object identity必须可取地址因此只有 class 的大小是 1 byte这是 C 标准要求的sizeof(empty struct) 1 \text{sizeof(empty struct)} 1sizeof(empty struct)1虽然实际不需要真正的值但 ABI 仍需为它分配寄存器或者分配栈位置从而保证二进制兼容性binary ABI compatibility。总结非常关键空类型empty type在 C 是合法的但空参数仍然会占用一个 ABI 参数槽寄存器或栈改变函数 ABI导致不同的函数名修饰导致调用点必须传递某个字面值通常为 0Linux ABISystem V会极力优化 empty typeWindows ABIMSVC完全不会优化甚至更严格https://godbolt.org/z/vxG4eY1r4Empty parameter空参数与 C ABI、Core Guidelines 的深度理解这一部分讨论Itanium C ABIGCC/Clang 使用是如何处理空类型empty class的为什么空参数仍然不是“免费”的F.16 为什么建议“小型可廉价复制类型用值传递by value”1. Itanium C ABI空类型的大小与对齐ABI 是二进制接口Application Binary Interface即不同编译器必须遵守的函数调用规则。你引用2.2 POD Data TypesIf the base ABI does not specify rules for empty classes, then an empty class has size and alignment 1.解释如下C 规定每个对象都必须有一个独一无二的地址因此即使类里面什么成员都没有也必须至少占1 11字节即sizeof(empty class) 1 , alignof(empty class) 1 \text{sizeof(empty class)} 1, \quad \text{alignof(empty class)} 1sizeof(empty class)1,alignof(empty class)1原因非空对象必须拥有可辨认的身份object identity \text{非空对象必须拥有可辨认的身份object identity}非空对象必须拥有可辨认的身份object identity2. Itanium C ABI3.1.2.6 Empty Parameters你引用Arguments of empty class types that are not non-trivial for the purposes of calls are passed no differently from ordinary classes.意思是空类参数在 ABI 层面仍然被当作“普通参数”对待只是因为它非常小1 字节可以直接放进寄存器。因此空类不会被特殊“优化掉”它仍然是一个真正的函数参数ABI 必须保留它的传递方式否则会破坏函数签名这是为什么即使struct tag {}是空类型intf(tag);f(tag{});仍然要传一个“字节”给函数因此会占用寄存器空参数不等于无参数 \text{空参数不等于无参数}空参数不等于无参数这就是为什么我们说Empty parameter 不是 free 的——它仍然会影响 ABI、寄存器、函数签名以及调用规则。3. C Core Guidelines F.16何时应该使用值传递你引用F.16: For “in” parameters, pass cheaply-copied types by value and others by reference to const关键点如果类型复制成本非常低应该使用传值by value如果复制成本较高改用 const 引用这里强调“cheap to copy” ≈ 2 ∼ 3 个机器字大小 \text{“cheap to copy”} ≈ 2 \sim 3 个机器字大小“cheap to copy”≈2∼3个机器字大小在大多数架构一个 pointer 8 88字节一个 int/double 4 44或8 88字节因此 ABI 会尽可能将它们放入通用寄存器。所以对小型对象by value 会使用寄存器传参 → 更快避免额外间接访问调用方语义更清晰而空类型刚好是特别的小1 byte → 复制成本基本为零因此空类型也属于 cheap to copy应以值传递。例如structtag{};voidf(tag t);// 推荐voidf(consttagt);// 毫无意义还多一次间接访问4. F.16 的理由Reason详细解析你引用Reason Both let the caller know that a function will not modify the argument, and both allow initialization by rvalues.解释传值by value调用者明确知道被传入的数据不会被修改因为是副本传const语义上也表示不会修改此外对于传值可以直接接受右值f(42);f(std::string(hello));而传const也可以接受右值但一般来说性能差不多。但如果类型小于等于2 ∼ 3 words 16 ∼ 24 bytes 2 \sim 3 \text{ words} 16 \sim 24 \text{ bytes}2∼3words16∼24bytes传值通常更快因为不需要解引用指针可使用寄存器调用者行为更简单、更直观5. 为什么空类型应该总是 by value结合 ABI 和 F.16空类型大小为sizeof(tag) 1 \text{sizeof(tag)} 1sizeof(tag)1析构、构造均为 trivial没有非平凡行为所以它们自动满足cheap to copy AND pass-by-register \text{cheap to copy} \quad \text{AND} \quad \text{pass-by-register}cheap to copyANDpass-by-register也不会压栈不会生成额外内存访问因此空类型参数用 by value 是必然选择没有任何理由使用 const。总结概念解释空类 size1因为对象必须可寻址空类型参数仍然是参数ABI 不能忽略它否则破坏函数签名空类型传参不免费仍然要占用寄存器仍然影响 ABIF.16小型类型 → by value23 words 大小最适合空类型属于最便宜复制的类型应始终以值传递1. 问题核心为什么同一逻辑不同 ABI 有不同的寄存器移动我们有 4 个函数它们的逻辑本质完全相同只是决定先加哪两个参数intsum(intx1,intx2);intsum_12_3(intx1,intx2,intx3){returnsum(sum(x1,x2),x3);}intsum_13_2(intx1,intx2,intx3){returnsum(sum(x1,x3),x2);}intsum_23_1(intx1,intx2,intx3){returnsum(sum(x2,x3),x1);}intsum_21_3(intx1,intx2,intx3){returnsum(sum(x2,x1),x3);}2. 各 ABI 的整数参数寄存器顺序在三种 ABI 中整数参数的寄存器顺序都是固定的且非常不同。arm64 (AAPCS64)前 8 个整数参数寄存在x 0 , x 1 , x 2 , x 3 , x 4 , x 5 , x 6 , x 7 x0, x1, x2, x3, x4, x5, x6, x7x0,x1,x2,x3,x4,x5,x6,x7所以参数寄存器x 1 x1x1w 0 w0w0x 2 x2x2w 1 w1w1x 3 x3x3w 2 w2w2x86-64 System V ABI (Linux, GCC, Clang)前 6 个整数参数寄存在r d i , r s i , r d x , r c x , r 8 , r 9 rdi, rsi, rdx, rcx, r8, r9rdi,rsi,rdx,rcx,r8,r9所以参数寄存器x 1 x1x1e d i ediedix 2 x2x2e s i esiesix 3 x3x3e d x edxedxWindows x64 ABI (MSVC)前 4 个整数参数寄存在r c x , r d x , r 8 , r 9 rcx, rdx, r8, r9rcx,rdx,r8,r9所以参数寄存器x 1 x1x1e c x ecxecxx 2 x2x2e d x edxedxx 3 x3x3r 8 d r8dr8d3. 为什么会出现大量寄存器交换swap/shuffle因为调用sum(a,b)必须严格满足ABI 规定的参数顺序。在三种 ABI 中sum(int,int)的调用约束分别为arm64 →w 0 第 1 参 w0 第1参w0第1参,w 1 第 2 参 w1 第2参w1第2参SysV →e d i 第 1 参 edi 第1参edi第1参,e s i 第 2 参 esi 第2参esi第2参MSVC →e c x 第 1 参 ecx 第1参ecx第1参,e d x 第 2 参 edx 第2参edx第2参典型案例sum_21_3returnsum(sum(x2,x1),x3);最内层调用需要第 1 参数x 2 x2x2第 2 参数x 1 x1x1而 ABI 规定arm64 →w 0 x 2 w0 x2w0x2,w 1 x 1 w1 x1w1x1SysV →e d i x 2 edi x2edix2,e s i x 1 esi x1esix1MSVC →e c x x 2 ecx x2ecxx2,e d x x 1 edx x1edxx1但原始传入寄存器并不是这个顺序ABIx1x2x3arm64w0w1w2SysVediesiedxMSVCecxedxr8d所以为了构造s u m ( x 2 , x 1 ) sum(x2, x1)sum(x2,x1)必须重排( x 1 , x 2 ) → ( x 2 , x 1 ) (x1, x2) \rightarrow (x2, x1)(x1,x2)→(x2,x1)这是一个经典 swap 问题。4. 为什么“swap 需要 3 次 move”一个交换操作本质上需要把 A 保存到临时寄存器 TT ← A T \leftarrow AT←A把 B 复制到 AA ← B A \leftarrow BA←B把 T 复制到 BB ← T B \leftarrow TB←T总共3 moves 3\ \text{moves}3moves在汇编中对应arm64通常使用w 19 w19w19或w 8 w8w8sysv gcc使用r b x rbxrbxmsvc使用r b x rbxrbx或r 8 r8r8因此凡是需要交换参数顺序的函数都更慢、指令更多。5. 指令数量差异总结函数需要 swap?指令最少的平台sum_12_3否x86-64 gccsum_13_2是x1↔x3x86-64 gccsum_23_1是x1↔x2→x3x86-64 gccsum_21_3是x1↔x2x86-64 gcc因为 System V ABI 对 swap 的寄存器调度using rbx最便宜。6. “参数顺序限制” 的本质含义C Core Guidelines F.16 说cheap-to-copy 的类型应该按值传参。但ABI 的参数寄存器顺序是固定的。即使参数是 cheap-to-copy交换参数顺序仍需要指令。也就是说按值传参 ! 免费移动寄存器 \text{按值传参 ! 免费移动寄存器}按值传参!免费移动寄存器所以当你写sum(sum(x2,x1),x3);虽然是 trivial ints但 ABI 消耗3 次 move1 次 call额外寄存器保存与恢复7. 最终总结重点各 ABI 都规定了固定的参数寄存器顺序这决定了调用sum(int,int)时必须把参数放到指定寄存器。参数重排reorder导致寄存器 swap一个 swap 总是需要至少3 次 move 指令。因此不同排列的 sum_xx_x 会出现不同的汇编复杂度这与数据类型 cheap-to-copy 无关这是ABI 参数传递规则强制造成的额外开销。函数原型armv8-a clang 18.1.0x86-64 gcc 14.2x64 msvc v19.40 / VS17.10sum_12_39 instructions7 instructions9 instructionssum_13_210 instructions8 instructions10 instructionssum_23_111 instructions9 instructions12 instructionssum_21_312 instructions10 instructions12 instructionshttps://godbolt.org/z/MsjeT8TTK一、先回答你“需要哪些知识”那部分要点清单要理解std::function开销需要把下面三点连在一起看参数传递机制parameter passing参数在调用链中什么时候在寄存器什么时候被物化到内存由 ABI 与表达式的“物化点materialization”决定。如果一个调用链能够被内联inline展开参数可以一直留在寄存器里开销低如果中间有不可内联的 type-erased 间接调用就可能把值写到内存并通过地址传递或间接加载。一致的参数顺序consistent parameter orderABI 规定了第 1、2、3 个参数要进哪些寄存器如 x86-64 SysVrdi, rsi, rdxarm64x0, x1, x2。如果中间包装器改变了参数顺序或把参数先放栈/内存再做转发会引入额外 move/load/stack 操作。因此跨越不可内联边界的“包装器”必须遵循 ABI把参数放到正确寄存器或内存位置可能会导致寄存器 shuffle移动。完美转发perfect forwarding与“寄存器保留”C 的完美转发templateclass F void g(F f); std::forwardF(f)语义上保留值类别lvalue/rvalue但在函数参数层面传入的完美转发参数通常先以引用形式“命名为局部”——这可能导致物化不能一直在寄存器中传递。有研究/提案你提到的 “enhanced perfect forwarding” 思路尝试把 prvalue 更广泛地就地消除super-elider以便跨 template 构造器直接构造到目标内存从而“保留寄存器传递”。但这些不是 C 标准当前通用保障只有在特定编译器/特定情形下才会发生。理解以上 3 点就能理解std::function的主要开销来源与可优化空间。二、std::function的开销究竟在哪儿逐项拆解std::functionR(Args...)是一个类型抹除type-erased可调用对象常见实现要点及对应开销类型抹除 间接调用indirectionstd::function内部保存一个指向“调用函数”的函数指针通常是 small object 的调用 thunk 或 heap 分配的对象的调用 thunk。调用时是一次间接跳转function pointer call而非直接调用目标函数。间接调用通常无法内联除非编译器能在链接时做 devirtualization/ICM所以跨越这个调用边界的优化例如把参数一直放在寄存器里通常会丢失导致物化/寄存器-内存来回。内存分配可能发生许多实现对小 functor 使用Small Buffer OptimizationSBO把小的可调用体比如无捕获或少捕获的 lambda直接放在std::function自带的内存缓冲区里避免堆分配。但当可调用对象太大或动态分配时std::function会做new/delete→ 显著的分配/释放开销与内存抖动。是否分配取决于实现与被包装对象大小。构造/拷贝/移动开销std::function的拷贝会拷贝被封装的目标对象可能触发 heap allocation or deep-copy移动则依赖实现。热路径中频繁创建std::function会带来成本。ABI 寄存器影响调用开销具体表现当函数是通过std::function::operator()间接调用时参数会按 ABI 进行一次标准的封送marshal到std::function的调用点。如果该调用不可内联则 callee 不能利用调用者寄存器布局做跨边界优化这经常会导致寄存器值被存到内存或额外寄存器移动。异常/动态行为std::function的实现需要处理异常安全、类型擦除的析构等进一步增加复杂性不过这些多为构造/析构成本对单次调用影响小。总结std::function的主要运行时开销来自可能的堆分配与间接调用/不可内联导致的寄存器/内存物化。若std::function的目标可内联例如编译器能看到并能优化掉类型抹除那么开销可接近零但在一般 library-level type-erasure 场景std::function是比模板函数慢得多的。三、你贴的raw_copyvschecked_copy为什么两者都jmp memcpy你给的两段在NDEBUG下voidraw_copy(std::byte*dst,std::byteconst*src,size_t size){std::memcpy(dst,src,size);}voidchecked_copy(std::byte*dst,std::byteconst*src,size_t dst_size,size_t src_size){assert(src_sizedst_size);std::memcpy(dst,src,dst_size);}编译器输出都直接jmp memcpy。原因assert在NDEBUG下被去掉所以checked_copy的额外检查不在发布构建里出现。两者在发布版完全等价编译器把函数体优化为对memcpy的尾跳tail-call / jump因此生成的是jmp memcpy。在发布版中memcpy常常是一个库调用libc 的实现编译器不展开实现细节而是尾跳以节省栈/指令tail-call optimization。因此看起来两者“没有额外开销”。工程提醒在 debug 模式两者肯定不同checked_copy会做断言在 release 模式若断言被禁用checked_copy不增加运行时开销。但若你真正需要运行时检查不能用NDEBUG去除或必须用显式检查检查会增加开销条件分支、可能的分支错判成本。四、关于“增强完美转发preserves passing in registers”的说明你提到的目标是保持参数能够在寄存器中被原样传递通过包装/转发不造成物化。现实情况C 的完美转发保留 value-categorylvalue/rvalue但参数在进入模板函数后通常变成命名局部即左值这会导致物化尤其当模板构造/模板包装存在时。有研究比如 “super-elider” / “rvalues redefined”探索把 prvalue 的消除能力扩展到跨 template / 转发情形这在理想情况下确实能“preserve passing in registers”。但这不是主流编译器普遍保证的语义不能依赖在所有编译器/平台都有效。因此在实践中要用模板/内联路径template class F void call(F f)并尽量在调用点内联来保证参数能在寄存器中“直通”。而使用std::function这样的 type-erased 间接调用通常会丢失这一优点。五、实战建议与替代方案工程可立即采用如果你在意std::function的开销下面这些策略常用且有效——按优先级排序1)热路径不要用std::function用模板接收可调用对象templateclassFvoiddo_work(Ff){// 调用点通常可内联参数能保留在寄存器f(...);}优点零抽象开销、编译器能内联、能保留寄存器。缺点会使接口成为模板对 ABI/编译单元可见度有影响。2)在需要 type-erasure 但不想分配时用function_ref/function_view非拥有、非分配absl::FunctionRef、std::experimental::function_ref或自实现是轻量的函数引用不拥有目标也不分配调用时通常是一个直接指针到目标可调用体更容易内联。适合短期回调、临时传递可大幅降低std::function的分配与类型擦除成本。注意生命周期必须由调用者保证。3)如果必须用std::function尽量保证 SBO 生效与重用让可调用对象小减少捕获大小以命中实现的 SBO避免 heap allocation。重用std::function对象而不是频繁创建销毁减少分配开销。在 C17/C20 中许多实现已做了较好的 SBO但具体行为实现依赖需测量。4)如果需要 type-erasure allocation 控制使用自定义分配器或 pmr若实现支持定制内存资源std::pmr或实现特定的分配器可以让std::function使用预分配池消除运行时 new/delete 延迟。5)标记noexcept/ 简化多余的层级将小函数标注noexcept可帮助优化允许更多内联或优化调用约定。尽量避免把回调层级做过深的包装每层都会增加间接、寄存器移动。6)用span/ 明确界限替代裸指针 API你也已看到既保证安全性又可以被编译器当作两个寄存器传递ptr,len没有额外运行时开销。六、如何衡量测量策略在目标平台用 micro-benchmarkGoogle Benchmark / simple chrono timing测量template内联调用 vsfunction_refvsstd::function (SBO)vsstd::function (heap)。关注两部分每次调用的调用开销与构造/销毁的分配开销如果发生分配构造/销毁会 dominate。用 GodboltCompiler Explorer观察汇编判断是否发生内联、是否有call/jmp/mov/内存写入等。在 release 编译O2/O3/LTO下测量因为 debug 模式会掩盖多数优化。七、短结工程一句话std::function提供方便且安全的 type-erasure但在 hot path 上它有真实成本堆分配若未命中 SBO和间接调用不可内联导致的寄存器物化。如果性能关键优先使用模板 内联、或function_ref/function_view并在需要时用std::function作“配置/事件/非热路径”的统一持有类型。一、函数原型回顾调用侧voidraw_copy(std::byte*dst,std::byteconst*src,size_t size);voidchecked_copy(std::byte*dst,std::byteconst*src,size_t dst_size,size_t src_size);std::arraystd::byte,8arr;voidraw_copy_call(){raw_copy(arr.data(),arr.data(),8);}voidchecked_copy_call(){checked_copy(arr.data(),arr.data(),8,8);}两个调用点的语义几乎一样只是checked_copy_call传四个参数而已两次大小。二、为什么编译器把函数体替换为jmp memcpy/b memcpy你在之前的例子里已经看到在发布构建NDEBUG下checked_copy的断言被去掉两者的行为等价编译器将raw_copy/checked_copy优化成对库函数memcpy的尾跳tail call这是一种常见优化函数体仅仅调用另一个函数并返回时编译器用jmp/b直接跳到被调用函数以节省栈帧和指令尾调用/尾跳。因而你会看到调用点直接把参数放好然后jmp memcpyLinux/GCC/Clang或b memcpyARM64或相应的跳转。三、各 ABI 的参数寄存器顺序重要背景不同 ABI 把前几个参数放到不同寄存器这会直接影响编译器在调用处如何安排mov/lea/mov reg, imm等指令。常见我们用 64-bit 平台x86-64 System V (GCC/Clang on Linux/macOS)第 1…6 个整型/指针参数 →rdi, rsi, rdx, rcx, r8, r9低 32-bit 用edi, esi, edx等Windows x64 (MSVC)第 1…4 个整型/指针参数 →rcx, rdx, r8, r9ARM64 (AArch64, System V)第 1…8 个整型/指针参数 →x0, x1, x2, x3, x4, x5, x6, x7低 32-bit 是w0...w7四、逐 ABI 解释你看到的汇编调用端1) ARM64 clang 输出armv8-araw_copy_call(): adrp x0, arr add x0, x0, :lo12:arr mov w2, #8 mov x1, x0 b raw_copy(...)解释adrp x0; add x0, x0, :lo12:arr把数据地址arr放到寄存器x0arr.data()。mov w2, #8把常数8放到w2size- 第 3 个参数对应x2/w2。mov x1, x0把src第二个参数放到x1。最终寄存器排列为x0dst, x1src, x2size符合 AArch64 的参数顺序。b raw_copy尾跳到raw_copy。checked_copy_call()只是多了mov w3, #8第四个参数 -x3/w3其他相同。2) x86-64 System V (gcc)raw_copy_call(): mov esi, OFFSET FLAT:arr ; rsi arr (second param) mov edx, 8 ; rdx size (third param) mov rdi, rsi ; rdi dst (first param) jmp raw_copy checked_copy_call(): mov esi, OFFSET FLAT:arr ; rsi src mov ecx, 8 ; rcx fourth param mov edx, 8 ; rdx third param mov rdi, rsi ; rdi dst jmp checked_copy解释System V 的参数顺序是rdi, rsi, rdx, rcx, r8, r9第 1 → rdi第 2 → rsi第 3 → rdx第 4 → rcx。编译器先把arr放到rsi然后把rdi设为rsi因为dst和src都是arr.data()再把edx/ecx放好。最终顺序rdi(dst), rsi(src), rdx(size), rcx(size2)满足调用约定。jmp raw_copy表示尾跳到memcpy或到raw_copy的实现节省栈帧。注意顺序编译器可能按它喜欢的顺序填寄存器只要最终寄存器值正确即可。有时它先写 rsi 再 rdi而不是直接把地址先放 rdi。3) MSVC x64Windowsraw_copy_call(): mov r8d, 8 lea rdx, OFFSET FLAT:arr ; rdx src (second param) lea rcx, OFFSET FLAT:arr ; rcx dst (first param) jmp raw_copy checked_copy_call(): mov r9d, 8 lea rdx, OFFSET FLAT:arr ; rdx src mov r8d, r9d ; r8 dst_size lea rcx, OFFSET FLAT:arr ; rcx dst jmp checked_copy解释Windows x64 参数顺序是rcx, rdx, r8, r9第 1→rcx第 2→rdx第 3→r8第 4→r9。raw_copy_call将size放在r8d第三个参数把src放rdx、dst放rcx然后尾跳。checked_copy_call先把常量8放到r9d随后把r8d r9d把第三个参数设置为dst_size最终rcxdst, rdxsrc, r8dst_size, r9src_size。编译器有时为了避免直接把同一个立即数写两次而先写到r9d再复制到r8d寄存器写法或调度策略的微优化。五、为什么checked_copy_call会多出一些mov看起来多余几个原因参数数量不同checked_copy有 4 个参数编译器必须为第 4 个参数也准备寄存器或栈因此多了对rcx/r8/r9等寄存器的写入。寄存器分配顺序编译器会按内部调度决策写入寄存器先写某些寄存器比直接写所有寄存器更方便或高效。例如 MSVC 示例里把常数写到r9d再mov r8d, r9d这可能源自指令选择或立即数编码限制某些立即数直接写入某寄存器更优或者为了减少指令大小/重排约束。保持一致性即便dst和src相同都是arr.data()编译器在生成代码时一般不会做“跨参数等价传播”这种高层语义等价判断除非启发式优化它只是把每个参数表达式分别评估并放到相应寄存器。结果就是看到多个 mov/lea但语义正确。六、关于尾跳tail-call与栈帧你看到jmp raw_copy/b raw_copy而不是call说明编译器做了尾调用优化尾跳它把当前函数的栈帧用完后直接跳到 callee而不是再返回到这里节省栈空间、减少指令。因此调用站点通常没有典型的push/call/pop序列只有寄存器设置与尾跳。七、工程与可读性建议结论性建议在发布构建下checked_copy断言被禁用不会比raw_copy慢——编译器会把它们都转为memcpy调用尾跳。如果你需要实际在运行时检查断言开启或显式运行时检查这会引入分支和开销——那时checked_copy比raw_copy慢是显然的。使用std::span或span参数可以让接口更安全避免忘记 length但在调用层面span会被传作两个寄存器ptr,len对 64-bit 平台开销接近直接传ptr,size。因此语义安全与性能可以兼顾。不要过早进行手工微优化如果目标是性能关键路径测量microbenchmark并在对应目标 ABI 上观察汇编是必要的。八、快速总结一句话raw_copy_call与checked_copy_call在 release 下都变成对memcpy的尾跳jmp/b参数按 ABI 放寄存器checked_copy_call看起来有更多mov只是因为它有更多参数需要放到正确的寄存器编译器按自己的指令调度顺序写寄存器常常会先把常数写入某寄存器再复制到另一个寄存器这是正常且无害的使用std::span可以获得更清晰、更安全的 API同时保持几乎无额外调用开销在常见 64-bit ABI 下。1. C Core Guidelines —— I.13 I.23 解释I.13: “Do not pass an array as a single pointer”解释不要把数组当成一个单独指针传入函数。因为它丢失了数组的长度信息只剩下裸指针不安全。例如voidcopy_n(constT*p,T*q,intn);更安全的做法voidcopy(spanconstTr,spanTr2);为什么“不是免费”的Not freespan包含两个字段ptr与size传参至少需要两个寄存器或一个寄存器栈即不是“零成本抽象”。但“不是零成本”不表示“很贵”在 64-bit ABI 中传两个指针几乎永远是非常廉价的。I.23: “Keep the number of function arguments low”解释函数参数越多越难理解也越不安全。太多参数往往说明缺少抽象或违反“单一职责原则”。传参成本高的原因许多 ABI 对函数参数寄存器数量有限。例如x86-64 System V前 6 个整型参数进寄存器Windows x64前 4 个ARM64前 8 个参数超过寄存器数目就会溢出到栈导致额外load/store成本。2. 数学背景标量三重积scalar triple product标量三重积定义[ a , b , c ] ≡ a ⋅ ( b × c ) [a, b, c] \equiv a \cdot (b \times c)[a,b,c]≡a⋅(b×c)它等于由 3 个向量a , b , c a, b, ca,b,c组成的平行六面体的有向体积。循环置换不变a ⋅ ( b × c ) b ⋅ ( c × a ) c ⋅ ( a × b ) a \cdot (b \times c) b \cdot (c \times a) c \cdot (a \times b)a⋅(b×c)b⋅(c×a)c⋅(a×b)交换点叉积与点积的顺序也成立只要不交换变量顺序a ⋅ ( b × c ) ( a × b ) ⋅ c a \cdot (b \times c) (a \times b) \cdot ca⋅(b×c)(a×b)⋅c这就是后续cross_product→dot_product的数学依据。3. 两种 C 实现对比9 个intvs 3 个vector3版本 1参数是 9 个intinttriple_product(intax,intay,intaz,intbx,intby,intbz,intcx,intcy,intcz){vector3 dcross_product(ax,ay,az,bx,by,bz);returndot_product(d.x,d.y,d.z,cx,cy,cz);}特点参数数量 9ABI 参数寄存器不够用会导致传参复杂、压栈/还原。cross_product()返回一个结构vector3通常以 64bit or 96bit 返回值 ABI 表示。dot_product()又继续展开成 6 个int参数。因此生成汇编会较长、寄存器 shuffle 很多。版本 2参数是 3 个vector3 constintvector_triple_product(vector3consta,vector3constb,vector3constc){returnvector_dot_product(vector_cross_product(a,b),c);}特点仅 3 个参数 → 更容易映射到寄存器。vector_cross_product返回一个vector3可以存入临时然后按结构传给dot_product。结构传引用通常在 ABI 上被当作一个指针64 bit非常简单。因此汇编比版本 1 更短、更可读。结论减少参数数量 减少汇编复杂度 直接体现 I.23 的价值4. 汇编解析ARM64 / x86-64 / MSVC以下逐个解析。4.1 ARM64 clang —— 9 个 int 的版本triple_product(...): stp x29, x30, [sp, #-48]! str x21, [sp, #16] stp x20, x19, [sp, #32] mov x29, sp解释保存帧指针/返回地址x29/x30开栈帧。参数太多触发溢出到栈需要重新排列寄存器。ldr w21, [x29, #48] ; 读栈上的第 9 个参数 cz mov w19, w7 ; cy mov w20, w6 ; cx因为参数数量太多后几个参数在栈上bl cross_product调用cross_product(ax,ay,az,bx,by,bz)。返回值放在x0结构。lsr x8, x0, #32 ; 从返回结构取高 32 bit mov w2, w1 ; 参数重新排列 mov w3, w20 ; cx mov w4, w19 ; cy mov w1, w8 ; dx mov w5, w21 ; cz这里大量寄存器 shuffle原因dot_product(dx,dy,dz,cx,cy,cz)参数太多6 个 int。ABI 需要将它们放入正确寄存器x0,x1,x2,x3,x4,x5。结尾b dot_product尾跳tail call。4.2 ARM64 clang —— vector3 reference 版本vector_triple_product(...): sub sp, sp, #48 stp x29, x30, [sp, #16] ... bl vector_cross_product str x0, [sp] ; 保存 cross_product 的结构返回值 mov x0, sp ; 把临时结构的地址传给 dot_product mov x1, x19 ; 传入 c bl vector_dot_product显著变化没有复杂 shuffle。参数只有 2 个引用和一个结构指针简单。返回结构通过内存传递遵守 AArch64 ABI。整个汇编清晰得多。4.3 x86-64 System V GCC —— 9 个 int 版本triple_product: push r12 push rbp push rbx sub rsp, 16保存寄存器因为参数需要重排。mov ebx, DWORD PTR [rsp48] ; cz mov ebp, DWORD PTR [rsp56] ; cy mov r12d, DWORD PTR [rsp64] ; cx9 个参数溢出栈——这是复杂性的根本原因。call cross_product返回值存放在rax:rdx结构的小型返回。然后准备 dot_product 的 6 个参数mov r8d, ebp mov rcx, rax mov r9d, r12d mov edi, eax shr rcx, 32 mov esi, ecx mov ecx, ebx不难看到大量寄存器 shuffle。最后jmp dot_product尾跳。4.4 x86-64 System V —— vector reference 版本call vector_cross_product lea rdi, [rsp4] mov rsi, rbx mov QWORD PTR [rsp4], rax mov DWORD PTR [rsp12], edx call vector_dot_product明显更短、更干净。4.5 MSVC x64 —— 相同模式MSVC 版本因为多了 security cookie会更长但核心差异一致9 个 int 版本参数溢栈 大量寄存器 shufflevector3 reference传指针汇编明显更短5. 为什么“参数数量多”会导致复杂汇编这是I.23 的本质原因。ABI 对“能用寄存器传参的数量”有限制ARM64前 8 个System V前 6 个Windows x64前 4 个当参数数量超过此限制后面的参数在栈上读取这些参数需要额外mov/load重新排布到目标函数参数寄存器需要更多 shuffle当函数之间传递结构返回值时9-int 版本需要将结构拆散为 3 个 int 再重新组合为 6 个参数这导致大量寄存器 shuffle \text{大量寄存器 shuffle}大量寄存器shuffle当使用结构引用版本时参数数量少结构以指针形式传入ABI 友好几乎无额外 shuffle6. 总结核心理解I.13不要用裸指针表示数组→ 用span更安全并且性能几乎等价。I.23参数过多 汇编显著变复杂因为 ABI 限制导致参数溢栈复杂 register shuffle更多 prologue/epilogue传结构返回值时需要拆分、重新组装三重积例子完美展示9 个int参数数量 9返回结构拆分为 3 个 int再与另 3 个 int 构成 dot_product 的 6 参数结果汇编长、复杂、难读且有性能损失vector3 const参数数量 3结构按指针传递cross_product → dot_product 不需要拆分结构汇编极简、寄存器使用直接、性能更好数学上标量三重积为a ⋅ ( b × c ) a \cdot (b \times c)a⋅(b×c)这在结构版本中直接体现vector_dot_product(vector_cross_product(a,b),c)下面是对你提供内容的逐条、逐点、系统化的深度解析包含你要求的. . . ......与. . . ......数学公式格式。内容分三部分各 ABI 调用约定总结“组合类型在寄存器中传递/返回”的规则含义表格中每一行的含义详解哪些 composite types 可以进入寄存器为什么必须 trivial1. 各架构 ABI参数与返回值如何进入寄存器深度理解armv8-a System V ABI前 8 个参数使用寄存器传递r 0 , r 1 , . . . , r 7 r_0, r_1, ..., r_7r0​,r1​,...,r7​返回值也放在r 0 , r 1 r_0, r_1r0​,r1​和 x86-64 System V 一样遵循 AAPCS64小型结构体≤ 16 \le 16≤16字节会拆成寄存器对传递特点非常现代化寄存器用于大量参数。x86-64 System V ABI前 6 个整数类参数r d i , r s i , r d x , r c x , r 8 , r 9 rdi, rsi, rdx, rcx, r8, r9rdi,rsi,rdx,rcx,r8,r9返回值放在r a x , r d x rax, rdxrax,rdx小于等于 16 字节的 trivial aggregate 会分配到寄存器例如含两个int的 struct 可能被装进(rax, rdx)。是要求 composite type “trivial 且 ≤16 bytes” 才能进入寄存器的典型代表。x86-64 Microsoft ABI前 4 个参数在寄存器传递r c x , r d x , r 8 , r 9 rcx, rdx, r8, r9rcx,rdx,r8,r9对 composite type 规则更保守 ——仅限 C03 POD 1,2,4,8-byte 大小MSVC 对结构体进入寄存器非常保守。armv7-a System V ABI前 4 个参数r 0 , r 1 , r 2 , r 3 r_0, r_1, r_2, r_3r0​,r1​,r2​,r3​返回值在r 0 r_0r0​结构体进入寄存器的规则有限最大 4 字节返回值才能直接放在寄存器非常狭窄、偏向旧架构。x86 System V32-bit大多数参数都走栈例外前三个__m64使用MMX寄存器 %mm0-%mm2composite types 不进入整数寄存器32-bit ABI 受限几乎没有 composite register passing。x86 Microsoft32-bit默认全部参数压栈右→左__fastcall只有两个参数进 ECX、EDX最保守的 ABI几乎不用寄存器传 composite types。2. Composite types 在寄存器中传递/返回是什么意思Composite type 指structclassunion它们要想进入寄存器必须满足大小限制按 ABI 规定如 4 bytes、8 bytes、16 bytes 等Trivialtrivial default constructortrivial copy/movetrivial destructor无虚表无非平凡成员换句话说只有 C 的 POD 风格的 struct 才可能被分配到寄存器。例如structS{inta;intb;};// trivial, 8 bytes → 可以进寄存器但这个不行structX{std::string s;};// non-trivial → 一定在栈上传递3. 表格详细解读逐行意义给出的表格Architecture ABIComposite types returned in registersComposite types passed in registersNumber of registers for parameters returnarmv8-a System V≤ 16 bytes≤ 16 bytes8 totalarmv7-a System V≤ 4 bytes≤ 16 bytes4 totalx86-64 System V≤ 16 bytes≤ 16 bytes6 2x86 System Vfundamental onlySIMD only0 2x86-64 Microsoft1,2,4,8 bytes, C03 POD1,2,4,8 bytes4 1x86 Microsoft1,2,4,8 bytes, C03 PODnot even fundamental0 2x86 __fastcall Microsoft1,2,4,8 bytes, C03 PODfundamental only2 2下面解释每一行的含义。armv8-a System V返回composite type size ≤ 16 bytes ⇒ returned in registers \text{composite type size} \le 16 \text{ bytes} \Rightarrow \text{returned in registers}composite type size≤16bytes⇒returned in registers传参同样 ≤16 字节可以进入r 0 . . r 7 r_0..r_7r0​..r7​意义一个 3D vector{int x,y,z}12 字节可以完全在寄存器里传递。armv7-a System V返回≤4 字节才能寄存器返回只能放进 r0传参≤16 字节可拆分进入 r0-r3意义返回大型 struct 会走栈x86-64 System V返回≤16 字节 → 用两个寄存器(rax, rdx)返回一个 struct 会被视为两个 64-bit chunk传参≤16 字节 → 拆分到 1–2 个整数寄存器寄存器数量参数 6 个 返回 2 个x86 System V32-bit返回只能 fundamental传参只能 SIMD 可以进 mmX→ composite 全走栈x86-64 Microsoft ABI (MSVC)规则最严格仅以下 composite types 可以进入寄存器大小 ∈ {1,2,4,8} 字节必须是 C03 POD更严格 than trivial例如structP{intx;};// 4 bytes, C POD → OKstructQ{doublex;};// 8 bytes, OK但structR{inta;intb;};// 8 bytes 但不是 POD (aggregate but still POD?) 看编译器但 MSVC 更保守“passed in registers” 限制更严格 → 只能 1,2,4,8 bytesx86 Microsoft32-bit传参规则极其保守返回只允许 POD 1,2,4,8 字节进入寄存器传参甚至 fundamental 都进不了寄存器全走栈x86 __fastcall Microsoft前两个参数 (DWORD 或更小) 进 ECX、EDXcomposite type返回1/2/4/8-byte POD OK传参只允许 fundamental为什么必须 trivial 才允许 composite types 进入寄存器因为寄存器传递无法调用构造/析构/复制操作。举例structX{X();~X();};它是 non-trivial 类型传递 X(x) 时要运行拷贝构造析构可能的 alignment 处理寄存器无法承担这些操作因此 ABI 要求composite type must be trivial to be passed/returned in registers \text{composite type must be trivial to be passed/returned in registers}composite type must be trivial to be passed/returned in registers
版权声明:本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!

商城做网站阳江市房产网

Dify与Docker Run命令结合使用的最佳实践 在AI应用开发日益普及的今天,越来越多团队面临一个共同挑战:如何快速、稳定地将大语言模型(LLM)能力转化为可交付的产品?传统的开发流程往往受限于环境差异、依赖冲突和部署复…

张小明 2025/12/21 17:29:59 网站建设

做外贸网站平台有哪些自建站系统

FaceFusion命令行工具详解:自动化脚本编写实战 在数字内容爆炸式增长的今天,个性化视频制作的需求正以前所未有的速度攀升。从短视频平台上的“一键换脸”特效,到影视工业中复杂的角色替换任务,人脸编辑技术已成为连接创意与效率的…

张小明 2025/12/21 17:29:58 网站建设

网站关键词百度指数商业摄影网站源码

3步打造智能下拉框:Bootstrap-select语义化搜索实战 【免费下载链接】bootstrap-select 项目地址: https://gitcode.com/gh_mirrors/boo/bootstrap-select 你是否曾在电商网站搜索"水果"却找不到苹果?输入"红色"却看不到草莓…

张小明 2025/12/21 17:30:01 网站建设

网站可以做2个公司的吗怎么用壳域名做网站

Open Images数据集完全攻略:从新手到专家的5步实战指南 【免费下载链接】dataset The Open Images dataset 项目地址: https://gitcode.com/gh_mirrors/dat/dataset Open Images数据集作为Google推出的超大规模计算机视觉资源,为AI开发者提供了海…

张小明 2025/12/21 17:30:05 网站建设

网站建设 优化财经公关公司排名

FaceFusion支持竖屏短视频格式吗?移动端适配优化 在抖音、快手和 Instagram Reels 主导内容消费的今天,9:16 竖屏视频早已不是“趋势”,而是默认标准。用户拿起手机就是竖着拍、竖着看,任何试图强行塞进横屏逻辑的内容都会显得格格…

张小明 2025/12/21 17:30:11 网站建设