笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • 通用后台基础(跨域)

    • 并发模型 · 进程 / 线程 / 协程
    • 操作系统核心与零拷贝
    • Go 语言基础与常见陷阱
    • C++11 语言基础与常见陷阱
    • C++20 语言基础与常见陷阱
    • Rust 语言基础与常见陷阱
    • 设计模型 · Actor / CSP / Reactor / 同步异步
    • GC 与 STW · Go / JVM
    • 可观测性
    • 时序异常检测(EWMA / ARIMA / 滑动窗口)
    • 数据库范式与存储引擎:从关系型到向量库
    • MySQL InnoDB 索引与事务
    • Redis 版本演进 & 分布式
    • 消息队列 · 可靠投递与选型
    • 分布式事务 · 2PC / TCC / Saga / 最终一致性
    • HTTP / HTTPS / TLS 与 RPC
    • 加密基础:对称 / 非对称 / 哈希与组合模式
    • 叙事主骨架轴选择方法论 SOP

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&(左值表达式)而非 int
  • decltype(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_this
  • unique_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 为准。

自测:合上资料能说清楚吗?

  1. std::move(x) 到底做了什么?之后 x 处于什么状态、还能怎么用?
参考答案

move 只是 static_cast<T&&>,本身不搬运任何东西;真正的移动发生在随后的移动构造/赋值。之后 x 进入有效但未指定状态:可析构、可重新赋值,但不能假设其值。

  1. 模板 void f(T&& arg) 传一个左值会发生什么?和普通右值引用有何区别?
参考答案

这是转发引用(发生类型推导),非右值引用。传左值时 T 推导为 U&,经引用折叠 &+&&→&,T&& 变 U&,故能收左值。普通右值引用(无推导)只绑右值。

  1. 为什么移动构造/赋值必须标 noexcept?不标会怎样?
参考答案

vector 扩容为保证强异常安全,若移动可能抛异常则退回用拷贝搬迁元素。不标 noexcept → 移动优化直接失效,退化为 O(n) 深拷贝。

  1. shared_ptr 和 weak_ptr 各解决什么问题?请对比它们在打破循环引用时的作用。
参考答案

shared_ptr 引用计数共享所有权,双向互持会让计数永不归零 → 泄漏。weak_ptr 不增计数,仅观察,用 lock() 提升。规避:让「回指」一方改用 weak_ptr 打破环。

  1. 返回局部对象时该不该写 return std::move(local)?为什么?
参考答案

不该。编译器会做 RVO/NRVO,直接在返回位置构造,零拷贝。写 move 反而抑制返回值优化,把可省略的构造变成一次移动构造,得不偿失。

  1. 虚函数是怎么实现运行期多态的?为什么在构造函数里调虚函数不会分派到派生类?
参考答案

含虚函数的类有一张 vtable(每类一张,存函数指针),每个对象头部有 vptr 指向它。p->f() 先取 p->vptr 查表拿实际地址再调,实现动态分派。构造期 vptr 还指向当前正在构造的这层(派生部分未初始化),所以只调到当前类版本,不会分派到派生类;析构期 vptr 已退回基类,同理。

  1. 基类析构函数不声明 virtual,delete 一个基类指针指向的派生对象会怎样?
参考答案

只会调用基类析构,派生类析构不执行,派生类持有的资源泄漏,标准规定为未定义行为。规则:类打算被继承并通过基类指针删除时,析构必须 virtual;不做多态基类则不加(省一个 vptr)。

  1. shared_ptr 的引用计数存在哪里?它线程安全吗?make_shared 相比 new 有什么优势?
参考答案

计数在堆上的控制块(强计数+弱计数),增减是原子操作、线程安全;但所指对象本身不是线程安全的,多线程改同一对象仍要加锁。make_shared 一次分配同时放对象和控制块——少一次 malloc、cache 友好、异常安全;代价是 weak_ptr 存活会连带对象内存不释放。

  1. vector 扩容时迭代器/指针为什么会失效?如何避免反复失效?
参考答案

push_back 超容量时 vector 申请更大内存(GCC 2×、MSVC 1.5×)、搬迁元素、释放旧内存,指向旧内存的迭代器/指针/引用全部失效。边遍历边 push_back 或缓存 &v[0] 后扩容都是 UB。预知规模先 reserve(n) 一次到位,避免反复扩容与失效。

  1. map 和 unordered_map 底层结构、复杂度、适用场景分别是什么?
参考答案

map=红黑树,增删查 O(log n)、有序,支持范围查询、最坏复杂度稳定。unordered_map=哈希桶,均摊 O(1) 但常数大、无序、rehash 有抖动、最坏可退化。要有序/范围查询用 map;只要快速查找用 unordered_map。

最近更新: 2026/9/10 11:38
Prev
Go 语言基础与常见陷阱
Next
C++20 语言基础与常见陷阱