Appearance
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 把"可能失败"写进返回类型,调用方无法忽略该分支。
面试怎么问: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
}演示视图零拷贝的好处,同时点明它不持有数据、必须警惕悬垂。
面试怎么问: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 体操。
面试怎么问: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 和 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 协程是无栈协程,挂起点由编译器生成的协程帧保存状态,不需要独立栈。
相关
C++11 核心特性:现代 C++ 的起点,与本页对照理解"从哪来"。lambda 与 auto:闭包与类型推导,是visit、ranges、consteval的常见搭档。ranges 与视图:本页第 10 条的展开。views 与 format:格式化输出与视图组合的进一步用法。std::optional:本页第 3 条的深入讨论。协程:本页第 12 条在并发场景的落地。