跳转到内容

08 · 现代 C++ 新特性

定位:从 C++11 到 C++20 的特性是“区分新旧时代程序员”的分水岭,面试几乎必考:右值引用/移动语义/完美转发是重灾区(面试官最喜欢连环追问),lambda、auto、constexpr 是高频题,C++17/20 新特性考“至少说 3 个”。 学完标准:能手写完美转发代码;能解释“为什么 std::move 不移动任何东西”;能讲清左值/右值/将亡值;能说出 C++14/17/20 各至少 3 个新特性。


1. 左值、右值、将亡值(值类别,最底层地基)

Section titled “1. 左值、右值、将亡值(值类别,最底层地基)”
值类别 定义 例子
左值 lvalue 有名字、可以取地址、可以持久存在 变量名、数组名、*p、字符串字面量
纯右值 prvalue 临时值,无名字,用完即弃 3a+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 本身是左值(有名字、能取地址)。“右值引用”是引用的类型,不是被引对象的值类别。

旧 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 节

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)状态——可以析构、可以赋值,但不能依赖它的内容。

// 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 的数据被偷走
  • 函数返回局部对象(返回值优化 RVO / 移动,现代编译器一般直接 RVO 掉拷贝)
  • std::move(x) 作为参数
  • 容器操作:emplace_backpush_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.1 问题:参数在函数间传递时“左/右值性”丢失

Section titled “3.1 问题:参数在函数间传递时“左/右值性”丢失”
template <typename T>
void wrapper(T arg) { // 值传递:无论传左值右值都变成"拷贝"
process(arg); // 到 process 手里一定是个左值(右值性丢了)
}
// 需求:wrapper 接到什么,process 就看到什么——左值还是左值,右值还是右值(还能触发移动)
// 引用折叠规则(记住这两条就够):
// 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 推导为 intT&& 就是 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.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; // int
decltype(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 更简洁,保留引用性

[capture](params) -> return_type { body };
// | | | |
// 捕获 参数 返回类型(可省) 函数体
int base = 10;
auto f1 = [base](int x) { return x + base; }; // 值捕获:复制一份 base
auto 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 可以转换成函数指针(因为不需要状态)
auto add = [](auto a, auto b) { return a + b; }; // 相当于模板 lambda
add(1, 2); // int
add(1.5, 2); // double

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,可以安全地转换成任何指针类型。


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 —— 编译期确定,可以做数组大小
  • 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 新特性一览(必背)”
  1. 泛型 lambda:[](auto a, auto b)
  2. 初始化捕获:[x = expr]
  3. 返回类型推导:普通函数也能 auto f() { return 1; }
  4. decltype(auto)
  5. constexpr 函数放宽(支持循环/if)
  6. std::make_uniquestd::exchange
  1. 结构化绑定auto [a, b] = pair;
  2. if constexpr:编译期 if
  3. 折叠表达式(args + ...)
  4. inline 变量:头文件里 inline int x = 1;(解决全局常量 ODR 问题)
  5. std::string_view(只读字符串视图,零拷贝)
  6. std::optional / std::variant / std::any
  7. std::filesystem(文件系统库)
  8. 类模板参数推导(CTAD):std::pair p{1, 2.5};
  9. 嵌套命名空间: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; }
  1. concepts(概念/约束):给模板参数加约束,报错信息友好
  2. 协程(coroutines)co_await / co_yield / co_return,可挂起/恢复的函数
  3. rangesviews::filter | views::transform 管道式组合算法
  4. 三向比较运算符 <=>:一次定义六个比较关系
  5. std::format(printf 类型安全的现代版)
  6. consteval / constinit
  7. span(连续内存视图)
  8. 模块(modules):替代头文件(编译器支持还不全)
// concepts
template <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 挂起,等异步结果回来后继续执行

线程 协程
调度者 操作系统 程序自己(协作式)
切换开销 大(内核态切换、上下文切换) 极小(用户态切换,无内核陷入)
内存 每线程独立栈(几 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 v; v.push_back(std::string("x"));` 中括号里的临时对象能触发移动而非拷贝?

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. 三向比较运算符 `<=>` 解决了什么问题?

📖 答案(先自己答完再展开)
  1. 左值。r 有名字、能取地址。“右值引用”是类型,不是值类别。
  2. String(String&& o) noexcept : data_(o.data_), size_(o.size_) { o.data_ = nullptr; o.size_ = 0; }
  3. 临时对象是右值,push_back(T&&) 重载触发移动构造;且 string 的移动构造标了 noexcept,vector 扩容时也能移动。
  4. template <typename F, typename... Args> decltype(auto) wrapper(F&& f, Args&&... args) { return std::forward<F>(f)(std::forward<Args>(args)...); }(用 decltype(auto) 才能保留返回的引用)
  5. 值捕获:lambda 内是副本,改副本不影响外部;引用捕获:直接改外部变量。
  6. decltype(x) 直接看声明类型 → int;decltype((x)) 是表达式 → x 是左值表达式 → 推导为 int&。
  7. constexpr int fact(int n) { int r = 1; for (int i = 2; i <= n; ++i) r *= i; return r; }
  8. for (const auto& [k, v] : m) std::cout << k << ": " << v;
  9. 处于“有效但未指定”状态:可析构、可重新赋值,但不能依赖内容(比如不能读原字符串内容)。
  10. 泛型 lambda 是编译期按类型生成匿名类模板,等价于函数模板;区别是 lambda 还有捕获状态。
  11. 解决“可能没有值”的返回(替代哨兵值/out 参数/异常),用 has_value()*optopt.value_or(default)
  12. 一次定义 <=> 就自动得到 < <= > >= == != 六个比较,且保证语义一致(不会出现 a<b 和 b>a 不一致的手写 bug)。

## 13. 进阶追问(答不上来就回来复习)
  • RVO/NRVO 是怎么实现的?什么情况下编译器不能做复制消除?(返回全局、多分支返回)
  • 引用折叠的完整四象限(T&/T&&/T&&&…)?
  • decltype(auto) 什么时候会有坑?(返回引用悬垂)
  • 结构化绑定是引用还是拷贝?auto& [a,b]auto [a,b] 区别?
  • 协程的底层(对称栈协程?无栈协程?)——C++20 协程是无栈协程,内存放哪?
  • std::string_view 为什么不能长期持有?悬垂 view 的场景?
  • modules 能替代 #pragma once 吗?现在能用吗?