Skip to content

C++17/20 日常利器(替代手写样板) ​

本页聚焦一件事:把过去靠手写、靠约定、靠注释完成的样板代码,换成语言与标准库直接提供的表达。每一条都给出"以前怎么写、现在怎么写、为什么更好"的对照,覆盖结构化绑定、CTAD、optional/variant、视图类型、约束与概念、指定初始化、三路比较、ranges、编译期求值以及协程与模块。目标是更少代码、更少 bug、更友好的报错。

1. 结构化绑定:一次解包多个成员 ​

一句话:auto [k, v] : map 直接命名 map 的键值,替掉啰嗦的 .first/.second。

老写法 → 现代写法

  • 老:for (auto& it : m) { use(it.first, it.second); },或先 auto p = ...; p.first。
  • 现代:for (auto& [k, v] : m) { use(k, v); },还能绑定 std::pair、std::tuple、聚合体与数组,可读性与重构安全性同时提升。
小例子
cpp
#include <map>
#include <string>
#include <iostream>

int main() {
    std::map<std::string, int> age{{"amy", 30}, {"bob", 25}};

    // 老写法:靠 first/second 记忆语义,字段一改到处错
    for (const auto& kv : age)
        std::cout << kv.first << '=' << kv.second << '\n';

    // 现代写法:变量名即文档
    for (const auto& [name, a] : age)
        std::cout << name << '=' << a << '\n';
}

演示结构化绑定让键值的含义直接落在变量名上,不必再记 .first 是谁。

回链:pair 与 tuple、map

面试怎么问:结构化绑定能绑定到引用从而修改原对象吗? → 可以,用 auto& [k, v];绑定的是原对象的别名,不是拷贝。

2. CTAD:类模板实参推导,免写模板参数 ​

一句话:构造函数能推出模板参数,就别再手写 std::pair<int, double> 这类冗余参数。

老写法 → 现代写法

  • 老:std::pair<int, double> p(1, 2.0); std::lock_guard<std::mutex> lk(m);
  • 现代:std::pair p{1, 2.0}; std::lock_guard lk{m};,类型由构造实参推导,改名或改类型时改动面更小;注意 {} 初始化列表与 CTAD 的配合。
小例子
cpp
#include <mutex>
#include <utility>
#include <vector>

std::mutex m;

int main() {
    std::pair p{1, 2.0};          // 推导为 pair<int, double>
    std::vector v{1, 2, 3};       // 推导为 vector<int>
    std::lock_guard lk{m};        // 推导为 lock_guard<std::mutex>
    (void)p; (void)v;
}

演示 CTAD 省去显式模板实参,同时保持类型安全(仍由编译器推导,不是万能类型)。

回链:模板推导

面试怎么问:CTAD 会带来什么坑? → 可能推导出意料之外的类型(如 vector 推导为 vector<int> 而非 initializer_list),且聚合体 CTAD 需 C++20 支持。

3. std::optional:值语义的"可能为空" ​

一句话:用 optional 表达"有值 / 无值",替掉魔数、bool 输出参数与错误码。

老写法 → 现代写法

  • 老:返回 -1 表示失败、用 bool find(int, int& out) 输出参数、或约定空指针即无值。
  • 现代:std::optional<T> find(...) 返回值语义明确,调用方必须显式处理"无值",不引入额外哨兵值。
小例子
cpp
#include <optional>
#include <string>
#include <cstdlib>

std::optional<int> to_int(const std::string& s) {
    try { return std::stoi(s); }
    catch (...) { return std::nullopt; }   // 显式表达"没有值"
}

int main() {
    if (auto v = to_int("42")) use(*v);    // 有值才解引用
    auto u = to_int("x").value_or(0);      // 默认值兜底
    (void)u;
}

演示 optional 把"可能失败"写进返回类型,调用方无法忽略该分支。

回链:std::optional

面试怎么问:optional 和指针表示"可空"有何区别? → optional 是值语义、无堆分配、不涉及所有权,表达的是"就地可选值"而非"间接引用"。

4. std::variant + std::visit:类型安全的联合体 ​

一句话:variant 是记住"当前是哪种类型"的联合体,visit 强制穷尽处理每个分支。

老写法 → 现代写法

  • 老:用 union 加手工标签位,或为几种形态硬造一个继承层次再 dynamic_cast。
  • 现代:std::variant<A, B, C> + std::visit,类型安全、不怕忘记分支,也免去虚函数开销。
小例子
cpp
#include <variant>
#include <string>
#include <iostream>

struct Circle { double r; };
struct Rect   { double w, h; };

int main() {
    std::variant<Circle, Rect> shape = Rect{2, 3};

    double area = std::visit([](auto s) -> double {
        if constexpr (std::is_same_v<decltype(s), Circle>)
            return 3.14159 * s.r * s.r;
        else
            return s.w * s.h;
    }, shape);

    std::cout << area << '\n';
}

演示 variant 携带当前类型,visit 配合 if constexpr 对每个形态分派处理。

回链:多态

面试怎么问:variant 相比虚函数/继承的取舍? → variant 是封闭集合、值语义、无动态分配;新增类型需改所有 visit,而继承方案是开放的、但需堆分配与间接调用。

5. string_view / span:非拥有视图,避免拷贝 ​

一句话:只读不改的"看一段字符串 / 一段元素",用视图传参,省掉临时 std::string 与整段拷贝。

老写法 → 现代写法

  • 老:void f(const std::string& s),传字面量或 char* 时被迫构造临时 std::string;数组传参被迫退化为指针丢长度。
  • 现代:void f(std::string_view s)、void g(std::span<const T> xs),零拷贝、带长度、可接受多种来源。
小例子
cpp
#include <string_view>
#include <iostream>
#include <string>

std::string_view bad() {
    std::string tmp = "temporary";
    return tmp;                  // 悬垂!返回的是已销毁对象的视图
}

int main() {
    std::string_view sv = "hello world";
    std::cout << sv.substr(0, 5) << '\n';   // 零拷贝切片
    // auto x = bad();           // 使用即 UB:指向已析构的 string
}

演示视图零拷贝的好处,同时点明它不持有数据、必须警惕悬垂。

回链:std::string、vector

面试怎么问:string_view 最常见的 bug? → 指向临时对象或已释放缓冲,函数返回 string_view 指向局部 std::string 即悬垂。

6. [[nodiscard]] / explicit / =default/=delete:把意图写进类型 ​

一句话:返回值该不该忽略、构造该不该隐式、函数该不该可用,都用声明明确表达,交给编译器把关。

老写法 → 现代写法

  • 老:靠注释"请检查返回值"、靠单参构造的隐式转换、靠不写拷贝函数来"禁止"拷贝。
  • 现代:[[nodiscard]] 警告忽略返回值,explicit 禁止意外隐式转换,=default/=delete 精确控制特殊成员。
小例子
cpp
#include <mutex>

class File {
public:
    explicit File(const char* path);          // 禁止 const char* 隐式转 File
    File(const File&) = delete;               // 明确禁止拷贝
    File(File&&) = default;                   // 移动保持默认
};

[[nodiscard]] int must_check();

int main() {
    // must_check();        // 警告:忽略了 nodiscard 返回值
    // File f = "a.txt";    // 编译错误:explicit 阻止隐式转换
}

演示三条声明把"必须检查、不许隐式、不许拷贝"的意图变成编译期规则。

回链:把正确性前置

面试怎么问:=default 和手写空实现有何区别? → =default 保留编译器生成的语义(如成员逐一拷贝、平凡性),手写空函数会改变语义甚至破坏平凡性。

7. if constexpr + concepts / requires:编译期分支与约束 ​

一句话:用 if constexpr 在编译期选分支,用 concepts 约束模板参数,替掉难懂的 SFINAE 与 enable_if。

老写法 → 现代写法

  • 老:std::enable_if + std::is_integral 堆在返回类型上,报错晦涩、代码难读。
  • 现代:template <std::integral T> void f(T); 或 requires 子句,约束即文档,错误信息直指不满足的条件。
小例子
cpp
#include <concepts>
#include <type_traits>

template <std::integral T>
T twice(T x) { return x + x; }              // 只接受整型

template <typename T>
auto size_of(const T& x) {
    if constexpr (requires { x.size(); })
        return x.size();                    // 有 size() 就用
    else
        return 0;                           // 否则退回
}

int main() {
    twice(3);
    // twice(3.5);        // 编译错误:double 不满足 integral
}

演示 concepts 做入口约束、if constexpr 做分支,不再需要 enable_if 体操。

回链:concepts、SFINAE

面试怎么问:if constexpr 与普通 if 的关键区别? → 前者在编译期丢弃不成立的分支,被丢弃分支即使实例化会出错也不参与编译;后者两个分支都必须合法。

8. 指定初始化器:Point p{.x = 1, .y = 2} ​

一句话:C++20 允许按成员名初始化聚合体,省掉 Builder 与全参构造,可读又抗字段顺序变更。

老写法 → 现代写法

  • 老:为可选字段写多个构造函数、或引入 Builder 模式、或全参构造后靠注释说明参数含义。
  • 现代:聚合体用 Point p{.x = 1, .y = 2};,按名字赋值,顺序错、漏项、类型不符都会报错。
小例子
cpp
struct Point { double x = 0, y = 0; };
struct Config { int port = 80; bool tls = false; };

int main() {
    Point p{.x = 1.0, .y = 2.0};       // 指名道姓
    Config c{.tls = true};             // 其余字段走默认值
    (void)p; (void)c;
}

// 对比老写法:Config c(true); 读者根本不知道 true 是什么

演示指定初始化把"哪个参数是什么"写在调用点,也允许只指定关心的字段。

回链:关键字与初始化

面试怎么问:指定初始化器有什么限制? → 只能用于聚合类型,成员必须按声明顺序给出、不能重复、不能与位置初始化混用。

9. operator<=>:三路比较,一次生成全部比较 ​

一句话:实现 operator<=> 后,==、!=、<、> 等比较运算符由编译器按需生成。

老写法 → 现代写法

  • 老:为每个类型手写 6 个比较运算符,字段一加就得全部同步,容易写错。
  • 现代:默认 auto operator<=>(const T&) const = default;,按成员字典序比较,== 也随之自动生成。
小例子
cpp
#include <compare>
#include <string>

struct Version {
    int major, minor, patch;
    auto operator<=>(const Version&) const = default;   // 一步生成全部比较
};

int main() {
    Version a{1, 2, 0}, b{1, 3, 0};
    bool lt = a < b;        // 由 <=> 推导
    bool eq = a == b;       // 自动生成
    (void)lt; (void)eq;
}

演示一个默认三路比较即可获得完整的排序与相等语义,成员顺序即比较优先级。

回链:pair 与 tuple

面试怎么问:<=> 默认生成有什么前提? → 所有成员都支持三路比较,且默认按声明顺序做字典序比较;自定义语义需手写。

10. std::erase_if 与 ranges 算法:erase-remove 一行化 ​

一句话:删除满足条件的元素,std::erase/std::erase_if 一行搞定,ranges 算法则让整条链路可读。

老写法 → 现代写法

  • 老:v.erase(std::remove_if(v.begin(), v.end(), pred), v.end());,容易漏掉 erase 或写错 end()。
  • 现代:std::erase_if(v, pred);;链式处理则用 std::ranges:: 系列,范围作整体参数、可组合视图。
小例子
cpp
#include <vector>
#include <algorithm>
#include <ranges>

int main() {
    std::vector<int> v{1, 2, 3, 4, 5, 6};

    // 老写法:remove-erase 惯用法,漏一步就错
    v.erase(std::remove_if(v.begin(), v.end(),
                           [](int x) { return x % 2 == 0; }), v.end());

    // 现代写法:一行表达意图
    std::erase_if(v, [](int x) { return x < 3; });

    auto evens = v | std::views::filter([](int x) { return x % 2 == 0; });
    for (int x : evens) (void)x;    // 惰性、零拷贝地遍历
}

演示 erase_if 消除样板,ranges/views 则把过滤逻辑做成可组合的惰性管道。

回链:ranges 与视图

面试怎么问:为什么必须有 erase 而不只是 remove? → remove 只重排元素并把"新末尾"返回,并不改变容器大小,必须再 erase 才能真删。

11. constexpr / consteval / if consteval:能编译期算就别运行期算 ​

一句话:把可在编译期确定的值与逻辑前移,运行期只留必要计算,换来零运行期成本与编译期校验。

老写法 → 现代写法

  • 老:运行期算表、宏代替常量、或写模板元编程硬凑编译期结果。
  • 现代:constexpr 函数可用于两个世界,consteval 强制编译期求值,if consteval 区分"是否处于常量求值"。
小例子
cpp
constexpr int fib(int n) {
    return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

consteval int must_compile_time(int x) { return x * 2; }

constexpr int table[5] = {fib(0), fib(1), fib(2), fib(3), fib(4)};

int main() {
    constexpr int v = must_compile_time(21);   // 编译期确定
    static_assert(v == 42);
    return table[3];
}

演示 constexpr 把计算搬进编译期,consteval 则强制任何调用都必须常量求值。

回链:constexpr 元编程

面试怎么问:constexpr 和 consteval 的区别? → constexpr 允许编译期或运行期求值;consteval(C++20)要求必须编译期求值,否则报错。

12. 协程与 modules:C++20 的前沿能力 ​

一句话:协程把异步/生成器写成直线代码,模块取代头文件、缩短编译时间并改善封装。

老写法 → 现代写法

  • 老:异步靠回调地狱或手写状态机;接口靠 #include 头文件,宏与文本包含带来编译慢与泄漏。
  • 现代:co_await/co_yield 表达挂起与恢复;import 模块显式导出符号,隔离宏、加速编译。
小例子
cpp
// 生成器:用协程写出惰性序列(示意)
#include <coroutine>

template <typename T>
struct Generator;   // 略去实现细节

Generator<int> iota(int n) {
    for (int i = 0; i < n; ++i)
        co_yield i;                 // 挂起并产出一个值
    co_return;
}
// 调用端按普通迭代遍历,无需显式状态机

演示协程把"产出多个值"的过程写成顺序代码,挂起/恢复由编译器生成的骨架处理。模块侧则用 export module 与 import 替代包含。

回链:协程、编译与链接

面试怎么问:C++ 协程是无栈还是有栈? → C++20 协程是无栈协程,挂起点由编译器生成的协程帧保存状态,不需要独立栈。

相关 ​

糌粑的面经笔记 · VitePress 版