C++ 面试题库

收录真实面试常见问题,答案长度适合口头表达;拿不准时可返回相关主题复习。

71个问题 初级 高级
全部难度 初级 中级 高级
逐题查看 显示全部答案
报告错误

语言核心

11个问题
01 std::move 做了什么,真正的移动何时发生? 中级 常见 查看答案 ▾ 收起 ▴

std::move 是一次类型转换:它把参数表达成 xvalue,同时保留类型限定。它本身不会转移缓冲区,也不会修改源对象。只有后续初始化、赋值或函数调用选择了接收右值的操作,而且该操作确实转移状态时,移动才会发生。若没有可用的移动重载,代码仍可能选择复制。有名字的右值引用参数也是左值表达式,因此移动构造函数通常要对准备转移的源成员使用 std::move。

延伸阅读 移动语义
讲清楚了吗?
05 内置数组与指针有什么区别,数组何时会转换成指针? 中级 常见 查看答案 ▾ 收起 ▴

内置数组是包含固定数量连续元素的对象,边界属于其类型。指针则是保存地址的独立标量对象,类型中没有元素数量。数组左值在多数取值表达式中会转换为首元素指针,普通的按值 auto 推导也会触发转换。sizeof、对数组使用一元取地址、decltype 以及绑定数组引用时不会发生这种转换。转换完成后,仅凭指针无法恢复原来的数组边界,接口必须另行传递数量或终止条件。

延伸阅读 C 风格数组
讲清楚了吗?
17 如何理解 int (*handler)(int, int),其中的括号为什么不可省略? 初级 常见 查看答案 ▾ 收起 ▴

从 handler 开始向外读。括号内的星号先与 handler 组合,所以 handler 是指针;随后的参数列表说明它指向接收两个 int 的函数,最前面的 int 则是函数返回类型。函数与数组后缀比前缀星号绑定得更紧,因此括号不能省略。若写成 int handler(int, int),handler 就成了一个接收两个 int、返回 int 的函数。公共签名太长时,可以用 typedef 给指针类型或底层函数类型命名。

延伸阅读 C 函数指针
讲清楚了吗?
21 哪些因素决定 C 结构体的大小和成员偏移? 中级 常见 查看答案 ▾ 收起 ▴

普通成员按声明顺序排列,后声明成员的地址更高,但实现可以在成员之间及末尾插入填充,以满足对齐要求。第一个成员从结构体地址开始。sizeof 包含尾部填充,使数组中的相邻元素正确对齐;offsetof 给出特定成员偏移,alignof 给出类型的对齐要求。精确结果取决于目标 ABI、编译器选项、成员类型和打包设置。若接口要求固定布局,我会在每个受支持目标上使用静态断言验证,而不会把一次 x86-64 观察当成可移植 C 规则。

讲清楚了吗?
25 C++ 中的 class 与 struct 有何区别,应如何选择? 初级 常见 查看答案 ▾ 收起 ▴

两个关键字都定义类类型,也都支持数据成员、成员函数、构造函数、模板和继承。语言层面的区别是默认访问:class 的成员与基类默认是 private,struct 则默认是 public。我通常用 struct 表示调用者可以直接组合字段的开放记录,用 class 表示必须通过操作维护不变量的类型。这种选择表达接口意图,但不会改变正式的类型规则。聚合类型还有独立条件,因此把 class 改成 struct 不会自动让类型成为聚合。

延伸阅读
讲清楚了吗?
29 从执行 throw 表达式到进入匹配的 catch 之间会发生什么? 中级 常见 查看答案 ▾ 收起 ▴

throw 的操作数会初始化一个独立异常对象,运行时再按源码顺序搜索动态包围抛出点的处理器。相同类型、可访问且无歧义的基类、部分指针转换或兜底处理器可以匹配。搜索离开作用域时,已完成构造的自动对象按构造的逆序销毁,这就是栈展开,也是资源需要 RAII 所有者的原因。匹配后,程序初始化 catch 参数并执行处理器。若没有匹配项,或异常越过 noexcept 边界,程序最终会调用 std::terminate。

延伸阅读 异常处理
讲清楚了吗?
37 命名空间与类、C++20 模块有什么区别? 初级 常见 查看答案 ▾ 收起 ▴

命名空间是用于组织名称并参与查找的开放声明区域,可以由多个文件继续定义,但不能实例化、继承,也没有 public 或 private 成员访问控制。类会定义一种类型,对象可以保存状态,访问说明符还能强制限制成员访问。C++20 模块控制声明在模块单元之间的可见性与导出,并减少对文本包含的依赖。三者不是互斥选择:导出的声明仍可属于命名空间。我用命名空间表达名称所有权,用类表达对象契约,用模块建立分发与可见性边界。

延伸阅读 命名空间
讲清楚了吗?
41 如何在成员与非成员运算符重载之间选择? 中级 常见 查看答案 ▾ 收起 ▴

先看语言限制:赋值、下标、函数调用和箭头运算符必须是成员。+= 这类修改操作也自然属于左侧对象。对称二元运算通常适合非成员函数,常用隐藏友元,因为两个操作数能以相同方式参与转换匹配。流插入必须是非成员函数,因为左操作数是 std::ostream,类型作者不能向它添加成员。friend 只决定私有访问权限,不是函数成为非成员的原因。只要领域声称运算对称,我就会测试两种操作数顺序。

延伸阅读 运算符重载
讲清楚了吗?
49 派生成员要重写基类虚函数,哪些部分必须匹配? 中级 常见 查看答案 ▾ 收起 ▴

形参列表以及成员函数的 cv 限定符和引用限定符必须满足重写规则,所以末尾的 const 或 & 不是装饰。返回类型通常相同,指向类的指针或引用可在受限条件下使用协变返回。访问级别不决定重写,因此基类私有虚函数也能被重写。我会为每个预期派生实现写 override;它不会创造重写关系,却会让编译器拒绝不匹配。同时还要开启隐藏虚函数警告,因为同名重载可能从查找中消失,却没有形成重写。

延伸阅读 虚函数
讲清楚了吗?
56 const int * 与 int *const 有什么区别? 初级 常见 查看答案 ▾ 收起 ▴

在 const int *view 中,const 限定所指向的 int:view 可以改指向,但不能经 view 修改目标。在 int *const fixed 中,const 限定指针对象:fixed 必须保留原地址,*fixed 仍可写。const int *const 同时施加两项限制。把 int * 转换为 const int * 通常是安全的限定增加;强制去掉限定不会让实际定义为 const 的对象变得可写,修改它仍产生未定义行为。我会从变量名向外读声明,并分别审查每一层间接访问。

延伸阅读 C 指针
讲清楚了吗?
68 与指针相比,C++ 引用表达了什么契约? 初级 常见 查看答案 ▾ 收起 ▴

引用通常表示一个必须存在、而且不会通过该名称改绑的对象或函数。在行为有定义的程序中,引用初始化时就绑定有效目标;给引用赋值会修改目标,而不是绑定关系。指针是独立的值,可以为空,也可以重新指向别处,因此适合可选或需要移动位置的关系。两种原始形式都不拥有目标,也都可能悬空。我会先根据可空性和改绑需求选择,再记录所有者、失效操作,以及调用方可以保留这条访问路径多久。

延伸阅读 引用
讲清楚了吗?

资源所有权

2个问题
02 代码可以对移动后的对象安全地做什么? 中级 常见 查看答案 ▾ 收起 ▴

对于标准库类型,如果组件没有给出更强的后置条件,移动后的对象有效,但值未指定。它可以析构、重新赋值,也可以调用当前状态满足其前置条件的操作。这不表示可以安全索引移动后的 vector,因为它的大小未知。某些类型会规定更强状态,例如移动后的 unique_ptr 为空。自定义类型则必须定义并保持自己的不变量,尤其要同步处理指针、长度和所有权标志。

延伸阅读 移动语义
讲清楚了吗?
45 RAII 保证什么,为什么它不等同于栈分配? 中级 常见 查看答案 ▾ 收起 ▴

RAII 把释放责任绑定到所有者对象的生命周期。获取成功后,资源立即进入由析构函数执行配对释放的对象,因此正常返回、提前返回和异常栈展开共用同一条清理路径。栈分配只是管理所有者的一种方式;RAII 对象也可以是数据成员、容器元素,或由智能指针管理的动态对象。真正的契约是所有权,以及所有者生命周期结束时的确定性析构。RAII 不保证在 abort、断电或所有进程终止 API 之后仍能执行清理。

延伸阅读 RAII
讲清楚了吗?

标准库

2个问题
03 为什么容器使用的移动构造函数需要关注 noexcept? 高级 偶尔 查看答案 ▾ 收起 ▴

容器重分配时,如果新存储中的构造失败,可能需要保留原元素。不抛异常的移动提供了安全转移路径。若移动可能抛异常且复制可用,std::move_if_noexcept 等设施可以选择 const 引用路径,使失败时源对象保持不变。若类型只能移动,容器可能只能尝试会抛异常的移动,并为该操作提供较弱保证。noexcept 必须真实;异常若逃出 noexcept 移动操作,程序会调用 std::terminate。

延伸阅读 移动语义
讲清楚了吗?
20 正确的 qsort 比较函数要满足什么条件,为什么直接相减有风险? 中级 偶尔 查看答案 ▾ 收起 ▴

qsort 把两个元素的地址作为 const void* 传给比较函数。比较函数把它们转换为实际元素类型的指针,并用负数、0 或正数表示小于、等价或大于。它必须给出一致顺序,不应修改元素,也不能依赖调用顺序。直接返回 left->key - right->key 有风险,因为有符号整数溢出属于未定义行为。应使用两个关系比较组成符号,或写明确分支。比较结果相等也不表示输出保持原相对顺序,因为 qsort 不保证稳定性。

延伸阅读 C 函数指针
讲清楚了吗?

类设计

2个问题
04 零法则和五法则如何指导可移动类的设计? 中级 常见 查看答案 ▾ 收起 ▴

零法则是首选设计:把所有权放进 vector、string 或 unique_ptr 等成员,让它们的特殊成员函数组合出类的行为。这样能减少重复的生命周期代码,通常也能得到正确的条件 noexcept。五法则则提醒已经自定义析构、复制或移动的类,要把析构、复制构造、复制赋值、移动构造和移动赋值视为一组语义。应明确决定每项是默认、删除还是自定义,并测试源与目标不变量、自赋值、基类和析构。

延伸阅读 移动语义
讲清楚了吗?
27 零法则对类设计意味着什么? 中级 常见 查看答案 ▾ 收起 ▴

零法则主张:如果成员已经正确表达所有权,外层类就不要自行声明析构、复制或移动操作。string、vector 和智能指针等成员会把自身行为组合到外层类中,从而减少重复的生命周期代码;某个成员不可复制时,类型系统也会相应删除外层复制。不过我仍会验证最终语义:逐成员复制必须符合领域含义,引用与非拥有视图需要生命周期规则,用户声明的构造函数或析构函数也可能改变特殊成员函数的生成结果。

延伸阅读
讲清楚了吗?

API 设计

4个问题
06 C++ 函数如何接收数组而不丢失边界? 中级 常见 查看答案 ▾ 收起 ▴

int values[8] 这样的数组形参会调整成 int*,因此既不保留也不强制 8 个元素。const int (&values)[8] 这样的引用形参能够保留精确边界;模板 const T (&values)[N] 还能推导元素类型和边界。大多数应用接口使用 std::span 更灵活,因为它携带运行时数量,也能接收其他连续范围。固定边界本身就是契约时可用 std::span<T, N>,但任何 span 都不拥有底层元素。

延伸阅读 C 风格数组
讲清楚了吗?
28 成员函数末尾的 const 保证什么,又不保证什么? 初级 常见 查看答案 ▾ 收起 ▴

末尾的 const 会限定本次调用中的当前对象。函数可以通过 const 对象或 const 引用调用,也不能通过普通成员访问修改非 mutable 数据。它不保证返回引用比对象活得更久,不保证线程安全,也不会让函数自动变成编译期计算。mutable 成员以及通过指针间接访问的对象仍可能变化,因此 const 不是深层不可变。我会把观察函数标为 const,通过 const 引用编译测试,并说明它返回的引用、指针、span 或迭代器的生命周期。

延伸阅读
讲清楚了吗?
59 C API 应在何时使用二级指针输出形参? 中级 偶尔 查看答案 ▾ 收起 ▴

单个 T * 让函数修改 T 对象。函数若要替换调用方保存的 T * 值,就需要访问那个指针对象,常见形式是 T **out,调用处传入 &pointer。增加一层间接访问本身不代表发生分配或所有权转移;API 仍须说明结果是借用还是拥有、由谁释放,以及旧指针如何处理。我倾向于让输出具有失败原子性:先验证并准备完整结果,只在成功时一次写入 *out,使失败保留调用方旧值,也不泄露部分初始化的指针。

延伸阅读 C 指针
讲清楚了吗?
71 如何审查函数能否安全返回引用? 中级 常见 查看答案 ▾ 收起 ▴

我会先找出每条返回路径实际指向的对象,再确定其所有者。自动局部对象、按值形参,以及返回表达式计算出的临时对象都不能活过这次调用。成员或容器元素可能继续存活,但前提是拥有对象仍存在,而且没有操作使该元素失效。我还会检查 const 与可修改重载是否保持访问约束。测试应覆盖所有者销毁、vector 重新分配、元素删除和临时接收对象。若调用方无法简单说明并执行生命周期边界,按值返回更安全。

延伸阅读 引用
讲清楚了吗?

内存布局

1个问题
07 为什么 int[Rows][Cols] 数组不能按 int** 传递? 中级 偶尔 查看答案 ▾ 收起 ▴

二维内置数组是“行数组的数组”。外层数组转换后,结果指向一整行,因此 int grid[2][3] 会变成 int ()[3];指针运算需要列边界才能找到下一行。int** 则指向一个 int 对象,通常表示另一张独立的行指针表。二维内置数组直接包含各行元素,并不存在这些行指针对象。强制类型转换不会创造缺失的间接层,按 int** 解引用会把整数存储误解为指针,从而产生未定义行为。

延伸阅读 C 风格数组
讲清楚了吗?

库设计

2个问题
08 代码应在何时选择内置数组、std::array、std::span 或 std::vector? 初级 常见 查看答案 ▾ 收起 ▴

语言级布局或 C 互操作明确要求时,可以使用内置数组;它也适合完全不跨接口的局部定长存储。固定长度还需要赋值、按值返回等普通值语义时,优先使用 std::array<T, N>。借用已有连续序列并携带长度时使用 std::span 或 std::span,但 span 不会延长生命周期。长度在运行时确定或发生变化时使用 std::vector。选择应先回答所有权和边界问题,而不是比较哪种方括号写法最短。

延伸阅读 C 风格数组
讲清楚了吗?
40 未命名命名空间与内联命名空间分别解决什么问题,又不保证什么? 中级 偶尔 查看答案 ▾ 收起 ▴

未命名命名空间让其中名称在单个翻译单元内具有内部链接,适合源文件中的辅助函数与实现类型。但它若出现在头文件中,每个翻译单元都会得到独立实体,状态可能意外分裂。内联命名空间让成员也能通过外层命名空间查找,常用于选择默认 API 版本,同时保留明确的版本名称。这里的 inline 不是函数内联,也不保证切换版本后 ABI 兼容。我用未命名命名空间表达源文件局部所有权,只在已有明确版本与二进制兼容策略时采用内联命名空间。

延伸阅读 命名空间
讲清楚了吗?

编译模型

1个问题
09 C 中的声明、定义和链接有什么区别? 初级 常见 查看答案 ▾ 收起 ▴

声明引入名称及其类型,让编译器能够检查后续使用。定义还会提供函数体,或为对象提供存储。头文件通常保存多个翻译单元共享的声明,而某个源文件为每个需要的外部实体提供定义。编译阶段分别检查各翻译单元,链接阶段再解析目标文件和库之间的外部引用。兼容声明可以重复;缺少定义通常导致未解析引用,同一对象或函数出现多个外部定义则违反程序约束,并且常被链接器报错。

延伸阅读 C 语言基础
讲清楚了吗?

类型与转换

1个问题
10 为什么负 int 与 size_t 比较可能得到意外结果? 中级 常见 查看答案 ▾ 收起 ▴

size_t 是无符号整数类型。混合算术比较开始前,整数提升和通常算术转换会选出共同类型。根据类型及其转换等级,负 int 可能被转换为无符号类型,并按该类型的范围变成一个很大的值。比较针对转换后的值,而不是源码看起来表达的数学整数。同一取值域应尽量保持一致的符号性;先检查有符号错误结果,再转换有效值,并在转换成 size_t 前证明其范围有效。

延伸阅读 C 语言基础
讲清楚了吗?

正确性

1个问题
11 什么是未定义行为,为什么优化会改变缺陷的表现? 中级 常见 查看答案 ▾ 收起 ▴

未定义行为是 C 标准不对结果施加任何要求的行为,例如有符号整数溢出、越界访问,以及彼此无序且冲突的修改。它不是可移植的错误值,也不保证程序崩溃。优化器可以假定合法执行不会到达未定义行为,并据此简化代码,所以缺陷在优化后可能消失、换位置或产生不同结果。应先用明确契约和边界设计避免它,再通过严格警告、针对性测试、AddressSanitizer 和 UndefinedBehaviorSanitizer 检查实际执行路径中的违规。

延伸阅读 C 语言基础
讲清楚了吗?

函数

1个问题
12 函数接收指针时,C 是按引用传参吗? 初级 常见 查看答案 ▾ 收起 ▴

不是。C 始终按值传递每个实参。int 形参接收整数副本,int* 形参接收指针值的副本。函数解引用这个指针副本时,可以修改调用方被指向的对象,于是表现得类似按引用;但给局部指针改存另一个地址,不会改变调用方的指针。若要替换调用方的指针,需要把其地址作为二级指针传入。接口还必须说明是否允许空指针,以及被指向对象需要保持有效多久。

延伸阅读 C 语言基础
讲清楚了吗?

标准 I/O

2个问题
13 C 输入循环应如何区分文件结束与 I/O 错误,为什么 fgetc 返回 int? 中级 常见 查看答案 ▾ 收起 ▴

循环必须由读取操作的返回值驱动:检查 fgetc、fgets 或 fread 的结果,而不是预先检查 feof。读取返回 EOF、NULL 或短计数后,feof 表示没有更多输入,ferror 表示发生 I/O 错误;这些指示器描述的是已经发生的读取。fgetc 返回 int,才能同时表示所有 unsigned char 值和独立的 EOF 哨兵。应先用 int 保存结果并与 EOF 比较,确认成功后再转换或传递字符值。

延伸阅读 C 文件 I/O
讲清楚了吗?
14 fread 和 fwrite 返回什么,代码应如何处理短计数? 中级 常见 查看答案 ▾ 收起 ▴

两个函数都返回完整传输的元素数;只有元素大小参数为 1 时,该值才直接等于字节数。fread 的短计数可能表示正常到达文件末尾,也可能表示读取错误,因此代码先处理已经取得的元素,再在数量不足时查询 feof 和 ferror。fwrite 的短计数表示输出不完整。面向字节的写入辅助函数应按已接受数量推进指针,继续处理剩余部分或明确失败。最终 fflush 和 fclose 的错误仍必须影响整体结果。

延伸阅读 C 文件 I/O
讲清楚了吗?

流状态

1个问题
15 更新流在输入与输出之间切换时要遵守哪些顺序规则? 高级 偶尔 查看答案 ▾ 收起 ▴

r+、w+ 或 a+ 等更新模式允许两个方向,但不代表可以任意切换。输出后直接开始输入前,代码必须成功调用 fflush 或文件定位函数;输入后直接开始输出前,通常需要成功的定位调用,例外是前一次输入操作已经遇到文件结束。在阶段边界显式检查 fseek,能同时表达预期位置和方向转换。若两个阶段彼此独立,关闭后用更窄的模式重新打开,往往可以得到更简单的契约。

延伸阅读 C 文件 I/O
讲清楚了吗?

数据格式

1个问题
16 为什么用 fwrite 直接写结构体通常不是可移植文件格式? 中级 常见 查看答案 ▾ 收起 ▴

fwrite 写出的是当前实现中的结构体对象表示,其中可能包含填充字节、本机字节序、实现相关的类型宽度和浮点编码。填充还可能带出不应泄露的进程旧数据。稳定格式应独立于内存类型,明确规定魔数、版本、字段宽度、字节序、长度上限和验证规则。代码要逐字段编码与解码,并在分配内存前拒绝截断或自相矛盾的输入。只有文件明确限制在同一 ABI 和受控生命周期内时,才考虑原始布局。

延伸阅读 C 文件 I/O
讲清楚了吗?

类型安全

1个问题
18 为什么强转不兼容的函数指针不能让调用变得安全? 中级 常见 查看答案 ▾ 收起 ▴

强制转换只改变指针表达式的静态类型,不会改变目标函数实际接收参数和返回结果的约定。调用发生时,C 要求调用所用函数类型与目标函数定义兼容,否则行为未定义;参数或结果可能被错误解释,成功编译也不能证明安全。C 确实允许不同函数指针类型之间转换,并保证转回原类型后与原值相等。但这个往返保证只说明指针值能恢复,不代表可以通过中间类型调用。正确做法是修改原型或编写真正匹配签名的适配器。

延伸阅读 C 函数指针
讲清楚了吗?

回调与所有权

1个问题
19 接收 void* 上下文的回调必须附带哪些契约? 中级 常见 查看答案 ▾ 收起 ▴

签名本身不会说明 context 指向什么对象、由谁拥有,也不会说明它保持有效多久。API 必须规定调用是同步还是会被保留、注销何时完成、由哪个线程调用、调用能否重叠或重入,以及谁释放存储。回调必须把 context 转回最初产生该指针的同一种对象指针类型。栈地址可用于同步遍历,但若被保留到调用结束后就会悬空。长期注册时,拥有者必须让存储存活到所有进行中和未来回调都不再访问它。

延伸阅读 C 函数指针
讲清楚了吗?

类型设计

2个问题
22 如何在 C 中设计安全的带标记联合体? 中级 常见 查看答案 ▾ 收起 ▴

把枚举判别值和联合体载荷放进同一个结构体,并建立每个标签只对应一个成员的不变量。构造函数和修改函数同时更新二者;读取、复制、序列化和销毁函数都先检查标签,再访问载荷,并在外部边界拒绝未知值。若成员拥有资源,切换形态前要按旧标签释放旧成员,克隆时则按所有权要求执行深拷贝。内部 switch 可以配合枚举穷举警告省略 default,边界代码则应先验证原始整数,再调用内部逻辑。

讲清楚了吗?
33 C++ 中什么时候适合使用公有继承? 中级 常见 查看答案 ▾ 收起 ▴

公有继承适用于派生对象能够替换基类对象,而且不会让遵守基类契约的调用代码产生意外的情况。派生类必须接受基类允许的输入,保持其后置条件和不变量,也不能让已有操作失去含义。只想复用几个成员并不是充分理由。我会让调用者只依赖 Base 引用,再对每个派生类型运行同一组契约测试。如果真实关系是「有一个」,或派生类型必须限制基类行为,我会改用组合或单独的接口。

延伸阅读 继承
讲清楚了吗?

内存所有权

1个问题
23 对包含指针的结构体执行赋值时会发生什么? 中级 常见 查看答案 ▾ 收起 ▴

结构体赋值会复制每个成员的值。数组成员作为结构体的一部分被复制,指针成员却只复制地址,因此来源和目标随后指向同一个对象。若它明确表示借用的不可变对象,而且生命周期足够长,这样做可以安全;若指针表示所有权,两个表面上的拥有者可能重复释放,或一方释放后让另一方悬空。拥有型类型需要记录初始化、克隆、转交和销毁操作。克隆函数应分配独立存储;若分配失败,还要清理已经完成的部分并保持来源有效。

讲清楚了吗?

可移植性

1个问题
24 为什么 C 位域不适合作为网络包和硬件寄存器的默认表示? 高级 偶尔 查看答案 ▾ 收起 ▴

位域宽度会限制成员可用的值位,但分配单元、单元内排列顺序以及字段能否跨单元等关键布局细节由实现控制,打包和字节序还会增加目标相关假设。位域不能取地址,普通读改写也不会自动成为原子操作。处理可移植外部格式时,我会先把字节装入已知宽度的无符号整数,再使用明确的掩码和移位,并单独处理字节序。只有编译器与 ABI 契约已经记录并经过测试时,才适合采用位域映射。

讲清楚了吗?

对象生命周期

4个问题
26 C++ 构造函数按什么顺序初始化对象,这个顺序为什么重要? 中级 常见 查看答案 ▾ 收起 ▴

对完整对象而言,虚基类最先初始化,然后是直接基类,再按声明顺序初始化非静态数据成员,最后才执行构造函数体。成员初始化器列表的书写顺序不能改变这个过程。若一个成员的初始化表达式读取另一个成员,后者必须已经按声明顺序完成初始化。我会让声明与初始化器保持相同顺序,并启用重排警告。析构顺序与构造相反。构造抛出异常时,已完成的基类与成员会销毁,但未完成的外层对象不会执行自己的析构函数。

延伸阅读
讲清楚了吗?
35 C++ 基类应采用什么析构策略? 中级 常见 查看答案 ▾ 收起 ▴

析构策略取决于接口允许怎样销毁对象。如果调用者可以通过 Base* 或 unique_ptr 拥有派生对象,Base 就需要公有虚析构函数,使删除过程先执行最派生类析构,再销毁基类部分。公有非虚析构会让这种删除产生未定义行为。如果设计禁止通过基类接口删除,可以用受保护的非虚析构强制执行,同时允许直接销毁派生对象。我会检查工厂、容器、回调和所有权别名,而不是只根据类中是否碰巧有其他虚函数来判断。

延伸阅读 继承
讲清楚了吗?
50 构造函数和析构函数中的虚分派会发生什么? 中级 常见 查看答案 ▾ 收起 ▴

为 Derived 对象执行 Base 构造函数时,通过该对象发出的虚调用最多进入 Base 及已经构造的基类,不会进入生命周期尚未开始的 Derived。析构过程与之对称:执行到 Base 析构函数时,Derived 部分的生命周期已经结束,分派不会返回那里。构造或析构期间的虚调用若命中纯虚函数,行为未定义。我会避免在构造函数中使用可重写初始化钩子,改由工厂先完成构造,再对完整对象调用显式虚步骤。

延伸阅读 虚函数
讲清楚了吗?
69 引用绑定何时会延长临时对象生命周期,这条规则的边界在哪里? 中级 常见 查看答案 ▾ 收起 ▴

局部 const 左值引用或右值引用直接绑定某些临时表达式时,可以把临时对象延长到该引用的生命周期,但这种延长不能传递。临时对象绑定到引用形参时,通常只活到包含调用的完整表达式结束,所以保存或返回该形参都不能让对象继续存活。返回表达式中创建的临时对象同样不能支撑一个返回引用。我会追踪准确的初始化表达式和最终所有者;规则一旦变得微妙,返回或保存自有值通常是更清楚的接口。

延伸阅读 引用
讲清楚了吗?

异常安全

1个问题
30 基本、强和不抛出异常安全保证有何区别? 中级 常见 查看答案 ▾ 收起 ▴

基本保证表示失败不会泄漏资源,而且每个受影响对象仍然有效,但值可能已经改变。强保证进一步要求可观察状态不变,就像操作从未发生。不抛出保证则表示操作不会通过传播异常而失败。我会逐项检查每个可能抛出的步骤,而不是统计 try 块。RAII 用于保证资源清理;如果最终提交不会抛出,先准备后提交或复制后交换可以建立强保证。文档还必须说明保证覆盖哪些对象或外部事务。

延伸阅读 异常处理
讲清楚了吗?

API 契约

1个问题
31 noexcept 承诺什么,错误标注为什么危险? 高级 常见 查看答案 ▾ 收起 ▴

noexcept 承诺异常不会离开函数,但不会阻止函数体中的代码抛出。异常一旦越过这条边界,程序会调用 std::terminate,外层 catch 无法恢复。因此,这项标注改变的是失败语义,不只是优化提示。它还参与类型属性,并可能影响 std::move_if_noexcept 等泛型代码选择复制还是移动。我会根据基类与成员操作、回调、分配和日志行为推导异常规范,再用不抛出类型特征验证泛型情况,而不会机械添加 noexcept。

延伸阅读 异常处理
讲清楚了吗?

异常设计

1个问题
32 C++ 代码应如何捕获并重新抛出异常,才不会丢失动态类型? 中级 常见 查看答案 ▾ 收起 ▴

应按值抛出完整对象,并通常通过 const 引用捕获多态异常。若处理器类型是基类,按值声明 catch 参数会创建一个基类对象,切掉派生状态。处理器中的裸 throw; 会重新抛出当前异常对象并保留动态类型;写成 throw error; 则会从参数表达式开始新的抛出,可能再次发生切片。我只在当前层能够恢复、执行策略或转换类型时捕获。转换时把稳定上下文复制进领域异常或结构化原因,而不是依赖解析 what() 文本。

延伸阅读 异常处理
讲清楚了吗?

对象模型

2个问题
34 什么是对象切片,在 API 中应去哪里查找它? 中级 常见 查看答案 ▾ 收起 ▴

对象切片发生在派生对象按值初始化基类对象或赋给基类对象时。操作只复制基类子对象,派生状态和原来的动态类型都会消失;虚函数无法恢复从未保存的部分。我会检查多态基类是否按值出现在参数、返回值、catch 参数、数据成员和容器元素中。不转移所有权的 API 通常接收 Base& 或 const Base&,异构拥有者通常保存 unique_ptr。如果调用者需要值语义,类层次就要提供明确的克隆设计,或者改成封闭的 variant 表示。

延伸阅读 继承
讲清楚了吗?
48 静态类型与动态类型如何共同参与虚函数调用? 中级 常见 查看答案 ▾ 收起 ▴

静态类型控制名字查找、访问检查、重载决议和默认实参。若这个阶段选中虚成员,而且调用没有显式限定,完整对象的动态类型再选择最终覆盖函数。通过 Base& 观察 Derived 对象时,Base 决定调用哪个签名,Derived 可以提供函数体;非虚成员仍按 Base 选择。我会通过基类引用测试,因为直接调用 Derived 可能掩盖签名不匹配或名字隐藏,而生产代码会暴露这些问题。

延伸阅读 虚函数
讲清楚了吗?

多重继承

1个问题
36 虚继承会怎样改变菱形类层次? 高级 偶尔 查看答案 ▾ 收起 ▴

没有虚继承时,菱形中的每条路径都会带来一份共同基类子对象,因此状态、成员查找以及到共同基类的转换可能重复或产生歧义。两个中间类都虚继承共同基类后,最派生对象只包含一份共享虚基类子对象,并由最派生类构造函数负责初始化,而不是任意中间类决定。我只在两条路径确实表示同一身份时这样设计;若每个角色都应拥有独立基类状态,普通多重继承反而正确。同时,我不会依赖特定编译器的偏移或隐藏表布局。

延伸阅读 继承
讲清楚了吗?

名称查找

1个问题
38 using 声明和 using 指令如何影响名称查找? 中级 常见 查看答案 ▾ 收起 ▴

using 声明会指定具体声明,例如 using std::string,并让当前作用域可以查找到它;引入了什么以及名称属于谁,都集中在一行中。using 指令写作 using namespace std,让非限定查找考虑指定命名空间里的名称,但不会逐个创建本地副本。随着头文件或命名空间增加声明,候选集合也可能变化,并在远处制造歧义。我不会在头文件中使用指令,公共签名保持限定;实现代码重复过多时,只在较小块作用域中引入单个名称。两种写法都不改变实体身份或链接。

延伸阅读 命名空间
讲清楚了吗?

泛型编程

1个问题
39 什么是参数依赖查找,为什么泛型 C++ 代码会依赖它? 高级 常见 查看答案 ▾ 收起 ▴

对非限定函数调用,参数依赖查找会从实参类型的关联命名空间和关联类中加入候选。这样,用户定义类型旁边的操作无需全局 using 指令也能参与调用。运算符、隐藏友元和经典 swap 协议都依赖这项规则;泛型交换先引入 std::swap 作为后备,再非限定调用 swap,让 ADL 有机会选择类型专用重载。排查歧义时,我会分别列出普通查找与 ADL 候选。明确设计为定制点的调用不能随意加限定,因为限定会关闭这一步;普通业务调用通常可以显式限定。

延伸阅读 命名空间
讲清楚了吗?

运算符契约

1个问题
42 算术、复合赋值、自增、下标与流运算符通常应返回什么类型? 中级 常见 查看答案 ▾ 收起 ▴

二元算术通常按值返回新的 T;复合赋值修改左操作数并返回 T&,使链式调用继续操作同一对象。前缀自增一般在修改后返回 T&,后缀自增带一个占位 int 形参,并按值返回旧值。容器式下标通常为可写与只读对象分别提供 T& 和 const T& 重载。流插入返回 std::ostream&,从而保留原流并支持链接。这些是语义约定,不是适用于所有类型的语法硬规则,因此还要检查具体契约和每个返回引用的生命周期。

延伸阅读 运算符重载
讲清楚了吗?

比较

1个问题
43 默认三向比较会生成什么,什么时候不适合使用? 高级 常见 查看答案 ▾ 收起 ▴

默认 operator<=> 会按声明顺序比较基类子对象和非静态数据成员,关系运算符可以通过改写候选使用它。若类中没有声明名为 operator== 的成员或友元,默认三向比较还会促使编译器声明匹配的默认相等运算符。返回类别来自各子对象的比较,浮点成员可能带来偏序。如果缓存、调试计数或代理键等存储字段不属于值身份,就不适合直接默认比较。我会先定义参与语义的字段,再测试相等、排序等价、NaN 行为与传递性。

延伸阅读 运算符重载
讲清楚了吗?

生成代码审查

1个问题
44 怎样审查 AI 生成的一组运算符重载? 高级 偶尔 查看答案 ▾ 收起 ▴

我先建立契约表,写清操作数类型、是否修改、结果类别、相等字段、边界与失败行为。随后让编译器列出代表性表达式中的成员、非成员、内置和改写候选。测试要覆盖两种操作数顺序、const 对象、链式赋值、非法转换和返回引用身份。比较不能只验证六个复制出来的函数,还要检查关系性质;浮点值需加入 NaN 与带符号零。下标要核对可写与 const 重载及边界策略。若正确性依赖短路,我会拒绝重载 && 或 ||,并检查所有仅为消除诊断而加入的隐式转换。

延伸阅读 运算符重载
讲清楚了吗?

构造与析构

1个问题
46 RAII 类在取得资源后构造仍可能失败时,应怎样管理该资源? 高级 偶尔 查看答案 ▾ 收起 ▴

构造函数抛出时,完整对象的析构函数不会执行。因此,若后续初始化仍可能失败,把裸句柄存进普通整数或指针成员并不安全。已经完成构造的基类和成员子对象会按逆序销毁,所以我会让专用 RAII 成员先取得或接管句柄,再执行可能失败的工作。另一种做法是让工厂先建立临时所有者,完成全部配置后再移动进返回对象。除非无效状态本来就是类型契约的一部分,否则获取失败不应留下一个看似可用的对象。

延伸阅读 RAII
讲清楚了吗?

失败契约

1个问题
47 关闭或提交可能失败时,怎样设计 RAII 清理? 高级 常见 查看答案 ▾ 收起 ▴

我会把需要报告结果的完成操作与析构兜底分开。普通 closefinishcommit 在调用方仍能执行策略时返回状态或抛出,成功后把对象标为空或已完成。析构函数处理没有显式完成的对象,但不让清理异常逃出。事务守卫通常默认回滚而不是提交,因为离开作用域不能证明操作成功。若回滚本身也可能失败,析构函数可以尽力记录诊断或让连接失效,再由外层恢复机制确认外部状态。RAII 无法把本来会失败的操作变成绝不失败。

延伸阅读 RAII
讲清楚了吗?

接口设计

1个问题
51 纯虚函数与虚析构函数如何塑造接口类? 高级 偶尔 查看答案 ▾ 收起 ▴

纯虚函数以 = 0 声明必需操作,并让类保持抽象,直到出现具体最终覆盖实现。它仍可在类外提供定义,重写函数可通过显式限定调用该定义。纯虚析构函数也必须有定义,因为基类析构阶段仍会执行。析构策略取决于所有权:经 Base* 删除的接口需要公有虚析构;禁止这种删除时可用受保护非虚析构。我会让窄接口不保存数据,明确写出前置条件,并通过基类契约测试每个实现。

延伸阅读 虚函数
讲清楚了吗?

内存模型

1个问题
52 C 中的存储期、对象生命周期与所有权有什么区别? 中级 常见 查看答案 ▾ 收起 ▴

存储期是语言规定的存储保留类别,包括静态、线程、自动和已分配存储期。生命周期表示某个具体对象在这段存储中存在并可被访问的执行区间。所有权则是 API 约定,用来确定谁必须通过配对释放操作结束已分配对象的生命周期。复制指针只会产生别名,不会产生新分配或新的安全所有者。我会说明每个指针是拥有还是借用,同时传递可访问长度,并确保借用不跨过 free 或成功的 realloc。「栈与堆」本身不足以表达这些责任。

讲清楚了吗?

分配 API

1个问题
53 怎样以失败安全的方式使用 realloc? 中级 常见 查看答案 ▾ 收起 ▴

我先按 API 策略拒绝零大小,并证明请求字节数不会使 size_t 溢出。随后用临时指针接收 realloc,保持拥有型指针不变。对有效非零请求返回 NULL 时,旧对象及其内容仍然有效,调用方可以继续使用或释放它。返回非空时,该结果是唯一的新基址;即使地址数值相同,旧对象的生命周期也已经结束。最后初始化新增字节,再一起提交指针、长度和容量,并从新基址重新计算所有内部指针。

讲清楚了吗?

边界与安全

1个问题
54 怎样防止 C 的分配大小溢出? 高级 常见 查看答案 ▾ 收起 ▴

检查必须发生在乘法之前,因为 size_t 算术会先回绕,malloc 只能看到错误结果。分配 count 个元素时,我先拒绝大于 SIZE_MAX / sizeof *pointer 的 count,再计算乘积。多维大小需要逐次检查每个乘法,并按空输入策略处理零除数。C23 的 calloc 会拒绝元素数与大小乘积回绕,但无法执行业务上限。我仍会验证外部数量;调用方需要不同恢复方式时,还要区分大小非法与分配失败。非空结果不能替代这个证明或后续边界检查。

讲清楚了吗?

失败处理

1个问题
55 怎样审查一个获取多项资源的 C 函数的清理逻辑? 高级 偶尔 查看答案 ▾ 收起 ▴

我会按顺序列出资源获取,并检查每个 return、goto 和所有权转移点。每个出口都必须恰好释放一次仍由当前函数拥有的资源,尚未取得的资源则应能安全忽略。先把句柄初始化为有文档的空状态,就能利用 free(NULL) 建立逆序统一清理块。所有权转移要有明确提交点:提交前由当前函数清理,提交后由接收方清理。随后让每次获取依次失败,并在 sanitizer 下运行整个矩阵。刷新或提交等可能失败的完成操作应与最终兜底释放分开报告。

讲清楚了吗?

边界与生命周期

2个问题
57 C 中的指针运算、相减和顺序比较何时有效? 中级 常见 查看答案 ▾ 收起 ▴

指针加减围绕同一个数组对象定义。从元素指针出发,结果可以指向另一个元素或恰好指向数组尾后位置;尾后值只能作结束哨兵,不能解引用。两个指针相减或做顺序比较时,必须属于这个相同数组范围,所得差值以元素数而不是字节数计。两个独立声明的对象即使地址相邻,也不会因此组成数组。我优先使用半开区间,显式携带结束指针或数量,并证明每次解引用都发生在尾后位置之前。

延伸阅读 C 指针
讲清楚了吗?
58 为什么非空检查不能证明 C 指针可以安全解引用? 中级 常见 查看答案 ▾ 收起 ▴

空值只是一种不可用状态。非空值仍可能未初始化、位于数组尾后或范围之外、没有满足目标类型的对齐要求,也可能因为局部作用域返回、free 或 realloc 替换而悬空。强制转换可以隐藏类型诊断,却不能恢复这些事实。解引用前,我会把指针追溯到仍存活的对象,证明所需元素落在可访问范围内,确认目标类型与可变性,并检查接口规定的可空规则。sanitizer 能帮助测试已执行路径,但不能证明完整契约。

延伸阅读 C 指针
讲清楚了吗?

翻译模型

1个问题
60 C 预处理器会产生什么结果,又不理解哪些内容? 初级 常见 查看答案 ▾ 收起 ▴

预处理器在正式编译前处理文件包含、条件选择、指令和宏替换。对一个源文件而言,这些工作形成随后交给编译器分析的翻译单元。它操作预处理词元与空白,不理解带类型的 C 值、对象生命周期或运行时控制流。因此,预处理条件不能使用 sizeof,宏也可能接受一组最终无法通过类型检查的词元。排查问题时,我会先用 gcc -E -P 查看最小复现的预处理结果,再根据编译器诊断分析声明、类型与行为。

延伸阅读 C 预处理器
讲清楚了吗?

宏设计

1个问题
61 什么时候应把函数式宏改成内联函数? 中级 常见 查看答案 ▾ 收起 ▴

只要任务是对稳定类型做值计算,我就优先使用 static inline 函数。函数的每个实参只求值一次,会参与类型检查,遵守作用域,也更容易被调试器与分析工具理解。给宏加括号只能修复优先级,无法解决 MAX(index++, limit) 这类重复求值。只有需要字符串化源码词元、粘贴词元、附加 FILELINE,或通过条件编译删除代码时,宏才有明确优势。我会让这一层保持很薄,并写清实参不得带副作用等限制。

延伸阅读 C 预处理器
讲清楚了吗?

构建配置

1个问题
62 功能开关中的 #ifdef 与 #if 有什么区别? 中级 常见 查看答案 ▾ 收起 ▴

#ifdef FEATURE 只检查宏是否存在,因此执行 #define FEATURE 0 后仍为真。#if FEATURE 会展开数值并把零计算为假;表达式中剩余的未定义普通标识符也按零处理。我只用 #ifndef 提供默认值,随后校验值是否属于允许集合,再用 #if 选择实际分支。若未提供本身有单独含义,就显式检查 defined(FEATURE)。最后还要构建并测试所有受支持组合,因为某次构建中被排除的分支不会接受普通编译检查。

延伸阅读 C 预处理器
讲清楚了吗?

宏展开

1个问题
63 为什么字符串化和词元粘贴有时需要两层宏? 高级 偶尔 查看答案 ▾ 收起 ▴

宏实参通常会先展开,再代入替换列表。例外是紧邻 # 或 ## 的形参,这两个运算符在当前轮次接收未展开的实参词元。因此,若 RAW(x) 定义为 #x,RAW(VERSION) 会得到 “VERSION”,而不是该宏的值。外层 STRINGIFY(x) 再调用 RAW(x),就能先给实参一次展开机会。需要先展开输入再粘贴时,词元粘贴也出于同一原因使用间接层。我会查看预处理输出,因为看似合理的源码可能隐藏错误的展开阶段。

延伸阅读 C 预处理器
讲清楚了吗?

内存与所有权

1个问题
64 如何在 unique_ptr、shared_ptr、weak_ptr 和裸借用之间选择? 中级 常见 查看答案 ▾ 收起 ▴

我先分析所有权图,而不是先考虑写法是否方便。存在一个自然的最终释放者时,使用只移动的 unique_ptr,并把它作为默认拥有形式。多个独立参与者都必须延长寿命时,才使用 shared_ptr。某个关系可以失效且不应保活目标时,使用 weak_ptr,并通过 lock 取得临时强所有者。若代码只在调用方保证的期限内访问对象,则接收 T& 或 T*。形参与返回类型应明确表达转移、共同拥有、观察或借用,而不是让 shared_ptr 穿过每一层。

延伸阅读 智能指针
讲清楚了吗?

引用计数

1个问题
65 shared_ptr 控制块如何工作,为什么引用环会泄漏? 中级 常见 查看答案 ▾ 收起 ▴

shared_ptr 的副本加入同一个控制块;控制块保存强所有者、弱观察者以及销毁信息。最后一个强所有者离开时销毁被管理对象,仍有 weak_ptr 时控制块本身可以继续存在。计数无法判断对象能否从应用根部到达。若两个已经无法从外部访问的对象互持强指针,双方计数都会保持非零,因此析构函数都不会运行。我会根据领域关系找出不拥有的一边并改用 weak_ptr,再释放所有外部强所有者,观察对象析构与弱升级失败。

延伸阅读 智能指针
讲清楚了吗?

所有权身份

1个问题
66 为什么 shared_ptr<T>(this) 是错误的,shared_from_this 何时有效? 高级 偶尔 查看答案 ▾ 收起 ▴

shared_ptr(this) 通常会创建一个新控制块,与已经管理对象的所有者毫无关联,两个所有权组随后可能重复删除同一对象。类型若需要把自身的共享所有权交出去,应继承 enable_shared_from_this。兼容的 shared_ptr 建立所有权后,shared_from_this 会返回同组的新指针。在构造函数中、栈对象上或共享所有权建立前调用它,会抛出 bad_weak_ptr。不应延长寿命的回调更适合使用 weak_from_this;捕获弱指针,并在执行时对一次 lock 的结果进行分支。

延伸阅读 智能指针
讲清楚了吗?

并发

1个问题
67 shared_ptr 提供哪些线程安全保证? 高级 偶尔 查看答案 ▾ 收起 ▴

共享同一控制块的不同 shared_ptr 对象可以并发复制、移动、重置或销毁,而不会破坏控制块。这项保证不会让被管理的 T 自动线程安全,两个线程也不能在没有同步时修改同一个普通 shared_ptr 对象。所指对象需要自己的锁或不可变设计。若共享指针值本身必须原子地发布或替换,C++23 提供 atomic<shared_ptr>,也可以使用锁。我也不会用 use_count 做同步判断,因为它只是瞬时快照,调用后可以立刻变化。

延伸阅读 智能指针
讲清楚了吗?

泛型代码

1个问题
70 为什么有名字的右值引用形参是左值,代码应何时使用 std::move 或 std::forward? 中级 常见 查看答案 ▾ 收起 ▴

值类别属于表达式。有名字的变量表达式始终是左值,即使变量类型为 T&&,所以直接传递名称会选择左值路径。代码确实允许复用资源时,可以用 std::move 把它转换成 xvalue。透明模板包装器中的推导 T&& 可能是转发引用:T 可以推导为左值引用,再由引用折叠保留该类别。std::forward 会恢复调用方的值类别;无条件使用 std::move,则会把调用方拥有的左值也当成可消耗对象。

延伸阅读 引用
讲清楚了吗?