Skip to content

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:只要成员自身会正确管理资源,外层类就无需手写任何特殊成员函数。

回链:生命周期与所有权、RAII

面试怎么问:什么是 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,故天然自赋值安全。

回链:失败要原子、RAII

面试怎么问:为什么 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/抛异常,析构都会执行清理,杜绝"漏释放"。

回链:RAII、失败要原子

面试怎么问: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:按类型属性在编译期选路,未选中的分支不参与实例化,因此不会因类型不支持而报错。

回链:SFINAE、Concepts

面试怎么问: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:线程策略作为模板参数,单线程实例完全不付出锁的代价,两种配置共存于同一份代码。

回链:抽象不能有代价、Concepts

面试怎么问: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 元编程、抽象不能有代价

面试怎么问:constexpr 和 consteval 的区别? → constexpr 允许编译期或运行期求值(视上下文);consteval 强制只在编译期求值,非编译期调用会直接编译失败。

相关 ​

糌粑的面经笔记 · VitePress 版