Appearance
Modern C++ 核心惯用法(Idioms)
惯用法(idiom)是"老手约定俗成的写法",比设计模式更贴近日常代码。本页收集 12 个现代 C++ 里最有价值的惯用法,每个都给出"老写法 → 现代写法"的对照:让编译器替你干活、把约束写进类型、把错误提前到编译期。它们大多指向同一句总结:能默认就别手写,能不写就别写,能编译期就别运行期。
0. 总纲:惯用法的三条主线
一句话:Rule of Zero 少写代码、强类型把约束写进类型、constexpr/if constexpr 把决策前移到编译期。
展开:现代 C++ 的惯用法可以归到三条主线上。(1) 减少手写:Rule of 0/3/5、copy-and-swap、scope guard——把样板和容易写错的清理逻辑交给编译器或 RAII。(2) 把约束写进类型:newtype、enum class、move-only——让"非法状态不可表达",编译器帮你查错。(3) 把决策前移:tag dispatch、if constexpr、CRTP、policy、constexpr——凡是编译期能定的,就不要留到运行期。三条线共同服务于"更快、更安全、更好维护"。
小例子
cpp
struct Point { double x, y; }; // Rule of Zero:什么都不写,五个函数全默认
enum class Color { Red, Green }; // 类型安全:不能和 int 混用
// Color c = 1; // 编译错误
constexpr int bits = 8 * sizeof(int); // 编译期常量,无运行期开销演示同一段代码里三条主线各出现一次:默认生成、强类型、编译期求值。
1. Rule of Zero / 3 / 5
一句话:能靠成员默认就别声明特殊成员函数;要写就五个一起考虑。
老写法 → 现代写法
- 老:手写构造函数、析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值——只要有一个写错(比如漏了移动或忘了置空源对象)就埋下 bug。
- 现代:Rule of Zero——把资源交给
std::string、std::vector、std::unique_ptr等 RAII 成员管理,自己不声明任何特殊成员函数,编译器生成的默认版本全部正确。确实需要自定义所有权(裸句柄)时才谈 Rule of 3/5:要么五个都不写,要么五个都写全。
小例子
cpp
#include <string>
#include <vector>
class Buffer { // Rule of Zero:一个特殊成员函数都没写
std::string name;
std::vector<char> data; // 资源由成员 RAII 管理
public:
Buffer(std::string n, std::vector<char> d)
: name(std::move(n)), data(std::move(d)) {}
// 拷贝/移动/析构全部由编译器默认生成,且正确
};演示 Rule of Zero:只要成员自身会正确管理资源,外层类就无需手写任何特殊成员函数。
面试怎么问:什么是 Rule of Five?什么时候可以只写 Rule of Zero? → 五个特殊成员函数要写就一起写;当资源全部由 RAII 成员承载时可一个都不写(Rule of Zero)。
2. copy-and-swap
一句话:赋值运算符用"传值 + swap",一次拿到强异常保证 + 自动处理自赋值。
老写法 → 现代写法
- 老:
operator=里手动逐个成员赋值,还要特判自赋值if (this != &rhs);若中途抛异常,对象处于"改了一半"的破损状态。 - 现代:copy-and-swap——形参按值接收(拷贝在函数外完成,失败则不进入函数体),函数体内只做
swap;swap不抛异常,从而要么完整成功、要么原封不动。拷贝与移动赋值可复用同一份实现。
小例子
cpp
#include <utility>
class V {
int* p_;
size_t n_;
public:
V(const V& other) : p_(new int[other.n_]), n_(other.n_) { /* 拷贝 */ }
~V() { delete[] p_; }
V& operator=(V rhs) { // 注意:按值传参,拷贝在进入函数前完成
swap(rhs); // 不抛异常,因此本函数强异常安全
return *this;
}
void swap(V& o) noexcept { std::swap(p_, o.p_); std::swap(n_, o.n_); }
friend void swap(V& a, V& b) noexcept { a.swap(b); }
};演示 copy-and-swap:拷贝失败发生在参数构造阶段、不影响 *this;函数体只做不抛异常的 swap,故天然自赋值安全。
面试怎么问:为什么 copy-and-swap 能自动兼顾自赋值和强异常保证? → 拷贝在形参构造阶段完成(失败则对象未动),体内只有 swap 且 swap 不抛异常,故状态要么全新、要么原样。
3. Scope Guard(RAII 清理)
一句话:用 RAII 对象在作用域退出时自动执行清理,替代分散的 goto cleanup。
老写法 → 现代写法
- 老:C 风格的
goto cleanup集中释放,或手写多段if (err) { free(...); return; },退出路径一多就漏释放。 - 现代:写一个轻量 guard,构造时登记清理动作、析构时自动执行(可用
std::function或 lambda 模板参数);dismiss()表示"成功路径不清理"。这就是scope_exit/gsl::finally的形状,也和std::lock_guard、std::unique_ptr同源。
小例子
cpp
#include <utility>
template <class F>
class ScopeGuard {
F f_; bool active_ = true;
public:
explicit ScopeGuard(F f) : f_(std::move(f)) {}
~ScopeGuard() { if (active_) f_(); } // 离开作用域必然清理
void dismiss() noexcept { active_ = false; } // 成功时不清理
};
template <class F> ScopeGuard<F> make_guard(F f) { return ScopeGuard<F>(std::move(f)); }
// 用法:
// auto g = make_guard([&]{ unlock(); });
// ... 业务 ...
// g.dismiss(); // 顺利走完,无需解锁回收演示 scope guard:无论正常返回还是提前 return/抛异常,析构都会执行清理,杜绝"漏释放"。
面试怎么问:std::lock_guard 和 scope guard 是不是一回事? → 本质相同,都是 RAII 清理;scope guard 更通用,可登记任意清理动作,lock_guard 是其在锁上的特化。
4. Non-copyable / Move-only
一句话:不想被拷贝就 = delete;独占所有权就用 std::unique_ptr/move-only 语义表达。
老写法 → 现代写法
- 老:把拷贝构造声明为
private且不实现(C++03 惯用法),或干脆不管,任由调用方拷贝导致句柄被复制、双重释放。 - 现代:直接
= delete拷贝构造与拷贝赋值,语义一目了然;移动仍可保留(move-only),用std::unique_ptr成员天然表达"独占、可转移、不可复制"。
小例子
cpp
#include <memory>
#include <cstdio>
class FileHandle { // move-only:不可拷贝,可移动
std::unique_ptr<std::FILE, int(*)(std::FILE*)> fp_;
public:
FileHandle(const char* path)
: fp_(std::fopen(path, "r"), std::fclose) {}
// 不需要显式 delete:unique_ptr 成员已让拷贝函数被隐式删除
FileHandle(FileHandle&&) noexcept = default;
FileHandle& operator=(FileHandle&&) noexcept = default;
};演示用 unique_ptr 成员 + 默认 move 表达独占所有权:拷贝被自动禁止,所有权只能转移。
面试怎么问:= delete 拷贝和使用 private 拷贝构造有什么区别? → = delete 是编译期明确报错的"公共接口不存在",报错信息更清晰;旧 private 手法只把错误推给链接期/访问检查。
5. 强类型包装(Strong Typedef / newtype)
一句话:给相同底层类型起不同"类型名",让参数混淆在编译期就被拦住。
老写法 → 现代写法
- 老:
void transfer(int from, int to, int amount)——三个int全靠命名约定,调用transfer(b, a, x)把 from/to 写反也照样编译。 - 现代:用 newtype——
struct UserId { int v; };(或enum class),不同语义的类型不可隐式互换;UserId与AccountId即使底层都是int也不能混用。
小例子
cpp
struct UserId { int value; };
struct OrdId { long value; };
void transfer(UserId from, OrdId to, long amount);
// 调用点:transfer(OrdId{2}, UserId{1}, 100); // 编译错误:参数类型对不上
// 想要"零成本"?空基类/强类型都是纯值,无额外存储
static_assert(sizeof(UserId) == sizeof(int));演示强类型包装:语义不同的 id 不再是同一个类型,参数顺序写错、单位搞混都会在编译期暴露。
面试怎么问:如何防止两个都为 int 的参数被调用方传反? → 用强类型包装(newtype / enum class)区分语义,编译期阻止跨类型赋值与传参。
6. enum class 与三路比较 operator<=>(C++20)
一句话:用 enum class 得到作用域化、强类型枚举;用 <=> 一次生成全部比较运算。
老写法 → 现代写法
- 老:裸
enum会隐式转换成int、枚举名污染外层作用域;类里为支持排序要手写operator<、operator==等一大串。 - 现代:
enum class不隐式转整型、名字带作用域;类只需一个operator<=>(返回std::strong_ordering等),编译器据此自动合成==、!=、<、<=、>、>=。
小例子
cpp
#include <compare>
#include <string>
enum class Level { Low, Mid, High }; // 强类型,不可与 int 混用
struct Ver {
int major, minor;
auto operator<=>(const Ver&) const = default; // 逐成员字典序比较
};
Ver a{1, 9}, b{1, 10}, c{2, 0};
// a < b、b < c 全部自动可用,无需再写任何比较运算符
static_assert(std::is_eq(a <=> a));演示 enum class 的强类型安全,以及 <=> 一次声明换来全套比较运算符,且默认按成员字典序。
面试怎么问:C++20 的 operator<=> 解决了什么重复劳动? → 一个默认化的三路比较即可推导出全部六个关系运算符,省去手写 ==/</... 且保证语义一致。
7. 标签分发与 if constexpr(C++17)
一句话:用编译期分支选择实现,替代重载爆炸与 SFINAE 黑魔法。
老写法 → 现代写法
- 老:为每种"类型类别"写一个重载(迭代器 tag 分发),或堆
std::enable_if/ SFINAE 让某个重载只在条件成立时可见;模板里用if判断属性会导致两个分支都被实例化,编译不过。 - 现代:
if constexpr——条件为假的分支被丢弃、不实例化,于是可以在同一函数里按类型属性分派;比 SFINAE 直观,报错也更人性化。
小例子
cpp
#include <type_traits>
#include <string>
template <class T>
std::string describe(const T& v) {
if constexpr (std::is_integral_v<T>) {
return "int:" + std::to_string(v); // 仅整数才实例化此分支
} else if constexpr (std::is_floating_point_v<T>) {
return "float:" + std::to_string(v);
} else {
static_assert(std::is_same_v<T, std::string>, "不支持的类型");
return "str:" + v;
}
}演示 if constexpr:按类型属性在编译期选路,未选中的分支不参与实例化,因此不会因类型不支持而报错。
面试怎么问:if constexpr 和普通 if 在模板里有什么本质区别? → 普通 if 两个分支都会被实例化(类型不支持就编译失败);if constexpr 编译期求值,假分支被丢弃、不实例化。
8. CRTP
一句话:派生类把自身类型传给基类模板,实现编译期"静态多态",零虚表开销。
老写法 → 现代写法
- 老:需要"基类调派生类实现"就用虚函数,付出 vtable 与间接调用成本,且难以内联。
- 现代:CRTP——
class Foo : public Base<Foo>,基类里static_cast<Derived*>(this)后调用,编译期即确定具体类型,可内联、零开销。常用于静态接口、mixin(如给类注入计数/比较运算符)。
小例子
cpp
template <class Derived>
struct Counted { // mixin:统计创建了多少实例
Counted() { ++alive_; }
~Counted() { --alive_; }
static int alive() { return alive_; }
private:
inline static int alive_ = 0;
};
struct Widget : Counted<Widget> { /* Widget 专属内容 */ };
// Widget::alive() 即可读取当前存活的 Widget 数量,无运行期开销演示 CRTP 作为 mixin:基类模板通过 Counted<Widget> 回指派生类型,注入计数能力而不引入虚函数。
回链:CRTP 与静态多态、虚函数表
面试怎么问:CRTP 相比虚函数解决了什么问题? → 静态多态:编译期确定调用目标、可内联、无 vtable;代价是类型必须编译期已知,不能像虚函数那样在运行期装不同派生对象。
9. Policy-based Design
一句话:把可变的行为作为模板参数传入,像搭积木一样组合出零开销的定制版本。
老写法 → 现代写法
- 老:一个大类带一堆
bool开关(use_cache、checks、threadsafe),运行期if判断,用不到的功能照样占空间占分支。 - 现代:policy-based design——把每个可变维度抽成模板参数,默认给合理策略;按需组合。没用到的策略不产生任何运行期成本,配置全部落在编译期。
小例子
cpp
#include <vector>
#include <mutex>
struct SingleThreaded { void lock() {} void unlock() {} }; // 空锁:零开销
struct MultiThreaded {
std::mutex m_; void lock() { m_.lock(); } void unlock() { m_.unlock(); }
};
template <class Threading>
class Registry : Threading { // 用继承引入策略(空基类优化)
public:
void add(int x) { lock(); data_.push_back(x); unlock(); }
private:
std::vector<int> data_;
};
Registry<SingleThreaded> fast; // 单线程版本:无锁开销
Registry<MultiThreaded> safe; // 多线程版本:带锁演示 policy-based design:线程策略作为模板参数,单线程实例完全不付出锁的代价,两种配置共存于同一份代码。
面试怎么问:policy-based design 和策略模式区别在哪? → 前者把策略做成模板参数,编译期绑定、零开销、可多策略组合;后者运行期多态、可热插拔但有间接开销。
10. 类型擦除(Type Erasure)
一句话:对外统一接口、对内藏起具体类型;std::function/std::any 是现成方案,必要时手写给小对象优化。
老写法 → 现代写法
- 老:要么暴露模板导致接口复杂、要么强制继承基类(侵入式),想在容器里放"不同但可调用"的东西很别扭。
- 现代:类型擦除——内部用多态/虚表把具体类型藏起来,外部只见统一接口。
std::function(任意可调用体)、std::any(任意值)开箱即用;对性能敏感时手写并加小对象优化(SBO),小对象存栈上、避免堆分配。
小例子
cpp
#include <functional>
#include <vector>
// 统一存放:函数指针、lambda、绑定了状态的闭包,都擦除成同一类型
std::vector<std::function<int(int)>> pipeline;
pipeline.push_back([](int x) { return x + 1; });
int base = 10;
pipeline.push_back([base](int x) { return x * base; }); // 带捕获状态
int v = 7;
for (auto& f : pipeline) v = f(v); // 逐个应用,调用方无需知道具体类型演示类型擦除:不同签名的可调用体被统一进 std::function,容器里可混放、按统一接口调用。
面试怎么问:std::function 的代价是什么?为什么它有时会堆分配? → 类型擦除带来一次间接调用;被存对象超过 SBO 缓冲大小时会堆分配,故热路径上要留意。
11. 完美转发 / 通用引用
一句话:T&& 配合 std::forward 原样传递值类别,避免多一次拷贝。
老写法 → 现代写法
- 老:转发函数按值或按
const T&收参再传下去,丢掉右值属性,导致下游被迫拷贝;或用重载对左值/右值各写一版,代码翻倍。 - 现代:模板参数用通用引用
T&&,配合std::forward<T>(x)——左值仍以左值传、右值仍以右值传,值类别被完整保留,工厂/包装器可写出"零额外拷贝"的转发。
小例子
cpp
#include <memory>
#include <utility>
#include <string>
template <class T, class... Args>
T make(Args&&... args) { // 通用引用 + 参数包
return T(std::forward<Args>(args)...); // 原样转发值类别
}
std::string s = "hello";
auto p1 = make<std::unique_ptr<std::string>>(std::make_unique<std::string>(s)); // 拷贝
auto str = make<std::string>(std::move(s)); // 移动,不拷贝演示完美转发:std::forward 让左值/右值的值类别一路保留到目标构造函数,从而该拷贝就拷贝、该移动就移动。
面试怎么问:T&& 什么时候是右值引用、什么时候是通用引用? → 出现在模板参数 T&& 且 T 需推导时是通用引用(可绑左值也可绑右值);否则是普通右值引用。
12. constexpr 一切可编译期化的
一句话:能把计算挪到编译期就别留到运行期;C++20 起用 consteval 强制编译期求值。
老写法 → 现代写法
- 老:用宏或运行期函数算常量(如
#define SIZE 1024,或运行期循环算表),宏无类型检查、运行期算则白白耗时。 - 现代:
constexpr函数可以在编译期求值(同一份代码也可运行期用);constexpr变量保证编译期算好;consteval(C++20)要求的函数只能编译期调用,consteval保证不会退化为运行期。
小例子
cpp
#include <array>
constexpr int factorial(int n) { // 同一函数:编译期/运行期都能用
int r = 1;
for (int i = 1; i <= n; ++i) r *= i;
return r;
}
constexpr int f6 = factorial(6); // 编译期算出 720,零运行期开销
static_assert(f6 == 720);
// C++20:强制编译期
consteval int square(int x) { return x * x; }
constexpr int s9 = square(9); // 编译期 81;square(rand()) 会编译失败演示 constexpr/consteval 把计算前移到编译期:结果直接内联为常量,运行期不再计算,也拿不到"退化"的机会。
面试怎么问:constexpr 和 consteval 的区别? → constexpr 允许编译期或运行期求值(视上下文);consteval 强制只在编译期求值,非编译期调用会直接编译失败。
相关
设计思想总览:六大思想簇统一串起本页惯用法正确性前置:强类型、enum class背后的指导思想抽象不能有代价:CRTP、policy、类型擦除的成本对照行为型模式(Modern C++ 写法):策略/访问者/命令等模式如何复用这些惯用法CRTP 与静态多态:静态多态与 mixin 的完整展开constexpr 元编程:编译期计算与consteval的更多细节