返回文章索引

C++ 基本功面试题库:从类型系统到并发编程

按面试问答形式系统复习 C++ 基本功,覆盖类型、指针、生命周期、RAII、函数、对象模型、多态、模板、STL、编译链接、未定义行为、调试性能与并发。

GitHub 原文

这是一份面向面试前快速回忆的 C++ 基本功题库。每题先给出可以直接说出口的“面试短答”,再展开规则、边界和常见错误。复习时不要只记关键词:至少要能说清楚规则是什么、为什么这样设计、错误代码会怎样失效、工程中如何规避

本文以 C++11 的对象模型和资源管理为主线,补充 C++14-C++20 高频设施。算法、操作系统、网络、图形学和具体引擎机制不在本文展开。

一、面试前怎样使用这份题库

1. 只剩 10 分钟

优先回忆下面这些高频边界:

  1. const、引用、指针和 auto 的推导规则。
  2. 对象生命周期、RAII、五个特殊成员函数和智能指针控制块。
  3. 虚析构、对象切片、构造析构期间的虚调用。
  4. vector 扩容、容器迭代器失效和 map/unordered_map 的选择。
  5. 编译与链接的区别、ODR、模板为什么常放头文件。
  6. data race、互斥锁、条件变量、atomic 的能力边界。

2. 只剩 30 分钟

按章节先读每题的“面试短答”,再检查自己能否举出一个反例。凡是无法解释“为什么”的题目,再读展开部分。

3. 完整复习

模块 必须掌握 了解边界
类型系统 初始化、const、推导、值类别、四种 cast 精确位宽类型的可用条件
生命周期 存储期、RAII、智能指针、特殊成员 placement new、对象池
对象模型 构造析构、多态、切片、虚析构 多重继承、虚继承、ABI 布局
标准库 容器选择、复杂度、失效规则、算法前提 partial_sortnth_element
工程构建 翻译单元、ODR、链接错误、动态库 PCH、跨库 ABI
正确性 UB、异常安全、调试工具、性能测量 微基准偏差
并发 data race、锁、条件变量、原子操作 内存序、false sharing、CAS

二、类型、变量、表达式与转换

Q1:声明、定义、初始化和赋值有什么区别?

面试短答: 声明告诉编译器名字和类型;定义提供实体;初始化在对象生命周期开始时建立初始状态;赋值修改一个已经存在的对象。它们对应不同的编译规则和对象状态。

extern int count; // 声明,定义在其他位置
int count = 0;    // 定义并初始化

int value{};      // 定义并值初始化为 0
value = 42;       // 赋值

函数声明没有函数体,变量的 extern 声明通常不提供该实体的存储。普通自动局部标量若没有初始化,其值不确定,不应在赋值前读取。类的构造函数应把对象直接初始化到合法状态;const 对象也必须在初始化阶段获得值,因为之后不能通过普通接口赋值。

Q2:基础整数类型有什么常见风险?

面试短答: 风险主要来自宽度、范围、整数提升以及有符号和无符号混合转换。负数和 size_t 比较时可能先变成很大的无符号数,因此必须先检查范围或使用安全比较工具。

int index = -1;
std::size_t size = 1;
bool result = index < size; // 通常为 false

if (index >= 0 && static_cast<std::size_t>(index) < size) {
    // 范围已确认
}

整数类型的实际宽度由实现决定。需要明确位宽时可使用 <cstdint> 中的 std::int32_tstd::uint64_t 等,但精确位宽类型只在实现支持对应位宽时提供。C++20 还提供 std::cmp_less 等混合符号安全比较函数。enum class 具有独立作用域且不会隐式转整数,适合表达有限状态。

Q3:顶层 const 和底层 const 是什么?

面试短答: 顶层 const 限制当前对象本身,例如常量指针不能改指向;底层 const 限制通过某条指针或引用路径修改目标对象。const 描述访问路径,不代表对象在所有路径下都永远不变,也不提供线程安全。

const int* p1 = nullptr;       // 指向 const int,p1 可以改指向
int* const p2 = nullptr;       // const 指针,目标可修改,p2 不能改指向
const int* const p3 = nullptr; // 两者都受限

const 成员函数中的 this 可近似理解为 const T* const,它承诺不通过该路径改变对象的逻辑状态。mutable 可用于缓存、统计或锁等不改变抽象值的实现状态,但多个线程访问共享可变数据仍需同步。

Q4:autodecltype 的推导规则有什么区别?

面试短答: auto 大体遵循模板实参推导,按值时通常去掉引用和顶层 constdecltype 更忠实地保留声明类型,或按表达式值类别得到 TT&T&&decltype(x)decltype((x)) 可能不同。

const int value = 10;
auto a = value;             // int
auto& b = value;            // const int&
decltype(value) c = 10;     // const int
decltype((value)) d = value; // const int&,括号内是左值表达式

未加括号的名字或成员访问有特殊规则:直接得到实体的声明类型。其他表达式根据值类别推导。类型推导能减少重复书写,但接口中仍应让读者容易判断它是值、引用还是只读借用。

Q5:什么是左值、右值和移动语义?

面试短答: 左值表达式能稳定定位一个对象;右值通常表示临时结果或资源可被复用的对象。std::move 本身不移动任何资源,只把表达式转换为可绑定到右值引用的形式,真正的资源转移由后续右值重载决定。

std::string source = "player";
std::string target = std::move(source); // 调用移动构造
source.clear();                         // source 仍是有效对象

有名字的变量表达式是左值,即使它的声明类型是 T&&。移动后的对象仍在生命周期内,必须可安全析构和重新赋值;标准库类型通常只保证“有效但值未指定”,不要依赖它一定为空。std::move(const T) 得到 const T&&,而常规移动构造接收 T&&,所以通常会退化为拷贝。

Q6:花括号初始化、圆括号初始化和 nullptr 有哪些考点?

面试短答: 花括号初始化会拒绝许多窄化转换,但可能优先选择 initializer_list 构造函数;圆括号通常按普通构造参数匹配。nullptr 是类型安全的空指针字面量,应替代 0NULL

std::vector<int> a(3, 9); // 三个 9
std::vector<int> b{3, 9}; // 两个元素:3、9

// int x{3.14};           // 编译错误:窄化

回答时要先判断花括号表达的是“一组元素”还是“一组构造参数”。它不是所有场景下都比圆括号更正确。

Q7:静态类型和动态类型有什么区别?

面试短答: 静态类型由编译期表达式决定,控制成员查找和可见接口;动态类型是多态对象运行时最派生的实际类型,参与虚函数分派和 RTTI。

struct Base { virtual ~Base() = default; virtual void run(); };
struct Derived : Base { void run() override; void onlyDerived(); };

Derived object;
Base& ref = object; // 静态类型 Base&,动态类型 Derived
ref.run();          // 调用 Derived::run
// ref.onlyDerived(); // 静态接口中不可见

Q8:C++ 四种显式类型转换分别用于什么场景?

面试短答: static_cast 用于编译期可确定的常规转换;dynamic_cast 用于多态体系的运行时类型检查;const_cast 只调整 cv 限定;reinterpret_cast 用于底层地址或表示重解释,风险最高。

转换 典型用途 关键风险
static_cast 数值、枚举、明确的继承或用户定义转换 向下转换不检查动态类型
dynamic_cast 多态向下或交叉转换 指针失败返回 nullptr,引用失败抛 std::bad_cast
const_cast 适配 cv 限定不合理的旧接口 修改原本定义为 const 的对象是 UB
reinterpret_cast 系统编程中的低层地址/表示操作 对齐、别名、生命周期和可移植性

reinterpret_cast 不等于按位复制。需要复制对象表示时优先考虑 C++20 std::bit_caststd::memcpy。新代码避免 C 风格转换,因为它可能混合尝试多类转换,难以审查。

三、指针、引用、数组与字符串

Q9:指针和引用有什么区别?

面试短答: 引用通常表达“必须存在且不可改绑的借用”;指针可以为空、可以改指向并支持指针运算。二者都不天然表达所有权,也不会自动管理生命周期。

对比项 指针 引用
可否为空 可以为 nullptr 合法引用必须绑定对象
可否改指向 可以 初始化后不能改绑
访问语法 *-> 像对象别名
所有权 裸指针本身不表达 引用本身不表达

很多 ABI 中引用参数底层像地址传递,但这不是语言保证。真正稳定的是语义契约。必选参数优先引用,可选借用使用指针;所有权使用智能指针或其他 RAII 类型表达。

Q10:空指针、野指针和悬空指针分别是什么?

面试短答: 空指针明确表示没有对象;野指针通常保存未初始化或无意义地址;悬空指针曾经有效,但目标对象生命周期已结束或地址因重分配失效。三者都不能解引用,根因和修复方式不同。

int* empty = nullptr;
int* wild; // 未初始化,不能读取或解引用

int* dangling = nullptr;
{
    int local = 42;
    dangling = &local;
} // local 生命周期结束,dangling 悬空

把悬空指针简单置空只能修复这一个别名,不能修复其他仍指向旧对象的路径。根本方案是重构所有权和生命周期。

Q11:指针算术和数组退化有哪些边界?

面试短答: 指针加减只在同一数组对象及其尾后位置范围内有定义;尾后指针可比较但不能解引用。数组包含元素数量,而指针只保存地址。数组在多数表达式和函数参数中会退化为首元素指针。

int values[4]{10, 20, 30, 40};
int* end = values + 4; // 合法的尾后指针

for (int* p = values; p != end; ++p) {
    use(*p);
}

template<std::size_t N>
void inspect(const int (&data)[N]); // 通过数组引用保留长度

两个不属于同一数组的指针不能随意相减或用关系运算判断顺序。数组作为函数形参写成 T data[] 时,本质仍是指针形参,不能从它得到原数组长度。

Q12:C 字符串、std::stringstring_viewspan 怎么选?

面试短答: C 字符串依赖结尾的零字符;string 拥有字符存储;string_view 是地址加长度的非拥有字符视图;span 是连续元素的非拥有范围视图。视图不延长来源生命周期。

std::string name = "Hero";
std::string_view view = name;
name += "_Long_Name"; // 可能重分配,view 可能悬空

void normalize(std::span<float> values);

复制视图便宜正是因为它不复制和管理底层数据。不要返回指向局部 stringstring_view,也不要让异步任务保存生命周期不足的 span。C 字符数组容量和逻辑字符串长度不是同一个概念。

四、内存、存储期、生命周期与 RAII

Q13:存储期和对象生命周期有什么区别?

面试短答: 存储期描述底层存储通常存在多久;对象生命周期描述某个具体类型对象何时在这段存储中有效。地址和字节仍存在,不代表旧对象仍可按原类型访问。

存储期 常见对象 结束时机
自动 普通局部对象 离开块作用域
静态 全局对象、static 对象 程序结束
线程 thread_local 对象 所属线程结束
动态 动态分配的存储 由释放操作或资源管理器决定

异步回调、线程、迭代器和视图都容易把访问路径保存得比对象更久。分析此类问题时先找到对象建立和销毁的位置,再检查每条使用路径是否落在生命周期内。

Q14:栈和堆应该怎样准确回答?

面试短答: C++ 语言层更准确的概念是自动存储期和动态存储期。普通局部对象通常由线程栈实现,动态分配通常来自堆,但这属于常见实现。二者的核心差异是生命周期和管理方式,不应机械回答“栈一定快、堆一定慢”。

动态分配通常增加分配器、间接访问和碎片成本;实际性能还取决于数据布局、访问模式、缓存命中和是否批量分配。优先按所有权和生命周期选择,再以测量结果优化。

Q15:static 在不同位置分别表示什么?

面试短答: 函数局部 static 表示静态存储期并延迟到首次执行初始化;类的静态成员属于类而非每个实例;命名空间作用域的 static 给予内部链接,只在当前翻译单元可见。

C++11 起函数局部静态对象的初始化过程本身线程安全,但初始化完成后对对象的并发读写不自动安全。静态成员函数没有 this 指针。跨翻译单元的静态对象初始化顺序不要互相依赖,必要时使用函数局部静态对象延迟初始化。

Q16:什么是 RAII,为什么它是现代 C++ 的核心?

面试短答: RAII 把资源取得和对象构造绑定,把释放和析构绑定。正常返回、提前返回和异常展开都会销毁已构造的局部对象,因此所有控制流共享同一条清理路径。

资源不仅是内存,还包括锁、文件、套接字、线程、图形句柄和临时状态。RAII 的关键不是“析构里写 delete”,而是让一个类型明确拥有资源并独占或共享释放责任。标准容器、智能指针、lock_guard 都是 RAII。

Q17:new/deletemalloc/free 有什么区别?

面试短答: new 表达式取得存储并构造对象,delete 先析构对象再释放匹配存储;malloc/free 处理原始字节存储,不为普通非平凡 C++ 类型调用构造和析构。两组接口不能交叉配对。

Widget* a = new Widget(42);
delete a;

void* raw = std::malloc(sizeof(Widget));
std::free(raw); // 这里只处理原始存储

同样要区分 new/deletenew[]/delete[]。业务代码应优先使用容器、智能指针和 RAII 封装,而不是让裸 new 暴露在多条控制流中。

Q18:operator new 和 placement new 是什么?

面试短答: operator new 负责取得原始存储;普通 new 表达式还会在存储上构造对象。placement new 在调用者提供的地址上构造对象,不分配存储,因此调用者负责大小、对齐、析构和最终释放。

void* storage = ::operator new(sizeof(Widget));
Widget* object = new (storage) Widget(42);

object->~Widget();
::operator delete(storage);

显式析构后对象生命周期已经结束,即使原始存储仍存在,也不能继续按旧对象访问。对象池和联合体等低层代码必须同时管理存储与对象生命周期。

Q19:unique_ptr 应该怎样使用?

面试短答: unique_ptr 表达独占所有权,不能复制,只能移动。按值传入函数表示转移所有权,按值返回表示把新资源交给调用者。优先用 make_unique 创建。

std::unique_ptr<Node> makeNode(int value) {
    return std::make_unique<Node>(value);
}

auto node = makeNode(42);
install(std::move(node)); // 若按值接收,所有权转入 install

移动后源 unique_ptr 保证为空。若只是临时借用对象,函数应接收 T&T*,不要让调用者为了借用而转移所有权。

Q20:shared_ptr 的控制块保存什么?

面试短答: 控制块通常保存强引用计数、弱引用计数、删除器等管理信息。最后一个强引用消失时销毁被管理对象;控制块可能因 weak_ptr 仍存在而继续保留。

make_shared 通常把对象和控制块合并为一次分配,减少分配次数并改善局部性。多个 shared_ptr 实例共享控制块时,引用计数操作可并发,但这不代表被管理对象线程安全,也不代表可以无同步地同时修改同一个 shared_ptr 实例。

从同一个裸指针分别构造两个 shared_ptr 会建立两个控制块,最终重复释放同一对象,属于未定义行为。

Q21:weak_ptr 解决什么问题?

面试短答: weak_ptr 是不拥有对象的控制块观察者,常用于打破 shared_ptr 强引用环。访问前调用 lock(),成功后得到临时 shared_ptr,从而在使用期间保证对象存活。

if (auto owner = weak.lock()) {
    owner->run();
}

expired() 之后再单独访问存在检查与使用之间的竞态,因此通常直接使用 lock()。树的父子关系、订阅回调和缓存观察者是常见场景。

Q22:为什么需要 enable_shared_from_this

面试短答: 对象在成员函数中需要获得指向自己的 shared_ptr 时,必须复用外部已有控制块。enable_shared_from_this<T>shared_from_this() 生成共享同一控制块的新所有者,避免 shared_ptr(this) 建立第二个控制块。

对象必须已经被有效 shared_ptr 管理,调用 shared_from_this() 才成立;否则会抛 std::bad_weak_ptr。构造函数执行时通常还未完成控制块关联,所以不要在构造阶段调用。C++17 的 weak_from_this() 可取得弱观察关系。

Q23:常见内存错误怎样按根因分类?

面试短答: 泄漏是结束责任丢失;重复释放是多个路径争夺唯一责任;use-after-free 是生命周期结束后仍访问;越界是访问离开合法范围;未初始化读取是对象还没有明确值。

错误 首先被破坏的条件 典型表现
泄漏 无人结束资源责任 内存或句柄持续增长
重复释放 多个路径都认为自己唯一拥有 堆损坏、释放器崩溃
释放后使用 生命周期已结束 偶发正常,优化后异常
越界 地址离开合法范围 邻接数据损坏、延迟崩溃
未初始化读取 尚无明确对象值 随构建、机器变化

五、函数、参数传递与 Callable

Q24:常见参数形式分别表达什么语义?

面试短答: T 表示函数取得独立值;const T& 是必选只读借用;T& 是必选可写借用;T* 是可选借用;unique_ptr<T> 按值表示所有权转入;shared_ptr<T> 按值表示函数参与共享生命周期。

小型标量通常按值传递。函数若本来就要保存一份值,可以按值接收再移动到成员:左值调用者复制一次,右值调用者移动进入。不要为了“避免复制”把所有参数都改成引用;首先表达正确语义,再关注测量到的成本。

Q25:为什么现代 C++ 推荐按值返回?

面试短答: 编译器可以通过复制消除直接在调用者目标位置构造返回对象;无法消除时还可使用移动。因此按值返回通常比裸输出指针更清楚地表达所有权和异常安全。

std::vector<int> buildValues() {
    std::vector<int> result{1, 2, 3};
    return result; // 可 NRVO;否则可移动
}

不要机械写 return std::move(result);,它可能阻止 NRVO。不要返回局部对象的指针、引用、spanstring_view。返回 unique_ptr 表达独占所有权,返回 optional 表达结果可能不存在。

Q26:重载、默认参数和 inline 的考点是什么?

面试短答: 重载要求同一作用域同名函数具有不同参数列表,仅返回类型不同不能重载;默认参数由调用点按静态类型补入;inline 的语言作用是允许满足规则的相同定义出现在多个翻译单元,并不保证机器代码一定内联。

虚函数的函数体可以动态分派,但默认参数仍根据调用表达式的静态类型选择。因此虚函数默认参数容易产生“函数体来自派生类、参数值来自基类声明”的混合结果,工程中应避免。

Q27:Lambda 的捕获和生命周期怎么回答?

面试短答: Lambda 表达式生成闭包对象,捕获内容成为其状态。按值捕获保存创建时的副本,按引用捕获借用外部对象,初始化捕获可以移动资源。回调越过当前调用栈后,必须重新检查所有引用、视图和 this 的生命周期。

auto byValue = [factor](int x) { return factor * x; };
auto byRef = [&factor] { return ++factor; };
auto owns = [p = std::move(ptr)] { use(*p); };

捕获 this 只保存指针,不延长对象生命周期。按值捕获配合 mutable 只允许修改闭包内副本,不直接修改外部变量。异步回调可捕获 weak_ptr,执行时 lock(),表达“对象仍在才执行”。

Q28:std::function 的用途和代价是什么?

面试短答: std::function<R(Args...)> 通过类型擦除把不同可调用对象统一成同一签名,适合存储和替换回调。代价可能包括间接调用、对象尺寸增加和动态分配。

普通函数、函数指针、重载 operator() 的函数对象和 Lambda 都属于 Callable。热路径或无需存储时,模板参数可以保留具体可调用类型并帮助内联;接口稳定性和可替换性更重要时,std::function 更方便。

Q29:运算符重载有哪些基本限制?

面试短答: 至少一个操作数必须是用户定义类型;不能创造新运算符,也不能改变优先级、结合性或操作数数量。重载行为应符合运算符通常语义,比较器必须保持一致关系。

需要修改左操作数状态的操作通常适合成员函数;希望左右操作数对称转换的二元操作通常适合非成员函数。常见做法是用 operator+= 集中修改逻辑,再由非成员 operator+ 复用它。operator=、[]、()、-> 等有特定成员形式要求。

六、类、构造析构与特殊成员函数

Q30:什么是封装和类不变量?

面试短答: 封装不是简单把字段设为 private,而是让构造函数建立合法状态,并让每个公开操作结束后仍保持对象不变量。类型接口负责阻止外部绕过规则制造非法状态。

无法建立有效状态时应拒绝构造,例如抛出异常或使用工厂返回结果,而不是留下“半初始化对象”。尽量用 enum class、容器和智能指针等成员类型直接表达约束,减少互相冲突的布尔标志和裸资源。

Q31:对象的初始化和销毁顺序是什么?

面试短答: 构造最派生对象时依次初始化虚基类、直接基类、成员(按声明顺序),最后执行构造函数体;销毁顺序严格相反。初始化列表书写顺序不能改变成员声明顺序。

成员之间有依赖时,声明顺序就是设计的一部分。编译器常会警告初始化列表顺序与声明顺序不一致,但即使没有警告,实际顺序仍由声明决定。

Q32:六个特殊成员函数是什么?

面试短答: 默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值共同决定对象怎样建立、复制、转移和结束生命周期。编译器是否隐式声明或删除它们,受其他用户声明成员以及基类和数据成员能力影响。

T b = a;            // 拷贝构造:建立新对象
b = a;              // 拷贝赋值:替换已有对象状态
T c = std::move(a); // 移动构造
c = std::move(b);   // 移动赋值

不能简单背“没写就自动生成”。应先确定资源语义,再选择 = default= delete 或自定义实现。

Q33:Rule of Zero、Three 和 Five 是什么?

面试短答: Rule of Zero 优先把资源交给已经正确管理复制、移动和析构的成员类型,使外层类无需手写特殊成员。直接管理裸资源时至少要整体考虑析构、复制构造和复制赋值;现代 C++ 还要一起考虑两个移动操作,形成 Rule of Five。

通常最佳答案是 Rule of Zero:使用 stringvectorunique_ptr 等成员让编译器生成正确行为。不可复制资源应显式删除复制操作,并按需要提供 noexcept 移动。

Q34:深拷贝、浅拷贝和移动有什么区别?

面试短答: 浅拷贝复制地址或句柄;深拷贝建立独立资源副本;移动通常把资源责任转交给新对象。独占裸资源做浅拷贝会导致重复释放,共享资源应使用明确的共享所有权类型。

移动的具体行为由类型实现,语言不保证“一次移动绝不复制任何内容”。源对象移动后仍然存活,自定义类型应保证它可析构、可重新赋值,并清楚说明还能执行哪些操作。

Q35:explicitdefaultdeletefriend 各自解决什么问题?

面试短答: explicit 阻止构造函数或转换运算符参与意外隐式转换;= default 请求编译器生成默认实现;= delete 在编译期禁止某项操作;friend 向特定函数或类型授予私有访问。

friend 不会自动破坏封装,但会增加耦合,应限制授权范围。单参数构造函数不一定总要 explicit,关键看隐式转换是否符合类型自然语义;工程类型通常倾向显式。

Q36:对象大小、对齐和填充如何解释?

面试短答: sizeof 类对象包含非静态成员、基类子对象以及为满足对齐加入的填充,不包含静态数据成员。多态类型在常见实现中还会有虚指针开销,但具体布局不是 C++ 语言保证。

成员顺序可能改变填充量。只有对象数量巨大、缓存敏感或跨二进制边界时,才应结合测量和目标 ABI 调整布局;不要为了省少量字节破坏可读性。序列化不能直接假设内存布局跨平台稳定。

七、继承、多态与虚函数

Q37:public 继承表达什么关系?

面试短答: public 继承表达 is-a 和可替换关系:派生对象应能在需要基类的地方保持基类公开契约。若只是复用实现,组合通常更直接。

protected 会把实现细节暴露给整个继承层次,优先通过受控接口协作。私有继承更接近实现复用,但普通业务关系通常用组合更清晰。

Q38:虚函数怎样实现动态分派?

面试短答: 通过基类指针或引用调用虚函数时,运行时根据对象动态类型选择最终覆盖。主流实现通常使用对象中的虚指针和虚函数表,但语言只保证行为,不保证固定布局。

override 让编译器检查函数确实覆盖基类接口,应始终使用;final 可禁止继续覆盖某个虚函数或继续继承某个类。虚调用有间接分派成本,也可能限制内联,但应先以抽象正确性为主,性能用测量判断。

Q39:为什么多态基类通常需要虚析构函数?

面试短答: 如果接口允许通过基类指针删除实际的派生对象,基类析构必须是 public virtual,否则行为未定义。虚析构保证先销毁最派生部分,再逐层销毁基类。

如果明确禁止通过基类删除,可以使用 protected 非虚析构,让错误删除在编译期失败。常见准则是:基类析构要么 public virtual,要么 protected non-virtual。纯虚析构函数仍必须提供定义,因为派生对象销毁时最终会调用它。

Q40:纯虚函数和抽象类是什么?

面试短答: = 0 把虚函数声明为纯虚;只要类仍有纯虚最终覆盖,它就是抽象类,不能实例化。派生类为所有纯虚接口提供非纯最终覆盖后才成为具体类。

纯虚函数可以有类外定义,纯虚析构必须有定义。抽象基类应保持接口小而稳定,避免强迫所有派生类继承大量状态。

Q41:重载、重写和名称隐藏有什么区别?

面试短答: overload 是同一作用域内同名、不同参数列表的编译期选择;override 是派生虚函数匹配基类虚接口;name hiding 是派生类只要声明同名成员,就可能隐藏基类整个同名重载集合。

struct Base { void run(int); virtual void tick() const; };
struct Derived : Base {
    using Base::run;       // 重新引入基类重载集合
    void run(double);
    void tick() const override;
};

Q42:什么是对象切片?

面试短答: 派生对象按值转换或复制为基类对象时,只保留基类子对象,派生状态和动态类型被切掉。多态必须通过基类指针或引用维持。

void byValue(Base value); // 传入 Derived 会切片
void byRef(Base& value);  // 保留动态类型

std::vector<std::unique_ptr<Base>> objects; // 保存异构多态对象

容器直接存 Base 值也会切片。需要值语义的多态容器时可设计虚拟 clone(),或使用合适的类型擦除/变体方案。

Q43:为什么构造和析构期间不要依赖虚函数分派?

面试短答: 基类构造阶段派生部分尚未建立,基类析构阶段派生部分已经销毁,因此虚调用只分派到当前构造或析构层级,不会进入完整派生覆盖。

需要多态初始化时,应先完成构造,再由工厂或普通成员函数执行第二阶段初始化。不要在构造或析构期间调用依赖派生状态的纯虚接口。

Q44:dynamic_cast 和 RTTI 应怎样使用?

面试短答: dynamic_cast 在多态继承体系中做运行时身份检查。向下或交叉转换时源类型需为多态类型;指针失败返回 nullptr,引用失败抛 std::bad_cast

偶尔从公共接口恢复具体类型是合理的,但频繁向下转换通常说明基类接口不足或职责混乱,应考虑虚函数、访问者、variant 或显式状态。

Q45:多重继承和虚继承解决什么问题?

面试短答: 多重继承允许一个类继承多个基类,但可能产生名称冲突和重复基类子对象。菱形继承中,虚继承可让共同基类只保留一份,由最派生类负责初始化。

它们会增加对象布局、构造规则和转换关系的复杂度。普通业务设计应保持继承关系简单;多个纯接口的继承通常比多个带状态实现基类更容易管理。

八、模板基本功

Q46:模板何时生成具体代码?

面试短答: 模板描述一组可能的特化。编译器在具体特化被使用且当前语境需要定义时通常进行隐式实例化;也可以显式实例化或显式特化。写下模板定义不代表运行时生成了所有类型代码。

函数模板通常从调用实参推导模板参数;类模板可显式给出参数,C++17 还支持类模板实参推导。模板是编译期机制,不是运行时多态。

Q47:模板定义为什么通常放在头文件?

面试短答: 调用翻译单元实例化某个模板特化时通常必须看到完整定义。如果只在普通 .cpp 中放通用定义,其他翻译单元无法生成所需特化,常在链接阶段得到未定义引用。

也可以把定义放源文件并显式实例化固定类型,但这会把可用类型集合预先封闭。模板错误常在实例化点暴露,诊断会显示从调用点到模板内部的实例化链。

Q48:模板特化和重载有什么区别?

面试短答: 显式特化为确定模板实参提供专门实现。类模板支持偏特化,函数模板不支持偏特化,函数通常通过重载表达不同处理。

不要为了展示模板技巧而制造复杂的选择规则。普通函数重载通常更自然地参与重载决议,也更容易阅读和维护。

Q49:依赖名为什么需要 typenametemplate

面试短答: 模板定义解析时,依赖于模板参数的限定名究竟表示类型、值还是模板可能尚不确定。typename 明确它是类型;在需要消除歧义时,template 明确后续名字是模板。

template<class Container>
typename Container::value_type first(const Container& values) {
    return *values.begin();
}

Q50:转发引用和 std::forward 是什么?

面试短答: 函数模板中由形参直接推导、没有额外 cv 限定的 T&& 是转发引用。左值实参使 T 推导为 U&,右值实参使 T 推导为 Ustd::forward<T> 恢复调用者原始值类别。

template<class T>
void relay(T&& value) {
    target(std::forward<T>(value));
}

并非所有 T&& 都是转发引用,例如类模板已经确定的 T&& 通常只是右值引用。std::forward 不延长生命周期,也不保证性能一定提高。

Q51:constexpr、type traits 和 Concepts 分别做什么?

面试短答: constexpr 允许对象或函数参与常量求值;type traits 在编译期查询和变换类型;C++20 Concepts/requires 在模板接口处表达约束,并改善诊断和重载选择。

constexpr int square(int x) { return x * x; }
static_assert(square(4) == 16);
static_assert(std::is_same_v<std::remove_reference_t<int&>, int>);

const 只限制修改,不等于编译期常量。constexpr 函数在参数和上下文满足条件时可在编译期求值,否则仍可能在运行期执行。

九、STL 容器、迭代器与算法

Q52:vectordequelistarray 怎么选?

面试短答: array 固定长度且连续;vector 动态连续、随机访问和缓存局部性好,是默认动态序列;deque 分块存储并擅长两端增长;list 节点独立,适合确需节点稳定和常数时间拼接的少数场景。

理论复杂度不是唯一依据。list 中间插入虽可为 O(1),但前提是已有位置,且节点分配、额外指针和缓存缺失常让它慢于连续容器。小规模数据尤其应重视布局和常数成本。

Q53:vectorsizecapacityreserveresize 有什么区别?

面试短答: size 是已构造元素数量;capacity 是当前存储无需重分配可容纳的数量;reserve 准备容量但不创建元素;resize 改变元素数量并构造或销毁元素。

容量不足时扩容会取得新的连续存储并移动或复制元素,所有指向旧元素的迭代器、指针和引用失效。reserve 可减少已知规模增长时的重复分配。shrink_to_fit 只是非强制请求,不应机械调用。

Q54:mapunordered_map 的核心区别是什么?

面试短答: map 通常由平衡树实现,按键有序,查找插入删除为 O(log n),适合范围查询;unordered_map 是哈希表,无稳定顺序,平均 O(1)、最坏 O(n),性能受哈希质量和负载因子影响。

map 比较器必须形成严格弱序。unordered_map 需要哈希和相等关系一致:若两个键相等,它们的哈希值必须相同。需要稳定顺序、lower_bound 或可预测最坏复杂度时选 map;只需精确查找且哈希可靠时考虑 unordered_map

Q55:常见容器的迭代器失效规则是什么?

面试短答: vector 重分配使全部迭代器、指针和引用失效;未重分配的插入使插入点及之后失效,删除使删除点及之后失效。listmapset 通常只让被删除元素失效。无序容器 rehash 使迭代器失效,但不会仅因 rehash 让元素引用和指针失效。

deque 的规则随操作位置变化较多,应按具体操作查标准文档,不要套用 vectorlist 的结论。任何容器中,被删除元素的所有访问路径都失效。

Q56:push_backemplace_back 有什么区别?

面试短答: push_back 接收已经形成的对象,再复制或移动进容器;emplace_back 用参数直接构造元素。emplace 不保证总是更快,也可能选择意外的显式构造函数。

已经有现成对象时,push_back(std::move(value)) 往往意图更清晰。直接用构造参数建立新元素时,emplace_back 可能减少临时对象。无论哪种插入,都要检查是否触发扩容和访问路径失效。

Q57:迭代器有哪些能力类别?

面试短答: 输入迭代器支持单遍读取;前向迭代器支持多遍单向遍历;双向迭代器还能后退;随机访问迭代器支持常数时间跳转和差值;C++20 连续迭代器还保证元素内存连续。

迭代器类别只描述可执行操作和复杂度,不保证容器修改后的稳定性。算法根据所需能力限制可用范围,例如 std::sort 要求随机访问迭代器,不能直接排序 list

Q58:遍历容器时怎样安全删除元素?

面试短答: 删除当前元素后原迭代器可能立即失效,应使用容器 erase 返回的下一个有效迭代器继续;只有未删除时才自行递增。

for (auto it = values.begin(); it != values.end();) {
    if (shouldErase(*it)) {
        it = values.erase(it);
    } else {
        ++it;
    }
}

范围 for 隐式保存迭代器,循环中修改容器结构仍可能失效,不能因为语法简洁就忽略规则。

Q59:erase-remove 惯用法为什么需要两步?

面试短答: std::remove 是算法,它不会改变容器大小,只把保留元素前移并返回新的逻辑末尾;容器的 erase 才真正删除尾部区间。

values.erase(
    std::remove(values.begin(), values.end(), 0),
    values.end());

C++20 对常用容器提供 std::erasestd::erase_if。逻辑末尾到原末尾之间的元素仍存在,但值不应被依赖。

Q60:使用标准算法时必须检查什么?

面试短答: 要检查输入范围有效、输出空间足够、迭代器能力符合要求、比较器或谓词满足前提,并理解返回值含义。算法不会替调用者修复范围和生命周期错误。

lower_bound 要求范围已按同一比较关系排序;transform 写入外部范围时必须确保空间足够;有副作用的谓词不应依赖未规定的调用次数或顺序。

Q61:为什么排序比较器不能写 <=

面试短答: 排序比较器必须形成严格弱序,要求 comp(x, x)false<= 对相等元素返回 true,破坏算法前提,结果可能错误甚至触发未定义行为。

比较关系还应满足非对称性和传递性。比较器不应依赖持续变化的外部状态或随机结果。map 的键比较遵守同样要求。

Q62:Top-K 或中位数一定要完整排序吗?

面试短答: 不一定。partial_sort 把最小的前 K 项排好序;nth_element 只保证第 n 个元素处在完整排序后的正确位置,并完成两侧分区。只要部分结果时可避免完整排序成本。

若结果需要前 K 项本身有序,可用 partial_sort;只需第 K 项或中位数,用 nth_element 更合适。选择前先明确输出是否需要完整顺序。

十、编译、链接、头文件与构建

Q63:源代码到可执行文件经历哪些阶段?

面试短答: 通常经历预处理、编译、汇编和链接。预处理展开 #include、宏和条件编译;编译进行语法类型检查和代码生成;汇编生成目标文件;链接解析跨文件符号并组合目标文件与库。

一个源文件及其展开的头文件构成翻译单元,通常独立编译。语法和类型错误属于编译阶段;缺少定义或重复定义通常属于链接阶段;动态库缺失可能到装载或运行时才暴露。

Q64:跨文件的声明、定义和 extern 怎样组织?

面试短答: 头文件通常提供声明,某个源文件提供唯一的普通非 inline 定义。extern 变量声明允许其他翻译单元引用同一对象,但不会自动提供定义。

// Counter.h
extern int gCounter;
void addCount();

// Counter.cpp
int gCounter = 0;
void addCount() { ++gCounter; }

只有声明没有定义会产生未解析符号;在头文件直接定义普通全局变量会让多个翻译单元产生重复定义。C++17 可用 inline 变量表达允许多处相同定义的同一实体。

Q65:什么是头文件自包含和前向声明?

面试短答: 头文件应独立包含自己接口所需的声明,不依赖调用者碰巧先包含其他头。只声明指针、引用和部分函数接口时前向声明通常足够;需要对象大小、成员访问、继承或按值成员时必须看到完整定义。

#pragma once 或 include guard 防止同一翻译单元重复展开头文件。前向声明能减少编译依赖,但不能为了减少 include 而在需要完整类型时强行使用。头文件全局作用域避免 using namespace,否则会污染所有包含者。

Q66:什么是 ODR,inline 和模板为何能放头文件?

面试短答: 单一定义规则 ODR 要求实体在程序中的定义数量和一致性满足规则。普通外部链接函数或变量通常只能有一个程序级定义;inline 函数、inline 变量和模板允许在多个翻译单元出现相同定义,并表示同一实体。

这些允许的多处定义必须保持一致。违反 ODR 有时链接器能报错,有时表现为难以诊断的未定义行为,因此不能依赖“链接成功”证明程序正确。

Q67:常见编译、链接和装载错误怎样区分?

面试短答: incomplete type 表示当前代码需要完整类型但只有声明;undefined reference/unresolved external 表示链接器找不到匹配定义;multiple definition 表示普通实体重复定义;运行时找不到动态库通常是部署、搜索路径或版本问题。

排查未解析符号时检查:定义是否存在、签名是否完全一致、对应目标文件或库是否参与链接、模板定义是否可见、名称修饰和 ABI 是否匹配。头文件缺失是编译问题,不能靠链接参数解决。

Q68:静态库、动态库和 ABI 是什么?

面试短答: 静态链接把需要的目标代码纳入最终可执行文件;动态链接在装载或运行时从共享库提供代码。ABI 是调用约定、名称修饰、对象布局、异常机制和资源边界等二进制契约。

静态链接部署直接但产物较大、更新要重新链接;动态库可共享和独立更新,但必须保证库版本、搜索路径和 ABI 兼容。跨动态库边界不要随意传递编译器或运行库不兼容的 STL 对象,也要明确由哪一侧分配和释放资源。

Q69:PCH 能解决什么问题?

面试短答: 预编译头缓存稳定而庞大的头文件解析结果,用于减少重复编译时间。它是构建优化,不替代头文件自包含、清晰依赖和合理模块边界。

PCH 内容频繁变化会导致大范围重建。应把稳定、广泛使用且解析成本高的头放入 PCH,而不是把项目所有头文件都塞进去。

十一、UB、错误处理、调试与性能

Q70:什么是未定义行为?

面试短答: UB 表示程序违反语言规则,标准不再保证之后发生什么。越界、悬空访问、释放后使用、有符号整数溢出、无效移位、整数除零和 data race 都是常见来源。

程序没有立即崩溃不代表行为正确。优化器可假设 UB 不会发生,因此 Debug 看似正常、Release 异常是典型表现。工程规则是先满足对象生命周期、范围、对齐、初始化和并发同步前提,再谈结果。

Q71:怎样选择错误处理方式?

面试短答: 允许没有结果可用 optional、空指针或状态枚举;需要错误原因可用错误码或结果类型;当前层无法恢复且项目采用异常时可传播到明确异常边界;内部不变量用 assert;外部输入必须显式校验并返回可诊断错误。

底层负责描述失败,上层决定重试、降级、提示或终止。assert 不能代替用户输入、文件和网络数据校验,因为发布构建可能通过 NDEBUG 移除断言;断言表达式中不要放业务副作用。

Q72:异常为什么必须配合 RAII?

面试短答: 异常会跳过普通控制流并进行栈展开,已成功构造的自动对象会依次析构。只有资源由 RAII 对象持有,异常路径才不会跳过手工释放代码导致泄漏。

构造函数可抛异常拒绝产生无效对象。析构函数通常不应让异常逃逸,尤其在另一个异常展开期间再次抛出会终止程序。需要报告关闭失败的资源可提供显式 close/flush,析构只做尽力清理。

Q73:异常安全有哪些保证层级?

面试短答: 不抛出保证承诺异常不逃逸;强保证承诺失败时可观察状态不变;基本保证承诺对象仍合法且资源不泄漏,但状态可能改变;无保证则无法承诺对象仍可正常使用。

强保证常用“先在临时状态完成,成功后提交”实现,例如先复制到临时容器再 swap。RAII 主要解决资源收尾,不自动让多字段操作获得强保证。应根据成本和调用者恢复需求选择层级。

Q74:noexcept 的意义是什么?

面试短答: noexcept 是函数不让异常逃出的接口承诺;若违反会调用 std::terminate。它不是自动优化开关,但容器会利用 noexcept 移动构造决定重分配时是否安全移动元素。

如果移动可能抛而拷贝可用,vector 为维持异常保证可能选择拷贝。只有确实能保证异常不逃出时才标记 noexcept,清理和析构路径通常应做到不抛出。

Q75:排查崩溃的基本方法是什么?

面试短答: 目标是找到程序第一次偏离合法状态的位置,而不是只停在最终崩溃点。先稳定复现并记录输入、环境、构建配置和线程顺序,再用调用栈、条件断点、数据断点、日志和最小化测试逐个验证假设。

崩溃点常是损坏最终被观察的位置,第一次写坏内存可能早得多。AddressSanitizer 常查越界和 use-after-free,UndefinedBehaviorSanitizer 检查部分 UB,ThreadSanitizer 检测数据竞争;具体支持程度取决于平台和工具链。

Q76:大 O 和实际性能有什么区别?

面试短答: 大 O 描述输入规模增长趋势,不包含常数、缓存局部性、分配次数、分支预测和数据移动成本。因此理论复杂度相同的实现,真实性能可能差很多。

连续遍历 vector 常因缓存局部性优于节点结构。优化顺序应是:确认正确性,定位真实瓶颈,建立可重复基线,只改一个因素,再重新测量。不要凭感觉减少虚调用、哈希或临时对象。

Q77:微基准有哪些常见陷阱?

面试短答: 应隔离初始化和 I/O,使用代表性数据并重复测量,防止编译器把结果优化掉,同时考虑预热、缓存状态和一次性成本。否则测到的可能不是目标操作。

基准结果只对特定编译器、优化级别、硬件、输入分布和工作集有效。报告时应保存环境和统计分布,而不是只给一次耗时。

十二、线程、锁与 atomic

Q78:进程和线程的主要区别是什么?

面试短答: 进程通常有独立虚拟地址空间;同一进程中的线程共享地址空间和大部分资源,但各自拥有执行上下文、寄存器状态和线程栈。

并发设计应先减少共享可变状态:不可变数据可并发读取,线程私有数据无需同步,消息传递和所有权转移通常比多个线程随意修改同一对象更容易推理。

Q79:data race 和 race condition 有什么区别?

面试短答: data race 是多个线程对同一内存位置进行冲突访问,至少一个写、至少一个为非原子操作,且没有足够的 happens-before 顺序;它导致未定义行为。race condition 更宽泛,指业务结果错误地依赖执行时序。

每个字段都用 atomic 仍可能有逻辑竞态,因为多个操作组合起来的不变量没有原子维护。线程安全要围绕完整状态和操作检查,而不是只问“某个变量是不是原子的”。

Q80:std::threadjoindetach 有什么区别?

面试短答: join 等待线程结束并完成结构化收尾;detach 放弃通过该 thread 对象等待和管理线程的能力。仍处于 joinable()std::thread 在析构时会调用 std::terminate

detach 不会延长线程函数捕获对象的生命周期,也不提供取消和结果管理,因此很容易产生悬空引用和不可控关闭。C++20 std::jthread 析构会请求停止并自动 join,还可配合 stop_token 进行协作取消。

Q81:mutex 应保护什么,RAII 锁有什么区别?

面试短答: mutex 应保护一组共享状态和不变量,所有访问路径必须遵守同一加锁规则。lock_guard 适合简单作用域持锁;unique_lock 支持延迟加锁、临时解锁和条件变量;scoped_lock 可一次管理多把锁。

锁范围要覆盖“检查并修改”这个完整原子步骤,同时避免在锁内执行慢 I/O、回调或不可控外部代码。锁对象使用 RAII,防止异常或提前返回漏解锁。

Q82:死锁怎样产生和预防?

面试短答: 死锁通常由互斥占有、持有并等待、不可抢占和循环等待共同形成。工程上通过统一锁顺序、减少同时持锁、缩小锁范围以及用 scoped_lock 获取多把锁来降低风险。

scoped_lock 只能帮助同一处多锁获取,不会自动解决回调重入、跨模块锁层级和等待外部资源造成的死锁。应记录锁顺序并避免持锁调用未知代码。

Q83:条件变量为什么必须配合互斥锁和谓词?

面试短答: 条件变量只负责等待和通知,不保存业务状态。共享状态由互斥锁保护,wait 原子地释放锁并休眠,醒来后重新加锁。必须循环检查谓词以处理虚假唤醒和时序变化。

std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock, [&] { return stopping || !queue.empty(); });

修改状态时在同一互斥规则下完成,再通知等待者。通知不是状态,先通知而没有正确维护谓词会丢失业务含义。多个不同谓词共用一个条件变量时,notify_all 常比错误的 notify_one 更稳妥,但还要权衡唤醒成本。

Q84:atomic 能解决什么,不能解决什么?

面试短答: atomic<T> 保证针对该原子对象的读写和读改写不形成 data race,并为它建立规定的修改顺序。它不自动保护相邻字段,也不维护多个变量之间的复合不变量。

独立计数器或单一状态常适合原子变量;需要同时检查和修改多个相关字段时,互斥锁通常更清晰。atomic 不等于 lock-free,可用 is_lock_free() 查询实现能力,且原子操作本身仍有同步成本。

Q85:内存序和 happens-before 应怎样回答?

面试短答: 原子操作默认使用顺序一致 memory_order_seq_cst,给出最直观的全局顺序约束。acquire/release 等较弱内存序可减少约束,但必须证明数据发布和读取之间建立了正确的 happens-before。

如果一个原子 flag 用来发布其他普通数据,就不仅是“flag 自己原子”这么简单;写数据后 release 存储、另一线程 acquire 读取成功,才能建立相应可见性。没有完整证明时优先使用默认内存序或互斥锁。

Q86:future 和生产者-消费者队列考察什么?

面试短答: future 表示异步结果,可取得返回值并在 get() 时重新抛出任务异常。生产者-消费者通常由队列、互斥锁和条件变量组成,关键是让队列状态、等待谓词和关闭流程保持一致。

关闭时要设置停止状态并 notify_all,消费者谓词通常是“正在停止或队列非空”。还要明确:停止后是否继续清空队列、谁等待工作线程结束、重复调用停止是否安全。

Q87:volatile 能用于线程同步吗?什么是 false sharing?

面试短答: volatile 不提供原子性、线程可见性或内存顺序,不能替代 atomicmutex;它主要用于特定平台约定下的低层 I/O 等场景。false sharing 是不同线程频繁写同一缓存行中的不同变量,虽无 data race,却产生大量缓存一致性流量。

false sharing 应先通过性能分析确认,再考虑减少共享写入、拆分热点状态或调整布局。不能仅凭“两个变量相邻”就断定它一定是瓶颈。

Q88:CAS 的 weak 和 strong 有什么区别?

面试短答: compare_exchange 在原子值仍等于期望值时更新它,并在失败时把实际值写回期望参数。weak 允许伪失败,适合放在重试循环;strong 不允许伪失败,适合不循环或伪失败代价较高的场景。

int expected = oldValue;
while (!value.compare_exchange_weak(expected, newValue)) {
    newValue = compute(expected);
}

会使用 CAS 不等于能正确手写无锁数据结构。内存回收、ABA、内存序和进度保证都需要额外证明,面试基础阶段掌握用途和循环模式即可。

十三、C++14-C++20 高频补充

Q89:C++14 常见新增能力有哪些?

面试短答: 高频项包括 std::make_unique、泛型 Lambda 和 Lambda 初始化捕获。它们分别改善独占资源创建、泛型可调用对象和资源进入闭包的表达。

auto ptr = std::make_unique<Player>();
auto twice = [](auto value) { return value + value; };
auto task = [p = std::move(ptr)] { p->run(); };

Q90:C++17 常见新增能力有哪些?

面试短答: 高频项包括 string_viewoptional、结构化绑定、if constexprvariant、类模板实参推导、inline 变量和 scoped_lock

optional<T> 明确表达可能没有值;variant<Ts...> 保存封闭类型集合中的一个当前值;结构化绑定若写 auto 会复制,需修改原对象时通常使用 auto&string_view 的核心考点始终是非拥有生命周期。

Q91:C++20 常见新增能力有哪些?

面试短答: 高频项包括 spanstd::erase/erase_if、默认比较运算、Concepts/requiresjthread/stop_token 和连续迭代器。

它们大多不是改变 C++ 对象模型,而是在已有机制上减少样板、增强接口约束和改善结构化资源管理。面试中应优先说用途和边界,而不是机械背版本号。

十四、综合场景题

Q92:一个异步回调捕获了局部变量引用,可能有什么问题?

面试短答: 回调可能在局部变量离开作用域后才执行,引用因对象生命周期结束而悬空。跨线程还可能同时产生 data race。修复方式取决于语义:复制需要的值、转移独占所有权,或捕获 weak_ptr 并在执行时 lock()

不能简单把 [&] 改成 [=] 就认为安全,因为捕获的 this 仍是指针,视图也仍可能指向外部数据。必须逐项检查闭包成员实际拥有的是什么。

Q93:保存了 vector 元素地址,随后 push_back,为什么偶发崩溃?

面试短答: push_back 可能因容量不足触发重分配,旧存储中的元素被移动或复制,原地址、指针、引用和迭代器全部失效。继续解引用属于悬空访问和 UB。

可在规模已知时提前 reserve,插入后重新取得地址,或根据稳定性需求选择合适数据结构。但 reserve 只在容量不再被突破时有效,不能作为永久地址稳定保证。

Q94:为什么 shared_ptr(this) 容易重复释放?

面试短答: 如果对象已经由另一个 shared_ptr 管理,shared_ptr(this) 会从同一裸地址创建新的独立控制块。两个控制块都认为自己负责最终删除对象,因此会发生 double delete 和 UB。

正确做法是继承 enable_shared_from_this 并调用 shared_from_this(),且保证对象已进入有效共享控制块。若并不需要共享所有权,回调也可只保存受控借用关系。

Q95:如何设计一个线程安全任务队列的关闭流程?

面试短答: 用同一互斥锁保护队列和停止标志;消费者等待谓词为“停止或队列非空”;提交任务时持锁检查是否仍接受任务;关闭时持锁设置停止状态,解锁后 notify_all,最后由所有者 join 工作线程。

还要在接口契约中说明:关闭后提交返回什么、是否处理剩余任务、关闭是否幂等、任务异常如何传播。只加一个原子 stopping,却不把它与队列状态放在同一不变量中,仍可能出现逻辑竞态。

Q96:面试回答 C++ 题时怎样组织语言?

面试短答: 先用一句话给准确结论,再说明适用前提,然后举一个最小反例,最后给工程做法。不要从实现细节开始,也不要把“常见实现”说成语言标准保证。

可以固定使用下面的四步模板:

  1. 结论: 这个机制解决什么问题。
  2. 前提: 结论在哪些类型、生命周期或并发条件下成立。
  3. 失败方式: 违反前提是编译错误、可检测失败、异常还是 UB。
  4. 工程选择: 推荐的类型、接口或排查方法是什么。

例如回答虚析构:多态基类允许通过基类指针删除时,析构必须为虚函数;否则删除派生对象是 UB;工程上使用 public virtual 析构,若禁止该路径则使用 protected non-virtual 析构。这样的回答比只说“防止内存泄漏”更准确。

十五、最终速记

类型:先看范围、const 层级、推导结果和值类别。
借用:引用通常必选,指针通常可空;二者都不自动拥有。
生命周期:存储仍在不等于对象仍在;异步和视图最易悬空。
资源:优先 RAII 和 Rule of Zero;所有权流向写进类型。
移动:std::move 只是转换;源对象仍有效但值可能未指定。
多态:通过指针/引用维持;防切片;基类删除需要虚析构。
模板:实例化处通常要看到定义;forward 只恢复值类别。
容器:vector 默认优先;每次结构修改都检查失效规则。
算法:范围、迭代器能力、比较器前提由调用者保证。
构建:编译看单个翻译单元,链接解析跨文件定义。
正确性:UB 不保证崩溃;异常安全靠 RAII 和状态提交策略。
性能:先测量真实瓶颈,再进行单一改动并重新测量。
并发:锁保护不变量;通知不是状态;atomic 不保护复合状态。

真正可靠的 C++ 基本功,不是记住更多术语,而是能沿着类型、所有权、生命周期、状态不变量和同步关系把一段代码的合法性讲清楚。