C++ 值类别(Value Categories)
C++11 起表达式按两个独立维度分类:有没有身份(identity)、能不能被移动(movable from):
| 类别 | 全称 | 特征 | 例子 |
| lvalue | left value | 有身份,不可移动 | 变量名、*ptr、arr[0]、返回左值引用的函数调用 |
| prvalue | pure rvalue | 无身份,可移动 | 字面量 42、std::string("hi")、返回值类型的函数调用、lambda |
| xvalue | expiring value | 有身份,可移动 | std::move(x)、返回右值引用的函数调用(如 std::move本身) |
| glvalue | generalized lvalue | = lvalue + xvalue,有身份 | 能取地址、能绑定到左值引用 |
| rvalue | right value | = prvalue + xvalue,可移动 | 能绑定到右值引用(T&&) |
口诀:"左值有名字有地址,将亡值有名字但要死了,纯右值临时对象马上消失"。
右值引用与 std::move
右值引用 T&& 专门绑定到右值,目的是让函数能"偷走"临时对象的资源而不是深拷贝:
class MyString {
char* data_;
size_t size_;
public:
// 移动构造:把 other 的资源"偷"过来
MyString(MyString&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr; // 必须置空,否则 other 析构会 double free
other.size_ = 0;
}
// 移动赋值:先释放自己,再偷
MyString& operator=(MyString&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
~MyString() { delete[] data_; }
};
std::move(x) 本质就是一个 static_cast<T&&>(x),它不移动任何东西,只是把一个左值强制转成右值引用,让后续的重载决议选择移动版本。真正的"移动"发生在移动构造/移动赋值里。
面试口径:std::move 是 cast 不是 move,名字起得有误导性;真正的资源转移在移动构造/赋值里完成。
引用折叠(Reference Collapsing)与完美转发
引用折叠规则(C++ 不允许直接写"引用的引用",编译器按以下规则折叠):
| 你写的 | 折叠结果 |
| T& & | T& |
| T&& & | T& |
| T& && | T& |
| T&& && | T&& |
记忆:只要有一个 &,结果就是 &;全是 && 才是 &&。这是 forwarding reference(转发引用,template<typename T> void f(T&& x))工作的基础。
std::forward<T>(x) 保持参数原始的值类别:如果传入的是左值,T 被推导为 T&,forward 返回 T&;如果传入的是右值,T 被推导为 T,forward 返回 T&&:
template<typename T, typename... Args>
unique_ptr<T> make_unique(Args&&... args) {
return unique_ptr<T>(new T(std::forward<Args>(args)...));
}
// 工厂函数示例
template<typename T, typename... Args>
shared_ptr<T> create(Args&&... args) {
return make_shared<T>(std::forward<Args>(args)...);
}
为什么移动构造要加 noexcept?
vector 扩容时需要把旧元素搬到新内存。如果移动构造没有标记 noexcept,vector 无法保证移动过程中抛异常时旧数据仍然有效(异常安全问题),所以会退回到拷贝构造,即使你定义了移动构造也不会用。标准库容器对强异常安全的要求,导致 noexcept 成为移动语义能否真正生效的关键。
// 错误示范:移动构造没标 noexcept,vector 扩容时仍然拷贝
MyString(MyString&& other); // 不会在 vector 重新分配时被调用
// 正确写法
MyString(MyString&& other) noexcept; // vector 扩容时选择移动
面试时这条可以和 vector 扩容结合回答:noexcept 不止是优化提示,更是容器选择 move 还是 copy 的开关。
RVO / NRVO 与 Copy Elision
| 术语 | 全称 | 含义 |
| RVO | Return Value Optimization | 返回临时对象时,直接在调用方栈帧构造,省略拷贝/移动 |
| NRVO | Named RVO | 返回具名局部变量时,同样直接在外部构造 |
| Copy Elision | 复制消除 | C++17 起在 prvalue 场景下保证生效(不依赖优化) |
MyString make_string() {
return MyString("hello"); // C++17 保证不会拷贝/移动,直接构造
}
MyString make_string2() {
MyString s("hello");
return s; // NRVO,编译器通常会优化,但不保证
}
C++17 起,prvalue 作为返回值时 copy elision 是强制的,不是可选优化。但 NRVO 和其他场景(如按值传参)仍然是优化行为。
Rule of Zero / Three / Five
| 规则 | 内容 | 适用场景 |
| Rule of Zero | 不写任何特殊成员函数,全部交给成员对象(智能指针、STL 容器)处理 | 自己不管理裸资源的类,首选 |
| Rule of Three | 需要析构函数 → 必须同时写拷贝构造和拷贝赋值 | C++98 时代,自己管理资源 |
| Rule of Five | 写了析构/拷贝构造/拷贝赋值 → 应该同时写移动构造和移动赋值 | C++11 后,自己管理资源且想支持移动 |
最佳实践:能 Rule of Zero 就 Rule of Zero,把资源交给 unique_ptr、string、vector 这些已经写好 Rule of Five 的成员来管。只有写底层资源管理类(如自己写 string、智能指针)时才需要 Rule of Five。
push_back vs emplace_back
vector<pair<string, int>> v;
// push_back:先构造临时 pair,再移动进容器(一次移动)
v.push_back(make_pair("hello", 42));
v.push_back({"hello", 42});
// emplace_back:直接在容器内存里原地构造,省一次临时对象+移动
v.emplace_back("hello", 42); // 参数直接转发给 pair 构造函数
// 对已有左值,两者效果相同(都拷贝/移动)
string s = "hello";
v.push_back(s); // 拷贝
v.emplace_back(s); // 同样拷贝
emplace_back 利用完美转发把参数直接传给元素构造函数,避免临时对象的构造和移动,但对左值不会变快。
Q: std::move 做了什么?它真的移动了对象吗?
std::move 不做任何移动,它只是一个 static_cast<T&&>(x),把左值强制转换成右值引用。它唯一的作用是让重载决议选择移动构造/移动赋值运算符。真正的资源转移发生在移动构造函数或移动赋值运算符里。一个常见陷阱是:对一个 const 对象 std::move 仍然会调用拷贝构造,因为 const T&& 无法绑定到 T&&(会折叠到 const T&)。
Q: 完美转发解决了什么问题?为什么需要 std::forward?
模板中参数 T&& x(forwarding reference)接收参数后,x 本身是一个有名字的变量,在函数体内它是左值。如果直接把 x 传给下一个函数,就会丢失原始的右值属性,导致调用拷贝而不是移动。std::forward<T>(x) 根据 T 的推导结果,当原始参数是右值时把 x 转回右值,当原始参数是左值时保持左值,从而"完美"保持参数的值类别。这是工厂函数、emplace_back、make_shared 等泛型代码的基础。
Q: 为什么移动构造和移动赋值运算符要加 noexcept?
因为 标准库容器(如 vector)在扩容时对异常安全有强保证——如果搬迁过程中抛异常,旧数据必须保持完好可恢复。如果移动构造没有 noexcept,vector 无法假设移动不会抛异常,为了保证异常安全只能退而使用拷贝构造(拷贝失败时旧数据还在),你写了移动构造也白写。noexcept 在这里不只是一个优化提示,而是决定移动语义能否在标准库容器中真正生效的"开关"。
Q: RVO 和 std::move 在 return 语句上的关系?
在 return 局部变量时,不要写 return std::move(local_var);!这是一个常见错误。原因:编译器对 return 局部变量本来就会做 NRVO,或者在 NRVO 不生效时自动把它当作右值处理(隐式 move)。如果你显式写 std::move,反而会抑制 NRVO(因为编译器看到的是一个引用,不再是可被优化的具名对象),结果多了一次不必要的移动。唯一例外是返回一个非局部变量(如参数、成员变量)时需要显式 move。
vector:连续动态数组
vector 在堆上分配一块连续内存,用三个指针管理:start(起始)、finish(已用末尾)、end_of_storage(容量末尾)。
| 操作 | 复杂度 | 说明 |
| 随机访问 [] / at | O(1) | 连续内存,直接指针偏移 |
| push_back | 摊还 O(1) | 有容量时直接构造;不够时扩容 |
| pop_back | O(1) | 只析构末尾元素,不释放内存 |
| insert / erase(中间) | O(n) | 需要搬移后面所有元素 |
扩容策略:capacity 不足时分配新的更大内存(MSVC 是 1.5 倍,GCC 是 2 倍),把旧元素搬过去(移动构造优先,拷贝兜底),释放旧内存。
vector<int> v;
v.reserve(1000); // 提前预留容量,避免多次扩容
for (int i = 0; i < 1000; ++i) {
v.push_back(i); // 不会触发 reallocation
}
cout << v.size() << " / " << v.capacity() << endl; // 1000 / 1000
v.clear();
cout << v.size() << " / " << v.capacity() << endl; // 0 / 1000(内存不释放!)
// 真正释放内存:swap 空 vector
vector<int>().swap(v);
// C++11:v.shrink_to_fit();
string 的 SSO(Small String Optimization)
std::string 几乎所有主流实现(libstdc++、libc++、MSVC STL)都用 SSO:短字符串直接存在 string 对象内部的栈上 buffer,不分配堆内存;长度超过阈值才在堆上分配。
| 实现 | SSO 阈值(含 '\0') | 说明 |
| libstdc++ (GCC) | 16 字节(15 字符 + '\0') | 典型 64 位平台 |
| libc++ (Clang/Mac) | 23 字节(22 字符 + '\0') | 利用了 union 里的 padding |
| MSVC STL | 16 字节(15 字符 + '\0') | Debug 模式下更少 |
面试口径:string s = "hello"; 大概率不触发堆分配;但 string s(100, 'x'); 一定在堆上。写性能敏感代码时要意识到这一点。
deque:分段连续数组
deque 表面上支持随机访问和两端 O(1) 插入,但底层不是真正连续的。它维护一个"中控数组"(map),每个元素指向一块固定大小的连续内存段(通常 512 字节)。
- push_front / push_back:O(1),当前段不够时分配新段挂到 map 上,不需要搬移已有元素(这是和 vector 最大的区别)。
- 随机访问 []:O(1) 但比 vector 慢——要先算段号 + 段内偏移,多一次指针跳转。
- 中间 insert/erase:O(n),因为需要搬移元素。
- 没有 reserve(),因为 deque 的内存本来就是分段增长的。
list / forward_list:链表
- list:双向链表,每个节点有 prev/next 指针 + 数据。不支持随机访问,但任意位置 insert/erase 是 O(1)(前提是你已经有那个位置的迭代器)。
- forward_list:单向链表,更省内存(少一个指针),只支持前向遍历。
重要:由于链表节点分散在堆上,list 的缓存局部性极差(cache miss 多),遍历比 vector 慢很多倍。在 C++ 中除非确实有"在中间频繁插入/删除且不搬迁元素"的需求,否则优先用 vector。
list::sort() 提供了链表专用的归并排序(O(n log n)),不需要随机访问,比通用的 std::sort(需要 random access iterator)更适合 list。
set / map:红黑树有序
set、map、multiset、multimap 底层通常是红黑树(一种近似平衡的二叉搜索树):
- 所有操作(insert / erase / count / find)都是 O(log n)。
- 元素有序,按 key 排序,可以做范围查询(
lower_bound / upper_bound)。
- map 的 value_type 是
pair<const Key, T>,key 不可修改(修改会破坏 BST 性质)。
- 迭代器是双向迭代器(bidirectional),不是随机访问,
it + 5 不行。
unordered_map / unordered_set:哈希表
底层是哈希表,标准只规定接口不规定实现。常见实现:
- libstdc++:开链法(separate chaining),每个 bucket 是一个单向链表,挂 hash 冲突的元素。
- libc++:开链法 + 短期优化(bucket 里前几个元素直接存在 bucket 数组里,超过再链表)。
- MSVC:也曾用过开放寻址(open addressing),但新版本也偏向开链。
| 操作 | 平均 | 最坏 | 说明 |
| insert / find / erase | O(1) | O(n) | 所有 hash 冲突退化成链表时 |
| rehash | — | O(n) | bucket 扩容时重新算 hash 搬移所有元素 |
load factor(装载因子)= size / bucket_count。超过 max_load_factor()(默认 1.0)时触发 rehash,bucket 数大约翻倍,所有元素重新分配位置。rehash 会导致所有迭代器失效。
unordered_map<string, int> m;
m.reserve(10000); // 提前预留 bucket,避免插入过程中多次 rehash
for (int i = 0; i < 10000; ++i) {
m[to_string(i)] = i;
}
容器适配器:stack / queue / priority_queue
它们不是独立容器,而是对底层容器的封装:
| 适配器 | 默认底层容器 | 底层操作 | 可替换为 |
| stack | deque | push_back / pop_back / back | vector / list |
| queue | deque | push_back / pop_front / front / back | list(不能用 vector,因为没有 pop_front) |
| priority_queue | vector | push_back + pop_heap(堆调整) | deque |
priority_queue 是大顶堆(默认),top() 是最大值,push/pop 都是 O(log n)。要小顶堆用 priority_queue<int, vector<int>, greater<int>>。
迭代器失效(Iterator Invalidation)规则
| 容器 | 操作 | 哪些迭代器失效 |
| vector | reallocation(push_back 等导致扩容) | 全部迭代器/引用/指针失效 |
| insert / push_back(不扩容) | 插入点之后的迭代器失效 |
| erase / pop_back | 删除点及之后的迭代器失效(erase 返回下一个有效迭代器) |
| deque | push_front / push_back | 全部迭代器失效(但引用/指针仍有效,因为不搬移元素) |
| insert / erase 中间 | 全部迭代器/引用/指针失效 |
| list / forward_list | insert / push | 无失效 |
| erase | 仅被删元素的迭代器失效,其余不受影响 |
| set / map | insert / erase | 仅被删元素的迭代器失效 |
| unordered_map | insert(不触发 rehash) | 无失效 |
| insert 触发 rehash | 全部迭代器失效 |
面试高频坑:for (auto it = v.begin(); it != v.end(); ++it) { if (...) v.erase(it); } 是 UB,因为 erase 后 it 已失效。正确写法:it = v.erase(it); 或 v.erase(it++);。
Q: vector 扩容为什么是 1.5 倍或 2 倍?为什么不是 3 倍或固定大小?
核心是摊还复杂度和内存复用的平衡:①任何大于 1 的常数倍增长,push_back 的摊还时间都是 O(1)——每个元素最多被搬移常数次(2 倍时每个元素被搬 1 次左右)。②倍数不能太大(如 3 倍):扩容太激进导致内存浪费,而且每次分配的新大小总是大于之前所有已释放块的总和,无法复用刚释放的内存(buddy allocator 场景下)。③倍数不能太小(如 1.1 倍):扩容太频繁,insert/erase 摊还时间会退化成 O(n)。④1.5 倍 vs 2 倍:2 倍在数学上摊还分析更干净(每个元素恰好被搬一次),1.5 倍(MSVC 选择)能更好地复用之前释放的内存块。固定步长扩容会导致 O(n²) 的 push_back,所以必须是指数级增长。
Q: unordered_map 和 map 怎么选?
三个维度决策:①是否需要有序——需要范围查询、按序遍历、lower_bound/upper_bound 选 map(红黑树)。②性能要求——平均性能 unordered_map O(1) 优于 map O(log n),但 unordered_map 最坏 O(n)(hash 冲突严重时),且 rehash 时抖动明显;map 性能稳定可预测。③key 类型——unordered_map 需要 key 支持 hash(自定义类型要提供 std::hash 特化),map 需要 key 支持 < 比较(自定义类型提供 operator<)。经验法则:默认选 unordered_map 追求性能;需要有序或稳定延迟选 map;int/string 做 key 两者都行,小数据量(<1000 元素)两者差异可以忽略。
Q: 为什么 vector 的 clear() 不释放内存?怎么真正释放?
clear() 只做两件事:调用所有元素的析构函数,把 size 设为 0;capacity 不变,内存不还给操作系统/分配器。这是设计选择——clear() 常见用法是"清空后再填新元素",保持 capacity 可以避免重新分配,提升性能。真正释放内存有两种方式:①vector<T>().swap(v);(swap 一个临时空 vector,临时对象析构时带走旧内存);②C++11 的 v.shrink_to_fit()(非强制,请求释放多余容量,但编译器可能不执行)。这是 C++ "不花不需要的开销"哲学的体现。
Q: emplace_back 和 push_back 到底有什么区别?
push_back(obj) 接收一个已经构造好的对象(或隐式转换构造临时对象),然后把它拷贝/移动进容器。emplace_back(args...) 接收构造函数参数,直接在 vector 的内存位置上原地构造(in-place construction,通过 placement new 和完美转发),省去一次临时对象的构造 + 移动/拷贝。关键区别:①对已有左值对象,两者一样,都会拷贝。②对"需要多个构造参数"的对象,emplace_back 明显更高效(如 v.emplace_back(42, "hello") 直接构造 pair)。③对单参数隐式转换场景,push_back(T&&) 本身也会触发移动,差异不大。④小心 emplace_back 隐式转换带来的可读性问题:v.emplace_back(10) 在 vector<int> 可以,但如果是 vector<bool> 会有有趣的陷阱。总结:用参数包直接构造时用 emplace_back,传已有对象时两者等价。
std::thread 基础
#include <thread>
void func(int x, const string& s) { /* ... */ }
thread t(func, 42, ref(str)); // ref 包装引用参数,否则按值拷贝
t.join(); // 等待线程结束
t.detach(); // 分离,线程在后台继续运行(小心生命周期问题!)
// jthread (C++20) 自动 join,支持 cooperative cancellation
jthread jt(func, 42, ref(str));
传参陷阱:thread 构造函数默认把参数按值拷贝到内部 storage,即使函数签名是引用。要传引用必须用 std::ref / std::cref。
线程函数的参数如果是左值引用,忘记 std::ref 会编译报错;如果是右值引用会隐式 move,但要警惕对象已被 move 后续访问。
Mutex 家族与 RAII 锁
| Mutex 类型 | 特点 | 用途 |
| mutex | 最基本的互斥锁,不可递归、不可超时 | 大多数场景 |
| timed_mutex | 支持 try_lock_for / try_lock_until | 需要超时避免死等 |
| recursive_mutex | 同一线程可多次 lock,不会死锁 | 递归函数中加锁(尽量避免) |
| RAII 锁 | 特点 |
| lock_guard | 最简单,构造时 lock、析构时 unlock,不可手动解锁 |
| unique_lock | 灵活:可 defer_lock(延后加锁)、可手动 unlock/lock、可转移所有权,condition_variable wait 必须用它 |
| scoped_lock (C++17) | 同时锁多个 mutex,内部用死锁避免算法(std::lock),推荐多锁场景使用 |
mutex mtx;
// 推荐:用 lock_guard / unique_lock 管理锁
{
lock_guard<mutex> lock(mtx); // 构造加锁
shared_data++; // 临界区
} // 析构自动解锁,异常安全
// C++17 多锁,防死锁
mutex m1, m2;
{
scoped_lock lock(m1, m2); // 内部用 std::lock 避免死锁
}
Condition Variable 与生产者-消费者
condition_variable 用于"等待某个条件成立"的线程间通信,必须和 mutex + unique_lock 配合使用:
template <typename T>
class ThreadSafeQueue {
queue<T> q_;
mutable mutex mtx_;
condition_variable cv_;
public:
void push(T value) {
{
lock_guard<mutex> lock(mtx_);
q_.push(std::move(value));
}
cv_.notify_one(); // 通知一个等待的消费者
}
T pop() {
unique_lock<mutex> lock(mtx_);
// wait:必须用 predicate 形式,防止虚假唤醒
cv_.wait(lock, [this] { return !q_.empty(); });
T value = std::move(q_.front());
q_.pop();
return value;
}
optional<T> try_pop() {
lock_guard<mutex> lock(mtx_);
if (q_.empty()) return nullopt;
T value = std::move(q_.front());
q_.pop();
return value;
}
};
要点:①wait 必须带 predicate(lambda),循环检查条件,防虚假唤醒;②通知可以在锁外也可以在锁内,锁外通知可减少锁竞争;③push 时 notify_one 唤醒一个消费者,notify_all 唤醒所有(多个条件如队列有多个优先级用 notify_all)。
Future / Promise / async
#include <future>
// std::async:异步执行函数,返回 future
future<int> f = async(launch::async, [] { return 42; });
int result = f.get(); // 阻塞等待结果
// launch::async 立即在新线程执行;launch::deferred 延迟到 get() 时在当前线程执行
// 默认策略(不指定)是 deferred|async,由实现选择
// promise + future:手动传递结果
promise<int> p;
future<int> fut = p.get_future();
thread t([p = std::move(p)]() mutable {
this_thread::sleep_for(1s);
p.set_value(42); // 生产结果
});
int val = fut.get(); // 等待 set_value
t.join();
// packaged_task:把函数包装成 future 提供者
packaged_task<int()> task([] { return 42; });
future<int> f2 = task.get_future();
thread t2(std::move(task));
int v = f2.get();
共享 future:shared_future<T> 可以被多个线程等待(future 只能 get 一次),通过 fut.share() 获取。
Atomic 与 Memory Order
std::atomic<T> 提供原子操作,底层靠 CPU 指令(lock 前缀、LL/SC、CAS)保证单个操作的原子性,但原子性不等于跨线程顺序。memory order 控制编译器和 CPU 的重排:
| memory_order | 语义 | 用途 |
| memory_order_relaxed | 仅保证原子性,无顺序约束 | 计数器(不依赖顺序) |
| memory_order_acquire | 读操作,之后的读写不能重排到它前面 | 加载同步点 |
| memory_order_release | 写操作,之前的读写不能重排到它后面 | 发布同步点 |
| memory_order_acq_rel | 同时是 acquire 和 release(RMW 操作) | fetch_add 等读改写 |
| memory_order_seq_cst | 顺序一致性,全局统一顺序(最强也是默认) | 默认选择,最不容易错 |
// 无锁计数器:CAS 循环(compare-exchange loop)
atomic<int> counter{0};
void increment() {
int expected = counter.load(memory_order_relaxed);
while (!counter.compare_exchange_weak(
expected, expected + 1,
memory_order_acq_rel, memory_order_relaxed)) {
// expected 被更新为当前值,重试
}
}
// 简单场景直接用:counter.fetch_add(1, memory_order_relaxed);
compare_exchange_weak vs strong:weak 可能伪失败(即使值相等也返回 false),适合循环中使用;strong 保证不伪失败,但性能略差,适合不循环的场景。
Acquire-Release 模式详解
这是最常用的非 seq_cst 模式,用于"生产者发布数据,消费者获取数据":
atomic<bool> ready{false};
int data = 0;
// 线程 A(生产者)
void producer() {
data = 42; // (1) 写数据
ready.store(true, memory_order_release); // (2) 发布:(1) 不会重排到 (2) 之后
}
// 线程 B(消费者)
void consumer() {
while (!ready.load(memory_order_acquire)); // (3) 获取
assert(data == 42); // (4) 一定读到 42!
// acquire 保证 (4) 不会重排到 (3) 之前,且看到 release 之前的所有写
}
synchronizes-with 关系:如果一个 release store 被一个 acquire load 读到,那么 release 之前的所有写操作都对 acquire 之后的读可见,这就是 happens-before 的基础。
面试口径:relaxed 最轻量但只能保证原子性;acquire/release 建立单向同步;seq_cst 最强,所有线程看到一致的全局修改顺序,默认用 seq_cst 不出错。
死锁预防
死锁四个必要条件:互斥、持有并等待、不可剥夺、循环等待。常用预防手段:
- 固定加锁顺序:多个线程都按相同顺序获取锁,打破循环等待。
- std::scoped_lock / std::lock:C++17 的 scoped_lock 内部使用死锁避免算法(类似 try-and-backoff),一次性获取多个锁不会死锁。
- try_lock 回退:获取不到所有锁时释放已获取的,等一会重试。
- 减少锁粒度:锁持有时间尽量短,锁的范围尽量小。
Q: condition_variable 为什么必须和 mutex 一起用?为什么不能直接用一个 atomic bool?
有两个核心原因:①丢失唤醒(lost wakeup)问题。如果线程 A 检查条件发现不满足、正准备 wait 时,线程 B 修改条件并 notify,这个 notify 会丢失(A 还没进入 wait 状态),A 永远等不到。mutex 保证"检查条件"和"进入 wait"是原子的——wait 在内部把锁释放并进入等待是一个不可分割的操作,notify 在持锁时发,wait 在持锁时等,就不会丢信号。②条件本身的保护。条件(如队列是否为空)是多个线程共享的变量,必须用 mutex 保护,否则检查条件本身就是 data race(UB)。atomic bool 可以解决原子性,但无法原子地"检查并睡眠",仍有 lost wakeup 风险。
Q: memory_order_relaxed 到底会怎样?为什么不总是用最强的 seq_cst?
memory_order_relaxed 只保证单个原子变量的修改是原子的(不会读到撕裂值),但完全不保证顺序:不同线程对多个变量的 relaxed 访问可能看到完全不同的顺序,编译器和 CPU 可以自由重排。它适合纯计数器场景(如引用计数、统计计数),这些场景只关心最终值,不依赖操作顺序。为什么不用 seq_cst 到处用?因为性能开销:seq_cst 通常会插入内存屏障指令(如 x86 的 MFENCE 或 lock 前缀),阻止编译器和 CPU 重排,在高并发场景(如无锁数据结构)会显著影响性能。但在一般业务代码中,优先用 seq_cst(或 mutex)保证正确性,只有在性能热点且对内存模型理解透彻时才用 acquire/release/relaxed。
Q: 什么是虚假唤醒(spurious wakeup)?为什么 wait 必须带 predicate?
虚假唤醒是指 condition_variable 的 wait 没有被任何线程 notify 却返回了的现象。这不是 bug,而是POSIX 线程等底层实现允许的行为(为了实现更简单/性能更好),在 Linux 上偶发、Windows 上几乎不会发生。应对方式就是 wait 的 predicate 形式:cv.wait(lock, predicate) 等价于 while (!predicate()) cv.wait(lock);,醒来后再检查一次条件,不满足就继续等。永远不要写不带 predicate 的 wait:cv.wait(lock); 是错的!因为即使不是虚假唤醒,也可能有多个消费者被 notify,一个消费者取走数据后队列又空了,其他消费者醒来发现条件不满足也得继续等。
Q: detach 后的线程怎么安全退出?
强烈建议避免 detach,优先用 join + 协作式退出标志(如 atomic<bool> stop_flag 或 C++20 jthread 的 stop_token)。如果必须 detach(如某些框架要求),安全退出要点:①线程函数不能持有栈上/已析构对象的引用或指针(detach 后线程还在跑,主线程对象可能已销毁)——这是 detach 最大的坑,常导致 use-after-free。②用静态对象、shared_ptr 或全局/堆上数据来传递状态。③用 std::atomic<bool> 做退出标志,线程循环检查它。④程序退出时无法 join detached 线程,main 返回后 detached 线程会被强制终止(可能导致数据损坏),尽量设计成 daemon 线程或用 std::atexit 协调。工程上更推荐:让线程对象作为类成员,析构时设置 stop flag + join 等待退出,即 RAII 管理线程生命周期。
Lambda 表达式详解
lambda 是编译器生成的匿名闭包类(closure type)的实例,可以捕获局部变量:
// C++11 基础捕获
int x = 10;
auto f1 = [=]() { return x; }; // 值捕获(拷贝一份 x)
auto f2 = [&]() { return x++; }; // 引用捕获(小心生命周期!)
auto f3 = [x]() { return x + 1; }; // 显式值捕获 x
auto f4 = [&x]() { x++; }; // 显式引用捕获 x
auto f5 = [=, &x]() { return x + y; };// 默认值捕获,x 引用捕获
// C++14 init capture(广义捕获):移动捕获
auto p = make_unique<int>(42);
auto f6 = [p = std::move(p)]() { return *p; }; // p 被 move 进闭包
// C++14 泛型 lambda(auto 参数)
auto add = [](auto a, auto b) { return a + b; };
// 无捕获 lambda 可以转成函数指针
using Func = int(*)(int);
Func f = [](int x) { return x * 2; }; // OK,无捕获
引用捕获陷阱:lambda 如果引用捕获局部变量,lambda 的生命周期不能超过变量作用域。异步场景(thread、async)尤其要注意。
shared_ptr 控制块(Control Block)
shared_ptr 内部有两个指针:一个指向被管理对象,一个指向堆上的控制块。控制块包含:
- 强引用计数(use_count):有多少个 shared_ptr 共享对象
- 弱引用计数(weak_count):有多少个 weak_ptr 观察对象
- deleter:删除器(类型擦除)
- allocator:分配器(可选)
make_shared 优化:make_shared<T>(args...) 一次分配同时容纳对象和控制块,比 shared_ptr<T>(new T(args))(两次分配:new T + new 控制块)更高效,cache locality 也更好。但代价是:有 weak_ptr 存在时,即使所有 shared_ptr 都释放了,对象内存也无法回收(因为控制块还在,对象嵌在里面),要等 weak_ptr 也释放。
// 正确(推荐)
auto p = make_shared<MyClass>(42); // 单次分配
// 不推荐(两次分配 + 可能泄漏的危险)
shared_ptr<MyClass> p(new MyClass(42));
// 如果 new MyClass 成功、shared_ptr 构造抛异常(不太可能但极端情况),内存泄漏
// 正确写法里 make_shared 封装了这一切,异常安全
// 循环引用问题
struct Node {
shared_ptr<Node> next;
// ~Node() 永远不会被调用!互相引用导致 use_count 永远不为 0
};
// 解决:把一边改成 weak_ptr
struct Node2 {
weak_ptr<Node2> next; // 不增加强引用计数
};
weak_ptr 与 enable_shared_from_this
weak_ptr 是 shared_ptr 的观察者,不增加引用计数,不能直接访问对象,必须通过 lock() 提升为 shared_ptr:
weak_ptr<MyClass> wp = p;
if (auto sp = wp.lock()) { // 原子地检查是否还活着,提升为 shared_ptr
sp->do_something(); // 安全使用,sp 在作用域内持有引用
} else {
// 对象已被销毁
}
// expired() 检查是否失效(仅判断,不如 lock 安全,因为判断后可能立即失效)
if (!wp.expired()) { /* 不推荐!TOCTOU 问题 */ }
enable_shared_from_this<T>:当类内部需要把"自身的 shared_ptr"传出去时使用。直接 shared_ptr<T>(this) 会创建第二个独立的控制块,导致 double free:
class MyClass : public enable_shared_from_this<MyClass> {
public:
void start_task() {
// auto self = shared_ptr<T>(this); // 错!会创建第二个控制块
auto self = shared_from_this(); // 对!从已有控制块获取 shared_ptr
async_operation([self] { self->on_done(); });
}
private:
void on_done() { /* ... */ }
};
面试口径:enable_shared_from_this 让对象能安全获取"指向自己的 shared_ptr",前提是对象必须先被 shared_ptr 管理(make_shared 或 shared_ptr 构造),直接栈上对象调用 shared_from_this() 是 UB。
C++17 常用新特性
#include <optional>
#include <variant>
#include <string_view>
// optional:表示"可能没有值"
optional<User> find_user(int id) {
if (exists(id)) return load_user(id);
return nullopt;
}
if (auto u = find_user(123)) { u->print(); }
// variant:类型安全的 union
variant<int, string, float> v = 42;
v = "hello"s; // 现在是 string
int* pi = get_if<int>(&v); // pi == nullptr,当前是 string
cout << get<string>(v); // "hello"
visit([](auto&& val) { /* 类型分支处理 */ }, v);
// string_view:非拥有的字符串视图,零拷贝
string_view sv = "hello world"; // 不拷贝,直接指向字面量
void parse(string_view sv); // 参数零拷贝传字符串
// ⚠️ 生命周期坑!string_view 不持有数据
string_view bad() {
string s = "temporary";
return s; // 悬垂引用!s 销毁后 sv 指向无效内存
}
// structured bindings:结构化绑定
auto [iter, inserted] = m.insert({"key", 42});
auto& [k, v] = *m.begin();
for (auto&& [key, value] : m) { cout << key << value; }
// if constexpr:编译期 if
template <typename T>
auto serialize(T&& val) {
if constexpr (is_integral_v<remove_cvref_t<T>>) {
return to_string(val);
} else {
return val.to_json();
}
}
C++20 核心新特性
#include <concepts>
#include <ranges>
#include <format>
#include <coroutine>
// Concepts:模板约束,替代 SFINAE 的友好写法
template <typename T>
concept Integral = is_integral_v<T>;
template <Integral T> // 简洁形式
T add(T a, T b) { return a + b; }
// 要求形式
template <typename T> requires Integral<T>
T sub(T a, T b) { return a - b; }
// Ranges:函数式 pipeline
vector<int> v = {1,2,3,4,5,6};
auto result = v | views::filter([](int x) { return x % 2 == 0; })
| views::transform([](int x) { return x * x; })
| ranges::to<vector>(); // {4, 16, 36}
// std::format:类型安全的格式化
string s = format("Hello, {}! The answer is {}.", "world", 42);
// 比 printf 类型安全,比 iostream 快很多
// Coroutines:三个关键字 co_await / co_yield / co_return
// 简化异步编程,但自己写 coroutine handle 很复杂,一般用库封装
Task<int> async_compute() {
int a = co_await fetch_a();
int b = co_await fetch_b();
co_return a + b;
}
模板进阶:Variadic Templates、SFINAE、constexpr
// Variadic templates(C++11):参数包展开
template <typename T>
T sum(T t) { return t; }
template <typename T, typename... Rest>
T sum(T t, Rest... rest) {
return t + sum(rest...); // 递归展开
}
int total = sum(1, 2, 3, 4); // 10
// C++17 fold expressions 简化
template <typename... Args>
auto sum2(Args... args) {
return (args + ...); // 右折叠:((1 + 2) + 3) + 4
}
// SFINAE:Substitution Failure Is Not An Error
// 替换失败不算错误,从重载集中移除,用于编译期分支
template <typename T>
enable_if_t<is_integral_v<T>, string> to_str(T x) {
return to_string(x);
}
template <typename T>
enable_if_t<is_floating_point_v<T>, string> to_str(T x) {
return to_string(x);
}
// C++17 后被 if constexpr 替代,C++20 后被 Concepts 替代
// constexpr:编译期计算
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int f10 = factorial(10); // 编译期就算好了,运行时直接用
技术演进:SFINAE(C++98,复杂)→ void_t / enable_if(C++11/14,可用但难读)→ if constexpr(C++17,分支更清晰)→ Concepts(C++20,最清晰,编译器错误信息也好读)。
unique_ptr 自定义删除器
unique_ptr 的删除器是类型的一部分(不像 shared_ptr 类型擦除),用于管理非 new 分配的资源:
// 文件指针 RAII 包装
unique_ptr<FILE, decltype(&fclose)> fp(fopen("a.txt", "r"), &fclose);
// C++20 可以用 lambda 作删除器(C++11/14 中 lambda 不能用于 default-constructible 删除器场景)
auto file_deleter = [](FILE* f) { if (f) fclose(f); };
unique_ptr<FILE, decltype(file_deleter)> fp2(fopen("a.txt", "r"), file_deleter);
// 管理数组
unique_ptr<int[]> arr(new int[100]); // 自动用 delete[]
Q: shared_ptr 线程安全吗?
分两方面:①shared_ptr 对象本身(引用计数操作)是线程安全的——use_count 的增减是原子操作,多个线程各自持有同一个 shared_ptr 的拷贝,同时析构/拷贝是安全的。②被管理对象的访问不是线程安全的——多个线程通过 shared_ptr 修改同一对象,仍然需要外部同步(mutex)。③同一个 shared_ptr 对象被多个线程同时读写(如一个线程 reset、一个线程拷贝)也不是线程安全的——shared_ptr 的两个指针(对象指针+控制块指针)不是原子同时更新的,并发读写同一个 shared_ptr 实例是 data race(UB),需要加锁或用全局函数 atomic_load/atomic_store(C++20 提供了 atomic<shared_ptr<T>> 特化)。面试要点:引用计数是原子的,但对象访问和 shared_ptr 实例本身不是。
Q: string_view 有什么坑?
最大的坑就是生命周期问题(悬垂引用):string_view 不拥有数据,只是一个"指针+长度"的视图。如果它指向的字符串销毁了,string_view 就变成悬垂指针。常见陷阱:①返回局部 string 的 string_view(函数内 string s; return string_view(s); 返回后 s 销毁,sv 失效)。②从临时 string 构造 string_view(string_view sv = string("temporary"); 这行之后临时 string 销毁,sv 悬垂)。③string 重新分配后保留的 string_view(string s = "hi"; string_view sv = s; s += " very long string that causes reallocation"; sv 悬垂!因为 s 扩容后原 buffer 被释放)。④不是 null-terminated,不能直接传给需要 C 字符串的函数(如 printf("%s", sv.data()) 是错的,sv 中间可能有 '\0' 或末尾没有 '\0')。string_view 适合用作函数参数(调用方保证传入数据在调用期间有效),不适合存储和返回。
Q: enable_shared_from_this 解决什么问题?为什么不能直接 shared_ptr<T>(this)?
问题场景:类内部需要把"指向自己的 shared_ptr"传给异步回调、注册观察者等,让外部在异步任务完成前对象不会被析构。shared_ptr<T>(this) 的错误在于:它会以 this 指针为对象,创建一个全新的控制块,和原来管理对象的 shared_ptr 的控制块完全独立。结果是两个控制块各自引用计数,当第一个 shared_ptr 计数归零就会 delete 对象,第二个控制块的 shared_ptr 还在时对象已经被销毁——double free 或 use-after-free。enable_shared_from_this 在对象内部存了一个 weak_ptr,指向自己的控制块;shared_from_this() 从这个 weak_ptr lock 出 shared_ptr,使用的是同一个控制块,引用计数正确增加。注意:对象必须先被 shared_ptr 管理(即外面有 shared_ptr 指向它),在构造函数内调用 shared_from_this() 是 UB(此时还没创建 shared_ptr,weak_ptr 未初始化)。
Q: Concepts 和 SFINAE 有什么关系?
Concepts(C++20)是 SFINAE(C++98)的"进化版",都用于约束模板参数、在重载/特化时做编译期分支,但 Concepts 解决了 SFINAE 的主要痛点:①可读性——SFINAE 靠 enable_if、decltype、void_t 等黑魔法,代码非常难读;Concepts 用 concept 关键字命名约束,template<Integral T> 像自然语言。②错误信息——SFINAE 替换失败时编译器输出几百行模板栈信息;Concepts 约束不满足时直接告诉你"T 不满足 Integral 概念",错误清晰。③表达力——Concept 内可以写复合要求(compound requirement)、嵌套要求,比 enable_if 更容易表达"需要有某个成员函数/支持某个操作"。但 Concepts 不做的事:它不改变 SFINAE 的底层机制,Concept 约束失败仍然是通过 substitution failure 从重载集中移除模板(或报错)。SFINAE 在理解 C++ 模板机制上仍有价值,Concepts 是工程上更好用的接口。