Appearance
抽象不能有代价:零开销抽象与编译期计算
C++ 的设计信条之一:你不需要为你没有用到的东西付出代价,而你用到的东西,也不应该比手写更慢。这一页把散落在模板、
constexpr、concepts、内联、多态里的设计思想串成一条线——为什么"高级写法"常常能和"手写汇编式代码"跑得一样快,以及代价究竟藏在哪些角落。
零、总纲:抽象的成本必须可解释
一句话:抽象本身不该自带税,任何性能损耗都要能说清"它买了什么"。
展开:C++ 与"抽象即昂贵"的语言路线不同,它把"零开销"当作可验证的工程目标而非口号。判断一个抽象是否合格,可以问三件事:不用它时是否零成本?用它时是否 ≤ 手写?成本是否编译期可判定、因而不必运行时支付?凡是能用类型系统在编译期解决的事,就不该留到运行期。这套思路贯穿本页所有思想点。
小例子
cpp
// 不用虚函数、不用动态分配,靠模板在编译期"展开"
template <class F>
int sum_if(const std::vector<int>& v, F pred) {
int s = 0;
for (int x : v) if (pred(x)) s += x; // pred 内联,循环零间接调用
return s;
}同一个 sum_if,传 lambda 会被内联成裸循环——抽象没留下运行期痕迹。
一、零开销抽象:不为没用到的东西付费
一句话:只对你实际使用的特性付出成本,未使用的语言机制近似零负担。
展开:Bjarne Stroustrup 的原话大意是"你不用的东西,不该为它付钱;你用的东西,不该付得起更贵"。这带来两个具体后果:其一,语言里存在大量特性(异常、RTTI、虚函数、模板),但只要你不用,它们就不产生运行期开销;其二,标准库的选择必须允许零成本替代——比如 std::sort 能跑得比手写快排快,因为它内联了比较、复用了函数指针以外的优化。代价是:这些抽象可能让二进制变大(模板实例化、内联展开),空间换时间。
小例子
cpp
struct NoVtable { void f() { /* 编译期直接调用 */ } };
struct WithVtable { virtual void f(); }; // 有虚表指针,对象变大
// 不用多态时,不会为虚函数机制付出任何运行期成本
static_assert(sizeof(NoVtable) == 1); // 空类只占 1 字节演示"不使用虚函数就不承担虚表指针与间接调用"的成本。
二、值语义优先:成本可见、无隐藏共享
一句话:值就是值,拷贝/移动的代价写在类型上,不偷偷共享状态。
展开:C++ 默认让对象具备值语义——赋值就是复制一份独立的数据。好处是成本显式可见:看到拷贝就能推断开销,不存在"改 A 影响 B"的隐藏别名。要共享时,用 shared_ptr、引用或显式接口把共享写进类型,让读者一眼看到。这条原则的代价是:大对象拷贝昂贵,于是移动语义(见下一点)和按引用传参成为必要补偿。相比"一切皆引用"的语言,值语义把"要不要共享"的决定权交还给作者,而不是隐含在语言默认行为里。
小例子
cpp
struct Vec2 { double x = 0, y = 0; }; // 纯值类型
Vec2 a{1, 2};
Vec2 b = a; // 复制:b 与 a 完全独立
b.x = 99; // 不影响 a
// 想共享?把意图写进类型,而不是靠默认行为
auto sp = std::make_shared<Vec2>(a); // 明确表达"这里要共享"演示值类型默认独立复制、共享必须显式表达。
三、拷贝 vs 移动:move 不是免费,只是更便宜
一句话:移动把"深拷贝"降级为"指针搬运",但仍有固定开销,不是零成本。
展开:移动构造并不"免费"——它要做的是把源对象的资源指针转移过来、再把源置空,通常还有一次分支判断与若干赋值,O(1) 但常数非零。对小对象(如两个 double),移动和拷贝几乎一样,甚至拷贝因为 memcpy 更友好而更快;移动的真正价值在持有大块堆内存/句柄的类型(vector、string、unique_ptr)上,把 O(n) 拷贝降为 O(1)。另一层成本:移动后源对象处于"有效但未指定"状态,使用它需要额外约定;而 vector 扩容是否移动,还取决于移动是否 noexcept。
小例子
cpp
std::vector<int> make_big() {
std::vector<int> v(1'000'000, 7);
return v; // NRVO/移动:不拷贝 100 万个 int
}
std::vector<int> a = make_big();
std::vector<int> b = std::move(a); // O(1):只是偷走三个指针
// a 此刻"有效但未指定",不要再假设它为空演示移动把 O(n) 拷贝降为 O(1) 指针转移,以及源对象的失效约定。
四、编译期计算:把运行期负担搬到编译期
一句话:能用编译期算出来的,就别留到运行期;constexpr 让这件事自然表达。
展开:模板元编程与 constexpr 的核心动机是把可预测的计算前移。查表、常量折叠、类型分发、尺寸计算——这些不需要运行时输入的工作,全部在编译期完成,运行期只剩直接使用结果。代价是编译时间与二进制体积可能上升,且调试编译期代码更困难。C++11 的 constexpr 从"单表达式"扩展到 C++14 的"多语句函数"、C++20 的 consteval/constinit,让编译期计算从"元编程黑魔法"回归普通代码写法。
小例子
cpp
constexpr int fib(int n) { // C++14 起支持循环/局部变量
int a = 0, b = 1;
while (n-- > 0) { int t = a + b; a = b; b = t; }
return a;
}
constexpr int k = fib(20); // 编译期算好,运行期零成本
static_assert(k == 6765);
int arr[fib(8)]; // 结果可当编译期常量用演示同一函数既能编译期求值、也能运行期调用,无需两套代码。
五、约束与诊断:concepts 让错误"说人话"
一句话:用 concepts/requires 表达"模板要求什么",把玄学报错变成可读诊断。
展开:SFINAE 时代,模板对类型的要求藏在 enable_if 与替换失败的规则里,一旦不满足,编译器会抛出几百行"实例化回溯",用户得从噪音里倒推真正原因。concepts 把约束显式写进接口:参数"必须是可比较的""必须支持 +",被违反时编译器直接指出"哪个约束没满足、哪个类型不行"。这既改善诊断,也参与重载决议,让"约束更特化者优先"成为语言规则。代价是概念本身也需设计,写不好会限制复用。
小例子
cpp
#include <concepts>
template <class T>
concept Addable = requires(T a, T b) { a + b; }; // 显式声明要求
template <Addable T>
T sum3(T a, T b, T c) { return a + b + c; }
// sum3(std::string{"x"}, 1, 2); // 报错直接指出"约束 Addable 未满足"演示约束把"模板要求"写进签名,报错信息从回溯噪音变成一句话。
六、类型擦除的代价:统一接口换间接调用
一句话:std::function/any 用运行期间接调用与堆分配,换取"同一类型装下不同实现"。
展开:类型擦除的本质是把编译期已知的具体类型藏到一个统一接口后面。std::function 能装 lambda、函数指针、仿函数,是因为它内部持有一个小对象 + 一个调用表(vtable 式的间接层)。代价有三:调用变成间接跳转(不易内联),小对象可能触发堆分配(除非落在 SBO 缓冲内),且拷贝语义变复杂。它适合"接口边界、运行时回调、异构集合"等确实需要统一类型的场景;在热路径上,能用模板就不该用 std::function。
小例子
cpp
#include <functional>
// 统一类型:不同可调用体都能装进来
std::vector<std::function<int(int)>> handlers;
handlers.push_back([](int x) { return x + 1; });
handlers.push_back([](int x) { return x * x; });
// 代价:每次调用是间接调用,且可能绕过内联
int r = handlers[0](41);演示 std::function 用运行时间接换统一类型,适合边界而非热循环。
七、静态多态 vs 动态多态:编译期解析 vs 运行期分发
一句话:模板/CRTP 在编译期解析调用(零开销、不可晚绑定);虚函数在运行期分发(可扩展、有间接成本)。
展开:静态多态通过模板参数或 CRTP 把"具体类型"在编译期绑定,调用可内联、无虚表指针、无间接跳转,适合性能敏感且类型集合封闭的场景。动态多态通过 virtual 与虚表实现运行期分发,换来"新增类型不必改已有代码"的开放性,代价是每个对象多一个虚表指针、每次调用一次间接跳转、且难以内联。选择标准通常是:类型集合是否需要运行时扩展——要扩展用虚函数,追求极致性能且集合封闭则用模板/CRTP。
小例子
cpp
// 静态多态:CRTP,编译期解析,可内联
template <class D>
struct Shape {
double area() const { return static_cast<const D*>(this)->area_impl(); }
};
struct Circle : Shape<Circle> { double area_impl() const { return 3.14 * 1 * 1; } };
Circle c;
double a = c.area(); // 静态绑定,无虚表演示 CRTP 把多态分发提前到编译期,避免虚表开销。
八、内联与抽象惩罚:好抽象为何能跑得和手写一样快
一句话:抽象是否零开销,很大程度取决于编译器能否看穿并内联它。
展开:模板、lambda、constexpr、运算符重载之所以常有"零抽象惩罚",是因为它们在编译期完全展开/内联,生成与手写等价的机器码。"抽象惩罚"(abstraction penalty)指的是因抽象阻碍优化而产生的额外开销:跨翻译单元不内联、间接调用打断流水线、临时对象与拷贝、指针别名让编译器不敢重排。现代实践的对策是:把热路径的头文件内联、开启 LTO、避免不必要的间接层、用 const/restrict 减少别名不确定性。结论:抽象不是天然慢,未被优化的抽象才慢。
小例子
cpp
// 头文件中的小函数:内联后连调用开销都消失
inline int square(int x) { return x * x; }
int f(const std::vector<int>& v) {
int s = 0;
for (int x : v) s += square(x); // 展开后就是 mul + add
return s;
}演示可内联的抽象(含 const 引用参数)能被优化成裸循环。
九、可选特性可关:不用就真的不付钱
一句话:异常、RTTI 这类"带运行期机制"的特性可按需关闭,印证"零开销"不是口号。
展开:零开销抽象最硬的证据,是语言允许你把整套机制整体移除:-fno-exceptions 让代码里没有抛出与栈展开的元数据,-fno-rtti 让对象里不含类型信息。这在嵌入式、内核、实时系统里很常见——它们既要 C++ 的抽象表达力(模板、类、容器),又不愿为异常/RTTI 付出二进制体积与不确定性延迟。代价是:一旦关闭,你就失去了该特性的兜底与动态类型能力,错误处理必须改用错误码/expected,多态必须靠模板或自实现。换句话说,零开销的前提是"你清楚代价,并愿意为不需要的能力买断"。
小例子
cpp
// 编译期判断"当前构建是否启用异常",据此切换错误处理策略
#if defined(__cpp_exceptions)
#define TRY_OR_RETURN(expr, fallback) \
try { return (expr); } catch (...) { return (fallback); }
#else
#define TRY_OR_RETURN(expr, fallback) (expr) // 无异常:直接求值
#endif
int risky(int x) { return TRY_OR_RETURN(x * 2, -1); }演示同一份源码可在"有/无异常"两种构建下切换策略,特性可整体裁剪。
十、避免隐藏分配:小对象优化与显式内存资源
一句话:把"堆分配"从抽象的内脏里挤出来,是零开销在内存层面的落地。
展开:很多抽象之所以慢,不是逻辑慢,而是偷偷 new。std::string 的小字符串优化(SSO)、std::function 的小对象缓冲(SBO)、std::pmr 的 memory_resource,都是同一思路:把常见的小尺寸数据放在栈上/预分配缓冲里,躲开堆分配与随之而来的分配器竞争、缓存缺失。代价是类型变胖(缓冲占空间)、移动语义变复杂(有时"移动"其实是拷贝)。设计上应尽量把内存策略显式化——用 pmr 传入分配器、用 reserve 预留、用固定容量容器限制上界,让"是否分配、分配多少"成为可推理的工程参数。
小例子
cpp
#include <memory_resource>
#include <vector>
// 用栈上缓冲做单调分配,热点路径上零堆分配
std::pmr::monotonic_buffer_resource pool{4096};
std::pmr::vector<int> v{&pool};
v.reserve(256); // 显式预留,避免反复扩容
std::string s = "hi"; // 短字符串走 SSO,不碰堆演示通过 pmr 与 reserve 把分配行为显式化,减少隐藏堆分配。