C++ 深入专题
模板、现代库与并发推演 把类型推导、借用关系和线程交错写清楚,再判断程序为什么成立。
8 个专题 25 道深入问答 5 个完整实验
先独立推演,再展开答案。自评不会自动判断代码正确;完整实验的验证环境在各节注明。
ADV-01 / 模板与接口约束 ○ 标为掌握
模板推导与重载决议:先找候选,再比较转换 推导成功、候选可行、最终胜出是三个不同步骤;普通函数并非总胜过函数模板。
分析调用时,先根据名字查找得到候选,再对函数模板推导模板实参并检查约束,最后比较可行候选的实参转换和排序规则。函数模板不会先拿某种任意类型实例化,再尝试所有隐式转换。对于普通函数调用推导中的同一 T,如果两个参数给出冲突结论,通常直接推导失败,例如同一模板形参的两个按值参数不能同时由 int 和 double 推导成一个 T。明确指定模板实参则可能把问题变为已有形参类型上的转换。
按值参数的推导通常去掉实参顶层 const,并发生数组到指针等调整;引用参数可保留数组维度及被引用对象的 const。T&& 只有满足转发引用规则时,传入左值才会使 T 推导为左值引用,再经引用折叠形成左值引用参数。已经固定 T 的类模板成员参数 T&&,不能仅因写法相同就当作转发引用。返回类型、默认函数实参及嵌套类型等还可能落入非推导上下文,需要逐一识别。
若普通函数需要整型转换而函数模板产生精确匹配,模板可以获选。普通函数优先只是在相关转换比较等条件打平后适用的决胜规则之一。转发构造函数也可能比 const T& 复制构造更精确地匹配一个非 const 左值,因此无约束的通用入口容易抢走本来预期的调用。设计接口应限制真正接受的类型,并用少量覆盖 const、左值、右值和数组的编译测试固定行为。
template<class T> void pair_of(T, T);
// pair_of(1, 2.0); // T 的推导冲突
pair_of<double>(1, 2.0); // 参数类型已确定,随后检查转换
template<class T, std::size_t N>
constexpr std::size_t array_size(T (&)[N]) { return N; }深入面试与自测 Q1 · 已有 f(long),又加入 template<class T> f(T)。调用 f(1) 时哪个更可能获选?加入模板为何会改变原来代码? 在这组候选中,整数字面量 1 的类型是 int,普通 f(long) 需要整型转换,而模板可推导 T=int,形成精确匹配,所以选模板。普通函数优先不能覆盖更差的转换等级。库增加一个宽泛模板入口后,原有调用可能静默改变行为,尤其涉及字面量、代理类型和隐式转换类型。评审应列出旧调用集合,限制新模板接受的域,并使用编译测试检查返回类型或可观察的分支选择;只验证接口仍然能编译不足以保证行为兼容。
回答应覆盖 先比较隐式转换序列 模板可形成精确匹配 接口扩展可能静默改变获选函数 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 为什么添加一个接收 T&& 的构造函数后,复制一个非 const 对象反而进入了模板构造? 设复制构造参数为 const Widget&。对 Widget 类型的非 const 左值 w,转发构造可推导 T=Widget&,折叠后的形参为 Widget&,它对这个实参的匹配可能优于需要增加 const 的复制构造。修复应排除自身类型及需要按复制/移动语义处理的相关类型,并限制目标成员是否确实可由该输入构造。C++17 可使用 enable_if 与 decay 类型检查,C++20 可用 requires 表达同一契约。再分别测试 const/非 const 左值、右值和合法转换类型,不能只新增一个特定调用的重载掩盖通用模板过宽的问题。
回答应覆盖 左值使 T 推导为引用 比较 Widget& 与 const Widget& 的匹配 约束自身类型并验证调用矩阵 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · template<class T> void use(T) 与 void use(const T&) 对数组参数的推导为什么不同?这会影响什么接口决策? 按值形参推导会对数组实参进行相应调整,T 通常成为元素指针类型,数组长度信息丢失;引用形参可以绑定数组,从而保留数组类型和长度。若算法依赖编译期长度,可写 T(&)[N] 或接收 std::array;若只需借用连续区间,C++20 span 可以把指针和长度一起传入,但仍需明确底层存储寿命。不能在已经退化为指针的参数内用 sizeof 推断数组元素数,也不能以引用保留了长度推断它自动管理数组所有权。
回答应覆盖 按值推导中的数组调整 数组引用保留维度 长度与所有权分别建模 ☆ 收藏 自评 需重学 不完整 能解释
完整实验 已验证 · MSVC 19.37 / x64 · c++17
四个静态断言检查模板精确匹配胜过普通函数转换、两种同等匹配下普通函数胜出的调用,以及数组引用保留长度。该程序不依赖 sizeof 的平台具体数值;执行结果需由构建验证记录确认。
查看完整源代码 #include <cassert>
#include <cstddef>
#include <iostream>
constexpr int select(long) { return 1; }
template<class T> constexpr int select(T) { return 2; }
constexpr int tied(int) { return 3; }
template<class T> constexpr int tied(T) { return 4; }
template<class T, std::size_t N>
constexpr std::size_t extent(T (&)[N]) { return N; }
int main() {
static_assert(select(1) == 2);
static_assert(select(1L) == 1);
static_assert(tied(1) == 3);
const int a[] = {1, 2, 3};
static_assert(extent(a) == 3);
assert(extent(a) == 3);
std::cout << "overload ok\n";
}
预期行为 overload ok
下载源代码 这里的通过记录只证明指定编译器和测试输入上的结果,不证明所有平台或全部输入都正确。
动手验证 为一个接收字符串的类分别加入 const std::string&、std::string&& 与受约束的转发构造。列出从非 const 本类左值、const 本类左值、字符串字面量构造时的候选和获选原因。
ADV-02 / 模板与接口约束 ○ 标为掌握
特化、名字查找与 ADL:声明位置为什么会改变结果 类模板偏特化、函数模板重载和函数模板显式特化承担不同角色;依赖调用还受定义上下文与 ADL 约束。
类模板可用偏特化对一族类型改变实现,函数模板不能偏特化。函数模板的显式特化依附某个主模板,不是与所有普通重载平等竞争的独立候选。若需要为一组参数形态改变函数行为,通常选择重载,或把分类逻辑放入可偏特化的类模板。特化声明应在相关使用触发实例化之前可见;把定义零散放在源文件中,容易让不同翻译单元得到不一致的认知。
模板中的非依赖名字主要在定义上下文完成查找;依赖调用需要按照模板规则组合候选,不能理解为实例化时把此刻所有同名函数重新搜一遍。对未限定函数调用,ADL 还会根据实参类型寻找关联命名空间中的声明。单纯把一个同名函数写在模板后面,并不能保证它会加入普通名字查找的候选集合。
泛型交换常写 using std::swap; swap(a,b);,使通用候选和类型关联命名空间中的定制函数都能参与。直接写 std::swap(a,b) 是限定调用,不走同样的 ADL 定制路径。类型内部定义的隐藏友元也可由 ADL 找到,但不应据此扩大对任何名字和任何表达式的推断。工程上应选择清楚、窄小的定制入口,避免在无关命名空间堆积通用重载。
namespace model {
struct Item { int value; };
void swap(Item& a, Item& b) noexcept {
int old = a.value; a.value = b.value; b.value = old;
}
}
template<class T> void exchange(T& a, T& b) {
using std::swap;
swap(a, b); // 未限定调用允许 ADL 找到 model::swap
}深入面试与自测 Q1 · 想给所有指针参数提供特殊实现,为什么函数模板偏特化不是可行写法? 语言没有函数模板偏特化这一机制。可以增加形如 f(T*) 的函数模板重载,与 f(T) 在调用时进行推导和偏序比较;也可以把类型分类移到可偏特化的 helper 类,再由函数转交。显式特化 f<int*> 仅针对某个主模板的特定实例,并不会代表所有指针。选择方案时要评估是否需要用户定制、有哪些重载竞争和定义可见性要求,不能把类模板特化规则直接搬到函数模板上。
回答应覆盖 函数模板不支持偏特化 重载与类模板 helper 是不同方案 显式特化依附特定主模板 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 模板调用 helper(x),把 helper 的声明移动到模板后面,为什么有时可用、有时失效? 先判断该调用是否依赖模板参数,以及 helper 是否为未限定名字。普通查找的定义上下文可见集合与 ADL 的关联命名空间查找并不相同;后置声明若位于实参类型的关联命名空间,可能通过 ADL 参与,而某个无关命名空间中的晚声明不能据此补进候选。修复应让必要普通声明在模板定义前可见,或明确使用设计好的关联命名空间定制点。跨翻译单元还要检查声明组织是否一致,不能以一个编译器、一个源文件恰巧通过作为接口契约。
回答应覆盖 识别依赖未限定调用 区分普通查找与 ADL 统一头文件声明可见性 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · using std::swap; swap(a,b) 的两步为什么不能简单改成 std::swap(a,b)? 第一种写法把标准通用实现带入普通查找,同时保留未限定调用,使参数类型的关联命名空间可以提供更合适的 swap。限定到 std 后则选择该限定范围内的候选,不能期望同样触发用户命名空间的 ADL 定制。定制交换还要满足对象不变量和异常承诺,尤其用于 copy-and-swap 时,交换可能抛出会影响强异常保证。验证不仅看结果交换正确,还应确认实际调用路径和 noexcept 契约。
回答应覆盖 using 提供回退候选 未限定调用保留 ADL 交换异常语义关系到上层保证 ☆ 收藏 自评 需重学 不完整 能解释
动手验证 创建两个命名空间中的类型与 swap,比较未限定调用和限定调用的实际候选。再把一个普通重载放在模板定义前后,解释哪些调用变化来自 ADL,哪些不应变化。
ADV-03 / 模板与接口约束 ○ 标为掌握
SFINAE 与 concepts:把错误挡在接口边界 替换失败、约束不满足和函数体实例化错误不是同一件事;约束排序依赖可识别的结构,而非任意数学推理。
C++17 的 SFINAE 允许某些发生在直接上下文中的替换失败移除候选。若错误出现在为了检查候选而实例化的其他类模板或函数体中,未必受这层机制保护。仅把代码包装进模板或 decltype,并不能保证所有错误都变成可恢复的候选淘汰。检测惯用法应测试接口实际需要的表达式,并区分表达式是否合法、结果类型是否合适以及运行期契约是否成立。
C++20 concepts 把约束写在模板接口处,可通过 requires 表达式检查操作、类型或返回结果。约束不满足通常意味着该候选不可用,便于组织回退重载;但语法约束不能证明排序关系具有传递性,也不能证明对象寿命和线程安全。合法表达式与语义模型需要分别论证。一个能编译的比较器,仍可能不满足算法的严格弱序要求。
约束的更强关系采用规范化与原子约束身份等规则,不是通用逻辑证明器。设计更精细概念时,可复用已有命名概念,再增加条件,便于建立可识别的蕴含结构。重复书写看起来等价的表达式不一定获得预期的排序;相互交叉而无法比较的约束可能导致歧义。库接口应有清楚的约束层次,并为重叠类型建立编译测试。
template<class T> concept Integral = std::is_integral_v<T>;
template<class T> concept SignedIntegral = Integral<T> && std::is_signed_v<T>;
template<Integral T> void encode(T);
template<SignedIntegral T> void encode(T);
// SignedIntegral 复用 Integral,形成清楚的约束层次。深入面试与自测 Q1 · 为什么某个 enable_if 检测没有移除候选,反而触发了模板内部的一大段编译错误? 需要检查失败发生的位置。SFINAE 处理的是相关替换的直接上下文,不保证连带实例化出来的所有代码都受保护。例如检测过程中实例化一个 helper,而 helper 的定义本身包含非法操作,这种副作用可能直接造成硬错误。应把必要表达式放到适当的检测位置,减少不必要实例化,或用 C++20 requires 表达式更直接描述接口。修改后要同时验证支持类型、不支持类型和存在同名但结果类型错误的类型,而不是只让一个失败样例变得安静。
回答应覆盖 直接上下文与实例化副作用 缩小检测表达式 正反及近似匹配类型测试 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 两个约束在数学上有包含关系,为什么编译器仍可能认为重载歧义? 约束排序不是任意定理证明。编译器按照规范化后的原子约束及身份规则判断 subsumption,源码中不同位置的表达式即使人看来等价,也不一定是同一原子约束。为避免不稳定的设计,应定义一个命名的基础 concept,再在更强 concept 中复用它并增加要求。若两个约束本来就是交叉关系,则应增加明确交集重载或调整接口,而不是依赖声明顺序决定选择。编译测试要覆盖只满足 A、只满足 B、同时满足二者三种类型。
回答应覆盖 不是一般逻辑证明 复用命名 concept 建立原子约束关系 检查约束交集 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · 通过了 sortable 一类概念检查,能否保证用户比较器不会破坏排序? 不能仅凭编译通过就推断全部语义成立。约束能要求比较表达式存在、结果适当等可检查性质,但严格弱序的传递性等语义通常不能由编译器对任意函数自动证明。包含 NaN 的数据或使用误差阈值的比较器可能暴露这类问题。应明确输入域和比较契约,对三元组检查关键关系,或用经过论证的排序键。概念改善接口错误的位置,不能替代算法不变量、输入约束和性质测试。
回答应覆盖 语法满足与语义建模区分 严格弱序需要额外证明 输入域与性质测试 ☆ 收藏 自评 需重学 不完整 能解释
完整实验 已验证 · MSVC 19.37 / x64 · c++20
使用同一个命名概念构造更强约束,三个输入分别落入通用、整型、有符号整型重载。要求启用 C++20;这里不把一次编译成功外推为任意等价约束都能排序。
查看完整源代码 #include <cassert>
#include <iostream>
#include <type_traits>
template<class T> concept Integral = std::is_integral_v<T>;
template<class T> concept SignedIntegral = Integral<T> && std::is_signed_v<T>;
template<class T> constexpr int route(T) { return 0; }
template<Integral T> constexpr int route(T) { return 1; }
template<SignedIntegral T> constexpr int route(T) { return 2; }
int main() {
static_assert(route(1.5) == 0);
static_assert(route(1u) == 1);
static_assert(route(1) == 2);
assert(route(-2) == 2);
std::cout << "constraints ok\n";
}
预期行为 constraints ok
下载源代码 这里的通过记录只证明指定编译器和测试输入上的结果,不证明所有平台或全部输入都正确。
动手验证 为一个序列化接口写出“存在 serialize()”与“结果可作为所需字节缓冲使用”两层约束,再给一个表达式合法但结果寿命不安全的反例。解释为什么后一问题仍需接口设计解决。
ADV-04 / 现代库与借用关系 ○ 标为掌握
optional 与 variant:让缺失、失败和状态转换可区分 optional 表示值可缺失,variant 表示若干备选类型之一;二者能减少无效状态,但不会自动建立业务状态机。
optional<T> 管理一个可能存在的 T 的寿命。无值可以表达“没有查询结果”,但如果同时需要区分超时、权限错误和数据不存在,单个 optional 不足以携带原因。C++17/20 可设计带明确错误类型的结果结构或 variant;不要使用一个默认构造的 T 同时代表合法值和失败。访问前检查条件,或者使用会报告缺失的接口,不能把未检查的解引用当成可靠错误处理。
variant 用一个活动备选类型表示当前状态,适合替代多组可能互相矛盾的布尔标志。std::visit 的访问器需要处理可发生的备选组合;精确的访问器重载还能帮助新增状态时暴露遗漏。不过,默认构造选择什么备选、类型转换如何选入、状态是否允许转移,仍需要接口定义。对外暴露任意 emplace,可能让调用者绕开状态迁移规则。
variant 在某些抛异常的类型变更操作中可能进入 valueless_by_exception 状态。并非任何异常都必然导致这种状态,也不能用一次具体实现测试把“本次保留旧值”当成普遍强异常保证。应检查备选类型的构造与移动性质,在状态切换前准备临时结果,必要时采用不抛出的提交操作或独立拥有者。异常安全等级由整个迁移过程决定,不能只看外层类型叫 variant。
using Result = std::variant<Value, ParseError>;
// 不用 optional<Value> 同时表达“没有值”与任意失败原因。
std::optional<bool> setting = false;
if (setting.has_value()) {
bool enabled = *setting; // enabled 为 false,setting 仍有值
}深入面试与自测 Q1 · 查询接口返回 optional<Record>,为什么调用方无法判断是否应重试? 如果无值既可能表示未找到,又可能表示网络超时或权限错误,接口已经丢失重试决策所需的信息。应把业务上的缺失与操作失败分开,例如结果类型的一支携带 optional<Record>,另一支携带有分类的错误。重试还要考虑请求是否幂等、截止时间和退避预算,不能看到无值就无限循环。若使用异常传递失败,也需明确异常边界与分类;关键是保持错误信息和契约,而不是规定所有接口必须采用同一种容器。
回答应覆盖 缺失与失败分离 返回足以决定重试的信息 重试受幂等与预算约束 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 用 variant 重写状态机后,是不是就不可能出现非法业务状态? variant 保证活动值只属于定义的备选类型之一,减少了多个布尔变量的无效组合,但无法自动保证转移顺序。例如 Running 可以有一个无效任务编号,或调用方从 Failed 直接改成 Finished。应把状态数据构造和转移操作封装起来,在转换入口检查前置条件,并让不允许的转换返回明确错误。测试可以遍历状态与事件的组合,记录合法目标和拒绝行为;新增备选类型时,避免使用过宽的兜底访问器悄悄吞掉新状态。
回答应覆盖 表示约束不等于业务约束 封装转移与前置条件 状态事件矩阵验证 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · variant 中一个备选类型的构造抛异常,调用方能否总是假设旧值仍在? 不能统一假设。需要查看具体操作、原活动类型以及备选类型的异常与移动性质;某些类型变更可能先销毁旧值,再在构造新值时失败,留下 valueless_by_exception。若上层要求强异常保证,应先准备新结果并分析最终提交是否不抛出,或采用间接表示,在新对象构造成功后通过不抛出的拥有者交换提交。访问路径也要定义无值异常状态如何恢复。测试应注入构造、复制和移动阶段的失败,而不是仅测试正常分支或以某次实现保留旧值推断所有情况。
回答应覆盖 按具体操作分析异常保证 识别 valueless_by_exception 准备与提交阶段分开 ☆ 收藏 自评 需重学 不完整 能解释
完整实验 已验证 · MSVC 19.37 / x64 · c++17
备选类型全部为简单不抛出类型;断言覆盖非法转换、有效零值与缺失结果。这个小例子不用于证明任意复杂备选类型的强异常保证。运行测试时不要定义 NDEBUG。
查看完整源代码 #include <cassert>
#include <iostream>
#include <optional>
#include <variant>
struct Idle {};
struct Running { int id; };
struct Finished { int value; };
struct Failed { int code; };
using State = std::variant<Idle, Running, Finished, Failed>;
bool start(State& state, int id) {
if (!std::holds_alternative<Idle>(state) || id <= 0) return false;
state = Running{id};
return true;
}
bool complete(State& state, std::optional<int> result) {
if (!std::holds_alternative<Running>(state)) return false;
if (result) state = Finished{*result};
else state = Failed{1};
return true;
}
int main() {
State state = Idle{};
const bool early = complete(state, 7);
assert(!early);
const bool invalid = start(state, 0);
assert(!invalid);
const bool begun = start(state, 10);
assert(begun);
const bool duplicate = start(state, 20);
assert(!duplicate);
const bool finished = complete(state, 0);
assert(finished);
assert(std::get<Finished>(state).value == 0);
const bool repeated = complete(state, 9);
assert(!repeated);
State failed = Idle{};
const bool second_begun = start(failed, 11);
assert(second_begun);
const bool second_finished = complete(failed, std::nullopt);
assert(second_finished);
assert(std::get<Failed>(failed).code == 1);
std::cout << "state transitions ok\n";
}
预期行为 state transitions ok
下载源代码 这里的通过记录只证明指定编译器和测试输入上的结果,不证明所有平台或全部输入都正确。
动手验证 把 idle/running/done/failed 四组布尔标志改为 variant 状态机。定义重复 start、未开始就 complete、取消和失败后的重试策略,列出每种非法转换的返回结果。
ADV-05 / 现代库与借用关系 ○ 标为掌握
string_view、span 与 ranges:借用的到底是谁的寿命 view 本身轻量不代表底层资源安全;borrowed_range 描述迭代器与范围对象的关系,不承诺被借用内存永生。
string_view 和 span 保存对已有连续存储的观察能力,不接管底层字符或元素的寿命。复制一个 view 只是复制借用关系。返回局部 string 的 view、让 vector 扩容后继续使用旧 span,都会破坏这层关系。const view 或只读元素只能限制修改方式,不能阻止拥有者销毁、释放或搬迁存储。接口必须交代拥有者、有效期及使句柄失效的操作。
ranges 算法的返回类型会利用 borrowed_range 等概念减少部分临时范围造成的悬空。例如对非借用的临时拥有型范围查找,相关算法可返回 dangling 标记,阻止把结果误当成可解引用迭代器。对 span 这类范围对象,迭代器有效性不依赖这个轻量 span 对象自身是否还存在,但仍依赖被引用数组的寿命和稳定性。因此 borrowed_range 不能修复一个创建时已经悬空的 span。
惰性视图还可能把谓词、投影或其他视图保存在内部;实际迭代时才执行工作。如果 lambda 捕获了已经离开作用域的局部引用,保留底层容器也不够。分析组合管道要分别追踪拥有型范围、引用型视图、谓词对象及迭代器是否依赖父视图。跨线程或异步任务若无法证明所有借用寿命,通常应传递明确的拥有者或复制必要数据。
std::vector<int> owner = {1, 2, 3};
std::span<int> view(owner);
// owner.push_back(4) 若引发重新分配,view 保存的地址会失效。
// 某些 ranges 算法针对临时拥有型范围返回 dangling,
// 但 std::span<int>(dangling_pointer, n) 无法让失效指针重新合法。深入面试与自测 Q1 · span 满足 borrowed_range,为什么返回局部数组构造的 span 仍然错误? borrowed_range 讨论的是迭代器是否依赖范围对象本身的寿命。span 是轻量描述符,销毁 span 不会销毁它指向的数组,所以这种描述成立;但局部数组在函数返回时结束寿命,所有借用该数组的 span 和指针都失效。修复是返回拥有数据的数组或容器,或者要求调用方提供足够长寿的缓冲。检查某个范围满足概念,仅证明相应范围模型的性质,不能推翻底层对象已经销毁这一事实。
回答应覆盖 范围对象与底层存储区分 borrowed_range 不延长数组寿命 用拥有者或调用方缓冲修复 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 容器仍然存在,为什么保存下来的惰性过滤视图也可能悬空? 视图还可能依赖谓词捕获的对象或其他中间视图。比如函数返回 filter 管道,谓词用引用捕获函数局部阈值,调用方随后迭代时局部变量已经销毁;底层 vector 活着也不能使谓词合法。应按数据所有权、闭包捕获和父视图依赖逐层审查,需要跨作用域保留的阈值通常按值捕获。测试要把视图构建与实际迭代分开,以暴露惰性执行的时序,而不是只在函数内部立即遍历一遍。
回答应覆盖 惰性执行延后访问 闭包也是寿命依赖 分开构建与迭代验证 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · 接收 string_view 的异步发送函数在返回后还能读取参数吗? 只有契约确保拥有者和字符存储在异步消费完成前都有效,且没有冲突修改时才可以。一个按值传入的 string_view 不携带这些保证,调用方传临时 string 尤其危险。可让异步操作接收拥有型 string、独占缓冲或共享不可变块,也可采用明确的完成回调和缓冲归还协议。选择时比较复制量、内存滞留和复杂度,不能为了宣称零拷贝而隐含延长调用方寿命。接口文档应说明谁在什么事件后可以重用或释放缓冲。
回答应覆盖 按值 view 仍是借用 完成事件决定缓冲归还 比较复制成本与寿命管理成本 ☆ 收藏 自评 需重学 不完整 能解释
完整实验 已验证 · MSVC 19.37 / x64 · c++20
通过编译期类型检查区分临时 vector 的返回类型与 span 的借用属性;运行期仅解引用仍由 owner 数组持有的合法元素,不运行悬空访问。需要支持所用 C++20 ranges 与 span 的标准库。
查看完整源代码 #include <algorithm>
#include <cassert>
#include <concepts>
#include <iostream>
#include <ranges>
#include <span>
#include <vector>
int main() {
int owner[] = {10, 20, 30};
std::span<int> view(owner);
static_assert(std::ranges::borrowed_range<std::span<int>>);
static_assert(!std::ranges::borrowed_range<std::vector<int>>);
using TemporaryResult = decltype(std::ranges::find(std::vector<int>{1, 2}, 2));
static_assert(std::same_as<TemporaryResult, std::ranges::dangling>);
auto position = std::ranges::find(view, 20);
assert(position != view.end());
assert(*position == 20);
std::cout << "borrowed range ok\n";
}
预期行为 borrowed range ok
下载源代码 这里的通过记录只证明指定编译器和测试输入上的结果,不证明所有平台或全部输入都正确。
动手验证 画出一个 filter 管道中容器、视图、谓词闭包和迭代器的依赖图。分别让容器扩容、谓词引用的变量离开作用域、父视图销毁,判断哪种句柄可能失效。
ADV-06 / 并发正确性与异步生命周期 ○ 标为掌握
release/acquire:沿着读取来源建立 happens-before 要证明普通数据可安全读取,必须指出 acquire 实际读到了哪次发布,而不是只在两边写上内存序名称。
单次发布的典型关系是:生产者先写普通数据,再对某个原子状态执行 release;消费者用 acquire 读取同一原子状态,并确实读到该发布或规则允许的释放序列来源后,再读普通数据。线程内的先后关系与这条同步边共同把数据写入连接到读取。没有这条边,原子标志本身无数据竞争,并不意味着标志旁边的普通数据可以并发读写。
可把证明画成四个点:W(data) → store(flag, release) → load(flag, acquire) → R(data)。中间不是任意两次原子操作之间都存在的箭头,必须核对读取来源和版本。acquire 若仍读到初值,就没有获得本次发布的保证;读完后生产者再次改写普通 data,也不被上一次发布自动保护。单次不可变发布与可重复覆盖的信箱是不同协议,后者还需确认消费完成、分配独立槽位或使用其他同步。
relaxed 可以满足仅需原子性、无需借它发布其他数据的场景,例如独立统计计数,但不能以某个平台本次测试正确替代语言层证明。seq_cst 提供更强的原子操作顺序约束,却也不会自动延长对象寿命或修复读写没有按协议隔离的问题。释放序列等细节还应按目标标准版本核对,避免把历史版本规则无条件套到 C++20。
生产者:W(payload) ──sequenced-before──> flag.store(true, release)
│
acquire 确实读取这次发布 ────┘ synchronizes-with
↓
消费者:flag.load(acquire)==true ───────────> R(payload)
前提:payload 在发布后不再被并发改写,且寿命覆盖消费者使用。深入面试与自测 Q1 · 生产者写普通 payload 再 release 发布,消费者 acquire 读到 true,为什么 payload 不必全部做成 atomic? 在单次发布、发布后不再修改且对象仍存活的前提下,payload 写入先于 release,读取该发布的 acquire 与 release 建立同步,再连接到之后的 payload 读取,形成 happens-before。普通对象的冲突访问已经被排序,因此不需要逐字段原子化。证明中要指出 flag 是同一个原子对象,true 来自哪次写入,以及后续没有再次覆盖 payload。若消费者只读到初值、绕过等待,或生产者继续复用缓冲,这个结论都不能照搬。
回答应覆盖 画出完整 happens-before 链 说明读取来源 限定不可变发布与寿命 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 把所有原子操作都改成 seq_cst,为什么可重复使用的单槽消息盒仍可能有数据竞争? 原子状态的强顺序不能自动约束没有遵守协议的普通数据访问。若生产者看到旧状态或没有等待消费确认,就在消费者读取字符串时重写该字符串,二者仍可能冲突。设计需要两阶段握手、独立消息所有权或正确的槽位序号,使复用发生在消费者使用完成之后。先写出合法状态迁移和对象归属,再选择足够的内存序。直接升级 seq_cst 可能让某个测试更难失败,却不会凭空增加缺失的消费完成事件。
回答应覆盖 原子顺序不等于对象访问互斥 可读与可复用是两种事件 先协议后内存序 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · 为什么不能用大量压力测试无失败,证明一个 relaxed 发布方案正确? 测试仅覆盖实际出现的编译结果、架构与调度轨迹,不能排除标准允许但本次未出现的执行。正确性需要根据语言内存模型建立同步关系;如果普通数据存在无同步的冲突访问,程序已经落入未定义行为范围,观测输出不再是可靠的规格证明。压力测试、TSan 和目标硬件实验适合帮助发现遗漏、验证工程实现和性能,但不能替代对关系链的解释。应先给出定义良好的版本,再用明确目标比较更弱内存序是否有必要。
回答应覆盖 测试覆盖不等于模型证明 数据竞争意味着未定义行为 工具用于发现反例和测量 ☆ 收藏 自评 需重学 不完整 能解释
完整实验 已验证 · MSVC 19.37 / x64 · c++17
普通 payload 在 release 前写完,消费者 acquire 读到发布后才读取,之后没有再次修改,且两个线程 join 前对象一直存活。测试运行时不要定义 NDEBUG;类 Unix 编译链接通常需要 -pthread。本例没有给出调度完成时限,也没有用测试输出取代同步证明。
查看完整源代码 #include <atomic>
#include <cassert>
#include <iostream>
#include <thread>
struct Payload { int value = 0; int check = 0; };
int main() {
Payload payload;
std::atomic<bool> ready{false};
std::thread reader([&] {
while (!ready.load(std::memory_order_acquire)) std::this_thread::yield();
assert(payload.value == 42);
assert(payload.check == 84);
});
std::thread writer([&] {
payload.value = 42;
payload.check = 84;
ready.store(true, std::memory_order_release);
});
writer.join();
reader.join();
std::cout << "release-acquire ok\n";
}
预期行为 release-acquire ok
下载源代码 这里的通过记录只证明指定编译器和测试输入上的结果,不证明所有平台或全部输入都正确。
动手验证 为一个单槽信箱设计可重复发送协议。分别标出“消息可读”和“槽位可复用”两条同步关系,检查生产者是否可能在消费者读取字符串时覆盖它。不要运行有数据竞争的版本来猜固定输出。
ADV-07 / 并发正确性与异步生命周期 ○ 标为掌握
CAS、ABA 与安全回收:原子更新之后还缺什么 比较交换只决定某次值更新;对象是否仍活着、算法能否持续推进,都需要独立的协议和证明。
compare_exchange 比较当前原子值与 expected,成功时写入 desired,失败时会用观察到的值更新 expected。weak 允许伪失败,常用于重试循环;strong 避免这种伪失败,但并不避免真实竞争,更不解决 ABA。重试循环必须按新的 expected 重算依赖它的 desired,不能重复使用基于旧节点或旧计数推导的更新。成功和失败路径的内存序也应分别对应实际读写需求。
ABA 指值从 A 变为其他值再变回 A,使只比较当前值的操作无法察觉历史变化。对链式结构,地址相同还可能对应已删除后重新分配的另一个对象。计数标签能够检测一些历史变化,但仍需分析计数回绕、原子宽度支持和对象解引用时的寿命;把地址与版本一起比较,不会让已经释放的节点重新合法。一个线程在读取 next 之前就可能遭遇回收,甚至还没有机会执行 CAS。
安全回收必须规定何时可以把移除节点真正销毁并复用。Hazard pointer 思路是发布正在使用的节点,并在发布后验证来源仍然符合协议;移除者退休节点,确认没有保护者后才回收。Epoch 等方法采用不同的读侧与静止点约定,也有延迟回收和停滞线程方面的取舍。C++17/20 没有在标准库中提供一套通用 hazard pointer 容器实现,工程上应使用经过审查的实现并明确限制,不能把几行原子指针代码直接称作完整无锁队列。
进展保证也要分层。某个 atomic 类型的操作是否 lock-free 由类型和实现决定,可检查相应属性;一段算法即使使用无锁原子,也可能调用阻塞分配器、等待外部 I/O 或无限重试。系统整体持续有操作完成的 lock-free,与每个操作都有有界步骤的 wait-free 不同。评估时分别看正确性、饥饿风险、尾延迟、内存回收成本和实际吞吐,不以“没有 mutex”作为性能结论。
// 安全回收示意,不是可直接运行的完整算法:
读取候选 p → 按协议发布保护 → 重新验证 p 仍是有效候选
→ 访问 p 的字段 → 完成操作 → 清除保护
移除节点 → 放入退休集合 → 满足无保护者等回收条件 → 销毁/复用
// CAS 失败后 expected 已变化,desired 若依赖 expected 必须重算。深入面试与自测 Q1 · 单链栈的 pop 使用 compare_exchange_strong,为什么仍可能把已经移除的节点接回去? strong 只排除允许给 weak 的伪失败。线程 A 读到头为地址 P 后暂停,线程 B 可移除若干节点并把地址 P 对应的节点或复用地址重新放回头部。A 的比较看到头又是 P 便可能成功,但它保存的 next 已不再代表当前结构。需根据具体算法处理历史变化,并独立保护所有会解引用的节点。只给指针加标签还要分析回绕和内存回收;若当前业务不需要承担这套证明和维护成本,带锁实现可能更合适。
回答应覆盖 strong 不检测历史变化 陈旧 next 与结构更新 ABA 检测与安全回收分开 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 已经使用带版本号的头指针,为什么依旧需要安全回收? 版本比较发生在 CAS 时,而线程可能在这之前就解引用了候选节点,例如读取 next 或值。若其他线程已经释放节点,这一步就是无效访问;未来 CAS 因版本不同而失败,并不能撤销之前的错误。回收协议需要确保从开始读取字段到不再使用期间,节点不能被销毁或复用。标签解决的是可检测的值历史问题,保护发布、验证和退休机制解决的是对象寿命问题,二者不能互相替代。
回答应覆盖 错误可能早于 CAS 失败不能撤销已发生的无效读取 标签与回收解决不同不变量 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · 如何评价“所有共享变量都是 atomic,所以队列既无锁又更快”这一结论? 先逐层验证:atomic 的具体类型可能并不保证 lock-free;成功路径可能调用分配器或其他阻塞函数;重试算法可能让某个线程长期饥饿,不能据此声称 wait-free。再检查对象发布、节点回收和线性化点是否正确。性能方面应固定负载、核心数、生产消费比例和元素尺寸,比较竞争程度、尾延迟、内存滞留和 CPU 消耗。没有 mutex 只描述了源码表面特征,不能同时证明正确性、进展保证和吞吐优势。
回答应覆盖 原子类型与整体算法分层 lock-free 不等于 wait-free 正确性证明与负载基准分开 ☆ 收藏 自评 需重学 不完整 能解释
动手验证 画出 A 读头指针、B 移除并复用节点、A 读取 next/CAS 的交错。分别检查加标签、延迟回收和互斥保护能消除哪个问题,并说明各自剩余条件。
ADV-08 / 并发正确性与异步生命周期 ○ 标为掌握
协程取消与资源生命周期:谁有权恢复,谁负责销毁 co_await 提供挂起机制,不自带执行器、取消协议或自动拥有者;必须把完成、取消、恢复和销毁的竞态统一管理。
C++20 协程把跨挂起点保留的状态放入协程状态对象,调用方拿到的返回对象与 coroutine_handle 如何管理该状态,由类型设计决定。按值参数可以进入协程参数副本,但引用参数仍然借用原对象,成员协程也不会因为挂起就自动延长 this 指向对象的寿命。initial_suspend 和 final_suspend 的策略影响何时开始、何时保留帧,以及最终由谁销毁。
取消首先是协议请求。任务可能停在 I/O、定时器、队列或计算内部,不同等待点需要不同的唤醒与收尾机制。取消请求和正常完成可能同时发生,必须定义哪个状态转换赢得一次性完成权;失败的一方不得再恢复同一协程。若底层操作不可取消,需要保持它引用的缓冲和完成状态有效,直到实际完成,或选择隔离资源并放宽时限承诺。
直接调用 handle.destroy() 不是通用取消按钮。销毁要求协程处于合适的挂起状态,且还要保证没有线程正在恢复或即将再次使用该句柄。异步完成队列中遗留的回调可能在销毁后执行,形成悬空恢复。可靠任务拥有者应先阻止新的恢复来源,处理或撤销已登记回调,并在确认没有在途执行后完成销毁。
await_suspend 负责登记异步继续执行时,句柄一旦被发布,别的线程就可能恢复协程;因此不能无条件认为 await_suspend 返回前 awaiter 和帧必然仍可访问。还应避免持有要求同线程解锁的 mutex 跨越可能换线程恢复的挂起点。审查协程应画出运行、挂起、完成、取消、销毁的状态图,逐项检查所有权与一次性转换,而不是只把同步函数改写为 co_await。
运行 → 登记异步操作并挂起 → 完成方/取消方竞争一次性完成权
↓
唤醒协程 → 取结果或处理取消 → final_suspend
↓
拥有者确认没有在途恢复和外部借用 → 销毁帧
该图是协议审查框架;具体事件循环、I/O API 与 promise 类型仍需实现。深入面试与自测 Q1 · 用户点取消后立即 destroy 协程句柄,为什么几秒后仍可能崩溃? 取消可能只改变了上层状态,底层 I/O 或定时器仍持有回调与句柄。立即销毁帧后,迟到的完成事件可能再次 resume 该句柄,或访问帧内缓冲,造成无效访问。需要让取消与完成通过一个同步的状态机竞争一次性完成权,并明确回调登记撤销和在途回调结束的条件。只有确认不会再有执行或借用访问帧时才能销毁。若底层 API 不支持可靠撤销,应延长必要资源寿命至真实完成,而不能把取消按钮视为物理停止。
回答应覆盖 取消与底层完成分离 迟到回调是额外拥有关系 一次性完成权与销毁时机 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · await_suspend 把句柄提交到线程池后,为什么继续访问 awaiter 成员可能不安全? 协程已经进入挂起阶段,提交动作可能使其他线程立刻取得句柄并恢复它,协程随后还可能结束并销毁相关状态。提交线程若继续访问只由该帧保存的 awaiter,就可能与恢复后的销毁竞态。正确设计应在发布句柄前完成必要字段准备,发布后只使用具有独立有效寿命的状态,或通过明确同步和拥有关系保证访问安全。还要检查提交失败、同步完成和抛异常路径,不能只测试线程池排队较慢的情况。
回答应覆盖 发布句柄后可立即恢复 awaiter 寿命可能结束 发布前准备与失败路径设计 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · 为什么“协程函数的参数会保存在帧里”不能保证引用参数和 this 安全? 保存参数副本不等于复制其指向对象。引用参数仍然引用原对象,指针参数和成员函数的 this 也只表达一个地址;调用方销毁对象后,帧里保留的借用关系就会失效。需要将跨挂起点必需的数据按值持有,传递明确的共享或独占拥有者,或让外部作用域等待任务真正结束。使用 shared_ptr 也要避免循环拥有,并明确取消后仍需保留哪些资源。判断依据是所有者能否覆盖最后一次实际访问,而不是帧中是否还能看到那个指针值。
回答应覆盖 参数副本不复制被引用对象 追踪跨挂起点拥有者 完成前寿命与循环拥有取舍 ☆ 收藏 自评 需重学 不完整 能解释
Q4 · 面试要求所有协程任务在 100 毫秒内取消完成,怎样判断这个承诺能否实现? 先区分收到取消请求、停止产生新工作、底层操作结束、用户回调完成和全部资源回收五种时间点。若任务可进入不可中断的第三方调用,或执行没有停止检查点的无界计算,就无法仅靠协程语法承诺严格完成时限。可为等待设置可取消接口和统一截止时间,对计算分块检查,必要时隔离不可控工作;隔离也不等于工作已停止。验收应对每个阻塞点注入取消和同时完成,记录最坏路径并检查资源是否仍有合法拥有者,避免用提前返回成功掩盖后台残留任务。
回答应覆盖 明确取消完成的定义 不可中断操作限制时限 逐等待点故障注入与资源证据 ☆ 收藏 自评 需重学 不完整 能解释
动手验证 为一个带超时的异步读操作列出三种交错:正常完成先到、取消先到、二者同时到。指出缓冲何时可释放,句柄谁可恢复,失败的竞争方如何退出。
启用 JavaScript 后可保存记录、搜索和安装。以下正文可以直接阅读。