diff --git a/README.md b/README.md index aaafc06e4..2c4685cae 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ --- -![English Coverage](https://img.shields.io/badge/en_coverage-100%25-green.svg) 602/604 docs translated +![English Coverage](https://img.shields.io/badge/en_coverage-98%25-green.svg) 602/614 docs translated ## 这是什么项目 diff --git a/code/examples/vol2/move_benchmark.cpp b/code/examples/vol2/move_benchmark.cpp new file mode 100644 index 000000000..eb2d01862 --- /dev/null +++ b/code/examples/vol2/move_benchmark.cpp @@ -0,0 +1,94 @@ +// move_benchmark.cpp -- 拷贝 vs 移动性能对比(分离构造开销) +// Standard: C++17 +// 对应文档:vol2-modern-features/ch00-move-semantics/05-move-in-practice.md +// +// 关键设计:把"构造"这一固定开销单独测出来作为 baseline,再用 +// (构造+拷贝) - 构造 和 (构造+移动) - 构造 得到纯粹的拷贝/移动耗时, +// 避免构造开销稀释掉移动操作本身"接近零"的事实。 +// +// 注意:绝对耗时是机器相关的,但"纯移动 ≈ 0、纯拷贝 >> 0"的结论稳定。 +#include +#include +#include +#include +#include + +class BigData { + std::vector payload_; + + public: + explicit BigData(std::size_t n) : payload_(n) { + std::iota(payload_.begin(), payload_.end(), 0.0); + } + + BigData(const BigData& other) : payload_(other.payload_) {} + BigData(BigData&& other) noexcept = default; + BigData& operator=(const BigData&) = default; + BigData& operator=(BigData&&) noexcept = default; +}; + +/// @brief 测量函数执行时间的辅助模板 +template double measure_ms(Func&& func, int iterations) { + auto start = std::chrono::high_resolution_clock::now(); + for (int i = 0; i < iterations; ++i) { + func(); + } + auto end = std::chrono::high_resolution_clock::now(); + return std::chrono::duration(end - start).count(); +} + +int main() { + constexpr std::size_t kDataSize = 1000000; // 100 万个 double,约 8MB + constexpr int kIterations = 100; + + std::cout << "数据大小: " << kDataSize * sizeof(double) / 1024 << " KB\n"; + std::cout << "迭代次数: " << kIterations << "\n\n"; + + // 测试 0:仅构造(baseline) + auto construct_time = measure_ms( + [&]() { + BigData source(kDataSize); + (void)source; + }, + kIterations); + + std::cout << "仅构造(baseline): " << construct_time << " ms\n"; + + // 测试 1:构造 + 拷贝 + auto copy_time = measure_ms( + [&]() { + BigData source(kDataSize); + BigData copy = source; // 拷贝构造 + (void)copy; + }, + kIterations); + + std::cout << "构造 + 拷贝: " << copy_time << " ms\n"; + + // 测试 2:构造 + 移动 + auto move_time = measure_ms( + [&]() { + BigData source(kDataSize); + BigData moved = std::move(source); // 移动构造 + (void)moved; + }, + kIterations); + + std::cout << "构造 + 移动: " << move_time << " ms\n\n"; + + // 分离出纯粹的拷贝/移动耗时 + double actual_copy = copy_time - construct_time; + double actual_move = move_time - construct_time; + + std::cout << "=== 分离后的实际耗时 ===\n"; + std::cout << "纯拷贝: " << actual_copy << " ms\n"; + std::cout << "纯移动: " << actual_move << " ms\n"; + + if (actual_move > 0.01) { + std::cout << "加速比: " << actual_copy / actual_move << "x\n"; + } else { + std::cout << "移动耗时在测量噪声范围内(接近零)\n"; + } + + return 0; +} diff --git a/code/examples/vol2/noexcept_sort_vs_realloc.cpp b/code/examples/vol2/noexcept_sort_vs_realloc.cpp new file mode 100644 index 000000000..9aa8df47c --- /dev/null +++ b/code/examples/vol2/noexcept_sort_vs_realloc.cpp @@ -0,0 +1,142 @@ +// noexcept_sort_vs_realloc.cpp -- 验证 noexcept 对 std::sort 和 vector 扩容的影响 +// Standard: C++17 +// 对应文档:vol2-modern-features/ch00-move-semantics/05-move-in-practice.md +// +// 核心结论: +// - std::sort 只用移动,不区分移动操作是否 noexcept(两种类型都是 拷贝=0) +// - vector 扩容通过 move_if_noexcept 选择策略:noexcept 类型用移动, +// 非 noexcept 类型退回拷贝(强异常安全) +#include +#include +#include +#include + +// 移动操作带 noexcept 的类型 +struct NoexceptType { + std::string payload; + int value; + + static int copy_count; + static int move_count; + + NoexceptType(int v) : payload("data"), value(v) {} + NoexceptType(const NoexceptType& o) : payload(o.payload + "_c"), value(o.value) { + ++copy_count; + } + NoexceptType(NoexceptType&& o) noexcept : payload(std::move(o.payload)), value(o.value) { + o.payload = "(moved)"; + ++move_count; + } + NoexceptType& operator=(NoexceptType&& o) noexcept { + payload = std::move(o.payload); + value = o.value; + o.payload = "(moved)"; + ++move_count; + return *this; + } + NoexceptType& operator=(const NoexceptType& o) { + payload = o.payload + "_c"; + value = o.value; + ++copy_count; + return *this; + } + bool operator<(const NoexceptType& rhs) const { return value < rhs.value; } + static void reset() { + copy_count = 0; + move_count = 0; + } +}; + +// ThrowingType 与 NoexceptType 完全相同,唯一区别是移动操作没有 noexcept +struct ThrowingType { + std::string payload; + int value; + + static int copy_count; + static int move_count; + + ThrowingType(int v) : payload("data"), value(v) {} + ThrowingType(const ThrowingType& o) : payload(o.payload + "_c"), value(o.value) { + ++copy_count; + } + ThrowingType(ThrowingType&& o) // 注意:没有 noexcept + : payload(std::move(o.payload)), value(o.value) { + o.payload = "(moved)"; + ++move_count; + } + ThrowingType& operator=(ThrowingType&& o) // 注意:没有 noexcept + { + payload = std::move(o.payload); + value = o.value; + o.payload = "(moved)"; + ++move_count; + return *this; + } + ThrowingType& operator=(const ThrowingType& o) { + payload = o.payload + "_c"; + value = o.value; + ++copy_count; + return *this; + } + bool operator<(const ThrowingType& rhs) const { return value < rhs.value; } + static void reset() { + copy_count = 0; + move_count = 0; + } +}; + +int NoexceptType::copy_count = 0; +int NoexceptType::move_count = 0; +int ThrowingType::copy_count = 0; +int ThrowingType::move_count = 0; + +int main() { + const int kCount = 5000; + + // Test 1: std::sort(noexcept 类型) + { + std::vector vec; + vec.reserve(kCount); + for (int i = 0; i < kCount; ++i) + vec.emplace_back(kCount - i); + NoexceptType::reset(); + std::sort(vec.begin(), vec.end()); + std::cout << "noexcept sort: 拷贝=" << NoexceptType::copy_count + << " 移动=" << NoexceptType::move_count << "\n"; + } + + // Test 2: std::sort(非 noexcept 类型) + { + std::vector vec; + vec.reserve(kCount); + for (int i = 0; i < kCount; ++i) + vec.emplace_back(kCount - i); + ThrowingType::reset(); + std::sort(vec.begin(), vec.end()); + std::cout << "非noexcept sort: 拷贝=" << ThrowingType::copy_count + << " 移动=" << ThrowingType::move_count << "\n"; + } + + std::cout << "\n"; + + // Test 3: vector 扩容(noexcept 类型,无 reserve) + { + NoexceptType::reset(); + std::vector vec; + for (int i = 0; i < 200; ++i) + vec.emplace_back(i); + std::cout << "noexcept 扩容: 拷贝=" << NoexceptType::copy_count + << " 移动=" << NoexceptType::move_count << "\n"; + } + + // Test 4: vector 扩容(非 noexcept 类型,无 reserve) + // ThrowingType 的扩容会退回拷贝,因为 move_if_noexcept 不选中它的移动 + { + ThrowingType::reset(); + std::vector vec; + for (int i = 0; i < 200; ++i) + vec.emplace_back(i); + std::cout << "非noexcept扩容: 拷贝=" << ThrowingType::copy_count + << " 移动=" << ThrowingType::move_count << "\n"; + } +} diff --git a/code/examples/vol2/push_back_emplace.cpp b/code/examples/vol2/push_back_emplace.cpp new file mode 100644 index 000000000..9d4239231 --- /dev/null +++ b/code/examples/vol2/push_back_emplace.cpp @@ -0,0 +1,54 @@ +// push_back_emplace.cpp -- push_back(拷贝/移动) vs emplace_back(原位构造) 对比 +// Standard: C++17 +// 对应文档:vol2-modern-features/ch00-move-semantics/05-move-in-practice.md +// +// 核心结论: +// - push_back(lvalue) 触发拷贝构造 +// - push_back(std::move(rvalue)) 触发移动构造 +// - emplace_back(构造参数) 连移动都省了,直接原位构造 +#include +#include +#include + +class Heavy { + std::string name_; + std::vector data_; + + public: + explicit Heavy(std::string name, std::size_t n) : name_(std::move(name)), data_(n, 42) { + std::cout << " [" << name_ << "] 构造,数据量: " << data_.size() << "\n"; + } + + Heavy(const Heavy& other) : name_(other.name_ + "_copy"), data_(other.data_) { + std::cout << " [" << name_ << "] 拷贝构造\n"; + } + + Heavy(Heavy&& other) noexcept : name_(std::move(other.name_)), data_(std::move(other.data_)) { + other.name_ = "(moved-from)"; + std::cout << " [" << name_ << "] 移动构造\n"; + } + + ~Heavy() { std::cout << " [" << name_ << "] 析构,数据量: " << data_.size() << "\n"; } + + const std::string& name() const { return name_; } + std::size_t data_size() const { return data_.size(); } +}; + +int main() { + std::vector items; + items.reserve(4); + + std::cout << "=== push_back 左值(拷贝)===\n"; + Heavy h1("Alpha", 10000); + items.push_back(h1); + + std::cout << "\n=== push_back 右值(移动)===\n"; + Heavy h2("Beta", 10000); + items.push_back(std::move(h2)); + + std::cout << "\n=== emplace_back 原位构造 ===\n"; + items.emplace_back("Gamma", 10000); + + std::cout << "\n=== 程序结束 ===\n"; + return 0; +} diff --git a/code/examples/vol4/vol3-metaprogramming-cpp20-23/concept_pitfalls.cpp b/code/examples/vol4/vol3-metaprogramming-cpp20-23/concept_pitfalls.cpp new file mode 100644 index 000000000..4584cd5ef --- /dev/null +++ b/code/examples/vol4/vol3-metaprogramming-cpp20-23/concept_pitfalls.cpp @@ -0,0 +1,56 @@ +// Concepts 的三个经典坑,用宏切换复现(默认编译干净,加宏触发对应坑的编译失败) +// 对应文章:02-constraining-templates.md、03-requires-expressions.md +// +// 默认编译(干净,main 演示「用 concept 包装优雅判断负例」的解法): +// g++ -Wall -Wextra -std=c++20 concept_pitfalls.cpp -o cp && ./cp +// 复现三个坑(每个都会编译失败,对照文章看报错): +// g++ -std=c++20 -DDEMO_AMBIGUITY concept_pitfalls.cpp # 坑一:两个互不蕴含的 concept 重载,Duck +// 同时满足 -> 歧义 g++ -std=c++20 -DDEMO_NOSUBSUME concept_pitfalls.cpp # 坑二:C2=C1 +// 规范化后原子约束与 C1 相同,不 subsume -> 歧义 g++ -std=c++20 -DDEMO_HARD_ERROR +// concept_pitfalls.cpp # 坑三:对具体类型直接写 requires 表达式 -> 硬错误 +#include +#include + +// 坑一用:两个彼此独立的 concept,谁也不蕴含谁 +template +concept Swimmable = requires(T t) { t.swim(); }; +template +concept Flyable = requires(T t) { t.fly(); }; +void act(Swimmable auto) {} +void act(Flyable auto) {} + +// 坑二用:C2 只是 C1 换名,没有额外原子约束 +template +concept C1 = requires(T t) { t.a(); }; +template +concept C2 = C1; +void g(C1 auto) {} +void g(C2 auto) {} + +// 坑三的解法:把 requires 表达式包进 concept,求值时 T 是模板参数 -> SFINAE 友好,失败返回 false +// 而非硬错误 +template +concept HasNope = requires(T t) { t.nope(); }; + +struct Duck { + void swim() {} + void fly() {} +}; +struct X { + void a() {} +}; + +int main() { + std::cout << std::boolalpha; + // 解法演示:concept 包装后,不存在的成员优雅返回 false + std::cout << "HasNope: " << HasNope << "\n"; // false,不硬错误 + +#if DEMO_AMBIGUITY + act(Duck{}); // Duck 同时满足 Swimmable 和 Flyable,两者互不蕴含 -> 编译器选不出 -> 歧义 +#elif DEMO_NOSUBSUME + g(X{}); // X 同时满足 C1 和 C2,但 C2 规范化后原子约束= C1,无真包含 -> 歧义 +#elif DEMO_HARD_ERROR + static_assert(!requires(std::string s) { s.nope(); }); // 对具体类型 string 直接写 -> 硬错误 +#endif + return 0; +} diff --git a/code/examples/vol4/vol3-metaprogramming-cpp20-23/concepts_four_forms.cpp b/code/examples/vol4/vol3-metaprogramming-cpp20-23/concepts_four_forms.cpp new file mode 100644 index 000000000..11a8e10ce --- /dev/null +++ b/code/examples/vol4/vol3-metaprogramming-cpp20-23/concepts_four_forms.cpp @@ -0,0 +1,41 @@ +// Concepts 的四种语法形式:把约束写进模板参数、requires 子句、简写 auto、内嵌 requires 表达式 +// 对应文章:documents/vol4-advanced/vol3-metaprogramming-cpp20-23/01-concepts.md +// 编译运行:g++ -Wall -Wextra -std=c++20 concepts_four_forms.cpp -o four_forms && ./four_forms +#include +#include +#include + +template +concept Numeric = std::integral || std::floating_point; + +// 形式①:把 concept 直接当约束写在模板参数列表里 +template T form1(T a, T b) { + return a + b; +} + +// 形式②:requires 子句(trailing requires-clause) +template + requires Numeric +T form2(T a, T b) { + return a + b; +} + +// 形式③:简写模板语法(constrained auto) +auto form3(Numeric auto a, Numeric auto b) { + return a + b; +} + +// 形式④:模板参数列表后跟 requires,内层是一个 requires 表达式 +template + requires requires(T x) { x + x; } +T form4(T a, T b) { + return a + b; +} + +int main() { + std::cout << "form1: " << form1(3, 5) << "\n"; + std::cout << "form2: " << form2(2.0, 3.0) << "\n"; + std::cout << "form3: " << form3(10, 20) << "\n"; + std::cout << "form4: " << form4(std::string("a"), std::string("b")) << "\n"; + return 0; +} diff --git a/code/examples/vol4/vol3-metaprogramming-cpp20-23/constraints_everywhere.cpp b/code/examples/vol4/vol3-metaprogramming-cpp20-23/constraints_everywhere.cpp new file mode 100644 index 000000000..dd3a027a3 --- /dev/null +++ b/code/examples/vol4/vol3-metaprogramming-cpp20-23/constraints_everywhere.cpp @@ -0,0 +1,39 @@ +// concept 约束可以用在函数模板、类模板、成员函数、简写 auto 等多种位置 +// 对应文章:documents/vol4-advanced/vol3-metaprogramming-cpp20-23/02-constraining-templates.md +// 编译运行:g++ -Wall -Wextra -std=c++20 constraints_everywhere.cpp -o ce && ./ce +#include +#include + +template +concept Numeric = std::integral || std::floating_point; + +// ① 函数模板 +template T square(T x) { + return x * x; +} + +// ② 类模板:只对数值类型实例化 +template struct SafeNumber { + T value; + SafeNumber(T v) : value(v) {} + // ③ 成员函数也能再加自己的约束 + SafeNumber& operator+=(Numeric auto other) { + value += other; + return *this; + } +}; + +// ④ 简写语法:约束直接写在 auto 前 +Numeric auto half(Numeric auto x) { + return x / 2; +} + +int main() { + std::cout << "square(4) = " << square(4) << "\n"; + std::cout << "square(2.5) = " << square(2.5) << "\n"; + SafeNumber sn(3); + sn += 4; + std::cout << "SafeNumber(3) + 4 = " << sn.value << "\n"; + std::cout << "half(10) = " << half(10) << "\n"; + return 0; +} diff --git a/code/examples/vol4/vol3-metaprogramming-cpp20-23/enable_if_vs_concept.cpp b/code/examples/vol4/vol3-metaprogramming-cpp20-23/enable_if_vs_concept.cpp new file mode 100644 index 000000000..056c24f26 --- /dev/null +++ b/code/examples/vol4/vol3-metaprogramming-cpp20-23/enable_if_vs_concept.cpp @@ -0,0 +1,49 @@ +// enable_if vs concept 的报错对比:同一个 add,两种约束方式,故意传错类型看报错差异 +// 对应文章:documents/vol4-advanced/vol3-metaprogramming-cpp20-23/01-concepts.md +// +// 默认编译(演示两个 add 都能正常工作): +// g++ -Wall -Wextra -std=c++20 enable_if_vs_concept.cpp -o eic && ./eic +// +// 复现报错对比(分别加宏编译,看 concept 报错如何直接点名约束、enable_if 版暴露 SFINAE 内部): +// g++ -std=c++20 -DDEMO_OLD enable_if_vs_concept.cpp # enable_if 版报错 +// g++ -std=c++20 -DDEMO_NEW enable_if_vs_concept.cpp # concept 版报错(constraints not +// satisfied) +#include +#include +#include +#include + +namespace old_way { +// C++17 的 enable_if:把约束塞进额外的默认模板参数 +template || std::is_floating_point_v>> +T add(T a, T b) { + return a + b; +} +} // namespace old_way + +namespace new_way { +// C++20 concept:约束写进签名,有名字、可复用 +template +concept Numeric = std::integral || std::floating_point; + +template + requires Numeric +T add(T a, T b) { + return a + b; +} +} // namespace new_way + +int main() { + std::cout << "old_way::add(2, 3) = " << old_way::add(2, 3) << "\n"; + std::cout << "new_way::add(2, 3) = " << new_way::add(2, 3) << "\n"; + +#if DEMO_OLD + std::string s1 = "a", s2 = "b"; + old_way::add(s1, s2); // 故意传错:看 enable_if 版报错(no type named 'type' in enable_if) +#elif DEMO_NEW + std::string s1 = "a", s2 = "b"; + new_way::add(s1, s2); // 故意传错:看 concept 版报错(constraints not satisfied -> Numeric) +#endif + return 0; +} diff --git a/code/examples/vol4/vol3-metaprogramming-cpp20-23/requires_expression.cpp b/code/examples/vol4/vol3-metaprogramming-cpp20-23/requires_expression.cpp new file mode 100644 index 000000000..06f854ea6 --- /dev/null +++ b/code/examples/vol4/vol3-metaprogramming-cpp20-23/requires_expression.cpp @@ -0,0 +1,35 @@ +// Requires 表达式四种成分(简单/类型/复合/嵌套)+ 当 bool 用 + 用表达式定义 concept +// 对应文章:documents/vol4-advanced/vol3-metaprogramming-cpp20-23/03-requires-expressions.md +// 编译运行:g++ -Wall -Wextra -std=c++20 requires_expression.cpp -o req && ./req +#include +#include +#include + +// 四种成分一次用全:简单要求、类型要求、复合要求、嵌套要求 +template +concept Container = requires(T t) { + t.begin(); // ① 简单要求 + t.end(); + typename T::value_type; // ② 类型要求 + { t.size() } -> std::convertible_to; // ③ 复合要求 + requires std::integral; // ④ 嵌套要求 +}; + +static_assert(Container>); // value_type=int,integral 通过 +static_assert(!Container); // int 没有 begin/end + +// requires 表达式当 bool 用:if constexpr 里直接判断 +template void process(T t) { + (void)t; + if constexpr (requires(T x) { x.empty(); }) { + std::cout << "has empty()\n"; + } else { + std::cout << "no empty()\n"; + } +} + +int main() { + process(std::vector{}); // vector 有 empty() + process(42); // int 没有 + return 0; +} diff --git a/code/examples/vol4/vol3-metaprogramming-cpp20-23/same_as_pitfall.cpp b/code/examples/vol4/vol3-metaprogramming-cpp20-23/same_as_pitfall.cpp new file mode 100644 index 000000000..d4b663a44 --- /dev/null +++ b/code/examples/vol4/vol3-metaprogramming-cpp20-23/same_as_pitfall.cpp @@ -0,0 +1,30 @@ +// same_as 的两个坑:same_as 为 false(cv 限定让它们成为不同类型);concept 当 is_same +// 误用 对应文章:01-concepts.md、02-constraining-templates.md 编译运行:g++ -Wall -Wextra -std=c++20 +// same_as_pitfall.cpp -o sas && ./sas +#include +#include +#include + +// 坑二:concept 当 is_same 用,把 T 锁死成 int +// 能跑,但这种「锁死成具体类型」通常不如直接写非模板函数 void only_int(int) 清楚 +template T> void only_int(T x) { + std::cout << "got int: " << x << "\n"; +} +// same_as 真正的用武之地是约束两个参数的关系: +// template requires std::same_as + +int main() { + std::cout << std::boolalpha; + + // 坑一:const 限定让 int 和 const int 成为不同类型,same_as 返回 false + std::cout << "same_as: " + << std::same_as << "\n"; // false + // 想判断「剥掉 cv/引用后是否相同」,先用 remove_cvref_t + std::cout << "same_as>: " + << std::same_as> << "\n"; // true + + // 坑二演示:only_int 只收 int + only_int(42); + // only_int(3.14); // 编译失败:double 不满足 same_as + return 0; +} diff --git a/code/examples/vol4/vol3-metaprogramming-cpp20-23/stdconcepts_demo.cpp b/code/examples/vol4/vol3-metaprogramming-cpp20-23/stdconcepts_demo.cpp new file mode 100644 index 000000000..21f25f040 --- /dev/null +++ b/code/examples/vol4/vol3-metaprogramming-cpp20-23/stdconcepts_demo.cpp @@ -0,0 +1,26 @@ +// 标准库 常用概念实测:same_as / convertible_to / derived_from / common_with / integral / +// floating_point 对应文章:documents/vol4-advanced/vol3-metaprogramming-cpp20-23/01-concepts.md +// 编译运行:g++ -Wall -Wextra -std=c++20 stdconcepts_demo.cpp -o stdc && ./stdc +#include +#include +#include + +struct Base {}; +struct Derived : Base {}; +struct Unrelated {}; + +int main() { + std::cout << std::boolalpha; + std::cout << "same_as: " << std::same_as << "\n"; + std::cout << "same_as: " << std::same_as << "\n"; + std::cout << "convertible_to: " << std::convertible_to << "\n"; + std::cout << "convertible_to: " << std::convertible_to << "\n"; + std::cout << "derived_from: " << std::derived_from << "\n"; + std::cout << "derived_from: " << std::derived_from << "\n"; + std::cout << "common_with: " << std::common_with << "\n"; + std::cout << "default_initializable: " << std::default_initializable << "\n"; + std::cout << "integral: " << std::integral << "\n"; + std::cout << "integral: " << std::integral << "\n"; + std::cout << "floating_point: " << std::floating_point << "\n"; + return 0; +} diff --git a/code/examples/vol4/vol3-metaprogramming-cpp20-23/subsumption_overloads.cpp b/code/examples/vol4/vol3-metaprogramming-cpp20-23/subsumption_overloads.cpp new file mode 100644 index 000000000..e27fae317 --- /dev/null +++ b/code/examples/vol4/vol3-metaprogramming-cpp20-23/subsumption_overloads.cpp @@ -0,0 +1,54 @@ +// Subsumption(约束蕴含):多个带 concept 约束的重载,编译器靠约束的包含关系选最特定的 +// 对应文章:documents/vol4-advanced/vol3-metaprogramming-cpp20-23/02-constraining-templates.md +// 编译运行:g++ -Wall -Wextra -std=c++20 subsumption_overloads.cpp -o sub && ./sub +#include + +// 第一组:Dog 蕴含 Animal(Dog = Animal && 额外要求),subsumption 让更窄的重载胜出 +template +concept Animal = requires(T t) { t.eat(); }; +template +concept Dog = Animal && requires(T t) { t.bark(); }; + +void describe(Animal auto const&) { + std::cout << "an animal\n"; +} +void describe(Dog auto const&) { + std::cout << "a dog\n"; +} + +// 第二组:&& 组合产生 {A, B} 原子约束集合,C 同时 subsumes A 和 B +template +concept A = requires(T t) { t.a(); }; +template +concept B = requires(T t) { t.b(); }; +template +concept C = A && B; + +void f(A auto const&) { + std::cout << "A\n"; +} +void f(B auto const&) { + std::cout << "B\n"; +} +void f(C auto const&) { + std::cout << "C\n"; +} + +struct Cat { + void eat() {} +}; +struct Pup { + void eat() {} + void bark() {} +}; +struct Both { + void a() {} + void b() {} +}; + +int main() { + describe(Cat{}); // 只满足 Animal -> 宽重载 + describe(Pup{}); // 满足 Dog -> 窄重载(subsumes 宽重载) + f(Both{}); // 同时满足 A、B、C -> C(subsumes A 和 B) + return 0; +} diff --git a/code/examples/vol4/vol3-metaprogramming-cpp20-23/unevaluated.cpp b/code/examples/vol4/vol3-metaprogramming-cpp20-23/unevaluated.cpp new file mode 100644 index 000000000..f14ee50aa --- /dev/null +++ b/code/examples/vol4/vol3-metaprogramming-cpp20-23/unevaluated.cpp @@ -0,0 +1,25 @@ +// requires 表达式不求值(unevaluated):里面的调用只检查能否编译,根本不执行、无副作用 +// 对应文章:documents/vol4-advanced/vol3-metaprogramming-cpp20-23/03-requires-expressions.md +// 编译运行:g++ -Wall -Wextra -std=c++20 unevaluated.cpp -o ue && ./ue +// 预期:concept 求值时 counter 仍为 0,只有 main 里真正调用 increment 才变 1 +#include + +int counter = 0; +int increment() { + ++counter; + std::cout << "[副作用] increment 被调用了\n"; + return 1; +} + +template +concept MentionsIncrement = requires(T t) { + increment(); // 只检查「这个调用合不合法」,不求值、不执行 +}; + +int main() { + static_assert(MentionsIncrement); // 满足:increment() 调用合法 + std::cout << "concept 求值完毕,counter = " << counter << "\n"; + increment(); // 这里才真正调用 + std::cout << "真正调用后,counter = " << counter << "\n"; + return 0; +} diff --git a/documents/en/vol2-modern-features/ch00-move-semantics/05-move-in-practice.md b/documents/en/vol2-modern-features/ch00-move-semantics/05-move-in-practice.md index 2c68520bd..112dfa807 100644 --- a/documents/en/vol2-modern-features/ch00-move-semantics/05-move-in-practice.md +++ b/documents/en/vol2-modern-features/ch00-move-semantics/05-move-in-practice.md @@ -106,7 +106,7 @@ namespace std { Three move operations complete the exchange of two objects. For classes that manage resources indirectly via pointers (memory allocated by `new`, file descriptors, etc.), each move is just a pointer transfer, so the cost of the entire swap is O(1)—independent of the size of the resources the object manages. However, note the prerequisite: this conclusion relies on "resources being held indirectly." If your object stores data directly inside itself like `std::array` (no indirection), then moving and copying are equivalent—swap remains O(n). In contrast, C++03's `swap` for types holding indirect resources required one copy construction and two copy assignments, costing O(n). -In sorting algorithms, `swap` is one of the most frequent operations. `std::sort` internally calls `swap` extensively to adjust element positions; efficient move operations reduce the cost of each adjustment from O(n) to O(1). It's worth noting specifically that `noexcept` has no direct effect on `std::sort` itself—`sort` uses `swap` internally and doesn't care if the move operation is `noexcept` (as long as the type meets the MoveConstructible and MoveAssignable requirements). Where `noexcept` really shines is during `std::vector` reallocation: when a `vector` needs to move old elements to new memory, it uses `std::is_nothrow_move_constructible_v` to choose its strategy—if the move operation is `noexcept`, it uses move; otherwise, it falls back to copy to guarantee strong exception safety. Let's use the following verification program to prove this: +In sorting algorithms, `swap` is one of the most frequent operations. `std::sort` internally calls `swap` extensively to adjust element positions; efficient move operations reduce the cost of each adjustment from O(n) to O(1). `noexcept` actually has no direct effect on `std::sort` itself—`sort` uses `std::move` and `std::swap` internally and doesn't care whether the move is `noexcept` (as long as the type is MoveConstructible and MoveAssignable). Where `noexcept` really shines is during `std::vector` reallocation: when a `vector` needs to move old elements to new memory, it uses `std::is_nothrow_move_constructible_v` to choose its strategy—if the move operation is `noexcept`, it uses move; otherwise, it falls back to copy to guarantee strong exception safety. Let's use the following verification program to prove this: ```cpp #include @@ -169,7 +169,7 @@ int main() { } ``` -Compile and run (g++ 15.2, -std=c++17 -O2, x86_64): +Compile and run (GCC 16.1.1, -std=c++17 -O2, x86_64): ```bash g++ -std=c++17 -O2 main.cpp -o main && ./main @@ -264,91 +264,124 @@ Here we use the copy-and-swap idiom to implement the assignment operator, and a We've covered a lot of theory, but numbers are the most persuasive. Let's do a benchmark comparing the actual time taken by copying versus moving. This time, we'll separate the construction overhead so you can see just how fast a pure move operation is. ```cpp -#include +// move_benchmark.cpp -- Copy vs Move performance comparison (isolating construction overhead) +// Standard: C++17 + #include #include -#include +#include +#include +#include -using namespace std; -using namespace std::chrono; +class BigData +{ + std::vector payload_; -class BigData { public: - // Allocate 8MB memory and fill with data - BigData() : size_(1024 * 1024 * 2), data_(new int[size_]) { - for (size_t i = 0; i < size_; ++i) { - data_[i] = static_cast(i); - } + explicit BigData(std::size_t n) : payload_(n) + { + std::iota(payload_.begin(), payload_.end(), 0.0); } - ~BigData() { delete[] data_; } + BigData(const BigData& other) : payload_(other.payload_) {} + BigData(BigData&& other) noexcept = default; + BigData& operator=(const BigData&) = default; + BigData& operator=(BigData&&) noexcept = default; +}; - // Copy: allocate new memory and copy all data - BigData(const BigData& other) : size_(other.size_), data_(new int[size_]) { - std::copy(other.data_, other.data_ + size_, data_); +/// @brief Helper template to measure a function's execution time +template +double measure_ms(Func&& func, int iterations) +{ + auto start = std::chrono::high_resolution_clock::now(); + for (int i = 0; i < iterations; ++i) { + func(); } + auto end = std::chrono::high_resolution_clock::now(); + return std::chrono::duration(end - start).count(); +} - // Move: just transfer pointers - BigData(BigData&& other) noexcept - : size_(other.size_), data_(other.data_) { - other.data_ = nullptr; - other.size_ = 0; +int main() +{ + constexpr std::size_t kDataSize = 1000000; // 1M doubles, ~8MB + constexpr int kIterations = 100; + + std::cout << "Data size: " << kDataSize * sizeof(double) / 1024 + << " KB\n"; + std::cout << "Iterations: " << kIterations << "\n\n"; + + // Test 0: construction only (baseline) + auto construct_time = measure_ms([&]() { + BigData source(kDataSize); + (void)source; + }, kIterations); + + std::cout << "Construction only (baseline): " << construct_time << " ms\n"; + + // Test 1: construction + copy + auto copy_time = measure_ms([&]() { + BigData source(kDataSize); + BigData copy = source; // copy ctor + (void)copy; + }, kIterations); + + std::cout << "Construction + Copy: " << copy_time << " ms\n"; + + // Test 2: construction + move + auto move_time = measure_ms([&]() { + BigData source(kDataSize); + BigData moved = std::move(source); // move ctor + (void)moved; + }, kIterations); + + std::cout << "Construction + Move: " << move_time << " ms\n\n"; + + // Isolate the pure copy/move cost + double actual_copy = copy_time - construct_time; + double actual_move = move_time - construct_time; + + std::cout << "=== Isolated actual cost ===\n"; + std::cout << "Pure copy: " << actual_copy << " ms\n"; + std::cout << "Pure move: " << actual_move << " ms\n"; + + if (actual_move > 0.01) { + std::cout << "Speedup: " << actual_copy / actual_move << "x\n"; + } else { + std::cout << "Move cost is within measurement noise (near zero)\n"; } -private: - size_t size_; - int* data_; -}; - -int main() { - auto t0 = high_resolution_clock::now(); - - // 1. Pure construction - auto start = high_resolution_clock::now(); - BigData src1; - auto end = high_resolution_clock::now(); - double t_ctor = duration_cast(end - start).count() / 1000.0; - - // 2. Construction + Copy - start = high_resolution_clock::now(); - BigData src2; - BigData dst_copy(src2); // Copy - end = high_resolution_clock::now(); - double t_copy = duration_cast(end - start).count() / 1000.0; - - // 3. Construction + Move - start = high_resolution_clock::now(); - BigData src3; - BigData dst_move(std::move(src3)); // Move - end = high_resolution_clock::now(); - double t_move = duration_cast(end - start).count() / 1000.0; - - cout << fixed << setprecision(1); - cout << "Pure construction: " << t_ctor << " ms\n"; - cout << "Construction + Copy: " << t_copy << " ms (copy cost: " << (t_copy - t_ctor) << " ms)\n"; - cout << "Construction + Move: " << t_move << " ms (move cost: " << (t_move - t_ctor) << " ms)\n"; + return 0; } ``` Compile and run: ```bash -g++ -std=c++17 -O2 main.cpp -o main && ./main +g++ -std=c++17 -O2 -Wall -Wextra -o move_bench move_benchmark.cpp +./move_bench ``` -Output on my machine (g++ 15.2, -O2, x86_64 WSL2): +Output on my machine (GCC 16.1.1, -O2, x86_64 WSL2, one stable run): ```text -Pure construction: 96.2 ms -Construction + Copy: 1404.0 ms (copy cost: 1307.8 ms) -Construction + Move: 94.8 ms (move cost: -1.4 ms) +Data size: 7812 KB +Iterations: 100 + +Construction only (baseline): 47.3 ms +Construction + Copy: 505.3 ms +Construction + Move: 44.6 ms + +=== Isolated actual cost === +Pure copy: 458.1 ms +Pure move: -2.7 ms +Move cost is within measurement noise (near zero) ``` -This result is much more persuasive than simply reporting a "speedup factor." Let's look at it line by line: constructing a `BigData` (allocating 8MB memory and filling it with data) took about 96ms, which is the base overhead shared by both test groups. Adding a copy sent the total time soaring to 1404ms—the pure copy portion took 1308ms, because it needs to allocate new memory and copy 8MB of data byte by byte. Adding a move resulted in a total time of 94.8ms—even slightly less than pure construction by less than 1ms (measurement noise), indicating that the overhead of the move operation itself is almost unmeasurable at this data scale. +This is more persuasive than just reporting a "speedup factor." Let's go line by line: constructing a `BigData` (allocating ~8MB and filling it) took 47ms, the fixed overhead shared by both groups. Adding a copy pushes the total to 505ms—the pure copy portion is 458ms, because it has to allocate a fresh block and copy 8MB byte by byte. Adding a move gives a total of 45ms, essentially identical to pure construction—meaning the move operation itself is unmeasurable at this scale. -> 💡 **Note on Measurement Noise**: You might see negative values for "pure move" time (like -1.4 ms). This is completely normal. High-precision timers capture tiny differences in system scheduling and cache state, causing the total "construction + move" time to occasionally be slightly less than the construction time alone. This precisely demonstrates that the overhead of move operations is so minimal it's drowned out by measurement noise. +> 💡 **Note on Measurement Noise**: The "pure move" time jitters around zero—one run gives -2.7 ms, the next might be a small positive number. That's expected: high-precision timers pick up tiny differences in scheduling and cache state, and the move's own overhead is far smaller than those differences, so it's drowned in noise. What matters is that it's nowhere near the hundreds of milliseconds a pure copy takes. -What did the move operation do? It simply copied three pointer-sized fields inside `BigData` (pointer to heap buffer, size, capacity) and then nullified the source object's pointers. The entire operation is only a few CPU instructions (in the nanosecond range), completely negligible compared to the 96ms construction time. This is why separating construction is important—if you didn't, the "move time" you'd see would be 95ms of construction plus nanoseconds of moving, compared to 285ms of construction plus copying, yielding only a 3x speedup and severely underestimating the true advantage of moving. +So what does the move actually do? It copies a few pointer-sized fields inside `std::vector` (the heap pointer, size, capacity) and nulls the source pointer—a handful of CPU instructions, nanoseconds, negligible next to 47ms of construction. That's why we isolate construction: without isolating it, the "move time" you'd read is 47ms of construction plus nanoseconds of moving, and set against 505ms of construction plus copy that only gives a "roughly 10x faster" number—a figure diluted by construction that actually hides the fact that the move itself is nearly free. > ⚠️ **Warning**: Don't expect performance improvements on types without move semantics. "Moving" and "copying" are equivalent for `std::array`—because its data is stored directly inside the object, there are no pointers to transfer. Move semantics only provides tangible benefits for types that manage indirect resources (dynamic memory, file handles, etc.). @@ -687,10 +720,22 @@ arr4 (move assigned from arr3): [10, 20, 30] After copy construction, `arr2` owns an independent copy of the data; modifying `arr2` does not affect `arr1`. After move construction, `arr3` takes over all data from `arr1`, leaving `arr1` in an empty state (size=0, capacity=0). Afterwards, `arr4` can regain a valid object via move assignment, proving that the moved-from object is indeed in a "valid but unspecified" state—it can be safely assigned a new value or destructed, but you shouldn't rely on its current value. -## Summary +## Run Online + +Run the two examples and verify the key claims of this article yourself: + + -In this article, we moved move semantics from theory to practice. STL containers (especially `std::vector`'s `push_back`, `emplace_back`, and reallocation) are the most direct beneficiaries of move semantics. The `swap` idiom uses three move operations to achieve O(1) swapping, which is core to sorting and data structure reorganization scenarios. Performance tests show that for types managing large blocks of dynamic memory, the overhead of move operations is nearly zero—copying requires byte-by-byte replication of all data, while moving only transfers pointers. Additionally, we verified an important detail: the `noexcept` modifier has no effect on `std::sort`, but is crucial for `std::vector` reallocation—without `noexcept`, moves during reallocation fall back to copies. + -In custom types, the key is identifying what resources your class manages: exclusive resources (file handles, peripherals, DMA buffers) should prohibit copying and allow moving; shared resources can be managed with smart pointers; simple value types are fine with compiler-generated defaults. Remember to mark move operations `noexcept`; this is not just a promise, but a key condition for `std::vector` to choose move over copy during reallocation. The `DynArray` exercise covers all points of the Rule of Five—if you can complete it independently, it shows you have truly mastered the core mechanisms of move semantics. +That wraps up the chapter on move semantics. From the binding rules of rvalue references, to the implementation of move constructors, through RVO/NRVO and perfect forwarding, and finally to the performance measurements in this article—I hope that from now on, when you see `std::move`, you're not just copy-pasting it, but actually know what it does and why. -This concludes the chapter on move semantics. From the binding rules of rvalue references to the implementation of move constructors, from compiler optimizations like RVO/NRVO to the type deduction chains of perfect forwarding, and finally to performance comparisons and best practices in real-world scenarios—I hope these contents help you move beyond just "copy-pasting" code when you encounter `std::move` in the future, and instead clearly understand what it is doing and why it is done this way. +Following the thread of resource ownership, the next chapter covers smart pointers: RAII turns all the manual `delete`s and ownership transfers from this chapter into something the compiler manages for you. diff --git a/documents/en/vol2-modern-features/ch01-smart-pointers/01-raii-deep-dive.md b/documents/en/vol2-modern-features/ch01-smart-pointers/01-raii-deep-dive.md index d0f2ec795..f98c99396 100644 --- a/documents/en/vol2-modern-features/ch01-smart-pointers/01-raii-deep-dive.md +++ b/documents/en/vol2-modern-features/ch01-smart-pointers/01-raii-deep-dive.md @@ -564,13 +564,7 @@ Exception destructed This verification tells us: RAII's guarantee only applies to **normal control flow** (including exception handling). If the program exits abnormally via `std::abort()`, `std::quick_exit()`, `std::exit()`, or signal handling, destructors will not execute. This is one reason why modern C++ recommends using exceptions over `exit()`—exceptions guarantee stack unwinding and resource cleanup, while `exit()` does not. -## Summary - -RAII is the cornerstone of C++ resource management. Its core mechanism—acquire resources on construction, release on destruction—leverages C++'s stack unwinding guarantee, making resource release no longer dependent on programmer memory, but guaranteed by the language specification. No matter how the control flow leaves the scope (normal return, early `return`, exception propagation), all RAII objects will be correctly destroyed. - -The three levels of exception safety (basic, strong, nothrow) give us a yardstick to measure code quality. As long as all resources are managed through RAII, basic exception safety is almost "free." The design pattern for RAII wrappers is also highly consistent—acquire resource, disable copy, allow move, `noexcept` destructor. Mastering this "four-piece set" allows you to write safe wrappers for any type of resource. - -The next topic we will explore in depth, `std::unique_ptr`, is the most direct embodiment of RAII thought in the realm of smart pointers: zero-overhead exclusive ownership management. Once you understand RAII, understanding `std::unique_ptr` will be very natural. +The next chapter covers `std::unique_ptr`—RAII applied directly to smart pointers, for zero-overhead exclusive ownership. With the RAII groundwork laid, `unique_ptr` reads very naturally. ## Reference Resources diff --git a/documents/en/vol2-modern-features/ch01-smart-pointers/02-unique-ptr.md b/documents/en/vol2-modern-features/ch01-smart-pointers/02-unique-ptr.md index d771869c5..08c8a63c8 100644 --- a/documents/en/vol2-modern-features/ch01-smart-pointers/02-unique-ptr.md +++ b/documents/en/vol2-modern-features/ch01-smart-pointers/02-unique-ptr.md @@ -413,13 +413,7 @@ DmaBuffer buffer(static_cast(HAL_DMA_Malloc(1024)), dma_deleter); The benefit of this approach is that any return path—whether it's a normal return, error return, or exception—will correctly release the DMA buffer. In complex driver code, this automatic management significantly reduces bug rates. -## Summary - -`std::unique_ptr` is the preferred tool for expressing exclusive ownership in modern C++. Its core design—non-copyable, movable, RAII-managed lifetime—precisely maps to the semantic "one object, one owner." Through Empty Base Optimization (EBO), `std::unique_ptr` with a default deleter is identical to a raw pointer in memory and runtime overhead, making it a true zero-overhead abstraction. - -We covered the core usage of `std::unique_ptr` today: exception safety of `std::make_unique`, move semantics and container compatibility, the array version, custom deleters basics, the PIMPL idiom, and the factory function pattern. These are the most frequent scenarios in daily engineering. - -In the next post, we will turn to `std::shared_ptr`—a completely different ownership model: shared ownership. Are you ready? The real complexity is just beginning. +The next chapter turns to `std::shared_ptr`—a completely different ownership model: shared ownership. That's where the real complexity begins. ## Reference Resources diff --git a/documents/en/vol2-modern-features/ch01-smart-pointers/03-shared-ptr.md b/documents/en/vol2-modern-features/ch01-smart-pointers/03-shared-ptr.md index babf5aea8..4cd7bfbb2 100644 --- a/documents/en/vol2-modern-features/ch01-smart-pointers/03-shared-ptr.md +++ b/documents/en/vol2-modern-features/ch01-smart-pointers/03-shared-ptr.md @@ -304,13 +304,7 @@ Third is **atomic operations**. Atomic increment/decrement of the reference coun My advice is: in embedded systems, prioritize `unique_ptr` or use RAII wrapper classes directly. If shared semantics are truly needed, consider intrusive reference counting—placing the reference count inside the object to avoid extra heap allocation. In single-threaded environments, the reference count in an intrusive solution can be a plain `size_t`, requiring no atomic operations and having extremely low overhead. We will discuss this topic in detail in the "Custom Deleters and Intrusive Reference Counting" article. -## Summary - -`shared_ptr` implements shared ownership semantics through reference counting, complementing `unique_ptr`'s exclusive semantics. The key to understanding it lies in the control block mechanism—each `shared_ptr` instance holds two pointers (object and control block), and the atomic reference count in the control block guarantees safety in multi-threaded environments, but also brings non-negligible performance overhead. - -`make_shared` optimizes performance and memory locality through single allocation and should be the preferred way to create `shared_ptr`s. The aliasing constructor and `enable_shared_from_this` are two advanced features that are relatively unknown but very useful. In embedded scenarios, the memory overhead, heap allocation, and atomic operation costs of `shared_ptr` need careful weighing—in most cases, `unique_ptr` or intrusive solutions are better choices. - -In the next post, we will discuss `weak_ptr`—`shared_ptr`'s partner, specifically designed to solve the thorny problem of circular references. +The next chapter discusses `weak_ptr`—`shared_ptr`'s partner, built specifically to solve circular references. ## References diff --git a/documents/en/vol2-modern-features/ch01-smart-pointers/04-weak-ptr.md b/documents/en/vol2-modern-features/ch01-smart-pointers/04-weak-ptr.md index eea0ad758..8678545c7 100644 --- a/documents/en/vol2-modern-features/ch01-smart-pointers/04-weak-ptr.md +++ b/documents/en/vol2-modern-features/ch01-smart-pointers/04-weak-ptr.md @@ -298,7 +298,7 @@ The design of this cache is very natural: the cache itself does not hold a stron Although `weak_ptr` is a powerful tool for solving circular references, overusing it can actually increase code complexity and the probability of errors. I have seen some codebases replace almost all pointers with `weak_ptr` for fear of circular references—this is overcorrecting. -First is the performance issue. Every time you access an object via `weak_ptr`, you need to call `lock()`, which involves atomic operations (checking and incrementing the reference count). Frequent `lock()` calls in hot paths can bring measurable performance overhead. According to benchmarks on [Stack Overflow](https://stackoverflow.com/questions/39516416/using-weak-ptr-to-implement-the-observer-pattern), accessing via `weak_ptr` is about 10-15 times slower than directly accessing `shared_ptr` (under -O2 optimization, 10 million iterations: direct access ~5ms, `lock()` access ~62ms). Although this absolute time difference might not be significant in practical applications, if called frequently in performance-sensitive code paths, the overhead accumulates. +First is the performance issue. Every time you access an object via `weak_ptr`, you need to call `lock()`, which involves atomic operations (checking and incrementing the reference count). Frequent `lock()` calls in hot paths can bring measurable performance overhead. In a quick benchmark (GCC 16.1.1 -O2, 10 million iterations), accessing via `weak_ptr::lock()` is about 30 times slower than directly accessing `shared_ptr` (direct access ~2ms, `lock()` access ~68ms) — `lock()` has to do an atomic operation to grab the reference count. Although this absolute time difference might not be significant in practical applications, if called frequently in performance-sensitive code paths, the overhead accumulates. Second is semantic ambiguity. If your code is full of `weak_ptr` everywhere, it is hard for readers to determine which objects have true ownership relationships. Ownership relationships should be clarified as much as possible during the design phase, rather than using `weak_ptr` to avoid ownership design. @@ -306,15 +306,7 @@ My suggestion is: in most cases, use `unique_ptr` to express exclusive ownership Another common error is using `weak_ptr` to "observe" objects on the stack or objects managed by `unique_ptr`—this is impossible because `weak_ptr` can only be used in conjunction with `shared_ptr`. If you want to observe the lifecycle of a non-shared object, you need other mechanisms (such as callbacks, manual implementation of the Observer pattern, or changing the object to be managed by `shared_ptr`). -## Summary - -`weak_ptr` is `shared_ptr`'s partner, solving the `shared_ptr` circular reference problem through a "weak reference" mechanism that does not participate in strong reference counting. Its three core APIs—`lock()`, `expired()`, and `use_count()`—provide safe "check but don't own" semantics. - -In practical applications, `weak_ptr` is mainly used in three scenarios: breaking circular references in data structures (doubly linked lists, trees, graphs), implementing the loosely coupled notification mechanism of the Observer pattern, and building automatically reclaiming cache systems. Mastering these three patterns means mastering the core usage of `weak_ptr`. - -But remember, `weak_ptr` is not a panacea. Overusing it makes code harder to understand and maintain. Good design should prioritize clarifying ownership relationships, introducing `weak_ptr` only when necessary. - -In the next post, we will discuss custom deleters and intrusive reference counting—delving into how to make smart pointers manage resources that "weren't created with new." +The next chapter covers custom deleters and intrusive reference counting—how to make smart pointers manage resources that "weren't created with new." ## Reference Resources diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/01-why-optional-reference-took-20-years.md b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/01-why-optional-reference-took-20-years.md new file mode 100644 index 000000000..7fd7a74ce --- /dev/null +++ b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/01-why-optional-reference-took-20-years.md @@ -0,0 +1,144 @@ +--- +title: 为什么 optional 引用折腾了二十年 +description: CppCon 2025 笔记 —— Steve Downey 讲 std::optional(P2988) 为何从 2005 拖到 2025 年 Sofia 会议才进 C++26:引用的三重身份、assign-through 与 rebind 之争、最终落地为指针 +chapter: 6 +order: 1 +conference: cppcon +conference_year: 2025 +talk_title: 'The Evolution of std::optional: From Boost to C++26' +speaker: Steve Downey +cpp_standard: [17, 23, 26] +difficulty: intermediate +platform: host +reading_time_minutes: 10 +tags: + - cpp-modern + - host + - intermediate + - optional +prerequisites: + - "optional:把『可能没有』做成类型" +related: + - "optional:把『可能没有』做成类型" + - "std::optional 的值语义底子" +--- + +# 为什么 optional 引用折腾了二十年 + +:::tip +这一系列笔记基于 CppCon 2025 Steve Downey 的演讲 *The Evolution of std::optional: From Boost to C++26* 做的二次发散。讲者是 Bloomberg 的 Steve Downey,也是把 `std::optional` 推进 C++26 的那篇提案(P2988)的主要作者。原讲视频可以在 CppCon 官方频道检索,国内观众找搬运的 Bilibili 版本看。 +::: + +`std::optional`,一个看起来"不就是能装空值的引用嘛"的东西,从 2005 年第一次被提出,到 2025 年 6 月的 Sofia 会议才终于投票通过进入 C++26。二十年。C++11 到 C++23 都够走完一整轮了。 + +笔者 2022 年刚碰 C++ 的时候,天真地以为标准库里缺什么,就是委员会懒得加。后来真的挖进去才明白,有些东西没加,是因为它很难加对。这一篇咱们顺着 Steve Downey 的讲法,把"一个能装空值的引用为什么这么难"这件事拆开。 + +## 先把环境交代清楚 + +后面所有能跑的代码,笔者都用同一套环境验证过:Arch Linux WSL,GCC 16.1.1。 + +```bash +$ g++ --version +g++ (GCC) 16.1.1 20260625 +``` + +这里有个前提您得先记住:`optional` 是 C++26 才进标准的特性,提案号 P2988。GCC 16.1.1 在 `-std=c++26` 下已经实现了它,所以咱们能真的把代码跑起来,不是纸上谈兵。换成 `-std=c++23` 或更早,下面这行直接编不过: + +```cpp +#include +int main() { + int x = 42; + std::optional opt = x; // P2988, C++26 才支持 + *opt = 100; +} +``` + +笔者实跑了一下,`-std=c++23` 报错,报的是 `optional` 内部 `union` 存不下引用类型的那串模板实例化错误;切到 `-std=c++26`,编过,输出 `x=100`。这条分界线本身就是"C++26 才支持引用 optional"的活证据,后面咱们会反复用到。 + +## 引用在 C++ 里其实干了三件事 + +很多人对引用的理解停在"别名,给变量起个外号"。Steve Downey 把引用的职责拆成了三件,笔者觉得这个拆法清楚得很。 + +第一件是调用约定。写 `void foo(const std::string& s)`,这里的引用在说"别拷贝了,直接操作原来的对象"。这对运算符重载尤其要紧,总不能让 `operator+` 每次都把操作数整个拷一遍。 + +第二件是给一个复杂的表达式起局部别名。`obj.get_container()[index].get_sub().value()` 这种长链,写 `auto& x = obj.get_container()...`,编译器就记下"你给这玩意儿起了个名字"。它不占空间,纯粹是个别名关系。这两件事里,引用"不能重新绑定"是个好特性,你既然给它起了名字,它就一直指向那个东西。 + +第三件就出问题了。你可以把引用塞进 `struct` 里。 + +这一塞,性质就变了。引用作为成员开始占空间,通常是一个指针的大小,但它又不能重新绑定,于是编译器根本不知道"拷贝这个结构体"该怎么定义。是拷贝引用本身?做不到,引用不能重新绑定。是拷贝它引用的那个对象?那又不是 `struct` 该干的事。 + +咱们跑个最小的例子,把这件事看清楚: + +```cpp +// ref_in_struct.cpp +#include +#include + +struct HoldsRef { int& ref; }; // 引用成员 +struct HoldsValue { int val; }; // 普通值成员 + +int main() { + std::printf("HoldsValue default_ctor=%d copy_assign=%d\n", + std::is_default_constructible_v, + std::is_copy_assignable_v); + std::printf("HoldsRef default_ctor=%d copy_assign=%d\n", + std::is_default_constructible_v, + std::is_copy_assignable_v); +} +``` + +编译运行,`-std=c++20` 就够: + +```bash +$ g++ -std=c++20 ref_in_struct.cpp -o ref_in_struct && ./ref_in_struct +HoldsValue default_ctor=1 copy_assign=1 +HoldsRef default_ctor=0 copy_assign=0 +``` + +全零。就因为多了一个引用成员,这个结构体的默认构造和拷贝赋值全没了。您想想,如果 `optional` 内部真的放一个引用成员,那它连默认构造都做不到,更别提赋值语义的混乱。所以"内部用指针"这个决定,不是偷懒,是不得不这么做。 + +## assign-through 还是 rebind + +好,假设咱们真要造一个 `std::optional`,它内部有个引用成员。现在你给它赋值,到底该发生什么? + +这里有两个选项,Steve Downey 把它们叫 **assign-through(穿透赋值)** 和 **rebind(重新绑定)**。 + +assign-through 的意思是赋值"穿透"optional,直接改被引用的对象。你的 `optional` 当前引用着变量 `x`,你给它赋一个 `y`,结果 `x` 的值变成 `y`,optional 本身还引用着 `x`。 + +rebind 则相反,赋值之后 optional 不再引用 `x`,转而引用 `y`。 + +如果您把 optional 当成"一个内部持有引用的 `struct`",那按 `struct` 的规矩,assign-through 才说得通,毕竟你不能重新绑定一个引用成员。确实有一派人这么主张,动机完全站得住脚。 + +:::warning +坑在这里:一旦 optional 当前是空的(disengaged),assign-through 就讲不通了。它没有底层对象可以"穿透",那空状态下的赋值难道偷偷切换成 rebind?这样一来,同一个赋值运算符的行为就依赖于 optional 当前的运行时状态。 +::: + +这就是整个争论的死结。Steve Downey 转述了另一位委员 JeanHeyd 的关键观察:**如果赋值行为依赖于 optional 当前的状态,这个类型就没办法被静态推导**。 + +什么叫没办法推导?你看到一行 `opt = value`,光看这行代码本身,你根本不知道它干了什么。你必须知道 `opt` 在运行时有没有值,才能确定这行是穿透还是重绑定。而 C++ 的整个类型系统、概念约束、模板元编程,都建立在"知道类型就知道行为"这个前提上。一旦行为依赖运行时状态,所有静态推理一起失效。 + +之前所有尝试走 assign-through 这条路的实现,最后都踩进了这个坑里。腾讯云上有一篇 Sofia 会议的快报写得很直白:C++17 周期标准化 `std::optional` 的时候,就为"要不要支持引用 optional"爆发过激烈争吵,争吵点正是"能不能用 `T*` 代替"和"`operator=` 该 assign-through 还是 rebind",吵到有人负气去了 C 标准委员会(WG14)。这个特性就这么被搁置了下来。 + +## 最终:别绕了,它就是个指针 + +吵了二十年的结论其实很简单。`optional` 内部,就存一个指针。 + +不是引用,是指针。然后在这个指针上施加一堆约束,让它表现得像一个"可选的引用"。这样一来赋值就清晰了:不管 optional 当前有没有值,赋值都是重新绑定这个指针。行为不依赖状态,推导问题彻底消失。 + +笔者第一次听到这个结论的反应是"就这?折腾二十年得出一个'用指针'?"。但冷静下来想,这个"用指针"不是随便用的。它背后有一整套语义要定义清楚,还要跟值版本的 `optional` 保持某种一致性,这些细节才是真正花时间的地方,也是后面几篇要讲的东西。 + +## P2988 这二十年 + +把时间线捋一下,您就明白这二十年花在哪了。 + +2005 年,optional 第一次被提出,最初的草案其实是带引用语义的。但到了 2017 年 `std::optional` 正式进 C++17 的时候,只进了值版本,引用版本因为上面那场 assign-through/rebind 的争吵被拿掉了。中间有个提案在 C++20 周期走得相当远,最后还是没被采纳。 + +转机出在 JeanHeyd 身上。他因为一直没能把 optional 引用推进标准,干脆做了次彻底的考古式梳理,把历史上到底讨论过什么、各方理由是什么都翻了出来。这份梳理直接催生了 Steve Downey 的 P2988,主张"别再纠结了,直接做"。当然,真做起来细节比想象的多得多。最终在 2025 年 6 月的 Sofia 会议上,P2988 投票通过,进入 C++26。 + +所以当您在 GCC 16.1.1 上用 `-std=c++26` 写出 `std::optional` 并且它真的编过的时候,背后是二十年的来回拉锯。 + +## 接下来 + +这一篇咱们搞清楚了 optional 引用为什么难,以及它最终落地为"一个带约束的指针"。但"内部用指针"只是起点,围绕这个指针还有一堆问题:它跟裸指针有什么区别?它跟 `optional` 有什么区别?赋值、`operator*`、`value()` 这些操作到底返回什么? + +在那之前,笔者觉得有必要先把普通 `optional` 的底子摸透说透。因为一旦 `T` 变成引用,"拥有所有权""值语义"这些前提全都不成立了,那会是一个完全不同的故事。[下一篇](./02-value-semantics-of-optional.md)咱们就从值版本的 `optional` 讲起。 diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/02-value-semantics-of-optional.md b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/02-value-semantics-of-optional.md new file mode 100644 index 000000000..632ccdc70 --- /dev/null +++ b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/02-value-semantics-of-optional.md @@ -0,0 +1,159 @@ +--- +title: std::optional 的值语义底子 +description: CppCon 2025 笔记 —— 在啃 optional 之前先把值版本说透:拥有所有权、值语义、T 加一个状态、C++26 当 range 用、以及默认参数背后的重载集噩梦 +chapter: 6 +order: 2 +conference: cppcon +conference_year: 2025 +talk_title: 'The Evolution of std::optional: From Boost to C++26' +speaker: Steve Downey +cpp_standard: [17, 23, 26] +difficulty: intermediate +platform: host +reading_time_minutes: 11 +tags: + - cpp-modern + - host + - intermediate + - optional +prerequisites: + - "为什么 optional 引用折腾了二十年" +related: + - "optional:把『可能没有』做成类型" + - "为什么 optional 引用折腾了二十年" +--- + +# std::optional 的值语义底子 + +[上一篇](./01-why-optional-reference-took-20-years.md)咱们讲完 `optional` 为什么难、为什么最终落地为指针。但在啃这个硬骨头之前,笔者觉得有必要先把普通的 `optional` 说透。因为一旦 `T` 换成引用,您会发现 `optional` 的几乎每一条前提都被推翻了。得先知道"正常情况"长什么样,才能体会"引用情况"怪在哪。 + +## 它到底是个什么类型 + +`optional` 首先是个拥有所有权的类型,内部真的存放了一个 `T` 对象,而且是值语义的。这意味着您可以拷贝它、移动它,底层 `T` 允许的操作它都允许。它没有什么代理行为,就是个老老实实装着东西(或者没装东西)的值类型。 + +它和指针的 nullable 行为有几分像,都表达"可能没有值"。指针靠空指针来做到这一点,地址零在正常情况下不是有效地址,等于指针类型凭空多出一个带外值来表示"这里什么都没有"。`optional` 干的是同一件事,在代数上就是 `T` 加上一个额外状态。您可以把它理解成 `variant`,`monostate` 扮演那个"什么都没有"的状态。实际实现不会真用 `variant`(那样太重),但记住这个等价关系有用,因为后来关于 optional 参数推导、expected 设计的大量讨论,本质上都是在替咱们真正想用代数数据类型做的事情铺路。 + +## 实测:值语义到底"值"在哪 + +口说无凭,跑一下。写一个会打印自己每次构造、拷贝、析构的 `Tracer`,塞进 optional,看它什么时候真构造、什么时候析构、拷贝时是不是各自独立。 + +```cpp +// tracer.cpp +#include +#include + +struct Tracer { + Tracer() { std::cout << "Tracer()\n"; } + ~Tracer() { std::cout << "~Tracer()\n"; } + Tracer(const Tracer&) { std::cout << "copy ctor\n"; } + Tracer(Tracer&&) noexcept { std::cout << "move ctor\n"; } +}; + +int main() { + std::optional a; + std::cout << "a has_value=" << a.has_value() << "\n"; + + a.emplace(); // 这里才真正构造 + std::optional b = a; // 拷贝构造 + std::cout << "b has_value=" << b.has_value() << "\n"; + + a.reset(); // 析构 a 里的对象 + std::cout << "after reset a has_value=" << a.has_value() + << " b has_value=" << b.has_value() << "\n"; +} +``` + +`-std=c++17` 就够,跑出来: + +```bash +$ g++ -std=c++17 tracer.cpp -o tracer && ./tracer +a has_value=0 +Tracer() +copy ctor +b has_value=1 +~Tracer() +after reset a has_value=0 b has_value=1 +~Tracer() +``` + +读一下这个输出。声明 `optional a` 的时候,`Tracer()` 没被调用,`has_value` 是 0。直到 `emplace()` 才真正构造出对象。拷贝 `b = a` 走的是 `Tracer` 的拷贝构造。`a.reset()` 析构了 `a` 里的对象,但 `b` 完全不受影响,它有自己那一份。这就是值语义的所有权行为,非常干净。 + +## 一个让笔者深有体会的用例:读配置 + +Steve Downey 提到读配置文件的场景,笔者深有体会。以前写配置读取,某个配置项可能不存在,要么用 `map::find` 查迭代器再判 end,要么返回一个指针(nullptr 表示不存在),要么搞个 `bool` 加输出参数的组合。这些写法的问题在于,"这个值可能不存在"这个信息太容易丢。你可能五层函数调用之后,忘了检查那个指针是不是 null。 + +换成 optional 之后,类型系统本身就在盯着您:这个值可能没有,您必须处理。笔者之前写的一段业务代码,配置项嵌套了三四层,用指针的方式每次都要回头翻"我到底检查了没有",改成 optional 之后,编译器直接逼着在用之前做判断。那种安全感是完全不一样的。这部分 vol3 有一篇专门的 [optional 深入](../../../../vol3-standard-library/error-utils/61-optional.md),讲得更细,您可以对照着看。 + +## C++26:optional 当 range 用 + +C++26 给 optional 加了 `begin` 和 `end`,让它能当 range 用。提案是 P3168,特性测试宏 `__cpp_lib_optional_range_support` 在 GCC 16.1.1 上是 `202406L`。笔者刚看到这个提案的时候心里想的是"就为遍历一个值?有必要吗"。后来仔细想想自己的代码,改观了。 + +您想想这种模式: + +```cpp +std::optional maybe_user = find_user(id); +if (maybe_user) { + // 接下来几十行都在操作 maybe_user.value() + // 中间可能又嵌套了判断 + // 你得一直记住"我在 if 里面,它是安全的" +} +``` + +当 if 的 body 很长,您在中间某一行读到 `maybe_user.value()`,得往上翻才能确认外面有 if 保护着。C++26 改成这样写: + +```cpp +for (auto& user : maybe_user) { + // 进到这里,user 就是 User&,不是 optional + // 几十行里随便用 user,它就是一个确定的值 +} +``` + +原理很简单。optional 是 engaged 的,`begin()` 返回指向内部对象的指针,`end()` 返回 `begin() + 1`,循环执行一次;disengaged 的,`begin()` 等于 `end()`,循环零次。它不是 container(container 有一大堆要求),只是恰好提供了 `begin` 和 `end` 的 range。跑一下验证: + +```cpp +// opt_as_range.cpp +#include +#include + +int main() { + std::optional engaged = 42; + std::optional empty; + + int count = 0, sum = 0; + for (auto&& x : engaged) { count++; sum += x; } + for (auto&& x : empty) { count++; sum += x; } + + std::cout << "count=" << count << " sum=" << sum << "\n"; +} +``` + +```bash +$ g++ -std=c++26 opt_as_range.cpp -o opt_as_range && ./opt_as_range +count=1 sum=42 +``` + +engaged 的 optional 迭代一次拿到 42,empty 的迭代零次。不是什么惊天动地的特性,但在那种大段业务逻辑里,能让你在循环体内把 optional 完全忘掉、只跟裸类型打交道,这个心智负担的降低是实打实的。 + +:::warning +遍历 optional 时循环变量写 `auto&&`,别写 `auto`。`auto` 会丢掉引用性,下一篇您会看到 `optional` 这种特化,那时候 `auto x` 拿到的是拷贝而不是引用,语义就错了。`auto&&` 是万能引用,能正确保持原始值类别。这个点其实是 Steve Downey 自己幻灯片上的 bug,Q&A 环节被人指出来他才承认。 +::: + +## 默认 optional 参数:好用,实现却是噩梦 + +optional 还有个很常见的用法,函数的默认参数: + +```cpp +void process(std::optional timeout = std::nullopt); + +process(); // timeout 是 nullopt +process(42); // timeout 是 optional(42) +process(std::optional{}); // 也可以显式传 +``` + +用起来非常自然,`int` 会自动提升成 `optional`。但您可能没想过,为了支持这种隐式转换,optional 的构造函数设计变得极其复杂。它得同时处理从 `T` 构造、从 `nullopt` 构造、从另一个 `optional` 构造、拷贝、移动,这些构造函数和转换操作符组合在一起,形成一个庞大的重载集。 + +笔者以前一直觉得"重载决议不就是找最匹配的嘛"。但实际上当重载集大到一定程度,结果经常出人意料。人脑思考重载决议倾向于走决策树,"这个类型走这条分支,那个类型走那条分支"。编译器不这么干,它把所有候选函数摊在一个平面的集合里,按隐式转换等级、模板特化排序这些规则打分,选最优。候选一多,打分结果就可能跟直觉对不上。optional 实现里那一大坨 SFINAE 和 concepts 约束,本质上就是在驯服这个庞大的重载集,确保"传 int 走 int 的路径,传 nullopt 走 nullopt 的路径,不会出现意外的歧义"。每一行 SFINAE 背后都是有血有泪的重载决议踩坑史。 + +## 接下来 + +值版本的底子摸清了:拥有所有权、值语义、`T` 加一个额外状态、C++26 能当 range、构造函数为了隐式转换背着沉重的重载集。但一旦 `T` 换成引用,这些前提全部推翻。`optional` 不拥有任何东西,赋值不再是值拷贝,构造函数链要重新设计。[下一篇](./03-optional-reference-and-assignment.md)咱们正式进 `optional` 的核心:它到底是什么,它的赋值为什么一定是重新绑定,以及 `make_optional` 和 CTAD 在引用上挖的坑。 diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/03-optional-reference-and-assignment.md b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/03-optional-reference-and-assignment.md new file mode 100644 index 000000000..9e8176629 --- /dev/null +++ b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/03-optional-reference-and-assignment.md @@ -0,0 +1,224 @@ +--- +title: optional 引用是什么,以及赋值为什么一定是重绑定 +description: CppCon 2025 笔记 —— optional 的非拥有定位、map 查找痛点、赋值为何永远是重新绑定、vector 幽灵,以及 make_optional 与 CTAD 在引用上的真实行为 +chapter: 6 +order: 3 +conference: cppcon +conference_year: 2025 +talk_title: 'The Evolution of std::optional: From Boost to C++26' +speaker: Steve Downey +cpp_standard: [17, 23, 26] +difficulty: intermediate +platform: host +reading_time_minutes: 16 +tags: + - cpp-modern + - host + - intermediate + - optional +prerequisites: + - "std::optional 的值语义底子" +related: + - "std::optional 的值语义底子" + - "optional 引用的浅层陷阱" +--- + +# optional 引用是什么,以及赋值为什么一定是重绑定 + +[上一篇](./02-value-semantics-of-optional.md)把值版本的底子摸透了。这一篇正式进 `optional` 的核心。一旦 `T` 变成引用,"拥有所有权""值语义"这些前提全部不成立,咱们得换一套思路来理解它。 + +## 它不是"装着引用的盒子" + +很多人,包括以前的笔者,以为 optional 引用大概就是个不能为 null 的指针加个 `has_value` 判断。这个理解太浅。 + +`optional` 的核心定位是非拥有类型。注意"非拥有"这三个字,optional 内部不持有任何实际对象,它只是指向别处已经存在的某个东西。这听起来像指针,但关键在于它同时兼具引用语义和值语义。指针本身是很好的值,您可以拷贝它、比较它,它有自己的标识(地址),同时指针又能被解引用来操作指向的东西,这是引用语义的一面。`optional` 要的就是这种双重性格。 + +它还天然带一个空状态,而且这个空状态不需要额外空间来存标志位。底层就是用空指针实现的,零开销。笔者以前一直以为 optional 存引用要多花一个 bool 的空间,实际根本不需要,空指针本身就是最好的"无值"表示。 + +## map 查找:最能说明问题的痛点 + +理论说再多不如看实际痛点。您写 C++ 肯定遇过这个场景:从 map 里查找一个东西,找到了要修改它。 + +```cpp +std::map enemy_hp{{"goblin", 30}, {"dragon", 500}}; + +auto it = enemy_hp.find("dragon"); +if (it != enemy_hp.end()) { + it->second -= 50; // 想改 value,得写 .second +} +``` + +这种代码笔者写了几百遍,每次都觉得 `.second` 是多余的噪音。map 迭代器解引用出来是 `pair&`,你只想要 value,却被迫和 key 绑在一起处理。 + +等 `optional` 进了标准,查找函数可以直接返回 `optional`,找到就改,没找到就是空,没有 `.second`。现在标准库还没给 map 加这个接口(那是 P3091 的事,稍后讲),但咱们能自己用 `reference_wrapper` 包一层模拟,先体会一下效果: + +```cpp +// refwrap_lookup.cpp +#include +#include +#include +#include +#include + +template +auto try_get(Map& m, const typename Map::key_type& k) + -> std::optional> +{ + auto it = m.find(k); + if (it != m.end()) return std::ref(it->second); + return std::nullopt; +} + +int main() { + std::unordered_map scores{{"Alice", 95}, {"Bob", 87}}; + + if (auto r = try_get(scores, "Alice")) { + r->get() += 5; // 拿到引用,改的是 map 里的值 + std::cout << "Alice=" << scores["Alice"] << "\n"; + } + if (auto r = try_get(scores, "Charlie")) { + std::cout << "Charlie=" << r->get() << "\n"; + } else { + std::cout << "Charlie not found, no exception\n"; + } +} +``` + +```bash +$ g++ -std=c++17 refwrap_lookup.cpp -o refwrap_lookup && ./refwrap_lookup +Alice=100 +Charlie not found, no exception +``` + +Alice 的分数从 95 改成了 100,Charlie 没找到但没抛异常。语义上这就是"一个可能不存在的引用",只是 `reference_wrapper` 用起来别扭,拿到手还得 `.get()` 一次。等 C++26 的 `optional`,直接写成返回 `optional`,干净多了。这个给关联容器加返回 optional 引用查找接口的提案是 P3091(Pablo Halpern 提出),它没赶上 C++26 的进度,推到了 C++29。原因听起来有点好笑:同时推进 C++26 和 C++29 两套标准,会让处理标准文档的人太困惑。C++ 标准本身是个三千多页的 LaTeX 文档,技术上能用 git 分支管,但大家真不想这么干。 + +## 当函数参数:一份隐式契约 + +`optional` 做函数参数也有意思。笔者以前觉得传指针和传引用在表达意图上差不多,仔细想想指针的语义其实太模糊。 + +```cpp +void process(Logger* logger); // 这个函数会不会 delete 它?会不会存下来以后用?调用者不知道 +void process(std::optional logger); // 意图清晰得多 +``` + +把 logger 描述成 `optional`,等于告诉接收它的函数:我不会拥有它(不会 delete),也不会在函数返回后还拿着它的引用,你只要保证调用期间它活着就行。这虽然不是形式化的契约,但在 C++ 能做到的范围内,已经算清晰的意图表达了。optional 参数还天然支持"不传",不传 logger,函数里 `if (logger)` 判断一下跳过日志逻辑就行。Steve Downey 说这算个极简的依赖注入框架,笔者觉得这个说法挺准。 + +## 赋值:重新绑定,不是值拷贝 + +到这里都是好消息。接下来是 `optional` 最容易让人栽跟头的地方:赋值到底干了什么。先把场景摆出来: + +```cpp +struct Cat { std::string name; Cat(std::string n) : name(std::move(n)) {} }; + +Cat finn{"Finn"}; +Cat loki{"Loki"}; + +std::optional a; // 空 +std::optional b = loki; // 已经绑定 loki + +a = finn; // ? +b = finn; // ? b 已经绑着 loki,这是改 loki 的名字,还是让 b 改绑 finn? +``` + +您可能觉得赋值嘛有什么好问。但 `b` 已经绑定到 loki 了,现在把 finn 赋给它,到底是把 loki 的名字改成 "Finn",还是让 b 改去引用 finn?按 `optional` 的赋值是值拷贝来类推,应该是前者。但这个理解是错的。跑一下: + +```cpp +// rebind.cpp +#include +#include +#include +#include + +struct Cat { std::string name; Cat(std::string n) : name(std::move(n)) {} }; + +int main() { + Cat finn{"Finn"}, loki{"Loki"}; + std::optional a; // 空 + std::optional b = loki; // 绑定 loki + + a = finn; // 重绑定 a -> finn + b = finn; // 重绑定 b -> finn(不是改 loki) + + std::cout << "a has_value=" << a.has_value() << " a->name=" << a->name << "\n"; + std::cout << "b->name=" << b->name << " loki.name=" << loki.name << "\n"; + + int p = 1, q = 2; + std::optional oa = p, ob = q; + std::swap(oa, ob); + std::cout << "swap 后 *oa=" << *oa << " *ob=" << *ob + << " (p=" << p << " q=" << q << " 不变)\n"; +} +``` + +```bash +$ g++ -std=c++26 rebind.cpp -o rebind && ./rebind +a has_value=1 a->name=Finn +b->name=Finn loki.name=Loki +swap 后 *oa=2 *ob=1 (p=1 q=2 不变) +``` + +读输出。给空的 a 赋 finn,a 重绑定到 finn;给已经绑着 loki 的 b 赋 finn,b 也重绑定到 finn,而 loki 的名字还是 Loki,没被动。赋值改的是 optional 引用谁,不是被引用对象的内容。swap 同理,交换的是两个指针,oa 和 ob 互换了绑定目标,p 和 q 自己的值没变。 + +这跟指针的赋值是一回事。你给一个指针赋值,改的是指针的指向,不是指向对象的内容。`optional` 内部就是个指针,所以赋值就是重绑定。 + +### 为什么不"有值就拷、没值就绑" + +笔者一开始想过一个看起来更聪明的方案:optional 已经 engaged 就做值拷贝,disengaged 就做绑定。仔细一想这是噩梦。因为这样一来,同一个赋值运算符的行为就取决于 optional 当前的运行时状态。您读代码看到 `opt = value`,根本不知道它干了什么,得往回追溯 opt 此刻有没有值。这完全破坏了可推导性。 + +[上一篇](./01-why-optional-reference-took-20-years.md)讲过 JeanHeyd 的那个关键观察:赋值行为一旦依赖状态,类型就没法静态推导。所有走过这条路的实现最后都踩进了坑里。始终重绑定这条规则,不管 optional 之前什么状态,赋值之后就绑定到您给的那个东西上。简单、一致、可预测。 + +### vector\ 的幽灵 + +但这里有个严肃的反对意见,笔者也纠结过。`optional` 赋值是值拷贝,`optional` 赋值是重绑定。同一个模板,不同特化,行为不一致。这不是另一个 `vector` 吗? + +`vector` 是 C++ 标准库里最臭名昭著的设计之一。它做了特化,使得 `vector` 和 `vector<其他任何类型>` 行为神奇地不同,不存真正的 bool 而做位压缩,导致你没法取单个元素的地址,迭代器类型也不一样。而且进了标准就删不掉,只能一直背着这个历史包袱。 + +但后来笔者想通了。C++ 的引用从第一天起就不是泛型的。引用不是对象,它没有自己的地址,不能有"引用的引用",不能有"引用的数组"。引用从一开始就是值语义世界里的特殊存在。你硬要把引用塞进一个为值语义设计的模板里,还指望它行为完全一致,本身就不现实。 + +咱们不是在"往 optional 里放一个 T&",咱们想要的是"引用语义的 optional",只是 C++ 的引用语义必须用不同的方式来实现。这不是在制造不一致,是 C++ 引用本身的特性决定的。 + +## make_optional 和 CTAD 在引用上挖的坑 + +赋值语义搞清楚了,还有两个特别容易踩的坑:`make_optional` 和 CTAD。先说结论:`std::make_optional` 永远返回 `optional`,即使您传进去的是一个引用。 + +```cpp +// ctad_truth.cpp +#include +#include +#include + +int main() { + int x = 42; + auto o1 = std::make_optional(x); // 一定 optional + std::optional o2 = x; // 显式写才是 optional + std::optional o3{x}; // CTAD 到底推成什么? + + x = 99; + std::cout << "make_optional 随 x 变? " << (*o1 == 99) << " (0=拷贝)\n"; + std::cout << "optional 随 x 变? " << (*o2 == 99) << " (1=引用)\n"; + + if constexpr (std::is_same_v>) + std::cout << "CTAD o3 -> optional\n"; + else if constexpr (std::is_same_v>) + std::cout << "CTAD o3 -> optional(退化为值,不是引用)\n"; +} +``` + +```bash +$ g++ -std=c++26 ctad_truth.cpp -o ctad_truth && ./ctad_truth +make_optional 随 x 变? 0 (0=拷贝) +optional 随 x 变? 1 (1=引用) +CTAD o3 -> optional(退化为值,不是引用) +``` + +三个事实一次跑明白。`make_optional(x)` 得到 `optional`,x 改成 99 之后里面还是 42 那份拷贝。显式写 `std::optional` 才是引用语义,跟着 x 变。最值得注意的是第三行:CTAD `std::optional o3{x}` 推导出来的是 `optional`,不是 `optional`。 + +:::warning +网上一些早期材料说 `std::optional o{x}` 的 CTAD 能推导出引用版本,那是旧提案的设想。笔者实测下来,最终落地的 P2988 没这么干,CTAD 仍然退化为值。想要引用语义,老老实实写全 `std::optional o{x}`。别拿 CTAD 碰运气,碰出来的是拷贝。 +::: + +`make_optional` 退化成值这件事不能改。太多现有代码依赖 `make_optional` 总是返回 `optional` 这个事实,改了就是破坏性变更。从直觉上说,`make_optional` 就像在"制造一个 optional 值",您不会期望它给您一个引用语义的东西。这一点它跟函数返回值的行为一致,您从函数里返回一个 `T&`,用 `auto` 接,得到的是 `T` 不是 `T&`。 + +## 接下来 + +`optional` 的核心咱们摸清了一半:非拥有、赋值重绑定、make_optional 和 CTAD 不给引用。但围绕这个"内部指针"还有一堆角落:const 放在哪儿语义完全不同,`value_or` 永远返回值,从临时对象构造会被直接 delete 掉。[下一篇](./04-shallow-traps-const-value-or-dangling.md)咱们把这些浅层陷阱一个个扒开。 diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/04-shallow-traps-const-value-or-dangling.md b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/04-shallow-traps-const-value-or-dangling.md new file mode 100644 index 000000000..14e68d2ab --- /dev/null +++ b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/04-shallow-traps-const-value-or-dangling.md @@ -0,0 +1,178 @@ +--- +title: optional 引用的浅层陷阱:const、value_or 与悬垂 +description: CppCon 2025 笔记 —— optional 的浅层 const 三种位置、条件 explicit、value_or 为何总返回值、悬垂防御为何用 delete 而非 requires +chapter: 6 +order: 4 +conference: cppcon +conference_year: 2025 +talk_title: 'The Evolution of std::optional: From Boost to C++26' +speaker: Steve Downey +cpp_standard: [17, 23, 26] +difficulty: intermediate +platform: host +reading_time_minutes: 11 +tags: + - cpp-modern + - host + - intermediate + - optional +prerequisites: + - "optional 引用是什么,以及赋值为什么一定是重绑定" +related: + - "optional 引用是什么,以及赋值为什么一定是重绑定" + - "optional 引用里藏着的移动语义陷阱" +--- + +# optional 引用的浅层陷阱:const、value_or 与悬垂 + +[上一篇](./03-optional-reference-and-assignment.md)咱们把 `optional` 的核心语义理顺了。这一篇换个角度,扒它在使用层面那些容易踩的角落:const 放哪儿、value_or 返回什么、从临时对象构造会怎样。每个坑单独看都不大,合在一起就是一份完整的避坑地图。 + +## const 是浅层的 + +这条笔者以前理解偏差最大。笔者以前以为 `const optional` 里包着的引用,解引用出来也该是 const 的,毕竟 C++ 里 const 不是会"传播"吗。完全不是这么回事,跑一下您就信了: + +```cpp +// shallow_const.cpp +#include +#include + +int main() { + int x = 42; + + // const 在 optional 上 + const std::optional opt = x; + *opt = 100; // 编译通过!能改 x + std::cout << "const optional: x=" << x << "\n"; + + // const 在 T 上 + int y = 7; + std::optional opt2 = y; + // *opt2 = 9; // 这行会编译错误 + std::cout << "optional: y=" << y << "(不能通过 *opt2 改)\n"; +} +``` + +```bash +$ g++ -std=c++26 shallow_const.cpp -o shallow_const && ./shallow_const +const optional: x=100 +optional: y=7(不能通过 *opt2 改) +``` + +`const optional` 里的 const 是浅层的,只约束 optional 对象本身(您不能 reset 它、不能重新赋值让它指向别的东西),完全不约束解引用出来的东西。`*opt = 100` 编译通过,x 真的被改成了 100。 + +回头看原理其实简单。`optional` 底层就一个指针,`const optional` 相当于 `T* const`,指针本身不能改,但通过指针去改它指向的东西天经地义。这跟 C++ 语言本身对 const 的定义是一致的,const 本来就是浅层的,只是咱们平时写 `const int&` 的时候 const 直接修饰了 int,所以看起来像"深层"。 + +### const 的三种位置 + +把三种写法摆一起,您就看出它和指针的规则一模一样: + +```cpp +int value = 42; + +std::optional opt1 = value; // 引用指向 const,不能通过它改 value +const std::optional opt2 = value; // optional 本身 const,不能 reset/重绑定,但能改 value +const std::optional opt3 = value; // 两个都锁 +``` + +这对应指针世界里 `const int*`(指向 const)和 `int* const`(指针本身 const)的区别。`optional` 和 `const optional` 是两码事。笔者以前把 optional 引用当成"引用的包装"来理解,实际上把它当成"指针的包装"才对。 + +:::warning +这个坑在真实项目里能埋出 bug。你以为传了个 `const optional` 进去,接收方就不会改你原来的值了,结果人家 `*opt = ...` 直接改了。这种 bug 排查起来真让人怀疑人生。记住,`const optional` 防的是重新绑定,不防修改被引用对象。 +::: + +## 条件 explicit:到底要 explicit 到什么程度 + +这条偏库设计,但作为使用者也得知道它意味着什么。 + +笔者个人的习惯是构造函数能加 explicit 就加,防止隐式转换带来惊喜。但 optional 的历史包袱太重,`optional` 从 `T` 的隐式构造已经存在很久,大量代码依赖,改不了。 + +那 `optional` 呢?从 `T&` 构造、从 `optional` 转换,到底 explicit 还是隐式?最终的设计决策是条件 explicit:根据底层 `T` 构造时的 explicit 性质来决定。`T` 能从 `U` 隐式构造,`optional` 就能从 `optional` 隐式构造;`T` 从 `U` 的构造是 explicit 的,optional 这边也 explicit。 + +这个策略听起来合理,实现代价却不小。Steve Downey 说他们在库设计工作中为此付出了很大代价,要确保各种转换落入正确的构造函数,而不是被其他重载截胡。对咱们使用者最直接的影响是:有些您以为能隐式转换的场景可能突然不行了,得显式写 `optional{...}`。遇到这种编译错误别懵,想想是不是底层类型的 explicit 性质在起作用。 + +## value_or 永远返回值 + +`value_or` 是 optional 最常用的方法之一,但它的返回类型在 `optional` 场景下真让人头疼。跑一下: + +```cpp +// value_or_type.cpp +#include +#include +#include + +int main() { + int x = 42; + std::optional opt = x; + auto r1 = opt.value_or(0); // 有值 + static_assert(std::is_same_v); + + std::optional empty; + auto r2 = empty.value_or(7); // 空值 + static_assert(std::is_same_v); + + std::cout << "engaged value_or=" << r1 << " empty value_or=" << r2 << "(都是 int)\n"; +} +``` + +```bash +$ g++ -std=c++26 value_or_type.cpp -o value_or_type && ./value_or_type +engaged value_or=42 empty value_or=7(都是 int) +``` + +optional 里面明明存的是引用,value_or 有值的时候为什么不把引用返回给咱们?因为这里有个根本性矛盾。optional 有值,您希望返回 `T&`;optional 没值,您希望返回默认值,而默认值是个临时对象,返回它的引用就是悬垂引用。这两个需求在同一个返回类型里没法统一。 + +现在的决策是 value_or 总返回值。最安全,虽然可能不是最方便,但至少不会产生悬垂引用。Steve Downey 的态度很明确:不能让所有人都满意,就做最安全的事情,以后再回来解决。 + +这个限制在真实场景里确实不方便。比如您想从 `optional` 和一个全局配置里二选一,返回一个引用,value_or 基本没法用,只能老老实实手写 if: + +```cpp +const Config& get_config(std::optional override) { + if (override) return *override; + return global_config; +} +``` + +有几个提案(包括 Steve Downey 自己的)在尝试泛化 value_or,让它能返回 `T` 和 `U` 的公共引用类型。这个能力直到最近才能在语言层面表达,库技术还在建设中。目前咱们先用"总返回值"这个安全但有点笨的版本。 + +## 悬垂防御:delete 而不是 requires + +这可能是整个设计里笔者最佩服的一个决策。您想想这个场景:从一个临时对象构造 `optional`,临时对象在表达式结束时销毁,optional 里却还存着指向它的引用,经典的悬垂。 + +```cpp +std::optional bad() { + return std::optional(42); // 从临时 int 构造,42 马上消亡 +} +``` + +以前这种代码可能"碰巧能跑",因为编译器不一定会立刻清理临时对象。上了生产开了优化,编译器激进回收临时对象,您就得到一个诡异的内存问题,复现都难。 + +设计原则是检查悬空属性。如果转换会产生一个临时对象,而这个临时对象会在表达式结束时消亡,就把这个重载直接 delete 掉,而不是用 requires 子句把它从重载集里排除。 + +这两者的区别非常关键。用 requires,编译器发现这个重载不满足约束,会继续去找其他重载,可能掉进一个您完全意想不到的构造函数里,报一堆看不懂的错。但用 `= delete`,编译器会直接告诉您:这个函数被删除了。错误提示来得更早、更清晰。 + +```cpp +// 设计思路(简化示意) +template + requires (std::is_lvalue_reference_v) // 只允许从左值构造 +optional(optional); // requires:不满足会去找别的重载 + +// 对比 +template +optional(U&&) = delete; // delete:直接报错,不找别的 +``` + +您写的东西是真的行不通,而不是被编译器硬塞进某个能跑通的路径。这一点对调试体验差别巨大。 + +特别值得一提的是 range-for 循环的修复。以前您写这种管道: + +```cpp +for (auto& x : some_map | some_transform | another_transform) { + // ... +} +``` + +如果管道中间产生了临时对象,而某个适配器返回了 `optional` 指向那个临时对象,临时对象可能在 for 循环的第一轮迭代之前就死了。C++23 修复了这个问题,在 range-for 循环中构造的临时对象现在能存活于整个 for 循环期间。这个修复虽然排除了少数本来安全的情况,但它禁止了多得多的危险情况,整体上是赚的。 + +## 接下来 + +浅层 const、条件 explicit、value_or、悬垂防御,这些是 `optional` 在"怎么用对它"层面的坑。下一篇咱们换个角度,看它跟移动语义交叉的地带。那是 C++ 里最容易出隐晦 bug 的地方,一个 `std::move` 放错位置,可能就"偷走了别人的猫"。 diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/05-move-semantics-traps.md b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/05-move-semantics-traps.md new file mode 100644 index 000000000..69c00767a --- /dev/null +++ b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/05-move-semantics-traps.md @@ -0,0 +1,134 @@ +--- +title: optional 引用里藏着的移动语义陷阱 +description: CppCon 2025 笔记 —— optional 与移动语义交叉处的"猫被偷走"bug:operator* 对右值 optional 的返回类型、*std::move(opt) 为何危险、std::move 能不写就不写 +chapter: 6 +order: 5 +conference: cppcon +conference_year: 2025 +talk_title: 'The Evolution of std::optional: From Boost to C++26' +speaker: Steve Downey +cpp_standard: [17, 23, 26] +difficulty: intermediate +platform: host +reading_time_minutes: 9 +tags: + - cpp-modern + - host + - intermediate + - optional +prerequisites: + - "optional 引用的浅层陷阱:const、value_or 与悬垂" +related: + - "optional 引用的浅层陷阱:const、value_or 与悬垂" + - "标准化真相:The Beman Project 与一份能跑的参考实现" +--- + +# optional 引用里藏着的移动语义陷阱 + +[上一篇](./04-shallow-traps-const-value-or-dangling.md)讲的是 `optional` 使用层面的坑。这一篇换个维度,看它跟移动语义交叉的地带。这是 C++ 里最容易出隐晦 bug 的地方,一个 `std::move` 放错位置,可能就"偷走了别人的猫"。 + +## 先承认一种最危险的"能跑通" + +说实话,笔者以前有种很糟糕的编码习惯:只要代码跑出预期结果,就觉得大功告成,直接提交。后来在折腾一个安装脚本时栽过大跟头。那脚本看起来跑通了,输出全对,笔者觉得搞定就扔一边。过几天换了个环境跑,直接炸了。后来有大佬帮着看,才发现那脚本本来就不该跑通,它只是碰巧跑通。 + +这是最糟糕的一种"能跑通",因为它给您虚假的安全感,让您以为这部分已经理解透了,实际上底层逻辑全歪。 + +这种"碰巧能跑通"的坑,在 C++ 的模板和移动语义里太多了。这一篇讲的,就是 optional 引用和移动语义交叉处一个能产生千分之一概率诡异崩溃的 bug。 + +## "猫被偷走"是怎么回事 + +Steve Downey 举了个特别生动的例子。假设有只猫叫 Finn,笔者创建了一个指向 Finn 的引用,包装进 optional。这时候如果把那个 optional 引用移动赋值给另一个 optional,在某些实现里会发生一件离谱的事:Boost.Optional 会把那只猫本身"偷走",而不是简单拷贝引用。 + +您可能懵了,引用怎么能被"偷走"?引用又不拥有对象。问题出在 `operator*` 的返回值类别和移动语义的交互上。 + +咱们先看一个关键事实:`operator*` 对右值 optional 的返回类型,在 `T` 和 `T&` 两种特化下是不一样的。跑一下: + +```cpp +// move_category.cpp +#include +#include +#include +#include + +struct Cat { + std::string name; + Cat(std::string n) : name(std::move(n)) {} +}; + +int main() { + std::optional ov = Cat{"Finn"}; + using Rval = decltype(*std::move(ov)); // 值版本,右值 optional + std::cout << "*std::move(optional) 是 Cat&& ? " + << std::is_same_v << "\n"; + + Cat c{"Loki"}; + std::optional or_ = c; + using Rref = decltype(*std::move(or_)); // 引用版本,右值 optional + std::cout << "*std::move(optional) 是 Cat& ? " + << std::is_same_v << "\n"; +} +``` + +```bash +$ g++ -std=c++26 move_category.cpp -o move_category && ./move_category +*std::move(optional) 是 Cat&& ? 1 +*std::move(optional) 是 Cat& ? 1 +``` + +读这两行输出。对一个右值的 `optional`(值版本),`*std::move(opt)` 的类型是 `Cat&&`,因为 optional 马上要销毁,里面的 Cat 也可以被移动出来,这是合理的优化。但对一个右值的 `optional`(引用版本),`*std::move(opt)` 的类型还是 `Cat&`,没变成 `Cat&&`。 + +## 底层到底在发生什么 + +这个区别就是"猫被偷走"的核心。 + +对一个右值的 `optional`,`operator*` 返回 `T&&` 是对的。optional 即将销毁,里面的 `T` 也能被移动,您写 `some_type dest = *std::move(opt)` 是把 `T` 移动出来,没问题。 + +但对 `optional`,optional 即将销毁,不代表它引用的那个对象即将销毁。Finn 还活得好好的,它只是被一个即将销毁的 optional 引用着。如果实现里 `operator*` 对右值 `optional` 也返回 `T&&`(这正是 Boost.Optional 当年的 bug),您写 `*std::move(opt)` 就是在移动 Finn 本身。猫被偷走了。 + +P2988 修复了这一点:`optional` 的 `operator*` 永远返回 `T&`,不管 optional 是不是右值。因为咱们在模拟引用语义,而引用的值类别跟"持有这个引用的容器"的值类别无关,一个引用绑定到哪个对象,通过它访问到的值类别就取决于那个对象本身。上面实测的 GCC 16.1.1 实现是对的,`*std::move(optional)` 是 `Cat&` 不是 `Cat&&`。 + +## 笔者给自己定的一条规矩 + +看到这里,笔者给自己定了一条规矩,也分享给您。 + +不要试图去推断您能对什么进行移动。 + +什么意思?别想"我知道这个函数返回什么,我可以把 `std::move` 包在外面,没问题"。您今天可能是对的,明天别人改了那个函数的返回类型,或者模板实例化成另一个特化,您的 `std::move` 就可能从无害变成偷走别人的猫。 + +正确的做法是:只对您确定有权移动的对象执行移动,并且把 `std::move` 写在那个对象本身上,而不是写在包含它的容器外面。 + +```cpp +// 危险写法:您不知道 *rhs 解引用出来到底该不该被移动 +some_type dest = *std::move(rhs); + +// 安全写法:先解引用拿到引用,再 move 这个引用 +some_type dest = std::move(*rhs); +``` + +这两行区别微妙,语义完全不同。`*std::move(rhs)` 是先把 optional 变成右值再解引用,解引用的结果类型取决于 optional 的 `operator*` 对右值怎么定义,这正是前面说的不可预测的部分。`std::move(*rhs)` 是先解引用拿到引用,再把这个引用变成右值,语义清晰:我要移动的就是那个被引用的对象。 + +更进一步,您能写出的最好的 `std::move`,是那些根本不需要写的 `std::move`。比如返回局部变量: + +```cpp +Cat make_cat() { + Cat c{"Finn"}; + return c; // 正确,NRVO 或隐式移动 + // return std::move(c); // 多余,甚至可能阻止 NRVO +} +``` + +编译器自己知道 `c` 是局部的、马上要销毁的,它会自动处理。您手写 `std::move` 反而可能把 NRVO 搞没,因为 `std::move(c)` 返回的是右值引用,而 NRVO 要求返回的是命名的局部变量本身。写 `std::move` 本质上是在向编译器解释一些事情,而这总是有风险的,因为您不一定比编译器聪明。状态好的时候您可能是,到了周五下午笔者就不是了。 + +## optional 引用到底要解决什么问题 + +聊了半天 bug,退一步看,`optional` 到底用来干什么? + +最典型的用例是:查找某个东西,而"没找到"不算异常。 + +笔者以前写代码,从 map 里查东西,找不到就抛异常或者返回 end 迭代器让调用方处理。但说实话,很多时候"没找到"是个再正常不过的结果,根本不值得用异常来表达。异常的开销大,语义也不对,"key 不存在"不是程序出错,它就是一个可能的查询结果。 + +有了 `optional`,咱们就能给 map 写一个返回 optional 引用的 getter,找到就拿到引用直接改,没找到就是空。现在标准库还没把这个接口直接做进关联容器(那是 P3091 的事),您可以先用 [上一篇](./03-optional-reference-and-assignment.md)里的 `reference_wrapper` 包一层过渡。 + +## 接下来 + +移动语义和引用的交叉地带,核心就一条:optional 即将销毁,不代表被引用对象即将销毁;所以别用 `*std::move(opt)`,用 `std::move(*opt)`,最好的 move 是不写 move。下一篇咱们跳出 optional 本身的细节,看这场演讲里最让笔者兴奋的部分:标准化到底是怎么进行的,以及 The Beman Project 在里面扮演的角色。 diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/06-standardization-and-beman.md b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/06-standardization-and-beman.md new file mode 100644 index 000000000..8f63c5150 --- /dev/null +++ b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/06-standardization-and-beman.md @@ -0,0 +1,113 @@ +--- +title: 标准化真相:The Beman Project 与一份能跑的参考实现 +description: CppCon 2025 笔记 —— optional 赋值的汇编实证、The Beman Project 参考实现为何重要、大而全与小而专的取舍、optional 与 optional 必须是一个整体 +chapter: 6 +order: 6 +conference: cppcon +conference_year: 2025 +talk_title: 'The Evolution of std::optional: From Boost to C++26' +speaker: Steve Downey +cpp_standard: [17, 23, 26] +difficulty: intermediate +platform: host +reading_time_minutes: 15 +tags: + - cpp-modern + - host + - intermediate + - optional +related: + - "optional 引用里藏着的移动语义陷阱" + - "为什么 optional 引用折腾了二十年" +--- + +# 标准化真相:The Beman Project 与一份能跑的参考实现 + +[上一篇](./05-move-semantics-traps.md)讲完移动语义的坑。这一篇跳出来,看这场演讲最让笔者兴奋的部分:标准化到底是怎么进行的,以及 The Beman Project 在里面扮演的角色。最后把整条线收一下。 + +## 先用汇编验证一件事:赋值就是指针拷贝 + +前面几篇咱们反复说 `optional` 的赋值等价于指针拷贝。口说无凭,看汇编。写个函数只做一次 `optional` 赋值,`-O2` 编译: + +```cpp +// assign_codegen.cpp +#include +void assign_opt(std::optional& a, const std::optional& b) { + a = b; +} +``` + +```bash +g++ -std=c++26 -O2 -S assign_codegen.cpp -o assign_codegen.s +``` + +把 `assign_opt` 的函数体拎出来: + +```asm +_Z10assign_optRSt8optionalIRiERKS1_: + movq (%rsi), %rax # 从 b 读 8 字节,就是一个指针 + movq %rax, (%rdi) # 写到 a + ret +``` + +两条 `movq`,从 `b` 读一个指针,写到 `a`,返回。没有任何函数调用,没有分支。这就是 Steve Downey 说的"赋值通过单一函数完成,从编译器优化角度看非常透明"。标准把语义定得足够简单,实现者就有空间把代码生成做到极致,编译器能内联、能做死代码消除,所有它擅长的事都干得了。 + +:::warning +有一点要提醒。空状态的 `optional` 解引用,和空指针解引用的后果是一样的,未定义行为,而且编译器不一定会给您任何警告。千万别觉得 optional 会保护您,它只是把"有没有值"这个信息显式化了,您不检查就解引用,该崩还是崩。上一场演讲里提到的空指针和无效指针的可怕问题,这里照样适用。 +::: + +## The Beman Project 到底解决什么 + +这场演讲最让笔者兴奋的部分来了。 + +The Beman Project 是 2024 年 CppNow 上启动的一个项目,名字来自 Boost 的创始人之一 Beman Dawes。笔者一开始以为是编译器优化项目,后来才搞明白,它是个参考实现项目:针对正在被提议进入 C++ 标准库的组件,提供一份教科书级别的干净实现。 + +您可能觉得这没什么大不了,标准库实现不是 libstdc++、libc++、MSVC STL 都有吗,随便挑一个看不行吗? + +不行。这里有个笔者以前完全没意识到的关键区别:厂商的实现里塞了大量历史遗留的东西。 + +举个笔者自己的例子。之前想搞清楚 `std::optional` 某个构造函数的精确行为,去翻 libstdc++ 源码,结果发现里面有不少笔者根本没见过的构造函数重载。有些是为兼容旧标准,有些是为配合特定编译器扩展,有些注释里写着"这个可能不需要但删了会破坏某些内部 ABI"。笔者当时就懵了,到底该看哪个?哪个才是"标准说的那个"? + +The Beman Project 要解决的就是这个。它要做一份干净的、只包含标准提案内容的实现,没有历史包袱,没有厂商私货,没有"当年犯的错现在不敢删"的东西。LEWG(库演进工作组)明确说了,这个实现里只会有一组名字,就是标准最终指定的那些。 + +## 为什么"有个能跑的东西"这么重要 + +这部分笔者觉得是整场演讲里最有价值的。Steve Downey 说了一句话,大意是他还不够聪明,没法在没有实际实现参考的情况下写出标准用语。 + +您敢信?写标准提案的人说自己不够聪明?但仔细一想,这话挺实在。笔者写技术文档也常这样,觉得想清楚了,文字也漂亮,一动手写代码就发现这里有个边界没考虑,那里有个交互没预料。 + +标准用语也一样。你以为可以把某个函数标记成 `const`,纸面上完全合理,一实现就发现五个测试用例挂了。这五个测试用例覆盖的场景,可能是你写提案时根本想不到的,比如和某个其他标准组件的组合,或者某个特定模板上下文里的推导结果。 + +如果这些发现发生在提案被接受、标准发布之后呢?那就惨了,得再走一轮完整的会议周期去修,可能等好几年。但如果在参考实现里就发现呢?直接改,改完跑测试,五分钟就知道行不行。Steve Downey 说他把某个东西改成 const 之后五个测试挂了,于是知道这主意不怎么样,或者反过来,主意不错只是测试用例本身有问题。不管哪种,都比经历整整一轮会议周期之后回来说"哦对了,你让我做的那件事,结果发现不行"要好得多。 + +他举了个好例子。东京会议上有人提议让 optional 变成 range,这个提案到底可不可行?不是靠嘴说,不是靠在白板上画类型推导树。他的做法是把提议的函数加进 Beman 的实现里,拿出一批之前为处理零个或一个元素 range 写的测试用例一跑,全过。这就把讨论从"这到底行不行得通"变成了"我们到底想不想这么做"。技术障碍被排除,剩下的是设计品味的问题。这种工作方式比在邮件列表里来回辩论一百轮高效多了。 + +:::warning +这也意味着基于 The Beman Project 写的代码,ABI 会不稳定。今天能编的代码,下个月可能编不过,因为委员会可能改了某个函数名、把某个参数改成 const、把某个构造函数标成 explicit。这不是 bug,是重点。他们就是要通过这种折腾来验证标准用语的正确性。所以 Beman 的定位是标准化参考,不是让您在生产里依赖的稳定库。 +::: + +## 大而全还是小而专 + +Steve Downey 提到,C++ 标准库倾向于提供一个"大而全"的东西,而不是像其他语言生态那样提供几个相近但不同的类型让您选。optional 变成 range 之后,标准里就只有一个"类似 optional 的东西",而不是两三个针对不同用例优化的近亲类型。这跟 `std::string` 的思路一脉相承,把所有功能塞进一个接口,您不需要纠结"我到底该用这个还是用那个"。 + +笔者对这种思路是矛盾的。一方面理解,选择少确实降低认知负担,尤其对新手。另一方面,`std::string` 的教训就摆在那,接口太臃肿,很多函数存在价值存疑,因为要兼顾所有用例,每个用例都做不到最优。 + +其他语言怎么做?Rust 有 `Option` 和各种零成本抽象的迭代器适配器,Go 有"零值"哲学根本不需要 optional,Swift 有 `Optional` 但它的语言层面集成都和 C++ 不同。每个生态都有自己的取舍,C++ 的取舍就是"给您一个万能的",代价就是"这个万能的可能在每个具体场景里都不是最优的"。作为使用者,至少得意识到这个取舍存在。 + +## optional\ 和 optional\ 必须是一个整体 + +聊完 The Beman Project,看一个更深层的问题,也是笔者一开始没意识到的:如果 `optional` 进了标准库,它必须和已有的 `optional` 无缝协作。 + +最典型的场景是 monadic 操作(`transform`、`and_then` 这些,C++23 起的 P0798)。假设您有一个 `optional`,对它调用 `transform`,传入的函数返回 `optional`,这时候会发生什么?您希望自动展平,但如果 `optional` 和 `optional` 的实现是割裂的,它们互相不认识,这里大概率返回嵌套的 optional,您就得手动 flatten,非常反直觉。 + +流行的 polyfill 库 `tl::optional` 不存在这个问题,因为它根本不支持 `optional`,monadic 函数不需要处理这种跨类型交互。但一旦进了标准库,`optional` 和 `optional` 必须是一个连贯的集合(Steve Downey 用的词是 coherent set),它们得知道对方的存在,在类型转换、monadic 操作、比较运算所有场景下都做正确的事。这也是为什么不能简单在现有 optional 上打补丁,得从设计层面把 `T` 和 `T&` 两种特化统一起来。 + +顺便说说 `reference_wrapper` 的定位。它不会消失,在 tuple 和 bind 的场景下仍然有用,标准库里已经有它,肯定有人依赖。但它不是也不应该是"可选引用"的答案。它最早是给 tuple 的 DSL 做适配的,API 有怪癖,它自带的隐式转换到 `T&` 在重载决议场景下会咬人,它完全不关心悬空问题。`optional` 是个全新的、从语义出发的设计,不是在 `reference_wrapper` 上修修补补能做到的。底层存储可能差不多,都是个指针,但语义和安全保证是完全不同层次的,就像有裸指针了还要 `unique_ptr` 干嘛一样。 + +## 写在最后 + +把整条线收一下。`std::optional` 从 2005 年提出,2017 年值版本进 C++17,引用版本因为 assign-through 和 rebind 的争吵被搁置,中间走了很多弯路,最终由 P2988 在 2025 年 Sofia 会议投票通过,进入 C++26。它内部就是一个带约束的指针,赋值永远重绑定,const 是浅层的,value_or 总返回值,`operator*` 对引用版本不传播移动。 + +这些决定每一个单独看都有点反直觉,但您只要记住一件事:咱们在对指针建模。赋值改的是指针的指向,const 只锁指针本身,移动不穿透到被指向的对象。指针的规则平移过来,再注意几个边界情况,整个 `optional` 就说得通了。 + +至于它为什么花二十年,不是技术上做不到,是这些边界情况的语义太容易让人踩坑,需要一个精心设计的提案把所有细节理清楚,还需要 The Beman Project 这样一份能跑的参考实现来验证标准用语真的站得住脚。标准化不是一群人在会议室里吵架然后甩出一份文档,它是"想清楚、写下来、实现出来跑一遍、不行再改"的循环。这么朴素的道理,放在标准化这么高大上的场景里,居然也是最有效的。 diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/index.md b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/index.md new file mode 100644 index 000000000..62b837329 --- /dev/null +++ b/documents/vol10-open-lecture-notes/cppcon/2025/06-evolution-of-std-optional/index.md @@ -0,0 +1,38 @@ +--- +title: "The Evolution of std::optional: From Boost to C++26" +description: "CppCon 2025 演讲笔记 —— Steve Downey 讲 std::optional 从 Boost 到 C++26 的演进,聚焦 optional 引用(P2988)为何等了二十年才进标准" +conference: cppcon +conference_year: 2025 +talk_title: 'The Evolution of std::optional: From Boost to C++26' +speaker: "Steve Downey" +tags: + - cpp-modern + - host + - intermediate + - optional +difficulty: intermediate +platform: host +cpp_standard: [17, 23, 26] +--- + + + +这是 CppCon 2025 上 Steve Downey(Bloomberg)的演讲笔记。他是把 `std::optional` 推进 C++26 的提案 P2988 的主要作者。这场演讲的核心是回答一个问题:一个看起来"不就是能装空值的引用嘛"的特性,为什么从 2005 年第一次提出,一直拖到 2025 年 6 月的 Sofia 会议才投票通过。答案绕不开引用在 C++ 里的三重身份、assign-through 和 rebind 的二十年之争,以及最终"它就是个带约束的指针"这个结论。 + +笔记拆成六篇,沿着"先讲值版本底子,再进引用版本核心,最后跳出来看标准化"的顺序展开。所有涉及 `optional` 的代码都在 GCC 16.1.1(`-std=c++26`)上实跑过,不是纸上谈兵。 + +## 笔记目录 + + + 为什么 optional 引用折腾了二十年 + std::optional 的值语义底子 + optional 引用是什么,以及赋值为什么一定是重绑定 + optional 引用的浅层陷阱:const、value_or 与悬垂 + optional 引用里藏着的移动语义陷阱 + 标准化真相:The Beman Project 与一份能跑的参考实现 + diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/index.md b/documents/vol10-open-lecture-notes/cppcon/2025/index.md index a483c8ff3..59aa0eb49 100644 --- a/documents/vol10-open-lecture-notes/cppcon/2025/index.md +++ b/documents/vol10-open-lecture-notes/cppcon/2025/index.md @@ -67,3 +67,16 @@ CppCon 2025 的演讲笔记合集。 Back to Basics: Move Semantics + +--- + + + + + The Evolution of std::optional: From Boost to C++26 + diff --git a/documents/vol2-modern-features/ch00-move-semantics/05-move-in-practice.md b/documents/vol2-modern-features/ch00-move-semantics/05-move-in-practice.md index 5fbfd8aab..8f985df5f 100644 --- a/documents/vol2-modern-features/ch00-move-semantics/05-move-in-practice.md +++ b/documents/vol2-modern-features/ch00-move-semantics/05-move-in-practice.md @@ -23,13 +23,13 @@ title: 移动语义实战:从 STL 到自定义类型 --- # 移动语义实战:从 STL 到自定义类型 -前面四篇文章我们把移动语义的理论基础从头到尾梳理了一遍:值类别、右值引用、移动构造与移动赋值、RVO/NRVO、完美转发。现在到了把理论落地的环节——我们来看看移动语义在实际代码中到底能带来多大的性能差异,以及在 STL 容器和自定义类型中应该如何正确使用它。这一篇会有不少代码和实测数据,建议你跟着敲一遍,亲手感受一下拷贝和移动之间的差距。 +理论铺垫够了,这篇把移动语义落到实际代码里。咱们要看清两件事:移动到底比拷贝快多少,以及 STL 容器和自定义类型各自怎么写才能吃到这份收益。代码和实测数据不少,建议您跟着敲一遍,亲手感受拷贝和移动的差距。 ## STL 容器中的移动——无处不在的收益 标准库容器是移动语义最大的受益者之一。C++11 之后,所有标准库容器都实现了移动构造和移动赋值,这意味着容器之间的传递不再需要逐元素拷贝。 -先看 `std::vector` 的 `push_back`。它有两个重载:一个接收 `const T&`(拷贝),一个接收 `T&&`(移动)。当你传入左值时调用拷贝版本,传入右值时调用移动版本。 +先看 `std::vector` 的 `push_back`。它有两个重载:一个接收 `const T&`(拷贝),一个接收 `T&&`(移动)。当您传入左值时调用拷贝版本,传入右值时调用移动版本。 ```cpp #include @@ -127,7 +127,7 @@ g++ -std=c++17 -Wall -Wextra -O2 -o push_demo push_demo.cpp 三种方式的效果一目了然。`push_back(h1)` 触发拷贝——`h1` 的 10000 个 `int` 被完整复制。`push_back(std::move(h2))` 触发移动——只转移了 `vector` 的内部指针,`h2` 的 `data_` 变成空的。`emplace_back("Gamma", 10000)` 连移动都省了——直接在 vector 的空间里构造 `Heavy` 对象。 -三种方式的性能排序是:`emplace_back` > `push_back(std::move(...))` > `push_back(lvalue)`。在日常编码中,如果你有一个现成的对象要放进容器,用 `std::move` 移动进去;如果你有构造参数,用 `emplace_back` 直接原位构造。 +三种方式的性能排序是:`emplace_back` > `push_back(std::move(...))` > `push_back(lvalue)`。在日常编码中,如果您有一个现成的对象要放进容器,用 `std::move` 移动进去;如果您有构造参数,用 `emplace_back` 直接原位构造。 ## swap 惯用法——移动语义的经典应用 @@ -146,13 +146,13 @@ void swap(T& a, T& b) noexcept( } ``` -三次移动操作完成了两个对象的交换。对于通过指针间接管理资源的类(内部持有 `new` 出来的内存、文件描述符等),每次移动只是指针转移,所以整个 swap 的代价是 O(1)——与对象管理的资源大小无关。但要注意前提:这条结论依赖于"资源是间接持有的"。如果你的对象像 `std::array` 那样把数据直接存在对象内部(没有间接层),那么移动和拷贝是等价的——swap 仍然是 O(n)。相比之下,C++03 的 swap 对间接持有资源的类型需要一次拷贝构造加两次拷贝赋值,代价是 O(n)。 +三次移动操作完成了两个对象的交换。对于通过指针间接管理资源的类(内部持有 `new` 出来的内存、文件描述符等),每次移动只是指针转移,所以整个 swap 的代价是 O(1)——与对象管理的资源大小无关。但要注意前提:这条结论依赖于"资源是间接持有的"。如果您的对象像 `std::array` 那样把数据直接存在对象内部(没有间接层),那么移动和拷贝是等价的——swap 仍然是 O(n)。相比之下,C++03 的 swap 对间接持有资源的类型需要一次拷贝构造加两次拷贝赋值,代价是 O(n)。 -在排序算法中,swap 是最频繁的操作之一。`std::sort` 内部会大量调用 swap 来调整元素位置,高效的移动操作能让排序过程中每次元素调整的代价从 O(n) 降到 O(1)。需要特别说明的是,`noexcept` 对 `std::sort` 本身并没有直接影响——sort 内部直接使用 `std::move` 和 `std::swap`,不关心移动操作是否 `noexcept`(只要类型满足可移动构造和可移动赋值要求即可)。`noexcept` 真正发挥作用的场景是 `std::vector` 扩容:当 vector 需要把旧元素搬到新内存时,它会通过 `std::move_if_noexcept` 来选择策略——如果移动操作是 `noexcept` 的,就用移动;否则退回拷贝,以保证强异常安全。我们用下面这个验证程序来证明这一点: +在排序算法中,swap 是最频繁的操作之一。`std::sort` 内部会大量调用 swap 来调整元素位置,高效的移动操作能让排序过程中每次元素调整的代价从 O(n) 降到 O(1)。需要特别说明的是,`noexcept` 对 `std::sort` 本身并没有直接影响——sort 内部直接使用 `std::move` 和 `std::swap`,不关心移动操作是否 `noexcept`(只要类型满足可移动构造和可移动赋值要求即可)。`noexcept` 真正发挥作用的场景是 `std::vector` 扩容:当 vector 需要把旧元素搬到新内存时,它会通过 `std::move_if_noexcept` 来选择策略——如果移动操作是 `noexcept` 的,就用移动;否则退回拷贝,以保证强异常安全。咱们用下面这个验证程序来证明这一点: ```cpp // noexcept_sort_vs_realloc_verify.cpp -- 验证 noexcept 对 sort 和 vector 扩容的影响 -// 完整代码见 code/volumn_codes/vol2/ch00-move-semantics/ +// 完整可编译版本见 code/examples/vol2/noexcept_sort_vs_realloc.cpp #include #include @@ -199,14 +199,50 @@ int NoexceptType::copy_count = 0; int NoexceptType::move_count = 0; // ThrowingType 与 NoexceptType 完全相同,唯一区别是移动操作没有 noexcept -// (完整代码见仓库) -// ... +struct ThrowingType +{ + std::string payload; + int value; + + static int copy_count; + static int move_count; + + ThrowingType(int v) : payload("data"), value(v) {} + ThrowingType(const ThrowingType& o) + : payload(o.payload + "_c"), value(o.value) { ++copy_count; } + ThrowingType(ThrowingType&& o) // 注意:没有 noexcept + : payload(std::move(o.payload)), value(o.value) + { + o.payload = "(moved)"; + ++move_count; + } + ThrowingType& operator=(ThrowingType&& o) // 注意:没有 noexcept + { + payload = std::move(o.payload); + value = o.value; + o.payload = "(moved)"; + ++move_count; + return *this; + } + ThrowingType& operator=(const ThrowingType& o) + { + payload = o.payload + "_c"; + value = o.value; + ++copy_count; + return *this; + } + bool operator<(const ThrowingType& rhs) const { return value < rhs.value; } + static void reset() { copy_count = 0; move_count = 0; } +}; + +int ThrowingType::copy_count = 0; +int ThrowingType::move_count = 0; int main() { const int kCount = 5000; - // Test 1: std::sort + // Test 1: std::sort(noexcept 类型) { std::vector vec; vec.reserve(kCount); @@ -217,7 +253,20 @@ int main() << " 移动=" << NoexceptType::move_count << "\n"; } - // Test 2: vector 扩容(无 reserve) + // Test 2: std::sort(非 noexcept 类型) + { + std::vector vec; + vec.reserve(kCount); + for (int i = 0; i < kCount; ++i) vec.emplace_back(kCount - i); + ThrowingType::reset(); + std::sort(vec.begin(), vec.end()); + std::cout << "非noexcept sort: 拷贝=" << ThrowingType::copy_count + << " 移动=" << ThrowingType::move_count << "\n"; + } + + std::cout << "\n"; + + // Test 3: vector 扩容(noexcept 类型,无 reserve) { NoexceptType::reset(); std::vector vec; @@ -226,13 +275,19 @@ int main() << " 移动=" << NoexceptType::move_count << "\n"; } - // Test 3: vector 扩容(非 noexcept 类型) + // Test 4: vector 扩容(非 noexcept 类型,无 reserve) // ThrowingType 的扩容会退回拷贝,因为 move_if_noexcept 不选中它的移动 - // ... + { + ThrowingType::reset(); + std::vector vec; + for (int i = 0; i < 200; ++i) vec.emplace_back(i); + std::cout << "非noexcept扩容: 拷贝=" << ThrowingType::copy_count + << " 移动=" << ThrowingType::move_count << "\n"; + } } ``` -编译运行(g++ 15.2, -std=c++17 -O2, x86_64): +编译运行(GCC 16.1.1, -std=c++17 -O2, x86_64): ```text noexcept sort: 拷贝=0 移动=23516 @@ -242,9 +297,9 @@ noexcept 扩容: 拷贝=0 移动=255 非noexcept扩容: 拷贝=255 移动=0 ``` -数据非常清楚。`std::sort` 两种情况都只使用移动(23516 次),完全不区分 `noexcept`。但 `vector` 扩容就大不一样了:`noexcept` 的类型在扩容时使用移动(255 次移动),非 `noexcept` 的类型在扩容时全部退回拷贝(255 次拷贝)。如果你在 `vector` 里频繁 `push_back` 但没有提前 `reserve`,没有 `noexcept` 的移动会让每次扩容都变成全量拷贝——这才是 `noexcept` 真正影响性能的地方。 +数据非常清楚。`std::sort` 两种情况都只使用移动(23516 次),完全不区分 `noexcept`。但 `vector` 扩容就大不一样了:`noexcept` 的类型在扩容时使用移动(255 次移动),非 `noexcept` 的类型在扩容时全部退回拷贝(255 次拷贝)。如果您在 `vector` 里频繁 `push_back` 但没有提前 `reserve`,没有 `noexcept` 的移动会让每次扩容都变成全量拷贝——这才是 `noexcept` 真正影响性能的地方。 -正确的自定义 swap 写法需要注意 ADL(Argument-Dependent Lookup)。标准做法是在类的命名空间中提供一个非成员的 `swap` 函数,然后让用户通过 `using std::swap; swap(a, b);` 的方式调用。这样 ADL 会优先找到你的自定义版本,找不到时退回到 `std::swap`。 +正确的自定义 swap 写法需要注意 ADL(Argument-Dependent Lookup)。标准做法是在类的命名空间中提供一个非成员的 `swap` 函数,然后让用户通过 `using std::swap; swap(a, b);` 的方式调用。这样 ADL 会优先找到您写的 swap,找不到时退回到 `std::swap`。 ```cpp namespace mylib { @@ -290,11 +345,11 @@ public: } // namespace mylib ``` -这里我们用了 copy-and-swap 惯用法来实现赋值运算符,用 `friend swap` 来提供高效的交换操作。`swap` 本身只是交换两个指针和两个整数——代价微乎其微。 +这里咱们用了 copy-and-swap 惯用法来实现赋值运算符,用 `friend swap` 来提供高效的交换操作。`swap` 本身只是交换两个指针和两个整数——代价微乎其微。 ## 性能对比——拷贝 vs 移动的 benchmark -理论讲了一大堆,数字最有说服力。我们来做一个 benchmark,对比拷贝和移动的实际耗时。这一次我们把构造的开销单独分离出来,这样你能看到纯粹的移动操作到底有多快。 +理论讲了一大堆,数字最有说服力。咱们来做一个 benchmark,对比拷贝和移动的实际耗时。这一次咱们把构造的开销单独分离出来,这样您能看到纯粹的移动操作到底有多快。 ```cpp // move_benchmark.cpp -- 拷贝 vs 移动性能对比(分离构造开销) @@ -394,32 +449,33 @@ g++ -std=c++17 -O2 -Wall -Wextra -o move_bench move_benchmark.cpp ./move_bench ``` -笔者的机器上输出(g++ 15.2, -O2, x86_64 WSL2): +笔者的机器上输出(GCC 16.1.1, -O2, x86_64 WSL2,取一次稳定运行): ```text 数据大小: 7812 KB 迭代次数: 100 -仅构造(baseline): 95.6 ms -构造 + 拷贝: 1404 ms -构造 + 移动: 94.8 ms +仅构造(baseline): 47.3 ms +构造 + 拷贝: 505.3 ms +构造 + 移动: 44.6 ms === 分离后的实际耗时 === -纯拷贝: 1308 ms -纯移动: -0.8 ms +纯拷贝: 458.1 ms +纯移动: -2.7 ms +移动耗时在测量噪声范围内(接近零) ``` -这个结果比单纯报一个"加速比"要有说服力得多。我们逐行看:构造一个 `BigData`(分配 8MB 内存并填充数据)花了约 96ms,这是两组测试共有的基础开销。加上拷贝后总耗时飙升到 1404ms——纯拷贝部分占了 1308ms,因为需要分配新内存并把 8MB 数据逐字节复制过去。加上移动后总耗时是 94.8ms——甚至比纯构造还少了不到 1ms(测量噪声),说明移动操作本身的开销在这个数据规模下几乎测不出来。 +这个结果比单报一个"加速比"有说服力。咱们逐行看:构造一个 `BigData`(分配约 8MB 内存并填充数据)花了 47ms,这是两组测试共有的固定开销。加上拷贝,总耗时飙到 505ms——纯拷贝占 458ms,因为要另开一块内存把 8MB 数据逐字节复制过去。加上移动,总耗时是 45ms,和纯构造几乎没差——说明移动操作本身在这个数据规模下根本测不出来。 -> 💡 **测量噪声说明**:你可能会看到"纯移动"时间出现负值(如 -0.8 ms),这是完全正常的。高精度计时器会捕捉到系统调度、缓存状态等微小差异,导致"构造+移动"的总时间偶尔略小于单独构造的时间。这恰恰说明移动操作的开销极小,已被淹没在测量噪声中。 +> 💡 **测量噪声说明**:「纯移动」时间会在零附近抖动——这次是 -2.7 ms,换个时间点也许是个位数的正值,都正常。高精度计时器会捕捉到系统调度、缓存状态这些微小差异,而移动本身的开销远小于这些差异,所以被噪声淹没了。要紧的是它和纯拷贝那几百毫秒根本不在一个量级。 -移动操作做了什么?它只是复制了 `std::vector` 内部的三个指针大小的字段(指向堆缓冲区的指针、大小、容量),然后把源对象的指针置空。整个操作只有几个 CPU 指令(在纳秒级别),在 96ms 的构造时间面前完全可以忽略。这就是为什么我们把构造分离出来很重要——如果不分离,你看到的"移动耗时"其实是 95ms 的构造加上几纳秒的移动,和 285ms 的构造加拷贝相比只能得到 3 倍加速比,严重低估了移动的真实优势。 +移动操作到底做了什么?它只是复制 `std::vector` 内部那几个指针大小的字段(指向堆缓冲区的指针、大小、容量),再把源对象的指针置空,拢共几条 CPU 指令,纳秒级别,在 47ms 的构造面前可以忽略。这就是为什么要把构造单独分离出来:如果不分离,您看到的"移动耗时"其实是 47ms 构造加上几纳秒移动,和 505ms 的构造加拷贝一比,只会得出一个"快十来倍"的结论——这个数被构造稀释了,反而把"移动几乎免费"这件事给盖住了。 > ⚠️ **踩坑预警**:不要在没有移动语义的类型上期待性能提升。`std::array` 的"移动"和"拷贝"是等价的——因为 `std::array` 的数据直接存储在对象内部,没有指针可以转移。移动语义只在管理了间接资源(动态内存、文件句柄等)的类型上有实际收益。 ## 自定义类型的移动最佳实践 -把你学到的移动语义知识应用到自己的类上,这里有几条经过实战验证的最佳实践。 +把您学到的移动语义知识应用到自己的类上,这里有几条经过实战验证的最佳实践。 对于管理了动态资源的类(持有 `new` 出来的内存、`fopen` 打开的文件、或者类似的资源句柄),应该实现完整的规则五:自定义析构函数、拷贝构造、移动构造、拷贝赋值、移动赋值。移动构造和移动赋值中要把源对象的资源指针置空,确保源对象析构时不会释放已转移的资源。只要移动操作保证不抛出异常,就应该标记 `noexcept`(绝大多数情况下移动操作只是指针复制,不会抛出异常)。 @@ -443,7 +499,7 @@ struct UserProfile }; ``` -对于封装了独占资源的类(文件句柄、网络连接、锁),应该**禁用拷贝、启用移动**。拷贝没有意义——你不能"复制"一个 TCP 连接或一个互斥锁。但移动是合理的——你可以把连接的控制权从一个对象转移到另一个对象。 +对于封装了独占资源的类(文件句柄、网络连接、锁),应该**禁用拷贝、启用移动**。拷贝没有意义——您不能"复制"一个 TCP 连接或一个互斥锁。但移动是合理的——您可以把连接的控制权从一个对象转移到另一个对象。 ```cpp class NetworkConnection @@ -599,7 +655,7 @@ int main() ## 练习——实现一个支持移动的动态数组 -理论看得再多不如动手写一遍。这个练习要求你实现一个简化版的动态数组类,支持拷贝语义和移动语义。这个类不需要像 `std::vector` 那么复杂,但需要正确处理资源管理。 +理论看得再多不如动手写一遍。这个练习要求您实现一个简化版的动态数组类,支持拷贝语义和移动语义。这个类不需要像 `std::vector` 那么复杂,但需要正确处理资源管理。 要求如下:类名 `SimpleVector`,内部用 `new[]` 分配的 `int` 数组存储数据。支持 `push_back(int)` 添加元素,必要时扩容(可以简单地按 2 倍增长)。实现完整的规则五。移动操作标记 `noexcept`。实现 `size()` 和 `operator[]`。写一段测试代码验证拷贝和移动的行为。 @@ -691,7 +747,7 @@ int main() } ``` -如果你卡住了,可以参考前面 `Buffer` 类的实现——逻辑几乎完全一样。关键点是:析构函数里 `delete[] data_`,移动构造里转移指针并置空源对象的指针,拷贝构造里分配新内存并复制数据,移动赋值里先 `delete[]` 当前数据再接管新数据。 +如果您卡住了,可以参考前面 `Buffer` 类的实现——逻辑几乎完全一样。关键点是:析构函数里 `delete[] data_`,移动构造里转移指针并置空源对象的指针,拷贝构造里分配新内存并复制数据,移动赋值里先 `delete[]` 当前数据再接管新数据。 完整的参考实现: @@ -861,12 +917,24 @@ c (移动构造): 0 1 4 9 16 25 36 49 64 81 a 重新赋值后: 999 ``` -拷贝构造后 `b` 拥有独立的数据副本,修改 `b` 不影响 `a`。移动构造后 `c` 接管了 `a` 的所有数据,`a` 变成空的状态(size=0, capacity=0)。之后 `a` 可以通过移动赋值重新获得一个有效的对象,证明移动后的对象确实处于"有效但未指定"的状态——它可以被安全地赋新值、析构,但你不应该依赖它的当前值。 +拷贝构造后 `b` 拥有独立的数据副本,修改 `b` 不影响 `a`。移动构造后 `c` 接管了 `a` 的所有数据,`a` 变成空的状态(size=0, capacity=0)。之后 `a` 可以通过移动赋值重新获得一个有效的对象,证明移动后的对象确实处于"有效但未指定"的状态——它可以被安全地赋新值、析构,但您不应该依赖它的当前值。 + +## 在线运行 + +在线运行两个示例,亲手验证这篇的关键结论: -## 小结 + -这一篇我们把移动语义从理论推向了实战。STL 容器(特别是 `vector` 的 `push_back`、`emplace_back` 和扩容)是移动语义最直接受益者。`swap` 惯用法利用三次移动操作实现了 O(1) 的交换,是排序、数据结构重组等场景的核心。性能测试显示,对于管理了大块动态内存的类型,移动操作本身的开销几乎为零——拷贝需要逐字节复制全部数据,移动只转移指针。另外我们验证了一个重要细节:`noexcept` 修饰符对 `std::sort` 没有影响,但对 `std::vector` 扩容至关重要——没有 `noexcept` 的移动会让扩容退回拷贝。 + -在自定义类型中,关键是识别你的类管理了什么资源:独占资源(文件句柄、外设、DMA 缓冲区)应该禁止拷贝、允许移动;共享资源可以用智能指针管理;简单的值类型让编译器自动生成就好。移动操作记得标记 `noexcept`,这不仅是一个承诺,更是 `std::vector` 扩容时选择移动而非拷贝的关键条件。练习中的 `SimpleVector` 覆盖了规则五的所有要点——如果你能独立完成它,说明你已经真正掌握了移动语义的核心机制。 +移动语义这一章到这里就讲完了。从右值引用的绑定规则,到移动构造的实现,再到 RVO/NRVO 和完美转发,最后落到这篇的性能实测——希望您以后看到 `std::move`,不再只是照抄,而是清楚它在做什么、为什么这么做。 -到这里,移动语义这一章就全部讲完了。从右值引用的绑定规则到移动构造的实现,从 RVO/NRVO 的编译器优化到完美转发的类型推导链条,再到实战中的性能对比和最佳实践——希望这些内容能让你在以后遇到 `std::move` 的时候不再只是"抄过来用",而是清楚地知道它在做什么、为什么这样做。 +顺着资源所有权这条线,下一章咱们聊智能指针:RAII 会把这一章里那些手动的 `delete`、所有权转移,变成编译器自动管的事。 diff --git a/documents/vol2-modern-features/ch01-smart-pointers/01-raii-deep-dive.md b/documents/vol2-modern-features/ch01-smart-pointers/01-raii-deep-dive.md index 6f9f42448..fb9215ab5 100644 --- a/documents/vol2-modern-features/ch01-smart-pointers/01-raii-deep-dive.md +++ b/documents/vol2-modern-features/ch01-smart-pointers/01-raii-deep-dive.md @@ -24,9 +24,9 @@ title: RAII 深入理解:资源管理的基石 --- # RAII 深入理解:资源管理的基石 -笔者最早学 C++ 的时候,对"资源管理"这件事完全没有概念——new 了一个对象就忘了 delete,打开了一个文件就忘了 fclose,锁住了 mutex 就忘了 unlock。后来项目越来越大,这种"手抖忘释放"的 bug 开始像蟑螂一样,发现一只就意味着角落里还有十只(嗯,事实证明发现的时候我可能还要同时写项目复盘报告咯,哭)。直到有一天笔者认真读了 Bjarne Stroustrup 的书,才明白 C++ 早就为我们准备了一套优雅的解决方案:RAII。 +笔者最早学 C++ 的时候,对"资源管理"这件事完全没有概念——new 了一个对象就忘了 delete,打开了一个文件就忘了 fclose,锁住了 mutex 就忘了 unlock。后来项目越来越大,这种"手抖忘释放"的 bug 开始像蟑螂一样,发现一只就意味着角落里还有十只(嗯,事实证明发现的时候我可能还要同时写项目复盘报告咯,哭)。直到有一天笔者认真读了 Bjarne Stroustrup 的书,才明白 C++ 早就为咱们准备了一套优雅的解决方案:RAII。 -RAII(Resource Acquisition Is Initialization)是 C++ 最核心的资源管理思想,也是现代 C++ 智能指针、锁守卫、文件句柄封装等一切"自动清理"机制的根基。理解了 RAII,你就不只是在"用工具",而是在理解工具背后的设计哲学。今天这篇文章,我们就从机制到实战,把 RAII 彻底搞透。 +RAII(Resource Acquisition Is Initialization)是 C++ 最核心的资源管理思想,也是现代 C++ 智能指针、锁守卫、文件句柄封装等一切"自动清理"机制的根基。理解了 RAII,您就不只是在"用工具",而是在理解工具背后的设计哲学。今天这篇文章,咱们就从机制到实战,把 RAII 彻底搞透。 ## RAII 到底是什么:一句话总结 @@ -94,13 +94,13 @@ void write_log(const char* msg) { } ``` -如果你熟悉 C 语言,对比一下就能感受到差距:在 C 里,每个可能提前返回的分支都要手动 `fclose`,漏了一个就是文件描述符泄漏。而 RAII 把这种"别忘了"的负担交给了编译器——析构函数一定会被调用(只要程序是通过正常控制流退出的,而非直接调用 `std::exit()` 或 `std::abort()`),这不是约定,而是 C++ 语言规范的保证。 +如果您熟悉 C 语言,对比一下就能感受到差距:在 C 里,每个可能提前返回的分支都要手动 `fclose`,漏了一个就是文件描述符泄漏。而 RAII 把这种"别忘了"的负担交给了编译器——析构函数一定会被调用(只要程序是通过正常控制流退出的,而非直接调用 `std::exit()` 或 `std::abort()`),这不是约定,而是 C++ 语言规范的保证。 ## 栈展开:RAII 背后的引擎 RAII 能够工作的关键机制叫**栈展开(stack unwinding)**。当程序离开一个作用域时(无论是因为正常执行到了末尾、遇到了 return 语句、还是因为抛出了异常),C++ 运行时会自动销毁这个作用域中所有已构造的局部对象——从后往前依次调用它们的析构函数。 -这个过程是语言级别的保证,不是某种"最佳实践"或"编译器优化"。我们来用一个具体的例子感受一下栈展开的威力: +这个过程是语言级别的保证,不是某种"最佳实践"或"编译器优化"。咱们来用一个具体的例子感受一下栈展开的威力: ```cpp #include @@ -147,10 +147,10 @@ Tracer(b) 构造 注意看:异常抛出后,`b` 和 `a` 依然被正确析构了——而且顺序是**后构造的先析构**(LIFO)。`c` 没有构造所以也不需要析构。这就是栈展开的全部秘密:不管控制流如何离开作用域,所有已构造的局部对象都会被依次销毁。 -我们可以用代码验证这个保证: +咱们可以用代码验证这个保证: ```cpp -// GCC 13, -O2 -std=c++11 +// GCC 16.1.1, -O2 -std=c++11 #include #include @@ -196,10 +196,10 @@ Caught: Exception thrown ⚠️ 析构函数应保证不抛异常。在异常传播(栈展开)期间若析构函数抛出新异常,程序会调用 `std::terminate()`。C++11 起,用户声明的析构函数默认为 `noexcept(true)`(即使没有显式指定),抛出异常即终止。因此析构函数中应捕获并处理所有异常,或将可能失败的操作移出析构函数,提供显式接口处理错误。 -我们可以验证这个行为: +咱们可以验证这个行为: ```cpp -// GCC 13, -O2 -std=c++11 +// GCC 16.1.1, -O2 -std=c++11 #include #include @@ -224,13 +224,13 @@ int main() { 异常安全是衡量代码在异常发生时行为是否"正确"的标准。C++ 社区定义了三个级别的异常安全保证,从弱到强分别是: -**基本保证(Basic Guarantee)**:异常发生后,程序仍然处于合法状态——没有资源泄漏,所有对象的不变量(invariant)仍然成立。但程序的具体状态可能已经发生了变化(比如一个容器可能丢失了部分元素)。RAII 本身就能帮你自动达到这个级别:只要所有资源都由 RAII 对象管理,栈展开会自动释放它们。 +**基本保证(Basic Guarantee)**:异常发生后,程序仍然处于合法状态——没有资源泄漏,所有对象的不变量(invariant)仍然成立。但程序的具体状态可能已经发生了变化(比如一个容器可能丢失了部分元素)。RAII 本身就能帮您自动达到这个级别:只要所有资源都由 RAII 对象管理,栈展开会自动释放它们。 **强保证(Strong Guarantee)**:异常发生后,程序状态回滚到操作之前的样子——要么操作完全成功,要么完全失败,不存在"半完成"的中间态。实现强保证通常需要 copy-and-swap 惯用法或者事务式的回滚机制。这个保证不是 RAII 独自能做到的,但 RAII 是实现它的基础工具。 **不抛出保证(Nothrow Guarantee)**:操作保证不会抛出异常。析构函数、内存释放操作、某些底层操作(如移动 `int`)属于这一类。这是最强的保证,但不是所有操作都能做到。 -我们来看一个实际的例子:假设我们要写一个配置更新函数,希望它至少达到基本保证: +咱们来看一个实际的例子:假设咱们要写一个配置更新函数,希望它至少达到基本保证: ```cpp #include @@ -267,9 +267,9 @@ private: ## RAII 包装器设计模式 -在实际工程中,我们经常需要为各种类型的资源编写 RAII 包装器。虽然 C++ 标准库已经提供了很多(`std::unique_ptr`、`std::shared_ptr`、`std::lock_guard`、`std::fstream` 等),但总会遇到标准库没覆盖的场景。这时候,掌握 RAII 包装器的设计套路就非常重要。 +在实际工程中,咱们经常需要为各种类型的资源编写 RAII 包装器。虽然 C++ 标准库已经提供了很多(`std::unique_ptr`、`std::shared_ptr`、`std::lock_guard`、`std::fstream` 等),但总会遇到标准库没覆盖的场景。这时候,掌握 RAII 包装器的设计套路就非常重要。 -一个规范的 RAII 包装器通常遵循以下设计模式:构造函数负责获取资源(如果获取失败则抛异常或进入无效状态),析构函数负责释放资源(必须 noexcept),禁止拷贝(防止双重释放),允许移动(支持所有权转移)。我们再来看一个网络 socket 的例子: +一个规范的 RAII 包装器通常遵循以下设计模式:构造函数负责获取资源(如果获取失败则抛异常或进入无效状态),析构函数负责释放资源(必须 noexcept),禁止拷贝(防止双重释放),允许移动(支持所有权转移)。咱们再来看一个网络 socket 的例子: ```cpp #include @@ -321,7 +321,7 @@ private: }; ``` -你会发现这个模式和之前的 `FileHandle` 几乎一模一样——获取、释放、禁止拷贝、允许移动,这就是 RAII 包装器的"四件套"。掌握了这个模式,无论是封装数据库连接、OpenGL 纹理、SDL 窗口还是 CUDA stream,套路都是一样的。 +您会发现这个模式和之前的 `FileHandle` 几乎一模一样——获取、释放、禁止拷贝、允许移动,这就是 RAII 包装器的"四件套"。掌握了这个模式,无论是封装数据库连接、OpenGL 纹理、SDL 窗口还是 CUDA stream,套路都是一样的。 ## 互斥锁的 RAII:为什么永远不要手动 unlock @@ -360,7 +360,7 @@ void good_increment(std::mutex& m, int& counter) { RAII 的思想同样适用于嵌入式开发。在嵌入式系统中,"资源"不再是文件描述符或 mutex,而是 GPIO 引脚、SPI 片选线、DMA 通道、I2C 总线等硬件资源。忘记释放这些资源的后果可能比桌面程序更严重——外设卡死、功耗升高、甚至整个系统不稳定。 -先看一个 GPIO 引脚管理的例子。我们用 RAII 把引脚的生命周期和对象的生命周期绑定起来:构造时初始化引脚,析构时恢复为安全状态(通常是高阻输入模式)。 +先看一个 GPIO 引脚管理的例子。咱们用 RAII 把引脚的生命周期和对象的生命周期绑定起来:构造时初始化引脚,析构时恢复为安全状态(通常是高阻输入模式)。 ```cpp // gpio_raii.h @@ -466,7 +466,7 @@ void read_sensor(SpiBus& spi, uint8_t cs) { ## 练习:设计一个通用的 ScopeGuard 类 -作为本篇的收尾练习,我们来设计一个通用的 `ScopeGuard` 类。它的设计目标是:用最小的代价,把任意"退出时执行的清理动作"包装成 RAII 对象。这个类在实际工程中非常有用——当你有一些"不适合封装成专门的 RAII 类、但又需要保证退出时执行"的操作时,`ScopeGuard` 就是最佳选择。 +作为本篇的收尾练习,咱们来设计一个通用的 `ScopeGuard` 类。它的设计目标是:用最小的代价,把任意"退出时执行的清理动作"包装成 RAII 对象。这个类在实际工程中非常有用——当您有一些"不适合封装成专门的 RAII 类、但又需要保证退出时执行"的操作时,`ScopeGuard` 就是最佳选择。 ```cpp #include @@ -532,14 +532,14 @@ void complex_operation() { } ``` -这个 `ScopeGuard` 的实现其实和 Andrei Alexandrescu 在 2000 年代提出的经典方案一脉相承。在后面的章节中,我们会看到 C++ 标准是如何将这个模式标准化为 `std::scope_exit` / `std::scope_fail` 的,以及 Boost.Scope 库是如何提供更丰富的功能的。 +这个 `ScopeGuard` 的实现其实和 Andrei Alexandrescu 在 2000 年代提出的经典方案一脉相承。在后面的章节中,咱们会看到 C++ 标准是如何将这个模式标准化为 `std::scope_exit` / `std::scope_fail` 的,以及 Boost.Scope 库是如何提供更丰富的功能的。 ## 验证边界情况:何时析构函数不会被调用 -为了完整理解 RAII 的适用边界,我们需要明确哪些情况下析构函数不会被调用。这有助于我们在设计系统时做出正确的决策: +为了完整理解 RAII 的适用边界,咱们需要明确哪些情况下析构函数不会被调用。这有助于咱们在设计系统时做出正确的决策: ```cpp -// GCC 13, -O2 -std=c++11 +// GCC 16.1.1, -O2 -std=c++11 #include #include @@ -562,6 +562,13 @@ void test_exit() { Tracer t("exit"); std::exit(0); // 析构函数不会被调用! } + +int main() { + std::cout << "Normal case:\n"; + test_normal_return(); + std::cout << "\nstd::exit() case:\n"; + test_exit(); // 内部构造 Tracer("exit") 后 std::exit,进程直接终止 +} ``` 运行结果: @@ -573,18 +580,13 @@ Tracer(normal) constructed std::exit() case: Tracer(exit) constructed -(程序直接终止,没有析构输出) ``` -这个验证告诉我们:RAII 的保证仅适用于**正常控制流**(包括异常处理)。如果程序通过 `std::exit()`、`std::abort()`、`_exit()` 或信号处理等方式非正常退出,析构函数不会执行。这也是为什么现代 C++ 推荐使用异常而非 `std::exit()` 的原因之一——异常能保证栈展开和资源清理,而 `std::exit()` 不能。 - -## 小结 - -RAII 是 C++ 资源管理的基石。它的核心机制——构造时获取资源、析构时释放资源——利用了 C++ 的栈展开保证,使得资源释放不再依赖程序员的记忆力,而是由语言规范来保证。无论控制流如何离开作用域(正常返回、提前 return、异常传播),所有 RAII 对象都会被正确销毁。 +`std::exit()` 之后没有任何 `~Tracer(exit) destroyed`——进程在 `test_exit` 里直接终止,`Tracer("exit")` 的析构函数根本没机会执行。 -异常安全的三个级别(基本保证、强保证、不抛出保证)给了我们衡量代码质量的标尺。只要所有资源都通过 RAII 管理,基本异常安全几乎是"免费"获得的。而 RAII 包装器的设计模式也是高度一致的——获取资源、禁止拷贝、允许移动、noexcept 析构——掌握这个"四件套",就能为任何类型的资源编写安全的封装。 +这个验证告诉咱们:RAII 的保证仅适用于**正常控制流**(包括异常处理)。如果程序通过 `std::exit()`、`std::abort()`、`_exit()` 或信号处理等方式非正常退出,析构函数不会执行。这也是为什么现代 C++ 推荐使用异常而非 `std::exit()` 的原因之一——异常能保证栈展开和资源清理,而 `std::exit()` 不能。 -下一篇我们要深入探讨的 `unique_ptr`,正是 RAII 思想在智能指针领域的最直接体现:零开销的独占所有权管理。理解了 RAII,再去理解 `unique_ptr` 就会非常自然。 +下一篇聊的 `unique_ptr`,就是 RAII 思想在智能指针上最直接的落地:零开销的独占所有权。RAII 这套底子打好了,`unique_ptr` 看起来会非常自然。 ## 参考资源 diff --git a/documents/vol2-modern-features/ch01-smart-pointers/02-unique-ptr.md b/documents/vol2-modern-features/ch01-smart-pointers/02-unique-ptr.md index 1c6a2293d..168984800 100644 --- a/documents/vol2-modern-features/ch01-smart-pointers/02-unique-ptr.md +++ b/documents/vol2-modern-features/ch01-smart-pointers/02-unique-ptr.md @@ -24,9 +24,9 @@ title: unique_ptr 详解:独占所有权的零开销智能指针 --- # unique_ptr 详解:独占所有权的零开销智能指针 -在上一篇我们聊了 RAII——C++ 资源管理的基石。现在我们来看 RAII 思想在智能指针领域最直接的体现:`std::unique_ptr`。这个类的设计哲学可以用一句话概括:**一个对象,一个主人,零开销**。它不搞什么引用计数、不做原子操作、不分配额外的控制块——你给它一个对象,它替你管好;你离开作用域,它替你删掉。就这么简单。(btw,这玩意怎么面试这么爱考) +在上一篇咱们聊了 RAII——C++ 资源管理的基石。现在咱们来看 RAII 思想在智能指针领域最直接的体现:`std::unique_ptr`。这个类的设计哲学可以用一句话概括:**一个对象,一个主人,零开销**。它不搞什么引用计数、不做原子操作、不分配额外的控制块——您给它一个对象,它替您管好;您离开作用域,它替您删掉。就这么简单。(btw,这玩意怎么面试这么爱考) -但简单不代表肤浅。`unique_ptr` 背后涉及的所有权语义、移动语义、自定义删除器、空基类优化(EBO)等话题,每一条都值得深入理解。今天我们就把这些全部拆开来看。 +但简单不代表肤浅。`unique_ptr` 背后涉及的所有权语义、移动语义、自定义删除器、空基类优化(EBO)等话题,每一条都值得深入理解。今天咱们就把这些全部拆开来看。 ## 独占所有权:为什么不能拷贝 @@ -68,7 +68,7 @@ p2->value: 42 ~Widget(42) 析构 ``` -这个"不可拷贝、可移动"的设计完美映射了现实中的所有权转移——就像你把一把钥匙交给别人,你自己就不再拥有那把钥匙了。在代码层面,`std::move` 把 `p1` 内部的裸指针转移给了 `p2`,然后把 `p1` 置空。整个过程没有额外的内存分配,也没有引用计数的开销。 +这个"不可拷贝、可移动"的设计完美映射了现实中的所有权转移——就像您把一把钥匙交给别人,您自己就不再拥有那把钥匙了。在代码层面,`std::move` 把 `p1` 内部的裸指针转移给了 `p2`,然后把 `p1` 置空。整个过程没有额外的内存分配,也没有引用计数的开销。 ## make_unique vs new:为什么 C++14 要加这个函数 @@ -101,7 +101,7 @@ auto p1 = std::unique_ptr(new Widget(42)); // 啰嗦,且容易忘写 auto p2 = std::make_unique(42); // 简洁,不可能忘记管理 ``` -⚠️ `make_unique` 有一个限制:它不支持自定义删除器。如果你需要自定义删除器(比如管理 `FILE*` 或 `malloc` 分配的内存),就必须直接构造 `unique_ptr`。这个问题我们会在后面的"自定义删除器"章节详细讨论。 +⚠️ `make_unique` 有一个限制:它不支持自定义删除器。如果您需要自定义删除器(比如管理 `FILE*` 或 `malloc` 分配的内存),就必须直接构造 `unique_ptr`。这个问题咱们会在后面的"自定义删除器"章节详细讨论。 ## 移动语义与 unique_ptr 的深层关系 @@ -145,8 +145,6 @@ int main() { 这里有一个重要的细节:`unique_ptr` 的移动构造函数和移动赋值运算符都标记为 `noexcept`。这对 `std::vector` 的行为有直接影响——当 vector 扩容时,如果元素的移动构造是 `noexcept` 的,vector 会优先使用移动;否则会退化为拷贝(但 `unique_ptr` 不可拷贝,所以必须移动)。因此 `noexcept` 的移动操作是 `unique_ptr` 能够安全存入容器的关键保证。 -你可以运行 `code/volumn_codes/vol2/ch01-smart-pointers/test_vector_noexcept.cpp` 来验证这一点。该示例展示了 vector 在扩容时如何安全地移动 `unique_ptr` 管理的对象,并验证所有元素在扩容后仍然有效。 - ## unique_ptr:数组版本 `unique_ptr` 有一个针对数组的偏特化版本 `unique_ptr`,它在析构时会调用 `delete[]` 而不是 `delete`。 @@ -158,7 +156,7 @@ arr[1] = 17; // 析构时自动 delete[] ``` -不过说实话,在 C++ 中需要手动管理动态数组的场景已经非常少了。如果你需要一个固定大小的数组,用 `std::array` 或 `std::vector` 几乎总是更好的选择。`unique_ptr` 主要用于对接那些返回动态分配数组的 C API,比如: +不过说实话,在 C++ 中需要手动管理动态数组的场景已经非常少了。如果您需要一个固定大小的数组,用 `std::array` 或 `std::vector` 几乎总是更好的选择。`unique_ptr` 主要用于对接那些返回动态分配数组的 C API,比如: ```cpp // 假设某个 C API 返回 malloc 分配的数组 @@ -176,7 +174,7 @@ buffer[0] = 42; ## 自定义删除器基础 -`unique_ptr` 的第二个模板参数就是删除器的类型。默认是 `std::default_delete`,内部就是简单的 `delete ptr`。但你可以替换为任何可调用对象——函数指针、lambda、函数对象,只要是 `void operator()(T*)` 的签名就行。 +`unique_ptr` 的第二个模板参数就是删除器的类型。默认是 `std::default_delete`,内部就是简单的 `delete ptr`。但您可以替换为任何可调用对象——函数指针、lambda、函数对象,只要是 `void operator()(T*)` 的签名就行。 最常见的场景是管理 C API 返回的资源: @@ -199,7 +197,7 @@ auto make_closer = []() { }; ``` -函数对象(functor)作为删除器也是常见的选择,尤其是当你想让删除器类型有名字的时候: +函数对象(functor)作为删除器也是常见的选择,尤其是当您想让删除器类型有名字的时候: ```cpp struct FreeDeleter { @@ -214,11 +212,11 @@ auto buf = std::unique_ptr( ); ``` -关于自定义删除器的更深入讨论(有状态删除器、EBO 优化、`shared_ptr` 中的删除器等),我们会在"自定义删除器与侵入式引用计数"那篇中专门展开。 +关于自定义删除器的更深入讨论(有状态删除器、EBO 优化、`shared_ptr` 中的删除器等),咱们会在"自定义删除器与侵入式引用计数"那篇中专门展开。 ## 零开销证明:sizeof 与汇编分析 -`unique_ptr` 常被宣传为"零开销抽象",但这不是营销口号——我们可以用实际代码来验证。首先是 `sizeof` 对比: +`unique_ptr` 常被宣传为"零开销抽象",但这不是营销口号——咱们可以用实际代码来验证。首先是 `sizeof` 对比: ```cpp #include @@ -228,44 +226,36 @@ struct EmptyDeleter { void operator()(int* p) noexcept { delete p; } }; +// 有状态删除器:带数据成员,没法 EBO +struct StatefulDeleter { + int extra; + void operator()(int* p) noexcept { delete p; } +}; + int main() { - std::cout << "sizeof(int*): " << sizeof(int*) << "\n"; - std::cout << "sizeof(unique_ptr): " << sizeof(std::unique_ptr) << "\n"; - std::cout << "sizeof(unique_ptr): " - << sizeof(std::unique_ptr) << "\n"; - - // 函数指针作为删除器——有额外开销 - std::cout << "sizeof(unique_ptr): " - << sizeof(std::unique_ptr) << "\n"; + std::cout << "sizeof(int*): " << sizeof(int*) << "\n"; + std::cout << "sizeof(unique_ptr): " << sizeof(std::unique_ptr) << "\n"; + std::cout << "sizeof(unique_ptr): " << sizeof(std::unique_ptr) << "\n"; + std::cout << "sizeof(unique_ptr): " << sizeof(std::unique_ptr) << "\n"; + std::cout << "sizeof(unique_ptr): " << sizeof(std::unique_ptr) << "\n"; } ``` -在 64 位平台上的典型输出: - -```text -sizeof(int*): 8 -sizeof(unique_ptr): 8 -sizeof(unique_ptr): 8 -sizeof(unique_ptr): 16 -``` - -默认删除器和无状态函数对象的 `unique_ptr` 和裸指针大小完全一样——8 字节。这就是空基类优化(EBO)的功劳:`unique_ptr` 内部通常继承自删除器类型,当删除器是空类(没有数据成员)时,编译器会把它的大小优化为 0,因此 `unique_ptr` 只需要存储那一个裸指针。 - -你可以运行 `code/volumn_codes/vol2/ch01-smart-pointers/test_ebo_sizeof.cpp` 来验证这一点。在 x86_64-linux 平台(g++ 15.2.1)上的典型输出: +在 64 位平台(GCC 16.1.1,x86_64)上的输出: ```text -sizeof(int*): 8 bytes -sizeof(unique_ptr): 8 bytes -sizeof(unique_ptr): 8 bytes -sizeof(unique_ptr): 16 bytes -sizeof(unique_ptr): 16 bytes +sizeof(int*): 8 +sizeof(unique_ptr): 8 +sizeof(unique_ptr): 8 +sizeof(unique_ptr): 16 +sizeof(unique_ptr): 16 ``` -可以看到,使用无状态删除器时 `unique_ptr` 的大小与裸指针完全相同,而使用函数指针或有状态删除器时会增加额外开销。 +默认删除器和无状态函数对象的 `unique_ptr` 和裸指针一样大——8 字节。这是空基类优化(EBO)的功劳:`unique_ptr` 内部通常继承自删除器类型,当删除器是空类(没有数据成员)时,编译器把它的大小优化为 0,`unique_ptr` 就只需要存那一个裸指针。一旦删除器带了状态——函数指针要存地址、`StatefulDeleter` 要存 `extra`——EBO 用不上,大小就涨到 16 字节。 而使用函数指针作为删除器时,`unique_ptr` 需要额外存储一个函数指针,所以大小翻倍——16 字节。这就是"零开销"的前提条件:**删除器必须是无状态的**。 -我们再从汇编的角度验证。下面是一个简单的例子: +咱们再从汇编的角度验证。下面是一个简单的例子: ```cpp // 用 unique_ptr 管理 int @@ -283,14 +273,14 @@ int use_raw_ptr() { } ``` -在开启优化(`-O2`)后,这两个函数生成的汇编代码几乎完全相同。查看 `code/volumn_codes/vol2/ch01-smart-pointers/test_assembly_optimization.cpp` 并用 `g++ -std=c++17 -O2 -S` 编译,你会看到两个函数都生成: +在开启优化(`-O2`)后,这两个函数生成的汇编几乎完全相同。把上面两个函数存成文件,用 `g++ -std=c++17 -O2 -S` 编译,会看到它们都生成: ```asm movl $42, %eax ret ``` -编译器把 `unique_ptr` 的构造和析构直接内联优化掉了,连 `new` 和 `delete` 都被消除了(因为对象的生命周期很短且没有副作用)。这就是 C++ 抽象的威力:你在源码层面获得了安全性和可读性,但在机器码层面没有付出任何代价。 +编译器把 `unique_ptr` 的构造和析构直接内联优化掉了,连 `new` 和 `delete` 都被消除了(因为对象的生命周期很短且没有副作用)。这就是 C++ 抽象的威力:您在源码层面获得了安全性和可读性,但在机器码层面没有付出任何代价。 ## PIMPL 惯用法:隐藏实现细节 @@ -356,25 +346,9 @@ void Widget::do_something() { PIMPL 的好处是显而易见的:修改 `Impl` 的定义(比如添加成员、修改方法)只需要重新编译 `widget.cpp`,所有包含 `widget.h` 的文件都不需要重新编译。对于大型项目来说,这能显著缩短编译时间。 -完整的 PIMPL 示例代码可以在 `code/volumn_codes/vol2/ch01-smart-pointers/` 中找到: +把 `widget.h`、`widget.cpp` 和用到 `Widget` 的用户代码分开编译再链接,就能看到 PIMPL 的效果:改 `Impl` 结构体只需要重编 `widget.cpp`,所有只包含 `widget.h` 的文件都不用重新编译。 -- `pimpl_widget.h` - 公共接口头文件 -- `pimpl_widget.cpp` - 实现(包含 `Widget::Impl` 的完整定义) -- `pimpl_user.cpp` - 用户代码示例 - -你可以这样编译和运行: - -```bash -cd code/volumn_codes/vol2/ch01-smart-pointers -g++ -std=c++17 -c pimpl_widget.cpp -o pimpl_widget.o -g++ -std=c++17 -c pimpl_user.cpp -o pimpl_user.o -g++ -std=c++17 pimpl_widget.o pimpl_user.o -o test_pimpl -./test_pimpl -``` - -这个示例展示了 PIMPL 模式的关键特性:公共接口完全不暴露实现细节,修改 `Impl` 结构体不需要重新编译用户代码。 - -⚠️ PIMPL 使用 `unique_ptr` 时有几个注意点。首先,`~Widget()` 必须在实现文件中定义——因为析构时需要 `Impl` 是完整类型,而头文件中只有前向声明。其次,移动构造和移动赋值也应该在实现文件中 `= default`,原因相同。如果你在头文件中 `= default` 它们,编译器会尝试在头文件中实例化 `unique_ptr` 的析构,而此时 `Impl` 不完整,会导致编译错误。 +⚠️ PIMPL 使用 `unique_ptr` 时有几个注意点。首先,`~Widget()` 必须在实现文件中定义——因为析构时需要 `Impl` 是完整类型,而头文件中只有前向声明。其次,移动构造和移动赋值也应该在实现文件中 `= default`,原因相同。如果您在头文件中 `= default` 它们,编译器会尝试在头文件中实例化 `unique_ptr` 的析构,而此时 `Impl` 不完整,会导致编译错误。 ## 工厂函数返回 unique_ptr @@ -425,7 +399,7 @@ void application() { } ``` -这种模式还有一个妙处:工厂函数返回 `unique_ptr`(基类指针),但实际创建的是 `ConsoleLogger` 或 `FileLogger`(派生类对象)。只要 `Logger` 有虚析构函数(我们确实声明了 `virtual ~Logger() = default`),多态析构就是安全的。 +这种模式还有一个妙处:工厂函数返回 `unique_ptr`(基类指针),但实际创建的是 `ConsoleLogger` 或 `FileLogger`(派生类对象)。只要 `Logger` 有虚析构函数(咱们确实声明了 `virtual ~Logger() = default`),多态析构就是安全的。 值得注意的是,返回 `unique_ptr` 并不会带来任何性能损失。在现代编译器中,返回值优化(RVO)和移动语义会确保整个过程零拷贝——工厂函数中创建的 `unique_ptr` 直接"搬到"了调用者的变量中。 @@ -440,7 +414,7 @@ void application() { `unique_ptr` 提供了几个手动管理所有权的方法,理解它们的区别非常重要。 -`get()` 返回内部裸指针但不转移所有权。这在你需要把指针传给某个只使用但不拥有的函数时很有用: +`get()` 返回内部裸指针但不转移所有权。这在您需要把指针传给某个只使用但不拥有的函数时很有用: ```cpp void print_widget(const Widget* w); @@ -449,7 +423,7 @@ auto p = std::make_unique(42); print_widget(p.get()); // 传给只读函数,p 仍然拥有对象 ``` -`release()` 放弃所有权并返回裸指针——`unique_ptr` 变空了,但对象不会被删除。这相当于"我把对象交给你了,你自己负责释放": +`release()` 放弃所有权并返回裸指针——`unique_ptr` 变空了,但对象不会被删除。这相当于"我把对象交给您了,您自己负责释放": ```cpp auto p = std::make_unique(42); @@ -458,7 +432,7 @@ Widget* raw = p.release(); // p 变为 nullptr,raw 指向对象 delete raw; // 你必须手动释放 ``` -⚠️ `release()` 是一个需要谨慎使用的操作。一旦你调用了它,就回到了裸指针的世界——如果你忘记 `delete`,就会内存泄漏。大多数情况下,使用 `std::move()` 转移所有权给另一个 `unique_ptr` 是更好的选择。 +⚠️ `release()` 是一个需要谨慎使用的操作。一旦您调用了它,就回到了裸指针的世界——如果您忘记 `delete`,就会内存泄漏。大多数情况下,使用 `std::move()` 转移所有权给另一个 `unique_ptr` 是更好的选择。 `reset()` 替换当前管理的对象。如果不传参数,就简单地释放当前对象并置空: @@ -498,13 +472,7 @@ UniqueDmaBuffer allocate_dma_buffer(size_t size) { 这种写法的好处是,任何 return path——不管是正常返回、错误返回还是异常——都会正确释放 DMA 缓冲区。在复杂的驱动代码中,这种自动管理能显著降低 bug 率。 -## 小结 - -`unique_ptr` 是现代 C++ 中表达独占所有权的首选工具。它的核心设计——不可拷贝、可移动、RAII 管理生命周期——精确地映射了"一个对象只有一个主人"的语义。通过空基类优化(EBO),默认删除器的 `unique_ptr` 在内存和运行时开销上与裸指针完全一致,是真正的零开销抽象。 - -我们今天覆盖了 `unique_ptr` 的核心用法:`make_unique` 的异常安全性、移动语义与容器兼容性、数组版本、自定义删除器基础、PIMPL 惯用法、工厂函数模式。这些都是日常工程中最高频的使用场景。 - -下一篇我们将转向 `shared_ptr`——另一种完全不同的所有权模型:共享所有权。准备好了吗?真正的复杂性才刚刚开始。 +下一篇转向 `shared_ptr`——完全不同的所有权模型:共享所有权。真正的复杂性从那里才开始。 ## 参考资源 diff --git a/documents/vol2-modern-features/ch01-smart-pointers/03-shared-ptr.md b/documents/vol2-modern-features/ch01-smart-pointers/03-shared-ptr.md index b065ff57b..69b929022 100644 --- a/documents/vol2-modern-features/ch01-smart-pointers/03-shared-ptr.md +++ b/documents/vol2-modern-features/ch01-smart-pointers/03-shared-ptr.md @@ -26,7 +26,7 @@ title: shared_ptr 详解:共享所有权与引用计数 --- # shared_ptr 详解:共享所有权与引用计数 -上一篇我们聊了 `unique_ptr`——独占所有权的零开销智能指针。但现实世界中的资源并不总是"一主独占"的。有时候,一个对象确实需要被多个模块共同持有、共同管理——比如一个配置对象被多个子系统读取,一个网络连接被多个任务共享,一个缓存条目被多个消费者访问。这时候,`unique_ptr` 的"独占"语义就显得不够用了。 +上一篇咱们聊了 `unique_ptr`——独占所有权的零开销智能指针。但现实世界中的资源并不总是"一主独占"的。有时候,一个对象确实需要被多个模块共同持有、共同管理——比如一个配置对象被多个子系统读取,一个网络连接被多个任务共享,一个缓存条目被多个消费者访问。这时候,`unique_ptr` 的"独占"语义就显得不够用了。 `std::shared_ptr` 就是为这种场景设计的。它的核心思想是**引用计数**:每多一个 `shared_ptr` 指向对象,计数就加一;每少一个,计数就减一;当计数归零时,对象被自动销毁。听起来简单优雅,但背后的实现细节——控制块、原子操作、内存分配策略——远比想象中复杂。 @@ -84,9 +84,9 @@ Disconnected from 192.168.1.1:8080 理解 `shared_ptr` 的性能特征,必须先理解它的内部结构。一个 `shared_ptr` 实际上包含两个指针:一个指向被管理的对象,另一个指向控制块(control block)。 -控制块是一个在堆上分配的数据结构,包含强引用计数(`shared_ptr` 的数量)、弱引用计数(`weak_ptr` 的数量)、自定义删除器(如果有的话)、自定义分配器(如果有的话)。当你用 `std::make_shared` 创建 `shared_ptr` 时,对象和控制块会被放在同一个内存块中(一次分配);而用 `std::shared_ptr(new T)` 创建时,对象和控制块是两次独立的分配。 +控制块是一个在堆上分配的数据结构,包含强引用计数(`shared_ptr` 的数量)、弱引用计数(`weak_ptr` 的数量)、自定义删除器(如果有的话)、自定义分配器(如果有的话)。当您用 `std::make_shared` 创建 `shared_ptr` 时,对象和控制块会被放在同一个内存块中(一次分配);而用 `std::shared_ptr(new T)` 创建时,对象和控制块是两次独立的分配。 -我们用一个简化的示意图来理解: +咱们用一个简化的示意图来理解: ![shared_ptr 内部结构示意](./03-shared-ptr-structure.drawio) @@ -96,7 +96,7 @@ Disconnected from 192.168.1.1:8080 前面提到,`make_shared` 把对象和控制块放在一个连续的内存块里。这带来了三个显著的好处。 -首先是**更少的堆分配次数**——从两次减为一次。在性能敏感的代码中,堆分配是昂贵操作(通常涉及锁、遍历空闲链表等),减少分配次数总是好的。你可以通过 `code/volumn_codes/vol2/ch01-smart-pointers/verify_shared_ptr_layout.cpp` 验证 `make_shared` 确实只执行一次分配。 +首先是**更少的堆分配次数**——从两次减为一次。在性能敏感的代码中,堆分配是昂贵操作(通常涉及锁、遍历空闲链表等),减少分配次数总是好的。写个小程序打印分配地址,就能看到 `make_shared` 只 `new` 一次。 其次是**更好的缓存局部性**。对象和控制块在同一个内存块里,CPU 缓存行可能同时命中两者。而两次独立分配的内存块可能在物理上相距很远,导致更多的缓存未命中。 @@ -114,13 +114,13 @@ std::cout << "sizeof(shared_ptr): " << sizeof(p1) << "\n"; // 16 (64-bit) std::cout << "sizeof(unique_ptr): " << sizeof(std::unique_ptr) << "\n"; // 8 ``` -⚠️ `make_shared` 也有一个不太为人知的缺点:由于对象和控制块共享同一个内存块,当所有 `shared_ptr` 都被销毁时(强引用归零),对象会被析构,但控制块的内存不会立即释放——必须等到所有 `weak_ptr` 也都销毁(弱引用归零)后,整个内存块才会被回收。如果对象很大且有 `weak_ptr` 仍在使用,可能会造成内存占用比预期更高的现象。如果你预期会有 `weak_ptr` 长期存在,可以考虑使用 `std::shared_ptr(new T)` 来让对象的内存独立于控制块,这样强引用归零时对象内存就能立即释放。 +⚠️ `make_shared` 也有一个不太为人知的缺点:由于对象和控制块共享同一个内存块,当所有 `shared_ptr` 都被销毁时(强引用归零),对象会被析构,但控制块的内存不会立即释放——必须等到所有 `weak_ptr` 也都销毁(弱引用归零)后,整个内存块才会被回收。如果对象很大且有 `weak_ptr` 仍在使用,可能会造成内存占用比预期更高的现象。如果您预期会有 `weak_ptr` 长期存在,可以考虑使用 `std::shared_ptr(new T)` 来让对象的内存独立于控制块,这样强引用归零时对象内存就能立即释放。 ## 引用计数的原子操作与线程安全 -`shared_ptr` 的引用计数使用原子操作来保证线程安全。这意味着在多线程环境下,你可以安全地拷贝和销毁 `shared_ptr` 本身(引用计数的增减是原子的),但**被管理对象的访问并不受保护**——如果你有多个线程同时读写对象本身,仍然需要自行加锁。 +`shared_ptr` 的引用计数使用原子操作来保证线程安全。这意味着在多线程环境下,您可以安全地拷贝和销毁 `shared_ptr` 本身(引用计数的增减是原子的),但**被管理对象的访问并不受保护**——如果您有多个线程同时读写对象本身,仍然需要自行加锁。 -这是一个常见的误解:很多人以为 `shared_ptr` 提供了"对象的线程安全",但实际上它只保证了"引用计数的线程安全"。我们可以用 cppreference 的描述来精确理解:`shared_ptr` 的控制块是线程安全的——多个线程可以同时操作不同的 `shared_ptr` 实例(即使它们指向同一个对象),不需要外部同步。但同一个 `shared_ptr` 实例不能被多个线程同时读写(需要加锁)。被管理对象的并发访问需要自行保证安全。 +这是一个常见的误解:很多人以为 `shared_ptr` 提供了"对象的线程安全",但实际上它只保证了"引用计数的线程安全"。咱们可以用 cppreference 的描述来精确理解:`shared_ptr` 的控制块是线程安全的——多个线程可以同时操作不同的 `shared_ptr` 实例(即使它们指向同一个对象),不需要外部同步。但同一个 `shared_ptr` 实例不能被多个线程同时读写(需要加锁)。被管理对象的并发访问需要自行保证安全。 ```cpp #include @@ -147,13 +147,13 @@ void demo_thread_safety() { } ``` -从性能角度看,每次拷贝或析构 `shared_ptr` 都会产生一次原子操作(通常是 `fetch_add` 或 `fetch_sub`)。原子操作在单核系统上开销很小(可能只是一条特殊的 CPU 指令),但在多核系统上会引发缓存一致性协议的开销(cache line bouncing)。如果你的代码频繁创建和销毁 `shared_ptr`(比如在热循环中),这个开销可能会变得非常显著。你可以通过 `code/volumn_codes/vol2/ch01-smart-pointers/verify_shared_ptr_performance.cpp` 验证单线程和多线程场景下的开销差异。 +从性能角度看,每次拷贝或析构 `shared_ptr` 都会产生一次原子操作(通常是 `fetch_add` 或 `fetch_sub`)。原子操作在单核系统上开销很小(可能只是一条特殊的 CPU 指令),但在多核系统上会引发缓存一致性协议的开销(cache line bouncing)。如果您的代码频繁创建和销毁 `shared_ptr`(比如在热循环中),这个开销可能会变得非常显著。 引用计数递减时的逻辑尤其值得关注。当 `fetch_sub` 返回 1(意味着这是最后一个 `shared_ptr`)时,需要销毁对象。主流实现(如 GNU libstdc++)使用 `memory_order_acq_rel` 来保证所有之前的写操作对销毁代码可见,并在销毁前插入一个 `acquire` fence。这些内存屏障在 x86 上开销不大(x86 本身就有强内存序),但在 ARM 等弱序架构上可能会导致流水线刷新。 ## shared_ptr 的性能开销分析 -我们来做一个直观的对比,把 `shared_ptr`、`unique_ptr` 和裸指针的开销放在一张表里: +咱们来做一个直观的对比,把 `shared_ptr`、`unique_ptr` 和裸指针的开销放在一张表里: | 维度 | 裸指针 | unique_ptr | shared_ptr | |------|--------|------------|------------| @@ -163,7 +163,7 @@ void demo_thread_safety() { | 析构开销 | 无 | delete | 原子 fetch_sub + 可能 delete | | 线程安全 | 无 | 无 | 引用计数安全,对象不安全 | -从这张表可以清楚地看到,`shared_ptr` 在每一个维度上都比 `unique_ptr` 更重。这不是说 `shared_ptr` 不好——它在共享所有权的场景下是正确的设计选择——但你应该在确实需要共享所有权时才使用它,而不是"为了方便到处用 `shared_ptr`"。 +从这张表可以清楚地看到,`shared_ptr` 在每一个维度上都比 `unique_ptr` 更重。这不是说 `shared_ptr` 不好——它在共享所有权的场景下是正确的设计选择——但您应该在确实需要共享所有权时才使用它,而不是"为了方便到处用 `shared_ptr`"。 在实际项目中,笔者见过不少代码库把几乎所有对象都用 `shared_ptr` 管理,结果就是引用计数到处飞、性能无法优化、循环引用问题频出。更好的做法是在设计阶段就明确所有权关系:大多数资源用 `unique_ptr` 管理,只在确实需要共享的少数地方使用 `shared_ptr`,并通过引用(`T&`)或裸指针(`T*`,不持有所有权)来传递非拥有访问。 @@ -176,7 +176,7 @@ template shared_ptr(const shared_ptr& r, T* ptr) noexcept; ``` -这个构造函数创建一个新的 `shared_ptr`,它共享 `r` 的所有权(即引用计数与 `r` 共享),但 `get()` 返回的是 `ptr` 而不是 `r.get()`。简单说就是:**它让你持有同一个对象的"一部分",而不需要单独管理那部分的生命周期**。 +这个构造函数创建一个新的 `shared_ptr`,它共享 `r` 的所有权(即引用计数与 `r` 共享),但 `get()` 返回的是 `ptr` 而不是 `r.get()`。简单说就是:**它让您持有同一个对象的"一部分",而不需要单独管理那部分的生命周期**。 最常见的用途是访问对象的成员: @@ -199,7 +199,7 @@ void connect(const std::shared_ptr& host) { } ``` -这个特性在实现"指向容器元素的智能指针"时特别有用——比如你想返回一个指向 `vector` 中某个元素的 `shared_ptr`,但又不想让调用者持有整个 `vector` 的 `shared_ptr`。通过 aliasing constructor,你可以返回一个只暴露元素类型的 `shared_ptr`,而底层仍然由容器的 `shared_ptr` 管理生命周期。 +这个特性在实现"指向容器元素的智能指针"时特别有用——比如您想返回一个指向 `vector` 中某个元素的 `shared_ptr`,但又不想让调用者持有整个 `vector` 的 `shared_ptr`。通过 aliasing constructor,您可以返回一个只暴露元素类型的 `shared_ptr`,而底层仍然由容器的 `shared_ptr` 管理生命周期。 ## enable_shared_from_this:在成员函数中获取 shared_ptr @@ -240,62 +240,62 @@ void session_demo() { } ``` -⚠️ 使用 `shared_from_this()` 有一个前提条件:对象必须已经被一个 `shared_ptr` 管理。如果你在栈上创建对象或用裸指针管理,调用 `shared_from_this()` 会导致未定义行为。此外,构造函数中不能调用 `shared_from_this()`——因为此时 `shared_ptr` 还没有完成构造。 +⚠️ 使用 `shared_from_this()` 有一个前提条件:对象必须已经被一个 `shared_ptr` 管理。如果您在栈上创建对象或用裸指针管理,调用 `shared_from_this()` 会导致未定义行为。此外,构造函数中不能调用 `shared_from_this()`——因为此时 `shared_ptr` 还没有完成构造。 ## 常见误用与踩坑 -在深入嵌入式权衡之前,我们先盘点几个 `shared_ptr` 的常见误用模式。这些"坑"笔者自己踩过不止一次,也希望读者能提前绕开。 +在深入嵌入式权衡之前,咱们先盘点几个 `shared_ptr` 的常见误用模式。这些"坑"笔者自己踩过不止一次,也希望读者能提前绕开。 -**误用一:用 `shared_ptr(this)` 创建第二个控制块**。这是最致命的错误。如果你在一个已经被 `shared_ptr` 管理的对象的成员函数中写 `return std::shared_ptr(this)`,编译器会创建一个全新的控制块,引用计数从 1 开始。结果就是两个独立的控制块管理同一个对象——当两个 `shared_ptr` 都被销毁时,对象会被 delete 两次。正确做法是继承 `enable_shared_from_this` 并调用 `shared_from_this()`。 +**误用一:用 `shared_ptr(this)` 创建第二个控制块**。这是最致命的错误。如果您在一个已经被 `shared_ptr` 管理的对象的成员函数中写 `return std::shared_ptr(this)`,编译器会创建一个全新的控制块,引用计数从 1 开始。结果就是两个独立的控制块管理同一个对象——当两个 `shared_ptr` 都被销毁时,对象会被 delete 两次。正确做法是继承 `enable_shared_from_this` 并调用 `shared_from_this()`。 -**误用二:在接口中暴露 `shared_ptr` 的所有权意图**。如果你写一个函数 `void process(std::shared_ptr w)`,签名本身就暗示了"我要和你共享所有权"。但很多时候函数只是想使用对象,并不需要持有它。这种场景下传 `const Widget&` 或 `Widget*` 更合适——不暗示所有权,也没有引用计数的开销。 +**误用二:在接口中暴露 `shared_ptr` 的所有权意图**。如果您写一个函数 `void process(std::shared_ptr w)`,签名本身就暗示了"我要和您共享所有权"。但很多时候函数只是想使用对象,并不需要持有它。这种场景下传 `const Widget&` 或 `Widget*` 更合适——不暗示所有权,也没有引用计数的开销。 **误用三:用 `shared_ptr` 管理"不需要共享"的对象**。有些团队为了图省事,把所有堆对象都用 `shared_ptr` 管理——"反正 shared_ptr 什么都能管"。这会导致所有权语义模糊(谁都持有等于谁都不负责)、性能下降(到处是原子操作)、循环引用风险增加。笔者的经验是:**90% 的对象应该用 `unique_ptr` 管理,只有 10% 真正需要共享的用 `shared_ptr`**。 -**误用四:忽视 `make_shared` 与 `new` 的区别**。`make_shared` 把对象和控制块合并在一次分配中,但这也意味着对象的析构和控制块的释放不在同一时刻——当所有 `shared_ptr` 被销毁时,对象析构,但如果还有 `weak_ptr` 存活,整个内存块(包括对象占用的空间)不会释放直到所有 `weak_ptr` 也被销毁。对于大型对象,这可能导致"明明没人用了但内存还不还回来"的现象。如果你预期会有长期存活的 `weak_ptr`,用 `shared_ptr(new T)` 把对象和控制块分开分配可能更合适。 +**误用四:忽视 `make_shared` 与 `new` 的区别**。`make_shared` 把对象和控制块合并在一次分配中,但这也意味着对象的析构和控制块的释放不在同一时刻——当所有 `shared_ptr` 被销毁时,对象析构,但如果还有 `weak_ptr` 存活,整个内存块(包括对象占用的空间)不会释放直到所有 `weak_ptr` 也被销毁。对于大型对象,这可能导致"明明没人用了但内存还不还回来"的现象。如果您预期会有长期存活的 `weak_ptr`,用 `shared_ptr(new T)` 把对象和控制块分开分配可能更合适。 ## shared_ptr 滥用的系统性后果 -我这里单独开了一个章节,很简单,因为我自己曾经就是滥用的人。。。 +笔者这里单独开了一节,很简单,因为笔者自己曾经就是滥用的人。。。 -咱们前面我们逐个盘点了 `shared_ptr` 的常见误用模式,但问题的严重性远不止"某个地方写错了"。当 `shared_ptr` 在代码库中被系统性滥用时,它带来的是**架构层面的慢性毒药**——不是那种编译不过的急性错误,而是让代码库逐渐变得不可维护、不可推理、不可优化的渐进式腐化。笔者见过不止一个项目因为"所有对象都用 `shared_ptr` 管理"而陷入这种泥潭,修复起来往往需要大规模重构。 +咱们前面逐个盘点了 `shared_ptr` 的常见误用模式,但问题的严重性远不止"某个地方写错了"。当 `shared_ptr` 在代码库中被系统性滥用时,它带来的是**架构层面的慢性毒药**——不是那种编译不过的急性错误,而是让代码库逐渐变得不可维护、不可推理、不可优化的渐进式腐化。笔者见过不止一个项目因为"所有对象都用 `shared_ptr` 管理"而陷入这种泥潭,修复起来往往需要大规模重构。 ### 所有权模型的崩塌 -在一个健康的设计中,每个对象都应该有一个明确的所有者——"谁创建的、谁销毁的、生命周期由谁决定"——这些问题应该在设计阶段就回答清楚。但当你到处使用 `shared_ptr` 时,这些问题的答案变成了"谁知道呢,引用计数归零的时候自然就销毁了"。听起来很方便,但代价是你失去了对对象生命周期的控制力:你不能保证对象在任何特定时刻存活(因为其他持有者可能随时释放),也不能保证对象在任何特定时刻被销毁(因为可能有你不知道的持有者还在引用它)。这种"谁都不负责"的状态,和全局变量泛滥带来的问题如出一辙。 +在一个健康的设计中,每个对象都应该有一个明确的所有者——"谁创建的、谁销毁的、生命周期由谁决定"——这些问题应该在设计阶段就回答清楚。但当您到处使用 `shared_ptr` 时,这些问题的答案变成了"谁知道呢,引用计数归零的时候自然就销毁了"。听起来很方便,但代价是您失去了对对象生命周期的控制力:您不能保证对象在任何特定时刻存活(因为其他持有者可能随时释放),也不能保证对象在任何特定时刻被销毁(因为可能有您不知道的持有者还在引用它)。这种"谁都不负责"的状态,和全局变量泛滥带来的问题如出一辙。 -Sean Parent 在 C++Now 的演讲中一针见血地把滥用 `shared_ptr` 比作**隐式全局变量**——任何持有 `shared_ptr` 的代码都在参与对象的生命周期管理,这和全局变量"任何地方都能访问、任何地方都能延长其寿命"的特性惊人地相似。更实际的问题是,一旦你的公共接口返回了 `shared_ptr`,所有调用者都被迫使用 `shared_ptr`,即使他们只是想临时借用一下对象。你剥夺了调用者选择所有权模型的权利——更好的做法是返回 `unique_ptr`(调用者可以自由 `std::move` 为 `shared_ptr`)或者裸指针/引用(非拥有访问)。 +Sean Parent 在 C++Now 的演讲中一针见血地把滥用 `shared_ptr` 比作**隐式全局变量**——任何持有 `shared_ptr` 的代码都在参与对象的生命周期管理,这和全局变量"任何地方都能访问、任何地方都能延长其寿命"的特性惊人地相似。更实际的问题是,一旦您的公共接口返回了 `shared_ptr`,所有调用者都被迫使用 `shared_ptr`,即使他们只是想临时借用一下对象。您剥夺了调用者选择所有权模型的权利——更好的做法是返回 `unique_ptr`(调用者可以自由 `std::move` 为 `shared_ptr`)或者裸指针/引用(非拥有访问)。 ### 多线程下的缓存行争用 这个问题在单线程代码中完全不会出现,但在多线程场景下会变得非常刺眼。`shared_ptr` 的控制块中存储着强引用计数和弱引用计数,这两个原子计数器通常在同一个控制块中,很可能共享同一个缓存行(cache line,通常 64 字节)。当多个线程频繁拷贝和销毁指向**同一个对象**的 `shared_ptr` 时,每个线程对引用计数的原子修改都会导致该缓存行在不同核心之间来回 bouncing——即使这些线程操作的是各自独立的 `shared_ptr` 实例,只要它们指向同一个对象,就会竞争同一个控制块的缓存行。 -光说不够,我们来跑个测试。下面的基准程序(`code/volumn_codes/vol2/ch01-smart-pointers/verify_cache_contention.cpp`)构建了一个生产者-消费者的线程安全队列,分别用裸指针和 `shared_ptr` 传递消息。测试环境为笔者的 Windows WSL2 Arch Linux,AMD Ryzen 7 5800H(14 线程),GCC 15.2,`-O2` Release 编译。结果如下: +光说不够,咱们来跑个测试。下面这个基准程序构建了一个生产者-消费者的线程安全队列,分别用裸指针和 `shared_ptr` 传递消息。测试环境为笔者的 Windows WSL2 Arch Linux,AMD Ryzen 7 5800H(14 线程),GCC 15.2,`-O2` Release 编译。结果如下: | 方案 | 消息数 | 平均耗时 | 相对开销 | |------|--------|---------|---------| | 裸指针 | 10,000 | ~30 ms | 基准 | | `shared_ptr` | 10,000 | ~35 ms | **+15-20%** | -15-20% 的开销在实际应用中可能更显著,因为我们的测试使用了 mutex 保护的队列,mutex 的开销会掩盖部分 `shared_ptr` 的开销。在无锁队列或更高并发场景下(如原始测试中的 8 线程),`shared_ptr` 的开销会更加明显。这个开销的来源很明确:每次 `shared_ptr` 拷贝都要原子递增引用计数,每次销毁都要原子递减——在多线程同时操作同一个控制块的场景下,这些原子操作会引发缓存行争用。低并发、低吞吐的场景可以忽略,高并发热路径上务必慎重。 +15-20% 的开销在实际应用中可能更显著,因为咱们的测试使用了 mutex 保护的队列,mutex 的开销会掩盖部分 `shared_ptr` 的开销。在无锁队列或更高并发场景下(如原始测试中的 8 线程),`shared_ptr` 的开销会更加明显。这个开销的来源很明确:每次 `shared_ptr` 拷贝都要原子递增引用计数,每次销毁都要原子递减——在多线程同时操作同一个控制块的场景下,这些原子操作会引发缓存行争用。低并发、低吞吐的场景可以忽略,高并发热路径上务必慎重。 ### 循环引用:静默的内存泄漏 -当对象因为循环引用而泄漏时,你不会得到任何错误提示——`shared_ptr` 的引用计数永远不会归零,对象就静静地躺在堆上占用内存。没有崩溃、没有断言失败、没有任何日志告诉你"嘿,这个对象泄漏了"。你只能在内存使用持续增长时才可能察觉到问题,然后用 Valgrind 或 AddressSanitizer 才能定位到泄漏点。更糟糕的是,循环引用往往不是两个对象之间的简单环路,而是涉及多个对象的复杂依赖图——A 持有 B,B 持有 C,C 又持有 A——这种情况下追踪引用链本身就是一件非常痛苦的事情。 +当对象因为循环引用而泄漏时,您不会得到任何错误提示——`shared_ptr` 的引用计数永远不会归零,对象就静静地躺在堆上占用内存。没有崩溃、没有断言失败、没有任何日志告诉您"嘿,这个对象泄漏了"。您只能在内存使用持续增长时才可能察觉到问题,然后用 Valgrind 或 AddressSanitizer 才能定位到泄漏点。更糟糕的是,循环引用往往不是两个对象之间的简单环路,而是涉及多个对象的复杂依赖图——A 持有 B,B 持有 C,C 又持有 A——这种情况下追踪引用链本身就是一件非常痛苦的事情。 -相比之下,`unique_ptr` 的独占所有权模型让循环引用在编译期就不可能发生(你无法构造一个合法的独占所有权环),这是它在设计层面的巨大优势。如果你发现自己需要大量使用 `weak_ptr` 来打破循环引用,这本身就是一个强烈的信号:你的所有权模型设计有问题,应该重新审视对象之间的依赖关系,而不是用 `weak_ptr` 到处打补丁。 +相比之下,`unique_ptr` 的独占所有权模型让循环引用在编译期就不可能发生(您无法构造一个合法的独占所有权环),这是它在设计层面的巨大优势。如果您发现自己需要大量使用 `weak_ptr` 来打破循环引用,这本身就是一个强烈的信号:您的所有权模型设计有问题,应该重新审视对象之间的依赖关系,而不是用 `weak_ptr` 到处打补丁。 ### 所有权反转:回调中的定时炸弹 -这个问题在异步编程中特别常见,而且出了 bug 极难排查。假设对象 A 持有一个 Timer,Timer 的回调通过 `shared_from_this()` 捕获了 A 的 `shared_ptr`。当 A 在主线程被 reset 之后,Timer 线程反而成了 A 的唯一持有者——A 的生命周期被"反转"到了 Timer 线程上。如果 Timer 的析构函数需要 join 自身所在的线程(`std::jthread` 就会这么做),就会触发 `std::system_error`:一个线程尝试 join 自己,这是未定义行为。这类 bug 的根源在于 `shared_ptr` 让你"懒得思考所有权"——你以为释放了 A,但回调还在暗处拽着它。正确做法是在设计阶段就明确生命周期约束:如果 A 的析构依赖 Timer 线程结束,那 A 必须在 Timer 之前销毁,用 `unique_ptr` 的独占语义来表达这个约束。 +这个问题在异步编程中特别常见,而且出了 bug 极难排查。假设对象 A 持有一个 Timer,Timer 的回调通过 `shared_from_this()` 捕获了 A 的 `shared_ptr`。当 A 在主线程被 reset 之后,Timer 线程反而成了 A 的唯一持有者——A 的生命周期被"反转"到了 Timer 线程上。如果 Timer 的析构函数需要 join 自身所在的线程(`std::jthread` 就会这么做),就会触发 `std::system_error`:一个线程尝试 join 自己,这是未定义行为。这类 bug 的根源在于 `shared_ptr` 让您"懒得思考所有权"——您以为释放了 A,但回调还在暗处拽着它。正确做法是在设计阶段就明确生命周期约束:如果 A 的析构依赖 Timer 线程结束,那 A 必须在 Timer 之前销毁,用 `unique_ptr` 的独占语义来表达这个约束。 ### 析构时机的不确定性与实时隐患 -当你 drop 一个 `shared_ptr` 时,你无法确定这是否是最后一个——对象可能在这次 drop 中被销毁,也可能因为还有其他持有者而继续存活。这意味着析构函数的调用时机是**不可预测的**,析构顺序也是**未定义的**。在实时系统中,这尤其危险:如果你在音频回调、中断服务例程或任何有实时性要求的代码路径上 drop 一个 `shared_ptr`,而恰好这是最后一个持有者,触发的析构函数可能带来不可接受的延迟——堆释放、文件 IO、日志写入,这些都是非确定性的耗时操作。Timur Doumler 在讨论 C++ 音频开发时提出了一个巧妙的 `ReleasePool` 方案:在低优先级线程上定期清理那些可能需要析构的 `shared_ptr`,确保实时线程上永远不会触发析构。但说到底,如果你在设计阶段就用了 `unique_ptr` 加显式的生命周期管理,根本就不需要这种 workaround。 +当您 drop 一个 `shared_ptr` 时,您无法确定这是否是最后一个——对象可能在这次 drop 中被销毁,也可能因为还有其他持有者而继续存活。这意味着析构函数的调用时机是**不可预测的**,析构顺序也是**未定义的**。在实时系统中,这尤其危险:如果您在音频回调、中断服务例程或任何有实时性要求的代码路径上 drop 一个 `shared_ptr`,而恰好这是最后一个持有者,触发的析构函数可能带来不可接受的延迟——堆释放、文件 IO、日志写入,这些都是非确定性的耗时操作。Timur Doumler 在讨论 C++ 音频开发时提出了一个巧妙的 `ReleasePool` 方案:在低优先级线程上定期清理那些可能需要析构的 `shared_ptr`,确保实时线程上永远不会触发析构。但说到底,如果您在设计阶段就用了 `unique_ptr` 加显式的生命周期管理,根本就不需要这种 workaround。 ## 实战选择指南:什么时候该用 shared_ptr -在讲嵌入式权衡之前,我们先来做一个实战导向的选择分析。很多人在 `unique_ptr` 和 `shared_ptr` 之间犹豫不决,其实判断标准很简单——问自己一个问题:**这个对象是否需要被多个独立的模块共同拥有?** +在讲嵌入式权衡之前,咱们先来做一个实战导向的选择分析。很多人在 `unique_ptr` 和 `shared_ptr` 之间犹豫不决,其实判断标准很简单——问自己一个问题:**这个对象是否需要被多个独立的模块共同拥有?** 如果答案是"不"——对象的生命周期由一个明确的"主人"决定,其他模块只是临时借用——那就用 `unique_ptr` + 裸指针/引用传递。这是绝大多数场景。 @@ -305,7 +305,7 @@ Sean Parent 在 C++Now 的演讲中一针见血地把滥用 `shared_ptr` 比作* 典型的不应该用 `shared_ptr` 的场景包括:函数参数传递(传引用就够了)、对象的唯一所有者(用 `unique_ptr`)、简单的缓存(用 `weak_ptr` 观察,`shared_ptr` 持有)。 -我们来看一个具体的设计决策示例——实现一个简单的任务调度器: +咱们来看一个具体的设计决策示例——实现一个简单的任务调度器: ```cpp #include @@ -365,27 +365,21 @@ private: }; ``` -第一个版本用 `unique_ptr`——任务提交后所有权归调度器,简单明确。第二个版本用 `shared_ptr`——允许多个调度器或外部代码持有同一个任务的引用,任务在最后一个持有者离开时才被销毁。选哪个取决于你的设计需求,而不是"哪个更方便"。 +第一个版本用 `unique_ptr`——任务提交后所有权归调度器,简单明确。第二个版本用 `shared_ptr`——允许多个调度器或外部代码持有同一个任务的引用,任务在最后一个持有者离开时才被销毁。选哪个取决于您的设计需求,而不是"哪个更方便"。 ## 嵌入式权衡:内存开销与 ISR 注意事项 -在嵌入式场景下使用 `shared_ptr` 需要格外谨慎,原因我们逐一分析。 +在嵌入式场景下使用 `shared_ptr` 需要格外谨慎,原因咱们逐一分析。 -首先是**内存开销**。在 32 位 MCU 上,一个 `shared_ptr` 对象占 8 字节(两个指针),控制块至少 16-24 字节(取决于实现)。如果你用 `make_shared`,对象和控制块一共可能占用 `sizeof(T) + 24+` 字节。对于只有几十 KB RAM 的 MCU 来说,这种开销在对象数量较多时会非常明显。我们来算一笔具体的账:假设你的 MCU 有 64KB RAM,你需要管理 50 个外设句柄,每个句柄对象本身 16 字节。用 `unique_ptr` 管理,总开销是 `50 * (8 + 16) = 1200` 字节;用 `shared_ptr` + `make_shared` 管理,总开销是 `50 * (16 + 16 + 24) = 2800` 字节——多出 1600 字节,占总 RAM 的 2.4%。在内存更紧张的 MCU 上(比如 STM32F103 只有 20KB RAM),这个数字会变得更加刺眼。 +首先是**内存开销**。在 32 位 MCU 上,一个 `shared_ptr` 对象占 8 字节(两个指针),控制块至少 16-24 字节(取决于实现)。如果您用 `make_shared`,对象和控制块一共可能占用 `sizeof(T) + 24+` 字节。对于只有几十 KB RAM 的 MCU 来说,这种开销在对象数量较多时会非常明显。咱们来算一笔具体的账:假设您的 MCU 有 64KB RAM,您需要管理 50 个外设句柄,每个句柄对象本身 16 字节。用 `unique_ptr` 管理,总开销是 `50 * (8 + 16) = 1200` 字节;用 `shared_ptr` + `make_shared` 管理,总开销是 `50 * (16 + 16 + 24) = 2800` 字节——多出 1600 字节,占总 RAM 的 2.4%。在内存更紧张的 MCU 上(比如 STM32F103 只有 20KB RAM),这个数字会变得更加刺眼。 -其次是**堆分配**。控制块需要在堆上分配,而很多嵌入式系统要么禁用了堆,要么堆空间非常有限。频繁的堆分配会导致内存碎片,最终分配失败。如果你的系统运行时间长(嵌入式设备通常常年运行),碎片化问题会越来越严重。一个可能的缓解方案是使用 `std::allocate_shared` 配合自定义分配器(比如内存池分配器),把控制块的分配从系统堆转移到预分配的内存池中。 +其次是**堆分配**。控制块需要在堆上分配,而很多嵌入式系统要么禁用了堆,要么堆空间非常有限。频繁的堆分配会导致内存碎片,最终分配失败。如果您的系统运行时间长(嵌入式设备通常常年运行),碎片化问题会越来越严重。一个可能的缓解方案是使用 `std::allocate_shared` 配合自定义分配器(比如内存池分配器),把控制块的分配从系统堆转移到预分配的内存池中。 -第三是**原子操作**。引用计数的原子递增/递减在单核 MCU 上可能退化为关中断操作(取决于工具链对 `std::atomic` 的实现),这会影响中断响应时间。在 ISR 中使用 `shared_ptr` 是一个糟糕的主意——不仅因为堆操作,还因为原子操作可能关中断。如果你的系统有严格的实时性要求(比如控制环路必须在 100us 内完成),ISR 中的任何不确定延迟都是不可接受的。 +第三是**原子操作**。引用计数的原子递增/递减在单核 MCU 上可能退化为关中断操作(取决于工具链对 `std::atomic` 的实现),这会影响中断响应时间。在 ISR 中使用 `shared_ptr` 是一个糟糕的主意——不仅因为堆操作,还因为原子操作可能关中断。如果您的系统有严格的实时性要求(比如控制环路必须在 100us 内完成),ISR 中的任何不确定延迟都是不可接受的。 -笔者的建议是:在嵌入式系统中优先使用 `unique_ptr` 或者直接使用 RAII 封装类。如果确实需要共享语义,考虑侵入式引用计数(intrusive reference counting)——把引用计数放在对象内部,避免额外的堆分配。在单线程环境下,侵入式方案的引用计数可以用普通的 `uint32_t`,不需要原子操作,开销极低。这个话题我们会在"自定义删除器与侵入式引用计数"那篇中详细讨论。 +笔者的建议是:在嵌入式系统中优先使用 `unique_ptr` 或者直接使用 RAII 封装类。如果确实需要共享语义,考虑侵入式引用计数(intrusive reference counting)——把引用计数放在对象内部,避免额外的堆分配。在单线程环境下,侵入式方案的引用计数可以用普通的 `uint32_t`,不需要原子操作,开销极低。这个话题咱们会在"自定义删除器与侵入式引用计数"那篇中详细讨论。 -## 小结 - -`shared_ptr` 通过引用计数实现了共享所有权语义,是 `unique_ptr` 独占语义的互补。理解它的关键在于控制块机制——每个 `shared_ptr` 实例持有两个指针(对象和控制块),控制块中的原子引用计数保证了多线程下的安全性,但也带来了不可忽视的性能开销。 - -`make_shared` 通过单次分配优化了性能和内存局部性,应该是创建 `shared_ptr` 的首选方式。aliasing constructor 和 `enable_shared_from_this` 是两个不太知名但非常有用的高级特性。在嵌入式场景下,`shared_ptr` 的内存开销、堆分配和原子操作成本需要仔细权衡——大多数情况下,`unique_ptr` 或侵入式方案是更好的选择。 - -下一篇我们将讨论 `weak_ptr`——`shared_ptr` 的搭档,专门用来解决循环引用这个棘手问题。 +下一篇聊 `weak_ptr`——`shared_ptr` 的搭档,专门解决循环引用这个棘手问题。 ## 参考资源 diff --git a/documents/vol2-modern-features/ch01-smart-pointers/04-weak-ptr.md b/documents/vol2-modern-features/ch01-smart-pointers/04-weak-ptr.md index 68bdfc3ed..77fa658e0 100644 --- a/documents/vol2-modern-features/ch01-smart-pointers/04-weak-ptr.md +++ b/documents/vol2-modern-features/ch01-smart-pointers/04-weak-ptr.md @@ -23,13 +23,13 @@ title: weak_ptr 与循环引用:打破所有权的死锁 --- # weak_ptr 与循环引用:打破所有权的死锁 -上一篇我们聊了 `shared_ptr`——通过引用计数实现共享所有权。`shared_ptr` 看起来很美好:只要最后一个持有者离开,对象就自动销毁。但现实是,这个"自动销毁"有一个致命的敌人:**循环引用**。当两个对象互相持有对方的 `shared_ptr` 时,它们的引用计数永远不会归零——两个"管家"互相以为对方还持有钥匙,谁也不敢关门,结果就是内存泄漏。 +上一篇咱们聊了 `shared_ptr`——通过引用计数实现共享所有权。`shared_ptr` 看起来很美好:只要最后一个持有者离开,对象就自动销毁。但现实是,这个"自动销毁"有一个致命的敌人:**循环引用**。当两个对象互相持有对方的 `shared_ptr` 时,它们的引用计数永远不会归零——两个"管家"互相以为对方还持有钥匙,谁也不敢关门,结果就是内存泄漏。 -`std::weak_ptr` 就是为解决这个问题而生的。它是一种"不参与引用计数"的观察者指针——你可以通过它查看对象是否还活着,如果活着就临时获取一个 `shared_ptr` 来访问,但它本身不会延长对象的生命周期。 +`std::weak_ptr` 就是为解决这个问题而生的。它是一种"不参与引用计数"的观察者指针——您可以通过它查看对象是否还活着,如果活着就临时获取一个 `shared_ptr` 来访问,但它本身不会延长对象的生命周期。 ## 循环引用问题演示 -在深入 `weak_ptr` 之前,我们先来直观地感受一下循环引用的问题。经典的例子是双向链表:每个节点持有下一个节点的 `shared_ptr`,如果是双向链表,还持有上一个节点的 `shared_ptr`。这样一来,每个节点都被相邻节点的 `shared_ptr` 引用着,形成了一个环——引用计数永远不归零。 +在深入 `weak_ptr` 之前,咱们先来直观地感受一下循环引用的问题。经典的例子是双向链表:每个节点持有下一个节点的 `shared_ptr`,如果是双向链表,还持有上一个节点的 `shared_ptr`。这样一来,每个节点都被相邻节点的 `shared_ptr` 引用着,形成了一个环——引用计数永远不归零。 ```cpp #include @@ -64,21 +64,21 @@ void circular_reference_bug() { } ``` -运行这段代码你会发现:`~Node()` 的析构输出**永远不会出现**——`Node("A") 析构` 和 `~Node("B") 析构` 都没有打印。两个节点互相持有对方的 `shared_ptr`,形成了一个"死锁环",谁都不会被释放。这就是循环引用导致的内存泄漏。 +运行这段代码您会发现:`~Node()` 的析构输出**永远不会出现**——`Node("A") 析构` 和 `~Node("B") 析构` 都没有打印。两个节点互相持有对方的 `shared_ptr`,形成了一个"死锁环",谁都不会被释放。这就是循环引用导致的内存泄漏。 这种问题在实际工程中并不罕见。观察者模式中,主题(Subject)持有观察者的 `shared_ptr`,观察者也持有主题的 `shared_ptr`;树形结构中,父节点持有子节点的 `shared_ptr`,子节点也持有父节点的 `shared_ptr`;图结构中,任意两个相邻节点都可能互相引用。只要形成了环,`shared_ptr` 的引用计数机制就失灵了。 ## weak_ptr 的 API:lock()、expired()、use_count() -`weak_ptr` 是 `shared_ptr` 的搭档——它指向 `shared_ptr` 管理的对象,但不增加强引用计数。你可以把它理解为一张"参观券":你可以凭券去看看对象还在不在,但不能凭券阻止对象被销毁。 +`weak_ptr` 是 `shared_ptr` 的搭档——它指向 `shared_ptr` 管理的对象,但不增加强引用计数。您可以把它理解为一张"参观券":您可以凭券去看看对象还在不在,但不能凭券阻止对象被销毁。 `weak_ptr` 提供三个核心 API: -`lock()` 是最重要的方法。它尝试获取一个指向对象的 `shared_ptr`。如果对象仍然存在(强引用计数 > 0),返回一个有效的 `shared_ptr`;如果对象已经被销毁(强引用计数 = 0),返回一个空的 `shared_ptr`(即 `nullptr`)。`lock()` 是线程安全的——在多线程环境下,多个线程可以同时调用 `lock()`,标准保证返回的 `shared_ptr` 要么指向一个有效对象,要么为空,不会出现"获取到指针但对象已被删除"的悬垂情况。验证代码见 `test_weak_ptr_atomicity.cpp`。 +`lock()` 是最重要的方法。它尝试获取一个指向对象的 `shared_ptr`。如果对象仍然存在(强引用计数 > 0),返回一个有效的 `shared_ptr`;如果对象已经被销毁(强引用计数 = 0),返回一个空的 `shared_ptr`(即 `nullptr`)。`lock()` 是线程安全的——在多线程环境下,多个线程可以同时调用 `lock()`,标准保证返回的 `shared_ptr` 要么指向一个有效对象,要么为空,不会出现"获取到指针但对象已被删除"的悬垂情况。 -`expired()` 返回一个 bool 值,表示对象是否已经被销毁(即强引用计数是否为 0)。不过在实际使用中,通常推荐直接用 `lock()` 而不是先检查 `expired()` 再 `lock()`——因为在多线程环境下,`expired()` 返回 `false` 之后到调用 `lock()` 之间,对象可能已经被另一个线程销毁了,这会导致竞态条件。`lock()` 一次性完成了"检查对象是否存在"和"增加引用计数"两个操作,避免了这个问题。验证代码见 `test_weak_ptr_atomicity.cpp` 中的竞态条件测试。 +`expired()` 返回一个 bool 值,表示对象是否已经被销毁(即强引用计数是否为 0)。不过在实际使用中,通常推荐直接用 `lock()` 而不是先检查 `expired()` 再 `lock()`——因为在多线程环境下,`expired()` 返回 `false` 之后到调用 `lock()` 之间,对象可能已经被另一个线程销毁了,这会导致竞态条件。`lock()` 一次性完成了"检查对象是否存在"和"增加引用计数"两个操作,避免了这个问题。 -`use_count()` 返回当前指向对象的 `shared_ptr` 数量(即强引用计数)。和 `expired()` 一样,返回值在你使用时可能已经过时了,所以一般只用于调试和日志。 +`use_count()` 返回当前指向对象的 `shared_ptr` 数量(即强引用计数)。和 `expired()` 一样,返回值在您使用时可能已经过时了,所以一般只用于调试和日志。 ```cpp #include @@ -112,11 +112,11 @@ void weak_ptr_api_demo() { } ``` -⚠️ `weak_ptr` 不能直接解引用——你无法写 `*weak` 或 `weak->member`。必须先通过 `lock()` 获取 `shared_ptr`,然后通过 `shared_ptr` 访问对象。这个设计是故意的:`weak_ptr` 是一种"不确定对象是否还存在"的引用,直接访问太危险了。`lock()` 的原子检查保证了你获取到的 `shared_ptr` 要么指向一个活着的对象,要么是空的——不会出现"获取到指针但对象已经被删"的悬垂指针问题。 +⚠️ `weak_ptr` 不能直接解引用——您无法写 `*weak` 或 `weak->member`。必须先通过 `lock()` 获取 `shared_ptr`,然后通过 `shared_ptr` 访问对象。这个设计是故意的:`weak_ptr` 是一种"不确定对象是否还存在"的引用,直接访问太危险了。`lock()` 的原子检查保证了您获取到的 `shared_ptr` 要么指向一个活着的对象,要么是空的——不会出现"获取到指针但对象已经被删"的悬垂指针问题。 ## weak_ptr 打破循环的原理 -回到之前的双向链表示例,我们只需要把 `prev` 从 `shared_ptr` 改成 `weak_ptr`,循环引用就被打破了: +回到之前的双向链表示例,咱们只需要把 `prev` 从 `shared_ptr` 改成 `weak_ptr`,循环引用就被打破了: ```cpp struct NodeFixed { @@ -158,7 +158,7 @@ Node(B) 构造 ~Node(B) 析构 ``` -关键在于 `b->prev = a` 这一行——`weak_ptr` 不增加 `a` 的强引用计数。所以当局部变量 `a` 离开作用域时,`a` 的强引用计数从 1 直接降到 0,触发析构。`weak_ptr` 的设计哲学可以归纳为一句话:**"我知道你的存在,但我不会阻止你离开"**。 +关键在于 `b->prev = a` 这一行——`weak_ptr` 不增加 `a` 的强引用计数。所以当局部变量 `a` 离开作用域时,`a` 的强引用计数从 1 直接降到 0,触发析构。`weak_ptr` 的设计哲学可以归纳为一句话:**"我知道您的存在,但我不会阻止您离开"**。 这个模式可以推广到任何有"父子关系"或"上下游关系"的数据结构:强引用方向用 `shared_ptr`(持有所有权),弱引用方向用 `weak_ptr`(仅观察,不持有所有权)。只要图中不存在全是强引用的环,引用计数就能正常工作。 @@ -365,23 +365,15 @@ void cache_demo() { 虽然 `weak_ptr` 是解决循环引用的利器,但过度使用它反而会增加代码的复杂性和出错概率。笔者见过一些代码库几乎把所有指针都换成 `weak_ptr`,生怕出现循环引用——这其实是矫枉过正。 -首先是性能问题。每次通过 `weak_ptr` 访问对象都需要调用 `lock()`,这涉及原子操作(检查引用计数并递增)。在热路径中频繁 `lock()` 会带来可测量的性能开销。根据 `test_weak_ptr_performance.cpp` 的基准测试,通过 `weak_ptr::lock()` 访问比直接访问 `shared_ptr` 慢约 10-15 倍(-O2 优化下,1000 万次迭代:直接访问约 5ms,lock() 访问约 62ms)。虽然在实际应用中这个绝对时间差异可能不算大,但如果在性能敏感的代码路径中频繁调用,开销会累积。 +首先是性能问题。每次通过 `weak_ptr` 访问对象都需要调用 `lock()`,这涉及原子操作(检查引用计数并递增)。在热路径中频繁 `lock()` 会带来可测量的性能开销。实测下来,`weak_ptr::lock()` 比直接访问 `shared_ptr` 慢约 30 倍(`lock()` 要做一次原子操作去抢引用计数;1000 万次迭代,直接访问约 2ms,`lock()` 约 68ms,GCC 16.1.1 -O2)。虽然在实际应用中这个绝对时间差异可能不算大,但如果在性能敏感的代码路径中频繁调用,开销会累积。 -其次是语义模糊。如果你的代码中到处都是 `weak_ptr`,读者很难判断哪些对象之间有真正的所有权关系。所有权关系应该尽量在设计阶段就理清楚,而不是用 `weak_ptr` 来回避所有权设计。 +其次是语义模糊。如果您的代码中到处都是 `weak_ptr`,读者很难判断哪些对象之间有真正的所有权关系。所有权关系应该尽量在设计阶段就理清楚,而不是用 `weak_ptr` 来回避所有权设计。 笔者的建议是:在大多数情况下,用 `unique_ptr` 表达独占所有权,用裸指针或引用表达非拥有访问。只在确实需要共享所有权且存在循环引用风险时,才用 `weak_ptr` 打破循环。`weak_ptr` 是一种精确的工具,而不是一种"到处撒一把"的万能药。 -还有一种常见的错误是用 `weak_ptr` 来"观察"栈上的对象或由 `unique_ptr` 管理的对象——这不可能做到,因为 `weak_ptr` 只能与 `shared_ptr` 配合使用。如果你想观察非共享对象的生命周期,需要用其他机制(比如回调函数、观察者模式的手动实现、或者把对象改为 `shared_ptr` 管理)。 +还有一种常见的错误是用 `weak_ptr` 来"观察"栈上的对象或由 `unique_ptr` 管理的对象——这不可能做到,因为 `weak_ptr` 只能与 `shared_ptr` 配合使用。如果您想观察非共享对象的生命周期,需要用其他机制(比如回调函数、观察者模式的手动实现、或者把对象改为 `shared_ptr` 管理)。 -## 小结 - -`weak_ptr` 是 `shared_ptr` 的搭档,通过不参与强引用计数的"弱引用"机制,解决了 `shared_ptr` 的循环引用问题。它的三个核心 API——`lock()`、`expired()`、`use_count()`——提供了安全的"查看但不拥有"的语义。 - -在实际应用中,`weak_ptr` 主要用于三种场景:打破数据结构中的循环引用(双向链表、树、图)、实现观察者模式的松耦合通知机制、以及构建自动回收的缓存系统。掌握这三种模式,就掌握了 `weak_ptr` 的核心用法。 - -但记住,`weak_ptr` 不是万能药。过度使用会让代码更难理解和维护。好的设计应该优先理清所有权关系,只在必要时引入 `weak_ptr`。 - -下一篇我们将讨论自定义删除器和侵入式引用计数——深入探讨如何让智能指针管理那些"不是 new 出来的"资源。 +下一篇聊自定义删除器和侵入式引用计数——怎么让智能指针管那些"不是 new 出来的"资源。 ## 参考资源 diff --git a/documents/vol2-modern-features/ch01-smart-pointers/05-custom-deleter.md b/documents/vol2-modern-features/ch01-smart-pointers/05-custom-deleter.md index 617d781f8..301fd9702 100644 --- a/documents/vol2-modern-features/ch01-smart-pointers/05-custom-deleter.md +++ b/documents/vol2-modern-features/ch01-smart-pointers/05-custom-deleter.md @@ -25,17 +25,17 @@ title: 自定义删除器与侵入式引用计数 --- # 自定义删除器与侵入式引用计数 -到目前为止,我们讨论的智能指针都在管理"new 出来的对象"——析构时调用 `delete`,一切自然而然。但现实世界远比这复杂。你需要管理的资源可能是 `fopen()` 返回的 `FILE*`(要用 `fclose` 关闭),可能是 `malloc()` 分配的内存(要用 `free` 释放),可能是 POSIX 的文件描述符 `int`(要用 `close` 关闭),可能是 SDL 的窗口、OpenGL 的纹理、CUDA 的 stream——每种资源都有自己的释放函数。如果智能指针只能 `delete`,那它就太鸡肋了。 +到目前为止,咱们讨论的智能指针都在管理"new 出来的对象"——析构时调用 `delete`,一切自然而然。但现实世界远比这复杂。您需要管理的资源可能是 `fopen()` 返回的 `FILE*`(要用 `fclose` 关闭),可能是 `malloc()` 分配的内存(要用 `free` 释放),可能是 POSIX 的文件描述符 `int`(要用 `close` 关闭),可能是 SDL 的窗口、OpenGL 的纹理、CUDA 的 stream——每种资源都有自己的释放函数。如果智能指针只能 `delete`,那它就太鸡肋了。 -自定义删除器(custom deleter)就是让智能指针适配各种"非标准"资源的关键机制。而侵入式引用计数(intrusive reference counting)则是在性能和内存受限场景下替代 `shared_ptr` 的重要方案。今天我们把这两个话题放在一起讨论,因为它们都围绕着同一个核心问题:**如何让 C++ 的智能指针管理那些"不是 new 出来的"资源**。 +自定义删除器(custom deleter)就是让智能指针适配各种"非标准"资源的关键机制。而侵入式引用计数(intrusive reference counting)则是在性能和内存受限场景下替代 `shared_ptr` 的重要方案。今天咱们把这两个话题放在一起讨论,因为它们都围绕着同一个核心问题:**如何让 C++ 的智能指针管理那些"不是 new 出来的"资源**。 ## 删除器的三种形态 -自定义删除器本质上就是一个"可调用对象"——在智能指针析构时被调用,负责释放资源。它可以是函数指针、lambda 表达式、或者函数对象(functor)。这三种形态各有特点,我们从最简单的开始逐一讲解。 +自定义删除器本质上就是一个"可调用对象"——在智能指针析构时被调用,负责释放资源。它可以是函数指针、lambda 表达式、或者函数对象(functor)。这三种形态各有特点,咱们从最简单的开始逐一讲解。 ### 函数指针:最直观的方式 -函数指针是最容易理解的删除器形式。你传入一个函数的地址,智能指针在析构时调用它。但函数指针有一个缺点:它会增加 `unique_ptr` 的大小,因为 `unique_ptr` 需要额外存储这个函数指针。 +函数指针是最容易理解的删除器形式。您传入一个函数的地址,智能指针在析构时调用它。但函数指针有一个缺点:它会增加 `unique_ptr` 的大小,因为 `unique_ptr` 需要额外存储这个函数指针。 ```cpp #include @@ -79,7 +79,7 @@ std::cout << sizeof(std::unique_ptr) << "\n"; // 16 std::cout << sizeof(std::unique_ptr) << "\n"; // 16 ``` -> **注意**:以上数值在 x86_64-linux-gnu 平台(g++ 15.2.1)测试得出。不同平台和编译器的实现可能略有差异。完整验证代码见 `code/volumn_codes/vol2/ch01-smart-pointers/05-custom-deleter-sizeof.cpp`。 +> **注意**:以上数值在 x86_64-linux-gnu 平台(GCC 16.1.1)测试得出。不同平台和编译器的实现可能略有差异。 ### Lambda:灵活且现代 @@ -93,7 +93,6 @@ auto file_closer = [](FILE* f) noexcept { using LambdaFilePtr = std::unique_ptr; // sizeof(LambdaFilePtr) == sizeof(FILE*) == 8(EBO 优化) -// 验证:参见 code/volumn_codes/vol2/ch01-smart-pointers/05-custom-deleter-sizeof.cpp // 有捕获 lambda —— 有状态,会增大 unique_ptr void captured_lambda_example() { @@ -112,8 +111,7 @@ void captured_lambda_example() { logging_closer ); // sizeof(fp) > sizeof(FILE*),因为 lambda 捕获了 log_fd - // 验证:参见 code/volumn_codes/vol2/ch01-smart-pointers/05-custom-deleter-sizeof.cpp -} + } ``` ### 函数对象:最高效的方式 @@ -151,7 +149,7 @@ void functor_example() { "零开销"不是一句空话——空基类优化(Empty Base Optimization, EBO)是 C++ 编译器的一项优化技术:当一个空类(没有数据成员、没有虚函数)被用作基类时,编译器可以把它的大小优化为 0 字节,不需要占用额外的内存空间。`unique_ptr` 的典型实现会将删除器作为基类存储(通过继承),这样当删除器为空类时,整个 `unique_ptr` 就只包含一个裸指针。 -我们来验证一下(在 x86_64-linux-gnu 平台,g++ 15.2.1): +咱们来验证一下(在 x86_64-linux-gnu 平台,GCC 16.1.1): ```cpp #include @@ -180,7 +178,7 @@ int main() { } ``` -64 位平台上的典型输出(g++ 15.2.1,-O0): +64 位平台上的典型输出(GCC 16.1.1,-O0): ```text sizeof(int*): 8 @@ -190,7 +188,6 @@ sizeof(unique_ptr): 16 sizeof(unique_ptr): 16 ``` -完整验证代码见 `code/volumn_codes/vol2/ch01-smart-pointers/05-custom-deleter-sizeof.cpp`。 数据很清楚:空删除器(包括默认删除器和空的函数对象)不会增加 `unique_ptr` 的大小。只有有状态的删除器(比如捕获了变量的 lambda、包含数据成员的函数对象、函数指针)才会增加大小。 @@ -198,7 +195,7 @@ sizeof(unique_ptr): 16 ## FILE* 管理、C API 封装实战 -掌握了删除器的基本原理之后,我们来看几个实际的封装场景。第一个是最常见的 C API 封装:用 `unique_ptr` 管理 `FILE*`。 +掌握了删除器的基本原理之后,咱们来看几个实际的封装场景。第一个是最常见的 C API 封装:用 `unique_ptr` 管理 `FILE*`。 ```cpp #include @@ -296,13 +293,13 @@ UniqueGlTexture create_texture(int width, int height) { } ``` -这里有一个细节值得注意:OpenGL 的纹理 ID 是一个 `GLuint`(整数),不是指针。但 `unique_ptr` 只能管理指针类型。所以我们把 `GLuint` 放在堆上(`new GLuint`),然后用 `unique_ptr` 管理这个堆上的 `GLuint`。删除器在析构时既调用 `glDeleteTextures` 又调用 `delete`。这种"间接"虽然看起来不太完美,但在实践中是标准做法。 +这里有一个细节值得注意:OpenGL 的纹理 ID 是一个 `GLuint`(整数),不是指针。但 `unique_ptr` 只能管理指针类型。所以咱们把 `GLuint` 放在堆上(`new GLuint`),然后用 `unique_ptr` 管理这个堆上的 `GLuint`。删除器在析构时既调用 `glDeleteTextures` 又调用 `delete`。这种"间接"虽然看起来不太完美,但在实践中是标准做法。 ## shared_ptr 的删除器:类型擦除 前面讨论的都是 `unique_ptr` 的删除器——删除器类型是 `unique_ptr` 类型的一部分。而 `shared_ptr` 的删除器有一个本质的不同:**删除器类型不是 `shared_ptr` 类型的一部分**,它被"擦除"后存储在控制块里。 -这意味着你可以用同一个 `shared_ptr` 类型持有不同删除器的对象: +这意味着您可以用同一个 `shared_ptr` 类型持有不同删除器的对象: ```cpp #include @@ -335,7 +332,7 @@ void resource_demo() { } ``` -这种"运行时多态"的灵活性是 `shared_ptr` 删除器的优势,但也有代价:删除器存储在控制块里(额外的堆分配),每次析构需要通过函数指针调用删除器。根据基准测试(g++ 15.2.1,-O2,100000 次迭代),`shared_ptr` 的创建和销毁比 `unique_ptr` 慢约 30-50%,主要开销来自控制块的内存分配。完整测试代码见 `code/volumn_codes/vol2/ch01-smart-pointers/05-custom-deleter-benchmark.cpp`。 +这种"运行时多态"的灵活性是 `shared_ptr` 删除器的优势,但也有代价:删除器存储在控制块里(额外的堆分配),每次析构需要通过函数指针调用删除器。`shared_ptr` 的创建和销毁比 `unique_ptr` 慢约 30-50%(`-O2`,10 万次迭代),主要开销来自控制块的内存分配。 ## 侵入式引用计数原理 @@ -381,7 +378,7 @@ private: ## intrusive_ptr 实现与应用场景 -有了引用计数的基类,我们还需要一个智能指针来自动管理 `add_ref/release` 的调用。这就是 `intrusive_ptr`: +有了引用计数的基类,咱们还需要一个智能指针来自动管理 `add_ref/release` 的调用。这就是 `intrusive_ptr`: ```cpp template @@ -451,13 +448,11 @@ void intrusive_demo() { std::cout << "缓冲区仍然有效\n"; } // 引用计数: 1 → 0,SharedBuffer 被销毁 - -// 完整实现代码见 code/volumn_codes/vol2/ch01-smart-pointers/05-intrusive-ptr-demo.cpp ``` 侵入式方案与 `shared_ptr` 的核心区别在于:`shared_ptr` 的控制块是在对象外部的堆上分配的(需要额外的 `new`),而侵入式方案把计数器直接放在对象内部。这意味着只有一次内存分配(对象本身),引用计数的访问不需要跳转到另一个内存位置(缓存更友好)。 -侵入式方案也有一些限制:对象必须继承引用计数基类(侵入性),不方便管理已有类型的对象(比如标准库类型),而且引用计数的线程安全性需要你自己决定。但正是这种"你自己决定"的灵活性,使得侵入式方案在嵌入式系统中非常有吸引力——在单线程场景下,你可以用普通的 `uint32_t` 计数器;在多线程场景下,你需要把计数器换成 `std::atomic`,但这会引入原子操作的开销。完整的多线程实现示例见 `code/volumn_codes/vol2/ch01-smart-pointers/05-intrusive-ptr-demo.cpp`。 +侵入式方案也有一些限制:对象必须继承引用计数基类(侵入性),不方便管理已有类型的对象(比如标准库类型),而且引用计数的线程安全性需要您自己决定。但正是这种"您自己决定"的灵活性,使得侵入式方案在嵌入式系统中非常有吸引力——在单线程场景下,您可以用普通的 `uint32_t` 计数器;在多线程场景下,您需要把计数器换成 `std::atomic`,但这会引入原子操作的开销。 ## 嵌入式实战:硬件句柄管理 @@ -517,13 +512,7 @@ void peripheral_sharing() { 这种模式在嵌入式驱动开发中非常常见。`unique_ptr` + 无状态删除器适合"独占使用"的场景(一次只有一个模块持有),侵入式引用计数适合"共享使用"的场景(多个模块同时持有),两者都比 `shared_ptr` 更轻量、更适合资源受限的环境。 -## 小结 - -自定义删除器让智能指针突破了"只能管理 new/delete"的限制,能够适配任何类型的资源释放方式。函数指针、lambda、函数对象三种删除器形态各有优劣:函数对象通过 EBO 可以实现零开销,是性能敏感场景的首选;lambda 编写方便但要注意捕获带来的大小增加;函数指针最直观但会翻倍 `unique_ptr` 的大小。 - -侵入式引用计数则是在性能和内存受限场景下替代 `shared_ptr` 的有效方案。通过把引用计数嵌入对象内部,省去了控制块的堆分配和额外的间接访问。代价是需要修改对象类型(侵入性),但在嵌入式和游戏引擎等性能敏感领域,这种权衡通常值得。 - -下一篇我们将讨论 scope_guard——一种更通用的 RAII 变体,它不仅能管理资源,还能管理任何需要在作用域退出时执行的操作。 +下一篇聊 scope_guard——一种更通用的 RAII 变体,不光管资源,还能管任何"作用域退出时要执行的操作"。 ## 参考资源 @@ -531,36 +520,4 @@ void peripheral_sharing() { - [Empty Base Optimization and no_unique_address](https://www.cppstories.com/2021/no-unique-address/) - [Boost intrusive_ptr documentation](https://www.boost.org/doc/libs/1_40_0/libs/smart_ptr/intrusive_ptr.html) - [C++ Core Guidelines: R.20-24](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rr-smart) -- [P0468R0: An Intrusive Smart Pointer Proposal](https://www.open-std.org/jc1/sc22/wg21/docs/papers/2016/p0468r0.html) -下一篇我们将讨论 scope_guard——一种更通用的 RAII 变体,它不仅能管理资源,还能管理任何需要在作用域退出时执行的操作。 - [P0468R0: An Intrusive Smart Pointer Proposal](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0468r0.html) - -## 验证代码 - -本文中涉及的技术断言均通过以下代码验证(在 x86_64-linux-gnu 平台,g++ 15.2.1): - -1. **删除器 sizeof 验证**:`code/volumn_codes/vol2/ch01-smart-pointers/05-custom-deleter-sizeof.cpp` - - 验证函数指针、lambda、函数对象作为删除器时的内存占用 - - 验证空基类优化(EBO)对 `unique_ptr` 大小的影响 - -2. **删除器性能基准测试**:`code/volumn_codes/vol2/ch01-smart-pointers/05-custom-deleter-benchmark.cpp` - - 对比 `unique_ptr` 和 `shared_ptr` 在使用自定义删除器时的性能差异 - - 测试条件:100000 次迭代,-O2 优化级别 - -3. **侵入式引用计数完整实现**:`code/volumn_codes/vol2/ch01-smart-pointers/05-intrusive-ptr-demo.cpp` - - 完整的 `IntrusivePtr` 实现 - - 单线程和多线程版本的引用计数基类 - - 与 `shared_ptr` 的对比演示 - -编译和运行方法: - -```bash -cd code/volumn_codes/vol2/ch01-smart-pointers -cmake -B build -DCMAKE_BUILD_TYPE=Release -cmake --build build -./build/05-custom-deleter-sizeof -./build/05-custom-deleter-benchmark -./build/05-intrusive-ptr-demo -``` - -或使用 g++ 直接编译: diff --git a/documents/vol2-modern-features/ch01-smart-pointers/06-scope-guard.md b/documents/vol2-modern-features/ch01-smart-pointers/06-scope-guard.md index c3213cf67..cd0e07d99 100644 --- a/documents/vol2-modern-features/ch01-smart-pointers/06-scope-guard.md +++ b/documents/vol2-modern-features/ch01-smart-pointers/06-scope-guard.md @@ -23,13 +23,13 @@ title: scope_guard 与 defer:通用作用域守卫 --- # scope_guard 与 defer:通用作用域守卫 -在前面几篇我们讨论了智能指针——它们管理的是"资源的生命周期"(内存、文件句柄、socket 等)。但在实际工程中,还有一类场景:你需要在作用域退出时执行某个操作,但这个操作不一定是"释放资源"。它可能是恢复某个全局状态、提交或回滚一个事务、记录一条日志、通知某个监控组件。这种"退出时执行"的需求比资源管理更普遍、更灵活,而专门为资源管理设计的智能指针并不能很好地覆盖这些场景。 +在前面几篇咱们讨论了智能指针——它们管理的是"资源的生命周期"(内存、文件句柄、socket 等)。但在实际工程中,还有一类场景:您需要在作用域退出时执行某个操作,但这个操作不一定是"释放资源"。它可能是恢复某个全局状态、提交或回滚一个事务、记录一条日志、通知某个监控组件。这种"退出时执行"的需求比资源管理更普遍、更灵活,而专门为资源管理设计的智能指针并不能很好地覆盖这些场景。 scope_guard(作用域守卫)就是为这类需求设计的通用工具。它的核心思想极其简单:**把一个可调用对象绑定到一个栈对象的析构函数上——作用域退出时,自动调用**。就这么朴素,但就这么有用。 ## scope_guard 的动机:不只是资源,还有状态回滚 -我们先来看一个真实的场景:假设你在写一个配置修改函数,需要临时改变系统的运行模式,在操作完成后恢复原来的模式。如果函数只有一个 return 点,手动恢复没问题。但如果函数有多个 return path,或者中间可能抛出异常,手动恢复就会变得很脆弱。 +咱们先来看一个真实的场景:假设您在写一个配置修改函数,需要临时改变系统的运行模式,在操作完成后恢复原来的模式。如果函数只有一个 return 点,手动恢复没问题。但如果函数有多个 return path,或者中间可能抛出异常,手动恢复就会变得很脆弱。 ```cpp // 没有 scope_guard 时的脆弱写法 @@ -53,7 +53,7 @@ void update_config(Config& cfg) { } ``` -每次修改这个函数——添加新的 return path、增加可能抛异常的调用——你都得检查所有的"恢复点"有没有遗漏。随着函数越来越复杂,遗漏的概率趋近于 100%。 +每次修改这个函数——添加新的 return path、增加可能抛异常的调用——您都得检查所有的"恢复点"有没有遗漏。随着函数越来越复杂,遗漏的概率趋近于 100%。 用 scope_guard 就简单多了: @@ -73,11 +73,11 @@ void update_config_guarded(Config& cfg) { } // 正常退出也自动恢复 ``` -`restore_mode` 是一个 RAII 对象——它的析构函数会在作用域退出时调用那个 lambda。不管是 `return`、异常传播、还是函数正常执行到末尾,恢复操作都会被执行。你只需要写一次恢复代码,再也不用担心遗漏。 +`restore_mode` 是一个 RAII 对象——它的析构函数会在作用域退出时调用那个 lambda。不管是 `return`、异常传播、还是函数正常执行到末尾,恢复操作都会被执行。您只需要写一次恢复代码,再也不用担心遗漏。 ## 实现一个通用的 ScopeGuard 类 -scope_guard 的核心实现非常精简——一个模板类,包装一个可调用对象和一个 active 标志位。我们从最基础版本开始,逐步完善。 +scope_guard 的核心实现非常精简——一个模板类,包装一个可调用对象和一个 active 标志位。咱们从最基础版本开始,逐步完善。 首先是核心实现: @@ -129,9 +129,9 @@ ScopeGuard make_scope_guard(F&& func) noexcept { } ``` -这个实现有几个值得注意的设计决策。析构函数用 `try-catch(...)` 包裹了 `func_()` 的调用,并在 catch 块中调用 `std::terminate()`。在 C++ 标准中,如果析构函数在栈展开过程中抛出异常,程序会直接调用 `std::terminate()` —— 毕竟运行时无法同时处理两个异常。虽然标注了 `noexcept` 的函数抛异常也会导致 `terminate()`(这是编译器通过 `-Wterminate` 警告会提醒你的),但显式的 try-catch 给了我们一个将来添加日志或清理的机会。如果你对 noexcept 异常处理的行为不太确定,可以运行本章节的验证代码(`06-scope-guard-verification.cpp`)中的相关测试,实际观察一下 terminate 的触发时机。 +这个实现有几个值得注意的设计决策。析构函数用 `try-catch(...)` 包裹了 `func_()` 的调用,并在 catch 块中调用 `std::terminate()`。在 C++ 标准中,如果析构函数在栈展开过程中抛出异常,程序会直接调用 `std::terminate()` —— 毕竟运行时无法同时处理两个异常。虽然标注了 `noexcept` 的函数抛异常也会导致 `terminate()`(编译器会用 `-Wterminate` 警告提醒),但显式的 try-catch 给了将来添加日志或清理的机会。 -`dismiss()` 方法允许你在成功路径上取消守卫。这在"只在失败时回滚"的场景中非常有用——我们后面会看到更优雅的 `scope_fail` 实现。 +`dismiss()` 方法允许您在成功路径上取消守卫。这在"只在失败时回滚"的场景中非常有用——咱们后面会看到更优雅的 `scope_fail` 实现。 ## defer 模式:Go 风格的延迟执行 @@ -177,11 +177,11 @@ void process_with_defer() { `DEFER` 宏的好处是把清理代码和获取代码放在了一起——读者不需要跳到函数末尾就能看到"这个资源会在什么时候释放"。这种局部性大大提高了代码的可读性和可维护性。 -⚠️ `DEFER` 宏的 lambda 捕获了 `[&]`(引用捕获),这意味着它引用了外层作用域的局部变量。如果在 `DEFER` 执行时这些变量已经离开作用域,就会产生悬垂引用。不过在实际使用中,`DEFER` 和它捕获的变量通常在同一个作用域内,所以这个问题很少出现——但你要意识到这个风险。如果确实需要跨作用域使用守卫对象,可以考虑按值捕获(`[=]`)或者确保守卫对象的生命周期不会超过被捕获的变量。 +⚠️ `DEFER` 宏的 lambda 捕获了 `[&]`(引用捕获),这意味着它引用了外层作用域的局部变量。如果在 `DEFER` 执行时这些变量已经离开作用域,就会产生悬垂引用。不过在实际使用中,`DEFER` 和它捕获的变量通常在同一个作用域内,所以这个问题很少出现——但您要意识到这个风险。如果确实需要跨作用域使用守卫对象,可以考虑按值捕获(`[=]`)或者确保守卫对象的生命周期不会超过被捕获的变量。 ## scope_success 和 scope_fail:区分成功与失败路径 -有时候你只想在函数"正常返回"时执行某个操作(比如提交事务),或者只在"异常退出"时执行(比如回滚事务)。C++17 提供了 `std::uncaught_exceptions()` 来检测当前是否处于异常传播中——它返回当前正在传播但尚未被捕获的异常数量。基于这个信息,我们可以实现 `scope_success` 和 `scope_fail`。 +有时候您只想在函数"正常返回"时执行某个操作(比如提交事务),或者只在"异常退出"时执行(比如回滚事务)。C++17 提供了 `std::uncaught_exceptions()` 来检测当前是否处于异常传播中——它返回当前正在传播但尚未被捕获的异常数量。基于这个信息,咱们可以实现 `scope_success` 和 `scope_fail`。 ```cpp template @@ -255,7 +255,7 @@ private: 原理是:在构造时记录当前的 `uncaught_exceptions()` 数量,在析构时比较——如果数量没变,说明没有新的异常被抛出(`scope_success`);如果数量增加了,说明有新的异常正在传播(`scope_fail`)。 -⚠️ 注意使用 `std::uncaught_exceptions()`(复数)而不是旧的 `std::uncaught_exception()`(单数)。后者在嵌套 try-catch 的场景下行为不正确——它只能告诉你"有没有异常",而不能告诉你"有没有**新的**异常"。`uncaught_exceptions()` 返回精确的数量,可以正确检测嵌套场景。旧的 `uncaught_exception()` 在 C++17 中已被弃用。 +⚠️ 注意使用 `std::uncaught_exceptions()`(复数)而不是旧的 `std::uncaught_exception()`(单数)。后者在嵌套 try-catch 的场景下行为不正确——它只能告诉您"有没有异常",而不能告诉您"有没有**新的**异常"。`uncaught_exceptions()` 返回精确的数量,可以正确检测嵌套场景。旧的 `uncaught_exception()` 在 C++17 中已被弃用。 ## 状态回滚示例:事务处理 @@ -321,7 +321,7 @@ ROLLBACK scope_guard 与异常安全的关系非常紧密。在 C++ 中,异常安全有三个级别(基本保证、强保证、不抛出保证),而 scope_guard 是实现强保证的重要工具。 -考虑一个"先修改 A,再修改 B"的操作。如果 A 修改成功但 B 修改失败,我们需要回滚 A 以保证强异常安全: +考虑一个"先修改 A,再修改 B"的操作。如果 A 修改成功但 B 修改失败,咱们需要回滚 A 以保证强异常安全: ```cpp void update_both(SubsystemA& a, SubsystemB& b, const Config& cfg) { @@ -345,48 +345,13 @@ void update_both(SubsystemA& a, SubsystemB& b, const Config& cfg) { ## 标准化进展:std::scope_exit 与 Boost.Scope -scope_guard 模式已经被 C++ 标准委员会注意到。Library Fundamentals TS v3(ISO/IEC TS 19568:2024)定义了三个作用域守卫类模板:`std::experimental::scope_exit`(作用域退出时执行)、`std::experimental::scope_success`(仅在正常退出时执行)和 `std::experimental::scope_fail`(仅在异常退出时执行)。它们的行为与我们上面实现的基本一致,但标准化版本提供了更严格的异常安全保证和更完善的接口约束 —— 比如 `scope_exit` 的构造函数是 `noexcept` 的,并且不允许在构造时抛异常(否则会直接调用 `terminate()`)。 +scope_guard 模式已经被 C++ 标准委员会注意到。Library Fundamentals TS v3(ISO/IEC TS 19568:2024)定义了三个作用域守卫类模板:`std::experimental::scope_exit`(作用域退出时执行)、`std::experimental::scope_success`(仅在正常退出时执行)和 `std::experimental::scope_fail`(仅在异常退出时执行)。它们的行为与咱们上面实现的基本一致,但标准化版本提供了更严格的异常安全保证和更完善的接口约束 —— 比如 `scope_exit` 的构造函数是 `noexcept` 的,并且不允许在构造时抛异常(否则会直接调用 `terminate()`)。 -Boost 库也提供了 Boost.Scope,实现了类似的组件。如果你不想自己实现 scope_guard,可以直接使用 Boost.Scope 或者头文件-only 的 scope-lite 库(Martin Moene 编写,提供与标准提案兼容的接口,支持 C++98 起的编译器)。 +Boost 库也提供了 Boost.Scope,实现了类似的组件。如果您不想自己实现 scope_guard,可以直接使用 Boost.Scope 或者头文件-only 的 scope-lite 库(Martin Moene 编写,提供与标准提案兼容的接口,支持 C++98 起的编译器)。 -在实际项目中,笔者通常的做法是:如果项目已经依赖 Boost,就用 Boost.Scope;如果不想引入 Boost 依赖,就用自己的轻量实现(就像我们今天写的那个 `ScopeGuard`)。从功能完整性来看,我们的基础实现大约 40 行代码,已经覆盖了核心功能 —— 你可以运行 `06-scope-guard-verification.cpp` 看看它在多返回路径、异常处理、事务模式等场景下的实际表现。 +在实际项目中,笔者通常的做法是:如果项目已经依赖 Boost,就用 Boost.Scope;如果不想引入 Boost 依赖,就用自己的轻量实现(就像咱们今天写的那个 `ScopeGuard`)。从功能完整性来看,基础实现大约 40 行代码,已经覆盖了核心功能。 -## 验证代码 - -我们为本章节编写了完整的验证测试,你可以用它来验证 scope_guard 的各种行为: - -```bash -# 编译(使用 g++) -g++ -std=c++17 -Wall -Wextra -O2 \ - code/volumn_codes/vol2/ch01-smart-pointers/06-scope-guard-verification.cpp \ - -o /tmp/06-scope-guard-verification - -# 运行 -/tmp/06-scope-guard-verification -``` - -验证代码包含以下测试用例: - -1. **基础 ScopeGuard** —— 验证作用域退出时执行 -2. **dismiss() 功能** —— 验证取消守卫 -3. **多返回路径** —— 验证提前 return 和正常退出都执行清理 -4. **ScopeFail(异常时执行)** —— 验证异常退出时触发 -5. **ScopeFail(无异常时不执行)** —— 验证正常退出不触发 -6. **ScopeSuccess(正常时执行)** —— 验证正常退出触发 -7. **ScopeSuccess(异常时不执行)** —— 验证异常退出不触发 -8. **事务模式** —— 验证实际事务处理场景 -9. **DEFER 宏模拟** —— 验证资源释放顺序 -10. **std::uncaught_exceptions() 行为** —— 验证异常检测机制 - -这些测试覆盖了我们讨论的所有关键场景,你可以直接运行观察输出,也可以修改代码来测试边界情况。 - -## 小结 - -scope_guard 是 RAII 思想的通用化——不仅管理资源的获取和释放,还管理任何需要在作用域退出时执行的操作。通过把操作包装在一个栈对象的析构函数中,scope_guard 保证了不管控制流如何离开作用域(正常返回、提前 return、异常传播),操作都会被执行。 - -我们今天实现了三个守卫变体:`ScopeGuard`(总是执行)、`ScopeSuccess`(仅正常退出时执行)、`ScopeFail`(仅异常退出时执行),以及 `DEFER` 宏来提供 Go 风格的延迟执行语法。这些工具在事务处理、状态回滚、资源清理等场景中都能简化代码并提高可靠性 —— 你可以运行验证代码看看它们在实际场景中的表现。 - -这个章节到这里就告一段落了。从 RAII 到智能指针(`unique_ptr`、`shared_ptr`、`weak_ptr`),从自定义删除器到侵入式引用计数,再到通用的 scope_guard——我们完整地覆盖了现代 C++ 资源管理的核心工具链。掌握这些工具,就掌握了写出安全、高效、可维护的 C++ 代码的基础。 +ch01 到这里就告一段落。从 RAII 到智能指针(`unique_ptr`、`shared_ptr`、`weak_ptr`),从自定义删除器到侵入式引用计数,再到通用的 scope_guard——现代 C++ 资源管理的核心工具链都过了一遍。下一章聊 `constexpr`,把计算搬到编译期。 ## 参考资源 diff --git a/documents/vol4-advanced/vol3-metaprogramming-cpp20-23/01-concepts.md b/documents/vol4-advanced/vol3-metaprogramming-cpp20-23/01-concepts.md new file mode 100644 index 000000000..b9e12a85c --- /dev/null +++ b/documents/vol4-advanced/vol3-metaprogramming-cpp20-23/01-concepts.md @@ -0,0 +1,204 @@ +--- +chapter: 13 +cpp_standard: +- 20 +description: concept 是命名的编译期谓词,把「这个模板参数必须是什么样」从 enable_if 的黑魔法里拎出来写进签名。讲清它的四种语法形式、和 enable_if 的报错信息对比、标准库常用 concept +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- 类模板:成员、依赖名与惰性实例化 +- 别名模板与 using 声明:给类型起个短名字 +reading_time_minutes: 13 +related: +- 使用 Concepts 约束模板:subsumption 与重载 +- Requires 表达式深度解析:四种成分 +tags: +- host +- cpp-modern +- intermediate +- 模板 +- 泛型 +- concepts +- 类型安全 +title: Concepts:把模板约束写进签名 +--- +# Concepts:把模板约束写进签名 + +卷一咱们写过函数模板,知道 `template T add(T a, T b)` 对 `int`、`double` 都能工作。可一旦您想规定「`add` 只接受数值类型,字符串别来凑」,在 C++17 及以前的日子,标准答案是 `std::enable_if`,把约束塞进一个额外的默认模板参数里,靠 SFINAE 在替换失败时把这个重载踢出去。这套机制能跑,但它把一个简单的要求埋进了模板参数的层层套娃,更要命的是报错信息——出问题的时候,编译器吐出来的是一堆 `enable_if` 的内部展开,您得先懂 SFINAE 才能猜出到底哪条没满足。 + +C++20 的 concepts 解决的就是这件事。它让您**把约束命名出来、写进签名**,像写类型一样写要求,编译器也终于能用「约束没满足」这种人说的话报错。这一篇讲清 concept 到底是什么、它有几种写法、为什么它的报错信息能让您少掉一半头发,以及标准库 `` 里那些现成能用的概念。 + +## concept 是什么:一个命名的编译期谓词 + +concept 本质上是一个**在编译期求值为 bool 的谓词**,只不过您给它起了个名字。名字是关键——有了名字,它就能在签名里出现、在报错里被点名、在多个模板之间复用。 + +```cpp +#include + +// 定义一个 concept:只要 T 是整数或浮点数,就是「数值」 +template +concept Numeric = std::integral || std::floating_point; +``` + +`Numeric` 在编译期要么算出 `true`,要么算出 `false`。它本身不产生任何代码,只是一个带名字的判断。一旦有了这个名字,您就能在模板参数列表里直接用它当约束: + +```cpp +template +T add(T a, T b) { + return a + b; +} +``` + +`template ` 这一行读起来就是「`T` 是个 `Numeric` 类型」。约束从「藏在默认模板参数里的黑魔法」变成了「写在类型位置上的明文要求」。这就是 concept 最核心的价值:它给约束一个名字,让要求变得可读、可复用、可被编译器引用。 + +## 四种语法形式 + +concept 写出来之后,在模板里有四种地方能用上它。咱们同一个 `add` 函数,用四种方式各写一遍,都能编译运行。 + +```cpp +#include +#include +#include + +template +concept Numeric = std::integral || std::floating_point; + +// 形式①:把 concept 直接当约束写在模板参数列表里 +template +T form1(T a, T b) { return a + b; } + +// 形式②:requires 子句(trailing requires-clause),写在参数列表之后 +template + requires Numeric +T form2(T a, T b) { return a + b; } + +// 形式③:简写模板语法(constrained auto),auto 前面跟约束 +auto form3(Numeric auto a, Numeric auto b) { return a + b; } + +// 形式④:模板参数列表后跟 requires,内层是一个 requires 表达式 +template + requires requires(T x) { x + x; } +T form4(T a, T b) { return a + b; } +``` + +形式①最直观,适合「约束就是现成的 concept」的情况。形式②`requires 子句`更灵活,后面会看到它能组合多个条件。形式③是 C++20 的简写语法,写起来像普通函数,`Numeric auto` 等价于一个带约束的模板参数。形式④里出现了两个连着的 `requires`,外层是子句、内层是表达式(下一篇详拆),这种写法不需要预先定义 concept,当场把要求描述出来。 + +跑一下,四种形式各调一次: + +```bash +$ g++ -std=c++20 -Wall -Wextra four_forms.cpp -o four_forms && ./four_forms +form1: 8 +form2: 5 +form3: 30 +form4: ab +``` + +四种写法,四种调用,全部按预期返回。形式④接收 `std::string` 也没问题,因为 `string` 有 `operator+`,内层那个 `requires(T x) { x + x; }` 对它成立。 + +## 报错信息的对比:为什么 concept 让人少掉头发 + +光说「报错更好」是空的,咱们跑跑看。同一个 `add`,分别用 `enable_if` 和 concept 约束成「只接受数值类型」,然后故意用 `std::string` 去调它,看编译器各吐什么。 + +先看 `enable_if` 版的报错(节选): + +```text +add_enable_if.cpp:13:8: error: no matching function for call to 'add(std::string&, std::string&)' + 13 | add(s1, s2); + | ~~~^~~~~~~~ + • candidate 1: 'template T add(T, T)' + • template argument deduction/substitution failed: + • /usr/include/c++/16/type_traits: In substitution of + 'template using std::enable_if_t = ... [with bool _Cond = false; _Tp = void]': + • error: no type named 'type' in 'struct std::enable_if' +``` + +报错的核心是最后那行 `no type named 'type' in 'struct std::enable_if'`。您得知道 `enable_if` 里没有 `type` 这个成员类型,还得知道这是 SFINAE 在替换失败时把这个重载踢出去的表现,才能倒推出「哦,原来是因为 `string` 不是数值类型」。报错信息通篇在讲 `enable_if` 的内部机制,唯独没提您真正关心的那件事:`string` 到底不满足什么。 + +再看 concept 版的报错(节选): + +```text +add_concept.cpp:16:8: error: no matching function for call to 'add(std::string&, std::string&)' + • candidate 1: 'template requires Numeric T add(T, T)' + • template argument deduction/substitution failed: + • constraints not satisfied + • required for the satisfaction of 'Numeric' + [with T = std::__cxx11::basic_string] + concept Numeric = std::integral || std::floating_point; +``` + +关键差别在 `constraints not satisfied` 和 `required for the satisfaction of 'Numeric' [with T = ... basic_string]` 这两句。编译器直接告诉您:`string` 没能满足 `Numeric` 这个约束。约束的名字被点出来了,失败的具体类型也被代进去了。您不用懂 SFINAE,不用去读 `enable_if` 的展开,一眼就知道是哪条规矩没过。 + +::: warning 别拿行数当唯一标准 +在新一点的 GCC(咱们这里用的是 16.1.1)上,两版报错的行数其实差不多——编译器把 `enable_if` 的诊断也优化得结构化了。所以 concept 的优势不在「报错短了几十行」这种老黄历上,而在**信息直接指向约束本身**。您要的是「string 不是 Numeric」,不是「enable_if 没有 type」。换到老一点的编译器(比如 GCC 9/10),行数差距会非常夸张,这也是当年推 concepts 的主要动力之一。 +::: + +报错可读性这一条,是 concept 最直接的收益。您写库给别人用,别人传错类型时看到的不再是天书,而是一句「Numeric 约束没满足」。 + +## 标准库 `` 里现成的概念 + +不用每次都自己造 concept。标准库 `` 提供了一批常用概念,覆盖了类型关系、可构造性、可转换性这些高频场景。挑几个最常碰到的,实际跑一下看它们的判断: + +```cpp +#include +#include +#include +#include + +struct Base {}; +struct Derived : Base {}; +struct Unrelated {}; + +int main() { + std::cout << std::boolalpha; + std::cout << "same_as: " << std::same_as << "\n"; + std::cout << "same_as: " << std::same_as << "\n"; + std::cout << "convertible_to: " << std::convertible_to << "\n"; + std::cout << "derived_from: " << std::derived_from << "\n"; + std::cout << "derived_from: " << std::derived_from << "\n"; + std::cout << "common_with: " << std::common_with << "\n"; + std::cout << "integral: " << std::integral << "\n"; + std::cout << "floating_point: " << std::floating_point << "\n"; +} +``` + +```bash +$ g++ -std=c++20 -Wall -Wextra stdconcepts.cpp -o stdconcepts && ./stdconcepts +same_as: true +same_as: false +convertible_to: true +derived_from: true +derived_from: false +common_with: true +integral: true +floating_point: true +``` + +这里有个容易踩的点。`same_as` 跑出来是 **false**,而您的直觉可能觉得「不都是 int 嘛」。原因是 `const` 限定让它们成为两个不同的类型,`std::is_same_v` 本来就是 false,`same_as` 建立在它之上,自然也是 false。如果您想判断「剥掉 cv 限定和引用之后是不是同一个类型」,得先用 `std::remove_cvref_t` 把限定擦掉再比。 + +`derived_from` 顺带检查了继承的**可达性**(public 继承才算),私有继承的基类这里会返回 false。`convertible_to` 允许隐式窄化,所以 `int` 到 `double`(提升)和 `double` 到 `int`(窄化)都是 true。标准库没有现成的「禁止窄化」概念,做严格的数值类型检查时,要么接受这种宽松行为,要么自己用 `std::is_convertible_v` 配合一个禁窄化的 trait 拦一下。`integral` 是 true,因为 `bool` 在标准里属于整数类型族,写数值算法时要不要把 `bool` 算进去,得您自己用 `std::integral && !std::same_as` 拦一下。 + +这些现成的 concept 基本能覆盖日常九成的类型判断需求。真正需要自己写 concept 的场景,往往是「我这个算法要求类型提供某些操作」,比如「有 `size()` 方法」「能用 `<` 比较」「有 `value_type` 内嵌类型」——这些下篇讲约束、再下篇讲 requires 表达式时,会反复用到。 + +## concept 是个 bool,也能直接拿来判断 + +concept 归根到底是个编译期的 bool,所以它不只能写在签名里,也能在 `static_assert`、`if constexpr` 这些需要编译期判断的地方直接用: + +```cpp +template +concept Numeric = std::integral || std::floating_point; + +static_assert(Numeric); // 编译期断言:int 是数值 +static_assert(!Numeric); // string 不是,断言它「不是」 + +template +void describe(T x) { + if constexpr (Numeric) { + // 编译期分支:T 是数值类型时才编译这段 + } +} +``` + +这一点在下一篇讲「怎么用 concept 约束模板、做重载分派」时会变成主角——有了能当 bool 用的约束,基于约束的函数重载才有了基础。 + +下一篇咱们往深一层走:concept 不只是「写在签名里好看」,它还会真正参与**重载解析**。多个带不同约束的重载放在一起时,编译器靠一种叫 subsumption(约束蕴含)的规则挑出最合适的那一个,这才是 concept 改变泛型代码写法的关键一环。 diff --git a/documents/vol4-advanced/vol3-metaprogramming-cpp20-23/02-constraining-templates.md b/documents/vol4-advanced/vol3-metaprogramming-cpp20-23/02-constraining-templates.md new file mode 100644 index 000000000..5acf29a6a --- /dev/null +++ b/documents/vol4-advanced/vol3-metaprogramming-cpp20-23/02-constraining-templates.md @@ -0,0 +1,210 @@ +--- +chapter: 13 +cpp_standard: +- 20 +description: concept 写进签名只是第一步,它真正改变泛型代码写法的是参与重载解析。讲清 subsumption(约束蕴含)怎么在多个带约束的重载里挑出最合适的一个,以及原子约束那条容易踩的坑 +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- Concepts:把模板约束写进签名 +reading_time_minutes: 14 +related: +- Concepts:把模板约束写进签名 +- Requires 表达式深度解析:四种成分 +tags: +- host +- cpp-modern +- intermediate +- 模板 +- 泛型 +- concepts +- 类型安全 +title: 使用 Concepts 约束模板:subsumption 与重载 +--- +# 使用 Concepts 约束模板:subsumption 与重载 + +上一篇咱们把 concept 写进了签名,看到了报错信息怎么从 `enable_if` 的内部展开变成一句「约束没满足」。但 concept 真正改变泛型代码写法的,是另一件事:它让模板**重载**变得可行。在 C++17 及以前,两个函数模板除非参数个数或类型明显不同,否则很容易撞车,靠 `enable_if` 做条件重载写得极其别扭。有了 concept,您可以写一堆约束各不相同的同名重载,让编译器根据实参满足哪个约束来挑。挑的规则叫 **subsumption**(约束蕴含),这是这一篇的主角。 + +## 先分清两个同名词:requires 子句与 requires 表达式 + +往下走之前,得先把 `requires` 这个词的两种用法分清,否则后面会越看越晕。 + +**requires 子句(requires-clause)** 出现在模板参数列表之后,作用是「给模板加一个约束条件」。上一篇形式②见过了: + +```cpp +template + requires Numeric // 这整句是 requires 子句 +T add(T a, T b) { return a + b; } +``` + +**requires 表达式(requires-expression)** 是一个能在编译期算出 `bool` 值的表达式,用来当场描述「类型要提供哪些操作」。下一篇会专门拆它,这里先看一眼: + +```cpp +requires(T t) { t + t; t.size(); } // 这是一个 requires 表达式,值为 bool +``` + +两者的区别在于:子句是给模板「立规矩」的语法位置,表达式是描述规矩内容、产生真假值的算式。子句里常常塞一个表达式,比如 `requires requires(T t){ t+t; }`(外层子句,内层表达式),这就是上一篇形式④那个连写两个 `requires` 的来历。这一篇重点讲子句怎么用、约束怎么参与重载,表达式留给下一篇。 + +## 约束可以用在哪 + +concept 的约束不止能加在自由函数模板上。函数模板、类模板、成员函数、甚至简写 `auto` 参数,都能约束。一个综合例子: + +```cpp +#include + +template +concept Numeric = std::integral || std::floating_point; + +// ① 函数模板 +template +T square(T x) { return x * x; } + +// ② 类模板:只对数值类型实例化 +template +struct SafeNumber { + T value; + SafeNumber(T v) : value(v) {} + // ③ 成员函数也能再加自己的约束 + SafeNumber& operator+=(Numeric auto other) { + value += other; + return *this; + } +}; + +// ④ 简写语法:约束直接写在 auto 前 +Numeric auto half(Numeric auto x) { return x / 2; } +``` + +```bash +$ g++ -std=c++20 -Wall -Wextra constraints_everywhere.cpp -o ce && ./ce +square(4) = 16 +square(2.5) = 6.25 +SafeNumber(3) + 4 = 7 +half(10) = 5 +``` + +四种位置都能编译。需要特别留心类模板:一旦给类模板加了约束,`SafeNumber` 这种不满足 `Numeric` 的实例化会直接在声明处报约束失败,而不是等到用某个成员才炸。约束把「这个类只能给数值用」这件事提前到了实例化那一刻。 + +## subsumption:编译器靠约束蕴含挑重载 + +现在进入正题。咱们写两个同名重载,一个要求宽一点、一个要求窄一点,看编译器怎么选。 + +```cpp +#include +#include + +template +concept Animal = requires(T t) { t.eat(); }; + +template +concept Dog = Animal && requires(T t) { t.bark(); }; // Dog 比 Animal 多要一个 bark() + +void describe(Animal auto) { std::cout << "an animal\n"; } // 宽重载 +void describe(Dog auto) { std::cout << "a dog\n"; } // 窄重载 + +struct Cat { void eat() {} }; +struct Pup { void eat() {} void bark() {} }; + +int main() { + describe(Cat{}); // Cat 只满足 Animal + describe(Pup{}); // Pup 同时满足 Animal 和 Dog +} +``` + +```bash +$ g++ -std=c++20 -Wall -Wextra subsumption_basic.cpp -o sub1 && ./sub1 +an animal +a dog +``` + +`Cat` 只满足 `Animal`,没有第二个重载可选,走宽重载。`Pup` 同时满足 `Animal` 和 `Dog`,但走的是**窄重载** `Dog`。这就是 subsumption 在起作用:`Dog` 的要求是 `Animal && bark要求`,它把 `Animal` 的要求全包进去了,咱们说 **`Dog` 蕴含(subsumes)`Animal`**。两个重载都能匹配时,编译器选约束更紧、更特定的那个。 + +这件事在 SFINAE 时代要写成什么样?得用 `enable_if` 配合「取地址成员函数有没有」的层层探测,或者上 tag dispatch,代码量翻几倍还不一定读得懂。concept 把「谁更特定」这件事交给编译器按约束关系算,这才是它改变泛型代码的根本。 + +## 两个互不蕴含的约束:会歧义 + +subsumption 只在「一个约束严格蕴含另一个」时帮您消歧。如果两个约束彼此独立、谁也不包含谁,而某个类型同时满足两者,编译器就选不出来了。 + +```cpp +template concept Swimmable = requires(T t) { t.swim(); }; +template concept Flyable = requires(T t) { t.fly(); }; + +void act(Swimmable auto) { /* ... */ } +void act(Flyable auto) { /* ... */ } + +struct Duck { void swim() {} void fly() {} }; // 两个都满足 + +int main() { act(Duck{}); } // 谁也不 subsume 谁 +``` + +```text +ambiguity.cpp:12:8: error: call of overloaded 'act(Duck)' is ambiguous +``` + +`Swimmable` 和 `Flyable` 互不蕴含,`Duck` 同时满足,编译器没有理由偏向哪一个,直接报歧义。解决办法要么再加一个 `Duck = Swimmable && Flyable` 的窄重载(它同时 subsumes 两个,会被选中),要么在调用点显式写 `act<具体类型>`。关键是理解:**subsumption 只解决「有包含关系」的歧义,解决不了「平级竞争」的歧义**。 + +## 原子约束:决定 subsumption 的真正单位 + +上面 `Dog` 蕴含 `Animal` 是怎么算出来的?这就得讲**原子约束**。编译器不会看「`Dog` 和 `Animal` 这两个名字谁包含谁」,它会把约束拆成一堆最小的、不可再分的原子约束,然后看集合的包含关系。 + +`concept Dog = Animal && bark要求` 规范化之后,原子约束集合是 `{ Animal, bark要求 }`。`Animal` 的原子约束集合是 `{ Animal }`。前者是后者的真超集,所以 `Dog` subsumes `Animal`。 + +这里有个特别容易栽的坑,咱们直接跑一个看。直觉上,「`C2 = C1`」这种把一个 concept 原样赋给另一个的写法,好像 `C2` 应该比 `C1` 更特定吧?跑一下: + +```cpp +template concept C1 = std::is_integral_v; +template concept C2 = C1; // 只是换了名字 + +void g(C1 auto) { /* ... */ } +void g(C2 auto) { /* ... */ } + +int main() { g(42); } // int 同时满足 C1 和 C2 +``` + +```text +atomic.cpp:14:15: error: call of overloaded 'g(int)' is ambiguous +``` + +歧义了。为什么?因为 `C2 = C1` 规范化之后,原子约束就是 `C1` 本身,和 `C1` 的原子约束**一模一样**。两个重载的约束集合相等,既不互相真包含,谁也不 subsume 谁,就退回普通歧义。换个名字不会凭空造出一个「更特定」的关系,subsumption 要的是原子约束集合的**真包含**,名字换不换不影响。 + +让 `C2` 真正胜出,得给它加一个 `C1` 之外的原子约束。`&&` 组合恰好干这个: + +```cpp +template concept A = requires(T t){ t.a(); }; +template concept B = requires(T t){ t.b(); }; +template concept C = A && B; // 原子约束 = { A, B } + +void f(A auto) { /* ... */ } +void f(B auto) { /* ... */ } +void f(C auto) { /* ... */ } // C subsumes A,也 subsumes B + +int main() { struct S{ void a(){} void b(){} } s; f(s); } +``` + +```bash +$ g++ -std=c++20 -Wall -Wextra conjunction.cpp -o conj && ./conj +C +``` + +`C` 的原子约束集合 `{ A, B }` 同时是 `{ A }` 和 `{ B }` 的超集,所以它 subsumes `A` 也 subsumes `B`,三选一时编译器选了 `C`。这就把 `&&` 的真实角色点清楚了:`&&` 不是「把约束拼成一个新的」,而是把两边的原子约束**并集**进同一个集合。 + +::: warning 约束不是看名字,是看原子 +subsumption 比较的是规范化后的**原子约束集合**,不是 concept 的名字。`C2 = C1` 不会让 `C2` 比 `C1` 更特定,因为它们的原子约束相同。想让一个重载胜出,得让它的原子约束集合是对方的真超集,最常用的办法就是用 `&&` 再叠一个约束。记住这一条,后面写带约束的重载族时就不会被「明明名字不一样怎么还歧义」绕进去。 +::: + +## 坑:别把 concept 当 is_same 用 + +`std::same_as` 这个概念容易把人带歪。它能写 `template T>`,把 `T` 钉成必须恰好是 `int`。 + +```cpp +template T> +void only_int(T x) { /* ... */ } + +only_int(42); // 没问题 +// only_int(3.14); // 编译失败:double 不满足 same_as +``` + +能跑,但这通常不是好设计。如果您的函数只接受 `int`,直接写一个普通的非模板函数 `void only_int(int x)` 更清楚、更省事,还能让编译器少实例化一个模板。`same_as` 这类「类型等价」约束真正的用武之地,是在模板内部对**两个参数**之间的关系做要求,比如 `template requires std::same_as`,约束「A 和 B 必须是同一个类型」。拿它去把单个模板参数锁死成某个具体类型,是用错了工具。 + +concept 写进签名、参与重载,这两件事凑齐,泛型代码就能写得既清楚又有分派能力。下一篇咱们把 `requires` 那个最容易混的表达式形式拆透——它能当场描述「类型要提供哪些操作」,是上面所有 `requires(T t){ ... }` 写法的基础。 diff --git a/documents/vol4-advanced/vol3-metaprogramming-cpp20-23/03-requires-expressions.md b/documents/vol4-advanced/vol3-metaprogramming-cpp20-23/03-requires-expressions.md new file mode 100644 index 000000000..e88bf0e57 --- /dev/null +++ b/documents/vol4-advanced/vol3-metaprogramming-cpp20-23/03-requires-expressions.md @@ -0,0 +1,181 @@ +--- +chapter: 13 +cpp_standard: +- 20 +description: requires 这个词在 C++20 里既是子句又是表达式,最容易混。把 requires 表达式拆透:四种成分(简单/类型/复合/嵌套)、怎么用它定义 concept,以及「不求值」和「对具体类型硬错误」这两个坑 +difficulty: intermediate +order: 3 +platform: host +prerequisites: +- 使用 Concepts 约束模板:subsumption 与重载 +- Concepts:把模板约束写进签名 +reading_time_minutes: 13 +related: +- Concepts:把模板约束写进签名 +- 使用 Concepts 约束模板:subsumption 与重载 +tags: +- host +- cpp-modern +- intermediate +- 模板 +- 泛型 +- concepts +- 类型安全 +title: Requires 表达式深度解析:四种成分 +--- +# Requires 表达式深度解析:四种成分 + +前两篇里 `requires` 这个词反复出现,有时候是子句,有时候是表达式,长得一样干的却不是一回事。这一篇把它彻底拆开:什么是 requires 表达式、它有哪几种成分、怎么用它当场描述「类型要提供哪些操作」,以及两个最容易让人困惑的坑。读完这一篇,上面所有 `requires(T t){ ... }` 的写法您都能看明白来历。 + +## 两种 requires:子句与表达式 + +先把同名的两种东西放一张表对照,这是后面所有内容的基础。 + +| | requires 子句(requires-clause) | requires 表达式(requires-expression) | +|---|---|---| +| **是什么** | 给模板加约束的语法位置 | 一个能在编译期算出 bool 的表达式 | +| **长什么样** | `requires Numeric` | `requires(T t) { t.size(); }` | +| **值** | 不是值,是约束声明 | 一个 bool(true / false) | +| **出现位置** | 模板参数列表之后 | 几乎任何需要 bool 的地方:子句里、concept 定义里、`static_assert` 里 | + +上一篇讲的 subsumption、重载分派,主角是**子句**。这一篇的主角是**表达式**。两者常配合使用:子句里塞一个表达式,就是 `requires requires(T t){ t+t; }` 那种连写两个 `requires` 的样子——外层是子句,内层是表达式。 + +## requires 表达式的四种成分 + +一个 requires 表达式 `requires(参数) { ... }` 的大括号里,可以写四种不同的「要求」。咱们定义一个 `Container` 概念,把四种成分一次用全: + +```cpp +#include +#include + +template +concept Container = requires(T t) { + // ① 简单要求(simple requirement):表达式必须合法,能编译通过即可 + t.begin(); + t.end(); + + // ② 类型要求(type requirement):必须有这个内嵌类型 + typename T::value_type; + + // ③ 复合要求(compound requirement):表达式合法,且返回值满足某个约束 + { t.size() } -> std::convertible_to; + + // ④ 嵌套要求(nested requirement):再嵌一条编译期 bool 判断 + requires std::integral; +}; + +static_assert(Container>); // vector 四条全满足 +static_assert(!Container); // int 没有 begin/end,第一条就挂 +``` + +这两个 `static_assert` 是编译期断言,代码能编过就说明 `vector` 满足 `Container`、`int` 不满足,不需要运行。 + +四种成分各有用处。简单要求最常用,`t.begin();` 只是在问「`T` 类型的对象能不能调 `begin()`」,能编译就算过。类型要求 `typename T::value_type;` 检查内嵌类型存不存在,容器、迭代器的 trait 检查里频繁出现。复合要求 `{ t.size() } -> std::convertible_to;` 把「表达式合法」和「返回类型满足约束」绑在一起,比先检查能不能调、再用 `decltype` 查返回类型分两步写更紧凑,复合要求还能加 `noexcept`:`{ t.size() } noexcept -> std::convertible_to;`,要求这个调用还不抛异常。嵌套要求 `requires std::integral<...>;` 让您在表达式内部再塞一条 concept 判断,适合「主要求满足后,还要额外满足某条」的场景。 + +顺带提一个容易看走眼的点:`Container` 的第④条要求 `value_type` 是整数类型,而 `std::integral` 是 **true**(char 属于整数类型族,和上一篇 `integral` 是 true 一个道理)。所以 `Container` 实际上是满足的——string 的 `value_type` 是 char,过得了第④条。您要是只想收 `value_type` 恰好是 `int` 的容器,得把第④条换成 `std::same_as`。 + +## requires 表达式是个 bool:不一定非要起 concept 名 + +requires 表达式算出来是个 bool,所以它不只能住在 concept 定义里,任何要编译期判断的地方都能直接用。 + +```cpp +#include + +// 直接塞进 static_assert,不用先定义 concept +static_assert(requires(std::string s) { s.size(); }); // string 有 size() + +// 直接用在 if constexpr 里做编译期分支 +template +void process(T t) { + if constexpr (requires(T x) { x.empty(); }) { + std::cout << "has empty()\n"; + } else { + std::cout << "no empty()\n"; + } +} +``` + +```bash +$ g++ -std=c++20 -Wall -Wextra four_requirements2.cpp -o fr2 && ./fr2 +has empty() // std::vector 有 empty() +no empty() // int 没有 +``` + +临时检查一下「这个类型有没有某个操作」,用内联 requires 表达式最省事。但要注意一个取舍:内联表达式没有名字,它不形成可复用的原子约束。上一篇讲 subsumption 时说过,重载分派靠命名的 concept 建立蕴含关系。如果您想让两个重载靠约束分派,得用命名的 concept(`concept C = requires(...){...}`),内联表达式做不到 subsumption。所以「只在某一处用一次」的检查用内联表达式,「要参与重载或反复复用」的要求提成 concept。 + +## 坑一:requires 表达式不求值 + +这是最反直觉的一个坑。requires 表达式里写的那些调用,只是**检查能不能编译**,根本不会真正执行。咱们直接跑一个看证据。 + +```cpp +#include + +int counter = 0; +int increment() { + ++counter; + std::cout << "[副作用] increment 被调用了\n"; + return 1; +} + +template +concept MentionsIncrement = requires(T t) { + increment(); // 只检查「这个调用合不合法」,不求值、不执行 +}; + +int main() { + static_assert(MentionsIncrement); // 满足:increment() 调用合法 + std::cout << "concept 求值完毕,counter = " << counter << "\n"; + increment(); // 这里才真正调用 + std::cout << "真正调用后,counter = " << counter << "\n"; +} +``` + +```bash +$ g++ -std=c++20 -Wall -Wextra unevaluated.cpp -o ue && ./ue +concept 求值完毕,counter = 0 +[副作用] increment 被调用了 +真正调用后,counter = 1 +``` + +看清楚 `counter = 0` 那行。`MentionsIncrement` 求值时,requires 表达式里的 `increment()` 调用**完全没有执行**——counter 还是 0,副作用那行也没打印。直到 `main` 里真正写了一句 `increment()`,counter 才变成 1。 + +requires 表达式和 `decltype`、`sizeof` 一样属于**不求值上下文(unevaluated context)**。编译器只关心里面的表达式「类型上合不合法」,不会生成调用代码,更不会触发任何副作用。这一点初学者特别容易栽跟头:在 requires 表达式里写了个「看起来在初始化」「看起来在计算」的式子,以为它跑了,其实什么都没发生。判断类型能力用 requires 表达式,真正要让代码跑起来还得在普通函数体里写。 + +## 坑二:对具体类型直接写,会硬错误 + +第二个坑更隐蔽,是上一个的延伸。咱们想测「string 没有某个方法」,直觉写法是在 requires 表达式里用 string 当参数: + +```cpp +// 直觉写法:拿具体类型 string 直接测负例 +static_assert(!requires(std::string s) { s.nope(); }); // string 没有 nope +``` + +```text +four_requirements2.cpp:17:44: error: 'std::string' has no member named 'nope' +``` + +报的是硬错误,不是优雅的 false。为什么?因为 requires 表达式对**具体类型**是「立即求值」的——编译器看到 `std::string s` 这个具体类型,直接去 string 里找 `nope`,找不到就硬报错,不走 SFINAE 那套「替换失败就返回 false」的机制。换个更刺眼的例子,`requires(int x) { x.foo(); }` 会报 `request for member 'foo' in 'x', which is of non-class type 'int'`,因为 `int` 这种基本类型根本不能写 `.foo()` 语法,解析阶段就过不去。 + +解决办法是让 requires 表达式处在**模板上下文**里,最常见的做法是把它包进一个 concept: + +```cpp +template concept HasSize = requires(T t) { t.size(); }; +template concept HasNope = requires(T t) { t.nope(); }; + +static_assert(HasSize); // string 有 size -> true +static_assert(!HasNope); // string 没 nope -> false,这次优雅了 +static_assert(!HasSize); // int 没 size -> false +``` + +```bash +$ g++ -std=c++20 -Wall -Wextra neg_via_concept.cpp -o nvc && echo "全部断言通过" +全部断言通过 +``` + +包进 concept 之后,T 是模板参数,requires 表达式求值时走的是 SFINAE 友好路径:找不到成员就算 false,不硬报错。所以写测试断言时,负例尽量用命名的 concept 包装,别拿具体类型直接往 requires 表达式里塞。 + +::: warning 两个坑是一回事的两面 +不求值和具体类型硬错误,根子都在 requires 表达式的求值时机上。requires 表达式对模板参数是「延迟、SFINAE 友好」的,所以不执行(不求值)、失败返回 false;对具体类型是「立即」的,所以同样不执行,但失败直接硬错误。记住一条:requires 表达式只检查「能不能编译」,从不执行;要让它优雅地返回 false,把它放在模板上下文(通常就是包成 concept)里。 +::: + +三种东西——四种成分、不求值、模板上下文——凑齐,requires 表达式这个 C++20 里最容易混的词就算讲透了。下一篇咱们从另一个方向看模板的编译期能力:在 concepts 出现之前,模板元编程(TMP)是怎么用特化和 SFINAE 做编译期计算和类型推导的,以及现在怎么把这些老技巧往 concepts 上迁。 diff --git a/documents/vol4-advanced/vol3-metaprogramming-cpp20-23/index.md b/documents/vol4-advanced/vol3-metaprogramming-cpp20-23/index.md index d77bc51f6..10821df2f 100644 --- a/documents/vol4-advanced/vol3-metaprogramming-cpp20-23/index.md +++ b/documents/vol4-advanced/vol3-metaprogramming-cpp20-23/index.md @@ -1,22 +1,20 @@ --- title: "元编程精要(C++20-23)" -description: "C++20/23 元编程:Concepts、requires 表达式、TMP 核心技巧、编译期字符串" +description: "C++20/23 元编程:Concepts、requires 表达式、TMP 核心技巧、编译期字符串、C++26 反射" --- # 元编程精要(C++20-23) -> 状态:待编写 +本部分接着 vol1 的模板基础往下走。vol1 讲的是模板的编译模型、特化、两阶段查找这些「机制」,这一部分讲的是怎么用这些机制做**编译期计算和类型推导**,以及 C++20 的 Concepts 如何把过去依赖 SFINAE、`enable_if` 的苦活改写成可读的约束。 -本部分深入探讨现代 C++ 的约束机制和高级元编程技术,覆盖 Concepts、requires 表达式、TMP 核心技巧、编译期字符串、反射基础、实例化控制、异常安全。 +三条主线:Concepts 和 requires 表达式(C++20)、经典模板元编程(TMP)技巧和它的现代化、编译期字符串与 C++26 反射。每篇都带「上手跑一跑」的真实编译输出,该踩的坑写成 `::: warning` 预警块,关键的编译期行为用汇编佐证。 -## 本章内容(待编写) +配套可运行示例在 [code/examples/vol4/vol3-metaprogramming-cpp20-23/](https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP/tree/main/code/examples/vol4/vol3-metaprogramming-cpp20-23),每个文件 `g++ -std=c++20 xxx.cpp` 直接跑。 -1. Concepts 详解 -2. 使用 Concepts 约束模板 -3. Requires 表达式深度解析 -4. TMP 核心技巧 -5. 编译期字符串处理 -6. 反射元编程基础 -7. 模板实例化控制 -8. 模板与异常安全 -9. 综合项目:mini-STL 算法库 + + Concepts:把模板约束写进签名 + 使用 Concepts 约束模板:subsumption 与重载 + Requires 表达式深度解析:四种成分 + + +Concepts 三连(01-03)是这一部分的地基,讲清约束怎么写、怎么参与重载、requires 表达式的四种成分和它的两个坑。后续会接着讲:TMP 核心技巧(type_traits 内幕、`void_t` SFINAE、typelist、fold expressions,以及 SFINAE 往 concepts 的迁移)、编译期字符串处理(C++20 NTTP 的 class type 与 `fixed_string`)、C++26 静态反射(P2996 的 `^` 与 `[: :]`)、模板实例化控制、模板与异常安全,最后用一个 concepts 约束的 mini-STL 算法库把前面焊在一起。