C++11 语言基础与常见陷阱
移动语义与右值引用 · 完美转发 · 智能指针与循环引用 · auto/decltype · lambda 捕获 · RAII · 特殊成员函数五法则 · 内存模型与原子 · 虚函数与 vtable · 对象内存布局 · STL 容器底层 · new/delete · 未定义行为
一句话抓手
C++11 的面试陷阱几乎都绕着三个真相:移动语义只是「偷资源」,被移动对象进入合法但未指定状态;T&& 是否右值引用取决于是否发生类型推导——模板里的 T&& 是「转发引用」,可能绑左值;对象生命周期与所有权是一切内存 bug 的根,RAII + 智能指针是把生命周期绑到作用域上的答案。抓住这三条,八成 C++ 语法题当场破。
场景问题
打个比方(移动语义):拷贝是"照着你家买一套一模一样的家具",又慢又费钱;移动是"直接把你家家具整套搬走"——只过户内部那根指向堆资源的指针,秒完成。被搬空的旧房子还在、还能拆(可析构)、你也能重新往里搬新东西(可赋新值),但别指望里头还留着原来的家具("值未指定"就是这意思)。类比失效边界:这个"搬比买快"的直觉只对握着堆资源的对象成立;像
int、小结构体、含定长数组成员的类型,压根没东西可"搬",移动就退化成老老实实的拷贝,一分便宜没占到。而且"搬完清空源"是常见实现、并非标准强制——所以标准只承诺被移动对象"合法但未指定",你不能依赖它一定变空。
面试白板高频「说输出 / 找 UB / 改错」题,本质在考对底层对象模型的理解:
| 题目 | 考点 | 直觉答案往往错在 |
|---|---|---|
std::move(x) 之后 x 还能用吗 | 移动后状态 | 能用但值「未指定」;只保证可析构/可赋新值 |
模板 void f(T&&) 传左值会怎样 | 转发引用 + 引用折叠 | 这不是右值引用,T 被推导为 U&,接受左值 |
shared_ptr 互相持有会泄漏吗 | 循环引用 | 会,引用计数永不归零,需 weak_ptr 打破 |
vector<bool> 是 bool 数组吗 | 特化位压缩 | 不是,位压缩,operator[] 返回代理对象非 bool& |
lambda [=] 捕获 this 拷贝了对象吗 | 捕获语义 | 只拷了指针 this,成员仍是引用访问 |
auto x = {1,2,3} 的类型 | auto 推导特例 | 是 initializer_list<int>,不是数组/vector |
返回局部对象要 std::move 吗 | RVO / NRVO | 不要,move 反而抑制返回值优化 |
再往下一层是「对象模型与 STL 底层」题,考的是内存布局与容器实现而非语法:
| 题目 | 考点 | 直觉答案往往错在 |
|---|---|---|
| 虚函数是怎么实现运行期分派的 | vptr + vtable | 每个含虚函数的对象头部多一个指向类 vtable 的指针 |
基类析构不加 virtual 会怎样 | 虚析构 | delete 基类指针 只调基类析构 → 派生资源泄漏(UB) |
空类 class E{}; 的 sizeof 是多少 | 对象布局 | 是 1,不是 0——要保证不同对象地址不同 |
shared_ptr 的引用计数存在哪、线程安全吗 | 控制块 | 存在堆上控制块,计数原子安全,但所指对象不安全 |
vector 扩容后老迭代器还能用吗 | 迭代器失效 | 不能,扩容会重新分配并搬迁,老迭代器/指针全失效 |
map 和 unordered_map 底层结构 | 红黑树 vs 哈希 | map=红黑树(有序 O(log n));unordered=哈希桶(均摊 O(1)) |
malloc 和 new 的区别 | 语言 vs 库 | new 会调构造函数并按类型算大小;malloc 只给裸内存 |
| 构造函数里调用虚函数会动态分派吗 | 构造期 vtable | 不会,此刻 vptr 还指向当前基类,调的是基类版本 |
实现方案
移动语义与右值引用
右值引用 T&& 绑定到将亡值(右值),让对象「偷」走源对象的堆资源而非深拷贝。移动构造/移动赋值把源对象的指针置空、计数转移:
class Buf {
char* p_; size_t n_;
public:
Buf(Buf&& o) noexcept : p_(o.p_), n_(o.n_) { // 偷资源
o.p_ = nullptr; o.n_ = 0; // 源置为可析构的空状态
}
};
移动后对象的状态
std::move(x) 本身不移动任何东西,它只是把 x 无条件转成右值引用(一个 static_cast<T&&>),真正的搬运发生在随后调用的移动构造/赋值里。被移动后的 x 进入有效但未指定(valid but unspecified)状态:可以安全析构、可以重新赋值,但不能假设它的值。
std::string s = "hello";
std::string t = std::move(s);
// s 现在可能是 "",也可能不是——标准只保证「可析构、可再赋值」
std::cout << s.size(); // 合法但结果未指定,别依赖
noexcept 不能省
移动构造/赋值必须标 noexcept,否则 vector 扩容时为保证强异常安全会退回用拷贝而非移动——性能优化直接失效。
完美转发与转发引用(引用折叠)
模板参数 T&& 在发生类型推导时叫「转发引用」(Forwarding / Universal Reference),不是右值引用。规则由引用折叠决定:& + && → &,&& + && → &&。传左值时 T 推导为 U&,T&& 折叠成 U&,于是接受左值。
template <class T>
void wrapper(T&& arg) { // 转发引用:既能收左值也能收右值
target(std::forward<T>(arg)); // 完美转发:左值仍是左值、右值仍是右值
}
std::forward<T>(arg) 是有条件的 move:只有当 T 推导为非引用(原实参是右值)时才转成右值,否则保持左值。用 move 转发左值会误偷调用方的资源——转发场景一律 forward。
智能指针与循环引用
unique_ptr:独占所有权,不可拷贝只可移动,零开销,首选shared_ptr:引用计数共享,计数原子操作有开销;控制块与对象最好用make_shared一次分配weak_ptr:不增加计数的观察者,用lock()提升为shared_ptr
shared_ptr 循环引用
struct Node {
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev; // 双向都用 shared → 互相持有
};
// a->next = b; b->prev = a; 出作用域后 a、b 计数都停在 1,永不析构 → 泄漏
规避:父子/双向结构中,一个方向用 weak_ptr 打破环(通常「回指」的一方用 weak)。
其他坑:enable_shared_from_this 才能在成员函数里安全拿到自身的 shared_ptr(直接 shared_ptr<T>(this) 会造出第二个独立控制块 → 双重释放);make_shared 把对象和控制块合并分配,但对象内存会随 weak_ptr 存活而延迟释放。
auto / decltype 推导
auto按值推导时丢弃顶层 const 和引用(像模板实参推导);要保留写auto&/const auto&auto x = {1,2,3}特例:推成std::initializer_list<int>decltype(expr):不求值,保留引用与 const;decltype((x))加括号得到int&(左值表达式)而非intdecltype(auto)(C++14):按decltype规则推导,用于完美转发返回值
lambda 捕获
int a = 1;
auto f1 = [a]() { return a; }; // 按值捕获,闭包内是拷贝快照
auto f2 = [&a]() { a++; }; // 按引用捕获,a 生命周期必须长于闭包
auto f3 = [=]() { /*...*/ }; // 隐式按值捕获用到的变量
捕获 this 与悬垂引用
成员函数里 [=] / [&] 捕获的是 this 指针(即使写 [=] 也只拷指针,成员仍按 this-> 访问)。若闭包生命周期超过对象 → 悬垂 this。C++11 无法只拷贝成员,需手动 [copy = *this](C++14)或显式拷出所需成员。[&] 捕获引用同理:闭包活得比被捕获变量长就是悬垂引用,异步回调里高发。
特殊成员函数:五法则 / 零法则
一旦你手写了析构、拷贝构造、拷贝赋值、移动构造、移动赋值中的任何一个,编译器对其余的自动生成规则就会改变(例如声明了析构或拷贝,移动就不再自动生成)。五法则:要写一个就把该写的都写全。零法则(更推荐):用 RAII 类型(智能指针、容器)管资源,让所有特殊成员都能由编译器正确默认生成,自己一个都不写。
内存模型与原子
C++11 首次有了跨线程的内存模型。std::atomic<T> 默认 memory_order_seq_cst(顺序一致,最强也最慢)。放松序(acquire/release/relaxed)性能高但极易写错——非专家场景一律用默认 seq_cst。数据竞争(两线程无同步地读写同一非原子变量,且至少一个是写)是未定义行为,不是「读到旧值」那么温和。
以上是「语言语法」层。再往下是对象模型与 STL 底层,是 C++ 岗位区分度最高的追问点。
虚函数:vptr、vtable 与动态分派
一个类只要有虚函数,编译器就为它生成一张 vtable(虚函数表,每类一张,存函数指针),并在每个对象的开头塞一个 vptr 指向本类 vtable。调用 p->foo() 时先取 p->vptr、查表拿到实际函数地址再调——这就是运行期多态:
struct Base { virtual void f(); void g(); }; // f 进 vtable,g 不进
struct Derived: Base { void f() override; }; // Derived 的 vtable 里 f 槽指向自己
Base* p = new Derived;
p->f(); // 查 p->vptr → Derived::vtable → Derived::f (动态分派)
p->g(); // 非虚,编译期按静态类型 Base 直接绑定
构造/析构期间虚函数不多态
构造函数运行时,vptr 还指向当前正在构造的这层(派生部分尚未初始化),所以在构造函数里调虚函数只会调到当前类的版本,不会分派到派生类——这是「构造期调虚函数」的经典陷阱,析构期同理(vptr 已退回基类)。
虚析构函数:为什么基类析构要 virtual
通过基类指针 delete 一个派生对象,若基类析构非虚,只会调用基类析构、派生类的析构不执行 → 派生类持有的资源泄漏,行为未定义:
struct Base { ~Base(); }; // 非虚!
struct Derived: Base { std::string big; };
Base* p = new Derived;
delete p; // ❌ 只调 ~Base,~Derived 不跑,big 泄漏(UB)
规则:一个类只要打算被继承并通过基类指针删除,析构函数就必须 virtual。反之,不做多态基类的类别加虚析构(会平白多一个 vptr、失去平凡析构)。
对象内存布局与对齐
- 空类
sizeof==1:C++ 要求不同对象有不同地址,空类也得占 1 字节占位(EBO 空基类优化例外,继承时可压到 0)。 - 含虚函数 → 多一个指针:对象头部的 vptr 通常 8 字节(64 位),这也是「虚函数有空间/时间成本」的来源。
- 内存对齐:成员按各自对齐要求排布,编译器插入 padding;调整成员顺序(大到小)可减小
sizeof。
struct A { char c; int i; char d; }; // sizeof=12:c+3pad + i + d+3pad
struct B { int i; char c; char d; }; // sizeof=8 :i + c + d + 2pad
shared_ptr 的控制块与 make_shared
shared_ptr 是两个指针:一个指向对象,一个指向堆上的控制块(含强引用计数 + 弱引用计数)。计数是原子操作(线程安全地增减),但所指对象本身不是线程安全的——多线程改同一对象仍要自己加锁。
auto a = std::make_shared<Foo>(x); // ✅ 一次分配:对象 + 控制块连续
std::shared_ptr<Foo> b(new Foo(x)); // ✗ 两次分配;且异常安全性差
make_shared一次分配同时放对象和控制块,少一次 malloc、cache 友好、异常安全;缺点是weak_ptr存活会连带对象内存一起不释放。- 循环引用(互相
shared_ptr)计数永不归零 → 一方改weak_ptr(见循环引用)。
STL 容器底层与迭代器失效
| 容器 | 底层结构 | 复杂度 | 迭代器失效点 |
|---|---|---|---|
vector | 连续数组 | 随机访问 O(1),尾插均摊 O(1) | 扩容(重分配)使全部失效;中间 insert/erase 使其后失效 |
deque | 分段连续(多块) | 两端 O(1) | 中间操作失效;两端操作使迭代器失效但引用可能保留 |
list | 双向链表 | 插删 O(1) | 只有被删元素失效,其余稳定 |
map/set | 红黑树 | 增删查 O(log n),有序 | 只有被删元素失效 |
unordered_map/set | 哈希桶 | 均摊 O(1) | rehash(超装载因子)使迭代器失效,引用/指针仍有效 |
vector 扩容与迭代器失效
push_back 触发扩容时,vector 申请更大内存(通常 GCC 2×、MSVC 1.5×)、把元素搬迁过去、释放旧内存——所有指向旧内存的迭代器、指针、引用全部失效。边遍历边 push_back、或缓存了 &v[0] 再扩容,都是经典 UB。预知规模先 reserve 可避免反复扩容和失效。
选型:要有序遍历 / 范围查询用 map(红黑树);只要快速查找用 unordered_map(哈希),但注意哈希冲突和 rehash 抖动。
new/delete vs malloc/free、placement new
new= 分配内存(operator new)+ 调构造函数,按类型自动算大小、返回带类型指针、失败抛bad_alloc。malloc只给一块裸内存,不调构造、返回void*、失败返回NULL。- 必须配对:
new↔delete、new[]↔delete[]、malloc↔free,混用是 UB。 - placement new
new (ptr) T(args)在已有内存上构造对象(对象池、vector内部就靠它),需手动调析构p->~T(),不释放内存。
为什么这么做
- 移动语义:C++ 的值语义带来可预测的生命周期,但深拷贝昂贵。右值引用让「反正要销毁的临时对象」把资源直接转移,把
O(n)拷贝降为O(1)指针搬运——vector/string的返回、push_back临时对象全靠它零成本。 - RAII + 智能指针:C++ 没有 GC,靠「资源获取即初始化」把资源生命周期绑定到栈对象的作用域,析构函数保证异常路径也释放。这是 C++ 内存安全的核心范式,比手动
new/delete可靠得多。 - 完美转发:泛型库(
make_shared、emplace_back、std::thread)要把任意实参原封不动传给构造函数,转发引用 +forward是保持值类别(左值/右值)不丢失的唯一手段。 - vtable 的空间换时间:把「按对象实际类型选函数」下沉成一次指针查表,代价是每对象一个 vptr、每次虚调一次间接跳转——用极小固定开销换运行期多态,比手写类型分支可维护得多。
- STL 用不同底层结构:
map/unordered_map分别用红黑树/哈希,是为了让使用者按「要不要有序、要 O(log n) 稳定还是 O(1) 均摊」按需选择,而不是一种结构强塞所有场景。
为什么别的选择不行
- 为什么不用 GC 而用 RAII:GC 带来不可预测的停顿与额外内存开销,且无法及时释放非内存资源(文件句柄、锁、连接)。RAII 确定性析构在资源用完那一刻就释放,契合 C++「零成本抽象、你不用的不为它付费」的哲学。
- 为什么
move不真的移动:把「转成右值」和「实际搬运」分离,是为了让搬运逻辑写在类型自己的移动构造里(类型最懂怎么偷自己的资源),std::move保持极致轻量(编译期一个 cast,零运行时开销)。 - 为什么移动后不清零到确定值:标准只要求「有效且可析构」,不强制清零是为了给实现留优化空间(清零也是成本)。想要确定状态请自己
x.clear()。 - 为什么非虚析构不能直接删派生对象:基类指针只知道基类布局,非虚析构编译期就把
delete绑到基类析构,看不到派生层——补救不是「记得转型」,而是基类析构声明 virtual,让删除走 vtable 找到最派生析构。 - 为什么不总用
unordered_map:哈希均摊 O(1) 但常数大、内存占用高、rehash 有抖动,且不保证顺序、最坏可退化。需要有序遍历、范围查询或稳定最坏复杂度时,红黑树的map反而更合适。 - 为什么不用
malloc建对象:malloc不调构造函数、不算类型大小、不做异常处理,拿到的是未初始化裸内存,直接当对象用是 UB。构造对象一律new/make_*,malloc只留给需要裸内存的底层场景。
沉淀结论
面试速答清单:
std::move只是 cast 成右值;被移动对象「有效但未指定」,只可析构/再赋值- 移动构造/赋值必须
noexcept,否则 vector 扩容退回拷贝 - 模板
T&&= 转发引用(可绑左值),配std::forward完美转发;move只用于确定的右值 shared_ptr循环引用泄漏 → 一方改weak_ptr;成员内取自身用enable_shared_from_thisunique_ptr优先,零开销;shared_ptr计数是原子操作有成本auto丢弃顶层 const/引用;auto x={..}是 initializer_list;decltype((x))是引用- lambda
[=]/[&]在成员里捕获的是this指针,闭包活得比对象长即悬垂 - 五法则:写一个特殊成员就写全;更优是零法则,用 RAII 让编译器全默认
- 返回局部对象别
std::move,会抑制 RVO/NRVO - 数据竞争是 UB;不确定就用默认 seq_cst 原子
- 虚函数=每类一张 vtable + 每对象一个 vptr;构造/析构期虚调不多态
- 多态基类析构必须 virtual,否则 delete 基类指针漏派生资源(UB)
- 空类 sizeof=1;含虚函数多一个 vptr;调成员顺序省 padding
shared_ptr计数在堆上控制块、原子安全,但所指对象不安全;优先make_shared一次分配- vector 扩容(GCC 2×)重分配 → 老迭代器/指针全失效;预知规模先 reserve
- map=红黑树(有序 O(log n));unordered_map=哈希(均摊 O(1));new 调构造、malloc 只给裸内存
记忆口诀
- 移动语义:偷资源 / 有效但未指定 / 必须 noexcept
- 转发引用:有推导才是
T&&/ 引用折叠 /forward而非move - 智能指针:unique 优先 / shared 计数原子 / weak 破环 / make_shared 一次分配
- 生命周期:RAII 绑作用域 / 五法则要写全 / 零法则更优
- 推导与陷阱:auto 丢 const 引用 /
[=]只拷 this / RVO 别 move - 对象模型:vptr+vtable / 多态基类虚析构 / 空类 sizeof=1 / 构造期不多态
- STL 底层:vector 扩容全失效 / map 红黑树有序 / unordered 哈希均摊 / new 带构造
内容来源
关键点整理自 cppreference、ISO C++ 标准(N3337,C++11)、《Effective Modern C++》(Scott Meyers,条款 1-42)与 isocpp Core Guidelines 重写为五段式。请以官方标准与 cppreference 为准。
自测:合上资料能说清楚吗?
std::move(x)到底做了什么?之后x处于什么状态、还能怎么用?
参考答案
move 只是 static_cast<T&&>,本身不搬运任何东西;真正的移动发生在随后的移动构造/赋值。之后 x 进入有效但未指定状态:可析构、可重新赋值,但不能假设其值。
- 模板
void f(T&& arg)传一个左值会发生什么?和普通右值引用有何区别?
参考答案
这是转发引用(发生类型推导),非右值引用。传左值时 T 推导为 U&,经引用折叠 &+&&→&,T&& 变 U&,故能收左值。普通右值引用(无推导)只绑右值。
- 为什么移动构造/赋值必须标
noexcept?不标会怎样?
参考答案
vector 扩容为保证强异常安全,若移动可能抛异常则退回用拷贝搬迁元素。不标 noexcept → 移动优化直接失效,退化为 O(n) 深拷贝。
shared_ptr和weak_ptr各解决什么问题?请对比它们在打破循环引用时的作用。
参考答案
shared_ptr 引用计数共享所有权,双向互持会让计数永不归零 → 泄漏。weak_ptr 不增计数,仅观察,用 lock() 提升。规避:让「回指」一方改用 weak_ptr 打破环。
- 返回局部对象时该不该写
return std::move(local)?为什么?
参考答案
不该。编译器会做 RVO/NRVO,直接在返回位置构造,零拷贝。写 move 反而抑制返回值优化,把可省略的构造变成一次移动构造,得不偿失。
- 虚函数是怎么实现运行期多态的?为什么在构造函数里调虚函数不会分派到派生类?
参考答案
含虚函数的类有一张 vtable(每类一张,存函数指针),每个对象头部有 vptr 指向它。p->f() 先取 p->vptr 查表拿实际地址再调,实现动态分派。构造期 vptr 还指向当前正在构造的这层(派生部分未初始化),所以只调到当前类版本,不会分派到派生类;析构期 vptr 已退回基类,同理。
- 基类析构函数不声明
virtual,delete一个基类指针指向的派生对象会怎样?
参考答案
只会调用基类析构,派生类析构不执行,派生类持有的资源泄漏,标准规定为未定义行为。规则:类打算被继承并通过基类指针删除时,析构必须 virtual;不做多态基类则不加(省一个 vptr)。
shared_ptr的引用计数存在哪里?它线程安全吗?make_shared相比new有什么优势?
参考答案
计数在堆上的控制块(强计数+弱计数),增减是原子操作、线程安全;但所指对象本身不是线程安全的,多线程改同一对象仍要加锁。make_shared 一次分配同时放对象和控制块——少一次 malloc、cache 友好、异常安全;代价是 weak_ptr 存活会连带对象内存不释放。
vector扩容时迭代器/指针为什么会失效?如何避免反复失效?
参考答案
push_back 超容量时 vector 申请更大内存(GCC 2×、MSVC 1.5×)、搬迁元素、释放旧内存,指向旧内存的迭代器/指针/引用全部失效。边遍历边 push_back 或缓存 &v[0] 后扩容都是 UB。预知规模先 reserve(n) 一次到位,避免反复扩容与失效。
map和unordered_map底层结构、复杂度、适用场景分别是什么?
参考答案
map=红黑树,增删查 O(log n)、有序,支持范围查询、最坏复杂度稳定。unordered_map=哈希桶,均摊 O(1) 但常数大、无序、rehash 有抖动、最坏可退化。要有序/范围查询用 map;只要快速查找用 unordered_map。