08 · 现代 C++ 新特性
定位:从 C++11 到 C++20 的特性是“区分新旧时代程序员”的分水岭,面试几乎必考:右值引用/移动语义/完美转发是重灾区(面试官最喜欢连环追问),lambda、auto、constexpr 是高频题,C++17/20 新特性考“至少说 3 个”。 学完标准:能手写完美转发代码;能解释“为什么 std::move 不移动任何东西”;能讲清左值/右值/将亡值;能说出 C++14/17/20 各至少 3 个新特性。
1. 左值、右值、将亡值(值类别,最底层地基)
Section titled “1. 左值、右值、将亡值(值类别,最底层地基)”1.1 一句话分类
Section titled “1.1 一句话分类”| 值类别 | 定义 | 例子 |
|---|---|---|
| 左值 lvalue | 有名字、可以取地址、可以持久存在 | 变量名、数组名、*p、字符串字面量 |
| 纯右值 prvalue | 临时值,无名字,用完即弃 | 3、a+b、'a'、lambda 表达式 |
| 将亡值 xvalue | 右值但“可以被偷走资源” | std::move(x) 的结果、static_cast<T&&>(x) |
| 广义右值 rvalue | 纯右值 + 将亡值 | 一切“临时的、可被移动的”东西 |
判断口诀:通常“有名字、能取地址 = 左值;临时无名 = 右值”(例外:字符串字面量是无名字的左值,位域是左值但不能取地址)。
int x = 3; // x 左值,3 纯右值int&& r = std::move(x); // r 左值(它有名字!),move(x) 是将亡值重要陷阱:
int&& r的 r 本身是左值(有名字、能取地址)。“右值引用”是引用的类型,不是被引对象的值类别。
1.2 为什么要有右值引用?
Section titled “1.2 为什么要有右值引用?”旧 C++(C++03):临时对象只能被拷贝(深拷贝,复制所有数据)。如果临时对象持有堆内存(比如 string、vector),拷贝就是白白地“复制一份再销毁”。
std::vector<int> make_vec(); // 返回一个临时对象std::vector<int> v = make_vec(); // C++03:拷贝临时对象(深拷贝!浪费)C++11 之后:临时对象是右值,可以移动——把它的堆指针“偷”过来,把原对象置空。开销从 O(n) 降到 O(1)。
1.3 左值引用 vs 右值引用 vs 转发引用
Section titled “1.3 左值引用 vs 右值引用 vs 转发引用”void f(int&); // 只能绑左值void g(int&&); // 只能绑右值void h(int); // 值传递,左右都行(拷贝)
template <typename T>void t(T&& x); // 转发引用(万能引用):T 推导后可以绑左值也可以绑右值,见第 3 节2. 移动语义与 std::move
Section titled “2. 移动语义与 std::move”2.1 移动构造函数长什么样
Section titled “2.1 移动构造函数长什么样”class String { char* data_; size_t size_;public: // 移动构造:偷资源,不深拷贝 String(String&& other) noexcept : data_(other.data_), size_(other.size_) { other.data_ = nullptr; // 关键:把源对象置空,防止析构时 double free other.size_ = 0; } ~String() { delete[] data_; }};移动后的源对象:处于”有效但未指定“(valid but unspecified)状态——可以析构、可以赋值,但不能依赖它的内容。
2.2 std::move 的本质(必考)
Section titled “2.2 std::move 的本质(必考)”// std::move 的实现(简化):template <typename T>constexpr std::remove_reference_t<T>&& move(T&& t) noexcept { return static_cast<std::remove_reference_t<T>&&>(t);}std::move 不移动任何东西,它只是一个类型转换:把参数强转成右值引用,让编译器“允许”调用移动构造/移动赋值。真正搬资源的是移动构造函数。
std::string a = "hello";std::string b = std::move(a); // move 只是把 a 转成右值 → 触发移动构造 → a 的数据被偷走2.3 什么时候会触发移动?
Section titled “2.3 什么时候会触发移动?”- 函数返回局部对象(返回值优化 RVO / 移动,现代编译器一般直接 RVO 掉拷贝)
std::move(x)作为参数- 容器操作:
emplace_back、push_back(std::move(x)) - 函数按值接收右值参数
2.4 移动语义的前提:noexcept(容易漏的知识点)
Section titled “2.4 移动语义的前提:noexcept(容易漏的知识点)”class String {public: String(String&& other) noexcept { ... } // 应尽量标 noexcept!};
std::vector<String> v;v.push_back(String("a")); // vector 扩容时,元素"移动"还是"拷贝"?vector 扩容时的关键规则:vector 使用
move_if_noexcept——若移动构造不是 noexcept 且有可用的拷贝构造,会退化为拷贝(保证强异常安全);若类不可拷贝,仍会移动。所以移动构造/移动赋值应尽量标 noexcept,否则可能你以为在移动,实际在拷贝!
3. 完美转发(Perfect Forwarding)
Section titled “3. 完美转发(Perfect Forwarding)”3.1 问题:参数在函数间传递时“左/右值性”丢失
Section titled “3.1 问题:参数在函数间传递时“左/右值性”丢失”template <typename T>void wrapper(T arg) { // 值传递:无论传左值右值都变成"拷贝" process(arg); // 到 process 手里一定是个左值(右值性丢了)}
// 需求:wrapper 接到什么,process 就看到什么——左值还是左值,右值还是右值(还能触发移动)3.2 引用折叠 + 转发引用
Section titled “3.2 引用折叠 + 转发引用”// 引用折叠规则(记住这两条就够):// T& + && → T& (左值引用遇到右值引用 → 折叠成左值引用)// T&& + && → T&& (右值引用遇到右值引用 → 折叠成右值引用)template <typename T>void wrapper(T&& arg) { // T&& 是转发引用 process(std::forward<T>(arg)); // 完美转发:保持 arg 的原始值类别}- 传左值
x→ T 推导为int&→T&&折叠为int&→ arg 是左值引用 - 传右值
3→ T 推导为int→T&&就是int&&→ arg 是右值引用
3.3 std::forward 与 std::move 的区别(必考)
Section titled “3.3 std::forward 与 std::move 的区别(必考)”// std::forward<T>(arg):当 T 是左值引用时 → 转成左值引用;T 是右值引用时 → 转成右值引用// 一句话:forward = 有条件的 move,只有当 T 是右值时才 move
// 错误示范(面试常考):template <typename T>void bad_wrapper(T&& arg) { process(std::move(arg)); // ❌ 传左值时也被 move 了!左值被偷资源,调用者遭殃}| std::move | std::forward | |
|---|---|---|
| 作用 | 无条件转成右值引用 | 有条件(T 为右值时)转成右值引用 |
| 场景 | 明确要转移资源 | 泛型转发,保持原值类别 |
| 口诀 | “不管三七二十一变成右值” | “原来是什么样,转出去还是什么样” |
4. auto 与 decltype
Section titled “4. auto 与 decltype”4.1 auto:靠初始化表达式推导,会“丢”引用和 const
Section titled “4.1 auto:靠初始化表达式推导,会“丢”引用和 const”int x = 5;const int cx = x;auto a = cx; // int(const 被丢掉!值拷贝)auto& ar = cx; // const int&(加 & 才保留引用和 const)auto b = std::move(x); // int(按值)auto&& c = x; // int&(引用折叠:左值 → int&)auto&& d = 3; // int&&(右值 → int&&)口诀:auto 默认按值推导,丢掉 const/引用/数组维度;想要引用用
auto&/auto&&。
4.2 decltype:原样保留类型(含 const 和引用)
Section titled “4.2 decltype:原样保留类型(含 const 和引用)”int x = 5;const int& r = x;decltype(x) a; // intdecltype(r) b; // const int&(原样保留!)decltype(x + 1) c; // int(表达式 → 右值的结果类型)decltype((x)) d; // int& —— 注意:(x) 是表达式,作为左值 → 推导出引用!auto vs decltype 对比:
| auto | decltype | |
|---|---|---|
| 推导依据 | 初始化表达式 | 表达式本身 |
| 保留引用/const | 默认丢 | 原样保留 |
| 典型用途 | 局部变量、遍历 | 声明依赖表达式类型的变量、返回类型 |
| 组合 | decltype(auto)(C++14):像 auto 一样用,但按 decltype 规则推导 |
template <typename T, typename U>auto add(T a, U b) -> decltype(a + b) { return a + b; } // C++11 尾置返回类型
template <typename T, typename U>decltype(auto) add2(T a, U b) { return a + b; } // C++14 更简洁,保留引用性5. lambda 表达式(重点)
Section titled “5. lambda 表达式(重点)”5.1 语法与捕获
Section titled “5.1 语法与捕获”[capture](params) -> return_type { body };// | | | |// 捕获 参数 返回类型(可省) 函数体int base = 10;auto f1 = [base](int x) { return x + base; }; // 值捕获:复制一份 baseauto f2 = [&base](int x) { return x + base; }; // 引用捕获:用 base 本身auto f3 = [=](int x) { return x + base; }; // 全部值捕获(注意:C++20 弃用的是 [=] 隐式捕获 this,应写 [=, this])auto f4 = [&](int x) { return x + base; }; // 全部引用捕获auto f5 = [base = base * 2](int x) { return x + base; }; // 初始化捕获(C++14):捕获时算好auto f6 = [base](int x) mutable { base += x; return base; }; // mutable:值捕获的可修改副本5.2 底层实现:lambda 就是仿函数(必考)
Section titled “5.2 底层实现:lambda 就是仿函数(必考)”auto f = [z = 42](int x) { return x + z; };// 编译器展开成:class __lambda_xxx { int z; // 捕获的变量成为成员public: __lambda_xxx(int z_) : z(z_) {} // 构造函数 int operator()(int x) const { return x + z; } // 重载 (),lambda 默认 const};结论:lambda 的本质是一个匿名类的临时对象(重载了 operator() 的仿函数)。所以:
- 值捕获 = 成员变量拷贝一份 → 捕获的是快照,改外面变量不影响
- 引用捕获 = 成员变量是引用 → 外面变量改了 lambda 看得到
- 引用捕获的危险:lambda 逃出作用域后引用悬垂 → 存进容器/异步任务时小心
- 空捕获
[]的 lambda 可以转换成函数指针(因为不需要状态)
5.3 泛型 lambda(C++14)
Section titled “5.3 泛型 lambda(C++14)”auto add = [](auto a, auto b) { return a + b; }; // 相当于模板 lambdaadd(1, 2); // intadd(1.5, 2); // double6. nullptr 与 NULL 的区别
Section titled “6. nullptr 与 NULL 的区别”NULL // 宏,C++ 中通常是 0 / 0L(或编译器内置 __null)—— 本质是整数nullptr // C++11 关键字,类型是 nullptr_t,专门表示空指针// 注:`(void*)0` 是 C 的常见定义;C++ 里 void* 不能隐式转其他指针,所以 C++ 的 NULL 不能是它void f(int);void f(char*);
f(NULL); // ❌ 错误选择:NULL 是整数 0 → 确定调用 f(int),而不是指针版本f(nullptr); // ✅ 明确调 f(char*)面试点:
nullptr解决了“NULL 到底是整数还是指针”的歧义;类型是std::nullptr_t,可以安全地转换成任何指针类型。
7. constexpr:编译期常量
Section titled “7. constexpr:编译期常量”7.1 const vs constexpr
Section titled “7.1 const vs constexpr”| const | constexpr | |
|---|---|---|
| 含义 | “这个值不可修改”(运行时常量) | “这个值编译期就能算出来”(编译期常量) |
| 保证 | 不保证编译期已知 | 强制编译期求值 |
| 用途 | 防止修改、只读接口 | 数组大小、模板参数、编译期计算 |
| 例子 | const int n = get_size(); |
constexpr int n = 10; |
const int a = rand(); // ✅ 合法:运行期才知道值// constexpr int b = rand(); // ❌ 编译错误:rand() 不是编译期常量
constexpr int sq(int x) { return x * x; } // constexpr 函数:入参是编译期常量就可编译期算int arr[sq(4)]; // 16 —— 编译期确定,可以做数组大小7.2 C++14/17 放宽
Section titled “7.2 C++14/17 放宽”- C++14:constexpr 函数允许循环、局部变量、if(不再只能是单条 return)。
- C++17:
if constexpr(编译期分支,见 07 章)、constexpr lambda。 - C++20:consteval(强制编译期)、constinit(编译期初始化但运行期可改)。
8. 智能指针(03 章已详讲,这里只列复习点)
Section titled “8. 智能指针(03 章已详讲,这里只列复习点)”| 类型 | 所有权 | 拷贝 | 场景 |
|---|---|---|---|
unique_ptr |
独占 | 禁止拷贝、支持移动 | 默认选择 |
shared_ptr |
共享 | 允许(计数+1) | 多所有权 |
weak_ptr |
无 | 允许(不增计数) | 打破循环引用、观察者 |
make_unique/make_shared优先(异常安全 + 一次分配)- 移动语义让 unique_ptr 成为可能——没有右值引用就没有 unique_ptr 的转移
9. C++14 / C++17 / C++20 新特性一览(必背)
Section titled “9. C++14 / C++17 / C++20 新特性一览(必背)”C++14(增强版 C++11)
Section titled “C++14(增强版 C++11)”- 泛型 lambda:
[](auto a, auto b) - 初始化捕获:
[x = expr] - 返回类型推导:普通函数也能
auto f() { return 1; } decltype(auto)- constexpr 函数放宽(支持循环/if)
std::make_unique、std::exchange
C++17(大版本)
Section titled “C++17(大版本)”- 结构化绑定:
auto [a, b] = pair; - if constexpr:编译期 if
- 折叠表达式:
(args + ...) - inline 变量:头文件里
inline int x = 1;(解决全局常量 ODR 问题) std::string_view(只读字符串视图,零拷贝)std::optional/std::variant/std::anystd::filesystem(文件系统库)- 类模板参数推导(CTAD):
std::pair p{1, 2.5}; - 嵌套命名空间:
namespace A::B::C
// 结构化绑定示例std::map<std::string, int> m;for (const auto& [key, value] : m) { ... } // 遍历 map 直接拆开
// optional 示例std::optional<int> find(int x); // 可能没有返回值if (auto v = find(5); v.has_value()) { std::cout << *v; }C++20(最新大版本)
Section titled “C++20(最新大版本)”- concepts(概念/约束):给模板参数加约束,报错信息友好
- 协程(coroutines):
co_await/co_yield/co_return,可挂起/恢复的函数 - ranges:
views::filter | views::transform管道式组合算法 - 三向比较运算符
<=>:一次定义六个比较关系 std::format(printf 类型安全的现代版)consteval/constinit- span(连续内存视图)
- 模块(modules):替代头文件(编译器支持还不全)
// conceptstemplate <std::integral T>T twice(T v) { return v * 2; } // 约束:T 必须是整型,否则直接报"不满足 concept"
// 三向比较struct Point { int x, y; auto operator<=>(const Point&) const = default; // 自动生成 == != < <= > >= 六个比较};
// 协程(概念级理解)// 一个可挂起/恢复的函数:co_await 挂起,等异步结果回来后继续执行10. 协程 vs 线程(高频题)
Section titled “10. 协程 vs 线程(高频题)”| 线程 | 协程 | |
|---|---|---|
| 调度者 | 操作系统 | 程序自己(协作式) |
| 切换开销 | 大(内核态切换、上下文切换) | 极小(用户态切换,无内核陷入) |
| 内存 | 每线程独立栈(几 MB) | 通常更轻量;C++20 协程是无栈协程,状态在堆上的 coroutine frame |
| 抢占 | 可抢占 | 不可抢占(主动让出 co_await) |
| 适用 | CPU 密集、多核并行 | I/O 密集、大量并发任务、异步流程 |
游戏里的典型场景:网络加载、动画/任务流程、异步资源加载。协程解决的是“并发写起来像同步”,线程解决的是“真正利用多核”。
11. 高频面试题 Q&A(合上书能讲)
Section titled “11. 高频面试题 Q&A(合上书能讲)”Q1:std::move 做了什么?为什么不移动任何东西?
move 只是 static_cast<T&&>——把对象强转成右值引用,告诉编译器“可以调用移动构造”。资源转移发生在移动构造函数里,move 本身零操作。
Q2:左值和右值怎么区分?将亡值是什么? 能取地址、有名字的是左值;临时、无名的是右值。将亡值(xvalue)是“可以被偷资源的右值”,如 move(x) 的结果——它马上要消亡,所以资源可以被拿走。
Q3:完美转发是什么?怎么写?
保持参数在函数链传递中的左/右值性不变。写法:T&& 转发引用接收 + std::forward<T>(arg) 转发。原理是引用折叠:左值传入时 T 推导为 T&。
Q4:std::forward 和 std::move 区别? move 无条件转右值;forward 有条件——T 推导为左值引用时保持左值,推导为右值时才转右值。泛型转发必须用 forward,用 move 会误伤左值。
Q5:lambda 的底层实现?捕获方式区别? 编译器生成匿名仿函数类(重载 operator()),捕获的变量成为成员。值捕获拷贝快照、外面改了不影响;引用捕获用引用、外面改了影响,但注意悬垂;初始化捕获(C++14)可以在捕获时算好值;空捕获 lambda 可转函数指针。
Q6:auto 和 decltype 区别?
auto 靠初始化推导、默认按值(丢 const/引用);decltype 原样保留类型(含引用)。decltype(auto)(C++14)用 auto 的简洁性 + decltype 的保真性。
Q7:const 和 constexpr 区别? const = 值不可改(运行期即可);constexpr = 编译期就能算出来(可做数组大小、模板参数)。constexpr 函数在入参为编译期常量时编译期求值,否则退化为普通函数。
Q8:C++14/17/20 各新增了哪些(至少各 3 个)? 14:泛型 lambda、初始化捕获、返回类型推导、decltype(auto);17:结构化绑定、if constexpr、折叠表达式、string_view、optional、inline 变量;20:concepts、协程、ranges、三向比较、format。
Q9:协程和线程什么区别? 协程是程序内协作式调度、切换开销极小、适合 I/O 密集大量并发;线程是内核调度、可抢占、适合 CPU 密集多核并行。协程解决“异步代码写得像同步”,线程解决“用上多核”。
Q10:移动构造为什么要 noexcept? vector 扩容需要强异常安全保证:元素移动构造不是 noexcept 且有拷贝可用时,vector 扩容会退化为拷贝;不可拷贝时仍会移动。标 noexcept 才能保证“扩容时真的移动”。
Q11:返回局部对象是拷贝还是移动? 现代编译器直接 RVO/复制消除(连移动都不需要,直接在目标位置构造);RVO 不可用时退化为移动;移动不可用时(没移动构造)才拷贝。
Q12:nullptr 和 NULL 区别?
NULL 是整数 0 的宏,重载 f(int) 和 f(char*) 时会调错版本;nullptr 是 std::nullptr_t 类型,专门表示空指针,能安全转成任何指针类型,语义明确。
12. 本章自测(12 题,限时 20 分钟,先自己答再看答案)
Section titled “12. 本章自测(12 题,限时 20 分钟,先自己答再看答案)”1. 判断:`int&& r = 5;` 中 r 是左值还是右值?
2. 手写一个 String 类的移动构造函数(含 noexcept 和源对象置空)。
3. 为什么 `std::vector
4. 手写完美转发模板:`wrapper(f, args...)` 把 args 原样转发给 f。
5. 值捕获和引用捕获,lambda 内部修改外部变量的区别?
6. `decltype((x))` 和 `decltype(x)` 为什么不同?
7. 写一个 constexpr 阶乘函数(C++14 语法)。
8. 用结构化绑定遍历 map 输出 key 和 value。
9. `std::move` 之后原对象还能用吗?
我的原答:处于什么状态?
10. 泛型 lambda 和普通模板函数有什么区别?
11. C++17 的 `std::optional` 解决什么问题?怎么判断有值?
12. 三向比较运算符 `<=>` 解决了什么问题?
📖 答案(先自己答完再展开)
- 左值。r 有名字、能取地址。“右值引用”是类型,不是值类别。
String(String&& o) noexcept : data_(o.data_), size_(o.size_) { o.data_ = nullptr; o.size_ = 0; }- 临时对象是右值,push_back(T&&) 重载触发移动构造;且 string 的移动构造标了 noexcept,vector 扩容时也能移动。
template <typename F, typename... Args> decltype(auto) wrapper(F&& f, Args&&... args) { return std::forward<F>(f)(std::forward<Args>(args)...); }(用 decltype(auto) 才能保留返回的引用)- 值捕获:lambda 内是副本,改副本不影响外部;引用捕获:直接改外部变量。
decltype(x)直接看声明类型 → int;decltype((x))是表达式 → x 是左值表达式 → 推导为 int&。constexpr int fact(int n) { int r = 1; for (int i = 2; i <= n; ++i) r *= i; return r; }for (const auto& [k, v] : m) std::cout << k << ": " << v;- 处于“有效但未指定”状态:可析构、可重新赋值,但不能依赖内容(比如不能读原字符串内容)。
- 泛型 lambda 是编译期按类型生成匿名类模板,等价于函数模板;区别是 lambda 还有捕获状态。
- 解决“可能没有值”的返回(替代哨兵值/out 参数/异常),用
has_value()、*opt、opt.value_or(default)。 - 一次定义
<=>就自动得到 < <= > >= == != 六个比较,且保证语义一致(不会出现 a<b 和 b>a 不一致的手写 bug)。
- RVO/NRVO 是怎么实现的?什么情况下编译器不能做复制消除?(返回全局、多分支返回)
- 引用折叠的完整四象限(T&/T&&/T&&&…)?
decltype(auto)什么时候会有坑?(返回引用悬垂)- 结构化绑定是引用还是拷贝?
auto& [a,b]和auto [a,b]区别? - 协程的底层(对称栈协程?无栈协程?)——C++20 协程是无栈协程,内存放哪?
std::string_view为什么不能长期持有?悬垂 view 的场景?- modules 能替代 #pragma once 吗?现在能用吗?