From f50bfd1f07758ff0659e1ee59761a7a00735415b Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Wed, 8 Jul 2026 17:52:16 +0800 Subject: [PATCH 1/2] feat: vol9 chrome opensources: weak_ptr, flat_map, no_destructor --- README.md | 2 +- .../chrome_design/12_intrusive_refcount.cpp | 117 +++++ .../chrome_design/13_atomic_flag.cpp | 59 +++ .../chrome_design/14_sequence_checker.cpp | 80 ++++ .../chrome_design/15_concepts_requires.cpp | 79 ++++ .../chrome_design/16_weak_ptr_skeleton.cpp | 38 ++ .../chrome_design/17_weak_ptr_factory.cpp | 57 +++ .../chrome_design/18_bind_weakptr_cancel.cpp | 75 ++++ .../chrome_design/19_flat_map_basic.cpp | 48 ++ .../chrome_design/20_lookup_vs_shift_perf.cpp | 67 +++ .../chrome_design/21_sorted_unique_tag.cpp | 45 ++ .../chrome_design/22_bulk_build_extract.cpp | 55 +++ .../chrome_design/23_no_destructor_basic.cpp | 45 ++ .../24_placement_new_and_storage.cpp | 46 ++ .../25_static_assert_gatekeep.cpp | 42 ++ .../chrome_design/CMakeLists.txt | 26 +- .../full_tutorial_codes/chrome_design/cm.cpp | 10 + .../chrome_design/flat_map.hpp | 229 ++++++++++ .../chrome_design/ho02_find.o | Bin 0 -> 1280 bytes .../chrome_design/no_destructor.hpp | 57 +++ .../chrome_design/weak_ptr.hpp | 270 ++++++++++++ ...once-callback-motivation-and-api-design.md | 226 +++++----- .../full/01-2-once-callback-core-skeleton.md | 164 +++---- .../full/01-3-once-callback-bind-once.md | 206 ++++----- .../01-4-once-callback-cancellation-token.md | 226 +++++----- .../full/01-5-once-callback-then-chaining.md | 239 +++++----- .../01-6-once-callback-testing-and-perf.md | 126 +++--- .../pre-00-once-callback-cpp-basics-review.md | 313 +++++++------ ...llback-function-type-and-specialization.md | 191 ++++---- ...re-02-once-callback-invoke-and-callable.md | 249 +++++------ .../pre-03-once-callback-lambda-advanced.md | 237 +++++----- ...-04-once-callback-concepts-and-requires.md | 241 +++++----- ...pre-05-once-callback-move-only-function.md | 226 +++++----- .../pre-06-once-callback-deducing-this.md | 222 ++++------ .../hands_on/01-once-callback-design.md | 315 ++++++------- .../02-once-callback-implementation.md | 414 +++++++++--------- .../hands_on/03-once-callback-testing.md | 342 ++++++++------- ...02-1-weak-ptr-motivation-and-api-design.md | 269 ++++++++++++ ...eak-ptr-core-skeleton-and-control-block.md | 327 ++++++++++++++ .../02-3-weak-ptr-factory-and-last-member.md | 257 +++++++++++ ...-ptr-sequence-affinity-and-lazy-binding.md | 209 +++++++++ .../full/02-5-weak-ptr-bind-integration.md | 295 +++++++++++++ .../full/02-6-weak-ptr-testing-and-perf.md | 236 ++++++++++ ...00-weak-ptr-weak-reference-and-lifetime.md | 213 +++++++++ ...tr-intrusive-refcount-and-scoped-refptr.md | 283 ++++++++++++ ...pre-02-weak-ptr-atomic-and-memory-order.md | 202 +++++++++ ...-weak-ptr-sequence-checker-dcheck-check.md | 123 ++++++ .../pre-04-weak-ptr-concepts-and-requires.md | 177 ++++++++ ...-weak-ptr-template-friend-and-uintptr-t.md | 183 ++++++++ .../full/pre-06-weak-ptr-trivial-abi.md | 102 +++++ .../hands_on/01-weak-ptr-design.md | 133 ++++++ .../hands_on/02-weak-ptr-implementation.md | 301 +++++++++++++ .../hands_on/03-weak-ptr-testing.md | 145 ++++++ .../chrome/02_weak_ptr/index.md | 34 ++ ...03-1-flat-map-motivation-and-api-design.md | 203 +++++++++ .../full/03-2-flat-map-flattree-skeleton.md | 282 ++++++++++++ .../full/03-3-flat-map-lookup-and-insert.md | 214 +++++++++ ...3-4-flat-map-sorted-unique-construction.md | 183 ++++++++ ...ap-iterator-invalidation-and-bulk-build.md | 152 +++++++ .../full/03-6-flat-map-testing-and-perf.md | 156 +++++++ ...-flat-map-ordered-assoc-container-intro.md | 101 +++++ ...01-flat-map-vector-internals-and-growth.md | 112 +++++ ...re-02-flat-map-complexity-and-amortized.md | 117 +++++ ...-03-flat-map-comparator-and-transparent.md | 159 +++++++ ...flat-map-tag-dispatch-and-sorted-unique.md | 176 ++++++++ ...e-05-flat-map-enua-ebo-and-pair-storage.md | 169 +++++++ .../hands_on/01-flat-map-design.md | 89 ++++ .../hands_on/02-flat-map-implementation.md | 270 ++++++++++++ .../hands_on/03-flat-map-testing.md | 126 ++++++ .../chrome/03_flat_map/index.md | 31 ++ .../04-1-no-destructor-motivation-and-api.md | 220 ++++++++++ .../full/04-2-no-destructor-core-impl.md | 225 ++++++++++ .../full/04-3-no-destructor-when-to-use.md | 154 +++++++ .../full/04-4-no-destructor-lsan-and-leak.md | 105 +++++ .../full/pre-00-static-storage-and-init.md | 158 +++++++ ...re-01-placement-new-and-aligned-storage.md | 171 ++++++++ .../01-no-destructor-design-and-impl.md | 123 ++++++ .../02-no-destructor-usage-and-testing.md | 83 ++++ .../chrome/04_no_destructor/index.md | 14 + .../vol9-open-source-project-learn/index.md | 9 +- .../chrome/.pages | 3 + ...once-callback-motivation-and-api-design.md | 82 ++-- .../full/01-2-once-callback-core-skeleton.md | 68 +-- .../full/01-3-once-callback-bind-once.md | 95 ++-- .../01-4-once-callback-cancellation-token.md | 81 ++-- .../full/01-5-once-callback-then-chaining.md | 84 ++-- .../01-6-once-callback-testing-and-perf.md | 56 +-- .../pre-00-once-callback-cpp-basics-review.md | 119 +++-- ...llback-function-type-and-specialization.md | 84 ++-- ...re-02-once-callback-invoke-and-callable.md | 118 ++--- .../pre-03-once-callback-lambda-advanced.md | 71 ++- ...-04-once-callback-concepts-and-requires.md | 97 ++-- ...pre-05-once-callback-move-only-function.md | 107 ++--- .../pre-06-once-callback-deducing-this.md | 104 ++--- .../hands_on/01-once-callback-design.md | 150 +++---- .../02-once-callback-implementation.md | 178 ++++---- .../hands_on/03-once-callback-testing.md | 78 ++-- .../chrome/01_once_callback/hands_on/index.md | 2 +- ...02-1-weak-ptr-motivation-and-api-design.md | 268 ++++++++++++ ...eak-ptr-core-skeleton-and-control-block.md | 323 ++++++++++++++ .../02-3-weak-ptr-factory-and-last-member.md | 258 +++++++++++ ...-ptr-sequence-affinity-and-lazy-binding.md | 208 +++++++++ .../full/02-5-weak-ptr-bind-integration.md | 294 +++++++++++++ .../full/02-6-weak-ptr-testing-and-perf.md | 237 ++++++++++ ...00-weak-ptr-weak-reference-and-lifetime.md | 213 +++++++++ ...tr-intrusive-refcount-and-scoped-refptr.md | 282 ++++++++++++ ...pre-02-weak-ptr-atomic-and-memory-order.md | 203 +++++++++ ...-weak-ptr-sequence-checker-dcheck-check.md | 124 ++++++ .../pre-04-weak-ptr-concepts-and-requires.md | 178 ++++++++ ...-weak-ptr-template-friend-and-uintptr-t.md | 184 ++++++++ .../full/pre-06-weak-ptr-trivial-abi.md | 103 +++++ .../hands_on/01-weak-ptr-design.md | 137 ++++++ .../hands_on/02-weak-ptr-implementation.md | 302 +++++++++++++ .../hands_on/03-weak-ptr-testing.md | 145 ++++++ .../chrome/02_weak_ptr/index.md | 34 ++ ...03-1-flat-map-motivation-and-api-design.md | 204 +++++++++ .../full/03-2-flat-map-flattree-skeleton.md | 283 ++++++++++++ .../full/03-3-flat-map-lookup-and-insert.md | 215 +++++++++ ...3-4-flat-map-sorted-unique-construction.md | 184 ++++++++ ...ap-iterator-invalidation-and-bulk-build.md | 153 +++++++ .../full/03-6-flat-map-testing-and-perf.md | 157 +++++++ ...-flat-map-ordered-assoc-container-intro.md | 102 +++++ ...01-flat-map-vector-internals-and-growth.md | 113 +++++ ...re-02-flat-map-complexity-and-amortized.md | 118 +++++ ...-03-flat-map-comparator-and-transparent.md | 160 +++++++ ...flat-map-tag-dispatch-and-sorted-unique.md | 177 ++++++++ ...e-05-flat-map-enua-ebo-and-pair-storage.md | 170 +++++++ .../hands_on/01-flat-map-design.md | 90 ++++ .../hands_on/02-flat-map-implementation.md | 271 ++++++++++++ .../hands_on/03-flat-map-testing.md | 127 ++++++ .../chrome/03_flat_map/index.md | 31 ++ .../04-1-no-destructor-motivation-and-api.md | 221 ++++++++++ .../full/04-2-no-destructor-core-impl.md | 226 ++++++++++ .../full/04-3-no-destructor-when-to-use.md | 155 +++++++ .../full/04-4-no-destructor-lsan-and-leak.md | 106 +++++ .../full/pre-00-static-storage-and-init.md | 159 +++++++ ...re-01-placement-new-and-aligned-storage.md | 170 +++++++ .../01-no-destructor-design-and-impl.md | 123 ++++++ .../02-no-destructor-usage-and-testing.md | 84 ++++ .../chrome/04_no_destructor/index.md | 14 + .../vol9-open-source-project-learn/index.md | 5 +- .../theme/components/OnlineCompilerDemo.vue | 1 + site/.vitepress/theme/custom.css | 2 + 143 files changed, 18501 insertions(+), 3065 deletions(-) create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/12_intrusive_refcount.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/13_atomic_flag.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/14_sequence_checker.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/15_concepts_requires.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/16_weak_ptr_skeleton.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/17_weak_ptr_factory.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/18_bind_weakptr_cancel.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/19_flat_map_basic.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/20_lookup_vs_shift_perf.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/21_sorted_unique_tag.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/22_bulk_build_extract.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/23_no_destructor_basic.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/24_placement_new_and_storage.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/25_static_assert_gatekeep.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/cm.cpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/flat_map.hpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/ho02_find.o create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/no_destructor.hpp create mode 100644 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/weak_ptr.hpp create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-1-weak-ptr-motivation-and-api-design.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-2-weak-ptr-core-skeleton-and-control-block.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-4-weak-ptr-sequence-affinity-and-lazy-binding.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-5-weak-ptr-bind-integration.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-6-weak-ptr-testing-and-perf.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-00-weak-ptr-weak-reference-and-lifetime.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-02-weak-ptr-atomic-and-memory-order.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-03-weak-ptr-sequence-checker-dcheck-check.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-04-weak-ptr-concepts-and-requires.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-05-weak-ptr-template-friend-and-uintptr-t.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/01-weak-ptr-design.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/02-weak-ptr-implementation.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/03-weak-ptr-testing.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/index.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-1-flat-map-motivation-and-api-design.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-2-flat-map-flattree-skeleton.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-3-flat-map-lookup-and-insert.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-4-flat-map-sorted-unique-construction.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-5-flat-map-iterator-invalidation-and-bulk-build.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-6-flat-map-testing-and-perf.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-00-flat-map-ordered-assoc-container-intro.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-01-flat-map-vector-internals-and-growth.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-02-flat-map-complexity-and-amortized.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-03-flat-map-comparator-and-transparent.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-04-flat-map-tag-dispatch-and-sorted-unique.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-05-flat-map-enua-ebo-and-pair-storage.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/01-flat-map-design.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/02-flat-map-implementation.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/03-flat-map-testing.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/03_flat_map/index.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-1-no-destructor-motivation-and-api.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-2-no-destructor-core-impl.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-3-no-destructor-when-to-use.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-4-no-destructor-lsan-and-leak.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-00-static-storage-and-init.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-01-placement-new-and-aligned-storage.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/01-no-destructor-design-and-impl.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/02-no-destructor-usage-and-testing.md create mode 100644 documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/index.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-1-weak-ptr-motivation-and-api-design.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-2-weak-ptr-core-skeleton-and-control-block.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-4-weak-ptr-sequence-affinity-and-lazy-binding.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-5-weak-ptr-bind-integration.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-6-weak-ptr-testing-and-perf.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-00-weak-ptr-weak-reference-and-lifetime.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-02-weak-ptr-atomic-and-memory-order.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-03-weak-ptr-sequence-checker-dcheck-check.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-04-weak-ptr-concepts-and-requires.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-05-weak-ptr-template-friend-and-uintptr-t.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/01-weak-ptr-design.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/02-weak-ptr-implementation.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/03-weak-ptr-testing.md create mode 100644 documents/vol9-open-source-project-learn/chrome/02_weak_ptr/index.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-1-flat-map-motivation-and-api-design.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-2-flat-map-flattree-skeleton.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-3-flat-map-lookup-and-insert.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-4-flat-map-sorted-unique-construction.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-5-flat-map-iterator-invalidation-and-bulk-build.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-6-flat-map-testing-and-perf.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-00-flat-map-ordered-assoc-container-intro.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-01-flat-map-vector-internals-and-growth.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-02-flat-map-complexity-and-amortized.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-03-flat-map-comparator-and-transparent.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-04-flat-map-tag-dispatch-and-sorted-unique.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-05-flat-map-enua-ebo-and-pair-storage.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/01-flat-map-design.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/02-flat-map-implementation.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/03-flat-map-testing.md create mode 100644 documents/vol9-open-source-project-learn/chrome/03_flat_map/index.md create mode 100644 documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-1-no-destructor-motivation-and-api.md create mode 100644 documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-2-no-destructor-core-impl.md create mode 100644 documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-3-no-destructor-when-to-use.md create mode 100644 documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-4-no-destructor-lsan-and-leak.md create mode 100644 documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-00-static-storage-and-init.md create mode 100644 documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-01-placement-new-and-aligned-storage.md create mode 100644 documents/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/01-no-destructor-design-and-impl.md create mode 100644 documents/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/02-no-destructor-usage-and-testing.md create mode 100644 documents/vol9-open-source-project-learn/chrome/04_no_destructor/index.md diff --git a/README.md b/README.md index c7001f207..807b550f1 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ --- -![English Coverage](https://img.shields.io/badge/en_coverage-92%25-green.svg) 544/592 docs translated +![English Coverage](https://img.shields.io/badge/en_coverage-99%25-green.svg) 586/592 docs translated ## 这是什么项目 diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/12_intrusive_refcount.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/12_intrusive_refcount.cpp new file mode 100644 index 000000000..29d2712f8 --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/12_intrusive_refcount.cpp @@ -0,0 +1,117 @@ +// 侵入式引用计数:RefCountedThreadSafe + scoped_refptr +// 来源:WeakPtr 前置知识(一):侵入式引用计数与 scoped_refptr (pre-01) +// 编译:g++ -std=c++17 -Wall -Wextra 12_intrusive_refcount.cpp -o 12_intrusive_refcount -pthread + +#include +#include +#include +#include + +namespace { + +// 跨序列安全的侵入式引用计数基类(简化版 base::RefCountedThreadSafe) +class RefCountedThreadSafe { + public: + void add_ref() const noexcept { ref_count_.fetch_add(1, std::memory_order_relaxed); } + bool release() const noexcept { + // acq_rel:减法读到最新 count;归零时把"析构前的写"发布给接管 delete 的线程 + if (ref_count_.fetch_sub(1, std::memory_order_acq_rel) == 1) { + return true; // 调用方负责 delete this + } + return false; + } + bool has_one_ref() const noexcept { return ref_count_.load(std::memory_order_acquire) == 1; } + int use_count() const noexcept { return ref_count_.load(std::memory_order_acquire); } + + protected: + RefCountedThreadSafe() = default; + ~RefCountedThreadSafe() = default; + + private: + mutable std::atomic ref_count_{0}; +}; + +// 侵入式智能指针外壳(简化版 base::scoped_refptr) +template class scoped_refptr { + public: + scoped_refptr() noexcept = default; + explicit scoped_refptr(T* p) noexcept : ptr_(p) { + if (ptr_) + ptr_->add_ref(); + } + scoped_refptr(const scoped_refptr& o) noexcept : ptr_(o.ptr_) { + if (ptr_) + ptr_->add_ref(); + } + scoped_refptr(scoped_refptr&& o) noexcept : ptr_(o.ptr_) { o.ptr_ = nullptr; } + ~scoped_refptr() { + if (ptr_ && ptr_->release()) + delete ptr_; + } + scoped_refptr& operator=(scoped_refptr r) noexcept { + T* t = ptr_; + ptr_ = r.ptr_; + r.ptr_ = t; // copy-and-swap + return *this; + } + T* get() const noexcept { return ptr_; } + T& operator*() const noexcept { return *ptr_; } + T* operator->() const noexcept { return ptr_; } + explicit operator bool() const noexcept { return ptr_ != nullptr; } + + private: + T* ptr_ = nullptr; +}; + +// 一个被引用计数的目标类型(析构 private,堵外部直接 delete) +class Flag : public RefCountedThreadSafe { + public: + Flag() = default; + int id() const { return id_; } + + private: + // 允许 scoped_refptr 在计数归零时 delete(Chromium 式受控析构) + template friend class scoped_refptr; + ~Flag() = default; + int id_ = 42; +}; + +scoped_refptr make_flag() { + return scoped_refptr(new Flag()); +} + +} // namespace + +int main() { + std::cout << "=== 侵入式引用计数 ===\n"; + + auto p = make_flag(); // ref_count = 1 + std::cout << " create: use_count=" << p->use_count() + << ", has_one_ref=" << p->has_one_ref() << "\n"; + + { + auto p2 = p; // copy → add_ref → ref_count = 2 + std::cout << " copy p2: use_count=" << p2->use_count() + << ", has_one_ref=" << p2->has_one_ref() << "\n"; + } // p2 析构 → release → ref_count = 1(不 delete) + std::cout << " p2 gone: use_count=" << p->use_count() << "\n"; + + { + auto p3 = std::move(p); // move → 不增不减,p 变空 + std::cout << " move to p3: p valid=" << (p ? "yes" : "no") + << ", p3 use_count=" << p3->use_count() << "\n"; + } // p3 析构 → release → ref_count = 0 → delete + + std::cout << "\n=== 与 std::shared_ptr 的分配/大小对比 ===\n"; + // shared_ptr 非侵入式:控制块是独立堆分配(与对象分开);scoped_refptr 侵入式:计数器是对象成员 + struct Pod { + int x; + }; // 用一个平凡类型做 sizeof 对比(Flag 析构 private 不便塞 shared_ptr) + std::cout << " sizeof(scoped_refptr) = " << sizeof(scoped_refptr) + << " (1 个指针)\n"; + std::cout << " sizeof(std::shared_ptr) = " << sizeof(std::shared_ptr) + << " (2 个指针:对象 + 控制块)\n"; + std::cout << " 分配:shared_ptr(new Pod) 2 次;make_shared 1 次但捆死内存;scoped_refptr 1 " + "次(计数嵌入对象)\n"; + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/13_atomic_flag.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/13_atomic_flag.cpp new file mode 100644 index 000000000..0b88a4c1f --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/13_atomic_flag.cpp @@ -0,0 +1,59 @@ +// AtomicFlag:一次性、release-Set / acquire-IsSet 的原子标志 +// 来源:WeakPtr 前置知识(二):std::atomic 与 memory_order (pre-02) +// 编译:g++ -std=c++17 -Wall -Wextra 13_atomic_flag.cpp -o 13_atomic_flag -pthread + +#include +#include +#include +#include + +namespace { + +// 对应 base::AtomicFlag:std::atomic 的语义收窄封装 +// 一次性(无 public clear)+ 单序列写(DCHECK,这里省略)+ release/acquire +class AtomicFlag { + public: + void Set() noexcept { + flag_.store(1, std::memory_order_release); // 发布"Set 之前的写" + } + bool IsSet() const noexcept { + return flag_.load(std::memory_order_acquire) != 0; // acquire:建立 happens-before + } + + private: + std::atomic flag_{0}; +}; + +} // namespace + +int main() { + std::cout << "=== AtomicFlag 基本行为 ===\n"; + AtomicFlag f; + std::cout << " 初始 IsSet=" << f.IsSet() << "\n"; + f.Set(); + std::cout << " Set 后 IsSet=" << f.IsSet() << "\n"; + + std::cout << "\n=== release/acquire 建立跨线程 happens-before ===\n"; + // 线程 A:写 data,然后 Set(release) + // 线程 B:IsSet(acquire)看到 true ⇒ 必然看到 data==42 + int data = 0; + AtomicFlag ready; + + std::thread producer([&] { + data = 42; // (1) 普通写 + ready.Set(); // (2) release-store:把 (1) 发布出去 + }); + std::thread consumer([&] { + while (!ready.IsSet()) { + } // (3) acquire-load:等 release + std::cout << " consumer 看到 data=" << data << " (期望 42)\n"; // (4) 保证看到 (1) + }); + producer.join(); + consumer.join(); + + std::cout << "\n=== 对比 relaxed:不建立同步,可能读到旧值 ===\n"; + std::cout << " (relaxed 只保证原子,不传递 data 的可见性 —— 本质上不能用来做 liveness flag)\n"; + std::cout + << " AtomicFlag 选 release/acquire 正是为了让 WeakPtr 的 IsValid 看到对象的全部状态\n"; + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/14_sequence_checker.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/14_sequence_checker.cpp new file mode 100644 index 000000000..66f59661a --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/14_sequence_checker.cpp @@ -0,0 +1,80 @@ +// SequenceChecker:lazy 序列绑定 + debug 抓跨线程违规(release 下 0 字节 no-op) +// 来源:WeakPtr 前置知识(三):序列、SEQUENCE_CHECKER 与 DCHECK/CHECK (pre-03) +// 编译:g++ -std=c++17 -Wall -Wextra 14_sequence_checker.cpp -o 14_sequence_checker -pthread +// debug:g++ -std=c++17 ... (默认,带 assert);release:g++ -std=c++17 -DNDEBUG ... + +#include +#include +#include + +namespace { + +// 教学版:用 std::thread::id 模拟"序列"(Chromium 用 SequenceToken,更细) +// release(NDEBUG)下全 no-op,0 字节成员 —— 对应 SEQUENCE_CHECKER 三宏 +#if defined(NDEBUG) +class SequenceChecker { + public: + void detach_from_sequence() noexcept {} + bool called_on_valid_sequence() const noexcept { return true; } +}; +#else +# include +class SequenceChecker { + public: + void detach_from_sequence() noexcept { bound_ = std::thread::id{}; } // 构造时:未绑定 + bool called_on_valid_sequence() const noexcept { + if (bound_ == std::thread::id{}) { + bound_ = std::this_thread::get_id(); // lazy 绑定:首次触碰才定 + return true; + } + return bound_ == std::this_thread::get_id(); + } + + private: + mutable std::thread::id bound_; +}; +#endif + +// 模拟 WeakPtr::Flag 的 lazy 绑定用法 +class Flag { + public: + Flag() { seq_.detach_from_sequence(); } // 构造:未绑定 + void Invalidate() { + assert(seq_.called_on_valid_sequence()); // 首次触碰 → 绑定到当前线程 + invalidated_ = true; + } + bool IsValid() const { + assert(seq_.called_on_valid_sequence()); // 之后必须在同线程 + return !invalidated_; + } + + private: + SequenceChecker seq_; + bool invalidated_ = false; +}; + +} // namespace + +int main() { + std::cout << "=== lazy 序列绑定(单线程,正常路径)==\n"; + Flag flag; // 构造在主线程,detach(未绑定) + std::cout << " 主线程 IsValid=" << flag.IsValid() << "(首次触碰 → 绑定到主线程)\n"; + flag.Invalidate(); + std::cout << " Invalidate 后 IsValid=" << flag.IsValid() << "\n"; + + std::cout << "\n=== 跨线程违规(debug 下 assert 失败)==\n"; + std::cout << " 下面的注释代码演示违规:在另一线程调 IsValid 会触发 assert\n"; + // 取消下面注释在 debug 构建跑,会 abort: + // std::thread t([&flag] { (void)flag.IsValid(); }); // 不在绑定线程 → assert 失败 + // t.join(); + + std::cout << "\n=== release 构建(DNDEBUG)下零开销 ===\n"; + // 注意:本教学版用真实空类模拟 SequenceChecker,C++ 空类对象 sizeof = 1(最低 1 字节)。 + // 真实 Chromium 的 SEQUENCE_CHECKER(name) 宏在 release 下展开为 static_assert(true, ""), + // 根本不生成成员 → 对宿主类 sizeof 贡献 0 字节(见文章 pre-03)。 + // 这里打印的是教学版空类的 sizeof,不是 Chromium 的真实行为。 + std::cout << " 教学版 sizeof(SequenceChecker) = " << sizeof(SequenceChecker) + << "(空类,C++ 保证 ≥1 字节;debug 下为 thread::id 通常 8 字节)\n"; + std::cout << " 真实 Chromium:宏展开为 static_assert,0 字节成员 —— 比教学版更极致\n"; + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/15_concepts_requires.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/15_concepts_requires.cpp new file mode 100644 index 000000000..f86970edd --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/15_concepts_requires.cpp @@ -0,0 +1,79 @@ +// concepts 与 requires:WeakPtr 的转换构造约束 + 成员函数 const 重载 +// 来源:WeakPtr 前置知识(四):concepts 与 requires 在 WeakPtr 里的应用 (pre-04) +// 编译:g++ -std=c++20 -Wall -Wextra 15_concepts_requires.cpp -o 15_concepts_requires + +#include +#include +#include +#include + +namespace { + +struct Base { + virtual ~Base() = default; + virtual int kind() const { return 0; } +}; +struct Derived : Base { + int kind() const override { return 1; } +}; + +// 简化版 WeakPtr,只保留转换构造 + const 重载这两个 concept 用法 +template class MiniWeakPtr { + public: + MiniWeakPtr() = default; + + // 向上转型转换构造:requires(std::convertible_to) + template + requires(std::convertible_to) + MiniWeakPtr(const MiniWeakPtr&) noexcept { + std::cout << " [转换构造] WeakPtr<" << typeid(U).name() << "> -> WeakPtr<" + << typeid(T).name() << ">\n"; + } +}; + +// 简化版 WeakPtrFactory:GetWeakPtr 的 const/非 const 重载用 requires(!is_const_v) +template class MiniFactory { + public: + explicit MiniFactory(T* p) : ptr_(p) {} + + // const factory → WeakPtr + void get() const { std::cout << " [const 重载] 返回 WeakPtr\n"; } + // 非 const factory → WeakPtr,仅在 T 非 const 时存在 + void get() + requires(!std::is_const_v) + { + std::cout << " [非 const 重载] 返回 WeakPtr(可变)\n"; + } + + private: + T* ptr_; +}; + +} // namespace + +int main() { + std::cout << "=== 转换构造:向上转型合法 ===\n"; + MiniWeakPtr wd; + MiniWeakPtr wb = wd; // ✓ Derived* → Base* 满足 convertible_to + std::cout << " Derived -> Base: OK\n"; + // MiniWeakPtr wd2 = wb; // ✗ Base* -> Derived* 不满足,编译错(constraints not + // satisfied) + + std::cout << "\n=== const 正确性:成员函数 requires ===\n"; + Derived d; + + std::cout << " MiniFactory:\n"; + MiniFactory fac(&d); + const MiniFactory cf(&d); + std::cout << " 非 const .get(): "; + fac.get(); + std::cout << " const .get(): "; + cf.get(); + + std::cout << "\n MiniFactory(T 已是 const → 非 const 重载被 " + "requires(!is_const_v) 干掉):\n"; + MiniFactory cfac(&d); + std::cout << " .get(): "; + cfac.get(); // 只剩 const 重载 + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/16_weak_ptr_skeleton.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/16_weak_ptr_skeleton.cpp new file mode 100644 index 000000000..2b8d4a87b --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/16_weak_ptr_skeleton.cpp @@ -0,0 +1,38 @@ +// WeakPtr 核心骨架演示:Flag/WeakReference/WeakPtr 三层 + get/invalidate 行为 +// 来源:WeakPtr 实战(二):核心骨架与控制块 (02-2) +// 编译:g++ -std=c++20 -Wall -Wextra -I. 16_weak_ptr_skeleton.cpp -o 16_weak_ptr_skeleton -pthread + +#include "weak_ptr.hpp" + +#include + +struct Foo { + int x = 42; + int get() const { return x; } +}; + +int main() { + using namespace tamcpp::chrome; + Foo foo{7}; + + // 一个 factory 挂在 foo 上(教学:这里直接用 factory,真实场景见 17_weak_ptr_factory) + WeakPtrFactory fac(&foo); + + std::cout << "=== WeakPtr 基本行为 ===\n"; + auto wp = fac.get_weak_ptr(); + std::cout << " wp 判活=" << (wp ? "yes" : "no") << ", get()=" << wp.get() + << ", wp->x=" << wp->x << "\n"; + + std::cout << "\n=== invalidate 后 ===\n"; + fac.invalidate_weak_ptrs(); + std::cout << " wp 判活=" << (wp ? "yes" : "no") << ", get()=" << wp.get() << "\n"; + std::cout << " was_invalidated=" << wp.was_invalidated() << " (区分被作废 vs 主动 reset)\n"; + // 注意:wp->x 此刻会 assert/debug abort —— 对应 Chromium 的 CHECK + std::cout << " (operator* / operator-> 在失效时 assert/CHECK,这里不再调用)\n"; + + std::cout << "\n=== sizeof WeakPtr ===\n"; + std::cout << " sizeof(WeakPtr) = " << sizeof(WeakPtr) + << " (WeakReference + T*,两个指针)\n"; + std::cout << " WeakPtrFactory 铸的所有 WeakPtr 共享同一枚 Flag → invalidate 一次集体失效\n"; + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/17_weak_ptr_factory.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/17_weak_ptr_factory.cpp new file mode 100644 index 000000000..4925009fb --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/17_weak_ptr_factory.cpp @@ -0,0 +1,57 @@ +// WeakPtrFactory 演示:最后成员惯用法 + invalidate_weak_ptrs(铸新 Flag)vs and_doom(不再铸) +// 来源:WeakPtr 实战(三):WeakPtrFactory 与"最后成员"惯用法 (02-3) +// 编译:g++ -std=c++20 -Wall -Wextra -I. 17_weak_ptr_factory.cpp -o 17_weak_ptr_factory -pthread + +#include "weak_ptr.hpp" + +#include +#include + +// ✓ 正确顺序:factory 最后声明 → 最先析构 → 守护成员析构期 +class Controller { + public: + void on_done(int v) { buf_.push_back(v); } + std::size_t buf_size() const { return buf_.size(); } + + tamcpp::chrome::WeakPtr get_weak() { return weak_factory_.get_weak_ptr(); } + void invalidate() { weak_factory_.invalidate_weak_ptrs(); } + bool has_observers() const { return weak_factory_.has_weak_ptrs(); } + + private: + std::vector buf_; // 先声明 → 最后析构 + tamcpp::chrome::WeakPtrFactory weak_factory_{this}; // 最后成员 → 最先析构 +}; + +int main() { + using namespace tamcpp::chrome; + + std::cout << "=== ① factory 析构 = 失效所有 WeakPtr(最后成员惯用法)==\n"; + WeakPtr wp; + { + Controller c; + wp = c.get_weak(); + c.on_done(1); + std::cout << " c 活着: wp 判活=" << (wp ? "yes" : "no") << ", buf_size=" << wp->buf_size() + << "\n"; + } // c 析构:weak_factory_ 最先析构 → wp 失效 → buf_ 才析构(成员析构期被守护) + std::cout << " c 析构后:wp 判活=" << (wp ? "yes" : "no") << "\n"; + + std::cout << "\n=== ② invalidate_weak_ptrs:集体失效 + factory 可继续铸新 Flag ===\n"; + Controller c; + auto wp1 = c.get_weak(); + auto wp2 = c.get_weak(); + std::cout << " invalidate 前: wp1=" << (wp1 ? "yes" : "no") << " wp2=" << (wp2 ? "yes" : "no") + << " has_observers=" << c.has_observers() << "\n"; + c.invalidate(); // 失效 wp1/wp2 + 铸一枚新 Flag + std::cout << " invalidate 后: wp1=" << (wp1 ? "yes" : "no") << " wp2=" << (wp2 ? "yes" : "no") + << "\n"; + auto wp3 = c.get_weak(); // factory 还能继续铸(用的是新 Flag) + std::cout << " 再铸 wp3: wp3=" << (wp3 ? "yes" : "no") + << "(wp3 与 wp1/wp2 不共享 Flag)\n"; + + std::cout << "\n=== ③ 反例:factory 放前面会留出成员析构期的悬垂窗口 ===\n"; + std::cout << " BadController { WeakPtrFactory fac_{this}; vector buf_; } ——\n"; + std::cout << " 析构顺序:buf_ 先析构 → fac_ 后析构 → 中间窗口 WeakPtr 仍有效 → deref 即 UAF\n"; + std::cout << " 这就是为什么 WeakPtrFactory 必须是最后一个成员\n"; + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/18_bind_weakptr_cancel.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/18_bind_weakptr_cancel.cpp new file mode 100644 index 000000000..2de4b099d --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/18_bind_weakptr_cancel.cpp @@ -0,0 +1,75 @@ +// BindOnce + WeakPtr 自动取消:工业级 InvokeHelper::MakeItSo 的教学翻译 +// 来源:WeakPtr 实战(五):与回调集成——关闭 OnceCallback 的环 (02-5) +// 编译:g++ -std=c++20 -Wall -Wextra -I. 18_bind_weakptr_cancel.cpp -o 18_bind_weakptr_cancel +// -pthread + +#include "weak_ptr.hpp" + +#include +#include +#include +#include +#include + +// 一个极简的 OnceCallback 等价物(用 std::function 代替 01 系列的 move_only_function) +using Task = std::function; + +namespace tamcpp::chrome { + +// bind_weak_once:把成员方法 + WeakPtr 绑成一个 void() 回调 +// 核心是 if(!receiver) return; —— 对应 Chromium InvokeHelper::MakeItSo 的取消点 +template +Task bind_weak_once(void (T::*method)(Bound...), WeakPtr receiver, Bound... bound_args) { + // 注意:弱调用强制 void 返回(取消时无值可返);这里 method 本就返回 void + return [method, receiver = std::move(receiver), + bound = std::make_tuple(std::move(bound_args)...)]() mutable { + if (!receiver) { // ← 取消点:对象死后静默 no-op + std::cout << " [task] receiver 已失效 → 静默 no-op\n"; + return; + } + std::cout << " [task] receiver 有效 → 真正调用方法\n"; + std::apply([&](auto&&... args) { (receiver.get()->*method)(args...); }, bound); + }; +} + +} // namespace tamcpp::chrome + +class Controller { + public: + void on_done(int v) { + buf_.push_back(v); + std::cout << " [Controller::on_done] 收到 " << v << ", buf_size=" << buf_.size() << "\n"; + } + tamcpp::chrome::WeakPtr get_weak() { return weak_factory_.get_weak_ptr(); } + + private: + std::vector buf_; + tamcpp::chrome::WeakPtrFactory weak_factory_{this}; // 最后成员 +}; + +int main() { + using namespace tamcpp::chrome; + + std::cout << "=== 对象活着时,绑定的回调正常调用 ===\n"; + { + Controller c; + Task task = bind_weak_once(&Controller::on_done, c.get_weak(), 42); + std::cout << " run task(c 还活着):\n"; + task(); // 真正调用 c.on_done(42) + } + + std::cout << "\n=== 对象析构后,回调自动静默 no-op(不会悬空 deref)==\n"; + Task dangling; + { + Controller c; + dangling = bind_weak_once(&Controller::on_done, c.get_weak(), 99); + std::cout << " (c 即将离开作用域,任务还持着 c 的 WeakPtr)\n"; + } // c 析构 → weak_factory_ 先失效所有 WeakPtr + std::cout << " run task(c 已析构):\n"; + dangling(); // receiver 失效 → if(!receiver) return; → 静默 no-op + + std::cout << "\n=== 对照:裸指针 receiver(Unretained 风格)对象死后即 UAF ===\n"; + std::cout << " Unretained 走 InvokeHelper,无 if(!target) 守门,\n"; + std::cout << " 对象死后还 run() 就是悬垂解引用(WeakPtr 用事前 no-op 规避)\n"; + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/19_flat_map_basic.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/19_flat_map_basic.cpp new file mode 100644 index 000000000..ecc7c8eb3 --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/19_flat_map_basic.cpp @@ -0,0 +1,48 @@ +// flat_map 基础:构造(自动排序去重)、查找、operator[]、at、insert_or_assign、sorted_unique +// 来源:flat_map 实战(一)(二)(三) +// 编译:g++ -std=c++20 -Wall -Wextra -I. 19_flat_map_basic.cpp -o 19_flat_map_basic + +#include "flat_map.hpp" + +#include +#include + +int main() { + using namespace tamcpp::chrome; + + std::cout << "=== 构造即排序去重 ===\n"; + flat_map m{{3, "c"}, {1, "a"}, {3, "dup"}, {2, "b"}}; + for (const auto& [k, v] : m) + std::cout << " " << k << " -> " << v << "\n"; + std::cout << " size=" << m.size() << "(重复的 3 被去重)\n"; + + std::cout << "\n=== operator[] / at / contains ===\n"; + m[5] = "e"; // 缺失插入默认,再赋值 + std::cout << " m[5]=" << m[5] << "\n"; + std::cout << " m.at(1)=" << m.at(1) << "\n"; + std::cout << " contains(4)=" << m.contains(4) << ", contains(5)=" << m.contains(5) << "\n"; + + std::cout << "\n=== insert_or_assign(覆写已存在)==\n"; + auto [it1, ins1] = m.insert_or_assign(2, "B"); + std::cout << " insert_or_assign(2): inserted=" << ins1 << ", m[2]=" << m.at(2) << "\n"; + auto [it2, ins2] = m.insert_or_assign(4, "D"); + std::cout << " insert_or_assign(4): inserted=" << ins2 << ", m[4]=" << m.at(4) << "\n"; + + std::cout << "\n=== sorted_unique 构造(数据已有序,跳过排序)==\n"; + std::vector> raw{{1, "x"}, {2, "y"}, {6, "z"}}; + flat_map sm(sorted_unique, std::move(raw)); + std::cout << " sorted_unique 构造,size=" << sm.size() << ", front=" << sm.front().first + << "\n"; + + std::cout << "\n=== flat_set(key=value,std::identity)==\n"; + flat_set s{{5, 3, 1, 3, 2}}; + std::cout << " size=" << s.size() << "\n"; + for (auto& k : s) + std::cout << " " << k; + std::cout << "\n"; + + std::cout << "\n=== sizeof + EBO ===\n"; + std::cout << " sizeof(flat_map)=" << sizeof(flat_map) + << "(vector 三指针)\n"; + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/20_lookup_vs_shift_perf.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/20_lookup_vs_shift_perf.cpp new file mode 100644 index 000000000..c6c4caff4 --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/20_lookup_vs_shift_perf.cpp @@ -0,0 +1,67 @@ +// flat_map 性能:查找 O(log n) cache 友好 vs 插入 O(n) shift 的实测对比 +// 来源:flat_map 实战(三)(六) + 前置知识(二) +// 编译:g++ -std=c++20 -Wall -Wextra -I. 20_lookup_vs_shift_perf.cpp -o 20_lookup_vs_shift_perf +// -pthread + +#include "flat_map.hpp" + +#include +#include +#include +#include + +int main() { + using namespace tamcpp::chrome; + constexpr int N = 100'000; + + std::cout << "=== 构造 ===\n"; + std::vector> raw; + raw.reserve(N); + for (int i = 0; i < N; ++i) + raw.emplace_back(i, i * 2); + + auto t0 = std::chrono::steady_clock::now(); + flat_map fm(raw.begin(), raw.end()); // flat_map 批量构造,O(N log N) + auto t1 = std::chrono::steady_clock::now(); + std::map sm(raw.begin(), raw.end()); // std::map 逐个插入,O(N log N) + auto t2 = std::chrono::steady_clock::now(); + std::cout << " flat_map 构造 " << N + << " 元素: " << std::chrono::duration_cast(t1 - t0).count() + << " ms\n"; + std::cout << " std::map 构造 " << N + << " 元素: " << std::chrono::duration_cast(t2 - t1).count() + << " ms\n"; + + std::cout << "\n=== 查找:flat_map vs std::map(同 O(log n),常数因子差)==\n"; + auto tf = std::chrono::steady_clock::now(); + volatile long acc1 = 0; + for (int i = 0; i < N; ++i) + acc1 += fm.find(i)->second; + auto tf2 = std::chrono::steady_clock::now(); + volatile long acc2 = 0; + for (int i = 0; i < N; ++i) + acc2 += sm.find(i)->second; + auto ts = std::chrono::steady_clock::now(); + std::cout << " flat_map find x" << N << ": " + << std::chrono::duration_cast(tf2 - tf).count() << " ms\n"; + std::cout << " std::map find x" << N << ": " + << std::chrono::duration_cast(ts - tf2).count() << " ms\n"; + std::cout << " (flat_map cache 友好通常更快)\n"; + + std::cout << "\n=== 插入:flat_map O(n) shift vs std::map O(log n)==\n"; + auto ti = std::chrono::steady_clock::now(); + for (int i = N; i < N + 1000; ++i) + fm.insert({i, i}); // 1000 次插入,每次 O(n) shift + auto ti2 = std::chrono::steady_clock::now(); + for (int i = N; i < N + 1000; ++i) + sm.insert({i, i}); // std::map O(log n) + auto ts2 = std::chrono::steady_clock::now(); + std::cout << " flat_map 插入 1000 次: " + << std::chrono::duration_cast(ti2 - ti).count() + << " ms (O(n) shift)\n"; + std::cout << " std::map 插入 1000 次: " + << std::chrono::duration_cast(ts2 - ti2).count() + << " ms (O(log n))\n"; + std::cout << " (查多写少 → flat_map;写多 → std::map)\n"; + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/21_sorted_unique_tag.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/21_sorted_unique_tag.cpp new file mode 100644 index 000000000..813977f62 --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/21_sorted_unique_tag.cpp @@ -0,0 +1,45 @@ +// sorted_unique tag dispatch:零成本跳过排序 + DCHECK 诚实契约 +// 来源:flat_map 实战(四)+ 前置知识(四) +// 编译:g++ -std=c++20 -Wall -Wextra -I. 21_sorted_unique_tag.cpp -o 21_sorted_unique_tag + +#include "flat_map.hpp" + +#include +#include +#include + +int main() { + using namespace tamcpp::chrome; + constexpr int N = 100'000; + + // 准备一份已有序无重复的数据 + std::vector> sorted_raw; + sorted_raw.reserve(N); + for (int i = 0; i < N; ++i) + sorted_raw.emplace_back(i, i); + + std::cout << "=== 普通构造:内部 sort_and_unique(O(N log N))==\n"; + auto t0 = std::chrono::steady_clock::now(); + flat_map m1(sorted_raw); // 普通:即便已有序也会再排一次 + auto t1 = std::chrono::steady_clock::now(); + std::cout << " 普通: " + << std::chrono::duration_cast(t1 - t0).count() << " ms\n"; + + std::cout << "\n=== sorted_unique 构造:跳过排序(O(N))==\n"; + auto t2 = std::chrono::steady_clock::now(); + flat_map m2(sorted_unique, sorted_raw); // sorted_unique:不排,只 DCHECK(debug) + auto t3 = std::chrono::steady_clock::now(); + std::cout << " sorted_unique: " + << std::chrono::duration_cast(t3 - t2).count() << " ms\n"; + std::cout << " (release 下 DCHECK 消失,真 O(N);debug 下 DCHECK 仍校验你诚实)\n"; + + std::cout << "\n=== 诚实契约:撒谎会被 DCHECK/debug assert 抓住 ===\n"; + std::cout << " 若取消下面注释(传未排序数据却宣誓 sorted_unique),debug 下 abort:\n"; + // flat_map bad(sorted_unique, std::vector>{{3,3}, {1,1}, {2,2}}); + + std::cout << "\n 何时用 sorted_unique:数据来源可信地保证有序无重复\n"; + std::cout << " - 来自另一个有序容器\n"; + std::cout << " - 你刚 sort+unique 过\n"; + std::cout << " - 编译期常量 initializer_list\n"; + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/22_bulk_build_extract.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/22_bulk_build_extract.cpp new file mode 100644 index 000000000..0257dcb17 --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/22_bulk_build_extract.cpp @@ -0,0 +1,55 @@ +// 批量构造模式 + extract/replace 批量重建:绕开单元素 O(n) shift 与迭代器失效 +// 来源:flat_map 实战(五) +// 编译:g++ -std=c++20 -Wall -Wextra -I. 22_bulk_build_extract.cpp -o 22_bulk_build_extract + +#include "flat_map.hpp" + +#include +#include +#include + +int main() { + using namespace tamcpp::chrome; + + std::cout << "=== 批量构造(推荐):先填 vector,再 move 进 flat_map ===\n"; + std::vector> batch; + batch.reserve(5); + for (int i : {3, 1, 4, 1, 5, 9, 2, 6}) + batch.emplace_back(i, std::to_string(i)); + flat_map m(std::move(batch)); // 一次 sort_and_unique,O(N log N) + std::cout << " size=" << m.size() << "(重复的 1 被去重)\n"; + for (const auto& [k, v] : m) + std::cout << " " << k << ":" << v; + std::cout << "\n"; + + std::cout << "\n=== extract + replace 批量重建 ===\n"; + // 1. extract 出 vector(对 rvalue 调) + std::vector> raw = std::move(m).extract(); + std::cout << " extract 后 m.size=" << m.size() << ", raw.size=" << raw.size() << "\n"; + + // 2. 在 vector 上自由批量改(无有序约束,无 shift 代价) + raw.emplace_back(7, "7"); + raw.emplace_back(8, "8"); + raw.emplace_back(0, "0"); + std::cout << " 加 3 个元素后 raw.size=" << raw.size() << "\n"; + + // 3. 排序去重 + std::sort(raw.begin(), raw.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + raw.erase(std::unique(raw.begin(), raw.end(), + [](const auto& a, const auto& b) { return a.first == b.first; }), + raw.end()); + + // 4. replace 回 flat_map(sorted_unique 式校验) + flat_map m2; + m2.replace(std::move(raw)); + std::cout << " replace 后 m2.size=" << m2.size() << "\n"; + for (const auto& [k, v] : m2) + std::cout << " " << k << ":" << v; + std::cout << "\n"; + + std::cout << "\n=== 对比:逐个 insert 是 O(N²) 陷阱(避免!)==\n"; + std::cout << " 错误姿势:for(...) m.insert(x) // 每次 O(n) shift,总 O(N²)\n"; + std::cout << " 正确姿势:先攒 vector(push_back 摊还 O(1)),再 move 进 flat_map\n"; + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/23_no_destructor_basic.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/23_no_destructor_basic.cpp new file mode 100644 index 000000000..217319cbc --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/23_no_destructor_basic.cpp @@ -0,0 +1,45 @@ +// NoDestructor 基础:函数局部静态用法 + "不析构"验证(Noisy 类型) +// 来源:NoDestructor 实战(一)(二) +// 编译:g++ -std=c++20 -Wall -Wextra -I. 23_no_destructor_basic.cpp -o 23_no_destructor_basic + +#include "no_destructor.hpp" + +#include +#include + +// 析构会打 log 的类型——验证 ~T() 永不调用 +struct Noisy { + int v; + explicit Noisy(int x) : v(x) { std::puts(" Noisy()"); } + ~Noisy() { std::puts(" ~Noisy() ← 这个永不打印(NoDestructor 跳过 ~T)"); } +}; + +// 标准用法:函数局部静态 + NoDestructor + 非平凡析构 T +const std::string& DefaultName() { + static const tamcpp::chrome::NoDestructor s("chromium"); + return *s; +} + +const Noisy& GlobalNoisy() { + static const tamcpp::chrome::NoDestructor n(42); + return *n; +} + +int main() { + std::cout << "=== 函数局部静态 + NoDestructor ===\n"; + std::cout << " DefaultName() = " << DefaultName() << "\n"; + std::cout << " 调用多次(构造只跑一次,magic statics):\n"; + DefaultName(); + DefaultName(); + std::cout << " GlobalNoisy().v = " << GlobalNoisy().v << "\n"; + + std::cout << "\n=== 智能指针风格访问 ===\n"; + static const tamcpp::chrome::NoDestructor s("hello"); + std::cout << " *s = " << *s << "\n"; // operator* + std::cout << " s->size() = " << s->size() << "\n"; // operator-> + std::cout << " s.get() = " << s.get() << "\n"; // get() + + std::cout << "\n=== 程序退出前:注意没有 ~Noisy() / ~string() 的析构输出 ===\n"; + std::cout << "(如果看到析构输出,说明 NoDestructor 失效了)\n"; + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/24_placement_new_and_storage.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/24_placement_new_and_storage.cpp new file mode 100644 index 000000000..e3ff99e50 --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/24_placement_new_and_storage.cpp @@ -0,0 +1,46 @@ +// placement new + 对齐存储:NoDestructor 实现的核心机制 +// 来源:NoDestructor 前置知识(一)+ 实战(二) +// 编译:g++ -std=c++20 -Wall -Wextra -I. 24_placement_new_and_storage.cpp -o +// 24_placement_new_and_storage + +#include +#include +#include + +// 手写最小版,展示 alignas + placement new + reinterpret_cast 三件套 +template class MiniNoDestructor { + public: + template explicit MiniNoDestructor(Args&&... args) { + new (storage_) T(std::forward(args)...); // placement new(不分配) + } + ~MiniNoDestructor() = default; // 只析构 char 成员,不调 ~T() + T* get() { return reinterpret_cast(storage_); } + + private: + alignas(T) char storage_[sizeof(T)]; // 对齐到 T 的缓冲 +}; + +int main() { + std::cout << "=== 对齐验证 ===\n"; + std::cout << " alignof(int) = " << alignof(int) << "\n"; + std::cout << " alignof(double) = " << alignof(double) << "\n"; + std::cout << " alignof(MiniNoDestructor) = " << alignof(MiniNoDestructor) << "\n"; + + std::cout << "\n=== placement new 构造 + reinterpret_cast 访问 ===\n"; + MiniNoDestructor nd(42); + int* p = nd.get(); + std::cout << " *nd.get() = " << *p << "\n"; + std::cout << " p 指向的地址 == &nd 的 storage_ 吗:" + << (p == reinterpret_cast(&nd) ? "是(同一块内存)" : "否") << "\n"; + + std::cout << "\n=== sizeof:NoDestructor 零额外开销 ===\n"; + std::cout << " sizeof(MiniNoDestructor) = " << sizeof(MiniNoDestructor) + << " (== sizeof(int) = " << sizeof(int) << ")\n"; + std::cout << " sizeof(MiniNoDestructor) = " << sizeof(MiniNoDestructor) + << " (== sizeof(double) = " << sizeof(double) << ")\n"; + + std::cout << "\n=== 手动析构 vs NoDestructor 的区别 ===\n"; + std::cout << " placement new 的对象通常要手动 ~T()(内置类型用 typedef:p->~I())\n"; + std::cout << " 但 NoDestructor 故意不调 ~T()——靠 =default 析构只看到 char 成员\n"; + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/25_static_assert_gatekeep.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/25_static_assert_gatekeep.cpp new file mode 100644 index 000000000..7a35f35dd --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/25_static_assert_gatekeep.cpp @@ -0,0 +1,42 @@ +// static_assert 把关:NoDestructor 拒绝平凡析构 T,引导用 constinit/裸静态 +// 来源:NoDestructor 实战(二)(三) +// 编译:g++ -std=c++20 -Wall -Wextra -I. 25_static_assert_gatekeep.cpp -o 25_static_assert_gatekeep +// 取消下面注释的"误用"行,会触发 static_assert 编译失败(这是预期的把关) + +#include "no_destructor.hpp" + +#include +#include +#include + +int main() { + std::cout << "=== static_assert 把关演示 ===\n\n"; + + std::cout << "[1] 正确用法:非平凡析构 T(std::string)\n"; + static const tamcpp::chrome::NoDestructor s("ok"); + std::cout << " NoDestructor 构造成功:*s = " << *s << "\n\n"; + + std::cout << "[2] 误用 1:平凡可构造+析构 T(int)——static_assert 拒绝\n"; + std::cout << " 若取消下面这行的注释,编译期报错(static_assert):\n"; + std::cout + << " error: T is trivially constructible and destructible; please use constinit T\n"; + std::cout << " (int 同时平凡可构造+析构,触发第一条更具体的断言,而非第二条)\n"; + // tamcpp::chrome::NoDestructor bad_int(42); // ← 取消注释触发 static_assert + + std::cout << "\n[3] 误用 2:平凡可构造+析构(uint64_t)——static_assert 拒绝\n"; + std::cout << " 若取消下面这行,编译期报错:\n"; + std::cout << " error: T is trivially constructible and destructible; use constinit T\n"; + // tamcpp::chrome::NoDestructor bad_seed(42); // ← 取消注释触发 + + std::cout << "\n[4] 正确替代:平凡析构 T 直接用裸静态(无需 NoDestructor)\n"; + static const uint64_t kSeed = 42; // ✓ 平凡析构,不产生全局析构器 + std::cout << " static const uint64_t kSeed = " << kSeed << "(裸静态,无需 NoDestructor)\n"; + + std::cout << "\n[5] 正确替代:常量可构造用 constinit(C++20)\n"; + static constexpr int kMax = 100; // ✓ 编译期常量初始化,零运行时代码 + std::cout << " static constexpr int kMax = " << kMax << "(编译期,无 ctor/dtor)\n"; + + std::cout << "\n小结:NoDestructor 只服务非平凡析构 T;static_assert " + "在编译期拦住误用并给出正确替代。\n"; + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/CMakeLists.txt b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/CMakeLists.txt index a29cbeb1d..e30eafb61 100644 --- a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/CMakeLists.txt +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/CMakeLists.txt @@ -16,12 +16,26 @@ set(CPP17_EXAMPLES 06_type_traits 07_function_type_specialization 08_invoke + 12_intrusive_refcount + 13_atomic_flag + 14_sequence_checker ) # C++20 示例(需要 concepts / lambda pack expansion) set(CPP20_EXAMPLES 05_lambda_advanced 09_concepts_requires + 15_concepts_requires + 16_weak_ptr_skeleton + 17_weak_ptr_factory + 18_bind_weakptr_cancel + 19_flat_map_basic + 20_lookup_vs_shift_perf + 21_sorted_unique_tag + 22_bulk_build_extract + 23_no_destructor_basic + 24_placement_new_and_storage + 25_static_assert_gatekeep ) # C++23 示例(需要 move_only_function / deducing this) @@ -51,6 +65,16 @@ foreach(name ${CPP23_EXAMPLES}) set_target_properties(${name} PROPERTIES CXX_STANDARD 23) endforeach() -# threading support for atomic example +# threading support:原子/序列检查示例需要 pthread find_package(Threads REQUIRED) target_link_libraries(03_atomic_memory_order PRIVATE Threads::Threads) +target_link_libraries(12_intrusive_refcount PRIVATE Threads::Threads) +target_link_libraries(13_atomic_flag PRIVATE Threads::Threads) +target_link_libraries(14_sequence_checker PRIVATE Threads::Threads) +target_link_libraries(16_weak_ptr_skeleton PRIVATE Threads::Threads) +target_link_libraries(17_weak_ptr_factory PRIVATE Threads::Threads) +target_link_libraries(18_bind_weakptr_cancel PRIVATE Threads::Threads) +target_link_libraries(19_flat_map_basic PRIVATE Threads::Threads) +target_link_libraries(20_lookup_vs_shift_perf PRIVATE Threads::Threads) +target_link_libraries(21_sorted_unique_tag PRIVATE Threads::Threads) +target_link_libraries(22_bulk_build_extract PRIVATE Threads::Threads) diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/cm.cpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/cm.cpp new file mode 100644 index 000000000..4d67dd5b5 --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/cm.cpp @@ -0,0 +1,10 @@ +#include +#ifdef LEAK_SANITIZER +const char* m = "DEFINED"; +#else +const char* m = "NOT defined"; +#endif +int main() { + std::puts(m); + return 0; +} diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/flat_map.hpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/flat_map.hpp new file mode 100644 index 000000000..b734324fd --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/flat_map.hpp @@ -0,0 +1,229 @@ +// flat_map.hpp —— Chromium 风格 flat_map/flat_tree 的教学版完整实现 +// 对应:flat_map 实战(二)~(五) + 设计指南(二) +// 设计要点(每个决策见 articles): +// - flat_tree 是唯一实现核心 +// - flat_map = flat_tree 子类(GetFirst 提 pair.first);flat_set = flat_tree 别名(std::identity) +// - 有序不变量:构造期 sort_and_unique + 插入期 lower_bound +// - sorted_unique_t tag dispatch 跳过 sort_and_unique,配 DCHECK 诚实契约 +// - 透明比较(std::less<> 默认)+ [[no_unique_address]] EBO 空比较器 +// 编译:g++/clang++ -std=c++20 + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace tamcpp::chrome { + +// sorted_unique tag(跳过 sort_and_unique 的诚实契约)—— 放 chrome 顶层,using namespace 可见 +struct sorted_unique_t {}; +inline constexpr sorted_unique_t sorted_unique{}; + +namespace internal { + +template class flat_tree { + public: + using key_type = Key; + using key_compare = KeyCompare; + using value_type = typename Container::value_type; + using container_type = Container; + using iterator = typename Container::iterator; + using const_iterator = typename Container::const_iterator; + using size_type = typename Container::size_type; + + // —— 构造 —— + flat_tree() = default; + explicit flat_tree(const KeyCompare& c) : comp_(c) {} + + template + flat_tree(InputIt first, InputIt last, const KeyCompare& c = KeyCompare()) + : body_(first, last), comp_(c) { + sort_and_unique(); + } + flat_tree(Container body, const KeyCompare& c = KeyCompare()) + : body_(std::move(body)), comp_(c) { + sort_and_unique(); + } + // sorted_unique 构造:跳过排序,只 debug 校验 + template + flat_tree(sorted_unique_t, InputIt first, InputIt last, const KeyCompare& c = KeyCompare()) + : body_(first, last), comp_(c) { + assert(is_sorted_unique() && "sorted_unique 构造要求输入确实有序无重复"); + } + flat_tree(sorted_unique_t, Container body, const KeyCompare& c = KeyCompare()) + : body_(std::move(body)), comp_(c) { + assert(is_sorted_unique() && "sorted_unique 构造要求输入确实有序无重复"); + } + // initializer_list 构造(支持 flat_map<...> m{{k,v},...}) + flat_tree(std::initializer_list il, const KeyCompare& c = KeyCompare()) + : body_(il.begin(), il.end()), comp_(c) { + sort_and_unique(); + } + flat_tree(sorted_unique_t, std::initializer_list il, + const KeyCompare& c = KeyCompare()) + : body_(il.begin(), il.end()), comp_(c) { + assert(is_sorted_unique() && "sorted_unique 构造要求输入确实有序无重复"); + } + + // —— 查找 O(log n) ——(const 与非 const 两套,operator[]/at/insert_or_assign 需非 const) + iterator lower_bound(const Key& key) { + return std::lower_bound(body_.begin(), body_.end(), key, + [this](const value_type& v, const Key& k) { return less(v, k); }); + } + const_iterator lower_bound(const Key& key) const { + return std::lower_bound(body_.begin(), body_.end(), key, + [this](const value_type& v, const Key& k) { return less(v, k); }); + } + iterator find(const Key& key) { + auto it = lower_bound(key); + if (it != body_.end() && !less(key, *it)) + return it; + return body_.end(); + } + const_iterator find(const Key& key) const { + auto it = lower_bound(key); + if (it != body_.end() && !less(key, *it)) + return it; + return body_.end(); + } + bool contains(const Key& key) const { return find(key) != body_.end(); } + size_type count(const Key& key) const { return contains(key) ? 1 : 0; } + + // —— 插入 O(n) shift —— + std::pair insert(value_type v) { + auto it = std::lower_bound( + body_.begin(), body_.end(), v, + [this](const value_type& a, const value_type& b) { return less(a, b); }); + if (it != body_.end() && !less(v, *it)) + return {it, false}; + return {body_.emplace(it, std::move(v)), true}; + } + + iterator erase(const_iterator pos) { return body_.erase(pos); } + size_type erase(const Key& key) { + auto it = find(key); + if (it == body_.end()) + return 0; + body_.erase(it); + return 1; + } + + // —— 批量重建 —— + container_type extract() && { return std::exchange(body_, Container{}); } + void replace(container_type body) { + body_ = std::move(body); + assert(is_sorted_unique() && "replace 要求新数据有序无重复"); + } + + // —— 通用接口 —— + size_type size() const { return body_.size(); } + bool empty() const { return body_.empty(); } + iterator begin() { return body_.begin(); } + iterator end() { return body_.end(); } + const_iterator begin() const { return body_.begin(); } + const_iterator end() const { return body_.end(); } + const value_type& front() const { return body_.front(); } + + protected: + Container body_; + [[no_unique_address]] KeyCompare comp_; + + // value-vs-value / key-vs-value 比较(异构)。value 走提取器,裸 key 原样过。 + template bool less(const A& a, const B& b) const { + GetKeyFromValue ext; + return comp_(extract_key(ext, a), extract_key(ext, b)); + } + template static const auto& extract_key(Ext& ext, const V& v) { + if constexpr (std::is_same_v, value_type>) + return ext(v); + else + return v; + } + + void sort_and_unique() { + std::stable_sort(body_.begin(), body_.end(), + [this](const value_type& a, const value_type& b) { return less(a, b); }); + body_.erase(std::unique(body_.begin(), body_.end(), + [this](const value_type& a, const value_type& b) { + return !less(a, b) && !less(b, a); + }), + body_.end()); + } + bool is_sorted_unique() const { + for (size_type i = 1; i < body_.size(); ++i) + if (!less(body_[i - 1], body_[i])) + return false; + return true; + } +}; + +} // namespace internal + +// flat_map 的 key 提取器:pair.first +struct GetFirst { + template constexpr const K& operator()(const std::pair& p) const { + return p.first; + } +}; + +template , + class Container = std::vector>> +class flat_map : public internal::flat_tree { + using base = internal::flat_tree; + + public: + using mapped_type = Mapped; + using key_type = typename base::key_type; + using value_type = typename base::value_type; + using iterator = typename base::iterator; + using const_iterator = typename base::const_iterator; + + using base::base; // 继承 flat_tree 的构造/查找/插入 + + mapped_type& operator[](const Key& key) { + auto it = this->lower_bound(key); + if (it == this->end() || this->less(key, *it)) { + it = this->insert(value_type{key, mapped_type{}}).first; + } + return it->second; + } + + mapped_type& at(const Key& key) { + auto it = this->find(key); + assert(it != this->end() && "flat_map::at key out of range"); + return const_cast(it->second); + } + const mapped_type& at(const Key& key) const { + auto it = this->find(key); + assert(it != this->end() && "flat_map::at key out of range"); + return it->second; + } + + template std::pair insert_or_assign(const Key& key, M&& obj) { + auto it = this->lower_bound(key); + if (it != this->end() && !this->less(key, *it)) { + it->second = std::forward(obj); // 覆写(依赖 pair 非 const) + return {it, false}; + } + return this->insert(value_type{key, mapped_type(std::forward(obj))}); + } + + template std::pair try_emplace(const Key& key, Args&&... args) { + auto it = this->lower_bound(key); + if (it != this->end() && !this->less(key, *it)) + return {it, false}; + return this->insert(value_type{std::piecewise_construct, std::forward_as_tuple(key), + std::forward_as_tuple(std::forward(args)...)}); + } +}; + +// flat_set:flat_tree 别名,key=value,std::identity 提取 +template , class Container = std::vector> +using flat_set = internal::flat_tree; + +} // namespace tamcpp::chrome diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/ho02_find.o b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/ho02_find.o new file mode 100644 index 0000000000000000000000000000000000000000..451ad550ccff90f1e082c5f83a35cd64f2927324 GIT binary patch literal 1280 zcmbVL%}T>S5T4Zk|0*5?J;aj=x-|6%@gSAr4=5fKK?Dign%baAO0r7pMNfh^k3Nb= z!58o)d;p(7XLP5HOT9R-`R1FMZ+6+)+-~k|Wh@KWELevY<0xXL3 zKNCNFh3#T-&7R%fKbo_h72ycS&gb$gxuwM%Kqr>ixVms*1-7$ZD8_qP1|P>9nRRmj zRy}J?4)*juV(}ElXps%jXJa^fpD7M?Z(#xPKaBS2gpbd5>5k?pTG2#Fl|ALv6^JvbB#2UyfG9^n;Ds0qBjpLa-i1WT@`dK-;gzVxQO%2014Qgy zO5s)AN-P79Pw{`8L@C{Ex6z`YdGmfW=XrDaG&&UP;DE+mgEKYtGnk;c|5s1*q@MUh ze;M!3Z;g|@KjShc*ykF;^E65MX6+{CdT{-kJ@Ts7nCCLz_$k&=PgDP-^{W~gO#Lw? z=szrQfyO(1& +#include +#include + +namespace tamcpp::chrome { + +template class NoDestructor { + public: + // 通用:从任意参数完美转发给 T 的构造函数 + template explicit NoDestructor(Args&&... args) { + new (storage_) T(std::forward(args)...); + } + // 从 T 直接拷贝/移动构造(方便 initializer_list 等场景) + explicit NoDestructor(const T& x) { new (storage_) T(x); } + explicit NoDestructor(T&& x) { new (storage_) T(std::move(x)); } + + NoDestructor(const NoDestructor&) = delete; + NoDestructor& operator=(const NoDestructor&) = delete; + + // 关键:=default 只析构 char 成员(平凡),不调 ~T() + ~NoDestructor() = default; + + // 智能指针风格访问 + const T& operator*() const { return *get(); } + T& operator*() { return *get(); } + const T* operator->() const { return get(); } + T* operator->() { return get(); } + const T* get() const { return reinterpret_cast(storage_); } + T* get() { return reinterpret_cast(storage_); } + + private: + // 把关:只服务非平凡析构 T;平凡情况引导用 constinit/裸静态 + static_assert(!(std::is_trivially_constructible_v && std::is_trivially_destructible_v), + "T is trivially constructible and destructible; " + "please use a constinit object of type T directly instead"); + static_assert(!std::is_trivially_destructible_v, + "T is trivially destructible; " + "please use a function-local static of type T directly instead"); + + alignas(T) char storage_[sizeof(T)]; + + // Chromium 在 LEAK_SANITIZER 构建下额外持 T* storage_ptr_ 作 LSan reachability 根 + // (crbug/40562930);教学版省略,用 LSan suppression 文件替代。 +}; + +} // namespace tamcpp::chrome diff --git a/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/weak_ptr.hpp b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/weak_ptr.hpp new file mode 100644 index 000000000..8b3241db0 --- /dev/null +++ b/code/volumn_codes/vol9/full_tutorial_codes/chrome_design/weak_ptr.hpp @@ -0,0 +1,270 @@ +// weak_ptr.hpp —— Chromium 风格 WeakPtr 的教学版完整实现 +// 对应:WeakPtr 实战(二)~(五) + 设计指南(二) +// 设计要点(每个决策见 articles): +// - Flag:RefCountedThreadSafe(原子引用计数)+ AtomicFlag(release/acquire liveness) +// - WeakReference:scoped_refptr 的薄包装 +// - WeakPtr:WeakReference + 允许悬垂的 T*;TRIVIAL_ABI;转换构造 requires convertible_to +// - WeakPtrFactory:铸币 + 批量失效(WeakReferenceOwner 析构即 invalidate) +// - 序列检查:debug 下 lazy 绑定,release(NDEBUG)下 0 字节 no-op +// 编译:clang++ -std=c++20([[clang::trivial_abi]] 是 Clang 专有属性,GCC 全系列/MSVC 都不支持; +// TAMCPP_TRIVIAL_ABI 宏在非 Clang 下展开为空,代码照常编译,仅无寄存器传递优化) + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if __has_cpp_attribute(clang_trivial_abi) || \ + (defined(__clang__) && __has_cpp_attribute(clang::trivial_abi)) +# define TAMCPP_TRIVIAL_ABI [[clang::trivial_abi]] +#else +# define TAMCPP_TRIVIAL_ABI +#endif + +namespace tamcpp::chrome { + +template class WeakPtr; +template class WeakPtrFactory; + +namespace internal { + +// ---- 跨序列安全的侵入式引用计数基类(简化 RefCountedThreadSafe)---- +class RefCountedThreadSafe { + public: + void add_ref() const noexcept { ref_count_.fetch_add(1, std::memory_order_relaxed); } + bool release() const noexcept { + return ref_count_.fetch_sub(1, std::memory_order_acq_rel) == 1; + } + bool has_one_ref() const noexcept { return ref_count_.load(std::memory_order_acquire) == 1; } + + protected: + RefCountedThreadSafe() = default; + ~RefCountedThreadSafe() = default; + + private: + mutable std::atomic ref_count_{0}; +}; + +// ---- 一次性 release/acquire 的原子标志(对应 base::AtomicFlag)---- +class AtomicFlag { + public: + void Set() noexcept { flag_.store(1, std::memory_order_release); } + bool IsSet() const noexcept { return flag_.load(std::memory_order_acquire) != 0; } + + private: + std::atomic flag_{0}; +}; + +// ---- 序列检查器:debug 下 lazy 绑定,release 下 0 字节 no-op ---- +#if defined(NDEBUG) +class SequenceChecker { + public: + void detach_from_sequence() noexcept {} + bool called_on_valid_sequence() const noexcept { return true; } +}; +#else +class SequenceChecker { + public: + void detach_from_sequence() noexcept { bound_ = {}; } + bool called_on_valid_sequence() const noexcept { + if (!bound_) { + bound_ = std::this_thread::get_id(); + return true; + } + return *bound_ == std::this_thread::get_id(); + } + + private: + mutable std::optional bound_; +}; +#endif + +// ---- 侵入式智能指针外壳 ---- +template class scoped_refptr { + public: + scoped_refptr() noexcept = default; + explicit scoped_refptr(T* p) noexcept : ptr_(p) { + if (ptr_) + ptr_->add_ref(); + } + scoped_refptr(std::nullptr_t) noexcept {} // NOLINT(google-explicit-constructor) + scoped_refptr(const scoped_refptr& o) noexcept : ptr_(o.ptr_) { + if (ptr_) + ptr_->add_ref(); + } + scoped_refptr(scoped_refptr&& o) noexcept : ptr_(o.ptr_) { o.ptr_ = nullptr; } + ~scoped_refptr() { + if (ptr_ && ptr_->release()) + delete ptr_; + } + scoped_refptr& operator=(scoped_refptr r) noexcept { + T* t = ptr_; + ptr_ = r.ptr_; + r.ptr_ = t; + return *this; + } + scoped_refptr& operator=(std::nullptr_t) noexcept { + if (ptr_ && ptr_->release()) + delete ptr_; + ptr_ = nullptr; + return *this; + } + T* get() const noexcept { return ptr_; } + T& operator*() const noexcept { return *ptr_; } + T* operator->() const noexcept { return ptr_; } + explicit operator bool() const noexcept { return ptr_ != nullptr; } + + private: + T* ptr_ = nullptr; +}; + +// ---- Flag:refcounted + 原子的 liveness ---- +class Flag : public RefCountedThreadSafe { + public: + Flag() { seq_.detach_from_sequence(); } // 构造时未绑定(lazy) + + void Invalidate() noexcept { + assert(seq_.called_on_valid_sequence() || has_one_ref()); // 跨线程析构豁免 + invalidated_.Set(); // release-store + } + bool IsValid() const noexcept { + assert(seq_.called_on_valid_sequence()); // 首次触碰 → 绑定 + return !invalidated_.IsSet(); // acquire-load + } + bool MaybeValid() const noexcept { + return !invalidated_.IsSet(); // 无序列断言,任意序列可调 + } + + private: + template friend class scoped_refptr; // 允许计数归零时 delete + ~Flag() = default; + mutable SequenceChecker seq_; + AtomicFlag invalidated_; +}; + +// ---- WeakReference:对 Flag 的引用包装 ---- +class WeakReference { + public: + WeakReference() = default; + explicit WeakReference(const scoped_refptr& flag) : flag_(flag) {} + bool IsValid() const noexcept { return flag_ && flag_->IsValid(); } + bool MaybeValid() const noexcept { return flag_ && flag_->MaybeValid(); } + void Reset() noexcept { flag_ = nullptr; } + + private: + scoped_refptr flag_; +}; + +// ---- WeakReferenceOwner:Flag 的发行方(析构即失效所有)---- +class WeakReferenceOwner { + public: + WeakReferenceOwner() : flag_(new Flag()) {} + ~WeakReferenceOwner() { + if (flag_) + flag_->Invalidate(); + } + WeakReference GetRef() const { return WeakReference(flag_); } + void Invalidate() { + flag_->Invalidate(); + flag_ = scoped_refptr(new Flag()); + } + void InvalidateAndDoom() { + flag_->Invalidate(); + flag_ = nullptr; + } + bool HasRefs() const { return !flag_->has_one_ref(); } + + private: + scoped_refptr flag_; +}; + +} // namespace internal + +// ---- WeakPtr:用户句柄 ---- +template class TAMCPP_TRIVIAL_ABI WeakPtr { + public: + WeakPtr() = default; + WeakPtr(std::nullptr_t) noexcept {} // NOLINT(google-explicit-constructor) + + template + requires(std::convertible_to) + WeakPtr(const WeakPtr& other) noexcept : ref_(other.ref_), ptr_(other.ptr_) {} + template + requires(std::convertible_to) + WeakPtr(WeakPtr&& other) noexcept : ref_(std::move(other.ref_)), ptr_(other.ptr_) {} + + T* get() const noexcept { return ref_.IsValid() ? ptr_ : nullptr; } + T& operator*() const { + assert(ref_.IsValid()); + return *ptr_; + } // Chromium 用 CHECK + T* operator->() const { + assert(ref_.IsValid()); + return ptr_; + } + explicit operator bool() const noexcept { return get() != nullptr; } + void reset() noexcept { + ref_.Reset(); + ptr_ = nullptr; + } + + bool maybe_valid() const noexcept { return ref_.MaybeValid(); } + bool was_invalidated() const noexcept { return ptr_ && !ref_.IsValid(); } + + private: + template friend class WeakPtr; + friend class WeakPtrFactory; + WeakPtr(internal::WeakReference&& ref, T* ptr) noexcept : ref_(std::move(ref)), ptr_(ptr) { + assert(ptr); + } + + internal::WeakReference ref_; + T* ptr_ = nullptr; // RAW_PTR_EXCLUSION:允许悬垂,deref 前由 ref_ 守门 +}; + +// ---- WeakPtrFactory:铸币 + 批量失效 ---- +template class WeakPtrFactory { + public: + WeakPtrFactory() = delete; + explicit WeakPtrFactory(T* ptr) : ptr_(reinterpret_cast(ptr)) { assert(ptr); } + WeakPtrFactory(const WeakPtrFactory&) = delete; + WeakPtrFactory& operator=(const WeakPtrFactory&) = delete; + + WeakPtr get_weak_ptr() const { + return WeakPtr(owner_.GetRef(), reinterpret_cast(ptr_)); + } + WeakPtr get_weak_ptr() + requires(!std::is_const_v) + { + return WeakPtr(owner_.GetRef(), reinterpret_cast(ptr_)); + } + WeakPtr get_mutable_weak_ptr() const + requires(!std::is_const_v) + { + return WeakPtr(owner_.GetRef(), reinterpret_cast(ptr_)); + } + + void invalidate_weak_ptrs() { + assert(ptr_); + owner_.Invalidate(); + } + void invalidate_weak_ptrs_and_doom() { + assert(ptr_); + owner_.InvalidateAndDoom(); + ptr_ = 0; + } + bool has_weak_ptrs() const { return ptr_ && owner_.HasRefs(); } + + private: + internal::WeakReferenceOwner owner_; + uintptr_t ptr_; +}; + +} // namespace tamcpp::chrome diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-1-once-callback-motivation-and-api-design.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-1-once-callback-motivation-and-api-design.md index 6ecdff37b..37f2600fd 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-1-once-callback-motivation-and-api-design.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-1-once-callback-motivation-and-api-design.md @@ -2,222 +2,226 @@ chapter: 1 cpp_standard: - 23 -description: Starting from a real asynchronous callback bug, we analyze the three - major shortcomings of `std::function` in asynchronous scenarios, and design the - complete target API for `OnceCallback`. +description: "Start from a real async-callback bug, dissect the three flaws std::function has in async settings, and lay out the full target API for OnceCallback" difficulty: beginner order: 1 platform: host prerequisites: -- OnceCallback 前置知识(一):函数类型与模板偏特化 -- OnceCallback 前置知识(五):std::move_only_function -- OnceCallback 前置知识(六):Deducing this +- 'OnceCallback prerequisites (I): function types and template partial specialization' +- 'OnceCallback prerequisites (V): std::move_only_function' +- 'OnceCallback prerequisites (VI): Deducing this' reading_time_minutes: 10 related: -- OnceCallback 实战(二):核心骨架搭建 -- OnceCallback 前置知识速查:C++11/14/17 核心特性回顾 +- 'OnceCallback in practice (II): the core skeleton' +- 'OnceCallback prerequisite cheat sheet: a recap of C++11/14/17 core features' tags: - host - cpp-modern - beginner - 回调机制 - 函数对象 -title: 'OnceCallback in Practice (Part 1): Motivation and Interface Design' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-1-once-callback-motivation-and-api-design.md - source_hash: 97ef56eda9b4d5cd69a586a7360b4c24a83c826da6e674bdd24307e5d313c908 - translated_at: '2026-06-16T04:13:11.873850+00:00' - engine: anthropic - token_count: 1724 +title: 'OnceCallback in practice (I): motivation and API design' --- -# OnceCallback in Action (Part 1): Motivation and Interface Design +# OnceCallback in practice (I): motivation and API design -## Introduction +## Starting from a bug -Honestly, the most common pitfall I've encountered while doing asynchronous programming is callbacks being invoked multiple times. The scenario is classic—you register a callback for file I/O completion, expecting it to run once and be done with it. But due to a logic slip-up somewhere, it gets triggered one extra time. The resources released in the callback are accessed a second time, and boom—you get a segmentation fault. A major characteristic of this type of bug is that it is very hard to reproduce in tests, because normal asynchronous paths usually only trigger the callback once; the real trigger is often some race condition or error retry path. +The bug that's bitten me the most times in async programming has a name: "the callback got called one extra time." The setup is mundane. You wrap an async file read, register a callback for I/O completion, and expect it to fire once and be done. Then some error-retry path slips up and triggers it a second time. Inside the callback `release_resources()` runs again, and on the second pass it touches memory that's already been freed. Segfault. The nasty part is that this almost never reproduces in tests, because the normal async path fires the callback exactly once. The real fuse is some race or retry that only shows up under production-level concurrency at a low probability. The first time I hit this I stared at a core dump for half an afternoon before realizing it wasn't a logic error at all. Nobody was checking the call count. -`std::function` can't help us here. It allows multiple invocations, allows copy propagation, and callback objects can end up flying everywhere. We need a mechanism that **constrains callback semantics at the type system level**—making the "invoke only once" rule a compiler check, not a test of the programmer's memory. +`std::function` is no help here. It can be called any number of times, and it can be copied all over the place, so there's no way to constrain the callback object. What we want is to weld the "called exactly once" rule into the type system and let the compiler enforce it, rather than relying on every person who writes a callback to remember. This piece works through the motivation and the interface. The next one starts writing code. -In this article, we will start from the motivation, analyze exactly what is wrong with `std::function`, and then design our target API. We will start writing code in the next article. +### Scenario: async file read -> **Learning Objectives** -> -> - Understand the three major flaws of `std::function` in callback scenarios through a real-world asynchronous bug. -> - Grasp the design philosophy of Chromium's OnceCallback: move-only + rvalue-qualified + single-shot consumption. -> - Design the complete public interface for OnceCallback. +Suppose we're writing a wrapper for async file reading. The user calls `read_file_async(path, callback)`, and when I/O finishes, `callback` fires once with the file contents. ---- - -## Starting with a Bug - -### Scenario: Asynchronous File Reading +```cpp +void read_file_async(const std::string& path, + std::function callback); -Suppose we are writing an asynchronous file reading wrapper. The user calls `async_read`, and when I/O completes, `on_complete` is triggered once, passing in the file content. +// Usage +void on_file_read(std::string content) { + process(content); // handle the contents + release_resources(); // free the associated resources +} -```cpp -void async_read(const std::string& path, std::function on_complete); +read_file_async("data.txt", on_file_read); ``` -Looks fine. But if the I/O system triggers a retry due to some error—the callback gets invoked twice. `on_complete` executes twice, and the second time it accesses already freed memory. Segmentation fault. In a test environment, this retry path might never be triggered; only in a high-concurrency production environment does this bug appear with a very low probability. +Looks harmless. But the moment the I/O subsystem retries on some error, the callback fires twice, `release_resources()` runs twice, and the second run touches already-freed memory. Segfault. That retry path never gets exercised in tests, so the bug only surfaces under high concurrency in production, and only at a low probability. -### std::function Doesn't Help Us +### std::function doesn't help us -Where is the problem? The type signature of `std::function` contains no information telling us "how many times this callback should be called". The type system provides no constraint; we can only rely on runtime assertions—if you have them—or programmer discipline to guarantee it. +Where does the problem come from? The type signature `std::function` carries zero information about how many times this callback should be called. The type system is absent here; the constraint lives entirely in runtime assertions, if you wrote any, or in programmer discipline. -Even worse, `std::function`'s characteristics make this problem harder to detect. It is copyable, meaning the callback can be copied to multiple places. If multiple execution paths hold copies of the same callback simultaneously, race conditions are lurking. Its `operator()` is `const`-qualified—calling it does not change the state of the `std::function` object itself—so you cannot express the "invoke is consume" semantic through the calling interface. +What makes it worse is that a few of `std::function`'s properties push the bug further out of reach. It's copyable, so the callback can be cloned to any number of places. The day two execution paths each hold a copy and run them at the same time, you've planted a race. And its `operator()` is `const`-qualified, so calling it doesn't change the object's own state, which means the "to call is to consume" semantic can't even be expressed through the call interface. --- -## Three Major Flaws of std::function +## Three flaws of std::function -Let's systematize the problem. `std::function`, as a general-purpose callable object container, is successful in its design—but in the specific scenario of asynchronous callbacks, it has three fatal flaws. +Let's systematize this. `std::function` as a general-purpose callable container is a successful design; I'm not disputing that. But dropped into the specific scenario of async callbacks, it has three lethal spots. -### Flaw 1: Copyable +The first is copyability. `std::function` supports copy natively. Copy it once and its internal type erasure copies the stored callable along with it. Inside an async system that means a single callback can be replicated to any number of places, one in the task queue, one in the timer, one in the error handler, and each copy can be invoked independently. If the callback captured a move-only resource (a `std::unique_ptr`, say), the copy fails to compile outright; if it captured a raw pointer or reference, then multiple copies running at once is a race. The Chrome team's stance is blunt: async task callbacks shouldn't be copied in the first place, so make them uncopyable at the type level. -`std::function` natively supports copying. When you copy a `std::function`, its internal type-erasure mechanism copies the stored callable object as well. In an asynchronous system, this means a callback can be copied to any number of places—one in the task queue, one in the timer, one in the error handler—and each copy can be invoked independently. +The second is repeated callability. `std::function::operator()` imposes zero control over how many times you call it; call the same object a thousand times and it'll happily run every time. But in an async callback setting, firing a file-read completion callback twice is a hard logic error: two resource releases, two state transitions, two messages sent, take your pick. And the type system can't catch a single word of it. -If the callback captures move-only resources (like `std::unique_ptr`), copying fails directly at compile time. If it captures raw pointers or references, multiple copies executing simultaneously will produce races. The Chrome team's approach is straightforward: since asynchronous task callbacks fundamentally shouldn't be copied, make them uncopyable at the type level. +The third is the sneakiest: there's no way to express consumption semantics. In Chrome's task-posting model, once you do `PostTask(FROM_HERE, callback)`, that `callback` should never be touched again, because its ownership has already been handed to the task system. But `std::function::operator()` is `const`-qualified, so calling it doesn't mutate the object, which means the "to call is to consume" semantic can't be hung off the interface at all. -### Flaw 2: Repeatedly Callable - -`std::function` has no constraint on the number of calls. You can invoke the same `std::function` a thousand times, and it will run without complaint. But in an asynchronous callback scenario, invoking a file-read completion callback twice is a logical error—it might trigger double resource release, double state transitions, or double message sending. This error is completely undetectable by the type system. - -### Flaw 3: Unable to Express Consumption Semantics - -In Chrome's task posting model, once a `OnceCallback` is called, it should not be used again—its ownership has been transferred to the task system. `std::function`'s `operator()` is `const`-qualified; calling it does not change the state of the `std::function` object itself, so you cannot express the "invoke is consume" semantic through the calling interface. - -These three problems boil down to one point: `std::function`'s interface design cannot express the constraint "this callback can only be invoked once, and becomes invalid after invocation". Our OnceCallback is designed to fill this semantic gap. +All three point at the same place: the `std::function` interface simply cannot express the constraint "this callback can be called only once, and is invalid afterward." Our OnceCallback exists to fill that gap. --- -## Chromium's Answer: OnceCallback Design Philosophy +## Chromium's answer: the OnceCallback design philosophy -Chrome's callback system is built on a core principle: **message passing over locks, serialization over threads**. Under this principle, every callback posted to the task system is an independent, one-shot message. After posting, ownership of the callback transfers from the caller to the task system; after execution, the callback is destroyed. No sharing, no reuse, no ambiguity. +Chrome's callback system rests on one core principle: message passing over locks, serialization over threads. Following that line, every callback posted to the task system is an independent, one-shot message. Once posted, ownership of the callback moves from the caller to the task system; once executed, the callback is destroyed. No sharing, no reuse, no ambiguity. -This philosophy is directly reflected in `base::OnceCallback`'s type design, with three key constraints: +That philosophy is carved straight into `OnceCallback`'s type design. First, move-only: `OnceCallback` deletes both copy construction and copy assignment and keeps only the move operations, so at the type level a callback has exactly one owner at any moment. Second, an rvalue-qualified `Run()`: it can only be called on an rvalue, and calling it on an lvalue is a hard compile error, which is the type system grabbing the caller by the collar to say "you are consuming this callback, don't touch it again." Third, single-shot consumption: inside `Run()`, a reference-counting mechanism destroys the `BindState`, so any access to the same object after the call is a safe no-op. Add the three together and "called only once" stops being a discipline problem and becomes a type problem. -**Move-only**: `OnceCallback` deletes copy construction and copy assignment, retaining only move operations. This guarantees at the type level that a callback has only one owner at any moment. +### A sketch of Chromium's internals -**Rvalue-qualified Run()**: `OnceCallback` can only be invoked via an rvalue reference. Invoking via an lvalue triggers a compile error. This syntactically reminds the caller: "You are consuming this callback, don't use it again." +Chromium's callback system stacks three layers. At the bottom sits `BindStateBase`, a type-erased base class carrying a reference count. It skips virtual functions and gets polymorphism through function-pointer members instead. The middle layer is `BindState`, a templated concrete class that actually stores the callable and the bound arguments. On top is `OnceCallback`, the type users handle directly; under the hood it's a `BindState` wrapped in a smart-pointer shell, and it's only 8 bytes. -**Single-shot consumption**: Internally, `OnceCallback` uses a reference counting mechanism to destroy the stored object after the first call, making any subsequent access to the same object a safe no-op. +Our implementation keeps the layered skeleton of "outer interface plus internal storage plus type erasure," but we swap two pieces: `std::move_only_function` replaces Chromium's hand-rolled `BindState` plus reference-count combo, and deducing this replaces the double-overload-plus-`!sizeof` hack. Put bluntly, the modern syntax does the heavy lifting the old generation had to do by hand. -### Chromium Internal Architecture Overview +--- -Chromium's callback system consists of three layers. The bottom layer is `base::InternalCallbackBase`—a type-erased base class with reference counting, using function pointer members instead of virtual functions for polymorphism. The middle layer is `base::InternalCallback`—a templated concrete class storing the actual callable object and bound arguments. The top layer is `base::OnceCallback`—the type users directly interact with, essentially a smart pointer wrapper to `base::InternalCallbackBase`, only 8 bytes in size. +## Designing the target API -Our implementation will retain the layered approach of "outer interface + internal storage + type erasure", but we will use `std::move_only_function` to replace Chromium's hand-rolled `base::InternalCallbackBase` + reference counting combo, and use deducing this to replace the double overload + `std::enable_if` hack. +An engineer's rule: get "what I want" on the table first, then come back and argue each decision. Let's pin down the target API. ---- +### Construction and invocation -## Designing the Target API +```cpp +#include "once_callback/once_callback.hpp" -Let's define the target API first, then discuss each design decision. This is how engineers work—figure out "what I want" first, then "how to do it". +using namespace tamcpp::chrome; -### Construction and Invocation +// Construct from a lambda +auto cb = OnceCallback([](int a, int b) { + return a + b; +}); -```cpp -// Construction from lambdas/function pointers -OnceCallback cb = [] { /* ... */ }; +// Invocation: must go through an rvalue +int result = std::move(cb).run(3, 4); // result == 7 -// Invocation (must be rvalue) -std::move(cb).run(); +// After the call, cb has been consumed +// std::move(cb).run(1, 2); // runtime assertion failure ``` -### Argument Binding +### Argument binding ```cpp -// Binding arguments (Currying) -auto add = [](int a, int b) { return a + b; }; -auto add_five = OnceCallback(add).bind(5); -int result = std::move(add_five).run(10); // Returns 15 +// bind_once: pre-bind part of the arguments and return a new OnceCallback +auto bound = bind_once( + [](int x, int y, int z) { return x + y + z; }, + 10, 20 // pre-bind the first two arguments +); + +int r = std::move(bound).run(30); // r == 60 ``` -### Cancellation Checks +### Cancellation check ```cpp -// Check if cancelled -if (cb.is_cancelled()) { /* ... */ } +auto cb = OnceCallback([](int x) { /* ... */ }); + +// Check whether the callback is still valid +if (!cb.is_cancelled()) { + std::move(cb).run(42); +} -// Check validity (optimistic) -if (cb.maybe_valid()) { /* ... */ } +// maybe_valid: an optimistic check +if (cb.maybe_valid()) { + std::move(cb).run(42); +} ``` ### Chaining ```cpp -// Chaining (then) -auto task = OnceCallback([] { /* task A */ }) - .then([] { /* task B */ }); -std::move(task).run(); +auto pipeline = OnceCallback([](int a, int b) { + return a + b; +}).then([](int sum) { + return sum * 2; +}); + +int final_result = std::move(pipeline).run(3, 4); +// final_result == 14, because (3+4)*2 = 14 ``` --- -## Interface Design Decision Analysis +## Walking through the interface decisions -### Why use run() instead of operator()? +### Why run() instead of operator() -Chromium uses `Run()` (Google style requires capitalization). We use `run()` to conform to snake_case naming conventions. The deeper reason is semantic distinction—`operator()` is too generic, any callable object has it; `run()` explicitly expresses the "execute task" semantic. During code review, you can see at a glance that this is consuming a OnceCallback, not just calling a normal function. +Chromium uses `Run()` because Google style mandates a leading capital. We use `run()` to stay consistent with snake_case. There's a deeper layer to it, too, which is semantic separation. `operator()` is far too generic; anything callable has an `operator()`. The name `run()` itself is announcing "I'm executing a task," so in code review you can tell at a glance that a OnceCallback is being consumed here, not just some ordinary function being called. -### Why must run() be invoked via an rvalue? +### Why run() has to go through an rvalue -This is the most critical point in the entire design. We use deducing this to let the compiler intercept lvalue calls for us—if you write `cb.run()` instead of `std::move(cb).run()`, the compiler will directly error out, and the error message explicitly tells you what to do. This mechanism was explained in detail in the Prerequisites (Part 6). +This is the one I care about most in the whole design. We lean on deducing this and let the compiler intercept lvalue calls for us. Write `cb.run(args)` instead of `std::move(cb).run(args)` and the compiler errors out on the spot, with a message that tells you exactly how to fix it. The mechanism was covered in prerequisites (VI), so I won't repeat it here. -### Why distinguish between is_cancelled() and maybe_valid()? +### Why split is_cancelled() and maybe_valid() -The difference lies in the strength of the safety guarantee. `is_cancelled()` provides a definitive answer—can only be called on the sequence where the callback is bound, guaranteeing an accurate result. `maybe_valid()` provides an optimistic estimate—can be called from any thread, but the result might be stale. In Chromium's full implementation, this distinction relates to thread safety guarantees. Our simplified version temporarily makes the semantics of both identical, but retains the interface for future expansion. +The difference is in how strong the safety guarantee is. `is_cancelled()` gives a definitive answer: it can only be called on the sequence the callback is bound to, and the result is guaranteed accurate. `maybe_valid()` is an optimistic estimate: callable from any thread, but the result might already be stale. In Chromium's full implementation, the split between the two is tied directly to the thread-safety guarantee. Our simplified version lets the two share the same semantics for now, but we keep the interface around so we can split them later when the system actually needs it. -### Why does then() consume *this? +### Why then() consumes *this -The semantic of `then()` is "pass the execution result of the current callback to the next callback". This requires the current callback to be fully captured in the new callback returned by `then()`. If `then()` does not consume `*this`, the same callback would exist in two places simultaneously—violating the move-only semantic constraint. Therefore, `then()` is declared as an rvalue-qualified member function; after invocation, the original callback object enters a consumed state. +What `then()` is trying to say is "pipe the current callback's result into the next callback." That requires the current callback to be swallowed whole inside the new callback that `then()` returns. If `then()` didn't consume `*this`, the same callback would be sitting in two places at once, and the move-only semantic would collapse on the spot. So `then()` is declared as an rvalue-qualified member function, and once it's called the original callback enters a consumed state. --- -## Environment Setup +## Setting up the environment -Before writing code, let's confirm the toolchain. OnceCallback relies on `std::move_only_function` and deducing this, both C++23 features. +Before we touch any code, get the toolchain sorted. OnceCallback depends on `std::move_only_function` and deducing this, both C++23 features, and without both of them the rest of this is wasted effort. -### Compiler Requirements +### Compiler requirements -GCC 13+ or Clang 17+ fully supports the above features. Add `-std=c++23` when compiling. +GCC 13+ or Clang 17+ fully support the features above; compile with `-std=c++23`. -### Verification Code +### Verification code ```cpp -// test_env.cpp #include +// Verify std::move_only_function is available +static_assert(__cpp_lib_move_only_function >= 202110L); + +// Verify deducing this is available +struct Check { + void test(this auto&& self) {} +}; + int main() { - std::move_only_function f = [] {}; - std::move(f)(); // Compile check + Check c; + c.test(); + return 0; } ``` -If this code compiles, the environment is set. +If that compiles, the environment is ready. -### Minimal CMake Configuration +### Minimal CMake configuration ```cmake cmake_minimum_required(VERSION 3.20) -project(OnceCallback LANGUAGES CXX) +project(once_callback_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) -add_executable(test_env test_env.cpp) +add_library(once_callback INTERFACE) +target_include_directories(once_callback INTERFACE + ${CMAKE_CURRENT_SOURCE_DIR}/.. +) ``` --- -## Summary - -In this article, starting from motivation, we clarified three things. `std::function` has three major flaws in asynchronous callback scenarios—copyable, repeatedly callable, unable to express consumption semantics—the root cause is that the type system cannot constrain "invoke only once". Chromium's OnceCallback fills this semantic gap through move-only + rvalue-qualified Run() + single-shot consumption. We designed a set of target APIs, covering four core features: construction and invocation, argument binding (`bind`), cancellation checks (`is_cancelled`/`maybe_valid`), and chaining (`then`). - -In the next article, we will start building the core skeleton—from template specialization to state management, we will set up the class skeleton for OnceCallback. +With the motivation and the interface taking shape here, the next piece gets our hands dirty: from template partial specialization to three-state management, we'll build up the OnceCallback class skeleton one piece at a time. ## References -- [Chromium Callback Documentation](https://chromium.googlesource.com/chromium/src/+/main/docs/callback.md) +- [Chromium Callback documentation](https://chromium.googlesource.com/chromium/src/+/main/docs/callback.md) - [cppreference: std::move_only_function](https://en.cppreference.com/w/cpp/utility/functional/move_only_function) -- [P0847R7 - Deducing this Proposal](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p0847r7.html) +- [P0847R7 - Deducing this proposal](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p0847r7.html) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-2-once-callback-core-skeleton.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-2-once-callback-core-skeleton.md index 6b93323b5..bfb847c36 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-2-once-callback-core-skeleton.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-2-once-callback-core-skeleton.md @@ -2,22 +2,20 @@ chapter: 1 cpp_standard: - 23 -description: 'Building the OnceCallback Class Skeleton in Five Steps: Template Partial - Specialization, Data Members, Constructor Constraints, `run()` Consumption Semantics, - and Query Interface' +description: "Building the OnceCallback class skeleton from scratch in five steps: template partial specialization, data members, constructor constraints, run() consumption semantics, and the query interface" difficulty: beginner order: 2 platform: host prerequisites: -- OnceCallback 实战(一):动机与接口设计 -- OnceCallback 前置知识(一):函数类型与模板偏特化 -- OnceCallback 前置知识(四):Concepts 与 requires 约束 -- OnceCallback 前置知识(五):std::move_only_function -- OnceCallback 前置知识(六):Deducing this +- 'OnceCallback hands-on (I): motivation and API design' +- 'OnceCallback prerequisite (I): function types and template partial specialization' +- 'OnceCallback prerequisite (IV): concepts and requires constraints' +- 'OnceCallback prerequisite (V): std::move_only_function' +- 'OnceCallback prerequisite (VI): deducing this' reading_time_minutes: 9 related: -- OnceCallback 实战(三):bind_once 实现 -- OnceCallback 实战(四):取消令牌设计 +- 'OnceCallback hands-on (III): implementing bind_once' +- 'OnceCallback hands-on (IV): the cancellation token' tags: - host - cpp-modern @@ -25,46 +23,30 @@ tags: - 回调机制 - 函数对象 - 模板 -title: 'OnceCallback in Practice (Part 2): Building the Core Skeleton' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-2-once-callback-core-skeleton.md - source_hash: bbacc2606374047c704315c92e0161ed381c557428b26bbcfcb6e6e970346f7b - translated_at: '2026-06-16T04:13:01.048920+00:00' - engine: anthropic - token_count: 2281 +title: "OnceCallback in Practice (II): Building the Core Skeleton" --- -# OnceCallback in Practice (Part 2): Building the Core Skeleton +# OnceCallback in Practice (II): Building the Core Skeleton -## Introduction +In the previous piece we sorted out why OnceCallback exists and what its target API looks like. With the motivation settled, the itch sets in. Staring at an interface only gets you so far. To tell a design that holds up from one that just looks good on paper, you have to write it out line by line. -In the previous post, we clarified "why we need OnceCallback" and "what the target API looks like." Now, let's write some code. The task here is to build the class skeleton for OnceCallback from scratch—not implementing all features at once, but in five steps, each layer building on the last. Once the skeleton is complete, we can add components like `bind_once`, cancellation tokens, and `then()` on top of it. +So let's build it. We start the class skeleton from zero and grow it in five steps, each one layering onto the last. Once the skeleton stands, everything that follows (`bind_once`, the cancellation token, `then()`) is just hanging components off this frame; no structural surgery ahead. We assume you've been through the seven prerequisite pieces. Function types, partial specialization, `requires`, `move_only_function`, deducing this, they all come into play below without re-explanation. -We have already covered all the prerequisite knowledge in the first seven articles. This post is pure practice—we will map every design decision directly into code by examining the actual source. +## Step 1: primary template and partial specialization -> **Learning Objectives** -> -> - Build the complete class skeleton for `OnceCallback` from scratch. -> - Understand the responsibilities of each data member and method. -> - Master the deducing this implementation for `run()` and the consumption logic for `impl_run()`. - ---- - -## Step 1: Primary Template and Partial Specialization - -In Prerequisite (1), we discussed the pattern of "function type + template partial specialization." Now, let's apply it directly to OnceCallback. +The "function type + template partial specialization" pattern from Prerequisite (I) now lands directly on OnceCallback. ```cpp namespace tamcpp::chrome { -// 主模板:只有声明,没有定义 -// 如果有人写了 OnceCallback(传了非函数类型),编译器会报错 +// Primary template: declaration only, no definition. +// If someone writes OnceCallback (a non-function type), the compiler errors. template class OnceCallback; -// 偏特化:FuncSignature 是 R(Args...) 形式的函数类型时匹配 +// Partial specialization: matches when FuncSignature is a function type R(Args...) template class OnceCallback { - // 所有真正的代码都在这个偏特化里 + // All the real code lives inside this specialization public: using FuncSig = ReturnType(FuncArgs...); // ... @@ -73,13 +55,13 @@ public: } // namespace tamcpp::chrome ``` -When you write `OnceCallback`, the compiler matches `int(int, int)` to the primary template's `FuncSignature`. It then discovers that the partial specialization can decompose it into `ReturnType = int` and `FuncArgs = {int, int}`, so it selects the specialized version. `FuncSig` is a type alias that stores the complete function signature—we will use this later when declaring `std::move_only_function`. +When you write `OnceCallback`, the compiler first feeds `int(int, int)` as a whole into the primary template's `FuncSignature`, then notices the partial specialization can split it back into `ReturnType = int` and `FuncArgs = {int, int}`, so the specialization wins. This "take it whole, let specialization decompose it" move is the universal skeleton for this kind of callback library. `std::function`, Chromium's `RepeatingCallback`, they all follow the same mold. The `FuncSig` alias keeps a copy of the full signature handy, so later when we declare `std::move_only_function` we just reuse it instead of stitching the signature back together. --- -## Step 2: Data Members—Three Core Storages +## Step 2: data members, three core storages -Now, let's add data members to the specialized class. OnceCallback needs three things to manage its state. +With the type skeleton in place, we fill in the data members. OnceCallback manages its own state, and it takes three things to do it: ```cpp template @@ -89,52 +71,52 @@ public: private: enum class Status : uint8_t { - kEmpty, // 从未被赋值(默认构造) - kValid, // 持有有效的可调用对象 - kConsumed // 已被 run() 调用过 + kEmpty, // never assigned (default-constructed) + kValid, // holds a valid callable + kConsumed // run() has been called } status_ = Status::kEmpty; - std::move_only_function func_; // 类型擦除的可调用对象 - std::shared_ptr token_; // 可选的取消令牌 + std::move_only_function func_; // type-erased callable + std::shared_ptr token_; // optional cancellation token }; ``` -`func_` is the core of type erasure—it wraps various forms of callable objects (lambdas, function pointers, functors) into a unified invocation interface with a `FuncSig` signature. Regardless of what you pass in, `func_` can invoke it using the same `operator()`. +Of the three, `func_` is the heart of type erasure. Lambdas, function pointers, functors, the shapes are all over the place, and `func_` funnels them into one `operator()` with the `FuncSig` signature. That's the "one interface catches every callable" we wanted. -`status_` is a three-state enumeration, distinguishing "never assigned," "ready to call," and "already invoked." Why can't we rely solely on the null check of `func_`? Because the `std::move_only_function`'s `operator bool()` can only distinguish between "null" and "non-null," and the state after moving is unspecified—we covered this in detail in Prerequisite (5). +The member worth dwelling on is the three-state `status_`. You might ask, why not just null-check `func_`? Because `std::move_only_function::operator bool()` only separates "empty" from "non-empty", and OnceCallback's contract is finer than that. "Never assigned" and "already consumed by `run()`" are different things. The second is an explicit contract violation (a callback runs exactly once), and it has to read differently from "empty off the factory line." Worse, the post-move state of `move_only_function` is "valid but unspecified" by the standard, so leaning on it for null checks is shaky ground. So we give the state its own enum and don't expect the underlying container to carry our semantics. Prerequisite (V) covered this trap; this is where it lands. -`token_` is an optional cancellation token used to check if execution should be aborted before the callback runs. It defaults to a null pointer (cancellation disabled) and is set via the `set_token()` method. We will cover this in a dedicated post later. +`token_` is the optional cancellation token. It starts as a null pointer (cancellation disabled) and only attaches through an explicit `set_token()`. We park it here as a placeholder; the cancellation mechanism gets its own piece later. --- -## Step 3: Constructors and requires Constraints +## Step 3: constructors and the requires constraint -Next, we add the constructors. The key point here is that the template constructor must use a `requires` constraint to prevent it from hijacking the move constructor—we discussed this issue in Prerequisite (4). +Data members in place, now we give the class its constructors. There's an old trap with template constructors: they elbow into overload resolution and steal the move constructor's job. A `requires` clause has to head them off. Prerequisite (IV) explained why; here we watch it land. ```cpp -// not_the_same_t concept:F 退化后不是 T +// not_the_same_t concept: F, after decay, is not T template concept not_the_same_t = !std::is_same_v, T>; template class OnceCallback { - // ... 数据成员 ... + // ... data members ... - // 禁止拷贝 + // Copying is forbidden OnceCallback(const OnceCallback&) = delete; OnceCallback& operator=(const OnceCallback&) = delete; public: - // 模板构造函数:接受任意可调用对象 + // Template constructor: accepts any callable template requires not_the_same_t explicit OnceCallback(Functor&& function) : status_(Status::kValid), func_(std::move(function)) {} - // 默认构造:创建空回调 + // Default constructor: produces an empty callback explicit OnceCallback() = default; - // 移动构造 + // Move constructor OnceCallback(OnceCallback&& other) noexcept : status_(other.status_), func_(std::move(other.func_)), @@ -142,7 +124,7 @@ public: other.status_ = Status::kEmpty; } - // 移动赋值 + // Move assignment OnceCallback& operator=(OnceCallback&& other) noexcept { if (this != &other) { status_ = other.status_; @@ -155,26 +137,24 @@ public: }; ``` -Let's understand these constructors one by one. - -The **template constructor** is the most commonly used—this is what gets called when you write `OnceCallback([](int x) { return x; })`. `Functor` is deduced as the lambda's closure type. `requires not_the_same_t` ensures that when the input is an `OnceCallback` itself, this template is excluded (letting the move constructor handle it). `std::move(function)` moves the incoming callable object into `func_`, and `status_` is set to `kValid`. +The most-used one here is the template constructor. When you write `OnceCallback([](int x) { return x; })`, this is what runs. `Functor` gets deduced as the lambda's closure type, and `requires not_the_same_t` blocks the case where the incoming thing is itself a `OnceCallback`, steering it to the move constructor instead. Without that clause the template constructor would greedily hijack copy and move, and overload resolution falls apart at compile time. `std::move(function)` moves the callable into `func_`, and `status_` flips to `kValid` at the same time. -The **default constructor** creates an empty OnceCallback—`status_` is `kEmpty` (determined by the member initializer's default value), and both `func_` and `token_` are empty. +The default constructor is dull by comparison. It produces an empty callback: `status_` is `kEmpty` (the member initializer's default), `func_` and `token_` both null. It exists mostly so OnceCallback can sit in containers and accept deferred assignment. -The **move constructor** steals everything from another OnceCallback—`func_` and `token_` are transferred via `std::move`, and `status_` is copied over as well. The key point is that we actively set the source object to `kEmpty` after the move, rather than relying on the unspecified post-move state of `std::move_only_function`. +The move constructor has one deliberate choice worth pointing out. `func_` and `token_` transfer through `std::move`, `status_` gets copied over, nothing surprising there. What's surprising is that we actively reset the source to `kEmpty` instead of trusting `move_only_function`'s post-move state. Same reasoning as before: keep the semantics in our own hands. Whether the underlying container comes out empty or valid after a move is a question the standard leaves open, and we won't bet on it. Move assignment follows the same logic, plus a self-assignment check. --- -## Step 4: The deducing this Implementation of run() +## Step 4: implementing run() with deducing this -This step is the soul of the entire skeleton. `run()` uses deducing this to intercept lvalue calls at compile time, while forwarding rvalue calls to the internal `impl_run()`. +This step nails the "runs exactly once" contract to the call site at compile time. Using deducing this, `run()` rejects lvalue calls during compilation and only releases rvalues (that is, `std::move(cb).run(...)`) through to the internal `impl_run()`. ```cpp -// 声明(在类体内) +// Declaration (inside the class body) template auto run(this Self&& self, FuncArgs&&... args) -> ReturnType; -// 实现(在类体外,once_callback_impl.hpp 中) +// Implementation (outside the class body, in once_callback_impl.hpp) template template auto OnceCallback::run(this Self&& self, FuncArgs&&... args) @@ -186,16 +166,16 @@ auto OnceCallback::run(this Self&& self, FuncArgs&&... } ``` -When the caller writes `cb.run(args)`, `Self` is deduced as `OnceCallback&` (an lvalue reference). `static_assert` triggers, and the error message directly tells the caller what to do. When writing `std::move(cb).run(args)`, `Self` is deduced as `OnceCallback` (non-reference), compilation succeeds, and it forwards to `impl_run`. +The mechanism is straightforward. When the caller writes `cb.run(args)` (no `std::move`), `Self` deduces to `OnceCallback&`, an lvalue reference. The `static_assert` fires on the spot, and the error message hands them the correct form `std::move(cb).run(...)` so they don't have to guess. Write `std::move(cb).run(args)` and `Self` deduces to `OnceCallback` (non-reference), compilation passes, and the call forwards into `impl_run`. -`impl_run` is where the callback is actually executed: +`impl_run` is where the actual work happens: ```cpp template ReturnType OnceCallback::impl_run(FuncArgs... args) { assert(status_ == Status::kValid); - // 取消检查:消费但不执行 + // Cancellation check: consume but do not execute if (token_ && !token_->is_valid()) { status_ = Status::kConsumed; func_ = nullptr; @@ -206,7 +186,7 @@ ReturnType OnceCallback::impl_run(FuncArgs... args) { } } - // 消费:先把 func_ 拿出来,再更新状态,最后执行 + // Consume: pull func_ out first, then update state, then execute auto functor = std::move(func_); func_ = nullptr; status_ = Status::kConsumed; @@ -219,19 +199,19 @@ ReturnType OnceCallback::impl_run(FuncArgs... args) { } ``` -Several key details are worth noting. +The thing most worth unpacking in this implementation is the consumption order. -First, look at the consumption order—`impl_run` first moves `func_` out into a local variable `functor`. Then, it nullifies `func_`, sets `status_` to kConsumed, and finally executes `functor`. This order is critical: secure the callable object and mark the state before execution. Even if the callable object throws an exception internally, `status_` is already `kConsumed`, ensuring the callback doesn't end up in an inconsistent state. +`impl_run` doesn't call `func_` directly. It first moves it out into a local `functor`, then nulls the member `func_`, sets `status_` to `kConsumed`, and only then executes `functor`. The ordering is not arbitrary. State gets marked first, the callable detaches from the member and lands on the stack, then it runs. So even if `functor` throws an exception on the way out, `status_` is already `kConsumed`, and the callback object never sits in a halfway state where `func_` still exists but the status says unconsumed. That's how the exception safety is carved out: pull the irreversible "consumed" state ahead of execution. -Next, look at `if constexpr`—void return types cannot be assigned or returned in the conventional way. `if constexpr (std::is_void_v)` selects the branch at compile time: the void case takes the "call but don't assign" path, while the non-void case takes the "call and assign to return" path. This is the standard pattern we discussed in the cheat sheet. +The cancellation check moved to the very front of execution. If a token is attached and it's invalid, the callback is consumed without running. The void path returns, the non-void path throws `std::bad_function_call`. The throw looks aggressive at first glance, but stand on the caller's side and it clicks: they wrote `auto x = std::move(cb).run(...)` expecting a value back, and you can't produce any meaningful return value. Handing back something undefined and letting them use it in surprising ways is worse than throwing and putting the problem on the table. It's a "fail loud" tradeoff, the same family of thinking as WeakPtr using `CHECK` to fail `operator*`. -Finally, the cancellation check—we verify the cancellation token before execution. If cancelled, we consume the callback but do not execute it. For void returns, we simply `return`; for non-void returns, we throw `std::bad_function_call`. Throwing an exception for non-void might seem aggressive, but the reasoning is solid: the caller expects a return value, but we cannot provide a meaningful one, so throwing an exception is safer than returning an undefined value. +The remaining `if constexpr` is a compile-time branch carved out for void return types. void can't take the usual "call and return the result" path, so `if constexpr (std::is_void_v)` picks the road at compile time: void takes "call but don't assign", non-void takes "call and return". This is the standard pattern from the cheat-sheet piece, so we won't expand on it here. --- -## Step 5: Query Interfaces +## Step 5: the query interface -Finally, let's add a set of query methods so the caller can check the callback's status before execution. +The skeleton is missing one last piece: a set of query methods so the caller can probe what state the callback is actually in before running it. ```cpp [[nodiscard]] bool is_cancelled() const noexcept { @@ -257,15 +237,15 @@ void set_token(std::shared_ptr token) { } ``` -The logic for `is_cancelled()` is: return true if the state is not kValid (both empty and consumed callbacks count as "cancelled"), or if there is a token and it is expired. `maybe_valid()` is currently just `!is_cancelled()`. `is_null()` only checks if it was never assigned. `operator bool()` combines both empty and cancelled conditions. +A word on how `is_cancelled()` reads the state. Anything that isn't `kValid` counts as "cancelled". Empty callbacks and consumed callbacks collapse into one bucket at this layer; from the caller's view both mean "don't count on this running." Then a token check layers on top: token attached and expired also counts as cancelled. `maybe_valid()` is currently just `!is_cancelled()`, kept under that name to leave room for cross-sequence semantics later. `is_null()` looks at one thing only, whether the callback was ever assigned, which is a separate question from cancellation. `operator bool()` folds "non-null" and "not cancelled" together, and it's the most common liveness check at call sites. -All query methods are marked with `[[nodiscard]]`—calling these methods is intended for judgment based on the return value, so ignoring the return value is likely a mistake. The `explicit` keyword prevents implicit conversion to `bool`. +Every query method carries `[[nodiscard]]`. Callers invoke these for their return value to make a decision on, so dropping the result is almost always a slip, and the compiler should holler. The `explicit` on `operator bool()` is the old discipline: block implicit conversions so a `cb` doesn't slip into a slot that wanted an `int`. --- -## Verifying the Core Skeleton +## Verifying the core skeleton -The skeleton is built. Let's quickly verify a few basic scenarios: +With the skeleton standing, the habit is to push on a few of the plainest cases first. Don't chase edges right away; lock the base down first. ```cpp #include "once_callback/once_callback.hpp" @@ -275,43 +255,35 @@ The skeleton is built. Let's quickly verify a few basic scenarios: int main() { using namespace tamcpp::chrome; - // 1. 非 void 返回 + // 1. Non-void return OnceCallback add([](int a, int b) { return a + b; }); assert(std::move(add).run(3, 4) == 7); - // 2. void 返回 + // 2. Void return bool called = false; OnceCallback side_effect([&called] { called = true; }); std::move(side_effect).run(); assert(called); - // 3. move-only 捕获 + // 3. Move-only capture auto ptr = std::make_unique(42); OnceCallback capture_move([p = std::move(ptr)] { return *p; }); assert(std::move(capture_move).run() == 42); - // 4. 移动语义 + // 4. Move semantics OnceCallback movable([] { return 1; }); OnceCallback moved_to = std::move(movable); - assert(movable.is_null()); // 源对象变空 - assert(std::move(moved_to).run() == 1); // 目标对象有效 + assert(movable.is_null()); // source goes empty + assert(std::move(moved_to).run() == 1); // target is valid return 0; } ``` -If these four scenarios pass—constructing a callback yields the correct return value, void callbacks execute normally, resources are released after a callback capturing `unique_ptr` is used, and the source becomes empty while the target is valid after a move—then the skeleton is solid. - ---- - -## Summary - -In this post, we built the core skeleton of OnceCallback in five steps. Template partial specialization `OnceCallback` decomposes the function type via pattern matching. Three data members each have their duties—`func_` handles type erasure, `status_` manages the three-state logic, and `token_` handles the cancellation mechanism. The constructors use `requires not_the_same_t` to protect the move constructor from being hijacked. `run()` uses deducing this to intercept lvalue calls at compile time, and `impl_run()` guarantees exception safety for consumption semantics via the "move func_ out first, then execute" order. - -In the next post, we will add the first component to the skeleton—`bind_once()`—to implement argument binding. +These four cases are the skeleton's floor: a non-void callback returns the right value, a void callback's side effect fires, a move-only callback capturing a `unique_ptr` releases its resource after running, and after a move the source reads empty while the target works. All green, and the skeleton can hold up the components coming next. If any one fails, don't rush forward; turn back and check this step. -## Reference Resources +## References - [Chromium callback.h source](https://chromium.googlesource.com/chromium/src/+/HEAD/base/functional/callback.h) - [cppreference: std::move_only_function](https://en.cppreference.com/w/cpp/utility/functional/move_only_function) -- [P0847R7 - Deducing this proposal](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p0847r7.html) +- [P0847R7, the Deducing this proposal](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p0847r7.html) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-3-once-callback-bind-once.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-3-once-callback-bind-once.md index 068e5fe09..007978056 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-3-once-callback-bind-once.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-3-once-callback-bind-once.md @@ -2,19 +2,17 @@ chapter: 1 cpp_standard: - 23 -description: A line-by-line breakdown of the parameter binding implementation in `bind_once`—from - the motivation to lambda capture pack expansion, followed by manually expanding - a complete template instantiation example. +description: "A line-by-line teardown of bind_once's parameter binding, from the motivation through the lambda capture pack expansion, finishing with a complete template instantiation example unfolded by hand." difficulty: beginner order: 3 platform: host prerequisites: -- OnceCallback 实战(二):核心骨架搭建 -- OnceCallback 前置知识(二):std::invoke 与统一调用协议 -- OnceCallback 前置知识(三):Lambda 高级特性 +- OnceCallback hands-on (II): the core skeleton +- OnceCallback prerequisites (II): std::invoke and the uniform call protocol +- OnceCallback prerequisites (III): advanced lambda features reading_time_minutes: 7 related: -- OnceCallback 实战(四):取消令牌设计 +- OnceCallback hands-on (IV): the cancellation token tags: - host - cpp-modern @@ -22,198 +20,158 @@ tags: - 回调机制 - 函数对象 - 模板 -title: 'OnceCallback in Practice (Part 3): Implementing bind_once' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-3-once-callback-bind-once.md - source_hash: d91277bee67e57d70108ec1a132efe8dcdf8b7a51b6a35d1a2d6d9a5971aec54 - translated_at: '2026-06-16T04:13:11.135927+00:00' - engine: anthropic - token_count: 1473 +title: "OnceCallback hands-on (III): implementing bind_once" --- -# OnceCallback in Practice (Part 3): Implementing `bind_once` +# OnceCallback hands-on (III): implementing bind_once -## Introduction +The skeleton is in place and `run()` can consume callbacks. But there's a familiar friction you hit pretty quickly: every time you build a `OnceCallback` you have to feed it a callable with the full signature, every argument handed over at the call site. The real world is rarely that tidy. More often a couple of arguments are pinned down when the callback is created, and only the remaining one or two have to wait until the call. -The core framework is in place, and `OnceCallback` can now consume callbacks. However, constructing a `OnceCallback` currently requires passing a callable object with a specific signature, where all arguments must be provided at the call site. In reality, we often encounter situations where certain arguments are known at callback creation time, while only a subset of arguments needs to be deferred until the call is made. `bind_once` is designed to solve this problem—it "bakes" the known arguments into the callback, allowing the caller to focus only on the unknown arguments. +That's what `bind_once` is for. It bakes the "already decided" arguments into the callback ahead of time, so the caller only has to supply the rest. In this piece we'll walk through its implementation line by line, then unfold a full template instantiation by hand so you can see exactly what the compiler is doing behind your back. -In this article, we will deconstruct the implementation of `bind_once` line by line and manually expand a complete template instantiation example to reveal exactly what the compiler does behind the scenes. - -> **Learning Objectives** -> -> - Understand what problems argument binding solves. -> - Understand the complete implementation of `bind_once` line by line. -> - Manually expand a specific template instantiation to see what the compiler does. -> - Understand why the signature must be explicitly specified. - ---- - -## What Problem Does Argument Binding Solve? - -Let's first look at a scenario without `bind_once`. Suppose you have a function with three parameters, but the first two are determined at the time of binding: +Let's start with the no-`bind_once` version. Say there's a three-parameter function, and the first two arguments are already known at bind time: ```cpp -void HandleEvent(EventSource* source, int id, const std::string& data); +int compute(int x, int y, int z) { + return x + y + z; +} + +// Without bind_once: every call has to pass all three arguments +auto cb = OnceCallback(compute); +int r = std::move(cb).run(10, 20, 30); // r == 60 ``` -If `source` and `id` are determined at binding time, and only `data` needs to be passed in at call time, we want to obtain a `OnceCallback` that only takes one argument. +If `x = 10` and `y = 20` are settled at bind time and only `z` has to come in at call time, what we really want is a `OnceCallback` that takes a single argument. -Without `bind_once`, you would have to manually write a lambda wrapper: +Without `bind_once`, the only option is to wrap it in a hand-written lambda: ```cpp -// Manual lambda wrapper -OnceCallback cb = - [source, id](const std::string& data) { - HandleEvent(source, id, data); - }; +auto wrapped = OnceCallback( + [](int z) { return compute(10, 20, z); } +); +int r = std::move(wrapped).run(30); // r == 60 ``` -This works, but if the number of arguments increases or the types become complex (e.g., binding move-only types like `unique_ptr`), manually writing lambdas becomes tedious. `bind_once` automates this "wrap in a lambda" process. +It works. But once the parameter list grows or the types get awkward (say, binding a move-only `unique_ptr`), writing that lambda by hand gets old fast. What `bind_once` does is automate the "wrap it in a lambda" step. ```cpp -// Automated binding -auto cb = bind_once(&HandleEvent, source, id); +auto bound = bind_once(compute, 10, 20); +int r = std::move(bound).run(30); // r == 60 ``` ---- - -## Line-by-Line Breakdown of `bind_once` Implementation +## A line-by-line teardown of the bind_once implementation -Let's examine the source code to understand what `bind_once` does. +Let's lay the full source out, then chew through it piece by piece. ```cpp -template -auto bind_once(Callable&& callable, BoundArgs&&... bound_args) { +template +auto bind_once(F&& funtor, BoundArgs&&... args) { return OnceCallback( - [callable = std::forward(callable), - ...bound_args = std::forward(bound_args)](auto&&... unbound_args) mutable { - std::invoke(std::move(callable), - std::move(bound_args)..., - std::forward(unbound_args)...); + [f = std::forward(funtor), + ...bound = std::forward(args)] + (auto&&... call_args) mutable -> decltype(auto) { + return std::invoke( + std::move(f), + std::move(bound)..., + std::forward(call_args)... + ); } ); } ``` -### Template Parameters - -`bind_once` has three template parameters. `Signature` is the function signature of the target callback (e.g., `void(int)`), which must be explicitly specified by the caller. `Callable` is the type of the callable object (lambda closure type, function pointer, etc.), deduced by the compiler from the first function argument. `BoundArgs` is the type pack of the bound arguments, also deduced by the compiler. - -### Lambda Capture List +## From the template parameters down into the lambda body -The capture list is the most ingenious part of the entire implementation. `callable = std::forward(callable)` uses init capture to perfectly forward the callable object into the lambda closure—if an rvalue is passed, it is moved; if an lvalue is passed, it is copied. +The template parameters are the entry point, so look there first. `bind_once` carries three. `Signature` is the target callback's signature (something like `int(int)`), and it has to be written by hand; the compiler can't deduce it. `F` is the type of the callable object (a lambda closure type, a function pointer, and so on), deduced from the first argument. `BoundArgs...` is the type pack of the bound arguments, following along with the trailing arguments. The last two are CTAD's job; only the first one has to come from you. -`...bound_args = std::forward(bound_args)` is a lambda init capture pack expansion introduced in C++20. It generates a corresponding capture variable for each type in `BoundArgs`, each initialized via perfect forwarding. Assuming `BoundArgs` is `int, std::string`, expansion is equivalent to: +Next is the capture list, the most intricate part of the whole implementation. `f = std::forward(funtor)` uses an init capture to perfectly forward the callable into the closure: if an rvalue came in, it gets moved in; if an lvalue, it gets copied in, and the value category is preserved the whole way. The line below it, `...bound = std::forward(args)`, is the lambda init capture pack expansion that C++20 brought in. It hands each type in `BoundArgs...` its own capture variable, each initialized through `std::forward`. If `BoundArgs = {int, std::string}`, the expansion comes out equivalent to: ```cpp -// Expanded capture list -[callable = std::forward(callable), - bound_args1 = std::forward(bound_args1), - bound_args2 = std::forward(bound_args2)] +[f = std::forward(funtor), + b1 = std::forward(arg1), + b2 = std::forward(arg2)] ``` -### Lambda Parameters and `mutable` +The parameter list `(auto&&... call_args)` is what receives the arguments handed in at runtime. `auto&&` here is equivalent to `T&&` for a template parameter, that is, a forwarding reference, not an rvalue reference. Beginners read past that distinction all the time. -`auto&&... unbound_args` are forwarding references for the generic lambda—arguments passed at runtime are received through them. `auto&&` here is equivalent to `T&&` in template parameters, which are forwarding references. +The `mutable` keyword is not something you can drop. Inside the lambda body we call `std::move(f)` and `std::move(bound)...`, and both of those need to modify the captured variables. A lambda without `mutable` is const, and the captures inside are const along with it. You can't move out of a const object, and the compiler will throw it right back at you. -The `mutable` keyword cannot be omitted—the lambda body needs to call `std::move(callable)` and `std::move(bound_args)...`. These operations modify the captured variables. If the lambda is const, the captured variables are const inside the body, preventing a move from a const object. - -### Lambda Body +The last layer is the lambda body: ```cpp -std::invoke(std::move(callable), - std::move(bound_args)..., - std::forward(unbound_args)...); +return std::invoke( + std::move(f), + std::move(bound)..., + std::forward(call_args)... +); ``` -`std::invoke` uniformly handles all types of callable objects—as discussed in the previous article. `std::move(callable)` forwards the callable object as an rvalue. `std::move(bound_args)...` forwards all bound arguments as rvalues (since captured variables inside the lambda are lvalues, `std::move` is needed to cast them to rvalues). `std::forward(unbound_args)...` perfectly forwards the runtime arguments. - -Bound arguments come first (`std::move(bound_args)...`), and runtime arguments come last (`std::forward...`). This order is crucial—it determines which arguments are "pre-bound" and which are deferred until the call. +`std::invoke` was covered in prerequisites (II); it's the catch-all that handles every shape of callable object, member function pointers included. `std::move(f)` and `std::move(bound)...` fling the captured values out as rvalues. Captured variables inside a `mutable` lambda are lvalues in their own right, so turning them into rvalues on the way out takes an explicit `std::move`. The `call_args...` line just perfect-forwards the runtime arguments as they came. ---- +There's an ordering here worth watching: bound arguments first, runtime arguments after. It isn't arbitrary. It directly decides which arguments are "pre-bound" and which are held back for the call. Get it backwards and the signature won't line up with the arguments. -## Manually Expanding a Concrete Example +## Unfolding a concrete example by hand -Let's use a concrete call example to manually expand the code after template instantiation. Suppose: +Reading the source only gets you so far. Let's take one concrete call and lay out what the template turns into once it's instantiated, so we can see exactly what the compiler generated. Suppose: ```cpp -class Printer { -public: - void Print(int id, const std::string& msg); +struct Calc { + int multiply(int a, int b) { return a * b; } }; -Printer* p = new Printer(); -auto cb = bind_once(&Printer::Print, p, 5); +Calc calc; +auto bound = bind_once(&Calc::multiply, &calc, 5); +int r = std::move(bound).run(8); // r == 40 ``` -### Template Parameter Deduction +## Stepping through the template expansion -`Signature = void(const std::string&)` (explicitly specified), `Callable = void(Printer::*)(int, const std::string&)` (member function pointer type), `BoundArgs = Printer*, int` (object pointer + first argument). +First, the parameter deduction. `Signature = int(int)` is what you wrote; no argument there. `F = int (Calc::*)(int, int)`, the member function pointer type the compiler reads off `&Calc::multiply`. `BoundArgs = {Calc*, int}`, an object pointer plus the first argument. -### Lambda Capture Expansion +The capture list expands into: ```cpp -// Expanded lambda capture -[callable = std::forward(callable), - bound_args1 = std::forward(bound_args1), // captures p - bound_args2 = std::forward(bound_args2)] // captures 5 -(auto&&... unbound_args) mutable { ... } +[f = std::forward(&Calc::multiply), + b1 = std::forward(&calc), + b2 = std::forward(5)] ``` -`callable` captures the member function pointer, `bound_args1` captures the object pointer `p`, and `bound_args2` captures the bound integer `5`. - -### `std::invoke` Expansion Inside Lambda +`f` grips the member function pointer, `b1` grips the object pointer, `b2` grips the bound integer 5. -When `cb` is called, `cb("hello")` is executed. `std::invoke` receives: +Now look at what happens when `bound.run(8)` is actually called. At that moment `call_args = {8}`, and the `std::invoke` inside the lambda body receives: ```cpp -// Arguments received by std::invoke -std::invoke( - std::move(callable), // &Printer::Print - std::move(bound_args1), // p - std::move(bound_args2), // 5 - std::forward("hello") // "hello" -); +std::invoke(std::move(f), std::move(b1), std::move(b2), 8) ``` Which is: ```cpp -// Equivalent call -std::invoke(&Printer::Print, p, 5, "hello"); +std::invoke(&Calc::multiply, &calc, 5, 8) ``` -`std::invoke` detects that the first argument is a member function pointer and the second is a pointer to an object, so it expands to: +`std::invoke` sees that the first argument is a member function pointer and the second is a pointer to an object, and it applies the member-call rule: ```cpp -// Expanded member function call -(p->*(&Printer::Print))(5, "hello"); +((*(&calc)).*(&Calc::multiply))(5, 8) ``` -Equivalent to `p->Print(5, "hello")`, resulting in the member function being called with the bound arguments and the runtime argument. +That's equivalent to `calc.multiply(5, 8)`, which gives `40`. The whole trick is just `std::invoke`'s member-function-pointer overload doing its job. -### Lifetime Trap +## A lifetime trap hiding here -Note that `bound_args1` captures a raw pointer `p`. `OnceCallback` does not manage the lifetime of `p`. If `p` is destroyed before the callback is invoked, the lambda holds a dangling pointer. `std::invoke` accessing freed memory via a dangling pointer results in undefined behavior. +`b1 = std::forward(&calc)` captures a raw pointer, `&calc`. `bind_once` doesn't manage `calc`'s lifetime for you at all. If `calc` gets destroyed before the callback runs, the lambda is left holding a dangling pointer, and `std::invoke` reaches through it into freed memory. That's undefined behavior, a textbook use-after-free. -Chromium uses `raw_ptr` to explicitly mark raw pointer safety, `std::unique_ptr` to take ownership, and `WeakPtr` to automatically cancel the callback when the object is destroyed. Our simplified version temporarily delegates safety responsibilities to the caller. +Chromium patches this three ways: `base::Unretained` to mark "I vouch for it being alive" explicitly, `base::Owned` to take ownership outright, and `base::WeakPtr` so the callback invalidates itself the moment the object destructs. Our stripped-down version takes the easy road and pushes the responsibility onto the caller. In real production code you'd want one of those three in place. ---- - -## Why Must the Signature Be Explicitly Specified? - -You may have noticed that the `Signature` in `bind_once` must be written manually. Ideally, the compiler should automatically deduce the remaining signature from the callable's signature and the number of bound arguments. However, this is more difficult in C++ than it seems. +## Why the signature has to be spelled out -For a function pointer `void(*)(int, float)`, one can extract the parameter list via template partial specialization and then use compile-time "type list slicing" to remove the first N types. For functors with a definite signature, one can extract the signature using `decltype(&Functor::operator())`. But for **generic lambdas** (`[](auto x){}`), its `operator()` is itself a template, so there is no unique signature—the compiler cannot obtain information about "what arguments this lambda accepts" at the type level. - -Chromium wrote hundreds of lines of template metaprogramming code to handle various edge cases. For teaching purposes, asking the caller to write one extra template parameter `Signature` is a more pragmatic choice. - ---- +You've probably noticed that the `int(int)` in `bind_once(...)` has to be written by hand. Ideally the compiler would take the callable's signature and the bound argument count and figure out the remaining signature on its own. In C++ that turns out to be a great deal harder than it looks. -## Summary +A function pointer `R(*)(Args...)` is the easy case: a template partial specialization can dig out the parameter list, and a compile-time "type list slice" chops off the first N. A functor with a fixed call signature is also fine, `decltype(&T::operator())` cracks it in one shot. The real wall is the generic lambda (`[](auto x) { ... }`). Its `operator()` is itself a template, so it has no single determined signature, and at the type level there's no way to ask "what arguments does this lambda take." -In this article, we deconstructed the implementation of `bind_once` line by line. It uses C++20's lambda capture pack expansion to expand bound arguments into the lambda's capture list, uses `std::invoke` to uniformly handle various callable objects (especially member function pointers), and uses the `mutable` keyword to allow modification of captured variables inside the lambda. We manually expanded a complete template instantiation for member function binding to see how `std::invoke` unwraps a member function pointer and object pointer into a normal member function call. Finally, we discussed why the signature must be explicitly specified—the existence of generic lambdas makes automatic deduction extremely complex. +Chromium wrote hundreds of lines of template metaprogramming to paper over those edge cases. There's no point in following it down that road for a teaching version. Making the caller type one extra `int(int)` is the best value for the effort. -In the next article, we will look at the design of cancellation tokens—a lightweight cancellation mechanism implemented using `std::shared_ptr` and `std::weak_ptr`. +In the next piece we look at how to build the cancellation token, a lightweight cancellation mechanism stitched together from `shared_ptr` and `atomic`. ## References diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-4-once-callback-cancellation-token.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-4-once-callback-cancellation-token.md index d2943e5ef..d97c6ac5e 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-4-once-callback-cancellation-token.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-4-once-callback-cancellation-token.md @@ -2,19 +2,17 @@ chapter: 1 cpp_standard: - 23 -description: Deep dive into the design of CancelableToken—implementing a lightweight - cancellation mechanism using `shared_ptr` + `atomic`, and how it integrates - into the execution flow of `OnceCallback`. +description: "A close look at CancelableToken's design: a lightweight cancellation mechanism built from shared_ptr + atomic, and how it slots into OnceCallback's execution flow" difficulty: beginner order: 4 platform: host prerequisites: -- OnceCallback 实战(二):核心骨架搭建 -- OnceCallback 前置知识速查:C++11/14/17 核心特性回顾 +- 'OnceCallback in Practice (II): the core skeleton' +- 'OnceCallback prerequisites: a refresher on C++11/14/17 essentials' reading_time_minutes: 8 related: -- OnceCallback 实战(五):then 链式组合 -- OnceCallback 实战(六):测试与性能对比 +- 'OnceCallback in Practice (V): then chaining' +- 'OnceCallback in Practice (VI): tests and performance' tags: - host - cpp-modern @@ -23,189 +21,167 @@ tags: - atomic - 智能指针 - 引用计数 -title: 'OnceCallback in Practice (Part 4): Designing a Cancellation Token' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-4-once-callback-cancellation-token.md - source_hash: c84ec06e9d73d6d11a77f352e48074daa2488703747abdb92727d138e24a1de2 - translated_at: '2026-06-16T04:13:09.609635+00:00' - engine: anthropic - token_count: 1517 +title: 'OnceCallback in Practice (IV): the cancellation token' --- -# OnceCallback in Practice (Part 4): Cancellation Token Design +# OnceCallback in Practice (IV): the cancellation token -## Introduction +A callback usually gets bound at one moment and actually runs at another, with a task queue sitting in between. A lot can happen in that gap: the bound object is gone, the upper layer cancels the task, the user has already closed the tab. When the callback finally gets its turn, that "do I still need to run?" check at the front is the cancellation token, and it is what this post builds. -A very common requirement in asynchronous programming is that external conditions change after a callback is created but before it is executed, rendering the callback meaningless—for example, the object bound to the callback is destroyed, or the task is cancelled. In these cases, we want the callback to check "should I still execute?" before running, rather than blindly executing. +Chromium turns this into the whole `WeakPtr` machinery under `//base`: when the object destructs, every callback hanging off it is voided at once. That system is a lot to take in at once, so here we build a minimum viable version first: a lightweight flag that several parties can share, that one invalidate drops for everyone, and that you can check across threads. Once this one runs, going back to Chromium's `WeakPtr` will feel a lot smoother. -This is the purpose of a cancellation token. In this post, we will implement a simplified cancellation token and see how it integrates into the execution flow of OnceCallback. +## One flag, shared how -> **Learning Objectives** -> -> - Understand the concept and motivation behind cancellation tokens. -> - Understand the implementation of `CancelableToken` line by line. -> - Understand how the cancellation mechanism integrates into `OnceCallback`. -> - Understand the different behaviors of void and non-void callbacks upon cancellation. +The core tension a cancellation token has to solve is one sentence: invalidation happens outside the callback, the check happens inside it, and the two sides are probably not in the same place, maybe not even on the same thread. So the token itself has to be something both sides can each hold a copy of, while pointing at the same shared state. ---- - -## The Concept of Cancellation Tokens - -You can think of a cancellation token as a "pass." When a callback is created, we give it a pass marked "valid." At some point, external conditions change (e.g., the bound object is destroyed), and external code says "the pass is voided" (calling `Invalidate()`). Afterward, all callbacks holding this pass will find it "invalid" upon checking before execution and skip the run. - -In Chromium, this "pass" is the control block inside `WeakPtr`—when the object pointed to by `WeakPtr` is destroyed, the flag in the control block is cleared, and all callbacks bound to this `WeakPtr` are automatically cancelled. Our simplified version doesn't need to be as complex as `WeakPtr`; we only need a simple "valid/invalid" flag. +That means two things have to hold at once. The token has to be copyable, so the callback holds one copy internally and the code that wants to cancel it holds another, both pointing at the same state. And the reads and writes of that shared state have to be thread-safe: an external thread calling `invalidate()` and the callback thread checking `is_valid()` must not collide into a torn read. -### Core Requirements - -A cancellation token needs to meet three conditions: multiple callbacks can share the same token (one `Invalidate()` invalidates all callbacks simultaneously), the token must be copyable and movable (convenient for holding a copy inside `OnceCallback` and one outside), and the invalidation check must be thread-safe (an external thread might call `Invalidate()` in one thread while the callback checks `IsValid()` in another). - ---- +Let us look at the implementation first, then come back to why it is shaped this way. -## Complete Implementation of CancelableToken +## The full CancelableToken -The entire cancellation token is only 18 lines of code, but every line has its purpose. +The whole cancellation token is 18 lines, but every line earns its keep. ```cpp -class CancelableToken { - public: - CancelableToken() : flag_(std::make_shared()) {} +#pragma once +#include +#include - void Invalidate() { - flag_->valid.store(false, std::memory_order_release); - } +namespace tamcpp::chrome { +class CancelableToken { + struct Flag { + std::atomic valid{true}; + }; + std::shared_ptr flag_; - bool IsValid() const { - return flag_->valid.load(std::memory_order_acquire); - } +public: + CancelableToken() : flag_(std::make_shared()) {} - private: - struct Flag { - std::atomic valid{true}; - }; + void invalidate() { + flag_->valid.store(false, std::memory_order_release); + } - std::shared_ptr flag_; + bool is_valid() const { + return flag_->valid.load(std::memory_order_acquire); + } }; +} // namespace tamcpp::chrome ``` -### Why Use a Nested `Flag` Struct? +### Why a nested Flag struct at all -You might wonder—why not just put an `atomic` directly in `CancelableToken`? The reason is that `shared_ptr` manages a heap object. If we put `atomic` directly in `CancelableToken`, `shared_ptr` would manage the `CancelableToken` itself—but `CancelableToken` has its own `shared_ptr` member, which creates a cycle where `shared_ptr` contains a `shared_ptr`. +The first time I wrote this, my hand moved faster than my head: I dropped an `std::atomic valid` straight into `CancelableToken` and figured that was it. The moment it compiled and I went to share it, the problem jumped out. `shared_ptr` cannot manage that `valid` on its own. To share it, you need the `shared_ptr` to point at an object that *contains* `valid`. If that object is `CancelableToken` itself, then `CancelableToken` has a `shared_ptr` member, and you are staring at a `shared_ptr` wrapping `shared_ptr` nesting doll. -By using a nested `Flag` struct to isolate the state that needs to be shared, `shared_ptr` directly manages `Flag`. The copying and moving of `CancelableToken` are automatically handled through the reference counting of `shared_ptr`—simple and correct. Another benefit is that the `Flag` struct is easy to extend later—if we need to add more atomic flags (like a cancellation reason code), we can just add them to `Flag`. +The window paper only tears once you peel out the bit of state you actually want to share, drop it into a `Flag` struct, and let `shared_ptr` manage that directly. At that point you do not have to think about `CancelableToken`'s copy and move semantics anymore: the compiler-generated copy constructor just shallow-copies the inner `shared_ptr`, the reference count ticks up, and every copy naturally lands on the same `Flag`. Thinking about it later, the struct has a side benefit: the day you want to add something to it, a cancellation reason code say, you just add a field to `Flag` and nothing outside moves. -### The Sharing Mechanism of `shared_ptr` +### How the sharing actually lands -The copy constructor and copy assignment of `CancelableToken` are compiler-generated defaults—they simply copy the `shared_ptr`, incrementing the reference count. All token copies created via copying share the same `Flag` object. When any copy calls `Invalidate()`, it modifies the same `Flag`, and all copies will see `false` on their next call to `IsValid()`. +Saying "shared on copy" can still feel abstract. Look at this snippet. `token2` is a copy of `token1`, and the `shared_ptr` inside both points at the same memory. When `token1` calls `invalidate()`, it mutates the `valid` in that shared memory, and the next time `token2` reads `is_valid()` it reads the same cell and naturally sees `false`: ```cpp -// Copying a token shares the underlying Flag -CancelableToken token2 = token1; // Both point to the same Flag -token1.Invalidate(); // Modifies the shared Flag -assert(!token2.IsValid()); // token2 sees the change +auto token1 = std::make_shared(); +auto token2 = token1; // shares the same Flag + +token1->invalidate(); +assert(!token2->is_valid()); // token2 sees the invalidation too ``` -### `memory_order_acquire`/`release` Pairing +One caution here: the example wraps the outer layer in another `shared_ptr` purely to keep the snippet short. In real use you copy `CancelableToken` by value, since it already shares state through its internal `shared_ptr`. No need to wrap it in another smart pointer. -`Invalidate()` uses `memory_order_release` to store `false`, and `IsValid()` uses `memory_order_acquire` to load. This is a pair of memory orders. The release store guarantees that all writes before the store (including any state modifications before calling `Invalidate()`) are visible to other threads. The acquire load guarantees that all reads after the load see the writes preceding the release store. +### The acquire/release pair -In our scenario, this means if one thread calls `Invalidate()`, another thread subsequently calling `IsValid()` is guaranteed to see `false`—there will be no "I just invalidated it but `is_valid` still returns true" situation. This is the guarantee of thread safety. +`invalidate()` stores `false` with `memory_order_release`, and `is_valid()` loads with `memory_order_acquire`. That is not a random pick. The release store guarantees that every write before it, say the mutations to object state that happened before invalidation, becomes visible to any thread that reads the new value through this store. The acquire load guarantees that after reading that new value, every subsequent read sees the writes that preceded the release. ---- +Dropping that into our scenario: one thread calls `invalidate()`, another thread checks `is_valid()` right after, and as long as the latter reads `false`, every bit of work the former did before invalidating is visible to it. You will not get hit with "I just called invalidate, why does is_valid still say true." That is the confidence that lets it be used across threads. Flip both to `memory_order_relaxed` and the visibility guarantee evaporates; whether the flag flipped and when other threads see it becomes a coin flip, and that is the spot where beginners most often slip. -## Integration into OnceCallback +## Slotting it into OnceCallback -The cancellation token is set into `OnceCallback` via the `set_cancel_token` method: +The cancellation token only earns its keep once it is attached to a callback. The attachment point is a `set_token()`: ```cpp -void set_cancel_token(CancelableToken token) { - cancel_token_ = std::move(token); +void set_token(std::shared_ptr token) { + token_ = std::move(token); } ``` -`cancel_token_` is of type `CancelableToken`, defaulting to an empty value (cancellation disabled). After setting, ownership of the cancellation token is transferred into `OnceCallback`. +`token_` defaults to an empty `shared_ptr`, which reads as "this callback does not participate in cancellation." The moment `set_token` is called, the token moves inside the callback and lives and dies with it. Note that we deliberately use `shared_ptr` rather than a bare `CancelableToken` here. OnceCallback is move-only, so whatever it holds either has to move wholesale or be a cheaply copyable handle, and `shared_ptr` is exactly the latter. -### Complete Logic of `is_cancelled()` +### is_cancelled() looks at two places ```cpp -bool is_cancelled() const { - if (state_ != State::kValid) { - return true; - } - if (cancel_token_ && !cancel_token_->IsValid()) { - return true; - } - return false; +[[nodiscard]] bool is_cancelled() const noexcept { + if (status_ != Status::kValid) return true; + if (token_ && !token_->is_valid()) return true; + return false; } ``` -Two layers of checks. First layer: if the state is not `kValid`, return true—empty callbacks (`kEmpty`) and consumed callbacks (`kConsumed`) both count as "cancelled." This makes sense—empty callbacks have nothing to execute, and consumed callbacks have already run. Second layer: if there is a cancellation token and the token is invalid, also return true. +This check does not look at the token alone. It first glances at the callback's own `status_`: an empty callback (`kEmpty`) and an already-run callback (`kConsumed`) both count as "cancelled" outright. That is reasonable, since an empty callback has nothing inside to execute and a consumed callback must not run again. Only after the `status_` gate passes does the token get its turn: if there is a token and it has been invalidated, that also counts as cancelled. Both gates have to be there. Look at the token alone and the empty and consumed callbacks slip through. -### Cancellation Check in `impl_run()` +### The gate inside impl_run() ```cpp -ReturnType impl_run(Args&&... args) { - if (is_cancelled()) { - state_ = State::kConsumed; - func_.reset(); // Release resources - if constexpr (std::is_void_v) { - return; - } else { - throw CallbackCancelledException("Callback was cancelled"); +ReturnType impl_run(FuncArgs... args) { + assert(status_ == Status::kValid); + + // Cancellation check before execution + if (token_ && !token_->is_valid()) { + status_ = Status::kConsumed; + func_ = nullptr; + if constexpr (std::is_void_v) { + return; + } else { + throw std::bad_function_call{}; + } } - } - // ... execute the callable + + // Normal consumption flow... } ``` -The cancellation check happens **before** executing the callable object. If cancelled, the callback is consumed directly without execution—`state_` is set to `kConsumed`, and `func_` is reset to `nullptr` (destroying the internal callable object and releasing resources). - ---- - -## Difference in Cancellation Behavior Between void and Non-void Callbacks +The cancellation check sits **before** the callable is executed, and the position matters. The moment the check hits, the callback is marked `kConsumed` on the spot. `func_` is nulled along with it, and the lambda inside it, plus whatever it captured, gets released right away. From the outside, this `run()` looks like it consumed the callback, except the function body never actually ran. That "consumed but not executed" semantics is the root of the void versus non-void split below. -There is a design decision here worth expanding on—when a void callback is cancelled, it simply returns (no execution, no error), whereas when a non-void callback is cancelled, it throws a `CallbackCancelledException`. +## Why void and non-void callbacks differ on cancellation -The reason is the caller's expectations differ. The caller of a void callback does not expect a return value—after calling `Run()`, it's done, regardless of whether the callback actually executed. So, skipping execution of a cancelled void callback is transparent to the caller. +This is the one spot in the whole design I think is worth stopping on. Both hit the cancellation check, but a void callback just `return`s and reports nothing, while a non-void callback throws `std::bad_function_call`. At first glance that looks inconsistent. Sit with it a second and you see it is forced. -The caller of a non-void callback expects a return value—`Run()` returns something. If the callback is cancelled, we cannot provide a meaningful return value. Returning a default value (like 0) might mask errors—the caller thinks the callback executed normally, but actually nothing was done. Throwing an exception seems aggressive, but it explicitly tells the caller "something went wrong," which is safer than silently returning an error value. +The caller of a void callback is not expecting a return value at all. They write `std::move(cb).run();` and walk away; they have no idea whether you executed or not. So skipping on cancel is fully transparent to them, no problem. -Chromium chooses to terminate the program directly (`CHECK` failure) here, reasoning that in Chrome's architecture, cancelled callbacks should not be called—the caller should check `IsCancelled()` before calling. We chose exceptions to make it easier to catch and verify in tests, rather than crashing the program directly. +A non-void callback is the awkward one. The caller wrote `int result = std::move(cb).run();` and they are staring at that return value. The callback got cancelled, so what do you hand back? Pick 0 at random? Then the caller gets a 0, assumes the callback ran cleanly and handed them a 0, and the downstream logic just keeps rolling on 0. That kind of "looks like success, did nothing" bug is harder to chase than a crash. So here we would rather throw, and tell the caller plainly: this one did not run, deal with it. ---- +Chromium's choice is rougher still: it `CHECK`-fails and terminates the process outright. The reasoning is that under their architecture, the caller is supposed to check `is_cancelled()` themselves before calling `run()`. If you checked, then charged in anyway, that is a bug, and they crash it in your face. We take the exception path here mostly so tests stay writable; a unit test can assert with `REQUIRE_THROWS` instead of taking the whole process down with it. Neither choice is right or wrong. It depends on how harsh the environment you are targeting with this callback is. -## Usage Example +## A usage example ```cpp -// Example 1: Normal execution -auto token = std::make_shared(); -OnceCallback cb = [token]() { - if (token->IsValid()) { - std::cout << "Executing task..." << std::endl; - } -}; -cb(); // Prints "Executing task..." +using namespace tamcpp::chrome; -// Example 2: Cancellation -auto token2 = std::make_shared(); -OnceCallback cb2 = [token2]() { - std::cout << "This will not print" << std::endl; -}; -token2->Invalidate(); -cb2.set_cancel_token(*token2); -cb2(); // Lambda does not execute, callback consumed -``` +// Create the token and the callback +auto token = std::make_shared(); +bool executed = false; -Note in the second example—`Run()` is called, but the lambda inside the callback does not execute. `impl_run()` detects the token is invalid before execution, consumes the callback, and returns. +OnceCallback cb([&executed] { executed = true; }); +cb.set_token(token); ---- +// With a valid token, the callback runs normally +assert(!cb.is_cancelled()); +std::move(cb).run(); +assert(executed); // the callback ran -## Summary +// Build another callback, this time invalidate the token first +executed = false; +auto cb2 = OnceCallback([&executed] { executed = true; }); +cb2.set_token(token); +token->invalidate(); // void the token -In this post, we implemented a cancellation token and integrated it into `OnceCallback`. `CancelableToken` uses `shared_ptr` + `atomic` to implement a lightweight cancellation mechanism—all token copies share the same `Flag` object, and one `Invalidate()` invalidates all copies simultaneously. The integration checks the token status before `impl_run()` executes—if cancelled, the callback is consumed without execution. Void callbacks return directly, while non-void callbacks throw `CallbackCancelledException`, a difference stemming from the caller's different expectations regarding return values. +assert(cb2.is_cancelled()); +std::move(cb2).run(); // a cancelled void callback does not run and does not throw +assert(!executed); // the callback did not run +``` -In the next post, we will look at `ThenCallback` chaining—the most intricate ownership design among the four `OnceCallback` features. +Read the second example carefully. `cb2.run()` does get called, but the lambda inside never executes a single line. `impl_run()` sees the invalidated token before execution, consumes the callback on the spot, and `return`s. `executed` is still `false`. That is the transparent semantics of a cancelled void callback. ## References - [cppreference: std::shared_ptr](https://en.cppreference.com/w/cpp/memory/shared_ptr) - [cppreference: std::atomic](https://en.cppreference.com/w/cpp/atomic/atomic) -- [Chromium WeakPtr Documentation](https://chromium.googlesource.com/chromium/src/+/main/docs/memory_model/weak_ptr.md) +- [Chromium WeakPtr documentation](https://chromium.googlesource.com/chromium/src/+/main/docs/memory_model/weak_ptr.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-5-once-callback-then-chaining.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-5-once-callback-then-chaining.md index 843cbebda..79e4ff1d5 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-5-once-callback-then-chaining.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-5-once-callback-then-chaining.md @@ -2,19 +2,17 @@ chapter: 1 cpp_standard: - 23 -description: Breaking down the ownership chain design of `then()` line by line—from - pipeline thinking to handling `void` and non-void branches, to understanding the - most intricate ownership management in `OnceCallback`. +description: "Line-by-line breakdown of then()'s ownership chain, from pipeline thinking to the void/non-void branches, to the most intricate ownership management in OnceCallback" difficulty: beginner order: 5 platform: host prerequisites: -- OnceCallback 实战(二):核心骨架搭建 -- OnceCallback 前置知识(二):std::invoke 与统一调用协议 -- OnceCallback 前置知识(三):Lambda 高级特性 +- OnceCallback in practice (II): the core skeleton +- OnceCallback prerequisites (II): std::invoke and the uniform call protocol +- OnceCallback prerequisites (III): advanced lambda features reading_time_minutes: 7 related: -- OnceCallback 实战(六):测试与性能对比 +- OnceCallback in practice (VI): tests and performance comparison tags: - host - cpp-modern @@ -22,215 +20,194 @@ tags: - 回调机制 - 函数对象 - 模板 -title: 'OnceCallback in Practice (Part 5): `then` Chained Composition' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-5-once-callback-then-chaining.md - source_hash: 9b2334e90bc74e0f07a3089fb4fe6a4a0d34aae56c1f851fbdf06dd3a5dae876 - translated_at: '2026-06-16T04:13:17.212980+00:00' - engine: anthropic - token_count: 1620 +title: 'OnceCallback in practice (V): chaining with then' --- -# OnceCallback in Practice (Part 5): Chaining with `then` +# OnceCallback in practice (V): chaining with then -## Introduction +`then()` stitches two callbacks into one pipeline, feeding the first one's output into the second. It's the same old Unix pipe trick, and you've surely seen it before: -`then` allows us to connect two callbacks into a pipeline—where the output of the first callback becomes the input of the second. This sounds simple, but it features the most sophisticated ownership design among the four `OnceCallback` features. Since `OnceCallback` is move-only, `then` must transfer the full ownership of the original callback into the new one, without any sharing or leaking. +```bash +# Unix pipe: cmd1's output is cmd2's input +echo "hello" | tr 'h' 'H' | wc -c +``` + +Drop that onto callbacks and it's the same story: callback A's output goes to callback B. -Starting from pipeline thinking, we will break down the implementation of `then` line by line, focusing on the ownership chain and the handling of void/non-void branches. +```cpp +auto pipeline = OnceCallback([](int a, int b) { + return a + b; // step one: 3 + 4 = 7 +}).then([](int sum) { + return sum * 2; // step two: 7 * 2 = 14 +}); -> **Learning Objectives** -> -> - Understand the pipeline semantics and ownership chain design of `then`. -> - Understand the complete implementation of `then` line by line. -> - Understand the special handling for void prefix callbacks. -> - Compare the choice of using `&&` ref-qualifier for `then` versus using deducing this. +int result = std::move(pipeline).run(3, 4); // result == 14 +``` ---- +We started out thinking this was easy. `then()` just sews two callbacks together, right? But OnceCallback is move-only, so the original callback's full estate has to move into the new one. Miss `func_`, miss `token_`, miss `status_`, and you're sunk. This piece walks through `then()` line by line, with two things under the magnifying glass: how the ownership chain gets joined up section by section, and how the void and non-void return types fork into two branches. -## Pipeline Thinking: The Semantics of `then()` +## Ownership: the real problem in `then()` -If you have used Unix pipes, the semantics of `then` are quite intuitive: +If you've used Unix pipes, the semantics of `then()` are pretty intuitive: ```bash -# Unix pipeline: output of cmd1 is input to cmd2 -cmd1 | cmd2 +# Unix pipe: cmd1's output is cmd2's input +echo "hello" | tr 'h' 'H' | wc -c ``` -`then` does the same thing—the output of callback A is the input to callback B. Expressed in code: +`then()` does the same thing: callback A's output becomes callback B's input. In code: ```cpp -// A returns int, B takes int -auto A = []() { return 1; }; -auto B = [](int x) { return x + 1; }; +auto pipeline = OnceCallback([](int a, int b) { + return a + b; // step one: 3 + 4 = 7 +}).then([](int sum) { + return sum * 2; // step two: 7 * 2 = 14 +}); -// Chained: returns 2 -auto chained = OnceCallback(A).then(B); -chained(); +int result = std::move(pipeline).run(3, 4); // result == 14 ``` -`then` connects two independent callbacks into a new one. When we call the new callback, it automatically executes the entire A → B flow. +`then()` chains two independent callbacks into a new one. Calling the new callback walks the whole A → B flow automatically. --- -## Ownership is the Core Challenge of `then()` +The chained callback has to keep both the original and the continuation in its own hands. On a plain `std::function` that's no trouble, you just copy. But OnceCallback is move-only, and `func_`, `status_`, `token_` are none of them copyable. `then()` has to consume `*this` and `next`, moving the whole estate of both into a fresh lambda closure. -The new chained callback needs to hold the **ownership** of both the original and the subsequent callback—otherwise, the original callback might be consumed externally beforehand, breaking the pipeline. Since `OnceCallback` is move-only, `then` must consume `*this` (the original callback) and `next` (the subsequent callback), transferring both ownerships into a new lambda closure. - -The entire ownership chain looks like this: +Drawn out, the ownership chain is a single line: ```mermaid -sequenceDiagram - participant Caller - participant Original as Original OnceCallback - participant Next as Next Callback - participant Lambda as Lambda Closure - participant New as New OnceCallback - - Caller->>Original: .then(std::move(next)) - Original->>Lambda: self = std::move(*this) - Next->>Lambda: Capture next - Lambda->>New: Construct new OnceCallback - New-->>Caller: Return new callback +graph LR + A["new OnceCallback"] --> B["move_only_function"] --> C["lambda closure"] --> D["original + continuation"] ``` -Every layer passes ownership via move semantics, without any sharing or copying. This is the complete embodiment of move-only semantics in `then`. +Every section is move semantics passing the baton. No copying, no sharing. That line is the full shape of the move-only constraint inside `then()`. --- -## Line-by-Line Breakdown of the Complete `then()` Implementation +## `then()` line by line ```cpp -template -auto then(Next&& next) && { - // ... +template +template +auto OnceCallback::then(Next&& next) && { + using NextType = std::decay_t; + + if constexpr (std::is_void_v) { + using NextRet = std::invoke_result_t; + return OnceCallback( + [self = std::move(*this), + cont = std::forward(next)] + (FuncArgs... args) mutable -> NextRet { + std::move(self).run(std::forward(args)...); + return std::invoke(std::move(cont)); + }); + } else { + using NextRet = std::invoke_result_t; + return OnceCallback( + [self = std::move(*this), + cont = std::forward(next)] + (FuncArgs... args) mutable -> NextRet { + auto mid = std::move(self).run(std::forward(args)...); + return std::invoke(std::move(cont), std::move(mid)); + }); + } } ``` -### Function Signature: Rvalue Qualifier +### Function signature: rvalue qualifier ```cpp auto then(Next&& next) && ``` -The `&&` at the end makes this an rvalue-qualified member function—it can only be called on `std::move(obj)` or a temporary object `create_cb().then(...)`. If the caller writes `cb.then(...)` (lvalue call), the compiler will directly report "no matching overload". This is another way to express consume semantics—unlike `operator()` which uses deducing this to distinguish lvalue and rvalue for different error messages, `then` uses the ref-qualifier for simplicity. +That trailing `&&` makes it an rvalue-qualified member function, meaning `then()` only accepts `std::move(cb).then(next)` or `.then(next)` on a temporary. If someone slips up and writes `cb.then(next)` on an lvalue, the compiler fires back "no matching overloaded function" right there, and the error is even a clear one. This is a different route from `run()`, which goes through deducing this. `run()` has to give different error messages on lvalue versus rvalue calls, which is more work. `then()` doesn't need that distinction, so one ref-qualifier does the job. Clean. -### `std::decay_t`: Decay to Remove References +### `std::decay_t`: strip the reference with decay ```cpp using NextType = std::decay_t; ``` -`Next` might be `Next&&` (rvalue reference) or `Next&` (lvalue reference). `std::decay_t` removes the reference to get the naked lambda type. We use `NextType` for type queries later. +When `Next` comes in it might be `SomeLambda&&`, it might be `SomeLambda&`, and dragging a reference along makes the later type deduction awkward. `std::decay_t` peels the reference off and leaves the bare lambda type. After that, `std::invoke_result_t` queries the return against this `NextType`. -### Two Branches of `if constexpr` +### The two branches of `if constexpr` -The core distinction in `then` lies in whether the original callback's return type is `void`. +What actually forks `then()` is whether the original callback's return type is void. Once that knife comes down, the two sides look quite different. -**Non-void branch**: The original callback returns a value, which needs to be passed to the subsequent callback. +When the original callback returns a value, the non-void branch, that value has to keep flowing into the continuation: ```cpp -if constexpr (!std::is_void_v) { - using NewRetType = std::invoke_result_t; - return OnceCallback( - [self = std::move(*this), next = std::forward(next)]() mutable -> NewRetType { - ArgType result = std::move(self).invoke(); - return std::invoke(next, std::move(result)); - } - ); -} +using NextRet = std::invoke_result_t; ``` -`std::invoke_result_t` deduces at compile time: "what type is returned when passing a value of type `ArgType` to a callable of type `NextType`". This is the return type of the new callback. - -The execution flow inside the lambda: first invoke the original callback to get the intermediate result `result`, then pass `result` to the subsequent callback. +`std::invoke_result_t` asks, at compile time: if we hand a value of type `ReturnType` to a callable of type `NextType`, what type does it spit back? That's the new pipeline's outward return type. The work inside the lambda body is straightforward too. Run the original callback to get the intermediate result `mid`, then pass it straight to the continuation: ```cpp -ArgType result = std::move(self).invoke(); -return std::invoke(next, std::move(result)); +auto mid = std::move(self).run(std::forward(args)...); +return std::invoke(std::move(cont), std::move(mid)); ``` -**Void branch**: The original callback has no return value, and the subsequent callback takes no arguments. +The void branch wears a different face. The original callback returns nothing, so naturally the continuation takes no argument: ```cpp -else { - using NewRetType = std::invoke_result_t; - return OnceCallback( - [self = std::move(*this), next = std::forward(next)]() mutable -> NewRetType { - std::move(self).invoke(); - return std::invoke(next); - } - ); -} +using NextRet = std::invoke_result_t; ``` -`std::invoke_result_t` deduces "what type is returned when calling `NextType` with no arguments". - -The execution flow inside the lambda: execute the original callback (ignoring the return value), then execute the subsequent callback (passing no arguments). +Here `std::invoke_result_t` deduces "call `NextType` with an empty parameter list, what do you get." Inside the lambda it's two steps: run the original callback, throw the result away, then pull out the continuation and run it, also with no argument: ```cpp -std::move(self).invoke(); -return std::invoke(next); +std::move(self).run(std::forward(args)...); +return std::invoke(std::move(cont)); ``` -### Lambda Capture: The Core of Ownership +### Lambda capture: the heart of ownership ```cpp -[self = std::move(*this), next = std::forward(next)] +[self = std::move(*this), cont = std::forward(next)] ``` -`self = std::move(*this)` is the key to the entire ownership chain—it moves **all contents** of the current `OnceCallback` object (`storage_`, `invoke_`, `status_`) into the lambda's closure object. After the move, the current object enters a "moved-from" state—`storage_` and `invoke_` have been moved away. +`self = std::move(*this)` is the crux of the whole ownership chain. It moves the current OnceCallback's entire estate, `func_`, `status_`, `token_`, not one left behind, into the lambda's closure. After the move the current object is an emptied-out shell; `func_` and `token_` are no longer its. `cont = std::forward(next)` brings the continuation in too, and `std::forward` keeps `next`'s original value category: rvalue moves, lvalue copies. -`next = std::forward(next)` also moves the subsequent callback into the lambda closure. `std::forward` preserves the value category of `next`—rvalue moves, lvalue copies. - -This lambda is then passed to a new `OnceCallback` constructor and stored in the new callback's `storage_`. `OnceCallback`'s type erasure capability ensures that regardless of the lambda's actual type, it can be stored uniformly. +This lambda finally gets handed to a fresh `OnceCallback` constructor and stuffed into its `std::move_only_function`. Type erasure is what lets it be folded into the same shell no matter what the lambda actually looks like on the outside. --- -## Multi-stage Pipelines +## Multi-stage pipelines -`then` can be chained to form multi-stage pipelines: +`then()` can keep chaining section by section into a multi-stage pipeline: ```cpp -auto task1 = []() { return 1; }; -auto task2 = [](int x) { return x * 2; }; -auto task3 = [](int x) { return x + 10; }; - -auto pipeline = OnceCallback(task1) - .then(task2) - .then(task3); - -pipeline(); // Returns 12: ((1 * 2) + 10) +using namespace tamcpp::chrome; +auto pipeline = OnceCallback([](int x) { + return x * 2; +}).then([](int x) { + return x + 10; +}).then([](int x) { + return std::to_string(x); +}); + +std::string result = std::move(pipeline).run(5); +// 5 * 2 = 10, 10 + 10 = 20, to_string(20) = "20" ``` -Every call to `then` creates a new `OnceCallback`, internally capturing the previous step's callback. When the outermost callback is invoked, the execution process unfolds recursively: the outermost callback is invoked → its lambda executes → the lambda calls `invoke` on the previous layer → calls the next layer → until the bottom. - -Performance-wise, each layer of `then` adds one level of `std::invoke` indirection. This is perfectly acceptable for pipelines of 2-3 levels. If the pipeline exceeds 10 levels, you might need to consider a flattened pipeline structure to avoid excessive nesting—but this is beyond our current scope. - ---- - -## Common Pitfalls - -### `mutable` Cannot Be Omitted +Every call to `then()` mints a new OnceCallback, with a closure inside that captures the previous step's callback. The moment the outermost `run()` fires, execution unrolls like a set of nested dolls: the outermost gets `run()` → its lambda runs → inside the lambda, `std::move(self).run()` hits the next layer up → and the next → all the way down to the bottom. -The lambda needs to call `self.invoke()`—this operation modifies the state of `self` (changing status from `kValid` to `kConsumed`). If the lambda is const (without `mutable`), `self` is a const reference inside the lambda, and we cannot call state-modifying operations on a const object, causing compilation to fail. +There's a cost. Each extra level of `then()` adds one `std::move_only_function` indirection. For two or three stages you can ignore it entirely. Push past ten and the nesting gets deep enough that you'd probably want a flattened pipeline structure, but that's well past the edge of what we're doing here, so we'll leave it for another day. -### State After `self = std::move(*this)` +## A few spots that trip people up -After the move, the current `OnceCallback` object's `storage_` and `invoke_` have been moved away—they are in a "moved-from" state. `status_` is not explicitly set to `kEmpty`, but keeps its original value. However, since `storage_` has been moved, the current object is effectively unusable—any operation on it is undefined behavior. The `&&` qualifier on `then` ensures the caller cannot continue using the original object after calling `then`. +### `mutable` is not optional -### Why Use `std::invoke` Instead of Direct Call +Inside the lambda we call `std::move(self).run()`, and that call genuinely mutates `self`'s state, flipping status from kValid to kConsumed. Without `mutable`, `self` is a const reference inside the lambda. The compiler will catch you tinkering with a const object every single time and refuse to compile. -`next` is a generic callable object (usually a lambda), so direct `next(...)` syntax would also work. However, `std::invoke` is defensive programming—if someone passes a member function pointer as the subsequent callback, direct call syntax fails, but `std::invoke` handles it. Uniformly using `std::invoke` guarantees correct behavior regardless of the callable type passed. - ---- +### The state of `self = std::move(*this)` -## Summary +After the move, the original OnceCallback's `func_` and `token_` have walked out the door and landed in "moved-from" territory. `status_` isn't explicitly reset to kEmpty, so its old value just hangs there. But with `func_` emptied, the shell is effectively dead, and anyone touching it is in undefined-behavior land. The saving grace is that `&&` qualifier on `then()` guards the door. The caller has no way to keep using the original object after `then()`. -In this post, we broke down the complete implementation of `then`. Its core challenge is ownership management—by using `self = std::move(*this)`, we move the entire original callback into the lambda closure, establishing a complete ownership chain. `if constexpr` handles the different semantics of void and non-void return types—void callbacks pass no arguments to the subsequent callback, while non-void callbacks pass the intermediate result. `then` uses the `&&` qualifier to express consume semantics (more concise than deducing this in `operator()`, as it doesn't require custom error messages), and the `mutable` keyword is essential (because the internal state of `self` needs to be modified). +### Why `std::invoke` instead of a direct call -The next post is the final one in this series—we will use systematic test cases to verify the entire implementation and compare performance differences with the Chromium version. +`cont` is usually a lambda, so `cont(mid)` would run fine. But the day someone passes in a member function pointer as the continuation, direct-call syntax dies on the spot, and `std::invoke` doesn't. Going through `std::invoke` uniformly means that whatever weapon the other side brings, our setup catches it. -## Reference Resources +## References - [Chromium callback.h source](https://chromium.googlesource.com/chromium/src/+/HEAD/base/functional/callback.h) - [cppreference: std::invoke](https://en.cppreference.com/w/cpp/utility/functional/invoke) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-6-once-callback-testing-and-perf.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-6-once-callback-testing-and-perf.md index 4d7066688..1bc1a4507 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-6-once-callback-testing-and-perf.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/01-6-once-callback-testing-and-perf.md @@ -2,51 +2,33 @@ chapter: 1 cpp_standard: - 23 -description: We systematically design six categories of test cases to verify all core - behaviors of `OnceCallback`, and compare performance differences with the original - Chromium version and the standard library solution. +description: "Systematically design six categories of test cases to verify every core behavior of OnceCallback, then measure how our version compares with the original Chromium implementation and a std::function baseline" difficulty: beginner order: 6 platform: host prerequisites: -- OnceCallback 实战(二):核心骨架搭建 -- OnceCallback 实战(三):bind_once 实现 -- OnceCallback 实战(四):取消令牌设计 -- OnceCallback 实战(五):then 链式组合 +- 'OnceCallback hands-on (II): the core skeleton' +- 'OnceCallback hands-on (III): bind_once' +- 'OnceCallback hands-on (IV): the cancellation token' +- 'OnceCallback hands-on (V): then chaining' reading_time_minutes: 8 related: -- OnceCallback 前置知识(五):std::move_only_function +- 'OnceCallback prerequisite (V): std::move_only_function' tags: - host - cpp-modern - beginner - 回调机制 - 函数对象 -title: 'OnceCallback in Practice (Part 6): Testing and Performance Comparison' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-6-once-callback-testing-and-perf.md - source_hash: 19141c279afaf333a9cf1a34b00ae8feb925ac191b4c0bb750915f1f7caac3b5 - translated_at: '2026-06-16T04:13:12.081300+00:00' - engine: anthropic - token_count: 1880 +title: 'OnceCallback Hands-On (VI): Tests and Performance' --- -# OnceCallback in Practice (Part 6): Testing and Performance Comparison +# OnceCallback Hands-On (VI): Tests and Performance -## Introduction +The core skeleton, `bind_once`, the cancellation token, the `then()` chaining, four pieces all assembled. It compiles, and it runs. We did not let out a breath at that point, because "it runs" and "it is correct in every corner case" are separated by a full test suite. This piece fills in that suite, and it also opens up the one question we have been most curious about: how much heavier and slower is the thing we built on `std::move_only_function` compared with the two-thousand-plus lines Chromium hand-rolled? Where did that weight come from, and what did it buy? -At this point, the four core features of OnceCallback—the core skeleton, move semantics, cancellation tokens, and `Then` chaining—have all been implemented. In this article, we will do two things: first, systematically review the testing strategy to ensure the implementation is correct under various boundary conditions; second, analyze the performance differences between our implementation, the original Chromium version, and standard library approaches, to understand exactly what we traded off and what we gained. +## Building the test framework -> **Learning Objectives** -> -> - Master the method of organizing test cases by invariants. -> - Understand the design intent and key assertions of the six test categories. -> - Clarify the performance trade-offs between our OnceCallback and the original Chromium version. - ---- - -## Building the Test Framework - -We use Catch2 v3 as our testing framework, automatically pulling dependencies via CPM (CMake Package Manager). +We use Catch2 v3, and pull it through CPM (CMake Package Manager). ```cmake # test/CMakeLists.txt @@ -59,17 +41,17 @@ target_compile_options(test_once_callback PRIVATE -Wall -Wextra -Wpedantic) add_test(NAME test_once_callback COMMAND test_once_callback) ``` -Catch2's `REQUIRE` macro is superior to `assert` because it reports the specific failed expression, file, and line number, and continues executing subsequent checks within the same `SECTION`. `REQUIRE_THROWS` is specifically used to verify exception types. +We moved off `assert()` to Catch2 mostly for two things. `REQUIRE` prints the failed expression, the file, and the line, and the rest of the checks inside the same `TEST_CASE` still run (unlike `assert`, which halts on the first failure). `REQUIRE_THROWS_AS` pins down the exact exception type, which matters a lot for the cancellation paths further down. -Running tests: In the `build` directory, run `ctest`. +Running the tests is the usual drill: from `build/`, `cmake --build . && ctest`. --- -## Six Categories of Test Cases +## Six categories of test cases -We organize the tests into six categories, each focusing on a specific design invariant. Organizing tests by invariants rather than by functionality makes it less likely to miss boundary conditions. +We split the tests into six categories, each one guarding a single design invariant. Why group by invariant instead of by feature? A feature list feels reassuring, you tick boxes and think you covered everything, while the corners slip through. An invariant is a hard contract that must hold in every situation, and if you build tests around it, the edges surface on their own. -### Category A: Basic Invocation and Return Values +### Category A: basic invocation and return values ```cpp TEST_CASE("non-void return", "[once_callback]") { @@ -86,9 +68,9 @@ TEST_CASE("void return", "[once_callback]") { } ``` -Verifies the most basic construction and invocation behavior—non-void callbacks return the correct values, and void callbacks execute normally. The void return path takes a different branch in `operator()`. +The two most basic cases. A non-void callback must carry its return value out, and a void callback must run to completion. The void case takes the other branch of `if constexpr (std::is_void_v)`, and we keep it explicitly because that two-path template split is exactly where it is easy to test one and forget the other. -### Category B: Move Semantics +### Category B: move semantics ```cpp TEST_CASE("move-only capture", "[once_callback]") { @@ -108,15 +90,15 @@ TEST_CASE("move semantics: source becomes null", "[once_callback]") { } ``` -The move-only capture test verifies that OnceCallback truly supports move-only callables—if the underlying implementation used `std::function` instead of a custom wrapper, this code would fail to compile. The move semantics test verifies that after a move construction, the source object enters the `kEmpty` state. +The first case, move-only capture, is a hard line for us. It is direct proof that the storage really is `std::move_only_function` and not a `std::function` we reached for out of laziness; with `std::function`, that line would not compile. The second case checks that the source is hollowed out after a move, which is the "move means empty" contract OnceCallback leans on. -There is a conceptual point that is easy to confuse—move operations transfer ownership but do not trigger consumption. Only `operator()` consumes the callback. `std::move` merely transfers ownership; the callback remains active until `operator()` is called. +One distinction tripped us up at first. Moving is a relocation, not a consumption. Only `run()` actually consumes the callback. After `OnceCallback cb2 = std::move(cb1)`, the callback is alive and well, it just lives at a new address, and it is not consumed until `cb2.run()`. Confuse those two and the cancellation token work later turns into a headache. -### Category C: Single-Invocation Constraint +### Category C: the single-invocation constraint -This constraint is implemented via deducing this + `delete` on `const`—calling `operator()` on a const object triggers a compile error, while calling on a non-const object passes. No runtime test is needed; the compilation success itself is the verification. +This category has no runtime test, because the constraint is enforced at compile time. Deducing this plus `static_assert` rejects `cb.run()` (no move) outright, and only `std::move(cb).run()` gets through. The fact that it compiles is the verification. We considered writing a `TEST_CASE` for it, then dropped the idea; the invariant is essentially "wrong code fails to compile", and forcing it through `static_assert(!std::is_invocable_v<...>)` is more contorted than letting the compiler referee. -### Category D: Argument Binding +### Category D: argument binding ```cpp TEST_CASE("bind_once basic", "[bind_once]") { @@ -136,9 +118,9 @@ TEST_CASE("bind_once with member function", "[bind_once]") { } ``` -Covers partial argument binding for normal lambdas and member function binding. The lifetime trap of member function binding was discussed in previous articles—`this` is a raw pointer, so the caller is responsible for safety. +Both binding flavors go through. A partial bind on a plain lambda, plus a member-function bind. We left the raw `&calc` form in the member test on purpose, as a standing reminder: lifetime responsibility rests entirely on the caller. The trap was unpacked in an earlier piece, so we do not repeat it here. -### Category E: Cancellation Mechanism +### Category E: the cancellation mechanism ```cpp TEST_CASE("is_cancelled respects cancel token", "[once_callback]") { @@ -172,9 +154,9 @@ TEST_CASE("cancelled non-void callback throws", "[once_callback]") { } ``` -Three key behaviors: no cancellation when the token is valid, void callbacks do not execute when the token is expired, and non-void callbacks throw `BadOnceCall` when the token is expired. +We spent the most time here, because cancellation has three branches that each need their own test. The token is alive, so no cancellation. The token is dead and the callback is void, so the run is silently skipped. The token is dead and the callback is non-void, so the run throws `std::bad_function_call`. Why must the third one throw? The caller is waiting for a return value, and silently returning a default would push the bug into runtime where it hides. Three cases, three branches pinned down. -### Category F: Then Composition +### Category F: then composition ```cpp TEST_CASE("then chains two callbacks", "[then]") { @@ -201,13 +183,15 @@ TEST_CASE("then with void first callback", "[then]") { } ``` -Covers three composition patterns: two-level non-void pipelines, multi-level pipelines (crossing type boundaries from int to string), and void prefix callbacks. +All three composition shapes get exercised. A two-stage non-void pipeline is the common case. The multi-stage pipeline crosses a type boundary on purpose (int into string), to confirm that `then`'s type deduction holds up when the return type changes. The void-prefix case we added later: when `then` is chained after a void callback, the next stage receives no "return value" from the previous step and has to relay through external state. That edge slipped past the first version of the tests. --- -## Performance Comparison: vs. Original Chromium +## Performance: versus the original Chromium + +All tests green. Now for the part we were most curious about. How much do we lose by putting our OnceCallback next to the original Chromium version, with its hand-written reference counting and function-pointer tables? The gap is real, and we are not going to dress it up. -### Object Size +### Object size ```cpp std::cout << "sizeof(std::function): " @@ -218,45 +202,39 @@ std::cout << "sizeof(std::move_only_function): " std::cout << "sizeof(OnceCallback): " << sizeof(OnceCallback) << " bytes\n"; -// 我们的:move_only_function (32) + status (1) + token ptr (16) + padding -// 预估 56-64 bytes +// ours: move_only_function (32) + status (1) + token ptr (16) + padding +// estimate 56-64 bytes ``` -On GCC, typical values are `std::function` at about 32 bytes, `std::move_only_function` at about 32 bytes, and our `OnceCallback` at about 56-64 bytes. Chromium's is only 8 bytes. +The typical numbers on GCC: `std::function` is around 32 bytes, `std::move_only_function` is also around 32 bytes, and our `OnceCallback`, after stacking on the status and token pointer, lands at 56 to 64 bytes. Chromium's? 8 bytes. The price of a single pointer. -The root of the difference lies in the storage strategy. Chromium places all state in a heap-allocated control block, and the callback object holds only a pointer. We use SBO (Small Buffer Optimization) to inline small objects directly, avoiding heap allocation but increasing object size. +Seven times larger gave us a pause at first, but it makes sense once you trace the storage strategy. Chromium stuffs the bound arguments, the function pointer, and the reference count into a heap-allocated `BindState`, and the callback object itself holds a single pointer. We go the SBO route of `std::move_only_function`: small lambdas inline directly into the object, which saves a heap allocation, at the cost of a fatter object. -### Allocation Behavior +### Allocation behavior -The SBO threshold for `std::function` is typically 2-3 pointer sizes (16-24 bytes). Lambdas capturing a few arguments usually fit in SBO and do not trigger heap allocation. Large lambdas, however, trigger heap allocation upon construction. +This is the one place where our design comes out ahead. The SBO threshold of `std::move_only_function` usually sits at two or three pointers (16 to 24 bytes), and a lambda that captures a few arguments fits inside without hitting the heap. Only a lambda with a large capture goes to the heap at construction. -Chromium always allocates on the heap, but allocation happens only once. Subsequent move operations of OnceCallback simply copy a pointer (8 bytes), which is extremely cheap. Our approach allocates nothing for small objects (SBO), but move operations require copying 32+ bytes. +Chromium flips this. It always heap-allocates (`new BindState`), but only once, and every move of the OnceCallback after that copies a single 8-byte pointer, which is almost free. Our side avoids allocation for small objects, but once a move happens, it copies a 32-plus-byte inline buffer. One side saves on allocations, the other saves on moves, and each takes one end of the trade. -### Indirect Invocation Overhead +### Indirect-call overhead -The invocation overhead is identical for both approaches—one indirect function call. Both our implementation and Chromium's dispatch via function pointers. Under optimization, this indirect call cannot be inlined away. +Once you reach the actual call, the two designs tie. Both do one indirect function call. `std::move_only_function::operator()` and Chromium's `polymorphic_invoke_` dispatch the same way. Under `-O2`, neither side can erase that indirect call: a function pointer that crosses translation units is something the compiler will not inline. -### Trade-off Summary - -| Metric | Our Approach | Chromium Approach | -|--------|--------------|-------------------| -| Callback Object Size | 56-64 bytes | 8 bytes | -| Small Lambda Heap Alloc | No allocation (SBO) | Always allocates | -| Move Cost | Copy 32+ bytes | Copy 1 pointer | -| Implementation Code Size | ~200 lines | ~2000+ lines | - -We sacrificed object compactness and极致 performance of move operations for implementation simplicity—no need to manually write reference counting, function pointer tables, or `annotate` attributes. Zero heap allocation for small lambdas can actually be an advantage in low-frequency scenarios. For educational purposes and most practical scenarios, this trade-off is worth it. - ---- +### The trade-off, summed up -## Summary +| Metric | Ours | Chromium | +|------|-----------|--------------| +| Callback object size | 56-64 bytes | 8 bytes | +| Heap alloc for a small lambda | None (SBO) | Always | +| Move cost | Copy 32+ bytes | Copy 1 pointer | +| Implementation size | ~200 lines | ~2000+ lines | -In this article, we did two things. Regarding testing, we designed 12 Catch2 test cases around six invariants (basic invocation, move semantics, single invocation, argument binding, cancellation mechanism, and chaining), covering all core behaviors of OnceCallback. Regarding performance, we compared differences with Chromium OnceCallback in object size, allocation behavior, and invocation overhead—our implementation traded compactness for simplicity. +We read those four rows more than once, and the most valuable one is the last. A full order of magnitude less code. We do not hand-write reference counting, we do not maintain a function-pointer table, we do not haggle with the compiler over `TRIVIAL_ABI` annotations; `std::move_only_function` absorbs all of that. What we get back is an object seven times larger and moves several times more expensive. The zero-allocation property for small lambdas is an accidental bonus in scenarios where callbacks are not posted at high frequency. -With this, the design, implementation, and verification of the OnceCallback component are fully complete. Across 13 articles, from prerequisite knowledge to practice, we have covered the complete knowledge chain from C++11 move semantics to C++23 deducing this. I hope this series helps you understand "how to design an industrial-grade component with modern C++"—not just writing code, but more importantly, understanding the reasoning behind every design decision. +Whether that trade pays off depends on what you are doing with it. For teaching, for prototypes, for most business-logic callbacks, we would take it. If you are dropping callbacks into Chromium's hot paths, where a single process hangs tens of thousands of callbacks and `[[clang::trivial_abi]]` is pushing them into registers, go copy the two thousand lines. The positioning of our version was always clear: explain the mechanism, lay the trade-off out on the table, and let you weigh which side matters. -## Reference Resources +## References - [Chromium base/functional/ source directory](https://source.chromium.org/chromium/chromium/src/+/main:base/functional/) - [cppreference: std::move_only_function](https://en.cppreference.com/w/cpp/utility/functional/move_only_function) -- [Catch2 Documentation](https://github.com/catchorg/Catch2/tree/devel/docs) +- [Catch2 documentation](https://github.com/catchorg/Catch2/tree/devel/docs) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-00-once-callback-cpp-basics-review.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-00-once-callback-cpp-basics-review.md index 04e9e694e..d790805a9 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-00-once-callback-cpp-basics-review.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-00-once-callback-cpp-basics-review.md @@ -5,302 +5,353 @@ cpp_standard: - 14 - 17 - 20 -description: A quick review of all the essential C++ features required for the OnceCallback - series—including move semantics, perfect forwarding, variadic templates, smart pointers, - atomics, lambdas, type traits, and more—to prepare for the upcoming deep dive. +description: "A fast review of every C++ feature the OnceCallback series leans on: move semantics, perfect forwarding, variadic templates, smart pointers, atomics, lambdas, type traits, and more. So the design articles after this one can stay focused on the design." difficulty: intermediate order: 0 platform: host prerequisites: -- 卷一 C++ 基础入门 +- Volume 1: C++ basics reading_time_minutes: 14 related: -- OnceCallback 前置知识(一):函数类型与模板偏特化 -- OnceCallback 前置知识(三):Lambda 高级特性 +- OnceCallback prerequisites (I): function types and template partial specialization +- OnceCallback prerequisites (III): advanced lambda features tags: - host - cpp-modern - intermediate - 基础 - 入门 -title: 'OnceCallback Prerequisites: A Quick Review of C++11/14/17 Core Features' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-00-once-callback-cpp-basics-review.md - source_hash: 5513768d040062a8299d0647724bce9cbfb7972e824859ac8da0697c07a50b6d - translated_at: '2026-06-16T04:13:41.649148+00:00' - engine: anthropic - token_count: 2741 +title: 'OnceCallback prerequisite cheat sheet: a review of C++11/14/17 core features' --- -# OnceCallback Prerequisite Cheat Sheet: A Review of C++11/14/17 Core Features +# OnceCallback prerequisite cheat sheet: a review of C++11/14/17 core features -## Introduction +Let's get the positioning straight first: this is not a from-zero tutorial. If move semantics and smart pointers are still completely foreign to you, go work through Volume 2 and come back. This piece assumes you've seen all of this once and just got rusty. The OnceCallback series leans on a stack of C++11/14/17 features, move semantics, perfect forwarding, variadic templates, smart pointers, atomics, lambdas, type traits, and we're going to run through them all in one go. For each one we cover three things: what it is, how to use it, and where OnceCallback actually needs it. Read this and the design articles that follow won't stall you on a syntax detail. -Let's be honest, this isn't a "from zero" tutorial—if you are completely unfamiliar with concepts like move semantics and smart pointers, I suggest going back to Volume Two and finishing the relevant chapters before returning. The role of this article is a **cheat sheet**: we run through all the C++ features that will be used repeatedly in the OnceCallback series. For each feature, we cover only three things—"what it is", "how to use it", and "where it appears in OnceCallback". The goal is to ensure you don't get stuck on syntax details while reading the subsequent articles. +## Move semantics and std::move -> **Learning Objectives** -> -> - Quickly review all C++11/14/17 fundamental features required for the OnceCallback series. -> - Understand the specific application of each feature in the design of OnceCallback. -> - Establish the knowledge baseline needed for deep learning later on. +The entire foundation of OnceCallback sits here. It's a move-only type, and the core design leans on move semantics end to end, so let's quickly walk the core ideas. ---- - -## Move Semantics and std::move - -Move semantics are the foundation of the entire OnceCallback—it is a move-only type, and its core design relies entirely on move semantics. Let's quickly review the core concepts. +### Rvalue references and the move constructor -### Rvalue References and Move Constructors - -C++11 introduced rvalue references `T&&`, which can bind to temporary objects (rvalues). The semantic of a move constructor is to **steal** resources from the source `other`, rather than making a copy. After stealing, `other` enters a "valid but unspecified" state—usually, it is emptied. +C++11 brought in rvalue references `T&&`, which can bind to temporary objects (rvalues). The move constructor `T(T&& other)` means "steal the resources from `other` instead of copying them." After the steal, `other` is in a "valid but unspecified" state, usually emptied. ```cpp -struct MoveOnly { - int* data; - // Move constructor - MoveOnly(MoveOnly&& other) noexcept : data(other.data) { - other.data = nullptr; // Steal resources, leave source empty +// Minimal move-semantics example +class Buffer { + int* data_; + std::size_t size_; +public: + // ordinary constructor + Buffer(std::size_t n) : data_(new int[n]), size_(n) {} + // move constructor: steal other's resources + Buffer(Buffer&& other) noexcept + : data_(other.data_), size_(other.size_) { + other.data_ = nullptr; // empty the source + other.size_ = 0; } + ~Buffer() { delete[] data_; } }; + +Buffer a(100); // a owns 100 ints +Buffer b = std::move(a); // b stole a's resources, a is now empty ``` -### The Essence of std::move +### What std::move actually does -`std::move` actually doesn't move anything—it is just a `static_cast` that unconditionally converts the input object into an rvalue reference. The ones actually performing the "move" are the move constructor or move assignment operator. The role of `std::move` is to tell the compiler: "I agree to treat this object as an rvalue; you can steal resources from it." +`std::move` doesn't move anything. It's a `static_cast` that unconditionally turns its argument into an rvalue reference. The ones that actually do the moving are the move constructor or move assignment operator. `std::move` just raises its hand and tells the compiler "I'm fine with this being treated as an rvalue, go ahead and steal from it." -### Application in OnceCallback +### Landing it on OnceCallback -The invocation method of OnceCallback is `cb(args...)`. `std::move(cb)` converts `cb` to an rvalue, and `operator()` uses deducing this (a C++23 feature, covered in a dedicated article later) to detect that this is an rvalue call, executes the callback, and marks the state of `*this` as "consumed". Any subsequent access to `cb` is illegal. The entire design idea is: **using the type system to enforce the "call-once-then-invalid" semantics**. +The OnceCallback invocation form is `std::move(cb).run(args...)`. `std::move` turns `cb` into an rvalue, and `run()` (through deducing this, a C++23 feature with its own article later) sees it's being called on an rvalue, runs the callback, then marks `cb`'s state as "consumed." Touching `cb` after that is illegal. The whole design, in plain terms, borrows the type system to enforce "called once, then invalid." -OnceCallback simultaneously deletes the copy constructor and copy assignment (`= delete`), retaining only move operations. This means a OnceCallback object has exactly one owner at any given time—you cannot copy it, you can only transfer ownership via `std::move`. +OnceCallback also `= delete`s both the copy constructor and copy assignment, leaving only the move operations. That means a OnceCallback object has exactly one owner at any moment, you can't copy it, you can only `std::move` ownership away. --- -## Perfect Forwarding and std::forward +## Perfect forwarding and std::forward -Perfect forwarding solves this problem: you write a function template that accepts parameters and passes them verbatim to another function. "Verbatim" means preserving the value category (lvalue vs. rvalue) and const qualifiers of the arguments. +The problem perfect forwarding is after is this: you write a function template that takes some arguments and passes them, unchanged, to another function. "Unchanged" here means keeping the value category (lvalue or rvalue) and the const qualification intact, so an rvalue doesn't quietly turn into an lvalue somewhere along the way. -### Forwarding References and Deduction Rules +### Forwarding references and deduction rules -When a function template's parameter is `T&&` and `T` is a template parameter, `T&&` is not a normal rvalue reference, but a **forwarding reference** (also known as a universal reference). The compiler deduces `T` based on the value category of the passed argument: +When a function template parameter is written `T&&` and `T` is a template parameter, that `T&&` is not a regular rvalue reference, it's a forwarding reference (some call it a universal reference). The compiler deduces `T` from the value category of the argument you pass in: -- Pass an lvalue `t` (type `U&`) → `T` deduced to `U&`, `T&&` collapses to `U&` -- Pass an rvalue `t` (type `U`) → `T` deduced to `U`, `T&&` is `U&&` +Pass an lvalue `x` (of type `int`), and `T` deduces to `int&`, with `T&&` collapsing to `int&`. Pass an rvalue `42` (of type `int`), and `T` deduces to `int`, with `T&&` becoming `int&&`. -### The Role of std::forward +### What std::forward is doing -`std::forward` decides whether to return an lvalue reference or an rvalue reference based on the template parameter `T`: +`std::forward(arg)` decides, based on the template parameter `T`, whether to return an lvalue reference or an rvalue reference: ```cpp template void wrapper(T&& arg) { - // Forward arg to target, preserving lvalue/rvalue + // std::forward preserves arg's original value category target(std::forward(arg)); } + +int x = 10; +wrapper(x); // arg is an lvalue reference, forward returns an lvalue reference +wrapper(10); // arg is an rvalue reference, forward returns an rvalue reference ``` -If you pass `arg` directly without `std::forward`, then `arg` is always an lvalue inside the function (because named variables are lvalues), and the rvalue information is lost. +A trap I hit when learning this: skip `std::forward` and pass `arg` straight through, and `arg` is always an lvalue inside the function (named variables are lvalues), so the rvalue-ness is just gone. `std::forward` is what recovers it. -### Application in OnceCallback +### Landing it on OnceCallback -Perfect forwarding appears many times in OnceCallback. The `bind` function template uses it to preserve the value category of bound arguments—`std::forward(args)` ensures that passed rvalues remain rvalues, and passed lvalues remain lvalues. The deducing this implementation of `operator()` also uses `std::forward` to perfectly forward the value category of `*this` to the internal lambda. +Perfect forwarding keeps showing up in OnceCallback. The `bind_once` function template relies on it to keep the bound arguments' value category: `std::forward(args)...` makes sure an rvalue passed in stays an rvalue, and an lvalue stays an lvalue. The deducing this implementation of `run()` also uses `std::forward(self)` to perfectly forward `self`'s value category down into `impl_run`. --- -## Variadic Templates and Parameter Pack Expansion +## Variadic templates and parameter pack expansion -Variadic templates allow you to write a function or class that accepts an arbitrary number of arguments of arbitrary types. OnceCallback's template signature `OnceCallback` uses parameter packs. +Variadic templates let you write a function or class that takes any number of arguments of any type. OnceCallback's own template signature `OnceCallback` carries a parameter pack. -### Basic Syntax +### Basic syntax ```cpp -template // Template parameter pack -void print_all(Ts... args) { // Function parameter pack - // sizeof...(args) returns the number of arguments +template // Types is a parameter pack +void print_all(Types... args) { + // args... expands here + // sizeof...(Types) returns the number of arguments } ``` -`Ts...` is called a parameter pack; it can contain zero or more types. `args...` is a function parameter pack, expanded at the call site. `sizeof...(args)` is a compile-time constant returning the number of elements in the pack. +`Types...` is a parameter pack and holds zero or more types; `args...` is a function parameter pack, expanded at the call site. `sizeof...(Types)` is a compile-time constant giving the number of elements in the pack. -### Expansion Positions +### Where packs expand -Parameter packs can be expanded in multiple places: function parameter lists, template parameter lists, initializer lists, capture lists (since C++20), etc. In OnceCallback, the most critical expansion position is the lambda's capture list—this feature was only introduced in C++20 and will be covered in a dedicated article. +A parameter pack can expand in several places: the function parameter list, the template parameter list, initializer lists, and (since C++20) capture lists. The most important expansion site inside OnceCallback is a lambda's capture list, a feature only added in C++20, and we have a dedicated article on it later. -### Application in OnceCallback +### Landing it on OnceCallback -`Args...` in `OnceCallback` is a parameter pack; it appears repeatedly throughout the class's implementation—the constructor's parameter types, `operator()`'s parameter types, and the internal storage's signature all come from this pack. `BoundArgs...` in `bind` is another parameter pack, expanded into the lambda's capture list and the call arguments of `target`. +The `Args...` in `OnceCallback` is a parameter pack, and it shows up all over the class implementation: the constructor's parameter types, `run()`'s parameter types, the signature of the internal `func_`, all of it traces back to that pack. `BoundArgs...` inside `bind_once` is another pack, expanded into the lambda's capture list and into the call arguments of `std::invoke`. --- -## Smart Pointer Cheat Sheet +## Smart pointer cheat sheet -OnceCallback uses two types of smart pointers internally; let's quickly review their respective roles. +OnceCallback only uses two smart pointer flavors internally, so let's look at the role each one plays. -### std::unique_ptr: Exclusive Ownership +### std::unique_ptr: exclusive ownership -`std::unique_ptr` is an exclusive smart pointer—only one `unique_ptr` points to the object at any given time. It is not copyable, only movable. The creation method is `std::make_unique`. +`unique_ptr` is the exclusive smart pointer, at most one `unique_ptr` points at the object at a time. It can't be copied, only moved, and you make one with `std::make_unique(args...)`. ```cpp -auto ptr = std::make_unique(42); +auto p = std::make_unique(42); +// auto p2 = p; // compile error: not copyable +auto p3 = std::move(p); // OK: move transfers ownership +// from here on p is nullptr ``` -In OnceCallback, the significance of `std::unique_ptr` is not that we use it directly, but that OnceCallback must support lambdas that capture move-only objects—if a lambda captures a `std::unique_ptr`, then the `std::function`-like storage (OnceCallback's internal storage) containing that lambda must also be move-only. This is something `std::function` cannot do, and it is one of the reasons we chose `std::unique_ptr`. +The point of `unique_ptr` inside OnceCallback isn't that we use it directly. It's that OnceCallback has to support lambdas that capture move-only objects. If a lambda captures a `unique_ptr`, then the `std::move_only_function` holding that lambda (OnceCallback's internal storage) is forced to be move-only too. `std::function` can't do that, which is one of the reasons we go with `std::move_only_function`. -### std::shared_ptr: Shared Ownership +### std::shared_ptr: shared ownership -`std::shared_ptr` manages an object's lifetime through reference counting. All `shared_ptr`s pointing to the same object share the same reference count; when the last `shared_ptr` is destroyed, the object is also destroyed. +`shared_ptr` manages an object's lifetime through reference counting. Every `shared_ptr` pointing at the same object shares one reference count, and when the last `shared_ptr` dies, the object dies with it. ```cpp -auto ptr1 = std::make_shared(42); -auto ptr2 = ptr1; // Both share ownership +auto p1 = std::make_shared(42); +auto p2 = p1; // OK: copy, refcount +1 +// p1 and p2 both point at the same int ``` -In OnceCallback, `std::shared_ptr` is used to manage the cancellation token `CancellationState`. The token needs to be shared between the OnceCallback object and an external controller—the external controller calls `cancel()` to invalidate the token, and OnceCallback checks the token status via its held `std::shared_ptr` copy before executing the callback. The reference counting of `std::shared_ptr` ensures that the underlying `CancellationState` object is not destroyed as long as someone holds the token. +Inside OnceCallback, `shared_ptr` manages the cancellation token `CancelableToken`. The token has to be shared between the OnceCallback object and an external controller: the controller calls `invalidate()` to void the token, and OnceCallback checks the token's state through its own `shared_ptr` copy before running the callback. The reference count guarantees one thing: as long as somebody still holds the token, the underlying `Flag` object won't be destroyed. --- ## std::atomic and memory_order -The internal implementation of the cancellation token uses `std::atomic` and `memory_order`. +The cancellation token's internal implementation uses `std::atomic` together with `memory_order_acquire/release`, so let's cover both at once. -### Atomic Operations +### Atomic operations -`std::atomic` provides atomic access to variables of type `T`—reads and writes cannot be interrupted by operations from other threads. Basic operations are `load` (read) and `store` (write), and you can specify the memory order. +`std::atomic` gives atomic access to a variable of type `T`, reads and writes can't be torn apart by another thread's operations. The basic ops are `load()` (read) and `store()` (write), and you can specify the memory order. ```cpp -std::atomic flag{false}; -flag.store(true, std::memory_order_release); -bool value = flag.load(std::memory_order_acquire); +std::atomic flag{true}; + +// Thread A: write +flag.store(false, std::memory_order_release); + +// Thread B: read +if (flag.load(std::memory_order_acquire)) { + // flag is still true +} ``` -### acquire/release Semantics +### The acquire/release pair -`memory_order_acquire` and `memory_order_release` are a pair of matching memory orders. Simply put: a `release` store guarantees that all writes before the store are visible to other threads; an `acquire` load guarantees that all reads after the load see the writes before the release store. In OnceCallback's cancellation token, `cancel()` uses a `release` store to set `canceled` to `true`, and `is_canceled()` uses an `acquire` load to read `canceled`—this guarantees that if `is_canceled()` returns `true`, all states related to the token are visible to the current thread. +`memory_order_release` and `memory_order_acquire` are a matched pair of memory orders. Loosely, a `release` store guarantees that every write before the store is visible to other threads; an `acquire` load guarantees that every read after the load sees the writes that happened before the release store. Get the pair right and you have happens-before. + +On OnceCallback's cancellation token, `invalidate()` does a `release` store to set `valid` to `false`, and `is_valid()` does an `acquire` load on `valid`. That guarantees: as long as `is_valid()` returns `true`, every state tied to the token is visible to the current thread. --- ## enum class -`enum class` is a scoped enumeration introduced in C++11, solving the name pollution and implicit conversion problems of old-style `enum`. +`enum class` is the scoped enumeration C++11 added, and it fixes two old habits of plain `enum`: name pollution and implicit conversion. ```cpp -enum class State { Empty, Ready, Consumed }; -State s = State::Ready; // Must use scope prefix +// Old enum: pollutes the surrounding namespace, implicitly converts to int +enum Color { Red, Green, Blue }; +int x = Red; // OK, implicit conversion + +// enum class: names scoped inside the enum, no implicit conversion +enum class Status : uint8_t { + kEmpty, // never assigned a value + kValid, // holds a valid callable + kConsumed // already consumed by run() +}; +Status s = Status::kValid; +// int y = s; // compile error: no implicit conversion ``` -OnceCallback uses `enum class` to distinguish three states of the callback. Specifying the underlying type as `uint8_t` saves memory—the entire enum occupies only one byte. +OnceCallback uses `enum class Status` to split the callback into three states. The underlying type is pinned to `uint8_t` to save memory, the whole enum takes one byte. --- -## Lambda Basics +## Lambda basics -Lambdas are ubiquitous in OnceCallback—constructing callbacks, `bind`, and the internal implementation of `operator()` all rely on lambdas. Here is a quick review of the basic syntax. +Lambdas are everywhere in OnceCallback, building callbacks, `bind_once`, the internals of `then()`, all of it. Quick refresher on the basic syntax. ```cpp -[capture](parameters) -> return_type { body } +auto add = [](int a, int b) { return a + b; }; +// add's type is a unique closure class generated by the compiler + +int x = 10; +// by-value capture: copies x +auto f1 = [x]() { return x; }; +// by-reference capture: refers to x (mind the lifetime) +auto f2 = [&x]() { return x; }; +// init capture (C++14): capture by move +auto f3 = [p = std::make_unique(42)]() { return *p; }; ``` -The `operator()` of the closure class generated by a lambda is `const` by default—this means you cannot modify value-captured variables inside the lambda unless you add the `mutable` keyword. In OnceCallback's `bind` and `operator()` implementation, the lambda must be declared as `mutable` because the internals need to call `consume()` to modify the state of `*this`. We will expand on this detail in the article on advanced lambda features. +One thing tripped me up here for a while: the closure class's `operator()` is `const` by default, so you can't mutate a by-value-captured variable inside the lambda unless you add `mutable`. In OnceCallback's `bind_once` and `then()` implementations, the lambda has to be `mutable`, because internally it calls `std::move(self).run()` and that mutates `self`'s state. We'll unpack that in the advanced lambda article. -Generic lambdas (since C++14) allow parameters to use `auto`: +Generic lambdas (since C++14) let parameters use `auto`: ```cpp -auto generic = [](auto&& x) { - // x is a forwarding reference -}; +auto generic = [](auto x, auto y) { return x + y; }; +// the compiler generates a templated operator() ``` -The lambda inside `OnceCallback::operator()` uses `auto&&` to accept runtime arguments—here `auto&&` is a forwarding reference (because `auto` is equivalent to a template parameter). +The lambda inside `bind_once` takes runtime args with `(auto&&... call_args)`, and here `auto&&` is a forwarding reference (since `auto` behaves like a template parameter). --- -## Type Traits +## Type traits -Type traits are tools for compile-time querying and manipulating type information. OnceCallback uses several key traits; let's quickly review them. +Type traits are tools for querying and manipulating type information at compile time. OnceCallback uses a handful of the key ones, quick run-through: ```cpp -std::is_same_v // Check if T and U are the same type -std::is_lvalue_reference_v // Check if T is an lvalue reference -std::remove_reference_t // Remove reference from T +#include + +// std::decay_t: strips references and const/volatile from T, decays arrays to pointers, functions to function pointers +using T1 = std::decay_t; // T1 = int +using T2 = std::decay_t; // T2 = OnceCallback (reference removed) + +// std::is_same_v: are A and B the same type +static_assert(std::is_same_v); // passes +static_assert(!std::is_same_v); // passes + +// std::is_lvalue_reference_v: is T an lvalue reference type +static_assert(std::is_lvalue_reference_v); // passes +static_assert(!std::is_lvalue_reference_v); // passes +static_assert(!std::is_lvalue_reference_v); // passes + +// std::is_void_v: is T void +static_assert(std::is_void_v); // passes +static_assert(!std::is_void_v); // passes ``` -In OnceCallback, `std::is_same_v` and `std::remove_reference_t` are used for the `NotSameAsThis` concept—it checks "whether the template parameter, after decay, is the same type as `This` itself", used to prevent the template constructor from hijacking move constructor calls. `std::is_lvalue_reference_v` is used in the deducing this implementation of `operator()`—it detects if the caller passed an lvalue, and if so, triggers a `static_assert` error. `std::is_void_v` is used for compile-time branching to distinguish between `void` and non-`void` return types in `bind` and `operator()`. +Inside OnceCallback, `std::decay_t` and `std::is_same_v` together build the `not_the_same_t` concept. It checks "is the template parameter, after decay, the same type as `OnceCallback` itself," and it's what stops the template constructor from hijacking move constructor calls. `std::is_lvalue_reference_v` shows up in `run()`'s deducing this implementation to detect whether the caller passed an lvalue, and if so it fires a `static_assert`. `std::is_void_v` shows up in `impl_run()` and `then()` to pick the right compile-time branch between void and non-void return types. --- ## if constexpr -`if constexpr` is a compile-time conditional branch introduced in C++17. The difference between it and a normal `if` is: the condition must be a compile-time constant expression, and **the unselected branch will not be compiled**—not even syntax checked. This feature is particularly useful when handling `void` return types. +`if constexpr` is the compile-time conditional branch C++17 added. The difference from a plain `if` is that the condition has to be a compile-time constant expression, and the branch not taken doesn't get compiled at all, not even syntax-checked. That's especially handy when you're dealing with void return types. ```cpp -template -auto get_value() { - if constexpr (std::is_void_v) { - return; // Only compiled if T is void +template +R do_something() { + if constexpr (std::is_void_v) { + // void return: do the work, no return value + perform_action(); + return; // void return } else { - return T{}; // Only compiled if T is not void + // non-void return: do the work, return the result + return perform_action(); } } ``` -Without `if constexpr` and using a normal `if`, both branches would be compiled. In that case, the `return T{}` in the `void` branch would cause an error immediately—`void` is not a type that can be assigned. `if constexpr` guarantees that the `void` case only generates the code for `return;`, and the non-`void` case only generates the code for `return T{}`. +Without `if constexpr`, with a plain `if`, both branches get compiled. Now the `return result;` inside the void branch blows up immediately, void isn't a type you can return a value of. `if constexpr` makes sure the void case only generates `return;` and the non-void case only generates `return result;`, and the two stay out of each other's way. -In OnceCallback, `if constexpr` appears in two places: the callback execution logic of `operator()` and the chaining composition logic of `then`. Both places face the same problem—`void` return types cannot be assigned and returned in the常规 way. +In OnceCallback, `if constexpr (std::is_void_v)` shows up in two places: the callback execution logic in `impl_run()`, and the chaining logic in `then()`. Both hit the same problem, void return types can't be assigned and returned the normal way. --- ## decltype(auto) -`decltype(auto)` is a return type deduction method introduced in C++14. The difference between it and `auto` lies in the handling of references: `auto` drops references and top-level const, while `decltype(auto)` preserves them. +`decltype(auto)` is the return type deduction form C++14 added. Where it differs from `auto` is in reference handling: `auto` drops references and top-level const, `decltype(auto)` keeps them. ```cpp -int x = 42; -int& foo() { return x; } -auto a = foo(); // a is int (copy) -decltype(auto) b = foo(); // b is int& (reference) +int x = 10; +int& ref = x; + +auto f1() { return ref; } // returns int (reference dropped) +decltype(auto) f2() { return ref; } // returns int& (reference kept) ``` -In OnceCallback, the lambdas in `bind` and `operator()` use `decltype(auto)` as a trailing return type. The purpose is to perfectly forward the return value of the callable object—if the called function returns `T&`, `decltype(auto)` will also return `T&`, preserving the value category information. +Inside OnceCallback, the lambdas in `bind_once` and `then()` use `-> decltype(auto)` as a trailing return type. The point is to perfectly forward the callable's return value: if the function being called returns `int&&`, `decltype(auto)` returns `int&&` too, no value category information lost. --- -## [[nodiscard]] Attribute +## The [[nodiscard]] attribute -`[[nodiscard]]` is an attribute standardized in C++17, telling the compiler "the return value of this function should not be ignored". If the caller writes `is_canceled()` but doesn't use the return value, the compiler will issue a warning. +`[[nodiscard]]` is the attribute C++17 standardized, and it tells the compiler "the return value of this function shouldn't be ignored." If a caller writes `cb.is_cancelled();` and throws away the result, the compiler warns. ```cpp -[[nodiscard]] bool is_canceled() const; +[[nodiscard]] bool is_cancelled() const noexcept; +[[nodiscard]] bool maybe_valid() const noexcept; +[[nodiscard]] bool is_null() const noexcept; ``` -All three query methods of OnceCallback are marked with `[[nodiscard]]`. The reason is simple—calling these methods is for getting the return value for judgment, and calls that ignore the return value are likely typos (e.g., writing `is_canceled()` instead of `cancel()`). The `[[nodiscard]]` on the `explicit operator bool` plays a similar role—preventing unexpected behavior caused by implicit conversion to `bool`. +All three of OnceCallback's query methods carry `[[nodiscard]]`. The reason is blunt: you call these to get a return value and branch on it, and a call that ignores the return value is almost always a typo, something like writing `cb.is_cancelled();` when you meant `if (!cb.is_cancelled())`. The `explicit` on `explicit operator bool()` plays a similar role, blocking the surprises that implicit conversion to `bool` can cause. --- -## Ref-qualified Member Functions +## Ref-qualified member functions -C++11 allows reference qualifiers (ref-qualifiers) for non-static member functions, annotating with `&` or `&&` after the function parameter list. `&` means it can only be called on an lvalue, `&&` means it can only be called on an rvalue. +C++11 lets you put a reference qualifier (ref-qualifier) on a non-static member function, marking it `&` or `&&` after the parameter list. `&` means it can only be called on an lvalue, `&&` only on an rvalue. ```cpp -struct RefQualified { - void foo() & { /* Called on lvalue */ } - void foo() && { /* Called on rvalue */ } +class Widget { +public: + void process() & { + // only callable on an lvalue: Widget w; w.process(); + } + void process() && { + // only callable on an rvalue: Widget().process(); or std::move(w).process(); + } }; ``` -In OnceCallback, the `operator()` method is declared as `R operator()(Args... args) &&`—the trailing `&&` means `operator()` can only be called on an rvalue (`std::move(cb)` or `cb` on a temporary object). This is another way to express consume semantics—unlike the C++23 `operator()` which uses deducing this to distinguish between lvalue and rvalue to give different error messages, the C++11 version uses a ref-qualifier directly, which is more concise. +In OnceCallback, the `then()` method is declared `auto then(Next&& next) &&`. The trailing `&&` means `then()` can only be called on an rvalue (`std::move(cb).then(next)`, or `.then(next)` on a temporary). That's another way to express consume semantics, different from how `run()` uses deducing this: `then()` doesn't need to tell lvalue and rvalue callers apart with different error messages, so a plain ref-qualifier is the cleaner choice. --- -## Summary - -In this article, we quickly ran through all the basic C++ features that will be used in the OnceCallback series. For each feature, we clarified three points: what it is, how to use it, and where it appears in OnceCallback. If you are unfamiliar with any feature, I suggest going back to the corresponding chapters in the earlier volumes to study systematically—subsequent articles will not re-explain these basic syntax rules. +That's the lot, every C++ feature the OnceCallback series is going to lean on. For each one we covered what it is, how to use it, and where it shows up inside OnceCallback. If any of them still feels shaky, go back to the matching chapter in the earlier volumes and work through it properly, the articles after this won't re-explain the basic syntax. -Next, we are entering the deep dive section. The first stop is "Function Types and Template Partial Specialization"—this is the key to understanding the weird syntax `OnceCallback` and the entry point for building our entire template skeleton. +Next we move into the deep dive. First stop is "function types and template partial specialization," the key to the odd-looking `OnceCallback` notation, and the entry point where we start putting the whole template skeleton together. -## Reference Resources +## References -- [cppreference: Move semantics and rvalue references](https://en.cppreference.com/w/cpp/language/reference) +- [cppreference: move semantics and rvalue references](https://en.cppreference.com/w/cpp/language/reference) - [cppreference: std::forward](https://en.cppreference.com/w/cpp/utility/forward) -- [cppreference: Variadic templates](https://en.cppreference.com/w/cpp/language/parameter_pack) +- [cppreference: variadic templates](https://en.cppreference.com/w/cpp/language/parameter_pack) - [cppreference: std::shared_ptr](https://en.cppreference.com/w/cpp/memory/shared_ptr) - [cppreference: std::atomic](https://en.cppreference.com/w/cpp/atomic/atomic) - [cppreference: if constexpr](https://en.cppreference.com/w/cpp/language/if) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-01-once-callback-function-type-and-specialization.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-01-once-callback-function-type-and-specialization.md index f2f04068d..88314925c 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-01-once-callback-function-type-and-specialization.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-01-once-callback-function-type-and-specialization.md @@ -5,188 +5,179 @@ cpp_standard: - 14 - 17 - 20 -description: Deep dive into what the function type `int(int,int)` is, and the template - partial specialization techniques behind `OnceCallback`—how the compiler - deconstructs function signatures through pattern matching. +description: "A close look at what the function type int(int,int) actually is, and the template partial specialization trick behind OnceCallback: how the compiler pulls a function signature apart by pattern matching" difficulty: intermediate order: 1 platform: host prerequisites: -- OnceCallback 前置知识速查:C++11/14/17 核心特性回顾 +- 'OnceCallback prerequisites: a C++11/14/17 refresher' reading_time_minutes: 8 related: -- OnceCallback 前置知识(五):std::move_only_function -- OnceCallback 实战(二):核心骨架搭建 +- 'OnceCallback prerequisites (V): std::move_only_function' +- 'OnceCallback hands-on (II): the core skeleton' tags: - host - cpp-modern - intermediate - 模板 - 泛型 -title: 'OnceCallback Prerequisites (Part 1): Function Types and Template Partial Specialization' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-01-once-callback-function-type-and-specialization.md - source_hash: f8ae704aefb39b88443c304d6f73c02c766d7b1e1f2fb677bac6e6bbd05de009 - translated_at: '2026-06-16T04:13:36.480668+00:00' - engine: anthropic - token_count: 1503 +title: 'OnceCallback Prerequisites (I): Function Types and Template Partial Specialization' --- -# OnceCallback Prerequisites (Part 1): Function Types and Template Partial Specialization +# OnceCallback Prerequisites (I): Function Types and Template Partial Specialization -## Introduction +The first time we ran into `OnceCallback` in the Chromium source, we stared at it for a while. `int(int, int)` looks like the wreckage of a function declaration, yet there it sits in a template parameter slot. What is this thing? And how does the compiler read "returns int, takes two ints" back out of `int(int, int)`? -If this is your first time seeing `OnceCallback`, you might find it a bit strange—`void(int, double)` looks like a function declaration, yet it appears in a template parameter position. What exactly is this thing? How does the compiler break down `int(int, int)` into information like "returns int, accepts two int parameters"? +We didn't figure it out at the time. It turns out this spelling is the shared foundation under `std::function`, `std::move_only_function`, and our entire `OnceCallback`. This piece works that foundation over. First we set the overlooked idea of a "function type" upright, then we walk through how primary template plus partial specialization pulls a signature apart by pattern matching. We will hand-roll a tiny `FuncTraits` along the way to make it run, and close on why the standard library collectively picked the signature form instead of the more obvious spelling. -In this post, we will break down this seemingly quirky but actually very elegant technique. Once you understand it, you will be able to see why the template signatures of `std::is_function`, `std::coroutine_traits`, and our `OnceCallback` look the way they do. +## Function types: a C++ type that's easy to miss -> **Learning Objectives** -> -> - Understand that a function type is a valid type in C++. -> - Master the recurring template design pattern of "primary template + partial specialization". -> - Be able to implement a minimal version of a function signature extraction tool. +Let's start with the plainest question: is `int(int, int)` a type in C++? ---- - -## Function Types: An Easily Overlooked Type in C++ - -Let's start with a basic question: Is `int(int, int)` a type in C++? +Yes. It has a name, a function type, and it means "a function taking two ints and returning an int." One thing worth flagging here: a function type sits lower in the stack than a function pointer. It is not the same thing as `int(*)(int, int)` (a pointer) or `int(&)(int, int)` (a reference). That "lower" position is exactly what lets partial specialization grab it, as we will see. -The answer is: Yes. `int(int, int)` is something called a **function type**. It describes "a function that accepts two `int` parameters and returns an `int`". Note that it is not a function pointer `int(*)(int, int)`, nor a function reference `int(&)(int, int)`—the function type is a more fundamental concept than function pointers. - -We can verify this with `using`: +A `static_assert` settles it: ```cpp -using Func = int(int, int); // Define Func as a function type +#include + +static_assert(std::is_function_v); // passes: it's a function type +static_assert(!std::is_pointer_v); // passes: not a pointer +static_assert(std::is_pointer_v); // passes: this one is a function pointer ``` -Function types appear in actual code more often than you might think. When you write a function declaration: +Function types show up more often than you'd think. Take an ordinary declaration: ```cpp int add(int a, int b); ``` -The type of `add` is `int(int, int)`. You can think of it as a "signature"—it completely describes what parameters the function accepts and what type it returns, without involving where the function itself is stored. - -There is an implicit conversion between function types and function pointers: in most expressions, a function name automatically decays into a pointer to itself. This is similar to how an array name decays into a pointer—`add` in `add(1, 2)` becomes a pointer in most contexts, just as `arr` in `arr[0]` becomes a pointer. +The type of `add` is `int(int, int)`. You can treat it as a signature: it says exactly what the function takes and what it returns, without saying where the function itself lives. -However, when passed as a **template argument**, the function type does not decay—the compiler receives this type exactly as is. This is the prerequisite that allows us to deconstruct it using template partial specialization. +There's an implicit conversion between function types and function pointers. In most expressions, a function name decays into a pointer to itself, the same way an array name decays into a pointer. The `arr` in `int arr[5]` becomes `int*` in most contexts; the `add` in `int add(int, int)` becomes `int(*)(int, int)`. ---- +But once it goes in as a template argument, the function type stops decaying. The compiler takes it as is. That is the prerequisite for taking it apart with partial specialization. -## Primary Template + Partial Specialization: The Pattern for Deconstructing Function Types +## Primary template plus partial specialization: the recipe for breaking down a function type -Now let's look at how `std::is_function`'s template declaration is written. It uses a two-step design: first, declare a primary template that accepts only one type parameter, then provide a partially specialized version for the case where "that type parameter happens to be a function type". +Next, let's look at how `OnceCallback`'s template declaration is written. It's a two-step move: first throw out a primary template that takes a single type parameter, then open a separate partial specialization for the case where "that type parameter happens to be a function type." -### Step 1: Primary Template Declaration +### Step 1: the primary template declaration ```cpp -template -struct std::is_function : std::false_type {}; +template +class OnceCallback; // primary template: declaration only, no definition ``` -The primary template intentionally provides no implementation. This isn't an oversight, but a design choice—if someone accidentally writes a usage like `is_function` (passing a plain `int` type instead of a function signature), the compiler will error during instantiation because it can't find a definition. This is a compile-time safety net. +The primary template deliberately has no implementation. This isn't an oversight. It's a compile-time safety net: if someone slips and writes `OnceCallback`, passing a plain int instead of a function signature, instantiation fails on the missing definition right there. -### Step 2: Partial Specialization Version +### Step 2: the partial specialization ```cpp -template -struct std::is_function : std::true_type {}; +template +class OnceCallback { + // all the real code lives here +}; ``` -The template parameter list for this partial specialization is `template `, while the `` following the class name is the **pattern matching condition** for the partial specialization. It says: "When `T` can be deconstructed into the form `Ret(Args...)`, use this version." +The template parameter list on this version is ``, but the part that matters is the `OnceCallback` after the class name. That's the pattern-matching condition for the partial specialization, and it says one thing: when `FuncSignature` can be assembled into the shape `ReturnType(FuncArgs...)`, use this version. -### The Compiler's Matching Process +### How the compiler pairs things up -When you write `is_function::value`, the compiler does the following: +When you write `OnceCallback`, the compiler does a few things. -First, it sees you are instantiating `is_function`, with the template argument being `int(int, int)`. Then it looks at the primary template `is_function`, binding `T` to the `int(int, int)` type as a whole. Next, it checks if a partial specialization can be used—the partial specialization requires `T` to match the pattern `Ret(Args...)`. `int(int, int)` can恰好 be broken down into `Ret=int` and `Args=int, int`, so the match succeeds! The partial specialization is selected. +It sees you instantiating `OnceCallback` with `int(int, int)` as the template argument. It goes to the primary template and binds `FuncSignature` to `int(int, int)` as a whole. Then it turns around and checks whether a partial specialization can match. The specialization needs `FuncSignature` to fit the pattern `ReturnType(FuncArgs...)`. `int(int, int)` breaks apart cleanly: `ReturnType = int`, `FuncArgs = {int, int}`. The match lands, and the specialization gets picked. -You can imagine this process as a type-level pattern matching—just like the regex `(\w+)\((\w+(?:,\s*\w+)*)\)` can extract return values and parameter lists from a string, template partial specialization extracts the return type and parameter pack from the type `int(int, int)`. +You can think of the whole process as pattern matching at the type level. Compare it to a regex like `(\w+)\((\w+(?:,\s*\w+)*)\)`, which digs the return value and parameter list out of the string `int(int, int)`. Partial specialization does the same job; it just operates on types instead of characters. -### Uses the Exact Same Technology as `std::coroutine_traits` +### The exact same trick as `std::function` -If you look at the standard library implementation of `std::coroutine_traits`, you will find it uses the exact same pattern: +Go pull up a standard library implementation of `std::function` and you'll find the same setup: ```cpp -template -struct coroutine_traits { - using promise_type = /* ... */; +// simplified std::function +template class function; // primary template + +template +class function { // partial specialization + // ... }; ``` -`std::is_function` (C++23) is the same. This "primary template + function type partial specialization" pattern appears three times in the standard library and is a well-validated design. - ---- +`std::move_only_function` (C++23) is the same. The "primary template plus function-type partial specialization" pair shows up at least three times in the standard library. It's a design that's been validated over and over. When we write our own `OnceCallback`, there's no reason to start from scratch. -## Hands-on Practice: Implementing a FuncTraits +## Hands-on: rolling a FuncTraits -Reading without practicing makes it hard to remember. Let's implement a minimal function signature extraction tool ourselves to solidify our understanding. The goal is: given a function type `F`, extract the return type `Ret` and the parameter pack `Args`. +Reading alone won't stick. Let's write the smallest function-signature extraction tool ourselves and hammer the idea down. The goal: hand it a function type `R(Args...)` and have it hand back the return type `R` and the parameter pack `Args...`. ```cpp -template -struct FuncTraits; // Primary template, intentionally undefined - -// Partial specialization for function types -template -struct FuncTraits { - using ReturnType = Ret; - using ArgTypes = std::tuple; - static constexpr std::size_t ArgCount = sizeof...(Args); +#include + +// primary template: no definition for non-function types +template +struct FuncTraits; + +// partial specialization: peel apart R(Args...) +template +struct FuncTraits { + using ReturnType = R; + using ArgsTuple = std::tuple; + + static constexpr std::size_t kArity = sizeof...(Args); }; + +// checks +static_assert(std::is_same_v::ReturnType, int>); +static_assert(std::is_same_v::ReturnType, void>); +static_assert(FuncTraits::kArity == 3); ``` -`FuncTraits` and `OnceCallback` use the exact same partial specialization pattern. The only difference is that `FuncTraits` stores the extracted types as a `ReturnType` alias and an `ArgCount` constant, while `OnceCallback` directly uses these types inside the partial specialization class to define data members and methods. +`FuncTraits` walks the same partial-specialization path as `OnceCallback`. There's one difference: `FuncTraits` stores the extracted types as `using` aliases and a `static constexpr` constant for outside callers, while `OnceCallback` takes those types directly and uses them inside the specialization class to define its data members and methods. -Try compiling and running this example—if all `static_assert`s pass (no compilation errors), it means the partial specialization correctly deconstructed the function type. You can try adding some more complex types to test: +Let's compile and run the example. If the `static_assert`s all pass (no compilation errors), the specialization split the function type correctly. You can throw a few harder types at it too: ```cpp -// Complex function type: returns a pointer to a function taking double and returning int -using ComplexFunc = int(*)(double); -using TestType = ComplexFunc(double); - -static_assert(std::is_same_v::ReturnType, ComplexFunc>); -static_assert(FuncTraits::ArgCount == 1); +// harder checks +static_assert(std::is_same_v< + FuncTraits::ReturnType, + std::string>); +static_assert(std::is_same_v< + FuncTraits::ArgsTuple, + std::tuple>); ``` --- -## Why Not Use `OnceCallback`? +## Why not write it as `OnceCallback`? -You might wonder, since the goal is to get the return type and parameter list, why not write it directly in the form `OnceCallback`? Like this: +You might be wondering: if all we want is the return type plus the parameter list, why not just spell it `OnceCallback` and be done? Something like: ```cpp -template +template class OnceCallback { // ... }; + +// usage: OnceCallback cb([](int a, int b) { return a + b; }); ``` -This approach is technically feasible, but the user experience is not as good. Let's compare the two ways of calling it: +This compiles fine. The ergonomics are worse, though. Let's set the two calls side by side: ```cpp -// Signature style (current approach) -OnceCallback cb1; +// signature form: one template argument, reads like a function signature +OnceCallback cb1([](int a, int b) { return a + b; }); -// Parameter list style (alternative approach) -OnceCallback cb2; +// parameter-list form: return type and parameters written separately +OnceCallback cb2([](int a, int b) { return a + b; }); ``` -The first one is more natural—`int(int, int)` is a complete function signature, which is clear at a glance. The second requires you to mentally interpret the first `int` as the return type and the subsequent `int, int` as the parameter list, which increases cognitive load. The standard library also chooses the signature style—`std::function` rather than `std::function`. - -The signature style also has a subtle benefit: it is more consistent with the C++ type system. `int(int, int)` is a real type, whereas "a return type plus a set of parameter types" is not a type—it is just a list of several types. Using a function type as a template parameter is operating at the level of the type system, not at the level of syntactic sugar. - -Of course, the signature style has one drawback—the compiler cannot automatically deduce the complete signature from a callable object. This is why the first template parameter of `OnceCallback` must be specified manually. We will discuss this trade-off in detail in the upcoming `OnceCallback` implementation post. - ---- - -## Summary +The first reads naturally. `int(int, int)` is a complete function signature, clear at a glance. The second makes you do a mental split: the first `int` is the return type, the next two `int, int` are the parameters. That's a tax on the reader for no payoff. The standard library made the same call: `std::function`, not `std::function`. -In this post, we figured out three things. The function type `int(int, int)` is a valid type in C++ that completely describes a function's signature; it is not a function pointer nor a function reference. The "primary template + partial specialization" pattern deconstructs the function type into a return type and a parameter pack through pattern matching. `std::is_function`, `std::coroutine_traits`, and our `OnceCallback` all use the same technique. The signature style `OnceCallback` is more natural and aligns better with C++ type system design philosophy than the parameter list style `OnceCallback`. +The signature form has a subtler benefit too. It lines up better with the C++ type system. `int(int, int)` is an actual type; "a return type plus a pile of parameter types" is not a type, it's several types sitting next to each other. Taking a function type as the template argument means we're working with the type system itself, not patching syntactic sugar on top. -In the next post, we will look at `std::mem_fn`—it is the key tool that allows `OnceCallback` to uniformly handle function pointers, member function pointers, and lambdas. +There's one corner where the signature form bites, though: the compiler can't deduce the full signature from a callable object on its own. That's why `bind_once`'s first template parameter, `Signature`, has to be written out by hand. We'll save that trade-off for the `bind_once` implementation piece. -## Reference Resources +## References -- [cppreference: Function type](https://en.cppreference.com/w/cpp/language/function) -- [cppreference: Template partial specialization](https://en.cppreference.com/w/cpp/language/template_specialization) +- [cppreference: function type](https://en.cppreference.com/w/cpp/language/function) +- [cppreference: template partial specialization](https://en.cppreference.com/w/cpp/language/template_specialization) - [cppreference: std::is_function](https://en.cppreference.com/w/cpp/types/is_function) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-02-once-callback-invoke-and-callable.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-02-once-callback-invoke-and-callable.md index a9ce302f9..6e26e4a7d 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-02-once-callback-invoke-and-callable.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-02-once-callback-invoke-and-callable.md @@ -2,245 +2,220 @@ chapter: 0 cpp_standard: - 17 -description: Gain a deep understanding of how `std::invoke` unifies the calling conventions - for function pointers, member function pointers, lambda expressions, and functors, - as well as the role of `std::invoke_result_t` in type deduction within `OnceCallback`. +description: "How std::invoke unifies the calling convention for function pointers, + member function pointers, lambdas, and functors, and how std::invoke_result_t drives + type deduction inside OnceCallback" difficulty: intermediate order: 2 platform: host prerequisites: -- OnceCallback 前置知识速查:C++11/14/17 核心特性回顾 -- OnceCallback 前置知识(一):函数类型与模板偏特化 +- 'OnceCallback prerequisites: a C++11/14/17 refresher' +- 'OnceCallback prerequisites (I): function types and template partial specialization' reading_time_minutes: 8 related: -- OnceCallback 实战(三):bind_once 实现 -- OnceCallback 实战(五):then 链式组合 +- 'OnceCallback in practice (III): implementing bind_once' +- 'OnceCallback in practice (V): chaining with then()' tags: - host - cpp-modern - intermediate - 函数对象 - std_invoke -title: 'OnceCallback Prerequisites (Part 2): std::invoke and the Uniform Calling Convention' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-02-once-callback-invoke-and-callable.md - source_hash: 6565cf61f22ee2e502e620b0d180df665832123aeb4edcf9cb0555f57898ff12 - translated_at: '2026-06-16T04:13:39.844416+00:00' - engine: anthropic - token_count: 1598 +title: 'OnceCallback prerequisites (II): std::invoke and the uniform calling convention' --- -# OnceCallback Prerequisites (Part 2): std::invoke and the Uniform Calling Convention +# OnceCallback prerequisites (II): std::invoke and the uniform calling convention -## Introduction +In the previous post we untangled function types and template partial specialization. This one tackles a more annoying problem: the calling syntax for callable objects. -Suppose you are writing a callback system—just like the OnceCallback we are building. Your system needs to accept various "callable objects": ordinary function pointers, lambdas, functors (class objects with overloaded `operator()`), and even member function pointers. The problem is that the calling syntax for these callable objects varies. Ordinary functions are called directly via `func()`, while member function pointers must be written as `(obj.*func)()`. If your code contains ten different callable objects, do you really need to write ten `if` branches to handle them separately? +When you write a callback system, the natural wish is that whatever comes in, a function pointer, a lambda, a member function pointer, you can fire it with one syntax. C++ does not oblige. A free function is fine as `f(args...)`, but a member function pointer insists on `(obj.*pmf)(args...)`, and even a pointer to a data member has to go through `obj.*pmd`. Ten kinds of callables, ten branches in your template to figure out which family it belongs to. Do that long enough and your blood pressure goes up. -`std::invoke` (C++17) was born to eliminate this fragmentation. It provides a uniform calling syntax, allowing all callable objects to be invoked in the same way. OnceCallback's `bind_once` and `then()` methods rely entirely on it to achieve the requirement of "correctly calling whatever callable object is passed in." +`std::invoke` (C++17) is what glues this pile of syntax into one layer. OnceCallback's `bind_once` and `then()` both lean on it internally, so they can correctly call whatever you hand them. -> **Learning Objectives** -> -> - Understand why a uniform calling convention is needed—the differences in calling syntax for various callable objects. -> - Master the complete dispatch rules of `std::invoke`. -> - Learn to use `std::invoke_result_t` to deduce the return type of a call at compile time. +## The problem: the calling syntax for callables is fragmented ---- - -## Problem: The Fragmentation of Callable Object Syntax +Let's line up the common callables side by side. The split is hard to miss. -In C++, there are at least four common callable objects, each with its own calling syntax. Let's examine them one by one. - -### Ordinary Function Pointers +A plain function pointer is the well-behaved one, anything works: ```cpp -void free_func(int x) { /* ... */ } +int add(int a, int b) { return a + b; } +int (*fp)(int, int) = &add; -void (*ptr)(int) = &free_func; -ptr(42); // Direct call +int result = fp(3, 4); // Direct call +int result2 = (*fp)(3, 4); // Dereferenced, then called (equivalent) ``` -### Lambda / Functor +Lambdas and functors both go through `operator()`, which looks almost like a free function call, so this part is friendly: ```cpp -auto lambda = [](int x) { /* ... */ }; -lambda(42); // Direct call -``` +auto lam = [](int a, int b) { return a + b; }; +int result = lam(3, 4); // Through operator() -### Member Function Pointers +struct Adder { + int operator()(int a, int b) { return a + b; } +}; +Adder fn; +int result2 = fn(3, 4); // Also through operator() +``` -Here, the syntax starts to get weird. Member function pointers cannot be called directly like ordinary functions—you must have an object instance and use the `.*` or `->*` operators to invoke them. +Things get ugly at the member function pointer. It can't be `()`-ed like a free function. You need an object instance first, then you bolt it on with the `.*` or `->*` operators, two of the rarer sights in the language. The first time we wrote one of these we flipped through a book for half an afternoon: ```cpp -struct Widget { - void func(int x); +struct Calculator { + int multiply(int a, int b) { return a * b; } }; -void (Widget::*mem_ptr)(int) = &Widget::func; +Calculator calc; +int (Calculator::*pmf)(int, int) = &Calculator::multiply; -Widget w; -(w.*mem_ptr)(42); // Call via object +// Must use the .* operator +int result = (calc.*pmf)(3, 4); // result == 12 ``` -### Pointer to Data Member - -Yes, C++ allows you to get a "pointer" to a data member—it's actually an offset. Access is also done via the `.*` operator. +There's a colder cousin: the pointer to data member. C++ lets you take a "pointer" to a data member, which is really an offset, and access goes through `.*` the same way: ```cpp -struct Widget { - int value; +struct Point { + double x, y; }; -int Widget::*mem_ptr = &Widget::value; +Point p{1.0, 2.0}; +double Point::*pmx = &Point::x; -Widget w; -w.*mem_ptr = 42; // Access via object +double val = p.*pmx; // val == 1.0 ``` -The problem is clear: if you are writing a template function that needs to call a "callable object of an unknown type," you cannot write a single calling syntax—because you don't know if it's an ordinary function or a member function pointer. `std::invoke` is the solution to this problem. +You see the shape of the problem. If you're writing a template that has to call "a callable of a completely unknown type," there's no single syntax that works. You don't know whether it's a function or a member pointer, and one wrong guess fails to compile. `std::invoke` is what plugs that hole. --- -## Dispatch Rules of std::invoke +## The dispatch rules of std::invoke -The job of `std::invoke` is to select the correct calling syntax based on the specific types of the callable object and the arguments. The standard defines the following scenarios (referred to in the C++ standard as INVOKE expressions): +What `std::invoke(f, args...)` does, in one sentence: it looks at the concrete types of `f` and `args`, and picks the right calling syntax. The standard calls this the INVOKE expression and splits it into three families. -### Case 1: Member Function Pointer + Object - -When `Callable` is a pointer to a member function, and the first element of `Args` is an object (or a reference to an object, or a pointer to an object), `std::invoke` expands to calling the member function via the object. +The thorniest, and the one worth memorizing, is the member function pointer. When `f` is a pointer to a member function and the first element of `args` is the object itself (a reference, a value, or a pointer to the object), `std::invoke` expands it into `(obj.*pmf)(rest...)`: ```cpp -struct Widget { - void func(int); +struct Calculator { + int multiply(int a, int b) { return a * b; } }; -Widget w; -Widget* ptr = &w; -void (Widget::*mem_func)(int) = &Widget::func; +Calculator calc; -// std::invoke handles both reference and pointer automatically -std::invoke(mem_func, w, 10); // Equivalent to (w.*mem_func)(10) -std::invoke(mem_func, ptr, 10); // Equivalent to ((*ptr).*mem_func)(10) +// By reference +std::invoke(&Calculator::multiply, calc, 3, 4); // (calc.*multiply)(3, 4) +// By pointer +std::invoke(&Calculator::multiply, &calc, 3, 4); // ((*ptr).*multiply)(3, 4) ``` -Note the second case—when the first argument is a pointer (`ptr`), `std::invoke` automatically dereferences the pointer. This behavior is crucial when binding member functions in `bind_once`. - -### Case 2: Pointer to Data Member + Object +Note the second line, the `&calc` one. When the first argument is a pointer, `std::invoke` dereferences it for you before going through `.*`. It looks unremarkable, but it saves the day when `bind_once` binds a member function, as we'll see further down. -When `Callable` is a pointer to a data member, `std::invoke` expands to accessing the data member via the object. +Pointers to data members take the same route, just with "access" where "call" used to be: ```cpp -struct Widget { - int value; -}; - -Widget w; -int Widget::*mem_data = &Widget::value; +struct Point { double x, y; }; +Point p{1.0, 2.0}; -// Returns a reference to w.value -int& res = std::invoke(mem_data, w); +double val = std::invoke(&Point::x, p); // p.*&Point::x == p.x ``` -### Case 3: Other Callable Objects - -When `Callable` is a function pointer, lambda, functor, or other "directly callable thing," `std::invoke` is simply `Callable(Args...)`. +The rest, function pointers, lambdas, functors, anything you can slap `()` onto, `std::invoke` faithfully calls as `f(args...)`: ```cpp -auto lambda = [](int x) { return x + 1; }; -std::invoke(lambda, 42); // Equivalent to lambda(42) +std::invoke([](int a, int b) { return a + b; }, 3, 4); // lambda(3, 4) ``` -### Unified Interface - -The key is that no matter which of the above cases `Callable` falls into, the calling syntax is always `std::invoke(Callable, Args...)`. In your template code, you don't need to know the specific type of `Callable`—`std::invoke` internally dispatches to the correct calling syntax for you. +Put the three families together and the takeaway is one line: no matter which one your `f` falls into, you always write `std::invoke(f, args...)`. Your template code no longer has to know what kind of thing `f` is. Dispatch is `std::invoke`'s problem, done for you internally. --- -## std::invoke_result_t: Compile-Time Return Type Deduction +## std::invoke_result_t: deducing the return type at compile time -Uniform calling alone isn't enough—sometimes you also need to know at compile time what the return type of `std::invoke` will be. For example, in the implementation of `then()`, we need to deduce "what type is returned when passing the previous callback's return value to the next callback." +Unified calling alone is not enough. Sometimes you have to ask, at compile time, "what type does `std::invoke(f, args...)` return?" The chained implementation of `then()` is the textbook case. You feed the previous callback's return value into the next callback, and the compiler has to compute the final type of the chain up front, or you can't even write the type signature. -`std::invoke_result_t` is designed for this. Given a callable object type `Callable` and argument types `Args`, it calculates the return type of `Callable(Args...)` at compile time. +That's what `std::invoke_result_t` computes. Hand it a callable type `F` and a pack of argument types `Args...`, and at compile time it gives you back the return type of `std::invoke(f, args...)`: ```cpp -template -using return_type_t = typename std::invoke_result_t; -``` +#include +#include -### Usage in OnceCallback +auto add(int a, int b) -> int { return a + b; } -The implementation of `then()` uses `std::invoke_result_t` to deduce the return type of the new callback in the chain. Specifically, when `then()` accepts a subsequent callback `NextCallback`, it needs to know what type `NextCallback` will return: +// Deduce the return type of add(1, 2) at compile time +using R = std::invoke_result_t; +static_assert(std::is_same_v); -```cpp -// Deduce the return type of the subsequent callback -using NextRet = std::invoke_result_t; +// Works for lambdas too +auto lam = [](double x) { return std::to_string(x); }; +using R2 = std::invoke_result_t; +static_assert(std::is_same_v); ``` -In the `void` branch, the subsequent callback accepts no arguments: +## How the two of them show up in the OnceCallback source -```cpp -using NextRet = std::invoke_result_t; -``` +Reading it straight out of the OnceCallback source is clearer than any abstract explanation. `std::invoke` appears twice in there, once for `bind_once` and once for `then()`. ---- +Here's the `bind_once` piece: -## Specific Usage in OnceCallback Source Code +```cpp +// Inside bind_once's lambda +return std::invoke( + std::move(f), + std::move(bound)..., + std::forward(call_args)... +); +``` -Let's look at the actual source code to see the two usage scenarios of `std::invoke` in OnceCallback. +That `f` accepts anything: a lambda, a member function pointer, even a pointer to a data member. If you skipped `std::invoke` and wrote `f(bound..., call_args...)` directly, the moment `f` is a member function pointer it would not compile. Member function pointers cannot be `()`-ed directly, period. -### std::invoke in bind_once +The `then()` piece follows the same logic: ```cpp -template -OnceCallback bind_once(F&& f, Args&&... args) { - return [f = std::forward(f), ...args = std::forward(args)]() mutable { - std::invoke(f, args...); // <--- Uniform call here - }; -} +// The non-void branch of then() +auto mid = std::move(self).run(std::forward(args)...); +return std::invoke(std::move(cont), std::move(mid)); ``` -Here, `f` can be any callable object—an ordinary lambda, a member function pointer, or even a pointer to a data member. If we didn't use `std::invoke` and wrote `f(args...)` directly, compilation would fail when `f` is a member function pointer—because member function pointers cannot be called directly with `operator()`. +Here `cont` (the continuation callback) is by design a plain callable, usually a lambda, so in theory `cont(mid)` would mostly work. So why wrap it in `std::invoke`? As a defensive habit. The day someone slips in a member function pointer as the continuation, the direct-call syntax dies on the spot, while `std::invoke` won't. Routing everything through it saves you the trouble of carving out special cases for odd types. -### std::invoke in then() +As for how `then()` uses `std::invoke_result_t` to deduce the return type, the need is concrete: in a chain, the next callback `next` takes the previous callback's return value and produces something in turn. The code reads like this: ```cpp -template -auto then(NextCallback&& next) && { - // ... (implementation details) - next(std::move(prev_ret)); // <--- Uniform call here -} +// In the non-void branch of then() +using NextRet = std::invoke_result_t; +// NextRet is "what type you get when you hand a ReturnType value to next" ``` -`next` (the subsequent callback) is designed as an ordinary callable object (usually a lambda) in `then()`, not a `OnceCallback`. So theoretically, a direct `next(...)` would work—and in most cases, it does. However, using `std::invoke` is a form of defensive programming: if someone passes a member function pointer as the subsequent callback, the direct call syntax fails, but `std::invoke` won't. Uniformly using `std::invoke` ensures that whatever callable object is passed will work correctly without extra code to handle special types. +In the `void` branch, the continuation takes no argument, so the call shape simplifies: ---- +```cpp +// In the void branch of then() +using NextRet = std::invoke_result_t; +// next takes no arguments, just call it +``` -## Trap Warning: The Lifetime Trap of Member Function Binding +## Trap warning: the lifetime pitfall of binding a member function -While `std::invoke` can uniformly handle member function pointers, it doesn't manage object lifetimes for you. When you bind a member function in `bind_once`: +`std::invoke` unifies the calling syntax, but it does not manage one thing: when the object dies. This is an easy trap to fall into, because the convenience of unified calling makes you forget there's a raw pointer hiding underneath. + +When you bind a member function in `bind_once`, it looks like this: ```cpp -class Manager { - public: - void OnData(int) { /* ... */ } +struct Calculator { + int multiply(int a, int b) { return a * b; } }; -Manager* mgr = new Manager; -// Capture raw pointer -auto cb = base::bind_once(&Manager::OnData, mgr, 42); +Calculator calc; +auto bound = bind_once(&Calculator::multiply, &calc, 5); ``` -`mgr` is a raw pointer, and `bind_once` stores it in the lambda's capture list. If `mgr` is destroyed before the callback is invoked, the lambda holds a dangling pointer. `std::invoke` accessing freed memory through a dangling pointer results in undefined behavior, likely causing a segmentation fault. - -Chromium uses `base::Unretained` to explicitly mark "I know this raw pointer's lifetime is safe," `base::Owned` to take ownership of the object, and `base::WeakPtr` to automatically cancel the callback when the object is destroyed. Our simplified version doesn't provide these protection mechanisms yet—safety is the caller's responsibility. This is an important design trade-off that we will mention again in the practical section. - ---- - -## Summary +That `&calc` is a raw pointer, and `bind_once` stores it verbatim in the lambda's capture. If `calc` destructs before the callback actually runs, the lambda is clutching a dangling pointer. `std::invoke` still reaches through it to grope at memory, undefined behavior, segfault nine times out of ten. `std::invoke` can't help you here. It has no idea whether the pointer you handed it is even valid. -In this post, we clarified the context of `std::invoke`. The core motivation is that the calling syntax for various callable objects differs—ordinary functions are called directly via `()`, member function pointers require `.*`, and data member pointers require `.*` as well. `std::invoke` unifies all of these into a single syntax: `std::invoke(Callable, Args...)`. Combined with `std::invoke_result_t`, we can deduce the return type of a call at compile time. In OnceCallback, both `bind_once` and `then()` rely on it to achieve a generic design that "doesn't care about the specific type of the callable object, as long as it can be called." +Chromium handles this carefully in `//base`: `base::Unretained` lets you explicitly declare "I vouch for this raw pointer's lifetime myself," `base::Owned` hands object ownership to the callback framework, and `base::WeakPtr` automatically voids the callback when the object destructs. Our OnceCallback here is a simplified teaching version, so we don't ship that layer of protection yet. The safety burden stays on the caller. We'll come back to this trade-off in the hands-on chapters, and then it'll be clear why Chromium felt it had to invent something like `WeakPtr`. -In the next post, we will look at advanced Lambda features—specifically the lambda init capture pack expansion introduced in C++20, which is the key to the concise implementation of `bind_once`. +Next, we move on to advanced lambda features, especially the C++20 init-capture pack expansion, which is the trick that lets `bind_once` be written this cleanly. -## Reference Resources +## References - [cppreference: std::invoke](https://en.cppreference.com/w/cpp/utility/functional/invoke) - [cppreference: std::invoke_result](https://en.cppreference.com/w/cpp/types/result_of) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-03-once-callback-lambda-advanced.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-03-once-callback-lambda-advanced.md index 4a4756320..036f329c7 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-03-once-callback-lambda-advanced.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-03-once-callback-lambda-advanced.md @@ -5,18 +5,18 @@ cpp_standard: - 17 - 20 - 23 -description: Deep dive into mutable lambdas, init-capture, C++20 lambda capture pack - expansion, and generic lambdas—core implementation techniques for `bind_once` and - `then()` in `OnceCallback`. +description: "A deep look at mutable lambdas, init capture, C++20 lambda capture pack + expansion, and generic lambdas: the core techniques behind bind_once and then() in + OnceCallback." difficulty: intermediate order: 3 platform: host prerequisites: -- OnceCallback 前置知识速查:C++11/14/17 核心特性回顾 +- OnceCallback prerequisites cheat sheet: a recap of core C++11/14/17 features reading_time_minutes: 8 related: -- OnceCallback 实战(三):bind_once 实现 -- OnceCallback 实战(五):then 链式组合 +- OnceCallback hands-on (III): implementing bind_once +- OnceCallback hands-on (V): chaining with then tags: - host - cpp-modern @@ -24,203 +24,202 @@ tags: - lambda - 函数对象 title: 'OnceCallback Prerequisites (Part 3): Advanced Lambda Features' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-03-once-callback-lambda-advanced.md - source_hash: 63daf4349fbff1e096abb6e16d48e74826048e5f529ce0cc36926c7044e85e7c - translated_at: '2026-06-16T04:42:37.832175+00:00' - engine: anthropic - token_count: 1880 --- -# Prerequisites for OnceCallback (Part 3): Advanced Lambda Features +# OnceCallback Prerequisites (Part 3): Advanced Lambda Features -## Introduction +The last cheat sheet ran through lambda's basic syntax in a hurry. This one digs in. A handful of lambda features do the real heavy lifting in `OnceCallback`'s implementation: `mutable`, init capture, and C++20 capture pack expansion. These are not garnish syntax. If you don't get a handle on them, reading `bind_once` and `then()` later will have you cursing the author for being opaque. Truth is, there's no way around them; without them OnceCallback simply can't be built. -In the previous cheat sheet, we quickly reviewed the basic syntax of lambdas. In this post, we will dive into the three advanced lambda features actually used in the `OnceCallback` implementation. These are not just "nice-to-have" syntactic sugar; they are the **key mechanisms** that make `then` and `bind_once` possible. Without understanding these features, the implementation code ahead will be quite painful to read. +Let's take them one at a time, starting with `mutable`, and why it can't be dropped from a single line in this implementation. -Specifically, we will cover three things: why `mutable` lambdas are indispensable in `OnceCallback`, how init capture allows `then` to move the entire `OnceCallback` object into a lambda, and how C++20 lambda capture pack expansion reduces the code volume of `bind_once` to one-third of its original size. +## mutable lambda: why OnceCallback can't do without it -> **Learning Objectives** -> -> - Understand the behavioral differences between `mutable` and `const` lambdas and their necessity in `OnceCallback` -> - Master the syntax and semantics of init capture and understand `std::move` ownership transfer -> - Learn C++20 lambda capture pack expansion and understand the concise implementation of `bind_once` -> - Understand the essence of generic lambda `auto&&` +The `operator()` a lambda generates is `const` by default. In other words, value-captured variables are look-don't-touch inside the body. Add `mutable` and `operator()` becomes non-const, and the captured copies are yours to modify. ---- - -## mutable Lambda: Why It's Indispensable in OnceCallback +Here's the contrast: -The `operator()` generated by a lambda is `const` by default—meaning you cannot modify value-captured variables inside the lambda. Adding the `mutable` keyword makes `operator()` non-const, allowing modifications. +```cpp +int x = 10; -### Behavior Comparison +// const lambda: cannot modify captured variables +auto f1 = [x]() { + // x++; // compile error: operator() is const + return x; +}; -```cpp -// 1. Default const lambda -void example_const() { - int x = 0; - auto lambda = [x]() mutable { - x++; // Error: x is read-only - }; - lambda(); -} +// mutable lambda: can modify captured variables +auto f2 = [x]() mutable { + x++; // OK: operator() is non-const + return x; +}; -// 2. mutable lambda -void example_mutable() { - int x = 0; - auto lambda = [x]() mutable { - x++; // OK - }; - lambda(); // x is now 1 - lambda(); // x is now 2 -} +f2(); // returns 11; x's copy has been modified +f2(); // returns 12; same lambda object called again, x keeps climbing ``` -Note the second example—the state of a `mutable` lambda persists across multiple invocations. This is because the lambda's closure object holds a copy of the captured variables, and `mutable` allows `operator()` to modify these copies. +There's a detail that's easy to miss: a `mutable` lambda's state **persists across calls**. `f2` returns 11 the first time, 12 the second. The closure object holds copies of the captured variables, and `mutable` hands `operator()` the right to mutate them. Once changed, the change stays put, and the next call picks up where the last one left off. OnceCallback happens to need exactly this. -### Role in OnceCallback +### Its role inside OnceCallback -Both `then` and `bind_once` must declare their lambdas as `mutable`. The reason is that their capture lists contain a `OnceCallback` object (captured via `std::move`), and calling `operator()` modifies the internal state of `OnceCallback` (changing `state_` from `kValid` to `kConsumed`). If the lambda were `const`, `*this` would be a const reference inside the lambda, and you couldn't call state-modifying operations on a const object—the compiler would error out. - -Simply put: **Once a lambda captures an object that needs to be modified upon invocation (like `OnceCallback`), you must add `mutable`**. This isn't optional—the code won't compile without it. +Every lambda inside `bind_once` and `then()` is marked `mutable`. No exceptions. The reason comes down to one line: their capture lists stash a `OnceCallback` object (via `self = std::move(*this)`, which we'll get to in a moment), and once you call `std::move(self).run()` you have to mutate its internal state, flipping `status_` from kValid to kConsumed. If that lambda were const, `self` inside the body would be a const reference, and you'd be trying to run a state-mutating operation on a const object. The compiler is the first thing that won't stand for that. ```cpp -// If we remove mutable: -auto then = [this, next = std::move(next)]() const { // const operator() - // ... - (*this)(); // Error: cannot call non-const operator() on const *this -}; +// the lambda inside then(): mutable is non-negotiable +[self = std::move(*this), cont = std::forward(next)] +(FuncArgs... args) mutable -> NextRet { + // self has to be mutated here (run() consumes it) + auto mid = std::move(self).run(std::forward(args)...); + return std::invoke(std::move(cont), std::move(mid)); +} ``` --- -## Init Capture: Moving Objects into Lambdas - -C++14 introduced init capture syntax, which allows you to execute an expression in the capture list and initialize a capture variable with the result. The syntax is `[var = expr]`. +## Init capture: moving objects into the lambda -### Difference from Simple Capture +C++14 handed us a new toy: init capture. The syntax is `name = expression`. You run an expression right there in the capture list and use the result to initialize a fresh capture variable. It sounds minor, but it patches the single biggest pain point C++11 lambdas had. -Simple capture `[var]` can only capture existing variables, using copy or reference semantics. Init capture `[var = expr]` allows you to do three things simple capture cannot: +### How it differs from simple capture -1. Capture the result of an expression (e.g., `std::move(x)`, `x + y`). -2. Capture by move (transfer ownership). -3. Declare a new variable with a specific type visible only within the lambda. +A simple capture `[x]` can only grab a variable that already exists, and you choose copy or reference, full stop. An init capture `[name = expr]` adds a layer; it can do three things simple capture flat-out cannot: -### Usage in OnceCallback +```cpp +auto ptr = std::make_unique(42); -The implementation of `then` does two critical things using init capture. +// 1. Move capture: move the unique_ptr into the lambda +auto f1 = [p = std::move(ptr)]() { return *p; }; +// ptr out here has been emptied -First, it moves the entire `OnceCallback` object into the lambda: +// 2. Store a computed result +std::string s = "hello"; +auto f2 = [len = s.size()]() { return len; }; // len has type size_t -```cpp -auto then = [self = *this, next = std::move(next)]() mutable { - // ... -}; +// 3. Capture a variable that doesn't exist outside +auto f3 = [counter = 0]() mutable { return ++counter; }; // counter is the lambda's own variable ``` -`*this` is the current `OnceCallback` object. `*this` converts it to an rvalue (actually a copy in this specific context, but usually implies moving), and the init capture `self = ...` triggers `OnceCallback`'s move constructor, moving `callback_`, `state_`, and `allocator_` all into the lambda's closure object. After the move, `*this` (the original `OnceCallback` object) enters a "moved-from" state—`callback_` and `state_` are now empty or null. +The first one is the killer. C++11 lambdas have no move capture. To get a `unique_ptr` into a lambda you had to take a long detour: stuff it into a `std::function` or hand-roll a function object. Before P0780, Chromium's `base::Bind` carried this gap on its back with manually written functors. Once init capture arrived, those hacks basically belonged in a museum. + +### How OnceCallback uses it + +In `then()`'s implementation, init capture pulls double duty. -*Correction:* In the specific context `[self = *this]`, `*this` is an lvalue. To actually move, we usually need `[self = std::move(*this)]`. However, the text says `*this` converts it to an rvalue. Let's stick to the text's logic or correct it if it's technically wrong. The text says `*this` converts it to an rvalue. This is technically incorrect (it's an lvalue), but `std::move` is usually used. I will translate faithfully but maybe add a note or just stick to the text if it's a tutorial simplification. The text says: "`*this` 把它转成右值". I will translate as "`*this` converts it to an rvalue". +The first job is moving the entire OnceCallback object into the lambda: -Wait, looking at the code block ````cpp +```cpp self = std::move(*this) -````, it might be `[self = std::move(*this)]`. I will assume the text implies the move operation. +``` + +`*this` is the current OnceCallback object. `std::move(*this)` casts it to an rvalue, and the init capture `self = std::move(*this)` fires OnceCallback's move constructor in place, dragging `func_`, `status_`, and `token_` wholesale into the lambda's closure object. After the move, the outer `*this` is a hollowed-out shell: `func_` is empty, `token_` is null, effectively "dead." This is the core action behind OnceCallback's move-only semantics; ownership slides sideways into the lambda. -Second, it moves the subsequent callback in: +The second job is moving the continuation callback in: ```cpp -next = std::move(next) +cont = std::forward(next) ``` -`std::move` preserves the value category of `next`—if an rvalue is passed in, it's a move; if an lvalue is passed in, it's a copy. Usually `then` receives a temporary lambda (an rvalue), so this is a move. +`std::forward(next)` preserves `next`'s value category as-is: if an rvalue came in, it moves; if an lvalue, it copies. In real use of `then()`, what gets passed is usually a temporary lambda (an rvalue), so this generally takes the move path. -### Ownership Chain +### The ownership chain -Looking at these two captures together, the new lambda created by `then` holds **full ownership** of the original callback and the subsequent callback. This lambda is then stored in the `callback_` member of a new `OnceCallback`. The entire ownership chain looks like this: +Looking at both steps together, the new lambda `then()` produces holds the complete ownership of both the original callback and the continuation. That lambda then gets tucked into a fresh `OnceCallback`'s `std::move_only_function`. The whole ownership chain nests layer by layer: ```mermaid graph LR - A[Original OnceCallback] -- move --> B[New Lambda Closure] - C[Next Callback] -- move --> B - B -- move --> D[New OnceCallback.callback_] + A["New OnceCallback"] --> B["move_only_function"] --> C["lambda closure"] --> D["Original OnceCallback + continuation"] ``` -Every layer transfers ownership via move semantics, with no sharing or copying. This is the complete embodiment of `OnceCallback`'s move-only semantics in `then`—ownership is transferred layer by layer from outside to inside, without gaps. +Every layer hands ownership down through move semantics: no sharing, no copying. OnceCallback's move-only discipline, inside `then()`, threads from the outside all the way in with no gaps. --- -## C++20 Lambda Capture Pack Expansion: The Secret to bind_once's Conciseness +## C++20 lambda capture pack expansion: the secret to bind_once's brevity -This is the most important feature in this post and the key to implementing `bind_once` in just a few lines of code. Before C++20, variadic template parameter packs **could not** be expanded directly into a lambda's capture list—you had to use a `tuple` to store the packed arguments first, then use `std::apply` inside the lambda to expand the call. +This is the one feature in this post that lets `bind_once` come together in a few lines of code. Before C++20, a variadic template's parameter pack could not be expanded directly into a lambda's capture list. You had to bundle the arguments into a `std::tuple` first, then unpack the call inside the lambda with `std::apply`. Roundabout, but there was no other way. -### Old Approach (C++17): tuple + apply +### The old way (C++17): tuple + apply ```cpp -template -auto bind_once_old(F&& f, Args&&... args) { +template +auto bind_old(F&& f, BoundArgs&&... args) { + // pack every bound argument into a tuple return [f = std::forward(f), - t = std::make_tuple(std::forward(args)...)]() mutable { - return std::apply(t, f); + tup = std::make_tuple(std::forward(args)...)] + (auto&&... call_args) mutable -> decltype(auto) { + // unpack the tuple with std::apply and call + return std::apply([&](auto&... bound) -> decltype(auto) { + return f(bound..., std::forward(call_args)...); + }, tup); }; } ``` -It works, but the code bloats significantly—you need an intermediate tuple, a `std::apply` call, and a nested lambda to handle the expansion. +It works, but the code is bloated to say the least: a tuple in the middle, `std::apply` on the outside, and another nested lambda inside to handle the expansion. The whole three-piece kit. -### New Syntax (C++20): Expand Pack Directly in Capture List +### The new syntax (C++20): expand the pack right in the capture list -C++20 allows pack expansion in lambda init-capture. The syntax is `...args`, which generates a corresponding capture variable for each type in the parameter pack. +C++20 finally relented and allows pack expansion inside a lambda's init capture. The syntax is `...name = expression`, and the effect is to generate a separate capture variable for each type in the parameter pack. ```cpp -template -auto bind_once(F&& f, Args&&... args) { +template +auto bind_new(F&& f, BoundArgs&&... args) { return [f = std::forward(f), - ...args = std::forward(args)]() mutable { - return std::move(f)(std::move(args)...); + ...bound = std::forward(args)] // ← pack expansion! + (auto&&... call_args) mutable -> decltype(auto) { + return std::invoke(std::move(f), + std::move(bound)..., // ← expand the capture variables + std::forward(call_args)...); }; } ``` -### Manually Expanding a Concrete Example +### Manually expanding a concrete example -Assume we call `bind_once(func, a, b)`, where `Args` is ``. The compiler expands the pack `...args` into: +Let's take a specific call and watch what the compiler actually does behind the scenes. Suppose we call `bind_new([](int a, std::string b, int c) { ... }, 10, std::string("hello"))`, so `BoundArgs = {int, std::string}`. The compiler expands the pack `...bound = std::forward(args)` into: ```cpp -[ - f = std::forward(func), - args0 = std::forward(a), - args1 = std::forward(b) -]() mutable { /* ... */ } +[f = std::forward(f), + b1 = std::forward(arg1), // int, forwarded directly + b2 = std::forward(arg2)] // std::string, move-forwarded +(auto&&... call_args) mutable -> decltype(auto) { + return std::invoke(std::move(f), + std::move(b1), std::move(b2), // expand the capture variables + std::forward(call_args)...); +} ``` -Each bound argument becomes an independent member variable in the lambda closure. When the lambda is invoked, they are expanded together via `std::move(args)...` and passed to `f`. +Each bound argument turns into an independent member variable in the lambda closure. When the lambda gets invoked, they all expand together through `std::move(bound)...` and feed into `std::invoke`. + +### Why std::move and not std::forward -### Why std::move Instead of std::forward +There's a trap here that nearly tripped me up the first time I read it. Inside the lambda body it's `std::move(bound)...`, not `std::forward(bound)...`. Why? -You might notice that the lambda uses `std::move(args)` instead of `std::forward(args)`. The reason is that the lambda is `mutable`, and the captured variable `args` is an **lvalue** inside the lambda (named variables are always lvalues). Since we want the bound arguments to be passed out as rvalues when the callback is invoked (triggering move semantics), we use `std::move` to turn them into rvalues. If we used `std::forward`, since `args` is already an lvalue, `std::forward` would only return an lvalue reference—move semantics would be lost. +The key is that the lambda is `mutable`, so the captured variable `bound` is an **lvalue** inside the body: a named variable is always an lvalue, no exceptions. We want the bound arguments to go out as rvalues when the callback fires (to trigger a move), so we need `std::move` to cast them to rvalues. If your hand slips and you write `std::forward(bound)`, since `bound` is already an lvalue, `std::forward` won't touch its value category at all: it still returns an lvalue reference, and move semantics evaporate on the spot. OnceCallback is move-only, so dropping the move here equals dropping ownership, and everything downstream goes sideways. --- -## Generic Lambda: auto&& as a Forwarding Reference +## Generic lambda: auto&& as a forwarding reference -The lambda inside `bind_once` uses `auto&&` to accept arguments passed in at runtime. Here `auto&&` is a forwarding reference—because `auto` in a lambda parameter is equivalent to a template parameter, `auto&&` has the same deduction rules as `T&&` (when T is a template parameter). +One last thing about the signature of the lambda inside `bind_once`: `(auto&&... call_args)`. This form is there to receive the arguments passed in at runtime. Here `auto&&` is a forwarding reference: `auto` in a lambda parameter is equivalent to a template parameter, so `auto&&` gets the exact same deduction rules as `T&&` (when T is a template parameter). ```cpp -[](auto&&... args) { - return (*this)(std::forward(args)...); -} +auto f = [](auto&& x) { + // x is a forwarding reference + // lvalue passed in: auto = int&, x's type is int& (lvalue reference) + // rvalue passed in: auto = int, x's type is int&& (rvalue reference) +}; + +int v = 10; +f(v); // x binds to an lvalue +f(10); // x binds to an rvalue ``` -The combination of `auto&&` and `...` (variadic pack) means this lambda can accept any number of arguments of any type while preserving the value category of each argument. Combined with `std::forward`, these arguments can be perfectly forwarded to the final callable object. +The `auto&&...` combination opens the lambda up to accept any number of arguments of any type, all while remembering each one's value category (lvalue or rvalue). Paired with `std::forward(call_args)...`, those arguments get perfectly forwarded to the final callable object, with no information lost along the way. --- -## Summary - -In this post, we mastered the three most critical lambda features in the `OnceCallback` implementation. The `mutable` lambda allows modifying captured objects inside the lambda; `OnceCallback`'s `then` and `bind_once` must use it to call `operator()` and modify the callback state within the lambda. Init capture `[var = expr]` allows `then` to move the entire `OnceCallback` object into the lambda closure via move semantics, establishing a complete ownership chain. C++20's lambda capture pack expansion `...args` allows `bind_once`'s bound arguments to be expanded directly into the capture list, replacing the bloated tuple + `apply` approach of the C++17 era. - -Next, we will look at Concepts and `std::enable_if` constraints—they are the key defensive measures preventing `OnceCallback`'s template constructor from being incorrectly matched. +Next we'll look at Concepts and `requires` constraints: the key defense that keeps OnceCallback's template constructor from matching the wrong things. -## Reference Resources +## References - [cppreference: Lambda expressions](https://en.cppreference.com/w/cpp/language/lambda) - [P0780R2 - Pack Expansion in Lambda Init-Capture](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0780r2.html) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-04-once-callback-concepts-and-requires.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-04-once-callback-concepts-and-requires.md index 6a28c02be..b366003ca 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-04-once-callback-concepts-and-requires.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-04-once-callback-concepts-and-requires.md @@ -2,232 +2,207 @@ chapter: 0 cpp_standard: - 20 -description: We explore the real-world issue where template constructors hijack move - constructors, and understand how Concepts and `requires` constraints ensure `OnceCallback` - constructors match correctly. +description: "Start from the real case of a template constructor hijacking the move constructor, then see how Concepts and requires constraints keep OnceCallback's constructors matching correctly." difficulty: intermediate order: 4 platform: host prerequisites: -- OnceCallback 前置知识速查:C++11/14/17 核心特性回顾 -- OnceCallback 前置知识(一):函数类型与模板偏特化 +- OnceCallback prerequisites cheat sheet: a recap of C++11/14/17 core features +- OnceCallback prerequisites (I): function types and template partial specialization reading_time_minutes: 9 related: -- OnceCallback 实战(二):核心骨架搭建 -- OnceCallback 前置知识(五):std::move_only_function +- OnceCallback hands-on (II): building the core skeleton +- OnceCallback prerequisites (V): std::move_only_function tags: - host - cpp-modern - intermediate - concepts - 模板 -title: 'Prerequisites for OnceCallback (Part 4): Concepts and requires Constraints' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-04-once-callback-concepts-and-requires.md - source_hash: 2d66382298f6e53f170ed119632aa7d08d3cce1abc9f95b1c2396e616e4d68f7 - translated_at: '2026-06-16T04:13:42.814016+00:00' - engine: anthropic - token_count: 1749 +title: 'OnceCallback prerequisites (IV): Concepts and requires constraints' --- -# OnceCallback Prerequisites (Part 4): Concepts and requires Constraints +# OnceCallback prerequisites (IV): Concepts and requires constraints -## Introduction - -The `OnceCallback` constructor has a constraint that looks somewhat redundant: +The OnceCallback constructor carries a constraint that looks almost redundant: ```cpp -template ::value>> -OnceCallback(F&& f); +template + requires not_the_same_t +explicit OnceCallback(Functor&& function); ``` -You might ask—why not just write `OnceCallback(F&& f)` and be done with it? What exactly is the extra constraint guarding against? - -In this post, we will answer this question. The answer involves a lesser-known pitfall in C++ overload resolution: **template constructors can hijack move constructor calls in certain situations**. Concepts and `requires` constraints are the defensive weapons C++20 provides us. +When we first read this line, the reaction was: isn't that overkill? Just leave it as `template` and move on. Who exactly does the `requires not_the_same_t` guard against? -> **Learning Objectives** -> -> - Understand the overload competition between template constructors and move constructors. -> - Master the basic syntax of concepts and the usage of the `requires` clause. -> - Be able to interpret the design intent of `not_the_same_t` and the meaning of every line of code. - ---- +After actually stepping on the trap, we learned it guards against a fairly nasty case in C++ overload resolution: **a template constructor can hijack the move constructor**. Concepts and the `requires` clause are the defensive weapons C++20 hands us. This piece digs the trap open from the top and walks through the concepts syntax along the way. -## Problem Introduction: The "Offside" of Template Constructors +## The problem: a template constructor going "offside" -### Scenario Reconstruction +Let's reconstruct the trap first. -Assume we have a simple wrapper class that accepts any callable object: +Say we write a simple wrapper that accepts any callable: ```cpp -class Wrapper { -public: - // Accepts any callable - template - Wrapper(F&& f) : func(std::forward(f)) {} - - // Move constructor (implicitly generated) - Wrapper(Wrapper&&) noexcept = default; +template +class Callback; - // Copy constructor (deleted) - Wrapper(const Wrapper&) = delete; -private: - std::function func; +template +class Callback { +public: + // Template constructor: accepts any callable + template + explicit Callback(Functor&& f) { + // initialize internal storage with f... + } + + // Implicitly generated move constructor + // Callback(Callback&& other) noexcept; }; ``` -Now we write `Wrapper w2(std::move(w1))`—the intent is obvious: we want to call the move constructor. The compiler has two paths: - -1. The implicitly generated move constructor `Wrapper(Wrapper&&)`. -2. The template constructor instantiation `Wrapper(Wrapper&&)` (where `F = Wrapper`). +We casually write `Callback cb2 = std::move(cb1);`, meaning is obvious: go through move construction. But the compiler actually sees two paths: the implicitly generated move constructor `Callback(Callback&&)`, and the template constructor instantiated as `Callback(Callback&&)` (with `Functor = Callback`). -Intuitively, we feel the move constructor should take priority—after all, it is "specifically designed for this type." However, C++ overload resolution rules are not that simple. In some cases, the function signature instantiated from a template is a "more exact" match than the implicitly declared special member function—because the template parameter `F` can perfectly match the type of the passed argument (including references), while the move constructor's parameter type is fixed `Wrapper&&`. +Your intuition says the move constructor wins easily, since it's "designed for this exact type." But C++ overload resolution does not follow intuition. The forwarding reference `Functor&&` on the template constructor is greedy; it can perfectly match anything, including a `Callback&&` itself. The move constructor's parameter type, on the other hand, is a fixed `Callback&&`. When it comes to "which one matches more precisely," the template-instantiated version can actually look like the tighter fit. -When the match quality of two overloads is identical, C++ rules dictate that **non-template functions take precedence over template functions**. So, in most cases, the move constructor does win. But edge cases are subtle—especially when forwarding references and perfect matching are involved, some compiler versions might behave differently. More critically, even if the move constructor wins, if the template constructor is also in the candidate list, certain SFINAE scenarios might lead to unexpected compilation errors. +C++ does have a fallback rule: when a template and a non-template version match equally well, the **non-template wins**. So in most cases the move constructor does come out ahead. But this is not as clean as it sounds. Once forwarding references and perfect matches enter the picture, behavior starts drifting across compilers and versions. Worse, even when the move constructor wins, the template constructor is still sitting in the candidate list, and some SFINAE contexts spit out baffling compile errors. -### Minimal Reproduction +### A minimal reproduction ```cpp -#include - -class Test { -public: - template - Test(T&&) { /* Generic template */ } - - Test(Test&&) noexcept = default; // Move constructor - Test(const Test&) = delete; // Copy constructor +struct Wrapper { + // Template constructor: accepts any type + template + Wrapper(T&& x) { + std::cout << "template constructor\n"; + } + + // Move constructor (implicitly generated or explicitly declared) + Wrapper(Wrapper&& other) noexcept { + std::cout << "move constructor\n"; + } }; -int main() { - Test t1; - // Which constructor is called? - Test t2(std::move(t1)); -} +Wrapper a; +Wrapper b = std::move(a); // You expect "move constructor" + // In some cases you may get "template constructor" ``` -The solution is to add a constraint to the template constructor—make it **not** match the class's own type. +The fix is to hang a constraint on the template constructor so it stops trying to match the wrapper's own type. That is where the `requires` clause comes in. --- -## Concept Basic Syntax +## What Concepts actually are -C++20 introduced Concepts—a mechanism for naming constraints. You can think of a concept as a "named compile-time boolean condition." If that sounds hard to grasp—personally, I think "concept" lives up to its name: it literally means a concept. Compared to the obscure way we used to express things with `enable_if`, we can now say what it is more easily—it is XXX, and XXX is a concept. It's just that simple. +C++20 introduced Concepts. The official definition is a mouthful, "a mechanism for naming constraints." We think that phrasing just tangles people up. The word concept does what it says on the tin: it's a concept. -### Declaring a concept +Step back for a second. Before concepts existed, saying "I only accept integer types" meant threading through the `enable_if` machinery: `typename std::enable_if::value, int>::type = 0`, a long, obscure string the reader has to mentally decode before they understand what you mean. A concept lets you **just say what the thing is**: it's called `Integral`, it's the concept of "integer." That's it. If `T` satisfies `Integral`, `T` is an integer; if not, it doesn't get in. + +Declaring a concept looks like this: ```cpp -template +template concept Integral = std::is_integral_v; ``` -`Integral` is a concept that checks if `T` is an integer type. `std::is_integral_v` is a compile-time boolean constant. The meaning here is simple—we just want an integer type! With this concept, we can use it in the next step with `requires`. - -### Using the requires clause +`Integral` checks whether `T` is an integer type, and `std::is_integral_v` is a compile-time boolean constant. That's the whole point we're making: we just want an integer. With that concept in hand, the next step is to feed it to `requires`. -The `requires` clause can be added after a template declaration to constrain template parameters to satisfy a specific condition: +A `requires` clause hangs off the back of a template declaration and puts a gate in front of the template parameter: ```cpp -template -requires Integral -void foo(T value); -``` +template + requires Integral +void foo(T x) { + // only instantiated when T is an integer type +} -### Standard library common concepts +foo(42); // OK: int is an integer +foo(3.14); // compile error: double does not satisfy Integral +``` -C++20 provides a batch of predefined concepts in the `` header file: +The `` header also ships a batch of ready-made standard concepts. A few commonly used ones: ```cpp -std::integral // T is an integral type -std::floating_point // T is a floating point type -std::same_as // T and U are the same type -std::convertible_to // T is convertible to U +#include + +// std::invocable: can F be called with Args...? +static_assert(std::invocable); + +// std::same_as: are A and B the same type? +static_assert(std::same_as); + +// std::convertible_to: can From implicitly convert to To? +static_assert(std::convertible_to); ``` --- -## not_the_same_t: Line-by-Line Breakdown +## Pulling `not_the_same_t` apart -Now let's look at this concept in `OnceCallback`: +Now let's look back at the concept on OnceCallback: ```cpp -template +template concept not_the_same_t = !std::is_same_v, T>; ``` -What it does, in one sentence, is: **The decayed type of F is not T**. Let's break down the three key components one by one. - -### std::decay_t\: Decay references and cv-qualifiers - -`std::decay_t` does three things to a type: removes references (`int&` → `int`), removes top-level const/volatile (`const int` → `int`), and decays array and function types (`int[3]` → `int*`, `void(int)` → `void(*)(int)`). - -In the `OnceCallback` scenario, the most critical part is removing references. When we write `OnceCallback(F&& f)`, `F` is deduced as `OnceCallback` (not `OnceCallback&&`, because forwarding reference deduction rules deduce rvalues as non-reference types). But if it were `OnceCallback(OnceCallback&)` (even though copy is deleted, this is just an example), `F` would be deduced as `OnceCallback&`. `std::decay_t` ensures that no matter what reference form `F` deduces to, after decay it is `OnceCallback`, which is compared with `T`. +In one sentence: once `F` decays, as long as it is not `T`, the constraint passes. There are three parts inside; let's take them one at a time. -### std::is_same_v<...>: Compare two types +First, `std::decay_t`. It does three things to a type: strips references (`int&` becomes `int`), strips top-level const/volatile (`const int` becomes `int`), and decays array and function types (`int[5]` becomes `int*`, `int(int)` becomes `int(*)(int)`). In the OnceCallback scenario the critical one is stripping references. When we write `OnceCallback cb2 = std::move(cb1)`, `Functor` is deduced as `OnceCallback` (not `OnceCallback&&`; forwarding-reference deduction rules deduce rvalues as non-reference). But if someone wrote `OnceCallback cb2 = cb1` (copy is deleted, this is just for illustration), `Functor` would be deduced as `OnceCallback&`. The job of `std::decay_t` is to take whatever reference shape `Functor` deduces to and reduce it to a bare `OnceCallback`, then compare against `T = OnceCallback`. -`std::is_same_v` returns `true` when `A` and `B` are identical. Note that "identical" is very strict—`int` and `const int` are different, `int&` and `int` are also different. That's why we need `std::decay_t` to unify the form first. +Next, `std::is_same_v`. It returns `true` only when `A` and `B` are exactly the same. Note "exactly the same" is strict: `int` and `const int` do not count, neither do `int&` and `int`. That's why we needed `std::decay_t` first, to unify the form on both sides; otherwise one side carries a reference and the other does not, and the comparison is pure noise. -### Negation `!`: Constraint passes when F is not T +The negation `!` at the end is the kicker. The whole concept's value is `!std::is_same_v, T>`: if `F`'s decayed type equals `T`, negation flips it to `false`, the constraint fails, and the template is kicked out of the candidate list; if it's not equal to `T`, negation gives `true`, the constraint passes, and the template participates in overload resolution normally. That's the entire logic. -The value of the entire concept is `!std::is_same_v<...>`—negation means that when `F`'s decayed type is the same as `T`, the constraint fails (the template is excluded), and when they are different, the constraint passes (the template participates in overload resolution). - -### Effect after adding the constraint +Hang the constraint back onto the constructor and watch it work: ```cpp -template -requires not_the_same_t -OnceCallback(F&& f); +template + requires not_the_same_t +explicit OnceCallback(Functor&& f) : status_(Status::kValid), func_(std::move(f)) {} ``` -When what is passed in is `OnceCallback` itself (like in a move constructor scenario), `not_the_same_t` evaluates to `false`, the constraint is not satisfied, and the template is removed from the candidate list. The compiler can only choose the move constructor. When a lambda, function pointer, or other type is passed, the constraint is satisfied, the template participates in overload resolution normally, and is selected as the constructor. +When what gets passed in is `OnceCallback` itself (the move-construction case), `not_the_same_t` evaluates to `!true = false`, the constraint is not satisfied, the template is sidelined, and the compiler can only pick the move constructor. When what gets passed is a lambda, a function pointer, or any other type, the constraint is satisfied, the template takes the call normally, and it is selected as the constructor. Clean. --- -## Application of this Pattern in the Standard Library +## This is not an OnceCallback exclusive -This is not just a special requirement for `OnceCallback`. The standard library's own `std::function` implementation has almost identical constraints—except the standard library uses the standard concept `!std::same_as` combined with `std::type_identity`. Any move-only type-erasing wrapper needs this defense—as long as your class has both a "template constructor accepting any type" and a "compiler-generated move constructor", you must add a constraint to prevent competition between the two. +This is not a need unique to OnceCallback. `std::move_only_function`'s own implementation hangs an almost identical constraint on itself; the standard library just spells it with the standard concept `std::constructible_from` paired with `!std::is_same_v`. Plainly put, any move-only type-erasing wrapper has to eat this defense. As long as your class has both "a template constructor that accepts any type" and "a compiler-generated move constructor", the two will fight, and you have to keep them apart with a constraint. -```cpp -// Standard library style (simplified) -template -requires (!std::same_as, MyType>) -MyType(F&& f); +```text +Pattern summary: +template constructor + requires excluding the class's own type = protects correct matching of move semantics ``` -If you write similar components in the future—like your own `unique_function`, `move_only_function` or other move-only wrappers—remember this pattern; it is a general defensive measure. +One note to file away: when you eventually roll your own `unique_function`, `any_invocable`, or other move-only wrappers, remember this pattern. It's a reusable defensive measure, and it saves you from debugging for an afternoon only to find move semantics got intercepted by the template. --- -## Pitfall Warning +## Pitfall warnings -### If you forget std::decay_t - -If you only write `!std::is_same_v` without adding `std::decay_t`, the problem is that the deduction result of `F` might carry a reference or might not, depending on the calling context. Consider the following scenario: +**Pitfall 1: forgetting `std::decay_t`.** Take the lazy route and write only `!std::is_same_v` without `std::decay_t`, and the trap is set. `F`'s deduced type may or may not carry a reference, depending entirely on how you call it. Look at these two scenarios: ```cpp -// Scenario A: Move -OnceCallback cb1; -OnceCallback cb2(std::move(cb1)); // F deduced as OnceCallback +OnceCallback cb1([](int x) { return x; }); -// Scenario B: Lvalue reference (hypothetically) -// OnceCallback cb3(cb1); // F deduced as OnceCallback& -``` +// Scenario A: std::move(cb1) is an rvalue +// Functor deduced as OnceCallback (no reference) +// is_same_v == true → constraint fails ✓ correct -In Scenario B, without `std::decay_t`, `F` (`OnceCallback&`) and `T` (`OnceCallback`) are not the same, the constraint passes, and the template constructor is selected—but semantically we expect a compilation error (copy is deleted) or at least not the template constructor. With `std::decay_t`, `F` decays to `OnceCallback`, which is the same as `T`, and the constraint correctly fails. +// Scenario B: const OnceCallback& ref = cb1; +// If someone then writes OnceCallback cb2(ref); +// Functor deduced as const OnceCallback& +// is_same_v == false → constraint passes ✗ wrong! +``` -### The trap of static_assert(false) +In scenario B, without `decay_t`, `const OnceCallback&` and `OnceCallback` are simply not the same type, so the constraint passes and the template constructor gets picked. Semantically, what we want is a compile error (copy is deleted), or at least not the template constructor. Add `decay_t`, and `const OnceCallback&` decays into `OnceCallback`, the two sides line up, and the constraint correctly fails. We stepped on this one, debugging for a while before realizing `decay_t` was missing. -Before C++23, `static_assert(false)` in a template causes all instantiations to trigger assertion failure—even if this template is never called. This is because the C++ standard prior to C++23 required `static_assert` to be evaluated immediately when the template is defined. Chromium uses `static_assert(sizeof(T) != 0)` to bypass this limit (`sizeof(T)` is never 0, but it depends on the type of `T`, so it is a type-dependent expression and won't be evaluated at definition time). C++23 relaxed this rule, but if you compile with C++20, you still need to be aware of this issue. +**Pitfall 2: `static_assert(false)` "misfires" inside templates.** Before C++23, writing `static_assert(false, "...")` inside a template trips the assertion on every instantiation, even if the template is never called. That's because the older standard required `static_assert(false)` to be evaluated the moment the template definition is seen. Chromium's workaround is `static_assert(!sizeof(*this), "...")`: `!sizeof` is always false, but it depends on the type of `*this`, so it's a dependent expression that does not get evaluated at definition time and only fires on instantiation. C++23 relaxed this rule, but if you're still compiling with C++20, keep this in mind. --- -## Summary - -In this post, we cleared up the seemingly redundant `requires not_the_same_t` constraint on the `OnceCallback` constructor. Its existence is to prevent the template constructor from hijacking move constructor calls in scenarios like `OnceCallback cb2(std::move(cb1))`. `not_the_same_t` uses `std::decay_t` to strip references and const qualifiers from `F` before comparing with `T`, and the negation ensures the template is excluded when passing its own type. This pattern is used in all move-only type-erasing wrappers—`std::function` has similar constraints. - -In the next post, we will look at `std::move_only_function`—it is the core storage type of `OnceCallback` and the key to us using standard library facilities to replace Chromium's hand-written `BindState`. +Next we'll look at `std::move_only_function`, the core storage type of OnceCallback and the key piece for replacing Chromium's hand-written BindState with standard library facilities. -## Reference Resources +## References - [cppreference: Constraints and concepts](https://en.cppreference.com/w/cpp/language/constraints) - [cppreference: std::decay](https://en.cppreference.com/w/cpp/types/decay) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-05-once-callback-move-only-function.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-05-once-callback-move-only-function.md index 486676a71..57a5efa39 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-05-once-callback-move-only-function.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-05-once-callback-move-only-function.md @@ -2,228 +2,200 @@ chapter: 0 cpp_standard: - 23 -description: Gain a deep understanding of C++23's `std::move_only_function`—the core - storage type of `OnceCallback`. We cover the evolution from `std::function`, Small - Buffer Optimization (SBO) behavior, and why `OnceCallback` requires independent - three-state management. +description: "A close look at C++23's std::move_only_function, the storage type behind OnceCallback's func_ member. Covers why std::function is not enough, how SBO behaves, and why OnceCallback still needs its own three-state Status enum instead of leaning on move_only_function's null check." difficulty: intermediate order: 5 platform: host prerequisites: -- OnceCallback 前置知识速查:C++11/14/17 核心特性回顾 -- OnceCallback 前置知识(一):函数类型与模板偏特化 +- OnceCallback prerequisites cheat sheet: a recap of C++11/14/17 core features +- OnceCallback prerequisites (I): function types and template partial specialization reading_time_minutes: 9 related: -- OnceCallback 实战(二):核心骨架搭建 -- OnceCallback 实战(六):测试与性能对比 +- OnceCallback hands-on (II): scaffolding the core skeleton +- OnceCallback hands-on (VI): tests and performance comparison tags: - host - cpp-modern - intermediate - 函数对象 - 智能指针 -title: 'Prerequisites for OnceCallback (Part 5): std::move_only_function (C++23)' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-05-once-callback-move-only-function.md - source_hash: e0cd8cad6cee646a86f5196837ef97f53ff080452df2faf67cc0d1d398df01e9 - translated_at: '2026-06-16T04:13:45.026070+00:00' - engine: anthropic - token_count: 1739 +title: 'OnceCallback Prerequisites (V): std::move_only_function (C++23)' --- -# OnceCallback Prerequisites (Part 5): std::move_only_function (C++23) +# OnceCallback Prerequisites (V): std::move_only_function (C++23) -## Introduction - -`std::move_only_function` is the heart of `OnceCallback`—it handles all the heavy lifting of type erasure. The `OnceCallback::callback_` member is a `std::move_only_function` type, which wraps various forms of callable objects—lambdas, function pointers, functors—into a unified calling interface with a known signature. - -In this post, we will clarify three things: what exactly is the difference between `std::move_only_function` and `std::function`, how its SBO (Small Buffer Optimization) behavior works, and why `OnceCallback` cannot rely directly on its null-check mechanism and needs to implement its own three-state management. - -> **Learning Objectives** -> -> - Understand the design motivation of `std::move_only_function`—why `std::function` isn't enough -> - Master the four core operations: construction, move, invocation, and null checking -> - Understand the principles of SBO and the allocation behavior of `std::move_only_function` -> - Clarify why `OnceCallback` needs an independent `Status` enumeration - ---- +The `func_` member of `OnceCallback` is typed `std::move_only_function`. Its job is the dirty work of type erasure: it takes the motley crew of lambdas, function pointers, and functors and herds them into a single call entry with a fixed signature. In this post we pull it apart and look at what it actually differs from the old `std::function`, how its SBO (Small Buffer Optimization) holds up, and one pitfall we walked into ourselves: why `OnceCallback` has to keep its own `Status` enum and cannot just piggyback on the null check. ## From std::function to std::move_only_function -### Limitations of std::function +### Where std::function gets stuck -`std::function` is a generic callable object container introduced in C++11. It unifies various callable objects into the same interface through type erasure. However, `std::function` has a fundamental limitation: it requires that the stored callable object **must be copyable**. +`std::function` is the general-purpose callable container C++11 handed us. It uses type erasure to fold a pile of callable objects into one interface. But it carries one brutal constraint: whatever it stores must be copyable. -The reason is that `std::function` itself is copyable—when you copy a `std::function`, it needs to copy the internally stored callable object as well. If you attempt to construct a `std::function` using a lambda that captures a `std::unique_ptr`, the compiler will error out directly on copy semantics: +The root cause is that it copies itself. When you copy a `std::function`, it has to copy the object held inside it too. So if you try to stuff in a lambda that captured a `std::unique_ptr`, a move-only type that flat-out refuses to copy, the compiler slaps the error in your face: ```cpp -// std::function requires the stored object to be copyable -std::function f = [up = std::make_unique(42)] { - *up = 100; -}; +#include +#include + +auto ptr = std::make_unique(42); -// Error: std::unique_ptr is not copyable -std::function f2 = f; +// Compile error: unique_ptr is not copyable, std::function requires copyable +std::function f = [p = std::move(ptr)]() { return *p; }; ``` -This limitation is fatal in the context of `OnceCallback`—the core selling point of `OnceCallback` is being move-only, and it must support lambdas that capture move-only types like `std::unique_ptr`. +For `OnceCallback` this is a wall. Move-only is the whole pitch, and that means it has to accept callbacks capturing a `unique_ptr`. -### The Solution: std::move_only_function +### How std::move_only_function breaks the deadlock -`std::move_only_function` (C++23, defined in ``) is the "move-only version of `std::function`". It deletes copy operations and retains only move operations, thus no longer requiring the stored callable object to be copyable. +C++23's `std::move_only_function` (still in ``) exists to crack exactly this nut. It lops off the copy operations and keeps only the move, so the thing stored no longer has to be copyable. ```cpp -// std::move_only_function only requires the object to be movable -std::move_only_function f = [up = std::make_unique(42)] { - *up = 100; -}; +#include +#include + +auto ptr = std::make_unique(42); -// OK: Transfer ownership -std::move_only_function f2 = std::move(f); +// OK: move_only_function does not require copyable +std::move_only_function f = [p = std::move(ptr)]() { return *p; }; + +int result = f(); // result == 42 ``` -The key difference in interface between the two types can be summarized as: `std::function` is copyable and movable, requiring the stored object to be copyable; `std::move_only_function` is not copyable but is movable, only requiring the stored object to be movable. +The interface difference is one sentence: `std::function` copies and moves, and what it stores must be copyable; `std::move_only_function` only moves, and what it stores only needs to be movable. --- -## Four Core Operations - -### Construction: Creating from Callable Objects +## Construction, move, call, null check -`std::move_only_function` accepts any callable object matching the signature `R(Args...)`—lambdas, function pointers, functors, and even another `std::move_only_function`: +Construction works the same way as `std::function`: `std::move_only_function` opens its arms to anything matching the signature, a lambda, a function pointer, a functor, even another `std::move_only_function`. A default-constructed one is empty and compares equal to `nullptr`. Calling uses the familiar `f(args...)` syntax; calling an empty object throws `std::bad_function_call`, and when it should crash, let it crash. ```cpp // From a lambda -std::move_only_function f1 = [](int x) { std::cout << x; }; +std::move_only_function f1 = [](int a, int b) { return a + b; }; // From a function pointer -void func(int x); -std::move_only_function f2 = func; - -// From another move_only_function (move construction) -std::move_only_function f3 = std::move(f1); -``` +int add(int a, int b) { return a + b; } +std::move_only_function f2 = &add; -### Move: Transferring Ownership - -The move operation transfers the callable object from the source to the target. After the move, the state of the source object is **unspecified**—the standard does not guarantee that it will definitely be empty. - -```cpp -std::move_only_function f = [] { /* ... */ }; -std::move_only_function g = std::move(f); +// From a functor +struct Multiplier { + int operator()(int a, int b) { return a * b; } +}; +std::move_only_function f3 = Multiplier{}; -// f is now in an unspecified state -// DO NOT rely on f being empty or non-empty! +// Default construction: create an empty move_only_function +std::move_only_function f4; // f4 == nullptr ``` -This is very important—and one of the reasons why `OnceCallback` needs its own `Status` enumeration. We will expand on this later. - -### Invocation: Executing via operator() - -The invocation syntax is the same as `std::function`—using the `operator()` directly: +The part worth pausing on is the move. The semantics are straightforward: the callable inside the source relocates wholesale to the target. But what state is the source left in? The standard gives four words: valid but unspecified. It does not promise the source ends up empty. ```cpp -std::move_only_function add = [](int a, int b) { - return a + b; -}; - -int result = add(3, 4); // result is 7 +std::move_only_function f = []() { return 42; }; +auto g = std::move(f); +// f's state is unspecified — may be empty, may not be +// Do not rely on f's post-move behavior ``` -If the `std::move_only_function` is empty (via default construction or `std::move`), the invocation will throw a `std::bad_function_call` exception. +We ran it on GCC 16 and `bool(f)` after the move did return `false`. But hold onto this: that is the implementation being kind, not a promise the standard backstops for you. Switch to another implementation and a `true` tomorrow is not off the table. This tail matters. It is half the reason, in a moment, that `OnceCallback` cannot lean on the null check and has to keep its own `Status`. -### Null Check: Checking if it Holds a Callable Object - -Use `operator bool` or compare with `nullptr`: +For the null check, use `operator bool()` or compare against `nullptr`; the two are equivalent. To clear it on purpose, assign `nullptr`, and the callable it was holding destructs: ```cpp -std::move_only_function f; - +std::move_only_function f; if (!f) { std::cout << "f is empty\n"; } +// equivalent to +if (f == nullptr) { + std::cout << "f is empty\n"; +} -f = [] { std::cout << "Hello"; }; -if (f != nullptr) { - f(); // Invokes the lambda +f = []() { return 42; }; +if (f) { + std::cout << "f is not empty\n"; } ``` -You can also actively clear it by assigning `nullptr`: - ```cpp -f = nullptr; // Clears the stored callable +f = nullptr; // clear f, destructing the previously held callable ``` --- -## SBO: Small Object Optimization +## SBO: Small Buffer Optimization -### What is SBO +Internally, `std::move_only_function` does SBO (Small Buffer Optimization), the same trick `std::function` uses. The recipe is not complicated: the object keeps a fixed-size buffer, usually a few pointers wide. If the callable is small enough, it goes straight into the buffer and the heap allocation is skipped. If it is too big, the heap is the fallback. -`std::move_only_function` (just like `std::function`) internally implements **Small Buffer Optimization** (SBO). The idea is simple: a fixed-size buffer (usually a few pointers in size) is reserved inside the object. If the callable object is small enough, it is stored directly in this buffer, avoiding heap allocation; if it is too large, memory is allocated on the heap to store it. +![SBO internal structure](./pre-05-sbo-structure.drawio) -![SBO 小对象优化内部结构](./pre-05-sbo-structure.drawio) +The SBO threshold is the implementation's call; it usually lands somewhere between 2 and 3 pointers wide (16 to 24 bytes). A lambda that captures little, like `[x = 42]` or `[&ref]`, almost always slides into SBO without triggering a heap allocation. But a lambda that captures a chunk, say a `std::string` plus a few `int`s, blows past the threshold and has to go to the heap at construction. -The threshold for SBO is implementation-defined—typically around 2-3 pointer sizes (16-24 bytes). A lambda capturing a few arguments (e.g., a `std::unique_ptr` or a few integers) can usually fit into the SBO without triggering a heap allocation. However, if the lambda captures a large amount of data (like a `std::string` + several `std::vector`s), exceeding the SBO threshold, construction will allocate on the heap. +### sizeof in practice -### sizeof Comparison +Talking about it is cheap; let us measure the real thing. GCC 16 prints this: ```cpp -std::cout << sizeof(std::function) << "\n"; // e.g., 32 bytes -std::cout << sizeof(std::move_only_function) << "\n"; // e.g., 32 bytes +#include +#include + +int main() { + std::cout << "sizeof(std::function): " + << sizeof(std::function) << "\n"; + std::cout << "sizeof(std::move_only_function): " + << sizeof(std::move_only_function) << "\n"; +} +``` + +```text +sizeof(std::function): 32 +sizeof(std::move_only_function): 40 ``` -On GCC, typical values are `std::function` at about 32 bytes, and `std::move_only_function` also at about 32 bytes. They are similar in size because they use similar SBO strategies. +`std::function` is 32 bytes; `std::move_only_function` is 8 bytes more, at 40. The SBO strategy underneath is similar, but the move-only side has its own overhead (dropping the work the copy path would have done, leaving a move vtable, and so on), and that is roughly where the extra bytes go. --- -## Why OnceCallback Needs an Independent Status Enumeration +## Why OnceCallback still needs its own Status enum -You may have noticed a detail—`OnceCallback` adds its own `Status` enumeration to track state, separate from `std::move_only_function`. Why not just use `std::move_only_function`'s null-check mechanism? +Reading this far you might be wondering: since `std::move_only_function` can already check for null, why does `OnceCallback` go to the trouble of wrapping a `Status` enum around it? We tried taking the shortcut too, using its null check directly. It is not enough once you actually sit down to write it. -The reason is that `std::move_only_function`'s null check cannot distinguish between three different states: +The root problem is that it does not have enough states. `operator bool()` only separates "empty" from "non-empty", but `OnceCallback` has to tell three states apart: ```cpp -enum class Status { - kEmpty, // Never assigned a value - kValid, // Has a value, can be invoked - kConsumed // Was valid, but has been moved out or invoked +enum class Status : uint8_t { + kEmpty, // never assigned (default constructed) + kValid, // holds a valid callable + kConsumed // run() has already been called }; ``` -`std::move_only_function`'s `operator bool` can only distinguish between "empty" and "non-empty" states. However, `OnceCallback` needs to know whether a callback is "never been assigned" (`kEmpty`) or "had a value but has already been invoked" (`kConsumed`). These two scenarios have completely different meanings during debugging—`kEmpty` implies "you forgot to assign a callback", while `kConsumed` implies "the callback was correctly invoked, and you should not use it again". +"Never assigned" (`kEmpty`) and "assigned, run, and already digested" (`kConsumed`) look identical to `operator bool()`, both empty, but the meaning is poles apart. When debugging, `kEmpty` is usually a reminder that you forgot to assign the callback, a genuine bug; `kConsumed` is the expected state after the callback has run normally, nothing wrong at all. Smear the two together and `DCHECK` cannot say anything sensible about it. -There is a more subtle issue: the state of a moved-from `std::move_only_function` is **unspecified**—the standard does not guarantee that `operator bool` of the source object returns `false` after a move. Some implementations might still return `true`, even though the internal data is invalid. If `OnceCallback` relied on `std::move_only_function`'s null check to determine state, it might get incorrect results after move operations. The independent `Status` enumeration is entirely under our control—the move constructor explicitly sets the source object to `kEmpty`, leaving no ambiguity. +Then there is the sneakier one: the "unspecified post-move state" from the last section. The standard does not guarantee that `operator bool()` returns `false` after a move. Some implementation is free to return `true` even though the goods inside have already been hauled out. If `OnceCallback` really relied on it for state, the moment a move happened it could misjudge. Owning its own `Status` is steadier. It is fully in our hands; on move construction we explicitly mark the source object `kEmpty`, clean and unambiguous. --- -## Comparison with Chromium's BindState +## Next to Chromium's BindState -Chromium does not use the standard library's type erasure facilities—it hand-writes a `BindState` system. Let's compare the core differences between the two approaches. +Chromium does not touch the standard library's type erasure. It hand-rolls its own `BindState`. Put the two side by side and the differences are worth a look. -Chromium's `BindState` is a heap-allocated object that stores the callable object and all bound parameters. `OnceCallback` itself only holds a smart pointer (`std::unique_ptr`) to the `BindState`, making it only 8 bytes in size—the size of a pointer. All state is placed in the heap-allocated `BindState`, and the callback object itself is just a "thin proxy". +Chromium's `BindState` is a heap object that takes in the callable and every bound argument. `OnceCallback` itself just holds a smart pointer (`scoped_refptr`) to the `BindState`, 8 bytes total, one pointer wide. All the state lives over on the `BindState` side; the callback is just a thin proxy. -Our approach replaces the entire `BindState` layer with `std::move_only_function`—it implements type erasure and SBO internally, saving us the work of hand-writing function pointer tables, SBO buffers, and move/destructor operations. The cost is that the object size expands from 8 bytes to about 32 bytes (the size of `std::move_only_function` itself), plus the `Status` enumeration and an optional `std::unique_ptr` pointer, making the whole `OnceCallback` about 56-64 bytes. +Our version replaces the entire `BindState` layer with `std::move_only_function`. Type erasure and SBO are handled for it internally, and the hand-written work of function pointer tables, SBO buffers, and move/destructor bookkeeping all gets dropped. The price is size: it grows from 8 bytes to 40 bytes (`std::move_only_function` on its own), then piles on the `Status` enum and an optional `CancelableToken` pointer, putting one `OnceCallback` at roughly 56 to 64 bytes. | Metric | Chromium BindState | Our std::move_only_function | -|--------|-------------------|-------------------------------| -| Callback Object Size | 8 bytes (one pointer) | 56-64 bytes | -| Heap Allocation | Always (new BindState) | Only when lambda exceeds SBO threshold | -| Move Cost | Copying one pointer | Copying 32+ bytes | -| Implementation Complexity | High (manual ref-count + function pointer table) | Low (reuse standard library) | - -For educational purposes and most practical scenarios, a 56-64 byte callback object is not a bottleneck at all. If your project indeed requires extreme compactness, you can refer to Chromium's approach—we will cover the core concepts in a future practical post. - ---- - -## Summary +|------|-------------------|-------------------------------| +| Callback object size | 8 bytes (one pointer) | 56-64 bytes | +| Heap allocation | Always (new BindState) | Only when lambda exceeds SBO threshold | +| Move cost | Copy one pointer | Copy 32+ bytes | +| Implementation complexity | High (manual refcount + function pointer table) | Low (reuse standard library) | -In this post, we clarified the ins and outs of `std::move_only_function`. It is the move-only version of `std::function` introduced in C++23, removing copy operations to support move-only callable objects. It implements SBO internally to optimize storage for small objects. However, its post-move state is unspecified, and it can only distinguish between "empty" and "non-empty" states—this is why `OnceCallback` needs an independent three-state `Status` enumeration. Compared to Chromium's hand-written `BindState`, we traded an increase in object size for a significant gain in implementation simplicity. +For teaching, and for most real scenarios, a fifty- to sixty-byte callback object is not a bottleneck at all. If you genuinely need to squeeze size to the limit, take the Chromium road; we get into the core idea in a later hands-on piece. -In the next post, we will look at the last prerequisite for `OnceCallback`—C++23's deducing this (explicit object parameter), which is the core mechanism enabling `OnceCallback::Invoke` to intercept compile-time lvalue/rvalue dispatch. +The next post is the last prerequisite for OnceCallback: C++23's deducing this (explicit object parameter). The reason `run()` can pull off compile-time lvalue/rvalue interception has its roots there. ## References - [cppreference: std::move_only_function](https://en.cppreference.com/w/cpp/utility/functional/move_only_function) -- [P0288R9 - move_only_function Proposal](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p0288r9.html) +- [P0288R9 - move_only_function proposal](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p0288r9.html) - [cppreference: std::function](https://en.cppreference.com/w/cpp/utility/functional/function) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-06-once-callback-deducing-this.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-06-once-callback-deducing-this.md index ec6084bc8..97bac28fc 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-06-once-callback-deducing-this.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-06-once-callback-deducing-this.md @@ -2,232 +2,204 @@ chapter: 0 cpp_standard: - 23 -description: Deep dive into how C++23 explicit object parameters (deducing this) allow - `OnceCallback::run()` to elegantly intercept lvalue calls at compile time, replacing - Chromium's double-overload hack. +description: "How C++23 explicit object parameters (deducing this) let OnceCallback::run() intercept lvalue calls at compile time, replacing Chromium's double-overload hack" difficulty: intermediate order: 6 platform: host prerequisites: -- OnceCallback 前置知识速查:C++11/14/17 核心特性回顾 +- OnceCallback prerequisites recap: a tour of the core C++11/14/17 features reading_time_minutes: 8 related: -- OnceCallback 实战(二):核心骨架搭建 -- OnceCallback 前置知识(四):Concepts 与 requires 约束 +- 'OnceCallback hands-on (II): the core skeleton' +- 'OnceCallback prerequisites (IV): concepts and requires constraints' tags: - host - cpp-modern - intermediate - 模板 -title: 'OnceCallback Prerequisites (Part 6): Deducing this (C++23)' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-06-once-callback-deducing-this.md - source_hash: 953b2e8cc557c7021354212b0dda82e5d95ec9dcb024ad800bb9f1173d768016 - translated_at: '2026-06-16T04:14:03.100115+00:00' - engine: anthropic - token_count: 1658 +title: 'OnceCallback Prerequisites (VI): Deducing this (C++23)' --- -# Prerequisites for OnceCallback (Part 6): Deducing this (C++23) +# OnceCallback Prerequisites (VI): Deducing this (C++23) -## Introduction +## First, that one line of declaration -The `OnceCallback::run` method is the soul of the entire component and is the most feature-dense method in terms of C++23 features. Its declaration looks like this: +`OnceCallback::run()` is the most counter-intuitive method in the whole component, and the densest spot for C++23 features. Its declaration looks like this: ```cpp -template -auto operator()(Args&&... args) -> decltype(auto) requires /* ... */; +template +auto run(this Self&& self, FuncArgs&&... args) -> ReturnType; ``` -If you haven't seen the syntax like `this auto&& self`—don't panic, this post is specifically about it. This is the "explicit object parameter" feature introduced in C++23, officially known as **deducing this**. It allows `OnceCallback` to use a single function template to achieve the effect of "compile-time error for lvalue calls, normal execution for rvalue calls," which is much cleaner than Chromium's approach. +That `this Self&& self` made us pause for a couple seconds the first time we saw it. A member function that writes `this` explicitly as a parameter? This is C++23's *explicit object parameter*, officially known as deducing this. It's just one line, but it lets OnceCallback pull off "lvalue call fails to compile, rvalue call runs fine" with a single function template, far cleaner than what Chromium does. This piece takes the thing apart: the syntax, the deduction rules, and how OnceCallback leans on it for compile-time interception. -> **Learning Objectives** -> -> - Understand the syntax and deduction rules of deducing this -> - Master how `OnceCallback::run` uses it to implement compile-time lvalue/rvalue interception -> - Understand the role of lazy instantiation in `static_assert` -> - Compare the applicable scenarios of deducing this and traditional ref-qualifiers +## Stating the problem clearly: why `cb.run()` must not compile ---- - -## Problem: How to make `cb.run()` fail to compile - -The core semantic of `OnceCallback` is "can only be called once, and must be called via an rvalue." Expressed in code: +OnceCallback's core semantic is one sentence: call it once, and only on an rvalue. Translated to code: ```cpp -OnceCallback cb = ...; -// cb.run(); // Compile error: lvalue call -std::move(cb).run(); // OK: rvalue call +OnceCallback cb([](int x) { return x * 2; }); + +cb.run(5); // must fail to compile: cb is an lvalue +std::move(cb).run(5); // must compile: std::move(cb) is an rvalue ``` -We need a mechanism that allows `run()` to distinguish between "called via an lvalue" and "called via an rvalue" at compile time, and provides a clear error message for lvalue calls. +What we want is a compile-time dispatch mechanism: an lvalue call blows up immediately with an error message that actually reads like English; an rvalue call goes through. -### Chromium's Old Approach +### Chromium had no C++23, so it wrote two overloads -Chromium didn't have the benefit of C++23, so it used a rather hacky approach—two overloads: +Back then Chromium didn't have C++23, so it had to hack it: two overloads for the same thing. ```cpp -// Rvalue overload (actual implementation) -ReturnType operator()(Args&&... args) &&; +// Rvalue version: the actual execution +R Run() && { + // execute the callback... +} -// Lvalue overload (deleted to prevent calls) -template -void operator()(Args&&... args) & = delete; +// Lvalue version: compile error +R Run() const& { + static_assert(!sizeof(*this), + "OnceCallback::Run() may only be invoked on a non-const rvalue, " + "i.e. std::move(callback).Run()."); +} ``` -Why use `= delete` instead of directly writing `static_assert(false)`? Because prior to C++23, `static_assert(false)` in a template would trigger the assertion on all code paths—even if the function was never called. C++23 relaxed this restriction. The `= delete` approach leverages the fact that `sizeof` must be evaluated on a complete type—it is a dependent expression that is only evaluated during template instantiation, thus achieving the effect of "triggering only when actually called." +One detail we got stuck on for a while: why not just `static_assert(false, "...")`? Because before C++23, writing a literal `false` inside a template fires unconditionally: even if this overload is never called once, the compiler blows up at the point of template definition. `!sizeof(*this)` is the workaround. It depends on the type of `*this`, so it's a dependent expression and only gets evaluated at template instantiation. In other words, it only blows up if someone actually wrote `cb.Run()`; if nobody did, it's as if it doesn't exist. -It works, but it's certainly not elegant—requiring two overloaded functions to handle the same thing, and the `sizeof` hack has poor readability. +It works, but it's not elegant. Two overloads doing one job, and the `!sizeof` hack takes a second to parse each time you read it. Once C++23 landed deducing this, there was a proper answer. --- -## Syntax and Deduction Rules of deducing this +## deducing this: writing `this` as a parameter -C++23's deducing this allows us to explicitly write the implicit `this` object parameter as the first parameter of a member function, and use a template parameter to deduce its type and value category. +What deducing this does is one sentence: it takes the `this` that's normally implicit inside a member function, drags it out, and writes it explicitly as the first parameter, with template deduction bolted on. -### Basic Syntax +### Syntax ```cpp -struct Widget { - void name(this auto&& self); +struct MyStruct { + void f(this auto&& self) { + // self is this — but its type is deduced + } }; ``` -`this auto&& self` is the declaration of the explicit object parameter. The keyword `this` appearing before the type tells the compiler "this is not a normal parameter, but an explicit object parameter." `auto&&` is the deduction placeholder—the compiler will deduce the specific type of `self` based on the value category of the object at the call site. +The `this` keyword placed before the type is a hint to the compiler: what follows isn't a normal parameter, it's an explicit object parameter. `auto&&` is the deduction placeholder; whoever calls it and how they call it decides what the deduced type looks like. -### Deduction Rules +### Deduction rules: identical to a forwarding reference -The type deduction rules for `self` are exactly the same as for forwarding references—because the deduction context of `auto&&` is equivalent to a template parameter: +The deduction rule for `self` is the exact same mold as the forwarding reference you hit when writing templates, because `self`'s deduction context is equivalent to a template parameter. This point matters; the lvalue interception later hinges on it. -- **Lvalue call** `w.name()`: - - The type of `self` is deduced as `Widget&` (lvalue reference) -- **Rvalue call** `std::move(w).name()` or `Widget{}.name()`: - - The type of `self` is deduced as `Widget` (non-reference, pure type) -- **const lvalue call** (assuming `Widget` is const): - - The type of `self` is deduced as `const Widget&` +Take an lvalue call like `obj.f()`: `self` deduces to `MyStruct&`, an lvalue reference. Switch to an rvalue call like `std::move(obj).f()` or `MyStruct{}.f()`: `self` deduces to `MyStruct`, the bare type, no reference. And a const lvalue call like `std::as_const(obj).f()`: `self` dutifully deduces to `const MyStruct&`. Hard to keep in your head? Just run it and see. -### Verifying Deduction Results +### Run it to verify ```cpp -struct Test { - void check(this auto&& self) { - std::cout << __PRETTY_FUNCTION__ << '\n'; +#include +#include + +struct Check { + void test(this auto&& self) { + using Self = decltype(self); + if constexpr (std::is_lvalue_reference_v) { + std::cout << "lvalue reference\n"; + } else { + std::cout << "rvalue (not a reference)\n"; + } } }; int main() { - Test t; - t.check(); // Deduced as: void check(Test &) - std::move(t).check(); // Deduced as: void check(Test) - const Test ct; - ct.check(); // Deduced as: void check(const Test &) + Check c; + c.test(); // prints: lvalue reference + std::move(c).test(); // prints: rvalue (not a reference) + std::as_const(c).test(); // prints: lvalue reference (const) } ``` --- -## Application in `OnceCallback::run` +## Onto `run()`: how deducing this actually works -Now let's look at the full implementation of `OnceCallback::run` to understand how it uses deducing this to intercept lvalue calls. +With the syntax in hand, let's look at `run()`'s full implementation and see how it pins lvalue calls down at compile time. ```cpp -template -class OnceCallback { -public: - // ... - template - decltype(auto) operator()(this Self&& self, Args&&... args) { - // 1. Intercept lvalue calls - static_assert( - !std::is_lvalue_reference_v, - "OnceCallback::run() must be called on an rvalue. " - "Use std::move(cb).run(...) instead." - ); - - // 2. Forward to impl_run - return std::forward(self).impl_run( - std::forward(args)... - ); - } -}; +template +auto run(this Self&& self, FuncArgs&&... args) -> ReturnType { + static_assert(!std::is_lvalue_reference_v, + "OnceCallback::run() must be called on an rvalue. " + "Use std::move(cb).run(...) instead."); + return std::forward(self).impl_run(std::forward(args)...); +} ``` -This code does three things; let's break them down one by one. - -### Intercepting Lvalue Calls +Three interlocking mechanisms are crammed into these few lines. We'll take them one at a time. -`std::is_lvalue_reference_v` checks whether `Self` is an lvalue reference type. When the caller writes `cb.run()`, `cb` is an lvalue, `Self` is deduced as `OnceCallback&`—this is an lvalue reference type, `is_lvalue_reference` returns `true`, negated it becomes `false`, `static_assert` fails, and the compiler reports the error message we wrote: "OnceCallback::run() must be called on an rvalue. Use std::move(cb).run(...) instead." +### Intercepting lvalue calls -When the caller writes `std::move(cb).run()`, `cb` is an rvalue (strictly speaking, an xvalue), `Self` is deduced as `OnceCallback`—not a reference type, `is_lvalue_reference` returns `false`, negated it becomes `true`, `static_assert` passes, and code execution continues. +`std::is_lvalue_reference_v` is asking: is `Self` an lvalue reference? The caller writes `cb.run(args)`; `cb` is an lvalue, so `Self` deduces to `OnceCallback&`, an lvalue reference. `is_lvalue_reference_v` returns `true`, the negation turns it to `false`, the `static_assert` fires on the spot, and the human-readable error message we wrote gets thrown in the caller's face: `OnceCallback::run() must be called on an rvalue. Use std::move(cb).run(...) instead.` -### Forwarding to `impl_run` +Flip it around. Write `std::move(cb).run(args)`: `std::move(cb)` is an rvalue (strictly, an xvalue), `Self` deduces to `OnceCallback`, not a reference. `is_lvalue_reference_v` returns `false`, negation flips it to `true`, the assert passes, and code marches on. One deduction, one negation, and lvalue versus rvalue part ways at compile time. -`std::forward(self)` determines whether to return an lvalue reference or an rvalue reference based on the type of `Self`. Since the `static_assert` has already excluded the lvalue case, `Self` reaching this point must be a non-reference type (rvalue), so `std::forward(self)` returns an rvalue reference—ensuring `impl_run` is called on an rvalue. +### Forwarding to impl_run -### Lazy Instantiation +Past the assert, `std::forward(self)` hands `self` off to the real execution function, `impl_run`, unchanged. Because the `static_assert` has already sealed the lvalue path, any `Self` that reaches this point has to be a non-reference rvalue, so `std::forward(self)` honestly returns an rvalue reference and guarantees `impl_run` gets an rvalue. It looks unremarkable, but it's the baton pass between "intercept" and "execute"; drop it and the semantics leak. -There is a nuanced detail here—the condition of `static_assert` depends on the template parameter `Self`, so it is only evaluated when the template is instantiated. This means: +### A word on lazy instantiation -- If `run()` is never called, `static_assert` will not trigger—regardless of whether the `OnceCallback` object itself is an lvalue or an rvalue. -- Only at a specific call point, when the compiler needs to instantiate this template, is the specific type of `Self` determined, and `static_assert` evaluated. +There's a detail we chewed on for a good while here. The `static_assert` condition carries the template parameter `Self`, so it's only evaluated at the moment of template instantiation. The other way around: if `run()` is never called at all, that `static_assert` just sits there doing nothing, regardless of whether the `OnceCallback` object itself is an lvalue or an rvalue. Only when somebody actually writes `cb.run(...)` on some line, forcing the compiler to instantiate this template, does `Self`'s concrete type get pinned down, and only then does the assert bother to look up and compute. -This is called "lazy instantiation," a fundamental characteristic of C++ templates. Function templates are only instantiated when used—no usage means no instantiation and no checks. This is why Chromium had to use `= delete` instead of directly writing `static_assert(false)`—prior to C++23, `static_assert(false)` did not depend on template parameters and would trigger at template definition time, rather than waiting for instantiation. +This is template lazy instantiation: a function template that isn't used isn't instantiated, and isn't checked. It also explains why Chromium had to reach for `!sizeof(*this)`: before C++23, `static_assert(false)` had no dependency on a template parameter, so it would blow up at the template definition point, well before instantiation ever happened. --- -## Comparison with Traditional Ref-qualifiers +## Versus the traditional ref-qualifier: when to pick which -In `OnceCallback`, there are two methods that express the "callable only via rvalue" semantic—`run` uses deducing this, while `then` uses the traditional ref-qualifier `&&`. Why not unify the approach? +Two methods in OnceCallback express the same idea of "rvalue-only call": `run()` uses deducing this, while `then()` uses the traditional ref-qualifier `&&`. We puzzled over this at first: if deducing this is so great, why not go all in and use it everywhere? It clicked once we laid the two scenarios side by side. The granularity they need isn't the same at all. -### `then()` uses Ref-qualifier +### then() is fine with just a ref-qualifier ```cpp -template -auto then(F&& f) && -> OnceCallback<...>; +template +auto then(Next&& next) && -> OnceCallback<...>; ``` -`then`'s requirement is simple—it only accepts rvalues, rejects lvalues, and doesn't need to distinguish between them to give different error messages. If the caller writes `cb.then(...)` (lvalue call), the compiler directly reports "no matching overloaded function." Although the error message is less instructive than deducing this, it is sufficient. The ref-qualifier is also more concise to write—just one `&&` and you're done. +`then()`'s needs are plain: take rvalues, slam the door on lvalues, and don't bother explaining why. If the caller writes `cb.then(next)` (an lvalue call), the compiler just shrugs "no matching overload" and moves on. The error message is rougher and less instructive than what deducing this gives you, but it's enough. The ref-qualifier is also less to write: tack `&&` on the end, one character, done. -### `run()` uses Deducing this +### run() really needs deducing this -`run`'s requirement is more refined—it not only needs to reject lvalue calls, but also needs to provide an **instructive error message**, telling the caller "you should use `std::move(cb).run(...)` instead." Deducing this makes this requirement natural—`static_assert` can output our custom error message instead of the compiler's default "no matching function." +`run()` is much pickier. Just refusing lvalues isn't enough; it also has to tell the caller "what you should write is `std::move(cb).run(...)`, not `cb.run(...)`", a human-readable error that lets them fix it on the spot. Deducing this paired with `static_assert` happens to do this very naturally: the error message is one we get to write ourselves, not the compiler's "no matching function" template. -### Selection Strategy +### How to choose -To summarize: If you only need the constraint of "accept rvalues only," the `&&` qualifier is more concise. If you also need to provide a custom error message for lvalue calls, deducing this combined with `static_assert` is more appropriate. +Our call: if all you want is the "rvalues only" constraint, `&&` is enough and cleaner. If you also owe the caller a custom, human-readable error for lvalue calls, reach for deducing this plus `static_assert`. Which tool you pick comes down to whether you need to explain. --- -## Pitfall Warning +## Pitfall warnings -### Explicit Object Parameters Cannot Coexist with cv-qualifiers or Ref-qualifiers +Here are the potholes we hit, so you can steer around them. -Member functions with an explicit object parameter cannot simultaneously be declared `const`, `volatile`, or with a ref-qualifier (`&`/`&&`). This is because the explicit object parameter has already taken over the deduction of the object type and value category—`const`/`volatile` and ref-qualifiers become redundant or even contradictory. +Explicit object parameters come with a hard rule: they cannot coexist with a cv-qualifier or a ref-qualifier. The reasoning is straightforward enough. The object type and value category are already handled by the explicit parameter, so stacking a `const` on top or tacking on `&&` confuses the compiler about who's in charge. The following won't compile: ```cpp struct Bad { - void foo(this auto&& self) const; // Error: conflicts with explicit object parameter - void bar(this auto&& self) &; // Error: conflicts with explicit object parameter + void f(this auto&& self) const; // error: explicit object parameter cannot also be const + void g(this auto&& self) &&; // error: explicit object parameter cannot also have && }; ``` -### Explicit Object Parameters Cannot Be Static Functions +One more thing people trip on: an explicit-object-parameter function looks like a static function, but it isn't. You still need an object instance to call it. The `this` parameter is deduced by the compiler from the call expression; it isn't something the caller hands in manually. Don't get that backwards. -Explicit object parameter functions are not static functions—they still require an object instance to invoke. The `self` parameter is deduced by the compiler from the call expression, not manually passed by the caller. - -### Compiler Support - -Deducing this is a C++23 feature. GCC 14+, Clang 18+, and MSVC 19.34+ support this feature. If your compiler does not support it, you must fall back to Chromium's double overload approach. +Finally, toolchain. deducing this is a C++23 feature, and only GCC 14+, Clang 18+, and MSVC 19.34+ recognize it. If you're still on an older compiler, you fall back to Chromium's double-overload approach. It's a hack, but at least it runs. --- -## Summary - -In this post, we clarified the ins and outs of deducing this. It allows `OnceCallback::run` to use a single function template to implement compile-time lvalue/rvalue interception—by judging whether the caller passed an lvalue or rvalue based on the deduced type of `Self`, combined with `static_assert` to provide an instructive error message. Compared to Chromium's two overloads + `sizeof` hack, the deducing this solution is more concise and aligns better with C++ design philosophy. Since `then()` does not need custom error messages, the traditional `&&` qualifier is more concise. +That covers deducing this as a tool. With one function template, `OnceCallback::run()` splits lvalue and rvalue at compile time, and the grim days of Chromium's two overloads plus the `!sizeof` hack are finally over. `then()` doesn't need any of that; a `&&` on the end keeps it simple. How you pick between the tools comes down to whether you owe the caller a human-readable error. -At this point, all prerequisites have been covered. In the next post, we will officially enter the practical implementation of OnceCallback—starting with motivation analysis and designing our target API. +That wraps up the prerequisites. Next piece, we start actually building OnceCallback's skeleton. ## References -- [P0847R7 - Deducing this Proposal](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p0847r7.html) +- [P0847R7 - Deducing this proposal](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p0847r7.html) - [C++23's Deducing this (Microsoft C++ Blog)](https://devblogs.microsoft.com/cppblog/cpp23-deducing-this/) - [cppreference: Explicit object parameter](https://en.cppreference.com/w/cpp/language/member_functions#Explicit_object_parameter) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/01-once-callback-design.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/01-once-callback-design.md index 514c459c3..5e0db6dd3 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/01-once-callback-design.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/01-once-callback-design.md @@ -2,292 +2,257 @@ chapter: 1 cpp_standard: - 23 -description: Starting from Chromium's OnceCallback, we design a C++23 move-only, single-shot - callback component — Part one focuses on motivation analysis and API design +description: "Starting from Chromium's OnceCallback, design a C++23 move-only, single-shot callback component; part one covers the motivation analysis and API design" difficulty: advanced order: 1 platform: host prerequisites: -- std::function、std::invoke 与可调用对象 -- 移动语义与完美转发 +- std::function, std::invoke, and callable objects +- Move semantics and perfect forwarding reading_time_minutes: 19 related: -- OnceCallback 与 RepeatingCallback -- bind_once / bind_repeating 与参数绑定 +- OnceCallback and RepeatingCallback +- bind_once / bind_repeating and argument binding tags: - host - cpp-modern - advanced - 回调机制 - 函数对象 -title: 'once_callback Design Guide (Part 1): Motivation and Interface Design' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/01-once-callback-design.md - source_hash: f805de2373d582e728b8d0bc5f6e8ef4cfca177210cda0095c5d3031685afe07 - translated_at: '2026-06-16T04:14:25.190373+00:00' - engine: anthropic - token_count: 3060 +title: "once_callback Design Guide (Part 1): Motivation and Interface Design" --- -# Design Guide for `once_callback` (Part 1): Motivation and Interface Design +# once_callback Design Guide (Part 1): Motivation and Interface Design -## Introduction +## Where the problem comes from: what `std::function` misses for async callbacks -Honestly, the most common pitfall I've encountered in asynchronous programming is callbacks being invoked multiple times. The scenario is classic: you register a callback for file I/O completion, expecting it to run once and be done. But due to a logic slip-up somewhere, it triggers an extra time. The resources released inside the callback are accessed a second time, leading straight to a segmentation fault. A major characteristic of this type of bug is that it is very hard to reproduce in tests, because normal asynchronous paths often only trigger the callback once. The real trigger is often some race condition or an error retry path. +The deepest hole I've fallen into over years of async programming is a callback fired twice. The scenario is textbook: register a callback for file I/O completion, expect it to run once and be done, then some retry path slips and fires it again. The resources just released inside the callback get touched a second time, and you eat a segfault. The nastiest part is that this kind of bug almost never reproduces in single-threaded unit tests. The happy path calls the callback once; the trigger hides in a race or an error path and only surfaces under real load in production. -`std::function` can't help us here. It allows multiple invocations, allows copy propagation, and callback objects can end up flying everywhere. In Volume 2, we already dissected the internal mechanisms of `std::function` (type erasure + SBO) and a simplified `small_function` implementation—that version solved the type erasure overhead problem but didn't touch the semantic issue of "how many times a callback should be invoked" at all. +`std::function` doesn't help us here. It's copyable, it's repeatable, and one callback object can be cloned to a dozen places with nobody watching. We took its internals apart in Volume 2 (type erasure plus SBO) and hand-wrote the `LightCallback` simplification, but that whole exercise went into squeezing type-erasure overhead. It never touched the real question: how many times should this callback be allowed to run? Leave the semantics undefined and the runtime will punish you. -When the Chromium team designed `OnceCallback`, they provided a very elegant answer: **Let the callback's type system itself constrain the invocation semantics.** `OnceCallback` is a move-only type; its `Run` method can only be invoked via an rvalue reference (`&&`). After one call, the callback object is consumed, and any subsequent call is a no-op or an assertion failure. This design has been fully validated in Chrome, where billions of tasks are posted daily. +Chromium's `base::OnceCallback` nails this down at the type level. `OnceCallback` is move-only, its `Run()` is callable only through an rvalue (`std::move(cb).Run()`), and one call consumes the object. A second call is a no-op or an assertion failure. This contract shoulders tens of billions of task postings every day inside Chrome, and it holds. -Our goal in this series is not to copy Chromium's implementation (which is very complex, involving hand-written reference counting, `__attribute__` annotations, and function pointer dispatch tables), but to leverage new C++23 features—specifically `std::move_only_function` and deducing this—to implement a `once_callback` component that retains the essence of Chromium's design while keeping the codebase manageable. +What this series does is extract the essence of Chromium's design and rebuild it on C++23 standard facilities (`std::move_only_function` and deducing this). The original carries real heavy lifting: hand-rolled reference counting, a `TRIVIAL_ABI` annotation, a function-pointer dispatch table. We take a different route, keep the codebase in check, and lose none of the semantics. -> **Learning Objectives** -> -> - Understand why "move-only + one-time consumption" is the correct semantic constraint for callbacks. -> - Design the complete public interface for `once_callback`. -> - Analyze the internal architecture of Chromium `OnceCallback` to understand the reasoning behind each design decision. +Let's pin down the three places `std::function` leaks in async scenarios before we touch anything. ---- - -## Our Problem: The Three Major Drawbacks of `std::function` in Asynchronous Scenarios - -Before we start designing, let's clarify the problem. As a generic callable object container, `std::function` is a design success—but in the specific context of asynchronous callbacks, it has three issues that raise my blood pressure. +First, it's copyable. A callback copied to several places means, in an async system, several execution paths each holding their own copy. If the callback captures something move-only like `std::unique_ptr`, the copy fails to compile, and that's the kind outcome: the compiler catches it for you. If it captures raw pointers or references, the copies all run and you've planted a race. Chrome's take is blunt: an async task callback shouldn't be copied at all, so kill copyability at the type level. -**First, it is copyable.** `std::function` natively supports copying, which means a callback can be copied to any number of places. In an asynchronous system, this equates to allowing multiple execution paths to hold copies of the same callback simultaneously. If the callback captures move-only resources (like `std::unique_ptr`), copying fails at compile time. If it captures raw pointers or references, multiple copies executing simultaneously creates a data race. The Chromium team's approach is straightforward: since asynchronous task callbacks fundamentally shouldn't be copied, make them non-copyable at the type level. +Second, it's repeatable. `std::function::operator()` has zero opinions about call count. Call one object a thousand times and it runs every time. But an async callback isn't that. A file-read completion callback fired twice is a logic error: resources freed twice, state transitioned twice, the message sent twice. The type system can't see this. You either lean on a runtime assertion or, more often, you discover it at the bug site. -**Second, it is repeatable.** `std::function` places no constraints on the number of invocations. You can invoke the same `std::function` a thousand times, and it will run every time. However, in asynchronous callback scenarios, invoking a file-read completion callback twice is a logic error—it might trigger double resource release, double state transitions, or double message sending. This error is completely undetectable in the type system; we can only rely on runtime assertions (if they exist) or—more commonly—discovering it at the crime scene (the bug report). +The third leak is the deepest. `std::function` can't express "calling is consuming." In Chrome's task posting, the moment `PostTask(FROM_HERE, callback)` runs, ownership of `callback` transfers to the task system and the caller is done with it. But `std::function::operator()` is `const`-qualified: one call doesn't change the object's state. You can't tell the world, through the calling interface itself, "this call eats the object." -**Third, it cannot express consumption semantics.** In Chrome's task posting model, once a `OnceCallback` is called, it should not be used again—its ownership has been transferred to the task system. `std::function`'s `operator()` is `const`-qualified; calling it does not change the state of the `std::function` object itself, so you cannot express the "call consumes" semantic through the calling interface. +Three leaks, one root: `std::function`'s interface can't express "this callback runs once and is then dead." `OnceCallback` exists to fill that gap. -These three issues boil down to one point: `std::function`'s interface design cannot express the constraint that "this callback can only be invoked once and becomes invalid afterward." Chrome's `OnceCallback` is designed specifically to fill this semantic gap. - ---- +## Chromium's answer: the design intent behind `OnceCallback` -## Chromium's Answer: `OnceCallback` Design Philosophy +Chrome's callback system rests on one principle: message passing beats locks, serialization beats threads. Under that principle, every callback posted to the task system (a `task` in Chrome parlance) is an independent, one-shot message. Once posted, ownership transfers to the task system; once executed, it's destroyed. No sharing, no reuse, no ambiguity. -Chrome's callback system is built on a core principle: **Message passing beats locking, serialization beats threading.** Under this principle, every callback posted to the task system (called a `task` in Chrome) is an independent, one-time message. Once posted, ownership of the callback transfers from the caller to the task system; once executed, the callback is destroyed. No sharing, no reuse, no ambiguity. +That philosophy lands directly on `OnceCallback`'s type design. The key tradeoffs: -This philosophy is directly reflected in the type design of `OnceCallback`: +| Tradeoff | OnceCallback's choice | What it solves | +|---|---|---| +| Copyability | Delete copy ctor/assign, move only | Type-level guarantee of a single owner at any moment | +| Value category of `Run()` | Rvalue-only (`std::move(cb).Run()`) | Syntax-level reminder: "you're consuming it, don't touch it after" | +| Call count | Internally consumes the `BindState`, later access is a no-op | Single-consumption semantics | -- **Move-only**: `OnceCallback` deletes the copy constructor and copy assignment, retaining only move operations. This guarantees at the type level that the callback has only one owner at any given moment. -- **Rvalue-qualified `Run`**: `Run` can only be invoked via an rvalue reference (`&&`). Lvalue invocation triggers a `static_assert`, producing a clear compile error. This reminds the caller at the syntax level: "You are consuming this callback; don't use it again." -- **Single consumption**: Internally, `OnceCallback` destroys the bound state via a reference counting mechanism (or similar logic) after the first call, making any subsequent access to the same object a safe no-op. +Worth noting: Chrome also ships `RepeatingCallback`, the copyable, repeatable sibling. Both callback classes share the same `BindState` machinery; the only differences are the value-category qualification on `Run()` and the ownership semantics of `BindState`. One binding backbone serves "one-shot task" and "repeating listener" at once, and I find that genuinely clean. -Chrome actually also has `RepeatingCallback`—a copyable, repeatable version. The two callback classes share the same internal `CallbackBase` implementation; the difference lies only in the value category qualification of `Run` and the ownership semantics. This design allows the same binding infrastructure to serve two distinct usage patterns: "one-shot tasks" and "repeating listeners." +### Chromium's internal layout -### Overview of Chromium's Internal Implementation +We're not going to read Chromium's source line by line, but we do need the core architecture in hand. The `OnceCallback` we're about to build walks the same layered spine, just with the implementation simplified through C++23 standard facilities. -We don't need to dive into every line of Chromium's source code, but we need to understand its core architecture because our `once_callback` will borrow a similar layered approach, using C++23 standard facilities to simplify the implementation. +Reading bottom up, Chromium's callback system stacks three layers. -Chromium's callback system consists of three layers, from bottom to top: +The bottom is `BindStateBase`, the type-erased base class with a reference count. One tradeoff stopped me cold the first time I read it: it carries a refcount, **yet uses no virtual functions**. Instead it holds three function-pointer members: `polymorphic_invoke_` for calling, `destructor_` for destruction, `query_cancellation_traits_` for cancellation queries. The Chrome team did this to crush binary bloat. A virtual function generates a separate vtable per template instantiation; if the project has a hundred `BindState` instantiations, you get a hundred vtables. The function-pointer route reuses the same static functions and only the pointer values differ, so the code segment doesn't bloat. -**Bottom Layer: `CallbackBase`**—The type-erased base class. It carries a reference count but, interestingly, **does not use virtual functions**. Instead, it uses three function pointer members: `Invoke` (handles invocation), `Destroy` (handles destruction), and `IsCancelled` (handles cancellation queries). The Chrome team chose function pointers over virtual functions to reduce binary bloat. Virtual functions generate a separate vtable for each template instantiation; if a project has 100 different `BindState` instantiations, there are 100 vtables. The function pointer approach allows reuse of the same static functions, differing only in pointer values, without generating additional code segments. +The middle layer is `BindState`, a templated concrete class inheriting from `BindStateBase`. Think of it as "the box that holds everything": your lambda, the bound arguments, and the function pointers the base needs. Its lifetime is managed by `scoped_refptr` (Chromium's own intrusive reference-counted smart pointer). `OnceCallback` releases its reference on `Run()`; `RepeatingCallback` holds the reference across every `Run()`. -**Middle Layer: `BindState`**—A templated concrete class inheriting from `CallbackBase`. It stores the actual callable object (`Functor`) and arguments bound via `std::forward` (Args...). You can think of it as a "box containing everything": the box holds your lambda, bound arguments, and the function pointers required by the base class. Instances of this class manage their lifecycles via `scoped_refptr` (Chromium's own intrusive reference-counted smart pointer)—`scoped_refptr` releases references in `Destroy`, and keeps a reference during each `Invoke`. +On top sit `OnceCallback` and `RepeatingCallback`, the types users actually touch. They're thin wrappers around `BindStateHolder`, which is itself a `scoped_refptr` annotated with `TRIVIAL_ABI`. `TRIVIAL_ABI` is a Clang extension attribute that tells the compiler "this type can ride in a register like an `int`," so `OnceCallback` is exactly one pointer wide (8 bytes), and moving it is just copying a pointer. Stupidly cheap. -**Top Layer: `OnceCallback` and `RepeatingCallback`**—The types users directly interact with. They are essentially thin wrappers around `scoped_refptr`, and `CallbackBase` is just a `__attribute__((packed))` annotated pointer. `__attribute__((packed))` is a Clang extension attribute telling the compiler "this type can be passed in a register like an `int`," making the actual size of `OnceCallback` just one pointer (8 bytes). Move operations are simply copying a pointer—extremely lightweight. - -The relationship between these three layers can be summarized in one sentence: **The top-level callback object is just a pointer to the middle-layer box, and the box holds the function pointers required by the bottom layer and the actual data.** In our next design, `once_callback` will retain this "outer interface + middle storage + type erasure" layering, but we will use `std::move_only_function` to replace Chromium's hand-written `CallbackBase` + `BindState` combo, and deducing this to replace the `&` overload + `delete` hack. +One sentence on the three layers: the top-level callback object is just a pointer to the middle-layer box, and the box holds the function pointers the bottom needs plus the real data. The `OnceCallback` we design next keeps the "outer interface plus middle storage plus type erasure" skeleton, but the bottom swaps Chromium's hand-written `BindState` + `scoped_refptr` for `std::move_only_function`, and swaps the `const&` overload with `static_assert` hack for deducing this. --- -## Environment Setup - -First, let's confirm our toolchain. `once_callback` relies on the following C++23 features: +## Environment and prerequisites -- **`std::move_only_function`** (``): A move-only type-erased callable wrapper introduced in C++23, this is our core building block. -- **Deducing this** (Explicit object parameter `this`): A C++23 feature allowing deduction of the value category of `this` in member functions. -- **`if constexpr`**: Compile-time conditional judgment (may be used in some implementations). +Before we start, confirm the toolchain. This `OnceCallback` series eats a few bites of C++23: `std::move_only_function` (in ``, the move-only type-erased callable wrapper introduced in C++23, our core building block), deducing this (the explicit object parameter `this auto&& self` that lets a member function deduce the value category of `this`), and occasionally `if consteval` for compile-time branching. -In terms of compiler requirements, GCC 12+ or Clang 16+ fully supports the above features. Just add `-std=c++23` at compile time. You can quickly verify your environment with the following code snippet: +On the compiler side, GCC 12+ or Clang 16+ fully support the above. Compile with `-std=c++23`. This snippet verifies the environment fast: ```cpp -// test_env.cpp -#include #include -int main() { - // Test 1: std::move_only_function - std::move_only_function f = [] {}; - std::move_only_function f2 = std::move(f); - - // Test 2: Deducing this - struct Test { - void operator()(this auto&& self) { - // If this compiles, deducing this is supported - } - }; - Test{}(); +// Verify std::move_only_function is available +static_assert(__cpp_lib_move_only_function >= 202110L); + +// Verify deducing this is available (compiles only if supported) +struct Check { + void test(this auto&& self) {} +}; +int main() { + Check c; + c.test(); return 0; } ``` -If this code compiles, your environment is good to go. However, honestly, as of the time of writing, some compilers' `std::move_only_function` implementations still have bugs (e.g., early versions of GCC 12 fail to compile in certain SFINAE scenarios), so I recommend using the latest stable versions of GCC 13+ or Clang 17+. - -### Prerequisites +If this compiles, the environment is set. Honest caveat: as I write this, some compilers' `std::move_only_function` implementations still have bugs (early GCC 12 fails in certain SFINAE scenarios). For safety, use the stable GCC 13+ or Clang 17+. -We assume the reader is already familiar with the following (covered in the corresponding Volume 2 articles): - -- **Move Semantics and Perfect Forwarding**: `once_callback` is move-only at its core; if you aren't familiar with the principles of `std::move` and `std::forward`, the implementation process will be very painful. Corresponding article: Vol 2 ch00 Move Semantics series. -- **`std::function`'s Type Erasure and SBO**: We build directly on top of `std::move_only_function`, so you need to understand the basic principles of type erasure and what Small Buffer Optimization is and why it matters. Corresponding article: Vol 2 ch03 `std::function` and Callable Objects. -- **`std::invoke` and Uniform Calling Convention**: `std::move_only_function` uses `std::invoke` internally to uniformly handle function pointers, member function pointers, functors, and other callable types. Corresponding article: Ibid. -- **Variadic Templates and Parameter Pack Expansion**: Template specialization of `once_callback` and argument binding in `Bind` both require familiarity with parameter pack syntax. Corresponding article: Vol 2 ch00 Perfect Forwarding, Vol 4 Template Basics. +On the prerequisites side, we assume you're already comfortable with the following (all covered in Volume 2). Move semantics and perfect forwarding: the core of `OnceCallback` is move-only, and the implementation will hurt if `std::move` and `std::forward` aren't second nature (Vol 2 ch00, the move semantics series). Type erasure and SBO in `std::function`: we build straight on top of `std::move_only_function`, so you need to know what type erasure is and why small buffer optimization matters (Vol 2 ch03). `std::invoke` and the uniform calling convention: `bind_once` uses it to handle function pointers, member function pointers, and functors uniformly (same Vol 2 ch03). Variadic templates and parameter-pack expansion: the template specialization of `OnceCallback` and the argument binding in `bind_once` both lean on pack syntax (Vol 2 ch00 on perfect forwarding, Vol 4 on template basics). --- -## Designing the Interface: What API Do We Want? +## Designing the interface: what API do we want -Let's settle on the target API first, then discuss each design decision. This is how engineers work—figure out "what I want" first, then "how to do it." +Let's pin the target API down first, then walk back through each decision. This is how engineers work: figure out "what I want" before "how to do it." -### Core Usage +### Core usage ```cpp -// Basic usage: create and run -auto cb = once_callback{ [](int x) { std::cout << x << '\n'; } }; -std::move(cb).Run(42); // OK: consumes the callback -// cb.Run(42); // Error: lvalue call is disabled +#include "once_callback/once_callback.hpp" + +// 1. Construct: from a lambda +using namespace tamcpp::chrome; +auto cb = OnceCallback([](int a, int b) { + return a + b; +}); + +// 2. Call: must go through an rvalue (std::move) +int result = std::move(cb).run(3, 4); // result == 7 + +// 3. After the call, cb is consumed +// std::move(cb).run(1, 2); // runtime assertion failure: callback already consumed ``` -### Argument Binding +### Argument binding ```cpp -// Binding arguments (partial application) -void ProcessData(int id, const std::string& data) { - // ... -} - -// Bind 'id' in advance, leave 'data' for later -auto cb = BindOnce(ProcessData, 100); -std::move(cb).Run("hello"); // Calls ProcessData(100, "hello") +// bind_once: pre-bind some arguments, return a OnceCallback +using namespace tamcpp::chrome; +auto bound = bind_once( + [](int x, int y, int z) { return x + y + z; }, + 10, 20 // pre-bind the first two arguments +); + +int r = std::move(bound).run(30); // r == 60 ``` -### Cancellation Checking +### Cancellation check ```cpp -// Check if the callback is still valid -auto cb = BindOnce(Task); -if (cb) { - std::move(cb).Run(); +using namespace tamcpp::chrome; +auto cb = OnceCallback([](int x) { /* ... */ }); + +// Check whether the callback is still valid +if (!cb.is_cancelled()) { + std::move(cb).run(42); } -// Or explicitly check -if (!cb.IsCancelled()) { - std::move(cb).Run(); +// maybe_valid: an optimistic check, for cross-sequence scenarios +if (cb.maybe_valid()) { + // "possibly" valid, not guaranteed + std::move(cb).run(42); } ``` -### Chained Composition +### Chained composition ```cpp -// Chaining: pass the result of one callback to the next -auto cb1 = BindOnce(FetchData); -auto cb2 = std::move(cb1).Then([](const Data& d) { - return Process(d); +using namespace tamcpp::chrome; +// then(): pass the current callback's return value to the next callback +auto pipeline = OnceCallback([](int a, int b) { + return a + b; +}).then([](int sum) { + return sum * 2; }); -std::move(cb2).Run(); // Executes FetchData -> Process -``` -### Analysis of Interface Design Decisions - -Now let's discuss the design decisions behind these APIs one by one. +int final_result = std::move(pipeline).run(3, 4); +// final_result == 14 (3+4)*2 +``` -**Why `Run` instead of `operator()`?** +### Walking through the interface decisions -Chromium uses `Run` (Google C++ style requires capitalization). We use `Run` to conform to snake_case naming conventions. But a deeper reason is semantic distinction: `operator()` is too generic; any callable object has it. `Run` explicitly expresses the semantics of "executing a task," making it immediately obvious during code review that this is consuming a `once_callback`, not just calling a generic callable object. +The API is fixed. Now we dig through the decisions behind it, one at a time. -**Why must `Run` be called via an rvalue?** +First, why `run()` and not `operator()`? Chromium uses `Run()` (Google C++ style wants the leading capital); we follow snake_case and use `run()`. But this isn't just a naming-convention difference. `operator()` is too generic: every callable object has one. `run()` says "execute the task" outright, so during code review you see at a glance that this is consuming a `OnceCallback`, not invoking some random callable. The semantic boundary is sharp, and the reader saves the mental cost. -This is the most critical point of the design. We need a mechanism where `cb.Run()` (lvalue call) fails to compile, but `std::move(cb).Run()` (rvalue call) succeeds. Chromium's implementation achieves this via two overloads: one `Run` is the actual execution version, and the other `&Run` contains a `static_assert` to produce a compile error. While effective, this hack is ugly. +The real load-bearing choice: why must `run()` go through an rvalue? This is the most critical piece of the whole design. We want a mechanism where `cb.run(args)` (an lvalue call) fails to compile and `std::move(cb).run(args)` (an rvalue call) compiles. Chromium's implementation pulls this off with two overloads: one `Run() &&` is the real execution, and one `Run() const&` carries a `static_assert(!sizeof(*this))` to block the lvalue. The hack works, but honestly it's ugly. -We can do this more elegantly using C++23's **deducing this** (explicit object parameter). Simply put, deducing this allows us to write `this` explicitly as a template parameter in a member function, and the compiler deduces this parameter's type based on whether the object is an lvalue or rvalue when called. Using this feature, `Run` distinguishes between lvalue and rvalue calls by deducing the value category of `self`, intercepting illegal usage at compile time: +C++23's deducing this (explicit object parameter) lets us do it more cleanly. In short, it lets a member function write `this` explicitly as a template parameter, and the compiler deduces that parameter's type from whether the call site uses an lvalue or an rvalue. With that, `run(this auto&& self, Args... args)` deduces the value category of `self` and rejects illegal uses at compile time: ```cpp -struct once_callback { - // ... - void Run(this auto&& self) requires std::is_rvalue_reference_v { - // Actual invocation logic - } -}; +template +auto run(this Self&& self, FuncArgs&&... args) -> ReturnType { + static_assert(!std::is_lvalue_reference_v, + "OnceCallback::run() must be called on an rvalue. " + "Use std::move(cb).run(...) instead."); + // ... actual call logic +} ``` -When the caller writes `cb.Run()`, `self` is deduced as `once_callback&` (lvalue reference), the `requires` clause triggers, and the error message tells the caller exactly what to do. When writing `std::move(cb).Run()`, `self` is deduced as `once_callback&&` (rvalue), and compilation passes. We will expand on the specific working mechanism of deducing this and a detailed comparison with the Chromium approach in the next implementation article. - -**Why distinguish between `IsCancelled` and `IsCancelled`?** - -This design comes directly from Chromium's `Callback`. The difference lies in the strength of the safety guarantee. `IsCancelled` provides a definitive answer—it can only be called on the sequence where the callback is bound, guaranteeing an accurate result. `MayBeCancelled` provides an optimistic estimate—it can be called from any thread, but the result might be stale. In practice, `IsCancelled` is used for "checking if it still makes sense before posting," while `MayBeCancelled` is used for the optimization path of "quickly checking if it's worth posting across threads." +When the caller writes `cb.run(args)`, `Self` deduces to `OnceCallback&` (an lvalue reference), the `static_assert` fires, and the error message tells them exactly how to fix it. When they write `std::move(cb).run(args)`, `Self` deduces to `OnceCallback` (an rvalue) and it compiles. How deducing this works in detail, and a close comparison with Chromium's hack, we save for the next implementation piece. -In our simplified implementation, both methods query via `std::move_only_function::operator bool`—`IsCancelled` checks if the state is valid and the token is still valid, `MayBeCancelled` is just a simple wrapper of `IsCancelled`. If finer-grained thread-safe semantics are needed later, we can distinguish between these two methods. +Next, a smaller but easy-to-trip choice: why split `is_cancelled()` and `maybe_valid()`? This design comes straight from Chromium's `CancellationQueryMode`, and the difference is in how strong the safety guarantee is. `is_cancelled()` gives a definitive answer: it can only be called on the sequence the callback is bound to, and the result is accurate. `maybe_valid()` gives an optimistic estimate: callable from any thread, but the result may be stale. In practice, `is_cancelled()` backs the "is it still worth posting?" check, while `maybe_valid()` backs the "quick cross-thread peek, is it worth the post?" optimization path. In our simplified implementation, both methods query a `CancelableToken`: `is_cancelled()` checks state validity and whether the token still exists, and `maybe_valid()` is just a thin wrapper around `!is_cancelled()`. If you later need finer-grained thread-safety semantics, split these two methods further. -**Why does `Then` consume `once_callback`?** - -The semantics of `Then` are "pass the result of the current callback to the next callback." This requires the current callback to be fully captured in the new callback returned by `Then`. If `Then` didn't consume `once_callback`, it would lead to the same callback existing in two places simultaneously—the original location and the new callback returned by `Then`—violating the move-only semantic constraint. Therefore, `Then` is declared as an rvalue-qualified member function (`&&`), and the original callback object enters a consumed state after the call. +Last, why does `then()` also have to eat `*this`? The semantics of `then()` are "pass the current callback's result to the next callback," which requires the current callback to be captured wholesale inside the new one. If `then()` didn't consume `*this`, the same callback would live in two places at once, the original spot and the new callback returned by `then()`, and move-only semantics would break on the spot. So `then()` is declared as an rvalue-qualified member (`then(...) &&`), and the original callback enters the consumed state after the call. --- -## Internal Mechanism: The Two-Layer Architecture of Type Erasure - -With the interface designed, let's look at how to organize the internals. Chromium used the `CallbackBase` + `BindState` + function pointer table combo to implement type erasure, which works well but results in a staggering amount of code. Our strategy is to use `std::move_only_function` to handle the dirty work of type erasure and small object optimization, allowing us to focus on the interesting parts: consumption semantics, argument binding, and chaining. +## Internal mechanism: the two-layer type-erasure architecture -### Why Choose `std::move_only_function` +The interface is set. Now the internals. Chromium's `BindStateBase` + `scoped_refptr` + function-pointer table combo does type erasure well, but the code volume is genuinely staggering. Our route is to let `std::move_only_function` carry the dirty work of type erasure and small-object optimization, and to focus our energy on the parts with real bite: consumption semantics, argument binding, and chained composition. -`std::move_only_function` was introduced in C++23, positioned as the "move-only version of `std::function`." It implements type erasure and SBO internally, behaving similarly to `std::function` but with copy operations deleted. +### Why `std::move_only_function` -You may have noticed syntax like `std::move_only_function`—`R(Args...)` looks like a function declaration, but in the context of template parameters, it is a **function type**. `R(Args...)` describes "a function accepting arguments `Args...` and returning `R`," and it is a legal C++ type. We deconstruct this type via template partial specialization—we'll explain this technique in detail in the next article. +`std::move_only_function` landed in C++23, positioned as "the move-only `std::function`." It does type erasure and SBO internally, behaves like `std::function`, and simply deletes the copy operations. -Using `std::move_only_function` for internal storage has several benefits. It saves us from hand-writing type erasion—recall in Volume 2 we spent a whole chapter hand-writing function pointer tables, SBO buffers, and move/destructor operations for `small_function`, whereas `std::move_only_function` encapsulates all of this for us. It natively supports move-only callables—if our callback captures a `std::unique_ptr`, `std::function` would fail to compile due to copy semantics requirements, but `std::move_only_function` handles this fine. Moreover, its SBO implementation has been carefully tuned by standard library authors, so in the vast majority of cases it doesn't require heap allocation—for lambdas capturing few arguments, performance is perfectly adequate. +You may have already noticed the `OnceCallback` spelling. `R(Args...)` looks like a function declaration, but in the template-parameter context it's a legitimate C++ type: a function type. `int(int, int)` describes "a function taking two ints and returning one int." We deconstruct this type through template partial specialization; we'll cover that technique in detail in the next article. -### Three-State Management +Using `std::move_only_function` for internal storage pays off in a few ways at once. First, it takes over the type-erasure work entirely. Recall the `LightCallback` from Volume 2: we spent a whole chapter hand-writing the function-pointer table, the SBO buffer, the move and destructor logic. `std::move_only_function` packages all of that and hands it to you. Second, it natively supports move-only callables: if a callback captures a `std::unique_ptr`, `std::function` refuses to compile over its copy requirement, while `std::move_only_function` has no such problem. Its SBO is also carefully tuned by the standard-library authors; in the vast majority of cases there's no heap allocation, and for lambdas capturing a handful of arguments the performance is more than enough. -After introducing `std::move_only_function`, there is a design problem to solve: how to distinguish between a "null callback" and a "consumed callback"? +### Three-state management -`std::move_only_function` itself can be null (default constructed or constructed from `nullptr`), but "null" and "already consumed by `Run`" are two different states. A null callback means "never assigned," and calling it should trigger a clear error ("callback is null"). A consumed callback means "once had a value, but has already been invoked," and calling it should also trigger an error ("callback already consumed"), but the error message is different, which is helpful for debugging. +Once `std::move_only_function` is in, a design question has to be settled: how do we tell apart "an empty callback" from "a consumed callback"? -So our internal state needs three states: +`std::move_only_function` can be empty on its own (default-constructed or built from `nullptr`), but "empty" and "consumed by `run()`" are not the same thing. Empty means "never assigned a value," and calling it should report a clear error ("callback is null"). Consumed means "it once held a value, but it's been called," and that should also report an error ("callback already consumed"), with a different message. That difference pays off in debugging: at a glance you can tell which step the callback is stuck on. So the internal state needs to be three-valued: ```cpp -enum class State { - Null, // Never assigned - Active, // Assigned, not yet run - Consumed // Already run +enum class Status : uint8_t { + kEmpty, // default-constructed, never assigned + kValid, // holds a valid callable + kConsumed // consumed by run() }; ``` -Combined with `std::move_only_function`, our internal storage structure looks roughly like this: +Paired with `std::move_only_function`, the internal storage looks roughly like this: ```cpp -class once_callback { - State state_ = State::Null; - std::move_only_function func_; - // Optional: CancellationToken* token_ +template +class OnceCallback { + std::move_only_function func_; + Status status_ = Status::kEmpty; + + // Cancellation token (optional) + std::shared_ptr token_; }; ``` -On move construction, `state_` and `func_` are moved together, and the source object's state is set to `State::Null`. When `Run` executes, it first checks if `state_` is `State::Active`, and after execution sets `func_` to null and `state_` to `State::Consumed`. This way, precise error messages can be given based on the value of `state_` during debugging. - -### Trade-offs Compared to Chromium's Original Version +On move construction, `func_` and `status_` move together and the source object is set to `kEmpty`. When `run()` fires, it first checks that `status_` is `kValid`; after the call, it nulls out `func_` and sets `status_` to `kConsumed`. During debugging, the value of `status_` gives you a precise error message. -Using `std::move_only_function` as the underlying storage gives us a simple implementation, but we also sacrifice some things. Chromium's `OnceCallback` is only the size of one pointer (8 bytes), thanks to the `__attribute__((packed))` annotation and reference-counted `CallbackBase`—the callback object itself is just a pointer to a heap-allocated `BindState`. Our `once_callback` wraps `std::move_only_function` (typically 32 bytes) plus a `State` enum and an optional `CancelToken*` pointer (16 bytes), totaling roughly 56-64 bytes. - -Another difference is reference counting. Chromium's `CallbackBase` is reference-counted, allowing multiple callbacks to share the same bound state (necessary for `RepeatingCallback`'s copy semantics). In our implementation, `once_callback` has exclusive ownership and does not support sharing. For `once_callback`'s move-only semantics, this isn't an issue, but when implementing `repeating_callback` later, this design will need to be reconsidered. - -These trade-offs are reasonable—we traded size and the flexibility of reference counting for a significantly lower implementation complexity. In actual use, a 56-64 byte callback object is not a bottleneck in the vast majority of scenarios, and the clear code structure makes maintenance and extension much cheaper. - ---- +### Tradeoffs against Chromium's original -## Summary +Using `std::move_only_function` as the backing storage buys a clean implementation, and the cost is real. Chromium's `OnceCallback` is one pointer wide (8 bytes), thanks to the `TRIVIAL_ABI` annotation on top of a refcounted `BindState`; the callback object is just a pointer to a heap-allocated `BindState`. Our `OnceCallback` wraps `std::move_only_function` (typically 32 bytes), plus the `Status` enum and the optional `CancelableToken` pointer (16 bytes), landing at roughly 56 to 64 bytes total. -In this article, we completed the design foundation for `once_callback`. Key takeaways: +The other difference is reference counting. Chromium's `BindState` is refcounted, so multiple callbacks can share one bound state (this is required for `RepeatingCallback`'s copy semantics). In our implementation, `std::move_only_function` owns its state exclusively and doesn't support sharing. For `OnceCallback`'s move-only semantics that's no obstacle, but when we get to `RepeatingCallback` later, this will need another look. -- `std::function` has three major drawbacks in asynchronous callback scenarios: copyable, repeatable, and unable to express consumption semantics. -- Chromium's `OnceCallback` constrains callback semantics via move-only + rvalue-qualified `Run` + single consumption. -- Our `once_callback` uses `std::move_only_function` for underlying type erasure and deducing this to implement rvalue-qualified `Run`. -- Internally, we use three-state management (`Null` / `Active` / `Consumed`) to distinguish between null and consumed callbacks. +I think these tradeoffs are worth taking. We trade size and the flexibility of reference counting for a steep drop in implementation complexity. In practice, a 56-to-64-byte callback object is not the bottleneck in the vast majority of scenarios, the code structure is clear, and the cost of maintaining and extending it is far lower. -In the next article, we will enter the implementation phase: starting with the core skeleton `once_callback`, and gradually adding `Bind`, cancellation checks, and `Then` chaining. +In the next piece we enter the implementation phase: start from the core `run()` skeleton and build `bind_once`, the cancellation check, and `then()` chaining on top of it. -## Reference Resources +## References -- [Chromium Callback Documentation](https://chromium.googlesource.com/chromium/src/+/main/docs/callback.md) -- [Chromium callback.h Source Code](https://chromium.googlesource.com/chromium/src/+/HEAD/base/functional/callback.h) +- [Chromium Callback documentation](https://chromium.googlesource.com/chromium/src/+/main/docs/callback.md) +- [Chromium callback.h source](https://chromium.googlesource.com/chromium/src/+/HEAD/base/functional/callback.h) - [cppreference: std::move_only_function](https://en.cppreference.com/w/cpp/utility/functional/move_only_function) -- [P0847R7 - Deducing this Proposal](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p0847r7.html) +- [P0847R7 - Deducing this proposal](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p0847r7.html) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/02-once-callback-implementation.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/02-once-callback-implementation.md index 2096c260d..38bf60b18 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/02-once-callback-implementation.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/02-once-callback-implementation.md @@ -2,391 +2,381 @@ chapter: 1 cpp_standard: - 23 -description: From core skeleton to complete component, a four-step walkthrough of - the `once_callback` implementation strategy, focusing on understanding template - techniques and ownership design. +description: "From the core skeleton to a complete component: a four-step walkthrough of the once_callback implementation, focused on the template techniques and the ownership design." difficulty: advanced order: 2 platform: host prerequisites: -- once_callback 设计指南(一):动机与接口设计 +- once_callback design guide (I): motivation and API design reading_time_minutes: 24 related: -- bind_once / bind_repeating 与参数绑定 -- 回调取消与组合模式 +- bind_once / bind_repeating and argument binding +- Callback cancellation and composition patterns tags: - host - cpp-modern - advanced - 回调机制 - 函数对象 -title: 'once_callback Design Guide (Part 2): Step-by-Step Implementation' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/02-once-callback-implementation.md - source_hash: 9235b72437f01739bf7c51c6b7987ca438ecb93b8d7dd17fa8a51f3447a2a7db - translated_at: '2026-06-16T04:14:35.634737+00:00' - engine: anthropic - token_count: 4328 +title: "once_callback Design Guide (II): Step-by-Step Implementation" --- -# Design Guide for `once_callback` (Part 2): Step-by-Step Implementation +# once_callback Design Guide (II): Step-by-Step Implementation -## Introduction +In the previous post we nailed down the target API and the internal architecture of `OnceCallback`. Now it is time to write code. One disclaimer first: this post will not paste the full header in front of you. That file runs to several hundred lines, and staring at it whole is a good way to lose focus. Instead we walk through the skeleton and the template tricks that actually demand thought, so the "why this shape" question gets answered properly. The complete, compilable code is left for the exercises and for the testing post that comes third. -In the previous post, we completed the analysis of motivation and interface design, establishing the target API and internal architecture for `once_callback`. In this post, we will start writing the actual code. But first, a heads-up—this post will not focus on "serving up the complete implementation," but rather on guiding you through the design rationale and key technical choices for each step. We will examine the skeletal structure of the code, but we will not post a complete, directly compilable header file—we will leave those details for post-class exercises and the testing verification in the third post. - -The implementation is divided into four steps, each building upon the previous one: first, nail the core `operator()` semantics, then add argument binding, followed by cancellation checks, and finally, `then` chain composition. In each step, we will focus only on "what this component looks like" and "what the key template techniques are," rather than interpreting the implementation line by line. - -> **Learning Objectives** -> -> - Understand the template partial specialization pattern and internal storage design of `once_callback` -> - Master the application of advanced template techniques such as deducing this, `requires` constraints, and lambda capture pack expansion in practical components -> - Understand the argument binding mechanism of `Bind` and the ownership chain design of `then` +The implementation stacks up in four steps. First we get the `run()` semantics right, since everything hangs off them. Then we add `bind_once()` for argument binding, hang a cancellation check off the front, and finally wire up `then()` for chaining. At each step we keep two questions in view: what does this piece look like, and where is the hard template trick hiding. --- -## Step 1: Core Skeleton — Starting with Template Partial Specialization +## Step 1: The core skeleton, starting from template partial specialization -### Why use this syntax for `once_callback` looks like that -You may have noticed that our declaration of `once_callback` is a bit special—it's not `template `, but `template `. This is called "signature-style template parameter," and `std::move_only_function` and `std::function` do the same. +If you have glanced at the standard library, you will have noticed that `std::function` and `std::move_only_function` share a shape: the template parameter is not "return type plus a parameter list written separately", it is one whole function signature. Our `OnceCallback` follows the same convention, for the same reason. Signature-style template parameters read cleanly. -The technique behind this is **template partial specialization**. We first declare a primary template with only a declaration and no definition: +The mechanism underneath is template partial specialization. We start with a bare primary template, declared but not defined: ```cpp -template -class once_callback; +template +class OnceCallback; // primary template: no implementation ``` -Then we provide a partial specialization for the case where `Signature` happens to be a function type: +Then we open one partial specialization that catches the case where the signature is exactly a function type: ```cpp -template -class once_callback { - // ... +template +class OnceCallback { + // all the real code lives in this specialization }; ``` -When the user writes `once_callback`, the compiler treats `int(int, int)` as a whole type matching the `Signature` of the primary template, then discovers that the partial specialization can unpack this whole into a return type `R` and a parameter pack `Args...`, so it selects the partial specialization. The benefit of this pattern is that users can use a very natural "function signature" syntax to specify the callback type, without needing to pass the return value and argument list separately. +When you write `OnceCallback`, the compiler first feeds `int(int, int)` as one whole type into the primary template's `FuncSignature`, then notices the partial specialization can split that whole into `ReturnType = int` and `FuncArgs... = {int, int}`, so the specialization wins. The payoff is obvious: users specify the callback type with a natural function-signature syntax, instead of passing return type and parameter list as two separate template arguments. -There is a confusing point here: `R(Args...)` looks like a function declaration, but in the context of template parameters, it is a **function type**. `int(int, int)` is a legal C++ type—it describes "a function that takes two int parameters and returns an int." Template partial specialization utilizes this type, unpacking it via pattern matching to extract the return type and parameter pack. +There is a small trap here that trips people. The `R(Args...)` spelling looks like a function declaration, but in a template parameter position it is actually a function type. `int(int, int)` is a legal C++ type in its own right, describing "a function that eats two ints and returns one int". The partial specialization hitchhikes on that, unpacking the signature through pattern matching. -### Internal Storage: What does the class skeleton look like +### Internal storage: what the class skeleton looks like -In the previous post, we established the three-state architecture. Now let's look at the class skeleton—ignoring method implementations for now, focusing only on data members and interface signatures: +The previous post fixed a three-state architecture. Now we stand the skeleton up. Ignore the method bodies for a moment and just look at the data members and the interface signatures: ```cpp -template -class once_callback { -public: - // Constructor, destructor, move operations - once_callback(); - once_callback(std::nullptr_t); - once_callback(once_callback&& other) noexcept; - once_callback& operator=(once_callback&& other) noexcept; +template +class OnceCallback { + // Core storage: holds the actual callable object. + // Feed it a lambda, a function pointer, or a functor — it takes them all. + std::move_only_function func_; - // Disallow copy - once_callback(const once_callback&) = delete; - once_callback& operator=(const once_callback&) = delete; + // Three-state flag: kEmpty → kValid → kConsumed + Status status_ = Status::kEmpty; - // Core execution interface - template - R operator()(this Self&& self, Args... args) - requires (!std::is_lvalue_reference_v); + // Cancellation token (optional) + std::shared_ptr token_; - // Cancellation interface - [[nodiscard]] bool is_cancelled() const; - void set_cancellation_token(cancellation_token token); +public: + // Construction: accepts any callable (with a requires constraint, explained below) + template + requires not_the_same_t + explicit OnceCallback(Functor&& f); -private: - enum class State { kEmpty, kValid, kConsumed }; + // Move-only: copy deleted + OnceCallback(const OnceCallback&) = delete; + OnceCallback& operator=(const OnceCallback&) = delete; + OnceCallback(OnceCallback&& other) noexcept; + OnceCallback& operator=(OnceCallback&& other) noexcept; - struct VTable { - void (*move)(void* src, void* dest) noexcept; - void (*destroy)(void* target) noexcept; - R (*invoke)(void* target, Args... args); - }; + // Core: runs the callback and consumes *this (via deducing this, explained below) + template + auto run(this Self&& self, FuncArgs&&... args) -> ReturnType; + + // Query interface + [[nodiscard]] bool is_cancelled() const noexcept; + [[nodiscard]] bool maybe_valid() const noexcept; + [[nodiscard]] bool is_null() const noexcept; + explicit operator bool() const noexcept; - static const VTable& get_vtable_for(); + // Install a cancellation token + void set_token(std::shared_ptr token); - State state_ = State::kEmpty; - std::optional cancel_token_; - alignas(void*) char storage_[sizeof(void*) * 2]; + // Chained composition + template auto then(Next&& next) &&; + +private: + ReturnType impl_run(FuncArgs... args); // the real execution logic }; ``` -Every member in the skeleton has a clear responsibility. `VTable` is responsible for type erasure—unifying various forms of callable objects into a known-signature call interface. `State` is a three-state enum, distinguishing "never assigned" (kEmpty), "ready to call" (kValid), and "already called" (kConsumed). `cancel_token_` is an optional cancellation token used to check whether execution should be skipped before the callback runs. Move operations perform pointer-level transfers, leaving the source object in the kEmpty state. +Every member has a clear job. `func_` does the dirty work of type erasure: whatever you feed it, lambda, function pointer, or functor, it gets folded into one call port with a known signature. `status_` is a three-state enum separating "never assigned" (kEmpty), "ready to run" (kValid), and "already ran" (kConsumed). `token_` is an optional cancellation token that gates the callback before it actually runs. Move operations do a pointer-level transfer and leave the source back in kEmpty. -Next, let's focus on the two most ingenious parts of the skeleton: the deducing this technique of `operator()` and the `requires` constraint of the constructor. These are the most template-intensive parts of the entire component and deserve to be explained thoroughly. +Once the skeleton is up, two spots carry the most template density: `run()` with its deducing this, and the constructor's `requires` constraint. Pulling those two apart and explaining them fully makes the rest easy reading. -### deducing this: Letting the compiler help us intercept erroneous calls +### deducing this: let the compiler block the wrong call for us -`operator()` is the soul of the entire component and the method with the densest C++23 features. Let's look at its declaration first: +`run()` is the soul of the component, and it is also the method most densely packed with C++23 features. Start with its declaration: ```cpp -template -R operator()(this Self&& self, Args... args) - requires (!std::is_lvalue_reference_v); +template +auto run(this Self&& self, Args... args) -> R; ``` -If you haven't seen `this Self&& self` syntax before, don't panic, we'll go through it step by step. - -#### What is deducing this - -deducing this is a feature introduced in C++23, officially called "explicit object parameter." In traditional member functions, `this` is an implicit parameter—the compiler automatically passes the address of the current object, invisible and untouchable. deducing this allows us to explicitly write `this` as the first parameter of the function and use a template parameter to deduce its type and value category. +That `this Self&& self` made me blink the first time I saw it. It is C++23's deducing this, officially called an explicit object parameter. In a traditional member function `this` is implicit: the compiler quietly threads the current object's address in, and you cannot see or touch it. Deducing this turns `this` into an explicit first parameter and uses template argument deduction to pin down its type and value category. ```cpp -template -void func(this Self&& self); -``` +// Traditional form: this is implicit +void run(FuncArgs... args); // compiler sees run(OnceCallback* this, FuncArgs... args) -The key lies in `Self&&`—it looks like an rvalue reference, but it's actually a **forwarding reference** because `Self` is a template parameter. The special thing about a forwarding reference is that it can be deduced as different types based on the value category of the passed argument: +// Deducing this form: this is explicit +template +auto run(this Self&& self, FuncArgs&&... args) -> ReturnType; // self is this +``` -- `obj.func()` — `obj` is an lvalue, `Self` deduces to `Self&` (lvalue reference) -- `std::move(obj).func()` — `obj` is an rvalue, `Self` deduces to `Self` (prvalue) -- `const_obj.func()` — const lvalue, `Self` deduces to `const Self&` +The trick lives in `Self&&`. It looks like an rvalue reference, but because `Self` is a template parameter it actually degrades into a forwarding reference. A forwarding reference shape-shifts based on the value category of the actual argument: an lvalue call like `cb.run(args)` deduces `Self` as `OnceCallback&`; `std::move(cb).run(args)` makes `Self` a prvalue `OnceCallback`; a const lvalue `std::as_const(cb).run(args)` gives `const OnceCallback&`. Three value categories, one template that catches them all. -#### How we utilize it +#### How we put it to work -Knowing the deduction rules of `Self&&`, intercepting lvalue calls is simple: +Once the deduction rules are clear, blocking lvalue calls is a one-liner: ```cpp -requires (!std::is_lvalue_reference_v) +template +auto run(this Self&& self, FuncArgs&&... args) -> ReturnType { + static_assert(!std::is_lvalue_reference_v, + "OnceCallback::run() must be called on an rvalue. " + "Use std::move(cb).run(...) instead."); + return std::forward(self).impl_run(std::forward(args)...); +} ``` -`std::is_lvalue_reference_v` is a compile-time constant checking if `Self` is an lvalue reference type. When the caller writes `callback(args...)`, `Self` is deduced as `once_callback&`, which is an lvalue reference, the condition is `true`, negating it makes the `requires` clause fail, and the compiler reports an error immediately—the error message is exactly what we wrote. When the caller writes `std::move(callback)(args...)`, `Self` is deduced as `once_callback`, which is not a reference, `requires` passes, and it enters the body to execute the real logic. Note that we use `std::forward(self)` instead of `self`—this ensures `operator()` is correctly invoked on the rvalue. +`std::is_lvalue_reference_v` is a compile-time constant that asks whether `Self` is an lvalue reference. When the caller writes `cb.run(args)`, `Self` deduces to `OnceCallback&`, which is an lvalue reference, the condition negates and trips the `static_assert`, and the compiler errors on the spot with the "use std::move" message we wrote. When the caller writes `std::move(cb).run(args)`, `Self` is a prvalue type, the `static_assert` lets it through, and `std::forward(self).impl_run(...)` hands the work to the real implementation. We use `std::forward(self)` rather than `self.impl_run(...)` deliberately, so that `impl_run` is invoked on an rvalue and the value category is not lost at this step. -There is a detail worth pondering: the condition of `requires` depends on the template parameter `Self`, so it is only evaluated when the template is instantiated. This means if `operator()` is never called, `requires` won't trigger—regardless of whether an lvalue or rvalue is passed. Only when the compiler needs to instantiate this template at a certain call point will the specific type of `Self` be determined, and `requires` evaluated. This is called "lazy instantiation," a very common pattern in template metaprogramming. +One detail I find worth chewing on: the `static_assert` condition hangs off the template parameter `Self`, so it is only evaluated at the moment of instantiation. In other words, if nobody calls `run()`, this assert never fires, no matter whether the would-be caller is an lvalue or an rvalue. Only when someone writes an actual call site and the compiler has to instantiate the template does `Self` resolve to a concrete type and the assert get evaluated. This mechanism is called lazy instantiation, and it is bread and butter in template metaprogramming. -#### Comparison with Chromium's approach +#### Compared with Chromium's approach -Chromium doesn't enjoy the benefits of C++23; it uses two overloads: `R operator()(Args...)&&` is the real execution version, and `R operator()(Args...)& = delete` contains a `sizeof` to produce a compile error. That `sizeof` hack exploits a property of C++: `sizeof` must be evaluated on a complete type, so when evaluated, it must be inside the class definition (the type of `*this` is complete), the expression value is definitely `false`. Before C++23, writing `requires` directly would trigger on all code paths (even if the overload was never called), so Chromium had to use the `sizeof` trick. C++23 relaxed this restriction, but Chromium's codebase hasn't fully migrated to C++23 yet, so it retains the old style. +Chromium does not get to enjoy C++23. It walks the old road of two overloads: `Run() &&` is the version that actually executes, and `Run() const&` stuffs a `static_assert(!sizeof(*this), "...")` in to force a compile error. That `!sizeof` hack leans on a property of C++: `sizeof` can only be evaluated on a complete type, so once `!sizeof(*this)` gets evaluated it proves we are inside the class definition (where `*this` is complete), and the value has to be `false`. Before C++23, writing `static_assert(false, "...")` directly would fire on every code path, even the ones where this overload is never called, which is why Chromium had to reach for the `!sizeof` detour. C++23 loosened that restriction, but Chromium's codebase has not migrated wholesale to C++23 yet, so the old form lives on. -Our deducing this solution requires only one function template, naturally distinguishing lvalues and rvalues through the deduction of `Self&&`, which is much cleaner than Chromium's two overloads + `sizeof` hack. +Our deducing this version uses a single function template and lets the deduction of `Self` separate lvalue from rvalue cleanly, which is a big step up in readability over Chromium's two overloads plus the `!sizeof` hack. Credit where it is due: that convenience is bought by standing on the new standard's shoulders. ### The constructor's requires constraint -There is a seemingly redundant constraint on the constructor template: +The constructor template carries a constraint that looks redundant at first glance: ```cpp -template - requires (!std::same_as, once_callback>) -once_callback(F&& f); +template + requires not_the_same_t +explicit OnceCallback(Functor&& f); ``` -Why not just `template once_callback(F&& f)` and be done with it? The problem lies in the competition between the template constructor and the move constructor. +Why not just `template` and be done with it? Because the templated constructor would otherwise steal work from the move constructor. -When we write `once_callback cb = std::move(other_cb)`, the compiler has two paths: call the implicitly declared move constructor `once_callback(once_callback&&)`, or instantiate the template constructor as `once_callback(once_callback&)` (letting `F` be `once_callback&`). Intuitively, we feel the move constructor is a "more specific" match and should be prioritized. But C++ overload resolution rules don't work that way—in some cases, a function signature instantiated from a template is a "more exact" match than implicitly declared special member functions, and the compiler will unhesitatingly choose the template version. This can lead to unexpected behavior, such as the template constructor failing to correctly set the source object's state to kEmpty. +When we write `OnceCallback cb2 = std::move(cb1)`, the compiler has two paths in front of it: take the implicitly declared move constructor `OnceCallback(OnceCallback&&)`, or instantiate the template constructor as `OnceCallback(OnceCallback&&)` by setting `Functor = OnceCallback`. Intuitively the move constructor feels "more specific" and should win. But C++ overload resolution does not follow intuition. In some cases the signature instantiated from the template matches "more exactly" than the implicitly declared special member function, and the compiler picks the template version without a second thought. That pick is where the trouble starts: the template constructor most likely does not dutifully reset the source object to kEmpty. -Our implementation uses a custom concept `not_same_as_once_callback` to solve this: `!std::same_as, once_callback>` means "exclude this template when the decayed type of `F` happens to be `once_callback` itself." Decay here serves to remove reference and cv qualifiers from `F`—because `F` could be `once_callback&` or `const once_callback`, after decay they both become `once_callback`. With the constraint, when passing `once_callback` itself, the template is excluded, and the compiler correctly matches the move constructor. +Our implementation squashes this with a custom concept `not_the_same_t`. It is essentially `!std::is_same_v, T>`, meaning "exclude this template when `F`, after decay, is exactly `T`". Decay's job here is to strip the references and cv-qualifiers off `F`: `F` might be `OnceCallback&&` or `const OnceCallback&`, and decay turns both back into `OnceCallback`. With the constraint attached, the moment the incoming type is `OnceCallback` itself the template drops out, and only then does the compiler fall back to the move constructor. -This technique is very common when implementing move-only type erasure wrappers—`std::move_only_function`'s implementation has similar constraints. If you write similar components in the future, remember this pattern: **template constructor + requires excluding self type = protecting the correct matching of move semantics**. +This pattern is everywhere when you write move-only type-erased wrappers. The implementation of `std::move_only_function` itself carries a similar constraint. If you ever build a component like this, burn the pattern in: template constructor plus a requires that excludes the self type is what backstops correct move-semantics matching. -### Internal implementation ideas for consumption semantics +### The internal shape of consumption semantics -The implementation logic of `operator()` is straightforward—check state, handle cancellation, call the callable object, update state. There are a few details worth mentioning. +The main line of `impl_run` is easy to read at a glance: check state, check cancellation, run the callable, flip the state. But a few details only register after you have stepped on them. -The first is that cancellation checks happen before execution. `operator()` first checks if the token is valid—if cancelled, it consumes the callback without executing, returning directly for void cases, or throwing `bad_callback_call` for non-void cases. This exception-throwing behavior might seem aggressive, but it has good reason: the caller expects a return value, but we cannot provide a meaningful one, so throwing an exception is safer than returning an undefined value. +First, the cancellation check has to come before execution. `impl_run` looks at the token first. If it has already been cancelled, the callback is consumed without running: for a void return type we just return, for a non-void return we throw `std::bad_function_call`. Throwing here feels aggressive at first, but the reasoning is solid. The caller is waiting on a return value, and we have no way to conjure a meaningful one out of thin air. Throwing is more honest than handing back an undefined value. -The second is the `if constexpr` branch. When the return type is `void`, we cannot write `return result(...)`—void is not a type that can be assigned. `if constexpr` selects the branch at compile time; the void case takes the "call but don't assign" path, while the non-void case takes the "call and assign to result" path. This is the standard pattern for handling void return types with `std::optional`. +Second is the `if constexpr (std::is_void_v)` branch. When the return type is void, the spelling `ReturnType result = func_(args...)` will not even compile, because void is not an assignable type. `if constexpr` picks the branch at compile time: the void case takes the "call but do not assign" path, the non-void case takes "call and assign to result". This is the standard recipe for handling void returns with `if constexpr`. -The third is nulling after consumption. `operator()` first moves the callable object out as a local variable, then sets `state_` to `kConsumed` and `cancel_token_` to empty, and finally executes the callable object in the local variable. This order is important—move the callable object out, mark the state, then execute. This way, even if the callable object throws an exception internally, `state_` is already kConsumed, and the callback won't be in an inconsistent state. Nulling isn't just about marking state—it triggers the `VTable::destroy` to destruct the internally held callable object, releasing resources captured by the lambda (like `std::unique_ptr`). +Third is the ordering of "consume then null", which I did not pay attention to at first and nearly got bitten by. `impl_run` has to move `func_` into a local variable first, then set `func_` to `nullptr` and `status_` to kConsumed, and only then execute the callable held in that local. That order is not negotiable. Pull the object out, mark the state, then run. That way, even if the callable throws internally, `status_` is already sitting at kConsumed and the callback cannot end up in a half-broken dirty state. The nulling step does more than flip a state, by the way. It triggers `std::move_only_function` to destruct the callable it holds, which in turn releases whatever resources the captured lambda owned (a `unique_ptr`, say). ### Verifying the core skeleton -After writing the skeleton, quickly verifying a few scenarios is enough: basic type return, void return, move-only capture, and move semantics. If these four scenarios pass—constructing a callback gets the correct return value, void callbacks execute normally, resources are released after callbacks capturing `unique_ptr` are used, the source object becomes empty after moving, and the target object is valid—the skeleton is fine. We will organize complete test cases in the third post. +Once the skeleton is written, four scenarios are enough to smoke-test it: basic return type, void return, move-only capture, and move semantics. If those four pass, meaning: constructing a callback gives the right return value, a void callback executes cleanly, a callback capturing a `unique_ptr` releases its resource after running, and after a move the source is empty while the target is valid, the skeleton holds. The full test suite is collected in the third post. --- -## Step 2: Argument Binding — `Bind` +## Step 2: Argument binding with `bind_once()` -### What problem are we solving +### The problem we are solving -The scenario for `Bind` is very intuitive: you have a three-parameter function `int add(int a, int b, int c)`, but the first two parameters can be determined at binding time (e.g., 10 and 20), and only the third parameter is passed at call time. You want to get a `once_callback` that only takes one argument, and when called, it automatically feeds 10, 20, and your passed argument to the original function. +The scenario for `bind_once` fits in one sentence. You have a three-parameter function `f(int, int, int)`, the first two arguments are known at bind time (say 10 and 20), and only the third has to wait until call time. What you want is a `OnceCallback` that takes a single argument, and when it runs it lines 10 and 20 up with the argument you passed in and hands the lot to the original function. -This is argument binding—stuffing "known parameters" into the callback in advance, so the caller only needs to care about "unknown parameters." Chromium's `base::BindOnce` does a lot of work in this area to handle parameter lifetimes (`Unretained`, `RetainedRef`, `Owned`, `WeakPtr`, etc.), our simplified version only focuses on the core argument binding logic. +That is argument binding. Stuff the known arguments into the callback ahead of time, and let the caller worry only about the unknown ones. Chromium's `BindOnce` goes to considerable lengths on argument lifetime (`Unretained`, `Owned`, `Passed`, `WeakPtr`, a whole pile of helpers); our simplified version only handles the core binding logic. -### Implementation skeleton of `Bind` +### The skeleton of `bind_once` ```cpp -template -auto Bind(F&& f, BoundArgs&&... bound_args) { - return [f = std::forward(f), - ...bound_args = std::forward(bound_args)] - (CallArgs&&... call_args) mutable - -> decltype(auto) - { - return std::invoke(f, bound_args..., std::forward(call_args)...); - }; +template +auto bind_once(F&& funtor, BoundArgs&&... args) { + return OnceCallback( + [f = std::forward(funtor), + ...bound = std::forward(args)] + (auto&&... call_args) mutable -> decltype(auto) { + return std::invoke( + std::move(f), + std::move(bound)..., + std::forward(call_args)... + ); + } + ); } ``` -This code isn't long, but it contains several template techniques worth expanding on. Let's break them down one by one. +The code is short, but it hides several template tricks each worth pulling out on its own. We will take them one at a time. -### Lambda Capture Pack Expansion +### Lambda capture pack expansion -The line `...bound_args = std::forward(bound_args)` is the **lambda init-capture pack expansion** syntax introduced in C++20. It is the key to the concise implementation of `Bind`. +The line `...bound = std::forward(args)` is the lambda init-capture pack expansion syntax, which C++20 let out of the cage. The whole reason `bind_once` can be written this cleanly is that line. -Before C++20, parameter packs of variadic templates could not be directly expanded into a lambda's capture list—you couldn't write code to "capture every element of `bound_args` into the lambda separately." The workaround was to pack all bound arguments into a `std::tuple`, then use `std::apply` inside the lambda to expand them into separate arguments for the call. This solution works, but the code bloats significantly—you need an extra tuple, an `std::apply` call, and template helper code to handle the move semantics of tuple elements. +Before C++20, the parameter pack of a variadic template could not be expanded directly into a lambda's capture list. You literally could not spell "capture each element of `args...` into the lambda as its own variable". The workaround was to pack every bound argument into a `std::tuple`, then call `std::apply` inside the lambda to unpack the tuple into separate arguments for the call. It works, but the code balloons: an extra tuple, a `std::apply` call, and a pile of template helper code to handle the move semantics of tuple elements. -C++20 finally allows pack expansion into lambda captures. Specifically, `...bound_args = ...` generates a corresponding capture variable for each type in `BoundArgs`, each initialized with perfect forwarding `std::forward`. To give a concrete example, if `BoundArgs` is `int&&, double&`, then expansion is equivalent to: +C++20 finally relented. The effect of `...bound = std::forward(args)` is to generate one capture variable per type in `BoundArgs...`, each initialized with perfect forwarding through `std::forward`. Concrete example: if `BoundArgs...` is `int, std::string`, the expansion is equivalent to: ```cpp -[ - f = std::forward(f), - bound_args_0 = std::forward(bound_arg_0), // int&& - bound_args_1 = std::forward(bound_arg_1) // double& -] +[b1 = std::forward(arg1), b2 = std::forward(arg2)] ``` -Each capture variable can be used independently inside the lambda, and in our `Bind`, they are passed together to `f` via `bound_args...` when the lambda is called. Note we use `bound_args...` not `std::move(bound_args)...`—because the lambda owns the captures, the captured variables are lvalues inside the lambda, and we want to pass them as rvalues to trigger move semantics. +Each capture variable is independently usable inside the lambda. In our `bind_once`, when the lambda is called they get unpacked together through `std::move(bound)...` and fed to `std::invoke`. One trap worth flagging: we use `std::move`, not `std::forward`. The lambda is marked `mutable`, so the captured variables are lvalues inside the lambda body, and to send them out as rvalues we have to move them explicitly. -### Unified invocation capability of `std::invoke` +### The unified call surface of `std::invoke` -The lambda uses `std::invoke` instead of directly calling `f(...)` because `std::invoke` can uniformly handle various callable objects. Calling a normal function pointer directly is fine, but member function pointers are different—you can't write `ptr(obj, args)`, you must use `(obj.*ptr)(args)` syntax. `std::invoke` encapsulates all these differences: `std::invoke(ptr, obj, args...)` is equivalent to `(obj.*ptr)(args...)`. +Inside the lambda we call `std::invoke` rather than writing `f(...)` directly, because `std::invoke` flattens the differences between callable shapes. Calling an ordinary function pointer directly is fine, but a member function pointer is a different animal: you cannot write `(&Class::method)(obj, args...)`, you have to switch to the special syntax `(obj.*method)(args...)`. `std::invoke` papers over all of that: `std::invoke(&Class::method, &obj, args...)` is equivalent to `(obj.*method)(args...)`. -This means `Bind` natively supports member function binding without extra code: +That gives `bind_once` member-function binding for free, with no extra code: ```cpp struct Calculator { - int add(int a, int b) { return a + b; } + int multiply(int a, int b) { return a * b; } }; Calculator calc; -auto cb = Bind(&Calculator::add, &calc, 10); -// cb(20) calls calc.add(10, 20) +auto bound = bind_once(&Calculator::multiply, &calc, 5); +int r = std::move(bound).run(8); // r == 40 ``` -However, there is a **lifetime trap** to note here: `&calc` is a raw pointer, and `Bind` won't manage its lifetime. If `calc` is destroyed before the callback is invoked, `std::invoke` will access freed memory through a dangling pointer. Chromium uses `Unretained` to explicitly mark "I know this raw pointer's lifetime is safe," `Owned` to take over ownership, and `WeakPtr` to automatically cancel the callback when the object is destructed. In our simplified version, this safety responsibility is temporarily handed to the caller. +There is a lifetime trap buried here that I have to flag. `&calc` is a raw pointer, and `bind_once` does not manage its life at all. If `calc` destructs before the callback actually runs, `std::invoke` will follow the dangling pointer straight into freed memory. Classic use-after-free. Chromium ships a whole set of helpers around this: `base::Unretained` to declare explicitly "I know what I am doing with this raw pointer's lifetime", `base::Owned` to take over ownership, and `base::WeakPtr` to void the callback when the object destructs. In our simplified version that safety burden stays on the caller's shoulders. -### Signature deduction: Why explicit specification of `BoundSig` is needed +### Signature deduction: why `Signature` has to be spelled out -You may have noticed that the first template parameter `BoundSig` (e.g., `int(int)`) of `Bind` needs to be explicitly specified by the caller. Ideally, the compiler should be able to automatically deduce the "remaining signature after removing bound parameters" from the callable signature of `F`. But this task is much more complex in C++ than imagined. +You probably noticed that the first template parameter of `bind_once`, `Signature` (something like `int(int)`), has to be written by the caller. In an ideal world the compiler would look at the callable signature of `F`, lop off the bound parameters, and hand you back "the remaining signature" automatically. In C++ that is much harder than it sounds. -For a function pointer `int(*)(int, int, int)`, you can extract the parameter list via template partial specialization and then use a compile-time "type list slicing" operation to remove the first N types. For functors with a definite signature, you can also extract the signature via `decltype(operator())`. But for **generic lambdas** (`[](auto x){...}`), its `operator()` is itself a template, and there is no uniquely determined signature—the compiler cannot obtain information on "what parameters this lambda accepts" at the type level at all. +A function pointer `R(*)(Args...)` is the easy case: a template partial specialization lifts out the parameter list, then a compile-time "type-list slice" chops off the first N types. A functor with a fixed signature is workable too, since `decltype(&T::operator())` digs the signature out. But a generic lambda (`[](auto x) { ... }`) kills the whole approach. Its `operator()` is itself a template, so there is no single fixed signature, and the compiler cannot ask "what parameters does this lambda take" as a type-level question at all. -Chromium wrote a whole set of type manipulation tools (`BindTraits`, `CallbackParamTraits`, etc.), about a few hundred lines of template metaprogramming code to handle various edge cases. For our teaching purposes, having the caller write one more template parameter `BoundSig` is a more pragmatic choice—it saves a huge amount of complex template metaprogramming and improves code clarity. +Chromium wrote a whole type-manipulation toolkit for this (`MakeUnboundRunType`, `DropTypeListItem`, and friends), a few hundred lines of template metaprogramming wrestling with one edge case after another. For our teaching purposes, asking the caller to spell out one more template parameter like `int(int)` is the pragmatic call. We skip a fat chunk of metaprogramming and the code stays readable. --- -## Step 3: Cancellation Check — `cancellation_token` and `is_cancelled` +## Step 3: Cancellation checks with `is_cancelled()` and `maybe_valid()` -### The concept of cancellation token +### What the cancellation token is for -A callback can be associated with a "cancellation token" when created. The token represents the lifetime of some external object—when that object is destroyed, the token becomes invalid, and all callbacks associated with the token become "cancelled." +When a callback is created it can be hooked up to a cancellation token. The token stands for the life and death of some external object: once that object is gone, the token goes invalid with it, and every callback tied to that token enters the "cancelled" state. -You can think of it as a "pass": when creating the callback, issue a pass to it that says "valid." At some moment, an external object says "the pass is voided" (calling `invalidate`), after which all callbacks holding this pass will find "the pass is already invalid" when checked before execution, and skip execution. In Chromium, this pass is the control block inside `base::WeakPtr`—when the object `WeakPtr` points to is destroyed, the flag in the control block is cleared, and all callbacks bound to this `WeakPtr` are automatically cancelled. +Think of it as a pass. When the callback is born it gets issued a pass stamped "valid". The day the external object says "void this pass" (by calling `invalidate()`), every callback holding that pass will check the pass before running, see it has been stamped void, and quietly skip itself. In Chromium that pass is the control block inside `WeakPtr`: the moment the object a `WeakPtr` points at destructs, a flag in the control block flips, and every callback bound to that `WeakPtr` is voided. -### Design ideas for `cancellation_token` +### The shape of `CancelableToken` -Our simplified cancellation token only needs three core operations: create (generate a valid token), invalidate (mark as void), and check (query if still valid). Internally, use `std::shared_ptr` to manage a `struct` containing `std::atomic`: +Our simplified token has three core actions: create (issue a valid one), invalidate (stamp it void), and check (ask whether it is still valid). Internally it holds a `Flag` struct wrapping an `atomic` behind a `shared_ptr`: ```cpp -class cancellation_token { -public: - cancellation_token() : state_(std::make_shared()) {} - - void invalidate() { state_->cancelled.store(true, std::memory_order_relaxed); } - bool is_cancelled() const { return state_->cancelled.load(std::memory_order_relaxed); } - -private: - struct State { - std::atomic cancelled{false}; +class CancelableToken { + struct Flag { + std::atomic valid{true}; // atomic: thread-safe }; + // every token copy shares the same Flag + std::shared_ptr flag_; - std::shared_ptr state_; +public: + CancelableToken() : flag_(std::make_shared()) {} + void invalidate() { flag_->valid.store(false, std::memory_order_release); } + bool is_valid() const { + return flag_->valid.load(std::memory_order_acquire); + } }; ``` -Using `std::shared_ptr` instead of a raw pointer allows the token to be copied and moved while all copies share the same `State`. `std::atomic` ensures the safety of multi-threaded access—one thread might be executing `is_cancelled` while another calls `invalidate`, and `memory_order_relaxed` semantics guarantee the former read sees the latter's write. +Why a `shared_ptr` and not a raw pointer? So the token can be copied and moved around while every copy still shares one and the same `Flag`. The `atomic` is what keeps multi-threaded access safe: one thread is reading `is_valid()` over here, another thread calls `invalidate()` over there, and the `memory_order_acquire` / `memory_order_release` pair lines the two sides up so the read is guaranteed to see the write. -### Integration into `once_callback` +### Slotting it into `OnceCallback` -Integrating the cancellation token into `once_callback` is straightforward: add an optional `cancellation_token` to the data members, set it via the `set_cancellation_token` method, and then check it in two places—when `is_cancelled` is queried and before `operator()` executes. +The way the token slots into `OnceCallback` is straightforward. A data member holds an optional `shared_ptr`, set through `set_token()`, and then it gets checked in two places: once when `is_cancelled()` is queried, and once before `impl_run()` actually runs. -`is_cancelled` logic: if the state is not kValid, return true (empty and consumed callbacks count as "cancelled"), and if there is a token and it is invalid, also return true. In `operator()`, before actually executing the callable object, check the token status—if cancelled, consume the callback without executing, return directly (void case) or throw `bad_callback_call` (case requiring a return value). +The `is_cancelled()` logic is one line: if the state is anything other than kValid, return true (an empty or consumed callback both count as "cancelled"), and if a token is present and invalid, also return true. On the `impl_run` side, right before the callable is about to run, the token gets a glance. If it has been cancelled, the callback is consumed without running: the void case returns, the needs-a-return-value case throws `std::bad_function_call`. -`set_cancellation_token` is currently just a simple wrapper for `std::optional::emplace`. In Chromium's full implementation, the difference lies in the strength of thread safety guarantees—`is_cancelled` can only be called on the sequence where the callback is bound (i.e., the thread that created the callback), guaranteeing a deterministic result; `MayBeCancelled` can be called from any thread, but the result may be stale. Our simplified version doesn't distinguish this semantics for now, but retains both method names for future expansion in `std::execution` or cross-thread scenarios. +`maybe_valid()` for now is just `!is_cancelled()` in a wrapper. In Chromium's full implementation the two differ in how strong a thread-safety guarantee they offer. `is_cancelled()` may only be called on the sequence the callback is bound to (the one that created the callback), and returns a deterministic result; `maybe_valid()` can be called from any thread, but the answer may already be stale. Our simplified version does not draw that distinction yet, but both method names are kept so they are ready for `RepeatingCallback` or cross-thread scenarios later. --- -## Step 4: Chain Composition — `then` +## Step 4: Chained composition with `then()` -### Semantics of `then` +### What `then()` actually does -`then` allows us to串联 two callbacks into a pipeline. The semantics are intuitive: when the pipeline is called, execute the first callback with the original arguments, then pass the return value to the second callback. For example, callback A calculates `x + 1`, callback B calculates `y * 2`,串联 with `then`, you get a new callback that automatically runs the entire A → B process when called. +`then()` threads two callbacks into one pipe. The semantics in one sentence: when the pipe is called, the first callback runs with the original arguments, its return value is handed to the second callback, and that one runs. For example, callback A computes `3 + 4 = 7`, callback B computes `7 * 2 = 14`, and `then()` glued together gives you a new callback that walks the whole A then B pipeline when you run it. -Sounds simple, but `then` is the one with the most ingenious ownership design among the four features. +Sounds simple. But `then()` is the one out of the four features where the ownership design demands the most thought. -### Ownership is key +### Ownership is the load-bearing wall -The new callback after串联 needs to hold the **ownership** of the original and subsequent callbacks—otherwise, the original callback might be consumed externally in advance, breaking the pipeline. And `once_callback` is move-only, which means `then` must consume the original callback (`this`) and the subsequent callback (`next`), transferring ownership of both to a new lambda closure. The entire ownership chain looks like this: +The new chained callback has to hold the ownership of both the original and the follow-up callback. Otherwise the original might get consumed somewhere outside, and the pipe snaps on the spot. The wrinkle is that `OnceCallback` is move-only, which means `then()` has to consume both `*this` (the original) and `next` (the follow-up), moving both into a fresh lambda closure. The whole ownership chain looks like this: ```mermaid graph LR - A[Original Callback] -->|move| B[Lambda Closure] - C[Subsequent Callback] -->|move| B - B -->|move| D[New Callback] + A["new callback"] --> B["move_only_function"] --> C["lambda closure"] --> D["original + follow-up"] ``` -The skeleton of the implementation idea looks like this: +The skeleton of the implementation looks roughly like this: ```cpp -template -auto then(once_callback&& next) && { - using Result = typename next_signature_traits::result_type; - - return once_callback( - [self = std::move(*this), next = std::move(next)](Args... args) mutable { - if constexpr (std::is_same_v) { - self(args...); // Execute original, discard return - next(); // Execute subsequent +template +auto then(Next&& next) && // the trailing && makes this an rvalue-qualified member function + -> OnceCallback +{ + return OnceCallback( + [self = std::move(*this), // move the entire original callback into the lambda + cont = std::forward(next)] // move the follow-up in too + (FuncArgs... args) mutable -> decltype(auto) { + if constexpr (std::is_void_v) { + std::move(self).run(std::forward(args)...); + return std::invoke(std::move(cont)); // void → nothing to pass along } else { - return next(self(args...)); // Pass result to next + auto mid = std::move(self).run(std::forward(args)...); + return std::invoke(std::move(cont), std::move(mid)); // hand the intermediate result on } } ); } ``` -Note an important difference from the Chromium original here: we use `std::move(next)` for the subsequent callback instead of `std::move(next)`. This is because `then`'s `next` parameter is a normal callable object (like a lambda), not a `once_callback`—the caller doesn't need to explicitly write `std::move(next)`, `then` calls it directly. Only the original callback (`this`) needs `std::move` to express consumption semantics. +One difference from the Chromium original deserves a callout. We invoke the follow-up with `std::invoke`, not `.run()`. The reason is that `then()`'s `next` parameter is an ordinary callable (a lambda, say), not a `OnceCallback`, so there is no point making the caller spell out `std::move(cont).run()`. `std::invoke` does the job in one stroke. Only the `self` step (the original callback) needs `std::move(...).run()` to express the consumption semantics. + +### A few traps I stepped on for you -### Several easy-to-fall traps +`then()` has a few spots where I ate dirt, so let me walk through them. -**First, `&&` ref-qualifier.** The `&&` at the end of the function declaration makes it an rvalue-qualified member function, callable only via `std::move(obj)` or a temporary object `obj.then(...)`. This is another way to express "consumption semantics"—unlike `operator()` using deducing this, `then` directly uses the traditional ref-qualifier. Why not deducing this? Because `then` doesn't need to distinguish lvalues and rvalues to give different error messages—it just accepts rvalues, no middle ground. +First, the trailing `&&`. It qualifies the member function as rvalue-only, callable via `std::move(cb).then(next)` or off a temporary `.then(next)`. This is the other route to expressing consumption semantics; unlike `run()`, which uses deducing this, `then()` walks the traditional ref-qualifier path. Why not deducing this here? Because `then()` has no call for different error messages on lvalue versus rvalue. It only eats rvalues, there is no middle ground, and a ref-qualifier is already clean enough. -**Second, `self = std::move(*this)`.** This line moves **all contents** of the current `once_callback` object into the lambda's closure object. After moving, the current object enters a consumed state (because we didn't set it to kEmpty, but let it naturally remain a "moved-from" state). The closure object is then stored in the `storage_` of the returned new `once_callback`—`once_callback`'s type erasure capability guarantees that no matter the lambda's actual type, it can be stored uniformly. +Second, the line `self = std::move(*this)`. It moves everything the current `OnceCallback` owns, in one lump, into the lambda's closure object. After the move, the current object is left in a consumed state (we do not reset it back to kEmpty, we just let it sit in its moved-out shape). That closure object then gets stuffed into the `move_only_function` of the returned new `OnceCallback`. Type erasure is what guarantees that no matter what type the lambda actually is, it can be stored uniformly. -**Third, the `mutable` keyword cannot be omitted.** The default `operator()` generated by a lambda is `const`—meaning the lambda cannot modify captured variables internally. But we need to call `operator()` on `self` inside the lambda, an operation that modifies the object state (changing status from kValid to kConsumed). So the lambda must be declared `mutable`, making `self` non-const. +Third, the `mutable` keyword is not optional. The `operator()` a lambda generates by default is `const`, which means the lambda is not allowed to mutate its captures inside the body. But we very much need to call `std::move(self).run()` on `self` inside the lambda, and that call mutates the object (flipping status from kValid to kConsumed). So the lambda has to be marked `mutable`, which turns `operator()` non-const. -**Fourth, `if constexpr`.** Just like the situation in `operator()`—when the original callback returns `void`, the semantics of `then` are "execute original, then execute subsequent (no parameter passing)." `if constexpr` selects branches at compile time, generating completely different code paths for the two cases. +Fourth, our old friend `if constexpr (std::is_void_v)`. Same reasoning as in `impl_run`. When the original callback returns `void`, the semantics of `then()` are "run the original, then run the follow-up, pass nothing in between". `if constexpr` picks the branch at compile time, and the two cases generate two fully separate code paths. -### Multi-level pipelines +### Multi-stage pipelines -`then` can be called in a chain, forming multi-level pipelines: +`then()` can keep chaining, stacking up a multi-stage pipe: ```cpp -auto pipeline = callback_a - .then(callback_b) - .then(callback_c); +using namespace tamcpp::chrome; +auto pipeline = OnceCallback([](int x) { + return x * 2; +}).then([](int x) { + return x + 10; +}).then([](int x) { + return std::to_string(x); +}); + +std::string result = std::move(pipeline).run(5); +// 5 * 2 = 10, 10 + 10 = 20, "20" ``` -Each `then` call creates a new `once_callback`, internally capturing the previous step's callback in a nested manner. The call order from outside to inside is recursively expanded: the outermost callback is invoked → execute its lambda → lambda calls `operator()` on the upper layer → then calls the upper layer → until the bottom. Performance-wise, each level of `then` adds one indirect call through `std::invoke`, which is completely acceptable for 2-3 level pipelines. If the pipeline is very deep (more than 10 levels), consider using `std::execution`'s `let_value` or similar to create a flattened pipeline structure to avoid nested closure overhead—but this is beyond our current discussion scope. - ---- - -## Summary - -In this post, we completed a design walkthrough of the four core functions of `once_callback`. Unlike the interface design of the first post, the focus here is on understanding "why write it this way" and "what the key template techniques are." Let's review a few core knowledge points: - -- **Template partial specialization** `once_callback` lets users specify callback types with natural function signature syntax, and the compiler unpacks the function type into return values and parameter packs via pattern matching -- **Deducing this** lets `operator()` implement compile-time lvalue/rvalue interception through a single function template, cleaner than Chromium's double overload + `sizeof` hack -- **`requires` constraint** (via `not_same_as_once_callback` concept) resolves the matching conflict between template constructors and move constructors, a standard defensive measure for move-only type erasure wrappers -- **Lambda capture pack expansion** is the key to the concise implementation of `Bind`, requiring tuple + `apply` workarounds before C++20 -- **Core challenge of `then`** is ownership management—it guarantees the integrity of the ownership chain of each callback in the pipeline through rvalue qualifiers + lambda capture move, using `std::invoke` to uniformly call subsequent callbacks +Each call to `then()` produces a new `OnceCallback` that nests a capture of the previous step. The call order unfolds recursively from the outside in: the outermost gets `run()`-ed, its lambda runs, the lambda calls `std::move(self).run()` on the layer above, which calls the one above that, all the way down. On performance, each `then()` layer adds one indirect call through `std::move_only_function`, which is perfectly survivable for a 2- or 3-stage pipe. If the depth really does climb past 10 stages, you could build a flattened pipeline with `std::variant` and dodge the nested-closure overhead, but that is well outside our scope here. -The next post will use systematic test cases to verify these designs and compare our performance trade-offs with the Chromium original. +The next post brings systematic test cases, walks through each of these design choices one by one, and compares the performance trade-offs we made against the Chromium original. -## Reference Resources +## References - [Chromium callback.h source](https://chromium.googlesource.com/chromium/src/+/HEAD/base/functional/callback.h) - [Chromium bind_internal.h source](https://chromium.googlesource.com/chromium/src/+/HEAD/base/functional/bind_internal.h) diff --git a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/03-once-callback-testing.md b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/03-once-callback-testing.md index bc735bbfc..6f8323664 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/03-once-callback-testing.md +++ b/documents/en/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/03-once-callback-testing.md @@ -2,259 +2,283 @@ chapter: 1 cpp_standard: - 23 -description: Design system test cases for `once_callback`, compare performance differences - against the original Chromium version and the standard library solution, and summarize - the design trade-offs. +description: "Design test cases for once_callback by invariant, compare its object size, allocation behavior, and call overhead against Chromium's original, and tally up what we traded away and what we got back." difficulty: advanced order: 3 platform: host prerequisites: -- once_callback 设计指南(一):动机与接口设计 -- once_callback 设计指南(二):逐步实现 +- 'once_callback design guide (I): motivation and API design' +- 'once_callback design guide (II): step-by-step implementation' reading_time_minutes: 12 related: -- 回调取消与组合模式 +- 'Callback cancellation and composition patterns' tags: - host - cpp-modern - advanced - 回调机制 - 函数对象 -title: 'once_callback Design Guide (Part 3): Testing Strategy and Performance Comparison' -translation: - source: documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/03-once-callback-testing.md - source_hash: c1310333e60d2b8f8eb9e67f4231def488f910d1cc9f2414bbfb4769048ff058 - translated_at: '2026-06-16T04:14:16.648804+00:00' - engine: anthropic - token_count: 2574 +title: 'once_callback Design Guide (III): Test Strategy and Performance Comparison' --- -# once_callback Design Guide (Part 3): Testing Strategy and Performance Comparison +# once_callback Design Guide (III): Test Strategy and Performance Comparison -## Introduction +At this point the `OnceCallback` interface and its implementation are both in place. But I'm not calling it done yet. Something like this, if you don't lean on it with tests, you don't trust it yourself. So in this piece we settle the test strategy and the performance bill in one go: is it actually correct, how far is it from Chromium's original, and do we accept the gap. -In the previous two parts, we completed the design and implementation of `once_callback`. In this part, we will do two things: first, systematically review the testing strategy and provide a complete list of test cases to ensure our implementation is correct under various boundary conditions; second, analyze the performance differences between our implementation, the original Chromium version, and the standard library approach, to understand what we sacrificed and what we gained. +## Slicing tests by invariant -> **Learning Objectives** -> -> - Master the design method for six categories of `once_callback` test cases -> - Understand the meaning of performance metrics such as object size, SBO threshold, and indirect call overhead -> - Clarify the trade-offs between our `once_callback` and Chromium's `OnceCallback` +How to organize the tests gave me pause at first. Grouping by feature leaks, because features are something you write for yourself: you test what you thought of while writing, so the blind spots are baked in. Switching to grouping by **invariant** felt much better. Each invariant is itself a sentence of the form "I promise this always holds," and the job of testing is to torture that sentence in every posture you can think of and see if it breaks. If it breaks, it's really wrong. If it doesn't, that whole class passes. ---- - -## Testing Strategy - -We organize tests into six categories, each focusing on a specific design invariant. Organizing tests by invariants rather than by functionality makes it less likely to miss edge cases—since each invariant is itself a correctness guarantee, the goal of testing is to verify that these guarantees hold in various scenarios. - -Our actual test code uses the Catch2 framework, managed with CMake + CPM. The test cases listed below correspond one-to-one with the actual code in `test/test_once_callback.cpp`. +The test code sits on top of Catch2, with dependencies pulled in by CMake + CPM. The cases listed below correspond one-to-one with the actual code in `code/volumn_codes/vol9/chrome_design/test/test_once_callback.cpp`. If you have that file in hand you can run them case by case. -### Category A: Basic Invocation and Return Values +### Category A: basic invocation and return value -This category verifies the basic construction and invocation behavior of `once_callback`. +The most basic thing: build a callback, run it, check the return value. ```cpp -// A1: Basic invocation and return value -auto cb = make_once_callback([]() { return 42; }); -REQUIRE(cb() == 42); - -// A2: void return type -bool invoked = false; -auto void_cb = make_once_callback([&invoked]() { invoked = true; }); -void_cb(); -REQUIRE(invoked); +TEST_CASE("non-void return", "[once_callback]") { + OnceCallback cb([](int a, int b) { return a + b; }); + int result = std::move(cb).run(3, 4); + REQUIRE(result == 7); +} + +TEST_CASE("void return", "[once_callback]") { + bool called = false; + OnceCallback cb([&called] { called = true; }); + std::move(cb).run(); + REQUIRE(called); +} ``` -The most basic scenario—construct a callback, invoke it, and verify the return value. The `void` return type exercises a different branch of `operator()`, confirming that our compile-time branching logic is correct. +The void return goes down the other branch of `if constexpr (std::is_void_v)`. These two cases are insurance on the compile-time branch logic. -### Category B: Move Semantics +### Category B: move semantics -This category verifies move-only constraints and the correctness of move operations. +This category watches two things: that the move-only constraint isn't faked open, and that moving doesn't lose state. ```cpp -// B1: Move-only capture -auto ptr = std::make_unique(99); -auto cb = make_once_callback([p = std::move(ptr)]() { return *p; }); -REQUIRE(cb() == 99); - -// B2: Move construction and state consumption -auto cb2 = make_once_callback([]() { return 42; }); -auto cb3 = std::move(cb2); -REQUIRE(!cb2); // Source is empty -REQUIRE(cb3); // Target is valid -REQUIRE(cb3() == 42); +TEST_CASE("move-only capture", "[once_callback]") { + auto ptr = std::make_unique(42); + OnceCallback cb([p = std::move(ptr)] { return *p; }); + int result = std::move(cb).run(); + REQUIRE(result == 42); +} + +TEST_CASE("move semantics: source becomes null", "[once_callback]") { + OnceCallback cb([] { return 1; }); + OnceCallback cb2 = std::move(cb); + REQUIRE(cb.is_null()); + + int result = std::move(cb2).run(); + REQUIRE(result == 1); +} ``` -The move-only capture test (`unique_ptr` captured into lambda) confirms that `once_callback` truly supports move-only callables—if the underlying implementation used `std::function` instead of `std::move_only_function`, this code would fail to compile. The move semantics test verifies that after move construction, the source object becomes an empty state (checked via `operator bool`), and the target object remains valid and can be invoked normally. +The move-only capture case stuffs `std::make_unique(42)` into the lambda. If the underlying storage quietly fell back to `std::function` instead of `std::move_only_function`, this wouldn't even compile, so the case doubles as a backstop on "did we actually use move-only." The move semantics case verifies that after move construction the source object falls back to `kEmpty` and `is_null()` reports true, while the target still runs normally. -There is a conceptual point that is easily confused—move operations transfer ownership but do not trigger consumption. Only `operator()` consumes the callback. This distinction is also important in Chromium: `std::move(callback)` simply transfers ownership; the callback remains active until the task is actually executed. +There's a point I burned half a day circling around, and it's worth pulling out: moving only transfers ownership, it does **not** consume. The thing that actually consumes the callback is `run()`. The two look like they both "moved cb," but the semantics are completely different animals. Chromium runs the same rule on its side. `PostTask(FROM_HERE, std::move(cb))` only moves ownership into the task queue; the callback stays alive right up until it actually executes. -### Category C: Single-Invocation Constraint +### Category C: the single-call constraint -This category verifies the core semantic of "invoke once to consume". In Category A and B tests, we covered the normal invocation path. Category C focuses on compile-time interception of lvalue invocation. This constraint is implemented via deducing this + `delete`—if you write `cb()` instead of `std::move(cb)()`, the compiler will directly report an error, explicitly telling the caller to use `std::move`. This part does not require runtime testing; compilation success is the verification itself. +Categories A and B have already walked the normal call path. Category C stares at one thing: lvalue invocation must fail to compile. We pin this constraint onto the signature with deducing this + `static_assert`, so it doesn't belong to runtime at all. If your hand slips and you write `cb.run()` instead of `std::move(cb).run()`, the compiler stops you on the spot and folds "you need std::move" into the error message. Compiles = verified, no run needed. -### Category D: Argument Binding +### Category D: argument binding ```cpp -// D1: Partial argument binding -auto cb1 = make_once_callback([](int x, int y) { return x + y; }, 10); -REQUIRE(cb1(5) == 15); - -// D2: Member function binding -struct Adder { - int add(int x, int y) const { return x + y; } -}; -Adder adder; -auto cb2 = make_once_callback(&Adder::add, &adder); -REQUIRE(cb2(3, 4) == 7); +TEST_CASE("bind_once basic", "[bind_once]") { + auto bound = bind_once([](int a, int b) { return a * b; }, 5); + int result = std::move(bound).run(8); + REQUIRE(result == 40); +} + +TEST_CASE("bind_once with member function", "[bind_once]") { + struct Calc { + int multiply(int a, int b) { return a * b; } + }; + Calc calc; + auto bound = bind_once(&Calc::multiply, &calc, 5); + int result = std::move(bound).run(8); + REQUIRE(result == 40); +} ``` -`bind_front` tests cover two typical scenarios: partial argument binding for normal lambdas and member function binding. The member function binding test deserves attention—`&Adder::add` is a member function pointer, `&adder` is an object pointer, and `bind_front` internally expands it into a `(adder.*add)(x, y)` call. Note a lifetime trap here: `&adder` is a raw pointer, and `bind_front` does not manage its lifetime. If `adder` is destroyed before the callback is invoked, `bind_front` will access freed memory through a dangling pointer. Chromium uses `raw_ptr` to explicitly mark raw pointer safety, uses `std::unique_ptr` to take ownership, and uses `WeakPtr` to automatically cancel the callback when the object is destroyed. In our simplified version, this safety responsibility is temporarily handed to the caller. +`bind_once` wades through two typical scenarios: partial argument binding for an ordinary lambda, and member function binding. The member function one needs a few extra words. `&Calc::multiply` is a member function pointer, `&calc` is an object pointer, and `std::invoke` underneath unfolds it into `(calc.*multiply)(5, 8)`. The trap is here: `&calc` is a raw pointer, and `bind_once` does not manage its life or death. If `calc` destructs before the callback actually runs, `std::invoke` will follow the dangling pointer and grope through already-freed memory. Chromium keeps three layers of insurance here: `base::Unretained` explicitly declares "this pointer is safe at your own risk," `base::Owned` takes over ownership outright, and `base::WeakPtr` lets the callback auto-cancel when the object destructs. Our simplified version temporarily shoves that responsibility onto the caller, and we'll come back to collect it in the piece on cancellation tokens. -### Category E: Cancellation Mechanism +### Category E: cancellation ```cpp -// E1: Token is valid, no cancellation -auto token = std::make_shared(42); -auto cb = make_once_callback([token]() { return *token; }); -REQUIRE(cb() == 42); - -// E2: Token invalidated, void callback does nothing -auto weak_token = std::weak_ptr(token); -auto void_cb = make_once_callback([weak_token]() { /* no-op */ }); -token.reset(); -REQUIRE_NOTHROW(std::move(void_cb)()); - -// E3: Token invalidated, non-void callback throws -auto int_cb = make_once_callback([weak_token]() { return *weak_token.lock(); }); -token.reset(); -REQUIRE_THROWS_AS(std::move(int_cb)(), std::bad_function_call); +TEST_CASE("is_cancelled respects cancel token", "[once_callback]") { + auto token = std::make_shared(); + OnceCallback cb([] {}); + cb.set_token(token); + + REQUIRE_FALSE(cb.is_cancelled()); + token->invalidate(); + REQUIRE(cb.is_cancelled()); +} + +TEST_CASE("cancelled void callback does not execute", "[once_callback]") { + auto token = std::make_shared(); + bool called = false; + OnceCallback cb([&called] { called = true; }); + cb.set_token(token); + token->invalidate(); + + std::move(cb).run(); + REQUIRE_FALSE(called); +} + +TEST_CASE("cancelled non-void callback throws", "[once_callback]") { + auto token = std::make_shared(); + OnceCallback cb([] { return 1; }); + cb.set_token(token); + token->invalidate(); + + REQUIRE_THROWS_AS(std::move(cb).run(), std::bad_function_call); +} ``` -Cancellation tests cover three key behaviors: no cancellation when the token is valid; void callback does not execute after the token is invalidated; non-void callback throws `std::bad_function_call` after the token is invalidated. The behavior of the third test is worth expanding on—our implementation throws an exception in a cancelled non-void callback because the caller expects a return value, but we cannot provide a meaningful one, so throwing is safer than returning an undefined value. Chromium's implementation would terminate the program directly (`CHECK` failure) here; we chose exceptions because they are easier to catch and verify in tests. +The cancellation category presses on three actions: no cancellation while the token is alive; a void callback honestly does not execute after the token is invalidated; a non-void callback throws `std::bad_function_call` after the token is invalidated. The third one needs a pause to explain. We choose to throw on a cancelled non-void callback because, from the caller's perspective, it wants a return value, but in the cancelled state we have no "meaningful value" on hand to give. Hand back a default value to fool it? That's sneakier than throwing. The bug would travel onward through that fake value. Chromium plays this one harder: it fails a `CHECK` outright and detonates the program. We pick the exception purely because it's easy to catch and easy to verify inside tests. This is a teaching-version tradeoff, not a design that's objectively better. -### Category F: Then Composition +### Category F: then composition ```cpp -// F1: Two-level non-void pipeline -auto cb = make_once_callback([]() { return 42; }) - .then([](int x) { return x * 2; }); -REQUIRE(cb() == 84); - -// F2: Multi-level pipeline (crossing type boundaries) -auto pipeline = make_once_callback([]() { return 3.14; }) - .then([](double d) { return static_cast(d); }) - .then([](int i) { return std::to_string(i); }); -REQUIRE(pipeline() == "3"); - -// F3: Void prefix callback -int value = 0; -auto void_pipe = make_once_callback([&value]() { value = 10; }) - .then([&value]() { return value + 5; }); -REQUIRE(void_pipe() == 15); +TEST_CASE("then chains two callbacks", "[then]") { + auto cb = OnceCallback([](int x) { return x * 2; }) + .then([](int x) { return x + 10; }); + int result = std::move(cb).run(5); + REQUIRE(result == 20); // 5 * 2 + 10 +} + +TEST_CASE("then multi-level pipeline", "[then]") { + auto pipeline = OnceCallback([](int x) { return x * 2; }) + .then([](int x) { return x + 10; }) + .then([](int x) { return std::to_string(x); }); + std::string result = std::move(pipeline).run(5); + REQUIRE(result == "20"); // (5*2)+10 = "20" +} + +TEST_CASE("then with void first callback", "[then]") { + int value = 0; + auto cb = OnceCallback([&value](int x) { value = x; }) + .then([&value] { return value * 3; }); + int result = std::move(cb).run(7); + REQUIRE(result == 21); +} ``` -`then` tests cover three composition patterns: two-level non-void pipeline, multi-level pipeline (crossing type boundaries—from `double` to `int` to `string`), and void prefix callback. The multi-level pipeline test is particularly interesting—`3.14` is converted to integer `3`, which is finally converted to string `"3"`. This test verifies that `then` correctly deduces the return type at each level and that type erasure (via `std::move_only_function`) works correctly between different types of lambdas. The void prefix test verifies the `void` branch—the first callback sets `value`, and the second callback reads `value` by reference and returns `value + 5`. +The three cases in the `then()` category each press on one posture: a two-level non-void pipeline, a multi-level pipeline across types, and a void-prefix callback. The multi-level pipeline case is, I think, the most telling. The number `(5*2)+10 = 20` finally gets folded into the string `"20"` by `std::to_string`. Along the way `then()` deduces the return type of each level correctly, and the type erasure done by `std::move_only_function` across several completely different lambda types doesn't collapse either. The void-prefix case specifically presses on the `if constexpr (std::is_void_v)` branch: the first callback writes 7 into the external `value`, the second reads `value` out through the reference and multiplies by 3 to get 21. -### Test Framework and Build Configuration +### Test framework and build configuration -We use Catch2 v3 as our test framework, automatically pulling dependencies via CPM (CMake Package Manager). The CMake configuration for tests is very concise: +The test framework we picked is Catch2 v3, with dependencies auto-pulled by CPM (CMake Package Manager). The CMake config is worry-free: ```cmake -cmake_minimum_required(VERSION 3.20) -project(once_callback_tests LANGUAGES CXX) - -set(CMAKE_CXX_STANDARD 23) -set(CMAKE_CXX_STANDARD_REQUIRED ON) - -find_package(Catch2 3 REQUIRED) -add_executable(test_once_callback test/test_once_callback.cpp) -target_link_libraries(test_once_callback PRIVATE Catch2::Catch2WithMain) -enable_testing() -include(CTest) -add_test(NAME all_tests COMMAND test_once_callback) +# test/CMakeLists.txt +CPMAddPackage("gh:catchorg/Catch2@3.7.1") + +add_executable(test_once_callback test_once_callback.cpp) +target_link_libraries(test_once_callback PRIVATE once_callback Catch2::Catch2WithMain) +target_compile_options(test_once_callback PRIVATE -Wall -Wextra -Wpedantic) + +add_test(NAME test_once_callback COMMAND test_once_callback) ``` -Catch2's `REQUIRE` macro is superior to `assert` because it reports the specific failed expression, file, and line number, and continues executing subsequent checks within the same `SECTION` (instead of terminating the program like `assert`). `REQUIRE_THROWS_AS` is specifically used to verify exception types—in the cancellation mechanism tests, we need to confirm that the cancelled non-void callback throws `std::bad_function_call`, not some other exception. +I use `REQUIRE` instead of `assert` for a very practical reason. `REQUIRE` spits out the failed expression, file, and line number on error, and subsequent assertions in the same `TEST_CASE` keep running. `assert` kills the whole program the moment it fires, so you only see one error at a time. `REQUIRE_THROWS_AS` is specifically for pressing on exception type. The cancellation test leans on it to confirm what gets thrown is `std::bad_function_call`, not something else. -Running the tests is simple—under the project root directory, run `cmake --build build --target test_once_callback`. +The way to run the tests is one line: under the `build/` directory, `cmake --build . && ctest`. --- -## Performance Considerations: Comparison with Chromium Original +## The performance bill: lining up against Chromium's original -### Object Size +### Object size -This is the most intuitive difference. We use a simple program to measure: +The most visible difference is in sizeof. We write a minimal program to measure: ```cpp -#include "once_callback.hpp" +#include #include +#include "once_callback/once_callback.hpp" int main() { - std::cout << "sizeof(once_callback): " - << sizeof(once_callback) << '\n'; - std::cout << "sizeof(std::move_only_function): " - << sizeof(std::move_only_function) << '\n'; - return 0; + std::cout << "sizeof(std::function): " + << sizeof(std::function) << " bytes\n"; + std::cout << "sizeof(std::move_only_function): " + << sizeof(std::move_only_function) << " bytes\n"; + // Chromium OnceCallback ≈ 8 bytes (one pointer) + + using namespace tamcpp::chrome; + std::cout << "sizeof(OnceCallback): " + << sizeof(OnceCallback) << " bytes\n"; + // Our OnceCallback is roughly: + // move_only_function (32) + status (1) + token ptr (16) + padding + // estimated 56-64 bytes } ``` -On GCC, typical values are: `std::move_only_function` is about 32 bytes, `std::function` is about 32 bytes, and our `once_callback` plus the `State` enum and optional `vtable` pointer is about 56-64 bytes. Chromium's `OnceCallback` is only 8 bytes—a pointer to an internal `CallbackBase`. +Running this on GCC gives you roughly this set of numbers: `std::function` about 32 bytes, `std::move_only_function` about 32 bytes, and our `OnceCallback` once you add in the `Status` enum and the optional `CancelableToken` pointer, about 56-64 bytes. Chromium's `OnceCallback` is just 8 bytes. One `scoped_refptr` pointing at a `BindState`, and that's it. -The root of the difference lies in storage strategy. Chromium places all state (callable object + bound arguments) in a heap-allocated `CallbackBase`, and the callback object itself holds only a pointer. We use SBO (Small Buffer Optimization) via `std::move_only_function` to store small objects directly inside the callback object, avoiding heap allocation but increasing object size. +Where does the gap come from? At the root, it's the storage strategy. Chromium jams everything, callable object and bound arguments alike, into a heap-allocated `BindState`, and the callback object itself holds only one pointer. Our version relies on `std::move_only_function`'s SBO to inline small objects directly into the callback object. We save the heap allocation, but the price is that the object body is a notch chubbier. -### Allocation Behavior +### Allocation behavior -The SBO threshold of `std::move_only_function` is implementation-defined, usually 2-3 pointer sizes (16-24 bytes). Lambdas capturing few parameters (like `[x] { return x; }` or `[p = std::unique_ptr()] {}`) usually fit in SBO and do not trigger heap allocation. However, if a lambda captures a large amount of data (like a `std::string` + several `std::vector`s), it will heap allocate upon construction. +The SBO threshold of `std::move_only_function` is implementation-defined, typically around 2-3 pointers (16-24 bytes). A very light-capturing lambda, like `[x = 42]` or `[&ref]`, generally fits inside SBO and doesn't trigger a heap allocation. If the lambda drags in a load of data, say a `std::string` plus a few `int`s, then construction has to fork over one more heap allocation. -Chromium's approach always heap allocates (`new`), but allocation only happens once—during construction. Subsequent move operations of `OnceCallback` just copy a pointer (8 bytes), which is extremely cheap. Our approach allocates nothing for small objects (SBO), but move operations need to copy the entire `std::move_only_function` (32 bytes) plus the `vtable` pointer, which is slightly more expensive. +Chromium's setup is a fixed heap allocation. `new BindState` always runs once, but **only once**, and it happens at the `BindOnce` moment. After that, moving a `OnceCallback` is just copying an 8-byte pointer, extremely light. Our version doesn't allocate for small objects (SBO catches them), but once a move is needed, the whole `std::move_only_function` (32 bytes) plus the `token_` pointer has to be carried along, clearly a notch more expensive. -Both strategies have their advantages in different scenarios. For high-frequency delivery of small callbacks (Chrome browser's main scenario), Chromium's approach is better—low move cost and consistent size benefit CPU cache. For low-frequency large callbacks (like one-time initialization tasks), our approach is better—saving one heap allocation. +Neither strategy sweeps the board. For high-frequency delivery of small callbacks (the browser is Chrome's main battleground), Chromium's setup wins. Moves are cheap, sizes are uniform and CPU-cache friendly. For low-frequency large callbacks (like one-shot initialization tasks), our setup is the better deal, one less heap allocation. Which one to pick depends on your project's frequency distribution. -### Indirect Call Overhead +### Indirect call overhead -The call overhead for both approaches is the same: one indirect function call. `std::move_only_function` internally dispatches to the specific callable object via a function pointer or virtual function table; Chromium's `OnceCallback` also uses function pointer dispatch. Under `-O2` optimization, this indirect call cannot be inlined away, so performance is equivalent for both. +Call overhead is flat between the two paths: both are one indirect call. `std::move_only_function::operator()` dispatches to the concrete callable object through a function pointer or vtable underneath; Chromium's `BindState::polymorphic_invoke_` is also function pointer dispatch. Under `-O2` the compiler can't inline away this layer of indirection, so the two are equivalent on the call link. -### What We Sacrificed and What We Gained +### What we gave up and what we got back -Let's summarize the trade-offs. +Let's settle the bill. -We sacrificed object compactness (56-64 bytes vs 8 bytes) in exchange for implementation simplicity—no need to manually write reference counting, function pointer tables, or `raw_ptr` annotations. We sacrificed extreme move performance (copying 32 bytes + pointer vs copying 8 bytes) in exchange for zero heap allocation for small objects. We sacrificed reference-counted sharing (unable to share a single `CallbackBase` among multiple callbacks), but `once_callback` is inherently exclusive semantics and does not need sharing. +What we gave out is object compactness (56-64 bytes versus 8 bytes). What we got back is a clean implementation. No need to hand-roll reference counting, function pointer tables, or `TRIVIAL_ABI` annotations. The move side also paid a price (moving 32 bytes + a pointer versus copying 8 bytes), and what we got back is zero heap allocation for small objects. We also gave up reference-counted sharing: there's no way to let multiple callbacks share one `BindState`. But `OnceCallback` is exclusive semantics to begin with, and sharing is something it has no use for anyway. -These trade-offs are reasonable for educational purposes and most practical scenarios. If your project truly requires Chromium-level extreme performance, you can refer to Chromium's source code for further optimization—the core ideas have been explained in these three design guide parts. +This set of tradeoffs holds up in a teaching scenario, and in the vast majority of real projects. If your project really presses to Chromium's level of performance requirements, you can go straight to Chromium's source and squeeze out one more layer. The core idea has been laid out across the previous three pieces; what's left is engineering detail. --- -## Complete Component File Overview +## Where the files live -At this point, the design, implementation, and testing strategy for the `once_callback` component are complete. The complete file list: +The design, implementation, and testing of the `OnceCallback` group close out here. The full file list is below; if you want to find the corresponding code, just follow the map. ```text -include/once_callback.hpp # Core implementation -test/test_once_callback.cpp # Catch2 test suite +documents/vol9-open-source-project-learn/chrome/hands_on/ +├── 01-once-callback-design.md # Design: motivation and API +├── 02-once-callback-implementation.md # Implementation: step by step +└── 03-once-callback-testing.md # Verification: tests and performance ``` -The corresponding compilable code (header + tests) is located in the project code directory: +The corresponding compilable code (header + tests) lives under the project code directory: ```text -project/ -├── include/ -│ └── once_callback.hpp +code/volumn_codes/vol9/chrome_design/ +├── CMakeLists.txt +├── cmake/CPM.cmake +├── cancel_token/ +│ └── cancel_token.hpp # cancellation token +├── once_callback/ +│ ├── CMakeLists.txt +│ ├── once_callback.hpp # main interface (template declarations) +│ └── once_callback_impl.hpp # implementation (template definitions) └── test/ - └── test_once_callback.cpp + ├── CMakeLists.txt # Catch2 test configuration + └── test_once_callback.cpp # full test cases ``` --- -## Summary - -In this verification part, we did two things. Regarding testing, we designed 12 Catch2 test cases around six invariants (basic invocation, move semantics, single invocation, argument binding, cancellation mechanism, and chaining), covering all core behaviors of `once_callback`. Regarding performance, we compared the differences with Chromium's `OnceCallback` in object size, allocation behavior, and call overhead—our implementation traded compactness for simplicity, which is worth it for the vast majority of scenarios. +This testing piece revolved around six invariants: basic invocation, move semantics, single invocation, argument binding, cancellation, and chaining. Out of them came 12 Catch2 cases that pin down the core behavior of `OnceCallback` pretty thoroughly. On the performance side, lined up against Chromium's original, the bills for size, allocation, and calls are all laid out. We traded compactness for simplicity, and that deal is worth it in the vast majority of scenarios. If you really want to squeeze to Chromium's order of magnitude, going back to chew on the source is fine too. -Next steps to try: implement `shared_callback` (a copyable, repeatable version), add `weak_from_this` / `shared_from_this` / `observe()` lifetime helpers to `once_callback`, or use Google Benchmark for precise performance measurement. +The `OnceCallback` group wraps up here. What comes next is `RepeatingCallback` (the copyable, repeatedly-callable version), and bolting the `Unretained` / `Owned` / `WeakPtr` lifetime helpers onto `bind_once`. That last one happens to be the entry point for the next topic, `WeakPtr`. -## Reference Resources +## References -- [Chromium base/functional/ Source Directory](https://source.chromium.org/chromium/chromium/src/+/main:base/functional/) +- [Chromium base/functional/ source directory](https://source.chromium.org/chromium/chromium/src/+/main:base/functional/) - [cppreference: std::move_only_function](https://en.cppreference.com/w/cpp/utility/functional/move_only_function) -- [Google Test Documentation](https://google.github.io/googletest/) -- [Google Benchmark Documentation](https://github.com/google/benchmark) +- [Google Test documentation](https://google.github.io/googletest/) +- [Google Benchmark documentation](https://github.com/google/benchmark) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-1-weak-ptr-motivation-and-api-design.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-1-weak-ptr-motivation-and-api-design.md new file mode 100644 index 000000000..aec1483b6 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-1-weak-ptr-motivation-and-api-design.md @@ -0,0 +1,269 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "Start from a real dangling-callback bug, pick up the loose end from 01-4's cancellation token, and pin down the hole WeakPtr has to fill and the full target API." +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- 'WeakPtr prerequisite (0): weak references and the lifetime puzzle' +- 'OnceCallback hands-on (IV): the cancellation token' +- 'WeakPtr prerequisite (I): intrusive refcount and scoped_refptr' +reading_time_minutes: 12 +related: +- 'weak_ptr design guide (II): core skeleton and the control block' +- 'WeakPtr prerequisite (I): intrusive refcount and scoped_refptr' +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- 内存管理 +- weak_ptr +title: "weak_ptr Design Guide (I): Motivation and API Design" +--- +# weak_ptr Design Guide (I): Motivation and API Design + +Back in 01-4, when we hand-rolled the cancellation token, I took a shortcut: slap an atomic flag on the callback, 0 while the object is alive, flip it to 1 right before the destructor, and `if`-check it before the callback runs. The dangling problem was papered over. But there was a loose end I never felt good about: who actually owns that flag, and how does the callback get hold of it? + +Sit with how awkward this is for a second. The flag is created by object A, and the callback runs somewhere else entirely. For the callback to see the flag, you have to hand it a pointer to the flag. Pass a raw pointer and, the moment A destructs, the flag goes with it, so the callback is holding a dangling pointer and we've looped right back to the start. Pass a `shared_ptr`? Now the flag never destructs. But we're only managing the flag, not A itself, so the problem isn't solved at all. We've just moved the hole. + +This awkwardness is, in fact, the concrete avatar of the classic C++ weak-reference problem. Chromium gives it a fairly hardcore answer inside `base`, and it's called `WeakPtr`. In this piece we'll sort out the motivation, pin down the hole it has to fill, and fix the target API as we go. Interface before implementation; the code lands in the next few pieces. + +--- + +## Starting from a bug + +### The scenario: posting an async task + +Say we have a `Controller` that posts tasks to a thread pool, and when a task finishes it comes back to update its own state. + +```cpp +class Controller { +public: + void start_work(ThreadPool& pool) { + // Post a task to the pool; on completion, call back into on_work_done + pool.post([this] { this->on_work_done(); }); + } + void on_work_done() { /* update state */ ++work_count_; } +private: + int work_count_ = 0; +}; +``` + +This code passes nine and a half times out of ten. But the moment `Controller` gets destructed before the task is reached (the user switched pages, the owning window closed), the task system is still clutching a copy of `this` inside that callback. When the task finally runs, `on_work_done` dereferences an already-destructed object. Segfault. + +### This is the problem we already met in 01-4 + +It's the same thing as the hand-rolled cancellation token back then: hang a flag on the Controller, flip it to 1 before the destructor runs, and check the flag before the callback fires. Identical idea. + +What I did back then was cut a corner: the flag's lifetime was glued together by "callback holds `shared_ptr`, Controller holds the raw flag," because the teaching version only wanted to make the "check" step clear. This time we have to do it properly. The real move is to fuse "the flag" and "a weak reference to the object" into one thing. What the callback receives is not a lonely little flag but a `WeakPtr`: it can tell the callback whether the Controller is dead, and when it isn't, it can call the Controller's methods directly. + +That is what `WeakPtr` is for. + +--- + +## Why three obvious solutions all fall short + +Before building a wheel of our own, let's close off the three ready-made roads, so you see why Chromium insisted on building its own. + +The raw `this` pointer is the code above: the object destructs, the callback dangles, UAF, nothing more to say. + +`shared_ptr` doesn't work either. It forces you to rewrite Controller as shared ownership, where a previously clean single owner becomes "theoretically anyone can hold one." Worse, as long as the callback is clutching that `shared_ptr`, the Controller **never destructs**. The task is still queuing, so the Controller just refuses to leave, and you get a resource leak on top of an out-of-control lifetime. This isn't fixing the bug, it's trading it for a different one. + +And `std::weak_ptr`? It needs a `shared_ptr` to exist first, so it loops right back to the previous road. Every access has to `lock()` to grab a temporary `shared_ptr`, which is both noisy and an extra atomic-op tax on a hot path like a callback. We covered its abstraction limits systematically in [prerequisite (0)](./pre-00-weak-ptr-weak-reference-and-lifetime.md); here, code speaks for itself. + +Three roads: either unsafe, or polluting ownership, or tied to the cost of being non-intrusive. What Chromium wants is plain: let Controller keep its original single-owner model, and hand the callback a weak reference that "doesn't extend lifetime, can check liveness, and can be invalidated in bulk." + +--- + +## Chromium's answer: the WeakPtr design philosophy + +Chromium's `WeakPtr` is not one isolated little class. It's a four-layer structure. Let's look from the bottom up. This layering is the same trick as the `BindState` we covered in [OnceCallback (I)](../../01_once_callback/full/01-1-once-callback-motivation-and-api-design.md): the bottom does type erasure and refcounting, the top hands the user a lightweight handle. Learn one and the other is basically free. + +### The four-layer architecture + +```mermaid +flowchart TB + subgraph FAC["WeakPtrFactory<T> — created by the user, manages batch invalidation"] + RO["WeakReferenceOwner
the flag's 'issuer'"] + end + subgraph WP["WeakPtr<T> — the handle the user passes / dereferences"] + WR["WeakReference — the reference side, + points at the same flag"] + Flag["Flag — refcounted + + atomic state"] + WR --> Flag + end + RO -. "issue / invalidate" .-> Flag +``` + +At the very bottom is `Flag`, an internal class the user never touches. It's the "is the object dead yet" state, carrying `RefCountedThreadSafe`, shared by the issuer and every reference holder, with a single atomic flag bit inside. One layer up is `WeakReference`, a reference wrapper around `Flag` that holds a `scoped_refptr`. That's the "weak reference" entity inside WeakPtr. Above that is `WeakPtr`, the handle the user actually manipulates, holding a `WeakReference` plus a `T*`. Its size is two pointers, and it's marked `TRIVIAL_ABI` so it can ride in registers. At the very top sits `WeakPtrFactory`, hanging off the observed object as the "mint." Call `GetWeakPtr()` to mint a fresh WeakPtr; call `InvalidateWeakPtrs()` to void every minted one in a single shot. + +There's a design choice here I find genuinely pretty: **every WeakPtr minted from the same factory shares one and the same Flag**. So "the object destructs, call `InvalidateWeakPtrs()` once, and every WeakPtr drops together" is almost free. This is precisely the "one invalidate, a whole batch" that `std::weak_ptr` cannot do. + +### Why this shape + +Look back at that knot from 01-4: how does the flag get passed around? Chromium's answer is this whole structure. Flag manages its own life through refcounting: as long as some WeakPtr is holding it, Flag stays alive. The object Flag points at destructs whenever it destructs; Flag doesn't get in the way. Two lifetimes, fully separated. + +The Controller's life is decided by its owner, and it has nothing to do with WeakPtr. Flag's life is managed by refcounting, from the first WeakPtr minted to the last one destroyed. And the "is the Controller dead" state lives inside Flag, with the factory calling `Invalidate` once to flip the bit, right before the Controller's destructor runs. + +This is "doesn't介入 ownership + can check liveness," made concrete. The four requirements we laid out in [prerequisite (0)](./pre-00-weak-ptr-weak-reference-and-lifetime.md) get eaten one by one by this four-layer structure. + +--- + +## Pinning down the target API + +Now let's fix the target API. This is how engineers work: figure out "what I want" first, then come back and argue every decision. Naming follows the project's `tamcpp::chrome` namespace, snake_case style, consistent with the OnceCallback series. + +### The weak pointer: WeakPtr\ + +```cpp +#include "weak_ptr/weak_ptr.hpp" +using namespace tamcpp::chrome; + +// Minted from a factory (see below) +WeakPtr wp = factory.get_weak_ptr(); + +// Liveness check + dereference +if (wp) { + wp->on_work_done(); // operator-> : valid → normal call +} + +// After invalidation +wp->on_work_done(); // operator-> : object dead → CHECK fails, abort +wp.get(); // get() : object dead → returns nullptr, no crash + +// Reset +wp.reset(); // let go; after this, wp == nullptr +``` + +### The factory: WeakPtrFactory\ + +```cpp +class Controller { +public: + void start_work(ThreadPool& pool); + void on_work_done(); + // Expose the minting interface to the outside (the factory itself stays private) + WeakPtr get_weak() { return weak_factory_.get_weak_ptr(); } + ~Controller() = default; +private: + int work_count_ = 0; + // Key point: the factory is the last member, and it stays private (02-3 explains why) + WeakPtrFactory weak_factory_{this}; +}; + +// Mint from elsewhere (through a public interface, not by poking the private factory) +WeakPtr wp = controller.get_weak(); +``` + +> Active invalidation (`invalidate_weak_ptrs()`) and querying (`has_weak_ptrs()`) are the factory's own methods, usually forwarded through the Controller's public methods. The object itself decides when to void all its observers, rather than letting external code poke `weak_factory_` directly. Otherwise one slip in outside code calls invalidate, and every observer goes blind at once. 02-3 expands on this encapsulation. + +### Integration with callbacks (preview of 02-5) + +This step is the "eye" of the whole series. The real Chromium idiom isn't `if (wp) wp->...`. You bind the WeakPtr straight into the callback, and the callback becomes a no-op **automatically** once the object dies: + +```cpp +// This task silently drops itself once controller dies; no dangling dereference +pool.post(bind_once(&Controller::on_work_done, controller.weak_factory_.get_weak_ptr())); +``` + +That's where the hand-rolled cancellation token from 01-4 finally plugs into a real callback system. In 02-5 we'll tear the mechanism down to the assembly level. + +--- + +## Walking through the API decisions + +The API is fixed, but every signature hides a decision. Let's lay out the "why" point by point. Every conclusion in this section maps to a comment or a line of implementation in the Chromium source, and the implementation pieces will cash each one in. + +### Why `get()` returns a raw pointer, while `operator*`/`operator->` use CHECK + +`WeakPtr` deliberately keeps "checked" and "unchecked" dereference far apart. + +`get()` returns a `T*`: the live address when the object is alive, `nullptr` when it's dead, **no crash**, and the judgment is yours. `operator*` and `operator->` are meaner: dereference after death and they fire a `CHECK`, halting the program. That holds in release builds too, not just the debug-only DCHECK. + +Why so harsh? Because dereferencing an invalidated WeakPtr is a **definite logic error**. You could `if (wp)` first, or call `get()` to check liveness. If you skip that and dereference anyway, the code is simply wrong. A bug like this has to blow up immediately in release, not limp along with a dangling pointer producing a pile of inexplicable symptoms later. Chromium's source comment writes this out as an explicit contract (`weak_ptr.h:240-252`). + +As for `get()`, it's the escape hatch for "I'll check liveness myself," returning a raw pointer without doing the check for you. It's what you reach for when you have to feed a liveness-checked pointer into older code that doesn't take a WeakPtr. + +### Why no `operator==` and no `operator<=>` + +You might find it odd. Smart pointers usually compare on address; why doesn't WeakPtr even give you `==`? Chromium wrote a dedicated comment explaining this (`weak_ptr.h:196-201`): + +> WeakPtr deliberately does not implement `operator==` or `operator<=>`, because weak-reference comparison is inherently unstable. + +Two reasons. If the comparison factored in validity, two WeakPtrs could be equal this instant, and a second later one is invalidated while the other isn't. The result keeps shifting, and there's no way to use it for sorting or as a key. And if the comparison only looked at the underlying pointer value, it gets worse: after the object destructs, that address might get reused by a brand-new object, and two completely unrelated WeakPtrs compare "equal" because of an address collision. That's a sneakier bug. + +So WeakPtr only compares against `nullptr`, i.e. the `if (wp)` liveness check. Every other comparison is simply not provided, shutting the misuse down at the type level. + +### Why WeakPtrFactory is composition, not inheritance + +There are actually two ways to get a WeakPtr in Chromium. + +The mainstream one is composition: the object holds a `WeakPtrFactory weak_factory_{this}` as a member. It's controllable, flexible, and even works for non-class types, like `WeakPtrFactory`. Historically there was also an inheritance flavor: Chromium used to ship `SupportsWeakPtr`, where T inheriting from it would automatically get `GetWeakPtr()`. That form **has been removed from Chromium**, because it encouraged unsafe usage, and current `//base` only keeps the composition form. I bring this up so you don't get confused reading old code; do not use it in new code. + +This series only implements the composition form, because it's the mainstream idiom Chromium recommends, and it's what carries the famous "last member" convention. 02-3 unpacks that. The inheritance form is really just syntactic sugar over it; once you understand composition, the other comes for free. + +### Why the factory has to be the last member + +02-3 will prove this carefully using destruction order. For now, just the conclusion: `WeakPtrFactory weak_factory_{this}` must be declared after every other member. The reason is that C++ destroys members in the reverse of declaration order. Put the factory last and it destructs first, so by the time any other member starts destructing, every WeakPtr is already invalidated. Flip it around: if the factory came first, some member would destruct while WeakPtrs were still valid, and anyone dereferencing one gets a half-destroyed object. This idiom is the watershed between WeakPtr used right and WeakPtr used wrong, and we dedicate 02-3 to it. + +--- + +## Our implementation vs. Chromium's tradeoffs + +Like the OnceCallback series, our teaching version keeps the core mechanism (the Flag, WeakReference, WeakPtr, WeakPtrFactory four layers) but simplifies a few things. Let's preview the tradeoffs; 02-6 closes it out with measured comparisons. + +| Dimension | Chromium | Our teaching version | +|---|---|---| +| Flag's refcount | `RefCountedThreadSafe` (atomic, cross-sequence) | Same (this is core, can't cut it) | +| Atomic flag | `base::AtomicFlag` (release/acquire wrapper) | Plain `std::atomic` with explicit memory_order | +| Sequence checking | `SEQUENCE_CHECKER` (no-op in release) | Simplified to an optional debug assertion | +| `SafeRef` | Full (non-null, crashes on dangle) | Not implemented (left as an extension) | +| `BindOnce` integration | Full `InvokeHelper` dispatch | Simplified trampoline + hookup to the OnceCallback from 01 | +| `TRIVIAL_ABI` | Annotated | Annotated (clang) | + +We swap `std::atomic` plus explicit memory_order in for Chromium's `base::AtomicFlag` because the former is standard library and everyone can compile it. One thing to be clear about, though: the two are equivalent in release/acquire semantics, which is exactly what pre-02 is about. + +--- + +## Setting up the environment + +WeakPtr's toolchain demands are lighter than OnceCallback's. It uses C++20 concepts and requires (conversion constructors, const overloads), but it doesn't need C++23's `move_only_function` or deducing this. + +### Compiler requirements + +GCC 11+ or Clang 12+ will do; compile with `-std=c++20`. We'll reach for `[[clang::trivial_abi]]` where it matters. This is **a Clang-only attribute; neither GCC nor MSVC supports it** (tested: GCC 16 still treats it as an ignored scoped attribute, no error but no register-passing effect either). Our `TAMCPP_TRIVIAL_ABI` macro expands to nothing on non-Clang compilers, so the code still compiles and behaves correctly, just without the trivial_abi ABI optimization. + +### Verification code + +```cpp +#include +#include + +// Verify concepts are available +template + requires std::convertible_to +constexpr bool check_convertible() { return true; } + +// Verify atomic + memory_order are available (each order's value is +// implementation-defined; the standard only guarantees they're distinct. +// Here we only assert distinctness, which holds across compilers.) +static_assert(std::memory_order::acquire != std::memory_order::release); + +int main() { return 0; } +``` + +If this compiles, the environment is ready. The project scaffolding lives in `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/`, and starting in 02-2 we'll drop the `12_` through `18_` batch of examples into it. + +--- + +## References + +- [Chromium `base/memory/weak_ptr.h` source and design comments](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [Chromium `base/memory/weak_ptr.cc` implementation](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.cc) +- [OnceCallback hands-on (IV): the cancellation token](../../01_once_callback/full/01-4-once-callback-cancellation-token.md) +- [WeakPtr prerequisite (0): weak references and the lifetime puzzle](./pre-00-weak-ptr-weak-reference-and-lifetime.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-2-weak-ptr-core-skeleton-and-control-block.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-2-weak-ptr-core-skeleton-and-control-block.md new file mode 100644 index 000000000..8e8715564 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-2-weak-ptr-core-skeleton-and-control-block.md @@ -0,0 +1,327 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "Build WeakPtr's three-layer skeleton by hand: the refcounted Flag control + block, the WeakReference wrapper, and the WeakPtr user handle, landing on std::atomic + + memory_order." +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- 'WeakPtr hands-on (II): the core skeleton and control block' +- 'WeakPtr prerequisite (I): intrusive refcounting and scoped_refptr' +- 'WeakPtr prerequisite (II): std::atomic and memory_order' +reading_time_minutes: 14 +related: +- 'WeakPtr hands-on (III): WeakPtrFactory and the last-member idiom' +- 'WeakPtr prerequisite (III): sequences, SEQUENCE_CHECKER, and DCHECK/CHECK' +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- weak_ptr +- atomic +- 内存管理 +title: "WeakPtr Hands-on (II): The Core Skeleton and Control Block" +--- +# WeakPtr Hands-on (II): The Core Skeleton and Control Block + +## Recap of the three layers + +WeakPtr is a three-layer structure: `Flag` (the control block), `WeakReference` (the reference side), and `WeakPtr` (the user handle). The seven prerequisite pieces gathered all the parts. Intrusive refcounting, acquire/release, CHECK/DCHECK. In this piece we weld them together and watch how they mesh as real code. We build bottom-up, starting with the lowest and most heavily loaded layer, `Flag`. It's the carrier for the "is the object dead yet?" state from [prerequisite (0)](./pre-00-weak-ptr-weak-reference-and-lifetime.md), and the industrial-grade version of the hand-rolled flag in [01-4 cancellation token](../../01_once_callback/full/01-4-once-callback-cancellation-token.md). + +Following the diagram from [02-1](./02-1-weak-ptr-motivation-and-api-design.md), let's put it in front of us again before we fill in the code: + +```mermaid +flowchart TB + subgraph WP["WeakPtr<T> — the handle users pass around / dereference"] + subgraph WR["WeakReference — ref side, holds scoped_refptr<Flag>"] + Flag["Flag — refcounted + atomic liveness"] + end + end +``` + +Each layer's job is straightforward. `Flag` owns the "is the object dead yet?" state: one atomic flag bit plus a refcount. `WeakReference` is a thin wrapper around `scoped_refptr`, nothing more. `WeakPtr` takes a `WeakReference` and adds a `T*`, becoming the handle you actually hold. + +--- + +## Layer one: Flag, a refcounted atomic state + +Before designing `Flag`, let's lay out the situation it's in. The factory side holds it, every WeakPtr side holds it, a crowd shares it. That settles it: it needs a refcount. The state it carries is a single boolean bit, "invalidated or not", and that bit gets read and written from different sequences, so it has to be atomic. On top of that, the refcount governs who deletes it, and the last sequence to let go might be the one doing the delete at any moment, so the count itself must be cross-thread safe. + +Stack those three together and you get [prerequisite (I)](./pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md)'s `RefCountedThreadSafe` meeting [prerequisite (II)](./pre-02-weak-ptr-atomic-and-memory-order.md)'s acquire/release. We reuse the minimal `RefCounted` from pre-01, give it the atomic counting semantics of `RefCountedThreadSafe`, then build Flag on top. + +```cpp +// Platform: host | C++ Standard: C++17 +#pragma once +#include +#include +#include + +namespace tamcpp::chrome::internal { + +// Sequence-safe intrusive refcount base (simplified RefCountedThreadSafe) +class RefCountedThreadSafe { +public: + void add_ref() const noexcept { + ref_count_.fetch_add(1, std::memory_order_relaxed); + } + bool release() const noexcept { + if (ref_count_.fetch_sub(1, std::memory_order_acq_rel) == 1) { + return true; // caller is responsible for delete this + } + return false; + } + bool has_one_ref() const noexcept { + return ref_count_.load(std::memory_order_acquire) == 1; + } +protected: + RefCountedThreadSafe() = default; + ~RefCountedThreadSafe() = default; +private: + mutable std::atomic ref_count_{0}; +}; + +// Matches Chromium's base::AtomicFlag: one-shot, release/acquire boolean flag +class AtomicFlag { +public: + void Set() noexcept { + flag_.store(1, std::memory_order_release); + } + bool IsSet() const noexcept { + return flag_.load(std::memory_order_acquire) != 0; + } +private: + std::atomic flag_{0}; +}; + +} // namespace tamcpp::chrome::internal +``` + +With both parts in place, `Flag` itself is thin: + +```cpp +// Platform: host | C++ Standard: C++17 +namespace tamcpp::chrome::internal { + +class Flag : public RefCountedThreadSafe { +public: + Flag() = default; + + // Invalidate: release-store publishes "object entered invalidated state" + // along with all prior writes. + void Invalidate() noexcept { + // Teaching version omits the sequence check; Chromium DCHECKs + // seq.CalledOnValidSequence() || HasOneRef() here. + invalidated_.Set(); + } + + // Liveness (same-sequence contract): acquire-load. + bool IsValid() const noexcept { + return !invalidated_.IsSet(); + } + + // Liveness (cross-sequence hint): also acquire-load, but the caller + // owns the risk that a positive result is not to be trusted. + bool MaybeValid() const noexcept { + return !invalidated_.IsSet(); + } + +private: + template friend class scoped_refptr; // allow delete when count hits zero + ~Flag() = default; // private: outsiders can't delete directly + AtomicFlag invalidated_; +}; + +} // namespace tamcpp::chrome::internal +``` + +A few points in this code deserve a separate callout. `Flag` inherits `RefCountedThreadSafe`, so the atomic refcount comes for free. The destructor is deliberately `private`, narrowing the delete path to `release` and friends. That's the "block outsiders from deleting directly" trick from the end of [prerequisite (I)](./pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md), saving us from careless hands. + +`Invalidate` pairs with `IsValid`: one release-store, one acquire-load. We worked through the happens-before of this pair in [prerequisite (II)](./pre-02-weak-ptr-atomic-and-memory-order.md). As long as you read "invalidated", every write the object made before entering that state is visible to you, no extra locking needed. The teaching version drops the sequence checks (Chromium hangs `DCHECK(seq.CalledOnValidSequence() || HasOneRef())` in `Invalidate` and `DCHECK_CALLED_ON_VALID_SEQUENCE` in `IsValid`); we pick those back up in 02-4 when we cover lazy binding. The acquire/release core semantics, though, are not discounted by a single word here. + +--- + +## Layer two: WeakReference, a wrapper around Flag + +One level up is `WeakReference`. Plainly put, it's a shell around `scoped_refptr`, and it doesn't do much: hold a refcounted handle to Flag, and forward `IsValid` / `MaybeValid` / `Reset` straight through. + +```cpp +// Platform: host | C++ Standard: C++17 +namespace tamcpp::chrome::internal { + +// Simplified scoped_refptr (see pre-01 for the full version) +template +class scoped_refptr { +public: + scoped_refptr() noexcept = default; + explicit scoped_refptr(T* p) noexcept : ptr_(p) { if (ptr_) ptr_->add_ref(); } + scoped_refptr(const scoped_refptr& o) noexcept : ptr_(o.ptr_) { if (ptr_) ptr_->add_ref(); } + scoped_refptr(scoped_refptr&& o) noexcept : ptr_(o.ptr_) { o.ptr_ = nullptr; } + ~scoped_refptr() { if (ptr_ && ptr_->release()) delete ptr_; } + scoped_refptr& operator=(scoped_refptr r) noexcept { T* t = ptr_; ptr_ = r.ptr_; r.ptr_ = t; return *this; } + T* get() const noexcept { return ptr_; } + explicit operator bool() const noexcept { return ptr_ != nullptr; } +private: + T* ptr_ = nullptr; +}; + +class WeakReference { +public: + WeakReference() = default; + explicit WeakReference(const scoped_refptr& flag) : flag_(flag) {} + + bool IsValid() const noexcept { return flag_ && flag_->IsValid(); } + bool MaybeValid() const noexcept { return flag_ && flag_->MaybeValid(); } + void Reset() noexcept { flag_ = nullptr; } + +private: + scoped_refptr flag_; +}; + +} // namespace tamcpp::chrome::internal +``` + +`flag_` holds a refcounted handle, so multiple WeakReferences sharing one Flag won't step on each other. Once a Flag is constructed, its identity (which Flag `flag_` points at) never moves again. The only thing that changes is the single bit in `AtomicFlag invalidated_`, and `Set()` / `IsSet()` are thread-safe atomic operations in their own right, so cross-sequence reads and writes need no extra lock. (The real Chromium version at `weak_ptr.h:153` uses `scoped_refptr` here, letting the type itself shout "Flag identity is immutable". The teaching version drops that const to stay consistent with the matching `weak_ptr.hpp`.) + +`Reset()` nulls `flag_`, and `IsValid()` / `MaybeValid()` immediately flip to false. That's letting go on purpose. + +--- + +## Layer three: WeakPtr\, the user handle + +At the top sits `WeakPtr`, which takes a `WeakReference` and adds a `T*`. The semantics of that pointer are a little counterintuitive. While the object lives it points at it; once the object destructs, it's allowed to dangle, hanging there in plain sight but off-limits. [Prerequisite (V)](./pre-05-weak-ptr-template-friend-and-uintptr-t.md) explains why this deliberately avoids `raw_ptr`: allowing the dangle is part of the design, and `WeakReference` does the gating. + +```cpp +// Platform: host | C++ Standard: C++20 +#include + +namespace tamcpp::chrome { + +template class WeakPtrFactory; // forward declaration + +template +class [[clang::trivial_abi]] WeakPtr { +public: + WeakPtr() = default; + WeakPtr(std::nullptr_t) noexcept {} // NOLINT(google-explicit-constructor) + + // Upcast conversion constructor (see pre-04) + template + requires(std::convertible_to) + WeakPtr(const WeakPtr& other) noexcept + : ref_(other.ref_), ptr_(other.ptr_) {} + + template + requires(std::convertible_to) + WeakPtr(WeakPtr&& other) noexcept + : ref_(std::move(other.ref_)), ptr_(other.ptr_) {} + + // Two postures: liveness check and dereference + T* get() const noexcept { return ref_.IsValid() ? ptr_ : nullptr; } + + T& operator*() const { assert(ref_.IsValid()); return *ptr_; } // teaching version uses assert; Chromium uses CHECK + T* operator->() const { assert(ref_.IsValid()); return ptr_; } + + explicit operator bool() const noexcept { return get() != nullptr; } + + void reset() noexcept { + ref_.Reset(); + ptr_ = nullptr; + } + + bool maybe_valid() const noexcept { return ref_.MaybeValid(); } + bool was_invalidated() const noexcept { return ptr_ && !ref_.IsValid(); } + +private: + template friend class WeakPtr; + friend class WeakPtrFactory; + + // Only the factory can call this: used at mint time + WeakPtr(internal::WeakReference&& ref, T* ptr) noexcept + : ref_(std::move(ref)), ptr_(ptr) { + assert(ptr); + } + + internal::WeakReference ref_; + T* ptr_ = nullptr; // RAW_PTR_EXCLUSION: dangling allowed, ref_ gates before deref +}; + +} // namespace tamcpp::chrome +``` + +Nothing here is casual. Every line maps back to some prerequisite piece. Let's go through them. + +The `[[clang::trivial_abi]]` at the top of the class comes from pre-06. Its job is to let a type with a non-trivial destructor still pass through registers like a trivial type. Pre-06 worked through the safety preconditions: `ptr_` is a raw pointer and trivial already, and the `scoped_refptr` inside `ref_` is trivially relocatable. Both hold, so the annotation doesn't flip over. + +The `requires` on the conversion constructors (pre-04's product) gates the cast direction. `WeakPtr` can go to `WeakPtr`; the reverse and unrelated types get stopped at compile time. The `template friend class WeakPtr` right after (pre-05) isn't decoration. The conversion constructors need to read `other.ref_` and `other.ptr_`, and without that friend declaration they couldn't reach them. + +`operator*` and `operator->` use `assert` in the teaching version, catching trouble in debug. Chromium's real version uses `CHECK`, which crashes in release too. Dereferencing after invalidation is a flat-out logic error; in production it has to blow up on the spot. In 02-6 we'll switch the release behavior with a macro, so keep that in mind. + +The private constructor plus `friend WeakPtrFactory` at the end is the minting slot reserved for the factory. Through it the factory can write directly into `ref_` and `ptr_`, while nobody outside can touch them. That upholds the contract: only the factory makes WeakPtrs. + +### The gating chain inside get() + +The most important line is `get()`: + +```cpp +T* get() const noexcept { return ref_.IsValid() ? ptr_ : nullptr; } +``` + +Expanded, the whole gating chain is: + +```text +get() → ref_.IsValid() → (flag_ && flag_->IsValid()) → !invalidated_.IsSet() + ↑ acquire-load +``` + +One `get()` call boils down to a single atomic acquire-load underneath. Read "not invalidated", return `ptr_`, and the caller derefs with confidence. Read "invalidated", hand back `nullptr` honestly. Every bit of WeakPtr's safety hangs on this gate, and every dereference goes through, and only through, `get()`. `operator*` and `operator->` look like they touch `ptr_` directly, but each one `CHECK`s `ref_.IsValid()` first, which amounts to confirming `get()` won't return null. + +--- + +## Stringing it together: a minimal runnable example + +The factory isn't written yet (that's the next piece's job), but we can hand-assemble a Flag and a WeakReference to verify the three layers run end to end. The snippet below is pseudocode for exposition. It calls WeakPtr's private constructor directly, which in normal use only `WeakPtrFactory` can reach through friendship. The real compilable version sits in the matching `code/.../chrome_design/16_weak_ptr_skeleton.cpp`, and it goes through the factory's proper minting path. + +```cpp +// Platform: host | C++ Standard: C++20 +#include + +struct Foo { int x = 42; }; + +int main() { + using namespace tamcpp::chrome; + using namespace tamcpp::chrome::internal; + + Foo foo; + + // Hand-assemble a Flag + WeakReference (simulating the factory mint; 02-3 wraps it) + auto* flag = new Flag(); + scoped_refptr flag_ref(flag); // ref_count = 1 + WeakReference ref(flag_ref); // ref_count = 2 + WeakPtr wp(std::move(ref), &foo); // holds ref + &foo + + std::cout << (wp ? "alive" : "dead") << '\n'; // alive + std::cout << wp->x << '\n'; // 42 + + flag->Invalidate(); // simulate the invalidation before object destructs + std::cout << (wp ? "alive" : "dead") << '\n'; // dead + std::cout << wp.get() << '\n'; // 0 (nullptr) + + return 0; +} +``` + +Run it and the terminal prints `alive` / `42` / `dead` / `0`. Once `Invalidate` is called, `wp`'s `operator bool` (which goes through `get()` under the hood) flips to false, and `get()` honestly hands back nullptr. The promise [02-1](./02-1-weak-ptr-motivation-and-api-design.md) made back then, that when the object dies the callback gets nullptr rather than a dangling pointer, is now made good in code. + +Our Flag here is still hand-assembled, though. Who mints it, and who calls `Invalidate` the moment the object destructs? That's where `WeakPtrFactory` steps in. Implementing the factory, plus the famous "last member" idiom, is the next piece. + +## References + +- [Chromium `base/memory/weak_ptr.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [Chromium `base/memory/weak_ptr.cc`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.cc) +- [WeakPtr prerequisite (I): intrusive refcounting and scoped_refptr](./pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md) +- [WeakPtr prerequisite (II): std::atomic and memory_order](./pre-02-weak-ptr-atomic-and-memory-order.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md new file mode 100644 index 000000000..23ad89e21 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md @@ -0,0 +1,257 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "Implementing WeakPtrFactory: the mint, the difference between InvalidateWeakPtrs and AndDoom, and why it must be the last member (a destruction-order argument), plus the composition-vs-inheritance tradeoff." +difficulty: intermediate +order: 3 +platform: host +prerequisites: +- 'weak_ptr hands-on (II): the core skeleton and control block' +- 'weak_ptr prerequisite (V): template friends and uintptr_t type erasure' +reading_time_minutes: 13 +related: +- 'weak_ptr hands-on (IV): sequence affinity and lazy binding' +- 'weak_ptr hands-on (I): motivation and API design' +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- weak_ptr +- 内存管理 +title: "weak_ptr hands-on (III): WeakPtrFactory and the last-member idiom" +--- +# weak_ptr hands-on (III): WeakPtrFactory and the last-member idiom + +In [02-2](./02-2-weak-ptr-core-skeleton-and-control-block.md) we hand-rolled a Flag as the raw material for the mint. But honestly, you can't be expected to `new` a Flag yourself every time you want a WeakPtr and then babysit the refcount. That gets old fast. Chromium bundles all that bookkeeping into `WeakPtrFactory`, a small mint that hangs on the observed object: you ask it for WeakPtrs, and when the object is done living, it invalidates every WeakPtr it ever minted in one shot. + +This piece builds the factory and then takes on its most famous usage rule, that `WeakPtrFactory weak_factory_{this}` has to be the last member of the class. The idiom looks pedantic on first read. It isn't. It is the line between correct and broken code. The first time I saw it I wondered whether member ordering could really matter for correctness, and it was only after a real bug that I understood why Chromium puts it at the top of the header in an EXAMPLE block. We will work through it with a destruction-order argument. + +## The Flag inside WeakPtrFactory + +The factory holds exactly one thing: a Flag, shared by every WeakPtr minted from it. So internally it wraps a `WeakReferenceOwner`, the issuer and holder of that Flag (`weak_ptr.cc:82-89`): + +```cpp +// Issuer: holds one Flag, responsible for invalidation +class WeakReferenceOwner { +public: + WeakReferenceOwner() : flag_(make_ref()) {} // mint a fresh Flag on construction + ~WeakReferenceOwner() { + if (flag_) flag_->Invalidate(); // invalidate all WeakPtrs on destruction + } + WeakReference GetRef() const { return WeakReference(flag_); } // the mint + // ... +private: + scoped_refptr flag_; +}; +``` + +This reads plainly, but every line is doing work. Construction mints a fresh Flag. `GetRef()` hands back a `WeakReference` pointing at that same Flag each time. The destructor's `flag_->Invalidate()` is the actual detonator. It turns "factory died, all WeakPtrs drop now" into an automatic side effect of the factory's destructor, so you never have to remember to call `invalidate` yourself. My reaction the first time I read this was: that is a genuinely considerate design. It stuffs the step you are most likely to forget into the destruction chain. + +On top of `WeakReferenceOwner`, `WeakPtrFactory` adds exactly one thing: a raw pointer back to the observed object. + +```cpp +// Platform: host | C++ Standard: C++20 +namespace tamcpp::chrome { + +template +class WeakPtrFactory : public internal::WeakPtrFactoryBase { +public: + WeakPtrFactory() = delete; + explicit WeakPtrFactory(T* ptr) + : WeakPtrFactoryBase(reinterpret_cast(ptr)) {} + + WeakPtrFactory(const WeakPtrFactory&) = delete; + WeakPtrFactory& operator=(const WeakPtrFactory&) = delete; + + // Mint: const factory hands out WeakPtr + WeakPtr get_weak_ptr() const { + return WeakPtr(weak_reference_owner_.GetRef(), + reinterpret_cast(ptr_)); + } + + // Non-const overload: hands out WeakPtr (pre-04 requires) + WeakPtr get_weak_ptr() + requires(!std::is_const_v) + { + return WeakPtr(weak_reference_owner_.GetRef(), + reinterpret_cast(ptr_)); + } + + // Active batch invalidation (object still alive, but you want all WeakPtrs gone) + void invalidate_weak_ptrs() { + assert(ptr_); + weak_reference_owner_.Invalidate(); // invalidate the old Flag + mint a fresh one + } + + void invalidate_weak_ptrs_and_doom() { + assert(ptr_); + weak_reference_owner_.InvalidateAndDoom(); // invalidate + never mint again + ptr_ = 0; + } + + bool has_weak_ptrs() const { return ptr_ && weak_reference_owner_.HasRefs(); } + +private: + internal::WeakReferenceOwner weak_reference_owner_; + // ptr_ lives in the non-template base WeakPtrFactoryBase (see pre-05) +}; + +} // namespace tamcpp::chrome +``` + +A few spots here are worth calling out, all techniques from the [pre-05](./pre-05-weak-ptr-template-friend-and-uintptr-t.md) piece. `reinterpret_cast(ptr)` stores the `T*` as an integer so the member can sink into the non-template base `WeakPtrFactoryBase`, which saves you from regenerating the template body for every `T`. Inside `get_weak_ptr` the reverse `reinterpret_cast(ptr_)` brings it back. The two `get_weak_ptr` overloads use a member function `requires(!std::is_const_v)` from [pre-04](./pre-04-weak-ptr-concepts-and-requires.md), so const-correctness sits directly on the signature: a const factory produces `WeakPtr`, a non-const one produces `WeakPtr`, and the split is settled at compile time. + +## invalidate_weak_ptrs vs invalidate_weak_ptrs_and_doom, what's the difference + +The factory exposes two invalidation methods, and from the names alone you may be as puzzled as I was. Aren't both of them just invalidation? The difference hides in one question: after invalidating, can the factory keep minting? Look at the two `WeakReferenceOwner` implementations side by side (`weak_ptr.cc:103-113`): + +```cpp +void WeakReferenceOwner::Invalidate() { + assert(flag_); + flag_->Invalidate(); // invalidate the old Flag + flag_ = make_ref(); // mint a fresh one, factory can keep minting +} + +void WeakReferenceOwner::InvalidateAndDoom() { + assert(flag_); + flag_->Invalidate(); // invalidate the old Flag + flag_.reset(); // hold no new Flag, factory enters the "doomed" state +} +``` + +The entire difference is the line after invalidating the old Flag. `invalidate_weak_ptrs()` invalidates the old Flag, then immediately `flag_ = make_ref()` mints a fresh one. All existing WeakPtrs drop together, but the factory itself is still breathing, and you can call `get_weak_ptr()` again. The new WeakPtrs share the new Flag. This is the right call when an object is entering a new phase and the old observers should clear out, but new observers still need to attach later. + +`invalidate_weak_ptrs_and_doom()` is the meaner sibling. After invalidating the old Flag it does not mint a new one, and it zeroes `ptr_` for good measure. The factory enters a doomed state, and any `get_weak_ptr()` you call after that hands back an invalid result. It is cheaper than the first option by exactly one Flag allocation. The name says it: doom is for the "this object is being retired" cleanup path. + +These two show up again in the [02-6](./02-6-weak-ptr-sequence-affinity-and-lazy-binding.md) performance comparison. Truth is, in nine out of ten day-to-day cases you never call either one explicitly. The factory's destructor-time auto-invalidation we are about to cover is enough on its own. + +--- + +## The main event: the last-member idiom + +This is the part the piece is really here for. The EXAMPLE block at the top of Chromium's `weak_ptr.h` puts the rule in the most visible spot it can (`weak_ptr.h:22-26`): + +> Member variables should appear before the WeakPtrFactory, to ensure that any WeakPtrs to Controller are invalidated before its members variable's destructors are executed. + +In one sentence: declare member variables before `WeakPtrFactory`, and put the factory last. My first thought reading this was, really? Member order affecting correctness? It does. Let's take it apart one piece at a time. + +### First, a foundation: C++ destroys members in reverse order + +This is a basic C++ rule, but it is easy to lose track of when you are working with WeakPtr. When an object is destroyed, its members are destroyed in the reverse of declaration order. The first declared dies last; the last declared dies first. So if `WeakPtrFactory` is declared last, it is destroyed first. Put it at the front and it becomes the last thing destroyed. Hold onto that word, reverse. The whole argument below rides on it. + +### Another foundation: factory destruction = invalidate all WeakPtrs + +That `flag_->Invalidate()` inside `WeakReferenceOwner::~WeakReferenceOwner()` is the key. The moment the factory is destroyed, every WeakPtr it ever minted drops. In other words, when the factory dies determines when all its WeakPtrs become invalid. + +### Stack the two: why the factory has to go last + +Put those two facts together and the conclusion writes itself. Say `Controller` has a few ordinary members plus a factory. Let's write both declaration orders and compare: + +```cpp +// ✗ Wrong order: factory first +class BadController { +public: + void on_work_done() { /* uses buf_ */ } +private: + WeakPtrFactory weak_factory_{this}; // declared first → destroyed last + std::vector buf_; // declared later → destroyed first +}; + +// ✓ Right order: factory last +class GoodController { +public: + void on_work_done() { /* uses buf_ */ } +private: + std::vector buf_; // declared first → destroyed last + WeakPtrFactory weak_factory_{this}; // declared later → destroyed first +}; +``` + +Take `BadController` first. Destruction walks in reverse: `buf_` dies, then `weak_factory_` gets its turn, and only then does it remember to invalidate the WeakPtrs. The problem is the window between those two events. `buf_` is already gone, but every WeakPtr is still cheerfully "valid". If an async task holding one of those WeakPtrs dereferences the `Controller` right then, it sails into `on_work_done()` and slams into a destroyed `buf_`. UAF, clean and inevitable. I once chased an intermittent ASAN red on a setup exactly like this, and member order was the root cause. + +`GoodController` flips the order. `weak_factory_` is declared last, so it is destroyed first, and the moment it dies it invalidates every WeakPtr. Only after that does `buf_` get destroyed. By the time `buf_`'s destructor runs, no "valid" WeakPtr can reach it from outside. A late dereference gets a `nullptr` at worst. Safe. + +The last-member idiom, in one line: let the factory die before the other members, and lean on its destructor-time invalidation to cover the rest of the members through their own destruction. + +### One edge worth pinning down: it guards the member destruction window, not the destructor body + +There is a detail I want to nail down here because it trips people up the most. We confirmed it specifically when we were doing the verification pass. Putting the factory last guards the member destruction window. It does not stop you from using WeakPtrs inside the object's own destructor body. Spread the timeline out: + +```text +GoodController destruction: + ① destructor body runs (all members still alive, WeakPtrs still valid) + ② members destroyed in reverse order: + weak_factory_ destroyed first → all WeakPtrs invalidated ← the gate fires here + buf_ destroyed after +``` + +So during ①, while the destructor body is running, WeakPtrs are still valid. This is the intuitive behavior. Inside the destructor body you often want to touch sibling members (notifying observers "I'm on my way out", say), and having WeakPtrs still valid there is the convenient choice. The actual invalidation happens once member destruction begins at ②. The point is to guarantee that any dereference after this point cannot reach a half-destroyed member. Chromium's source comments do not spell this boundary out. I read it off the code, and you should keep it in mind so you do not later assume "WeakPtrs invalidate the instant the object enters destruction". That misread will cost you. + +--- + +## Why Chromium kept only the composition path + +If you grep current `//base`, there is exactly one blessed way to get a WeakPtr: stuff a `WeakPtrFactory weak_factory_{this}` member into `Controller`. That is the carrier of the last-member idiom, and Chromium picked it for a reason. You control the invalidation timing, it works on types you do not own (you can go as far as `WeakPtrFactory`), and it does not pollute the inheritance chain. + +There used to be an inheritance-based variant, `SupportsWeakPtr`. Inherit from it and you got a `GetWeakPtr()` for free. Sounds more convenient, right? It nudged people toward unsafe patterns, and Chromium eventually pulled it out of `//base`. Today `grep SupportsWeakPtr weak_ptr.h` comes back empty. I bring it up only because you will run into the name in old code and old docs and it helps to know what it was. New code uses composition. Once you understand the composition form, the old mechanism is just the factory hidden in a base class. Same idea underneath. + +--- + +## Rewriting the 02-1 hazard with the factory + +Talk is cheap. Let's rewrite the dangling-callback scenario from [02-1](./02-1-weak-ptr-motivation-and-api-design.md) with the factory we just built, and watch it close the UAF hole: + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include +#include + +class Controller { +public: + void on_work_done(int v) { + buf_.push_back(v); + std::cout << "got " << v << ", buf size=" << buf_.size() << '\n'; + } + WeakPtr get_weak() { return weak_factory_.get_weak_ptr(); } + + ~Controller() = default; +private: + std::vector buf_; // declared first + WeakPtrFactory weak_factory_{this}; // last member! +}; + +int main() { + using namespace tamcpp::chrome; + WeakPtr wp; // declared first, filled in shortly + + { + Controller c; + wp = c.get_weak(); + std::cout << (wp ? "alive" : "dead") << '\n'; // alive + if (wp) wp->on_work_done(7); // got 7, buf size=1 + } // c leaves scope: weak_factory_ destroyed first → wp invalidates → buf_ destroyed after + + std::cout << (wp ? "alive" : "dead") << '\n'; // dead + if (wp) { + wp->on_work_done(8); // does not enter here + } else { + std::cout << "controller gone, skip\n"; // takes this branch + } + return 0; +} +``` + +Run it and the output lands in order: `alive`, then `got 7, buf size=1`, then `dead`, then `controller gone, skip`. The line to watch is the last one. After `Controller` is destroyed, `wp` has already auto-invalidated, and the `if (wp)` gate turns away the access to the destroyed object. The dangling callback from 02-1, the one that gave us headaches, is closed off entirely by the factory's destructor-time invalidation. You did not write a single line of defensive code. + +That gathers the core WeakPtr mechanisms into one place: the mint, invalidation, and the dereference gate. There is one usage contract we have been circling around without stating directly: dereferencing and invalidating a WeakPtr must happen on the same sequence it was bound on. That contract deserves a straight treatment, and we will also cover the factory's lazy sequence binding along the way. Next piece. + +## References + +- [Chromium `base/memory/weak_ptr.h` — WeakPtrFactory and the top-of-file EXAMPLE](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [Chromium `base/memory/weak_ptr.cc` — WeakReferenceOwner](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.cc) +- [weak_ptr hands-on (II): the core skeleton and control block](./02-2-weak-ptr-core-skeleton-and-control-block.md) +- [weak_ptr prerequisite (V): template friends and uintptr_t type erasure](./pre-05-weak-ptr-template-friend-and-uintptr-t.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-4-weak-ptr-sequence-affinity-and-lazy-binding.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-4-weak-ptr-sequence-affinity-and-lazy-binding.md new file mode 100644 index 000000000..964788387 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-4-weak-ptr-sequence-affinity-and-lazy-binding.md @@ -0,0 +1,209 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "Lay out WeakPtr's sequence contract head-on: deref and invalidation must + land on the bound sequence, and the Flag's lazy sequence-binding mechanism (release + relies on developer discipline), and pin down the difference between IsValid and MaybeValid." +difficulty: intermediate +order: 4 +platform: host +prerequisites: +- 'WeakPtr hands-on (III): WeakPtrFactory and the last-member idiom' +- 'WeakPtr prerequisite (III): sequences, SEQUENCE_CHECKER, and DCHECK/CHECK' +reading_time_minutes: 12 +related: +- 'WeakPtr hands-on (V): callback integration, closing the OnceCallback loop' +- 'WeakPtr prerequisite (II): std::atomic and memory_order' +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- weak_ptr +- 并发 +title: "WeakPtr Hands-on (IV): Sequence Affinity and Lazy Binding" +--- +# WeakPtr Hands-on (IV): Sequence Affinity and Lazy Binding + +In the previous pieces we put the WeakPtr skeleton on its feet. Minting, dereferencing, destruction-driven invalidation, it all runs. But one thread we have not touched, the one Chromium calls out in a long comment at the top of the source: a `WeakPtr` may travel between sequences, but dereference and invalidation **must happen on the sequence it was bound to**. We want to take that contract apart head-on in this piece, because two easy-to-confuse things hang off it. One is the Flag's lazy sequence binding. The other is the pair `IsValid` and `MaybeValid`, which look alike and mean completely different things. Once those two are clear, using WeakPtr across sequences actually lands. + +--- + +## The sequence contract: why deref and invalidation share a sequence + +First the contract, in its own words (`weak_ptr.h:50-54`): + +> Weak pointers may be passed safely between sequences, but must always be dereferenced and invalidated on the same SequencedTaskRunner otherwise checking the pointer would be racey. + +One sentence: a weak pointer can be passed safely across sequences, but dereferencing and invalidating it must always land on the same `SequencedTaskRunner`, otherwise the act of "checking this pointer" is itself a race. + +You might object: isn't `invalidated_` atomic, how is it a race. The atomic operation itself does not tear, yes. The problem is the window between "`get()` returns non-null" and "the caller takes that `T*` and uses it". Picture sequence A holding a `WeakPtr`. `get()` reads "valid" once and is about to deref. Sequence B calls `Invalidate()`, then the owner destructs the object. The `T*` in A's hand, which looked fine, now points at a half-built object or already-freed memory if A goes through with the access. Atomicity only guarantees the read/write does not tear. It cannot guarantee nobody touches the object during the deref window. The clean fix is to serialize deref and invalidation onto the same sequence, so the window never opens. + +Passing the pointer across sequences is allowed. You hand a `WeakPtr` from sequence A to sequence B (drop it into a thread pool, then post a task back to A). The handoff itself is just moving bytes, it never touches Flag. Only when B actually wants to "use" it (deref) or "void" it does it step onto the contract's territory. + +--- + +## Lazy binding: Flag does not bind a sequence at construction + +The contract is set. The next question is unavoidable: how does Flag know which sequence is "the bound one"? Chromium's answer: it does not bind at construction. It waits until the first time someone "touches" it, and only then does it commit. This is lazy binding. + +Look at `Flag`'s constructor. It does exactly one thing, `DETACH_FROM_SEQUENCE` (`weak_ptr.cc:15-20`): + +```cpp +WeakReference::Flag::Flag() { + // Flags only become bound when checked for validity, or invalidated, + // so that we can check that later validity/invalidation operations + // on the same Flag take place on the same sequenced thread. + DETACH_FROM_SEQUENCE(sequence_checker_); +} +``` + +`DETACH_FROM_SEQUENCE` means "I am not bound to any sequence yet, do not check". At this point Flag is unbound. The first time someone calls `IsValid` or `Invalidate` and touches it, `DCHECK_CALLED_ON_VALID_SEQUENCE` records the current sequence, and from then on Flag is loyal to it. Every later `IsValid`/`Invalidate` must run on that sequence: + +```cpp +bool WeakReference::Flag::IsValid() const { + DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); // first touch → bind; after → verify + return !invalidated_.IsSet(); +} +``` + +Why does it have to be lazy? Because in Chromium a lot of objects are "constructed on sequence A, used on sequence B", and at construction you do not yet know which sequence they will end up running on. If Flag pinned itself to the constructing sequence the moment it was built, none of those objects could use it. Lazy binding opens the path of "no opinion at construction, commit to a sequence the first time you actually use it", and the barrier to use drops right down. + +There is a finer seam hiding here. If a Flag currently has no WeakPtr holding it (`!HasRefs()`), `WeakReferenceOwner::GetRef` detaches it again (`weak_ptr.cc:91-101`): + +```cpp +WeakReference WeakReferenceOwner::GetRef() const { +#if DCHECK_IS_ON() + DCHECK(flag_); + if (!HasRefs()) { + flag_->DetachFromSequence(); // no holders → unbind, can rebind to another sequence + } +#endif + return WeakReference(flag_); +} +``` + +This block only takes effect under `DCHECK_IS_ON()`, but it opens a door: after all of a factory's WeakPtrs are gone, the factory can pick up on a different sequence. The next WeakPtr it mints will rebind to the new sequence on first touch. The source comment calls this out directly (`weak_ptr.h:63-65`): once all WeakPtrs are destroyed or invalidated, the factory is unbound from its sequence, may be destroyed on a different sequence, and may mint fresh WeakPtrs there. + +--- + +## Release: zero overhead, and self-discipline + +Line this up against the key fact from [prerequisite (III)](./pre-03-weak-ptr-sequence-checker-dcheck-check.md): all of the lazy binding and sequence checking is wrapped in `DCHECK_IS_ON()`, and in a release build it compiles away to nothing. + +Put differently, a release WeakPtr does zero runtime sequence checking. The sequence contract in release rests entirely on developer discipline. Violate it and your program will not crash on the spot. It will surface a race at some inconvenient moment. That is why taking every DCHECK seriously in a debug build matters so much. It is close to your only chance to catch a sequence violation. + +--- + +## IsValid vs MaybeValid: same-sequence truth vs cross-sequence hint + +Lazy binding piles all of the "sequence contract checking" onto `IsValid`, yet WeakPtr throws in a second query, `MaybeValid`. The two look alike. Their semantics are far apart. Mix them up and you step in it. + +Start with `IsValid`. It runs a sequence assertion. You must call it on the bound sequence, and its return value is fully trustworthy: true means really still valid, false means really invalidated. `WeakPtr::get()` and `operator bool` go through this path, which is why "check liveness, then deref" is safe. There is a side effect too: this `IsValid` call is what triggers lazy binding (if it has not been bound yet). + +```cpp +bool WeakReference::Flag::IsValid() const { + DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); // same-sequence contract + return !invalidated_.IsSet(); +} +``` + +`MaybeValid` is different. It carries no sequence assertion, any sequence can call it. The Chromium source comment states its boundary plainly (`weak_ptr.h:266-283`): + +> Returns false if the WeakReference is confirmed to be invalid. This call is safe to make from any thread, e.g. to optimize away unnecessary work, but `RefIsValid()` must always be called, on the correct sequence, before actually using the pointer. + +Memorize the asymmetry of its return value. A false return is trustworthy: an acquire read caught the invalidation bit a release wrote, the pointer is invalidated, full stop. A true return you cannot fully trust. It only counts as "maybe" still valid. You might read true, and right before you deref, the bound sequence invalidates it. So `MaybeValid` has exactly one fitting use: a speculative "can I skip this" check from another sequence. For example, a message loop can glance at it before dispatching a task, see false, and know the task is pointless, skip it, save a cross-sequence post. But the moment you want to actually touch the pointer, you must go back to the bound sequence and run `IsValid` again. Treat a positive result as a deref pass and you will crash sooner or later. + +```cpp +bool WeakReference::Flag::MaybeValid() const { + return !invalidated_.IsSet(); // no sequence assertion, any sequence may call +} +``` + +A comparison table for reference: + +| Query | Sequence constraint | Triggers lazy binding | Result trust | +|---|---|---|---| +| `IsValid()` | must be the bound sequence | yes | 100% accurate | +| `MaybeValid()` | any sequence | no | negative trusted / positive not trusted | + +This distinction pays off in the next piece (02-5, BindOnce integration). You will see Chromium's callback cancellation goes through `IsValid` (same-sequence, trustworthy), while `MaybeValid` is a separate "scheduler speculative query" channel. + +--- + +## Adding the sequence check to the teaching version + +Now we add the sequence check to the `Flag` from 02-2. The teaching version uses a simplified `SequenceChecker` that records a thread id in debug: + +```cpp +// Platform: host | C++ Standard: C++17 (debug-only check) +#if defined(NDEBUG) +// release: all no-op, zero bytes, zero overhead +class SequenceChecker { +public: + void detach_from_sequence() noexcept {} + bool called_on_valid_sequence() const noexcept { return true; } +}; +#else +#include +// debug: record the bound thread, abort on violation +class SequenceChecker { +public: + void detach_from_sequence() noexcept { bound_thread_ = std::thread::id{}; } + bool called_on_valid_sequence() const noexcept { + if (bound_thread_ == std::thread::id{}) { + bound_thread_ = std::this_thread::get_id(); // lazy binding + return true; + } + return bound_thread_ == std::this_thread::get_id(); + } +private: + mutable std::thread::id bound_thread_; +}; +#endif +``` + +You can map it onto Chromium's three macros: `detach_from_sequence` lines up with `DETACH_FROM_SEQUENCE`, `called_on_valid_sequence` with `DCHECK_CALLED_ON_VALID_SEQUENCE`, and in release they are all no-ops. The teaching version models a sequence with a thread id; real Chromium uses the finer `SequenceToken`, but the shape of lazy binding is the same. + +Now `Flag` plugs it in: + +```cpp +class Flag : public RefCountedThreadSafe { +public: + Flag() { seq_.detach_from_sequence(); } // construct: unbound + + void Invalidate() noexcept { + // DCHECK: same sequence, or only this ref left (cross-thread destruct ok) + assert(seq_.called_on_valid_sequence() || has_one_ref()); + invalidated_.Set(); + } + bool IsValid() const noexcept { + assert(seq_.called_on_valid_sequence()); // first touch → bind + return !invalidated_.IsSet(); + } + bool MaybeValid() const noexcept { + return !invalidated_.IsSet(); // never touches seq_, never binds + } +private: + // ... + mutable SequenceChecker seq_; + AtomicFlag invalidated_; +}; +``` + +Wire it up this way and in debug, if you call `IsValid` on the wrong thread, the assert catches the sequence violation. In release, these asserts along with `SequenceChecker` all disappear, zero overhead. + +--- + +This piece took the sequence contract apart head-on. A weak pointer can travel across sequences, but deref and invalidation must land on the same bound sequence, otherwise the check itself is a race. Flag's lazy binding (detach at construction, bind on first touch, unbind when no refs remain so it can be reused) is what makes "no sequence specified at construction" workable, and it fits the many Chromium objects built as "constructed in one place, used in another". All of this checking lives inside `DCHECK_IS_ON()`, zero overhead in release, the contract carried by self-discipline. And `IsValid` (same sequence, trustworthy, triggers binding) versus `MaybeValid` (any sequence, optimistic, negative trusted and positive not) is a pair you must keep straight. The first is the hard gate before a deref. The second is only a scheduler's skip hint. + +The real "eye" of the series is next. We plug WeakPtr into the callback system and watch `BindOnce` lean on `IsValid` so a callback turns into a no-op once its object dies, closing the tail the hand-rolled cancellation token from 01-4 left open. + +## References + +- [Chromium `base/memory/weak_ptr.h` — top-of-file thread-safety comment (lines 50-69)](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [Chromium `base/memory/weak_ptr.cc` — Flag / WeakReferenceOwner](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.cc) +- [WeakPtr prerequisite (III): sequences, SEQUENCE_CHECKER, and DCHECK/CHECK](./pre-03-weak-ptr-sequence-checker-dcheck-check.md) +- [Chromium `base/sequence_checker.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/sequence_checker.h) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-5-weak-ptr-bind-integration.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-5-weak-ptr-bind-integration.md new file mode 100644 index 000000000..8a6ff5a9b --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-5-weak-ptr-bind-integration.md @@ -0,0 +1,295 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "The eye of the series: wiring WeakPtr into the callback system. We unpack the compile-time wiring that lets BindOnce spot a WeakPtr (kIsWeakMethod) and the call-time dispatch in InvokeHelper that does if(!target) return;, then close the loop on the 01-4 cancellation token." +difficulty: intermediate +order: 5 +platform: host +prerequisites: +- 'WeakPtr hands-on (IV): sequence affinity and lazy binding' +- 'OnceCallback hands-on (IV): the cancellation token' +- 'OnceCallback hands-on (I): motivation and API design' +reading_time_minutes: 15 +related: +- 'WeakPtr hands-on (VI): tests and performance comparison' +- 'WeakPtr hands-on (I): motivation and API design' +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- weak_ptr +- 回调机制 +- 函数对象 +title: "WeakPtr Hands-on (V): Callback Integration, Closing the OnceCallback Loop" +--- +# WeakPtr Hands-on (V): Callback Integration, Closing the OnceCallback Loop + +We've finally reached the eye of the series. Cast your mind back to the opening of [01-4 cancellation token](../../01_once_callback/full/01-4-once-callback-cancellation-token.md) and that dangling callback: the task is still sitting in the queue when the object destructs first, and the callback runs and dereferences a hollow shell. Back then we took the shortcut, slapped an atomic flag plus a pre-call if-check on it, and moved on. But we left a loose end. How does that flag actually reach the callback, and who manages its lifetime? We didn't really think it through. + +Now we have a complete `WeakPtr` in hand, and we can wire it into the callback system properly. This piece walks through how Chromium uses `BindOnce` to make a callback bound to a `WeakPtr` go silent **automatically** once the object dies, turning into a no-op. You'll see that the hand-rolled hack from 01-4 maps almost line-for-line onto the industrial implementation. The only difference is two extra layers of engineering finish: type erasure, and a small speculative bet the scheduler makes. + +--- + +## The industrial answer: BindOnce + WeakPtr + +In Chromium the real idiom isn't `if (wp) wp->...`. You bind the `WeakPtr` straight into the callback: + +```cpp +// This task silently drops itself after controller dies; no dangling deref. +thread_pool.post( + base::BindOnce(&Controller::on_work_done, + controller.weak_factory_.GetWeakPtr())); +``` + +From the outside it looks like an ordinary `BindOnce`: a member method plus one argument (the WeakPtr), packed into a callable. But `BindOnce` sniffs out "the receiver is a WeakPtr" at **compile time** and quietly routes it down a special dispatch path. Before running, it null-checks the weak pointer; if it's invalid, `return`, do nothing. Only if it's still alive does it actually call the method. + +That special path has two halves, compile-time wiring and call-time dispatch. We'll take them apart one at a time. + +--- + +## Compile-time wiring: kIsWeakMethod / IsWeakReceiver + +`BindOnce`'s type-erasure machinery (the `BindState` apparatus from [01-1](../../01_once_callback/full/01-1-once-callback-motivation-and-api-design.md)) has to make up its mind at compile time: does this binding take the weak branch or not? The verdict is a constant called `kIsWeakMethod` (`bind_internal.h:436-448`): + +```cpp +template +inline constexpr bool kIsWeakMethod = false; + +template +inline constexpr bool kIsWeakMethod = IsWeakReceiver::value; +``` + +Two conditions must line up for it to be true. First, `is_method`: the thing being bound is a **member method**, not a free function. Second, `IsWeakReceiver::value`: the receiver's type `T` is a `WeakPtr`. The definition of `IsWeakReceiver` is blunt (`bind_internal.h:1925-1926`): + +```cpp +template +struct IsWeakReceiver : std::bool_constant> {}; +``` + +In plain terms: is `T` some instantiation of the `WeakPtr` template? + +The trigger is deliberately narrow, and that surprised me the first time I read it. It has to be a member method whose first argument (the receiver) is a `WeakPtr`. If you shove a `WeakPtr` in as a plain bound argument that isn't the receiver, or hand one to a free function, it does **not** trigger the weak branch. In that case the WeakPtr is just a value getting copied around, with no automatic no-op treatment. The narrowness is reasonable. Only when the WeakPtr is plainly "the method's receiver" does the sentence "if the object is dead, don't call" even make sense. + +--- + +## Call-time dispatch: InvokeHelper\::MakeItSo + +Once `kIsWeakMethod` evaluates true at compile time, it selects a specialized `InvokeHelper`, the executor for weak calls. The core code is startlingly short (`bind_internal.h:939-961`), short enough to quote in full: + +```cpp +template +struct InvokeHelper { + template + static inline void MakeItSo(Functor&& functor, BoundArgsTuple&& bound, + RunArgs&&... args) { + static_assert(index_target == 0); + // Note: the validity of the weak pointer must be tested after Unwrap, + // otherwise it creates a race for weak pointer implementations that + // allow cross-thread usage and perform Lock() inside Unwrap(). + const auto& target = Unwrap(std::get<0>(bound)); + if (!target) { // <- cancellation point: object dead, return, callback silently no-ops + return; + } + Traits::Invoke( + Unwrap(std::forward(functor)), target, + Unwrap(std::get(std::forward(bound)))..., + std::forward(args)...); + } +}; +``` + +The whole secret of cancellation is that one line, `if (!target) return;`. `target` is the receiver unwrapped from `Unwrap(std::get<0>(bound))`. For a native `WeakPtr`, `Unwrap` is a passthrough (the primary template), so `target` keeps its type as `WeakPtr` unchanged. + +Then `if (!target)` goes through `WeakPtr::operator bool` (`weak_ptr.h:255`), which calls `get()`, and inside `get()` the expression `ref_.IsValid() ? ptr_ : nullptr` (`weak_ptr.h:238`) is what actually decides. Unrolled so you can see every link: + +```text +if (!target) + -> target.operator bool() + -> target.get() + -> target.ref_.IsValid() + -> flag_ && flag_->IsValid() <- DCHECK same-sequence + acquire-load +``` + +So the cancellation check in a weak call bottoms out at that same sequence-bound, 100% accurate `IsValid` we covered in [02-4](./02-4-weak-ptr-sequence-affinity-and-lazy-binding.md). The moment the object dies, `get()` hands back `nullptr`, `operator bool` flips to false, and `MakeItSo` returns on the spot. The callback silently no-ops, and nobody so much as touches the dangling pointer. + +--- + +## The key: the check is IsValid, not MaybeValid + +Here's a point that's easy to get wrong, and that a lot of secondhand material gets wrong. The cancellation check in a weak call goes through `IsValid` (same-sequence, accurate), not `MaybeValid`. We drew this boundary back in [02-4](./02-4-weak-ptr-sequence-affinity-and-lazy-binding.md). `IsValid` is the hard gate before a dereference; `MaybeValid` is only an optimistic hint when crossing sequences. The `!target` line in `MakeItSo` flows through `operator bool`, through `get()`, and lands on `IsValid`. That's a deterministic judgment on the bound sequence, and it can promise "if the liveness check passes, the object is genuinely still breathing right now, safe to call." + +So what's `MaybeValid` doing? It travels a **separate channel** and never touches the no-op decision. Chromium's `CallbackCancellationTraits` has a dedicated specialization for weak receivers (`bind_internal.h:1985-2006`) that splits "cancellation query" clean in two: + +```cpp +template + requires internal::kIsWeakMethod<...> +struct CallbackCancellationTraits> { + static constexpr bool is_cancellable = true; + + template + static bool IsCancelled(const Functor&, const Receiver& receiver, const Args&...) { + return !receiver; // same-sequence, via IsValid, accurate + } + + template + static bool MaybeValid(const Functor&, const Receiver& receiver, const Args&...) { + return MaybeValidTraits::MaybeValid(receiver); // cross-sequence, via WeakPtr::MaybeValid + } +}; +``` + +Each channel serves its own master. `IsCancelled(!receiver)` backs `Callback::IsCancelled()`, queried on the bound sequence, and the answer is nailed down. `MaybeValid(receiver.MaybeValid())` backs `Callback::MaybeValid()`, queryable from any sequence at the cost of treating the result as an optimistic estimate. + +The `MaybeValid` line really serves the **scheduler / message loop**. Before a task is dispatched, the scheduler is free to speculatively probe `MaybeValid` from any sequence. If it comes back false, the scheduler knows this callback is definitely pointless and can skip it, saving a cross-sequence post in the bargain. But the moment the callback **actually executes**, the cancellation verdict runs through the `!target` line in `MakeItSo` (that is, `IsValid`). That line is the hard gate, and it's accurate. + +One sentence to pin it down: cancellation has two paths. Execution-time goes through `IsValid` (accurate); the scheduler's speculative probe goes through `MaybeValid` (optimistic). The execution-time path never touches `MaybeValid`. Draw this boundary cleanly and you're already more precise than most secondhand write-ups out there. + +--- + +## Weak calls are forced to return void + +The weak branch hides one more constraint, tucked into `MakeItSo`'s return type: it returns `void`. That's not a coincidence; it's nailed down by the `WeakCallReturnsVoid` static_assert (`bind_internal.h:1028-1040`): + +```cpp +if constexpr (WeakCallReturnsVoid::value) { + // take the InvokeHelper::MakeItSo path +} +``` + +The reason clicks into place once you think about it. Once a callback is cancelled, what executes is `return;` (no value). But what if the method itself returns a value? At the moment of cancellation, what do you hand back? So a weak call **must return `void`**. Write `BindOnce(&Foo::get_value, weak_ptr)` where `get_value` returns `int`, and compile time refuses it outright. This is a textbook case of shutting down ambiguity at the type level. Cancellation semantics demand void, so the signature forces void. No room for fuzzy edges. + +--- + +## The race defense behind "Unwrap before the liveness check" + +Look back at that `MakeItSo` snippet. One comment deserves to be pulled out on its own (`bind_internal.h:949-951`): + +> Note the validity of the weak pointer should be tested _after_ it is unwrapped, otherwise it creates a race for weak pointer implementations that allow cross-thread usage and perform `Lock()` in `Unwrap()` traits. + +Meaning: the step `target = Unwrap(...)` must come **before** `if (!target)`. Why? Some weak-pointer variants allow cross-thread use, and their `Unwrap()` does a `Lock()` (Chromium has weaker-pointer implementations fancier than `WeakPtr` internally). For those, if you check the bool first and then Unwrap, the two steps crack open a race window. The object is alive at the bool check, and gone by the time Unwrap runs. Unwrap first (pulling out the real pointer safely), then check liveness, and the window closes. + +Our own `WeakPtr`'s `Unwrap` is a passthrough with no Lock, so for it the two orders don't matter. But `MakeItSo` is a general template and has to cover the more general weak-pointer implementations, so the comment writes this race defense into the contract. Chromium's `bind_unittest.cc` even keeps a dedicated `MockRacyWeakPtr` around (its `operator bool()` always returns true, its `Lock()` always returns nullptr) precisely to exercise the "Unwrap before liveness check" path. + +--- + +## vs Unretained(this): safe no-op after the fact vs UAF alarm after the fact + +While we're here, let's pull in another idiom that gets conflated all the time: `base::Unretained(this)`. It also binds a member method into a callback, but it takes the **exact opposite** path, `InvokeHelper`, with no liveness check at all: + +```cpp +template +struct InvokeHelper { + template + static inline ReturnType MakeItSo(Functor&& functor, BoundArgsTuple&& bound, + RunArgs&&... args) { + return Traits::Invoke( + Unwrap(std::forward(functor)), + Unwrap(std::get(std::forward(bound)))..., + std::forward(args)...); + // no if (!target) return; -- Run() after the object dies is a dangling deref + } +}; +``` + +`Unretained`'s receiver unwraps to a raw `T*`, and the liveness check is skipped outright. Run the callback after the object dies and you get a UAF. Its only remaining line of defense is the PartitionAlloc backup-ref inside Chromium's `raw_ptr` memory-safety hardening, and that's an after-the-fact alarm, not avoidance. + +The fundamental difference, one sentence: + +> **A `WeakPtr` receiver means the callback silently no-ops after the object dies (safe up front). An `Unretained` receiver means UAF after the object dies (alarm after the fact, or UB).** + +In production code, if there's any chance at all the object might destruct before the callback runs, reach for `WeakPtr` every time. `Unretained` only earns a spot when you can **statically swear** the object outlives the callback (the callback runs synchronously, entirely within the object's scope, that sort of thing). + +--- + +## A teaching version: bolting WeakPtr onto the 01 OnceCallback + +Let's reduce the industrial machinery down to a teaching version and bolt it onto the `OnceCallback` from the [01 series](../../01_once_callback/full/01-1-once-callback-motivation-and-api-design.md). There's nothing to it really, just translating that one `MakeItSo` line: + +```cpp +// Platform: host | C++ Standard: C++20 +// Simplified: bind a member method + WeakPtr into a void() callback. +// (Here we use the 01 series' OnceCallback as the return type; the standalone +// compilable 18_bind_weakptr_cancel.cpp substitutes std::function, same logic.) +template +auto bind_weak_once(void (T::*method)(Bound...), + WeakPtr receiver, + Bound... bound_args) { + return OnceCallback( + [method, receiver = std::move(receiver), + bound = std::make_tuple(std::move(bound_args)...)]() mutable { + if (!receiver) return; // <- corresponds to the cancellation point in InvokeHelper::MakeItSo + std::apply( + [&](auto&&... args) { (receiver.get()->*method)(args...); }, + bound); + }); +} +``` + +It looks austere, but it's isomorphic to the industrial `MakeItSo`: `if (!receiver) return;` lines up with `if (!target) return;`. `receiver` is a `WeakPtr`, and `!receiver` flows through `operator bool`, through `get()`, down to `IsValid`; the moment the object dies it silently no-ops. Let's hang it on the 01 OnceCallback and run it: + +```cpp +class Controller { +public: + void on_work_done(int v) { std::cout << "got " << v << '\n'; } + WeakPtr get_weak() { return weak_factory_.get_weak_ptr(); } +private: + std::vector buf_; + WeakPtrFactory weak_factory_{this}; // last member +}; + +int main() { + WeakPtr alive_marker; + { + Controller c; + // Bind a callback whose receiver is c's WeakPtr. + auto task = bind_weak_once(&Controller::on_work_done, c.get_weak(), 42); + + // Run while c is still alive -> calls on_work_done. + std::move(task).run(); // got 42 + } // c destructs -> weak_factory_ invalidates all WeakPtrs first -> then buf_ destructs. + + // Now bind a fresh one and run it after c is dead. + auto task2 = [&] { + // Pretend we got hold of an already-invalidated WeakPtr (simulating + // the object destructing while the task still sits in the queue). + return bind_weak_once(&Controller::on_work_done, + WeakPtr{}, 99); // empty WeakPtr + }(); + std::move(task2).run(); // silent no-op, prints nothing. + return 0; +} +``` + +You'll see `got 42` printed once. The second task, its receiver invalid, silently no-ops and prints nothing. The dangling-callback bug from 01-4 finally has a real antidote, and on the user's side the only added cost is one `get_weak_ptr()` call. The whole tangle of cancellation complexity is shouldered by `BindOnce` plus `WeakPtr`. + +--- + +## Closing the loop: 01-4 hand-rolled token vs industrial WeakPtr + +The series closes here. Let's put the 01-4 hand-rolled cancellation token and the industrial WeakPtr on the same table, one-to-one: + +| 01-4 hand-rolled scheme | Industrial WeakPtr | +|---|---| +| One atomic flag (sharing across callbacks means manually copying the token) | `WeakReference::Flag` (`RefCountedThreadSafe` + `AtomicFlag`); the factory and all WeakPtrs **share the same one automatically** | +| Manually manage the flag's lifetime | `scoped_refptr` refcount manages it | +| Pre-call `if (!flag.is_set()) return;` | `if (!target) return;` inside `InvokeHelper::MakeItSo` | +| Manually stuff the flag into the callback | `kIsWeakMethod` / `IsWeakReceiver` **automatically** spots a WeakPtr receiver at compile time and picks the weak branch | +| Single check channel | Split into `IsCancelled` (same-sequence, accurate, for execution time) and `MaybeValid` (cross-sequence hint, for scheduler speculation) | +| Post-cancellation behavior: custom | Post-cancellation **forced silent no-op**, and weak calls are **forced to return void** (no value to hand back on cancellation) | + +If you ask me, the two most important leaps are these. First, the factory is bound to the object's identity, and all WeakPtrs share the same Flag automatically, so "one invalidate, every callback drops together" comes for free (in 01-4's hand-rolled version the user still had to copy the token to share it across callbacks, and the flag was a standalone little object with no tie to the object's identity). Second, the wiring happens automatically at compile time (`kIsWeakMethod`). You just write `BindOnce(&C::m, weak_factory_.GetWeakPtr())` and the cancellation machinery slots itself into place. No hand-written if-check required. + +That "loose end" we left in 01-4, how the flag gets passed in and who manages its life, is fully closed here. The flag is that shared Flag. Its life is managed by intrusive refcounting. It travels by WeakPtr handle. And it plugs into callbacks through compile-time wiring. Six prerequisite pieces, plus five hands-on pieces, and we've taken every screw out of the Chromium engineers' design and looked at it. + +--- + +## References + +- [Chromium `base/functional/bind_internal.h`: kIsWeakMethod / InvokeHelper / WeakCallReturnsVoid](https://source.chromium.org/chromium/chromium/src/+/main:base/functional/bind_internal.h) +- [Chromium `base/functional/callback.h`: IsCancelled/MaybeValid](https://source.chromium.org/chromium/chromium/src/+/main:base/functional/callback.h) +- [OnceCallback hands-on (IV): the cancellation token](../../01_once_callback/full/01-4-once-callback-cancellation-token.md) +- [WeakPtr hands-on (IV): sequence affinity and lazy binding](./02-4-weak-ptr-sequence-affinity-and-lazy-binding.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-6-weak-ptr-testing-and-perf.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-6-weak-ptr-testing-and-perf.md new file mode 100644 index 000000000..3f6fd4121 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-6-weak-ptr-testing-and-perf.md @@ -0,0 +1,236 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "Test the teaching WeakPtr against six invariants with Catch2, then measure object size, allocation behavior, call overhead, and the TRIVIAL_ABI payoff against std::weak_ptr and real Chromium." +difficulty: intermediate +order: 6 +platform: host +prerequisites: +- 'WeakPtr hands-on (V): callback integration, closing the OnceCallback loop' +- 'OnceCallback hands-on (VI): tests and performance' +reading_time_minutes: 13 +related: +- 'WeakPtr hands-on (II): the core skeleton and control block' +- 'WeakPtr prerequisite (VI): TRIVIAL_ABI and trivially relocatable' +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- weak_ptr +- 测试 +- 优化 +title: "WeakPtr Hands-on (VI): Tests and Performance Comparison" +--- +# WeakPtr Hands-on (VI): Tests and Performance Comparison + +Code written this far along, and the one thing that scares me is "it runs, but I can't honestly say why." A WeakPtr demo that passes only ever covers the comfortable path where the object is still alive. The cases that actually bite live on the edge: dereferencing after `invalidate`, holding a WeakPtr when the factory destructs, cross-sequence liveness checks returning a false positive. So this piece adds no new feature. Two jobs only: pin every property we care about into a test, then put the teaching version next to `std::weak_ptr` and real Chromium and measure. How big is the object, how many allocations, how far does one liveness check walk, and what does `TRIVIAL_ABI` actually buy. Like [01-6 tests and performance](../../01_once_callback/full/01-6-once-callback-testing-and-perf.md), I trust the measurements, not the assertions. + +--- + +## Six invariants + +What you test are "invariants," properties that have to hold no matter how you poke them. For WeakPtr I count six, and the tests orbit those six. + +The first is the plain one: a WeakPtr minted from a factory, with the object still alive, has to dereference correctly. That's the foundation. Fail this and nothing else matters. The second is move semantics. WeakPtr is move-friendly; after a move the source must be empty, you can't have two handles pointing at the same flag. The third is that liveness checks must fail after invalidate. One `invalidate_weak_ptrs()` goes out, and every already-minted WeakPtr's `get()` and `operator bool` must return null and false. None can slip through. + +The fourth is the one I want singled out: dereferencing an invalidated WeakPtr must trip an assertion. The teaching version uses `assert`, Chromium uses `CHECK` in release. This is a use-after-free in the making, non-negotiable, it has to blow up on the spot. The fifth is the asymmetry of `maybe_valid()`: when called cross-sequence its negative is trustworthy, its positive is not. If it says "invalidated," believe it. If it says "still alive," hedge. The sixth is the knottiest one, and the one that wrecks real code: factory destruction equals invalidating every WeakPtr it ever minted, and the factory as "last member" has to guard the pointed-at object's destructor in reverse. Let that slip and you get the classic UAF, a member half-destructed while someone still holds a WeakPtr and dereferences it. + +--- + +## Catch2 test cases + +My habit is [Catch2](https://github.com/catchorg/Catch2)'s `TEST_CASE` paired with `REQUIRE`, dropping each of the six invariants onto a concrete case. Here are the key ones. These are Catch2-style illustrations; the runnable samples that ship with the project live in `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/`, demos `12` through `18` .cpp. Wiring Catch2 in as a standalone test target I left as an extension; build it yourself if you want. + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include "weak_ptr/weak_ptr.hpp" + +using namespace tamcpp::chrome; + +struct Foo { + int x = 42; + int get() const { return x; } +}; + +TEST_CASE("WeakPtr basic: alive object dereferences correctly", "[weak_ptr]") { + Foo foo{7}; + WeakPtrFactory fac(&foo); + auto wp = fac.get_weak_ptr(); + + REQUIRE(wp); // Invariant 1: object alive, wp is truthy + REQUIRE(wp->x == 7); + REQUIRE(wp.get() == &foo); +} + +TEST_CASE("WeakPtr move: source is empty after move", "[weak_ptr]") { + Foo foo{1}; + WeakPtrFactory fac(&foo); + auto wp1 = fac.get_weak_ptr(); + auto wp2 = std::move(wp1); // Invariant 2: move + + REQUIRE_FALSE(wp1); // source empty after move + REQUIRE(wp2); + REQUIRE(wp2->x == 1); +} + +TEST_CASE("WeakPtr invalidate: all weak ptrs go null", "[weak_ptr]") { + Foo foo{1}; + WeakPtrFactory fac(&foo); + auto wp1 = fac.get_weak_ptr(); + auto wp2 = fac.get_weak_ptr(); + + fac.invalidate_weak_ptrs(); // Invariant 3: batch invalidation + + REQUIRE_FALSE(wp1); + REQUIRE_FALSE(wp2); + REQUIRE(wp1.get() == nullptr); +} + +TEST_CASE("WeakPtr factory destruct: invalidates all weak ptrs", "[weak_ptr]") { + Foo foo{1}; + WeakPtrFactory fac(&foo); + WeakPtr wp; + { + // Simulate factory destruction with an inner scope. + // (For the real "last member" scenario, see BadController/GoodController below.) + } + // Here we test the "doom" semantics of invalidate_weak_ptrs_and_doom directly. + fac.invalidate_weak_ptrs_and_doom(); + REQUIRE_FALSE(fac.has_weak_ptrs()); +} + +TEST_CASE("WeakPtr was_invalidated distinguishes dead-from-nulled", "[weak_ptr]") { + Foo foo{1}; + WeakPtrFactory fac(&foo); + auto wp = fac.get_weak_ptr(); + REQUIRE_FALSE(wp.was_invalidated()); // still alive, not "invalidated" + + fac.invalidate_weak_ptrs(); + REQUIRE(wp.was_invalidated()); // invalidated, not manually reset + + auto wp2 = fac.get_weak_ptr(); + wp2.reset(); + REQUIRE_FALSE(wp2.was_invalidated()); // manual reset does not count as "invalidated" +} + +#if !defined(NDEBUG) +// Debug only: dereferencing an invalidated handle asserts (teaching uses assert; Chromium uses CHECK) +TEST_CASE("WeakPtr deref invalid asserts in debug", "[weak_ptr][.assert]") { + Foo foo{1}; + WeakPtrFactory fac(&foo); + auto wp = fac.get_weak_ptr(); + fac.invalidate_weak_ptrs(); + // Invariant 4: this line should abort under debug. + // In an isolated assert test: REQUIRE_THROWS(wp->x); + // (Catch2 needs a subprocess to isolate abort; in practice use death_test mode.) +} +#endif +``` + +The test design follows the same line as 01-6: each case targets one invariant, it does not enumerate the API. I pulled `was_invalidated` out on purpose because it has to tell "invalidated" apart from "manually reset," and that distinction is the WeakPtr-specific semantics most likely to get written crooked. Get it wrong and callers can't tell "the object is really gone" from "I let go myself." One more trap I've stepped in: the dereference-invalid assertion test can't share a process with the normal cases. Both `assert` and `CHECK` abort, one blast and the whole batch goes red. Either run those cases in a death_test subprocess, or tag them `[.assert]` and run them in isolation. + +--- + +## Performance: object size + +First measure with `sizeof`. Real terminal output, GCC 13, x86-64: + +```cpp +static_assert(sizeof(WeakPtr) == sizeof(void*) * 2); // 16 bytes +``` + +| Type | Size (x86-64) | Composition | +|---|---|---| +| `WeakPtr` (teaching / Chromium) | **16 bytes** | `WeakReference` (=`scoped_refptr`, 1 pointer) + `T*` (1 pointer) | +| `std::weak_ptr` | **16 bytes** | object pointer + control block pointer | +| `std::shared_ptr` | 16 bytes | object pointer + control block pointer | + +All three come out to 16 bytes, looks like a tie. Don't be fooled by the number. What pulls them apart isn't size, it's composition and allocation behavior. + +--- + +## Performance: allocation behavior + +WeakPtr and `std::weak_ptr` match on size, so to find the gap you have to look at how the pointed-at object itself is allocated. Compare how many heap hits it takes to bring a weakly-referenced object into existence: + +| Scheme | Heap allocations | Notes | +|---|---|---| +| `std::weak_ptr` + `std::shared_ptr(new T)` | **2** | one for T, one for the control block | +| `std::weak_ptr` + `std::make_shared()` | 1 | T and control block fused, but a long-lived `weak_ptr` pins the whole block (see [pre-00](./pre-00-weak-ptr-weak-reference-and-lifetime.md)) | +| WeakPtr (pointed-at object carries a `WeakPtrFactory`) | **1** (Flag) + the object allocates however it likes | Flag is intrusive refcount, one allocation; the object isn't forced into shared, it allocates as many times as it needs | + +The part I like most about WeakPtr is that it doesn't force the pointed-at object into any particular allocation scheme. Manage the object the way you already do; attach a factory and the only real overhead is one intrusive Flag allocation inside the factory. `std::weak_ptr` is less generous. Either you pay two allocations honestly, or you take the `make_shared` shortcut for one, at the cost of fusing the object's memory with the control block so that a long-lived weak_ptr drags the whole block along and won't let it free. + +--- + +## Performance: call overhead + +One call to WeakPtr's `get()` walks `ref_.IsValid()` into `flag_->IsValid()` into one `invalidated_.IsSet()`. That is one atomic acquire-load. On the hot path, that's it, nothing else. + +`std::weak_ptr::lock()` is busier: one atomic read of the strong count to check the object is there, then one atomic increment of the strong count to grab it temporarily, and finally it hands back a temporary `shared_ptr`. Heavier than `get()`, and the weight is in that extra atomic increment and the temporary `shared_ptr` it has to construct. + +This is the direct dividend of WeakPtr's "no lifetime extension, only liveness check." It doesn't bump the refcount the way `lock()` does, it just reads the flag. There's no free lunch: the cost is that the returned raw pointer makes no guarantee the object is still alive when you dereference it. That's exactly what the "same-sequence deref" contract is there to backstop. + +--- + +## Performance: the TRIVIAL_ABI payoff + +`TRIVIAL_ABI` pays off at the calling-convention layer. With it, WeakPtr passed by value goes straight into registers instead of detouring through memory. We argued it was safe in [pre-06](./pre-06-weak-ptr-trivial-abi.md). Can we quantify it? + +```cpp +// Pass WeakPtr by value into a function. +void sink(WeakPtr wp) { (void)wp; } +``` + +Annotated `[[clang::trivial_abi]]`, those 16 bytes of argument travel in two registers (on x86-64 SysV, the `rdi`/`rsi` neighborhood). Without the annotation the compiler spills 16 bytes onto the stack and threads an implicit reference through. Once `-O2` simplifies, the former saves one copy in the frame layout plus the destructor's calling-convention overhead. + +A single call saves little. But think about the volume of Chromium's task system, callbacks ferrying WeakPtrs in bulk all day, and the savings stack up fast. That's why Chromium tags both WeakPtr and WeakReference with this attribute and force-inlines `IsSet()` into the header. The source comment says it outright: "measurable performance impact on base::WeakPtr." They measured it. + +--- + +## vs std::weak_ptr: the tradeoff table + +| Dimension | `std::weak_ptr` | `WeakPtr` | +|---|---|---| +| Takes a position on ownership | Yes (must pair with `shared_ptr`) | **No** (object manages itself) | +| Control block / Flag allocation | Non-intrusive (separate, or fused via `make_shared`) | **Intrusive** (one Flag allocation) | +| Thread / sequence model | Atomic ops are safe by themselves, sequencing is on the user | **Sequence-bound** (deref/invalidation on the bound sequence, debug catches it) | +| Cross-thread deref | `lock()` is thread-safe | Must be same-sequence (contract + DCHECK) | +| Batch invalidation | None (each weak_ptr expires independently) | **One `invalidate` drops all** (shared Flag) | +| Call overhead | `lock()` is two atomics + a temporary shared_ptr | `get()` is one atomic acquire-load | +| Size | 16 bytes | 16 bytes (and `TRIVIAL_ABI` lands it in registers) | + +One sentence: `std::weak_ptr` is the general, safe weak reference; `WeakPtr` is tailored for "post tasks, no ownership, serialized execution." Inside a system like Chromium the latter fits the model like a glove. In ordinary general-purpose C++, the former is enough and ships with the standard library. Nobody loses. + +--- + +## vs real Chromium: our tradeoffs + +Same as [01-6](../../01_once_callback/full/01-6-once-callback-testing-and-perf.md): the teaching version cuts corners, and I'll lay the tradeoffs on the table: + +| Dimension | Chromium | Our teaching version | +|---|---|---| +| Flag refcount | `RefCountedThreadSafe` (atomic) | Same (core, not skimped) | +| Atomic flag | `base::AtomicFlag` (wrapper) | `std::atomic` with memory_order (equivalent) | +| Sequence checking | `SEQUENCE_CHECKER` three macros + SequenceToken | Simplified `SequenceChecker` (thread id simulation) | +| `SafeRef` | Full (non-null, dangles-then-crashes) | Not implemented (left as an extension) | +| `BindOnce` integration | Full type erasure + `InvokeHelper` dual specialization | Simplified trampoline + wiring to 01 OnceCallback | +| `TRIVIAL_ABI` | Annotated | Annotated (clang) | +| `InvalidateAndDoom` / `BindToCurrentSequence` | Full | `AndDoom` kept; `BindToCurrentSequence` omitted | + +What I traded away is completeness. No `SafeRef`, `BindToCurrentSequence` cut, a stand-in instead of the real `SequenceToken`. What I bought back is readability and buildability: the teaching version runs on the standard library plus one clang attribute. The mechanisms that actually carry weight, the refcounted Flag, the acquire/release pairing, the sequence contract, the compile-time weak dispatch, I left untouched. I weighed it back and forth, and for the purpose of teaching, the trade reads as fair. + +That closes WeakPtr. Design, implementation, and now verification, the whole arc walked. Look back over the 13 pieces since [pre-00 weak references](./pre-00-weak-ptr-weak-reference-and-lifetime.md) and we really did one thing: take "object lifetime," an old fuzzy engineering intuition, and squeeze it step by step into code that compiles, tests, and has explicit acquire/release pairing. The lesson I kept relearning all the way through fits in one line: every signature, every `requires`, every memory-order pair has to be backed by a specific "why." The ones that can't answer that question will, sooner or later, break in some scene that should have been smooth, and usually in the hardest-to-reproduce way. This line and the OnceCallback line close here. When you go chew on the next industrial-scale component, I hope you carry this set of intuitions with you. + +--- + +## References + +- [Catch2 documentation](https://github.com/catchorg/Catch2/tree/devel/docs) +- [Chromium `base/memory/weak_ptr_unittest.cc`, official tests](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr_unittest.cc) +- [OnceCallback hands-on (VI): tests and performance](../../01_once_callback/full/01-6-once-callback-testing-and-perf.md) +- [cppreference: std::weak_ptr](https://en.cppreference.com/w/cpp/memory/weak_ptr) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-00-weak-ptr-weak-reference-and-lifetime.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-00-weak-ptr-weak-reference-and-lifetime.md new file mode 100644 index 000000000..203ece0b4 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-00-weak-ptr-weak-reference-and-lifetime.md @@ -0,0 +1,213 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 14 +- 17 +description: "From the ownership-vs-lifetime dilemma to what weak references actually solve: how std::weak_ptr works, its four limits, and what Chromium's WeakPtr wants to be" +difficulty: intermediate +order: 0 +platform: host +prerequisites: +- 'OnceCallback hands-on (IV): the cancellation token' +reading_time_minutes: 11 +related: +- 'WeakPtr hands-on (I): motivation and API design' +- 'WeakPtr prerequisite (I): intrusive refcounting and scoped_refptr' +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- 内存管理 +- weak_ptr +title: "WeakPtr prerequisite (0): weak references and the lifetime puzzle" +--- +# WeakPtr prerequisite (0): weak references and the lifetime puzzle + +In [OnceCallback hands-on (IV): the cancellation token](../../01_once_callback/full/01-4-once-callback-cancellation-token.md) we hand-rolled an atomic flag. Zero while the object is alive, flipped to 1 right before destruction, glanced at before the callback runs, no-op if set. The dangling problem went away. But the more we sat with it, the more something else bugged us: who owns that flag? How long does it live? How does the callback actually get hold of it? The tail we waved off back then is the most stubborn kind of problem in C++, lifetime and ownership. + +Object A wants to reference object B without extending B's life, and still be able to ask at any moment whether B is still around. This piece unpacks that: how the standard library's `std::weak_ptr` handles it, why it falls short for async callbacks, and why Chromium rolled its own `WeakPtr` from scratch. + +--- + +## Lifetime: two ends of an ownership spectrum + +Zoom all the way out first. "A references B" in C++ sits on an ownership spectrum with two clean extremes and a whole lot of empty space in between. What we're hunting for is a foothold somewhere in that middle. + +### Strong reference: to reference is to keep alive + +`std::shared_ptr` is the canonical strong reference. It expresses shared ownership: as long as one `shared_ptr` points at B, B cannot die; when the last one leaves, B gets destructed. + +```cpp +auto sp = std::make_shared(); // refcount = 1 +{ + std::shared_ptr sp2 = sp; // refcount = 2 +} // sp2 leaves, refcount back to 1, Foo survives +// sp still around, Foo still alive +``` + +The rule is safe. The cost is real, too: anyone who takes a `shared_ptr` gets a hand in B's lifespan. Say A is a timer and B is the business object A holds a reference to so it can call B's method when the timer fires. What happens if A takes a `shared_ptr`? As long as the timer hangs around, B can never be destructed, even when business logic says B should be gone. A meant to borrow B for a moment and ended up co-owning it. The ownership graph gets muddy, and everyone who later reasons about lifetimes has to squint. + +### Raw pointer: owns nothing, and doesn't notice when the other side is gone + +At the other end is the raw pointer `T*`. It stays entirely out of ownership; B's fate is none of A's business, use it if you want it. Light, yes. Dangerous, also yes: + +```cpp +Foo* p = obj; +obj = nullptr; // somewhere else destructed the object +p->do_something(); // dangling pointer, undefined behavior, likely a segfault +``` + +Note the real problem with raw pointers is not "doesn't extend lifetime", which is exactly what we want. The problem is that it has no way to express "and I still want to check whether the other side is alive." A pointer is an address, and an address doesn't go blank when the object is destroyed. `p` is still `p`, pointing at memory that may already be occupied by some other object. Access it and you get a use-after-free. + +### What we actually want + +Lay the two extremes side by side and what we want becomes clear, somewhere in the middle of the spectrum: + +> Doesn't own, so it doesn't extend lifetime; but the slip of paper in your hand can still tell you whether the other side is gone. + +That is a weak reference: it observes liveness without taking part in the ownership count. The standard library ships `std::weak_ptr`, but it carries nontrivial baggage; Chromium wrote its own `WeakPtr` in `//base`. The job of this series is to take both apart, understand them, and finally hand-build a teaching version ourselves. + +--- + +## std::weak_ptr: the standard library's weak reference + +`std::weak_ptr` entered the standard library in C++11. It has a hard rule: you can only construct it from a `shared_ptr`. You cannot conjure up a `weak_ptr` that points at a stack object or a bare `new`'d object. + +The rule grows out of its mechanism. A `shared_ptr` internally points not only at a raw pointer but also at a separate heap block called the control block, which holds the reference counts. A `weak_ptr` shares that same control block but increments a separate counter, the weak reference count. + +```mermaid +flowchart TB + SP["shared_ptr"] + WP["weak_ptr"] + CB["control block (on heap)
strong count = 1
weak count = 1
ptr to Foo"] + Foo["Foo (on heap)"] + SP --> CB + WP --> CB + CB --> Foo +``` + +Here's the crux: `weak_ptr` doesn't bump the strong count, so it has no say in when Foo destructs. But it does bump the weak count, and that keeps the control block itself alive. That's where the weak reference picks up its distinctive trick: even after the object destructs, it can still answer "is the object gone yet." + +### Three core operations + +```cpp +auto sp = std::make_shared(); +std::weak_ptr wp = sp; // constructed from shared_ptr, doesn't bump strong count + +wp.use_count(); // how many strong refs are left +wp.expired(); // equivalent to use_count() == 0, has the object destructed? +auto locked = wp.lock(); // try to upgrade back to a shared_ptr +``` + +`expired()` tells you whether the object is dead; `lock()` tries to promote the weak reference into a strong one. If the object is still alive it hands you a valid `shared_ptr`; if it's already gone it hands you a null one. + +### Why you must use lock(), not expired() plus construction + +The most natural way for a newcomer to write it is also the easiest way to crash: + +```cpp +std::weak_ptr wp = sp; +// ... somewhere else might release sp ... + +if (!wp.expired()) { + // wp isn't expired here? + sp->do_something(); // wrong! sp may have been released between expired() and this line +} +``` + +The instant `expired()` returns `false` the object is genuinely alive. But between you getting that `false` and actually dereferencing, another thread may release the last `shared_ptr` and trigger destruction. This is the textbook TOCTOU (time-of-check-to-time-of-use) race: the moment you check and the moment you use are separated by an open window. + +The fix is `lock()`. It packs "is it alive" and "promote to a strong reference" into a single atomic operation. Either you get a `shared_ptr` that guarantees the object is alive, or you get a null one. No gap in between: + +```cpp +if (auto locked = wp.lock()) { + locked->do_something(); // locked holds a strong count, object guaranteed alive +} +``` + +This step is the lifeline of safe `weak_ptr` use, and it's worth digesting: `lock()` folds the liveness check and the lifetime extension into one atomic op. Chromium's `WeakPtr` takes a different route. It doesn't extend lifetime at all, it only checks liveness, so it doesn't lean on `lock()`'s "check-plus-extend atomicity" to close the TOCTOU window. Instead it throws down a sequence contract: deref and invalidate must land on the same sequence, and within a sequence tasks run serially, so the window is gone at the root. We unpack that contract in 02-4 (sequence affinity); for now just plant the impression. + +--- + +## make_shared and the control block: a counterintuitive memory detail + +There's a detail worth stopping on here, because it leads straight into the "intrusive vs non-intrusive refcounting" question that motivates the next piece. We'll just plant the seed now. + +`std::shared_ptr`'s control block is non-intrusive: a separate heap allocation, living apart from the object. So a single `std::shared_ptr(new Foo)` is really two heap allocations under the hood, one for Foo and one for the control block. + +```cpp +std::shared_ptr sp1(new Foo); // two heap allocations: Foo + control block +auto sp2 = std::make_shared(); // one heap allocation: Foo and control block packed together +``` + +`std::make_shared` fuses the object and the control block into a single heap allocation, which is the main reason it's faster than `shared_ptr(new)`. But that optimization drags out a counterintuitive side effect: as long as one `weak_ptr` points at it, the entire block (including the chunk the object occupied) is never released, even after the object has long since destructed. + +```cpp +std::weak_ptr wp; +{ + auto sp = std::make_shared(); // one allocation: control block + Foo + wp = sp; +} // sp leaves, Foo destructs, but the control block survives because wp is still around +// Foo's destructor has run, but the memory it occupied is still pinned, because the control +// block is packed together with it +auto sp2 = wp.lock(); // returns an empty shared_ptr, the object is indeed gone +// but that make_shared block only gets released once wp itself is destroyed +``` + +Why? The control block has to outlive every `weak_ptr` (otherwise `weak_ptr` couldn't safely query `expired()`), and `make_shared` chose to bundle the control block and the object into one allocation. Object destruction is not the same as memory release. In a scenario where the `weak_ptr` lives long, you end up dragging along a chunk of "dead but still occupied" memory. + +This isn't a `weak_ptr` bug. It's the fallout of two design choices stacked together: a non-intrusive control block plus `make_shared`'s fused allocation. But it is a real cost, and Chromium's `WeakPtr` sidesteps it with intrusive refcounting. More on that in the next piece. + +--- + +## Four limits of std::weak_ptr in async / callback scenarios + +Zoom back in, to the scenario this series actually cares about: async callbacks and task posting. `std::weak_ptr` is a general, correct design; nobody is disputing that. But drop it into a system built on "post tasks + don't take ownership + serialized execution" and it bumps into you in four places, and each one is a reason Chromium started over. + +### Limit one: must pair with shared_ptr, forcing ownership involvement + +This one stings the most. `weak_ptr` can only come from a `shared_ptr`, which means: you want a weak reference? First rewrite the object to live behind a `shared_ptr`. But plenty of objects have a natural ownership that isn't shared at all. The object belongs to one owner, and when the owner goes it should go; it doesn't need the whole reference-counting apparatus. + +Slap a `shared_ptr` onto an object that has no business being shared and the ownership graph distorts on the spot. What used to be a clean "single owner" becomes "in principle anyone could grab one." Every maintainer who later sees a `shared_ptr` has to stop and wonder: is this a genuine share, or a `shared_ptr` bolted on just to fabricate a `weak_ptr`? That hesitation has a cost you can't see but can definitely feel. + +### Limit two: non-intrusive control block brings allocation overhead + +Covered in the previous section: `shared_ptr` means either two allocations, or `make_shared`'s single allocation that pins memory. For a weakly referenced object created at high frequency (and callback targets are often exactly that), the overhead is not friendly. Chromium went intrusive: the refcount is baked into the object as a member, one allocation and done. More on that in the next piece. + +### Limit three: can't invalidate a batch at once + +Say an object is referenced by a dozen callbacks or timers, each clutching a `weak_ptr`. When the object destructs, those dozen `weak_ptr`s should expire together. But `weak_ptr` has no "actively batch-invalidate" move; they expire simply because the object's last `shared_ptr` went away. In other words, invalidation is a side effect driven by the refcount, not an action you can explicitly call. + +In pure lifetime management that's fine. But the moment you want to express "the object is still alive, but it has entered a state where it must not be called back anymore," `weak_ptr` has nothing to say. In Chromium that kind of need is mundane; `WeakPtr` handles it (`InvalidateWeakPtrs()`, covered in 02-3). + +### Limit four: no sequence affinity + +`std::weak_ptr`'s threading model is "atomic operations are themselves safe; whether a dereference needs synchronization is your problem." In generic code that's a reasonable default. But drop it into a Chromium-style engineering discipline where "tasks run on a sequence, and almost every object recognizes exactly one sequence," and that freedom turns into a footgun. It won't remind you that "this object should only be deref'd on a particular sequence." Sooner or later you forget which dereference needed a lock. + +Chromium wants the inverse. A weak reference can flow between sequences, but dereference and invalidation must land on the bound sequence, and violations get caught at least in debug builds. That's what `WeakPtr`'s `SEQUENCE_CHECKER` is for, expanded in 02-4. + +--- + +## Chromium's trade: the weak reference it wanted + +Stack the four limits together and Chromium's requirements become about as clear as they can get: + +| Limit of `std::weak_ptr` | What Chromium's `WeakPtr` wants | +|---|---| +| Must pair with `shared_ptr` | **Doesn't take ownership**, the object is managed however it was managed, WeakPtr is only an observer | +| Non-intrusive control block | **Intrusive refcount**, the count is an object member, one allocation | +| Can't batch-invalidate | **Shared flag**, one factory invalidate drops every WeakPtr together | +| No sequence affinity | **Sequence-bound**, deref and invalidation must hit the bound sequence, DCHECK in debug | + +That table is the roadmap for the six hands-on pieces that follow. What we're doing is dropping those four requirements into code, line by line: a `RefCountedThreadSafe` flag (intrusive plus cross-sequence safe), a release/acquire atomic pair (safe visibility across sequences), a `WeakPtrFactory` that carries batch invalidation, and a set of `SEQUENCE_CHECKER` macros standing guard over the sequence contract. + +But before that, a few prerequisites need laying down first: how intrusive refcounting actually works (`scoped_refptr` / `RefCountedThreadSafe`, next piece), atomics and memory order (pre-02), sequences and thread affinity (pre-03), and the concepts plus `TRIVIAL_ABI` that WeakPtr leans on (pre-04 through pre-06). This piece is the "why." Get the requirements straight in your head and every implementation step that follows will clearly show which hole it's filling. + +## References + +- [cppreference: std::weak_ptr](https://en.cppreference.com/w/cpp/memory/weak_ptr) +- [cppreference: std::shared_ptr and the control block](https://en.cppreference.com/w/cpp/memory/shared_ptr) +- [cppreference: std::make_shared](https://en.cppreference.com/w/cpp/memory/shared_ptr/make_shared) +- [Chromium `base/memory/weak_ptr.h` design notes](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md new file mode 100644 index 000000000..34d0d9664 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md @@ -0,0 +1,283 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 14 +- 17 +- 20 +description: "Against std::shared_ptr's non-intrusive control block, this piece walks through Chromium's scoped_refptr + RefCountedThreadSafe intrusive refcount, and why WeakPtr::Flag has to be the cross-sequence version." +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- WeakPtr prerequisite (0): weak references and the lifetime puzzle +reading_time_minutes: 12 +related: +- WeakPtr hands-on (II): the core skeleton and control block +- WeakPtr prerequisite (II): std::atomic and memory_order +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- 内存管理 +- RAII +- weak_ptr +- 引用计数 +title: "WeakPtr prerequisite (I): intrusive refcount and scoped_refptr" +--- +# WeakPtr prerequisite (I): intrusive refcount and scoped_refptr + +In the previous piece we left a thread hanging: Chromium's `WeakPtr` parks the "is the object still alive?" state inside a small thing called `Flag`, and that Flag gets shared between the object's owner and every callback holding a WeakPtr, which often run on different sequences. A small object, shared by several parties, that still has to be freed safely. That is exactly the job refcounting was built for. + +Yet Chromium does not use `std::shared_ptr`. It rolls its own intrusive refcount, with a shell called `scoped_refptr` and a base class `RefCountedThreadSafe`. The first time I hit this in the source I was honestly puzzled: the standard library already ships this, so why reinvent it? This piece works through that puzzle. Three things, laid out plainly: how `std::shared_ptr`'s non-intrusive control block differs from an intrusive counter, what the `scoped_refptr` shell actually looks like, and why WeakPtr's Flag has to be the cross-sequence-safe version. + +--- + +## Refcounting: letting several owners share one object + +Set the smart-pointer syntax aside for a second and look at the essence. Refcounting answers one question: an object is held by several owners, so how do we make sure it only gets destructed when the last owner leaves? The mechanism is almost embarrassingly simple, two steps. Pick up a new reference, bump the counter. Drop a reference, decrement; if it hits zero, you were the last one out, so turn off the lights and destruct the object. + +The mechanism itself does not care where the counter lives. But "where the counter lives" is exactly the fork that splits `std::shared_ptr` and Chromium's `scoped_refptr` into two roads, one called non-intrusive, the other intrusive. + +--- + +## Non-intrusive: how std::shared_ptr does it + +`std::shared_ptr` puts the counter outside the object, in its own heap allocation called the control block. The smart pointer stores two pointers internally: one to the object, one to the control block. + +```mermaid +flowchart LR + SP1["shared_ptr"] & SP2["shared_ptr"] --> CB["control block (heap)
strong_count
weak_count"] + CB --> Foo["Foo (heap)"] +``` + +The upside is direct: the object has no idea it is being refcounted. Take any type `T`, drop it into a `shared_ptr`, and `T` does not change a single line. That is the great strength of non-intrusive, it is universal, anything fits. + +The cost lands on allocation. You write `std::shared_ptr(new Foo)` and under the hood there are two heap allocations, one for Foo, one for the control block. `std::make_shared()` folds them into one and saves a malloc, but we covered its side effect in [prerequisite (0)](./pre-00-weak-ptr-weak-reference-and-lifetime.md): hang a long-lived `weak_ptr` off it and the entire object's memory sticks around unreleased. + +There is a sneakier cost. The control block gets attached at runtime, so when all you have is a `T*` there is no way to backtrack to its refcount. That sounds harmless until you hit a question like "am I the sole owner right now?", and then it forces you to detour. + +--- + +## Intrusive: the counter is a member of the object + +Intrusive refcounting flips it around, the counter becomes part of the object itself. The usual move is to have `T` inherit a base class that carries the counter: + +```mermaid +flowchart LR + SR["scoped_refptr"] --> T + subgraph T["T (on the heap)"] + direction LR + M["...members..."] + RC["ref_count_ = 2
← the counter lives in the object"] + end +``` + +Now the pros and cons swap places compared to non-intrusive. The win is one allocation: object and counter are the same chunk, one `new` does it, no extra control block. The more valuable win is that any code holding a `T*` can reach the counter, because the counter is a member; `shared_ptr` given a raw pointer is helpless. The cost is the "intrusion" itself, `T` has to inherit a base class, change a line of code, and not just any type walks in. + +### Comparison table + +| Axis | Non-intrusive (`shared_ptr`) | Intrusive (`scoped_refptr`) | +|---|---|---| +| Counter location | Standalone control block (heap) | Member of the object itself | +| Heap allocations | 2 (or 1 with `make_shared`, but memory is fused) | **1** | +| Object modification needed | None | Must inherit a base class | +| Can a `T*` read the count | No | Yes (`HasOneRef()` etc.) | +| Weak reference | Built-in `weak_ptr` | Must build separately (that is what WeakPtr is for) | + +Chromium picks intrusive fundamentally to crush overhead and to keep one uniform convention. `//base` has a flood of small objects all going through refcounting; shaving one allocation and one pointer indirection per object, at browser scale, is real money. And the uniform intrusive convention makes "read the count straight off a raw pointer" a legitimate operation, which is exactly the capability that `WeakPtr::Flag::Invalidate` needs for its cross-thread destruction exemption. + +--- + +## Hand-rolling a minimal intrusive refcount + +Theory is fine, but it is more fun to build one ourselves and let Chromium's design decisions fall out of it step by step. First the counting base class: + +```cpp +// Platform: host | C++ Standard: C++17 +#include +#include + +// Atomic intrusive refcount base (this is Chromium's RefCountedThreadSafe, +// not the non-atomic RefCounted (that one is for single-sequence objects; +// here we go cross-sequence, so atomics) +class RefCountedThreadSafe { +public: + void add_ref() const noexcept { + ref_count_.fetch_add(1, std::memory_order_relaxed); + } + + bool release() const noexcept { + // release semantics: writes before destruction stay visible to the + // thread that then observes count == 0 + if (ref_count_.fetch_sub(1, std::memory_order_acq_rel) == 1) { + return true; // caller is responsible for delete this + } + return false; + } + + bool has_one_ref() const noexcept { + return ref_count_.load(std::memory_order_acquire) == 1; + } + +protected: + RefCountedThreadSafe() = default; + ~RefCountedThreadSafe() = default; + +private: + mutable std::atomic ref_count_{0}; +}; +``` + +A few things deserve a tap. The counter is `mutable`, because `add_ref` / `release` do not, logically, change the observable state of the object, so they have to be callable on a `const` object. The `acq_rel` on `release` is deliberate: the acquire side synchronizes correctly with other threads' `add_ref` / `release` (it establishes happens-before; "always read the freshest value" is a different claim and would need `seq_cst`), and the release side, when the count drops to zero, publishes every write on this object to whichever thread picks up the `delete`. + +Now have the target type inherit it: + +```cpp +class Flag : public RefCountedThreadSafe { +public: + Flag() = default; + // ... business interface ... +}; +``` + +At this point `Flag` carries its own counter. But a counter alone is not enough, we still need a smart pointer that calls `add_ref` / `release` automatically on copy, move, and destruct. That thing is `scoped_refptr`. + +--- + +## scoped_refptr: the smart-pointer shell for intrusive refcount + +`scoped_refptr` does the same job as `std::shared_ptr`: copy bumps the count, destruct decrements, hit zero and delete the object. The difference is that it does not keep a separate control block; it just calls the `add_ref` / `release` that `T` inherited. A minimal implementation looks like this: + +```cpp +// Platform: host | C++ Standard: C++17 +template +class scoped_refptr { +public: + scoped_refptr() noexcept = default; + + explicit scoped_refptr(T* p) noexcept : ptr_(p) { + if (ptr_) ptr_->add_ref(); + } + + // copy: +1 + scoped_refptr(const scoped_refptr& other) noexcept : ptr_(other.ptr_) { + if (ptr_) ptr_->add_ref(); + } + + // move: no bump, just empty the source + scoped_refptr(scoped_refptr&& other) noexcept : ptr_(other.ptr_) { + other.ptr_ = nullptr; + } + + ~scoped_refptr() { release(); } + + scoped_refptr& operator=(scoped_refptr r) noexcept { // copy-and-swap + swap(r); + return *this; + } + + void swap(scoped_refptr& other) noexcept { + T* tmp = ptr_; ptr_ = other.ptr_; other.ptr_ = tmp; + } + + T* get() const noexcept { return ptr_; } + T& operator*() const noexcept { return *ptr_; } + T* operator->() const noexcept { return ptr_; } + explicit operator bool() const noexcept { return ptr_ != nullptr; } + +private: + void release() noexcept { + if (ptr_ && ptr_->release()) { + delete ptr_; // last reference, destruct + ptr_ = nullptr; + } + } + + T* ptr_ = nullptr; +}; +``` + +The `operator=` here has a point to it: it uses copy-and-swap. The by-value parameter `r` already performs one copy and bumps the count; inside the body it swaps with `this`, and when `r` destructs it releases the old reference `this` was holding. One move handles self-assignment and exception safety in the same stroke. Chromium's `scoped_refptr` is roughly this shape, with more details (inter-type conversions, `raw_ptr` integration, that sort of thing), but the core skeleton matches what we have here. + +Usage is almost identical to `shared_ptr`: + +```cpp +auto p = scoped_refptr(new Flag); // ref_count = 1 +{ + auto p2 = p; // ref_count = 2 +} // p2 destructs, ref_count = 1 +// p is still around, Flag is alive +``` + +But in memory it only allocated once, the `Flag` object itself, with the counter embedded inside. + +--- + +## RefCounted vs RefCountedThreadSafe + +Chromium actually keeps two versions of the refcount base class, and the difference comes down to "atomics or not." + +One is `RefCounted`, the non-atomic version. The counter is a plain integer doing plain add/sub, so it can only be used on a single sequence. It carries a `SequenceChecker` internally that, in debug builds, catches violations like "refcount touched from another sequence." If the count stays at 1 and never gets copied, the object can in fact be moved to another sequence; but the moment `add_ref` / `release` get called concurrently, that is a real data race. The upside is the lowest overhead. + +The other is `RefCountedThreadSafe`, the atomic version. The counter uses atomic instructions (`fetch_add` / `fetch_sub` with `acq_rel`), so multiple sequences and threads can bump and drop concurrently and safely. The cost is higher than the non-atomic version, one atomic op is not the same as one plain add/sub, but for an object shared across sequences it is a hard requirement. + +The tradeoff is plain: stay single-sequence when you can, save the atomic cost; reach for the atomic version only when cross-sequence is unavoidable. Chromium does not just slap `ThreadSafe` on everything. In a browser the vast majority of objects are single-sequence by nature, and stacking atomics on hot paths adds up to real cost. + +### HasOneRef(): the privilege of reading the count off a raw pointer + +Intrusive has one capability non-intrusive cannot match. With nothing but a `T*` in hand, you can ask directly "am I the only reference right now": + +```cpp +bool has_one_ref() const noexcept { + return ref_count_.load(std::memory_order_acquire) == 1; +} +``` + +`shared_ptr` cannot do this, because it needs a `shared_ptr` first before it can reach the control block; hand it a bare `T*` and it has no idea where the control block is. But an intrusive counter grows right out of the object, so a `T*` is enough. + +WeakPtr uses this trick cleverly. As we will see in [02-2 hands-on], `Flag::Invalidate` has this line: + +```cpp +DCHECK(sequence_checker_.CalledOnValidSequence() || HasOneRef()); +``` + +Read it out loud: Invalidate must be called on the bound sequence; but if `HasOneRef()` is true, meaning no WeakPtr is holding this Flag anymore, then it does not matter which sequence destructs it, let it through. That cross-thread destruction opening only exists when two things line up: intrusive refcount, and the ability to read the count off `this`. It is the most concrete, most valuable payoff of intrusive refcounting inside WeakPtr. + +--- + +## A pit you must plug: never let users new/delete directly + +Intrusive refcounting has one iron invariant, the object may only be `delete`d by `release()` at the exact moment the count hits zero, and never directly by outside code. Think it through: a user grabs a `scoped_refptr`, then turns around and `delete p.get()`s it; the counter is still being referenced by other `scoped_refptr`s, but the object is already destroyed. Instant dangling. + +`std::shared_ptr` does not have to worry about this, because the control block holds the object in its grip. Intrusive cannot lean on that; it has to get the object to cooperate. Hide the destructor as `private` or `protected` and leave only the refcount `release` path as the way in. That is exactly what Chromium's `RefCountedThreadSafe` does: through a `friend`-protected `Destroy()` it actually runs `delete this`, and outside code cannot touch the destructor, so mistaken deletes are cut off at the source. + +```cpp +// Teaching version: non-template base (matches RefCountedThreadSafe above at line 123) +// Real Chromium is the template form RefCountedThreadSafe; see note below +class Flag : public RefCountedThreadSafe { +public: + Flag() = default; + +private: + template friend class scoped_refptr; // teaching: scoped_refptr does the delete + ~Flag() = default; // private: outside code cannot delete directly + // ... +}; +``` + +When we hand-roll the teaching version we follow the same principle, a private destructor plus a controlled release path, but one detail has to be said plainly. In real Chromium, `release()` calls a `Destroy()` static method that `RefCountedThreadSafe` befriends, and that runs `delete this`; our simplified version writes `delete ptr_` straight inside `scoped_refptr::~scoped_refptr()`. So in the teaching version Flag's friend is `scoped_refptr`, not `RefCountedThreadSafe`. The companion code `12_intrusive_refcount.cpp` and `weak_ptr.hpp` is written exactly this way and compiles directly. This approach is a twin sibling of RAII: resource acquisition is initialization, and release goes through exactly one controlled path. + +--- + +The two forms of refcounting are now unwound. Non-intrusive `std::shared_ptr` parks the counter in a standalone control block, universal, at the price of one extra allocation; intrusive `scoped_refptr` embeds the counter in the object, one allocation, and hands you "read the count off a `T*`" as a bonus, at the price of the object having to inherit a base class. Chromium bets on intrusive across `//base` to crush overhead and keep one uniform convention. The choice between the two bases, `RefCounted` (non-atomic, single-sequence) and `RefCountedThreadSafe` (atomic, cross-sequence), comes down to "save where you can." + +For WeakPtr specifically, the Flag is shared by WeakPtrs on several sequences, so it has to be `RefCountedThreadSafe`; and the "read the count off `this`" trick in `HasOneRef()` is the premise that lets `Flag::Invalidate` write its cross-thread destruction exemption. The next building block is atomics and memory order, so we can actually understand the `acq_rel` sitting inside `RefCountedThreadSafe`. + +## References + +- [Chromium `base/memory/ref_counted.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/ref_counted.h) +- [Chromium `base/memory/scoped_refptr.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/scoped_refptr.h) +- [cppreference: std::shared_ptr](https://en.cppreference.com/w/cpp/memory/shared_ptr) +- [cppreference: std::atomic and memory_order](https://en.cppreference.com/w/cpp/atomic/memory_order) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-02-weak-ptr-atomic-and-memory-order.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-02-weak-ptr-atomic-and-memory-order.md new file mode 100644 index 000000000..960ac490c --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-02-weak-ptr-atomic-and-memory-order.md @@ -0,0 +1,202 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: "Starts from a data race and works through the six std::memory_order values, focusing on how acquire/release pair up and landing on the release-Set / acquire-IsSet pair inside WeakPtr and AtomicFlag." +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- 'WeakPtr prerequisite (I): intrusive refcount and scoped_refptr' +reading_time_minutes: 13 +related: +- 'WeakPtr hands-on (II): the core skeleton and control block' +- 'WeakPtr prerequisite (III): sequences, SEQUENCE_CHECKER, and DCHECK/CHECK' +tags: +- host +- cpp-modern +- intermediate +- atomic +- memory_order +- 并发 +- weak_ptr +title: "WeakPtr prerequisite (II): std::atomic and memory_order" +--- +# WeakPtr prerequisite (II): std::atomic and memory_order + +In the last piece we hand-rolled a minimal refcount base class, and `add_ref` carried `memory_order_relaxed` while `release` carried `memory_order_acq_rel`. We waved past it at the time and left a loose thread: why not one uniform order? What are these `memory_order_*` things actually doing? + +That thread has to be picked up. The clever part of the whole WeakPtr mechanism is that it takes no lock. It leans on a single `release`/`acquire` pair of atomic operations to guarantee that when one sequence destructs the object, a WeakPtr held on another sequence can never dereference the destroyed object. To absorb that guarantee you need memory order first. It is the foundation the entire deref chain stands on, so this piece walks from data races all the way up to that one pair inside WeakPtr. + +--- + +## Why atomic operations exist: the data race + +Start with a smallest scenario that breaks. Two threads poke an ordinary `int` at the same time: + +```cpp +int counter = 0; +// Thread A +counter++; +// Thread B (same time) +counter++; +``` + +`counter++` looks like one statement. At runtime it is three: read `counter`, add 1, write it back. Once the two threads' three steps interleave, you can get A reads 0, B reads 0, A writes 1, B writes 1. The final result is 1, not 2. That is a data race. By the C++ standard, a program with a data race is undefined behavior right away, not just a wrong answer. The compiler is allowed to reason from the assumption that your program has no data race and optimize freely, so the binary you get can be something you never expected. + +`std::atomic` covers two things. One is atomicity: it squeezes those three steps into one indivisible step nobody can interrupt. The other is visibility and ordering, which governs under what conditions one thread's write becomes visible to another thread, and whether the compiler or CPU is allowed to reorder reads and writes. Most developers have a feel for the atomicity half. The hard half, the half that decides whether your concurrency is actually correct, is the second one. That is `memory_order`. + +--- + +## std::atomic basics + +`std::atomic` gives you atomic `load`/`store`/`exchange`/`compare_exchange` (CAS) plus read-modify-write operations like `fetch_add`/`fetch_sub`: + +```cpp +std::atomic a{0}; +a.store(1, std::memory_order_release); // atomic write +int v = a.load(std::memory_order_acquire); // atomic read +a.fetch_add(1, std::memory_order_relaxed); // atomic +1 +``` + +Each operation can take a `memory_order` argument. Leave it off and you get `std::memory_order_seq_cst`, the strongest and most expensive. C++ defines six orders, weakest to strongest: + +--- + +## The six memory orders + +| memory_order | Applies to | Semantics | +|---|---|---| +| `relaxed` | load/store/RMW | Atomic only; no synchronization, no reorder constraint | +| `consume` | load | Data-dependency acquire (in practice close to acquire; the standard is de-emphasizing it, don't use it) | +| `acquire` | load | Stops later reads/writes from moving before this load; pairs with release to synchronize | +| `release` | store | Stops earlier reads/writes from moving after this store; pairs with acquire to synchronize | +| `acq_rel` | RMW (fetch_*) | Both acquire and release at once | +| `seq_cst` | all | Adds a single global total order; strongest, default, most expensive | + +One piece of advice before we go further: don't try to carve all six into your head in one pass. Real engineering bounces between three combinations: `relaxed` (just counting, no information carried), `acquire`+`release` (single-writer multi-reader synchronization), and `seq_cst` (when you need one globally consistent order). The rest of this piece zooms in on `acquire`/`release`, because that is exactly the pair WeakPtr uses. + +--- + +## relaxed: atomic, but carries no information + +`relaxed` is the lowest-effort order. It makes the operation atomic and nothing else. It puts no constraint on compiler or CPU reordering, and it gives no guarantee about whether surrounding reads and writes become visible to other threads. + +In our experience the natural home for `relaxed` is a pure counter, where you only care that the number adds up and you don't care how it orders against other memory operations: + +```cpp +std::atomic hits{0}; +// Multiple threads each do: +hits.fetch_add(1, std::memory_order_relaxed); // just want the total count right +``` + +The trap hides here too. `relaxed` cannot carry a "the data is ready" signal. If you cut corners and write: + +```cpp +// Thread A +data = 42; // plain write +ready.store(true, std::memory_order_relaxed); + +// Thread B +while (!ready.load(std::memory_order_relaxed)); +assert(data == 42); // NOT guaranteed! May read a stale value of data +``` + +`relaxed` neither blocks the "write `ready` first, then `data`" reordering, nor guarantees that when B reads `ready==true`, `data==42` is visible to it. Whether the assert fires is luck. To carry this kind of signal you have to step up to `release`/`acquire`. + +--- + +## acquire / release: building happens-before + +This is the pair you reach for daily, and it is the hinge concurrency correctness swings on. Two rules, read them once. + +When a thread does a `release` store, every read and write it did before this store is forbidden from being reordered after the store. When a thread does an `acquire` load, every read and write it does after this load is forbidden from being reordered before the load. + +Mate the two ends and it clicks. Thread A finishes a pile of writes, then stores a flag with `release`. Thread B reads that flag with `acquire`. The moment B's acquire reads the value A's release wrote, every write A did before the release becomes visible to B. That edge is called happens-before. + +```cpp +// Platform: host | C++ Standard: C++17 +#include +#include +#include + +int data = 0; +std::atomic ready{false}; + +void producer() { + data = 42; // (1) plain write + ready.store(true, std::memory_order_release); // (2) release: "publish" (1) +} + +void consumer() { + while (!ready.load(std::memory_order_acquire)) { // (3) acquire: wait for release + // spin + } + assert(data == 42); // (4) guaranteed to pass! +} + +int main() { + std::thread t1(producer); + std::thread t2(consumer); + t1.join(); t2.join(); + return 0; +} +``` + +Why is the `assert` guaranteed to pass? Because the acquire at (3) read what the release at (2) wrote, so (1), which happened before (2), is visible to (4), which runs after (3). That is all the release/acquire pair does. It hands "the object is fully written" plus the written data to another thread, atomically and in order. + +### Back to release()'s acq_rel + +The refcount `release` in [prerequisite (I)](./pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md) used `memory_order_acq_rel`, not plain `release`. The reason is simple. `fetch_sub` is a read-modify-write: it reads the old value and writes the new one. `acq_rel` makes it do both. The acquire half sees the latest write any other thread made to this counter, and the release half publishes this thread's writes to the object out to whoever takes over the `delete`. When the refcount hits zero, that handoff is exactly enough. + +--- + +## Back to WeakPtr: AtomicFlag's release/acquire pair + +Tools in hand, we can finally stare precisely at WeakPtr's liveness mechanism. Chromium's `base::AtomicFlag` is a semantically narrowed wrapper around `std::atomic`. Inside WeakPtr it is used with restraint: `Set()` is release, `IsSet()` is acquire, and that is the whole pair: + +```cpp +// base::AtomicFlag simplified equivalent (our teaching version uses std::atomic directly) +class AtomicFlag { +public: + void Set() { + flag_.store(1, std::memory_order_release); + } + bool IsSet() const { + return flag_.load(std::memory_order_acquire) != 0; + } +private: + std::atomic flag_{0}; +}; +``` + +Map it onto WeakPtr's two actions. The invalidation chain runs from `WeakReferenceOwner::Invalidate()` down to `Flag::Invalidate()`, landing on `invalidated_.Set()`, one release-store. It fires at the moment the factory moves the object into an unusable state. There are two typical scenes: either at the start of `WeakPtrFactory`'s own destructor (the "last member" idiom, see [02-3](./02-3-weak-ptr-factory-and-last-member.md)), or when you call `InvalidateWeakPtrs()` explicitly. One ordering detail worth flagging: the factory destructor calls Invalidate first to drop all WeakPtrs, and only then do the other members get destructed. That is how the "last member" idiom guards member-destruction time. It is not the other way around, "destruct first, Invalidate after." + +The liveness-check chain runs the opposite direction. `WeakPtr::get()` calls into `WeakReference::IsValid()`, then `Flag::IsValid()`, which boils down to `!invalidated_.IsSet()`, one acquire-load. + +Drop those two ends into the producer/consumer model from the previous section and you see they are the same pair. Thread A is the factory's sequence. Before destructing the object it calls `Invalidate`, a release-store, publishing "the object is unusable" along with every prior write. Thread B holds a WeakPtr. Before dereferencing it calls `IsValid`, an acquire-load. If B reads invalidated, A's release has taken effect, and every write A's sequence made before that release, including the moves that took the object to unusable, is visible to B. B knows not to deref, and `get()` returns `nullptr`. If B reads not-invalidated, A has not released yet, the object is still breathing at this instant, and B's deref is safe. + +The full secret of "safe deref with no lock" is that one release/acquire pair. It blocks nothing and waits for nothing. It only stands up the happens-before edge "see the invalidation bit implies see every state the object was changed through," and leaves the rest to the atomic read itself. + +### Why not relaxed, and why not seq_cst + +`relaxed` is dead on arrival. The `data`/`ready` counterexample earlier already killed it: atomic without synchronization means the acquire side can read `ready==true` yet miss the state the object was in before it got destructed. WeakPtr would leak the check. + +`seq_cst` does work, but the cost is not worth it. It requires every `seq_cst` operation to agree on one globally consistent total order. On x86 that calls for stronger instructions. Plain acquire/release on x86 are essentially ordinary loads and stores, but a `seq_cst` store has to carry an `MFENCE` or a `LOCK` prefix. WeakPtr's deref is a hot path; every `get()` reads the flag once. Swapping in `seq_cst` blows that cost up for no reason. acquire/release is the "exactly enough" choice here: the synchronization stands up, and you skip the bill for `seq_cst`'s global total order. The Chromium source comment emphasizes "IsSet must stay inline, it has measurable perf impact on WeakPtr" for exactly this reason. + +### Why MaybeValid is also acquire + +In 02-4 we will meet `MaybeValid()`, a "optimistic liveness" query offered to cross-sequence callers. You see the name "maybe" and your first thought might be "fine, slack off with `relaxed`." No. Internally it is still `!invalidated_.IsSet()`, still an acquire read, for exactly the same reason as above. The moment it reads "invalidated," the caller has to be able to swear that every write the other sequence made to push the object into the invalidated state is visible on this side. The "maybe" in MaybeValid is not about memory order. It is about not checking sequence binding. You can call it from any sequence, and it only promises the negative result is trustworthy: reading invalidated means really invalidated. The positive result (reading not-invalidated) does not promise your subsequent operations are still safe. That distinction we save for 02-4. + +--- + +That covers memory order. One last piece of concurrency foundation is still missing: sequences and `SEQUENCE_CHECKER`, plus the line between `DCHECK` and `CHECK`. Once those three foundations are down, in 02-2 we can start stacking Flag, WeakReference, and WeakPtr up layer by layer. + +## References + +- [cppreference: std::atomic](https://en.cppreference.com/w/cpp/atomic/atomic) +- [cppreference: std::memory_order](https://en.cppreference.com/w/cpp/atomic/memory_order) +- [Herb Sutter — `atomic<>` Weapons](https://channel9.msdn.com/Shows/Going+Deep/Cpp-and-Beyond-2012-Herb-Sutter-atomic-Weapons-1-of-2) +- [Chromium `base/synchronization/atomic_flag.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/synchronization/atomic_flag.h) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-03-weak-ptr-sequence-checker-dcheck-check.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-03-weak-ptr-sequence-checker-dcheck-check.md new file mode 100644 index 000000000..0748ca374 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-03-weak-ptr-sequence-checker-dcheck-check.md @@ -0,0 +1,123 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: "Drawing the line between threads and sequences, the three SEQUENCE_CHECKER macros, why they vanish to zero cost in release, and the DCHECK vs CHECK tradeoff that explains why WeakPtr's operator* uses CHECK." +difficulty: intermediate +order: 3 +platform: host +prerequisites: +- 'WeakPtr prerequisite (II): std::atomic and memory_order' +reading_time_minutes: 11 +related: +- 'WeakPtr hands-on (IV): sequence affinity and lazy binding' +tags: +- host +- cpp-modern +- intermediate +- atomic +- 并发 +- weak_ptr +title: "WeakPtr prerequisite (III): sequences, SEQUENCE_CHECKER, and DCHECK/CHECK" +--- +# WeakPtr prerequisite (III): sequences, SEQUENCE_CHECKER, and DCHECK/CHECK + +[Prerequisite (II)](./pre-02-weak-ptr-atomic-and-memory-order.md) settled the visibility problem: two sequences poking a flag at the same time, acquire/release makes sure both see it. But WeakPtr carries one more contract, written in the comment at the top of `weak_ptr.h`. I skimmed right past it the first time, only came back to read it carefully after it bit me. **A weak pointer can be handed around across sequences freely, but dereferencing it and invalidating it must happen on the sequence it was bound to.** + +Atomic operations cannot enforce this. It takes a set of macros called `SEQUENCE_CHECKER`: debug builds catch violations, release builds let it evaporate to zero overhead. This piece walks through that machinery and, along the way, makes the `DCHECK` and `CHECK` tradeoff clear. Once those two click, you can read why WeakPtr puts a `CHECK` on `operator*` but only dares put a `DCHECK` on `IsValid`. + +## Threads vs sequences: Chromium's concurrency model + +First, a common misunderstanding. The moment people hear "thread-safe," most picture "several threads pile on at once." Chromium does not think about it that way. Its concurrency model is built on **sequences**. + +What is a sequence? Think of it as a virtual thread. Tasks inside it run one after another, the next one only starts when the previous one finishes. But the virtual thread itself is not nailed to any OS thread; it can hop between physical threads, as long as two of its tasks never overlap. + +Why the detour? I wondered the same thing at first, why not just use threads. It clicked eventually. The thing most objects in a browser actually want is "I want to be accessed serially, and I don't care which thread serves me." Use a real thread and you inherit its TLS, its message pump, its lifecycle. Use a sequence and all of that becomes the task scheduler's problem; you only say "this batch of code belongs to one sequence" and the scheduler guarantees they don't overlap. We covered this in [OnceCallback hands-on (I)](../../01_once_callback/full/01-1-once-callback-motivation-and-api-design.md). The one-line summary: **message passing over locks, serialization over threads.** + +WeakPtr's contract sits on this ground. Pass a weak pointer around as much as you like, hand a `WeakPtr` to a thread pool and post a callback back, nobody stops you. But **the moment you actually dereference it, or tell the factory to invalidate**, you must be back on the bound sequence. Otherwise deref and invalidate collide head-on, and that is a race. + +--- + +## SEQUENCE_CHECKER: it watches "mutual exclusion," not "same thread" + +The name `SequenceChecker` is misleading, it reads like it checks "are we on the same thread." It actually watches something lower-level, **mutual exclusion**. The first time a `SequenceChecker` is touched it remembers the current context. Every later touch, it checks one thing: is the context I'm in now mutually exclusive with the one I recorded? + +"Mutually exclusive" in Chromium has three sources, any one is enough: same physical thread, same sequence (same `SequencedTaskRunner`), or the same tracked lock. The judgment uses `SequenceToken`, which lives in thread-local storage. Tasks from the same sequence carry the same token when they get scheduled out, so `SequenceChecker` compares tokens and knows. + +Where this pays off: the nastiest class of concurrency bug is "I thought this ran serially, but it ran concurrently." Hard to reproduce, you can hit it a hundred times in the debugger with no crash, then a user clicks once in production and it blows up. `SequenceChecker` lets you catch that crowd almost for free in a debug build. + +## The three macros: how to use them + +Chromium wraps `SequenceChecker` into three macros. The debug/release difference hides entirely behind them: + +```cpp +class Controller { +public: + void do_work() { + DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); // I must run on the bound sequence + // ... work ... + } + +private: + SEQUENCE_CHECKER(sequence_checker_); // declare a checker member +}; +``` + +`SEQUENCE_CHECKER(name)` declares the member, you can put it anywhere, member or local variable. `DCHECK_CALLED_ON_VALID_SEQUENCE(name)` is the one that actually works; it bets you're on the bound sequence right now. If not, a debug build prints the call stack and aborts, a release build plays dead and does nothing. `DETACH_FROM_SEQUENCE(name)` is an explicit unbind; you tell the checker "I'm not bound to any sequence yet, bind on the next `DCHECK_CALLED_ON_VALID_SEQUENCE`." It's for the case where the object doesn't know which sequence it'll serve when constructed, which is common. + +WeakPtr's `Flag` is the textbook example of this pattern. Its `sequence_checker_` detaches the moment it's constructed, meaning "I don't know which sequence I belong to yet." It binds the first time `IsValid` or `Invalidate` touches it, and every access after that has to land on the same sequence. That is WeakPtr's lazy sequence binding, and I'll take it apart properly in 02-4. + +## All no-ops in release: where zero cost comes from + +Here is a fact that's easy to miss and matters a lot: **in a release build (`DCHECK_IS_ON()` false), all three macros compile to nothing.** + +One by one. `SEQUENCE_CHECKER(name)` expands to a `static_assert(true, "")` placeholder, not half a byte of member, `sizeof(Controller)` doesn't grow because of it. `DCHECK_CALLED_ON_VALID_SEQUENCE(name)` expands to a no-op placeholder (in real Chromium this is `EAT_CHECK_STREAM_PARAMS(...)`, its job is to swallow the attached stream parameters and check nothing). `DETACH_FROM_SEQUENCE(name)` likewise leaves nothing behind. + +I paused the first time I read that. Debug catches violations for free, release pays exactly zero for these checks, the code runs full speed with nothing in the way. That's the whole point of the `DCHECK` system: **catch bugs in development, zero overhead in production.** + +But zero cost has a price. Because release builds have no enforcement at all, WeakPtr's "sequence contract" in release **rests entirely on developer discipline**. `MaybeValid()` (02-4) is the one query interface deliberately unbound to a sequence, callable from anywhere, but its return value is an "optimistic estimate" at best. Violate the contract in release and the program won't crash for you. It'll hand you a race at some inconvenient moment instead. So in a debug build, every DCHECK matters. That's close to your only shot at catching this kind of bug. Let one slide and it's gone. + +## DCHECK vs CHECK: abort in debug only, or abort in release too + +`DCHECK` and `CHECK` are two tiers of Chromium's assertion system. The difference is one line. + +`DCHECK(expr)` checks `expr` only in a debug build (`DCHECK_IS_ON()`), aborts if it fails. In release, the whole expression including `expr` is never evaluated, it evaporates. `CHECK(expr)` doesn't care, debug or release both check it, both abort on failure. + +Put it plainly: `DCHECK` is a "development contract," `CHECK` is a "red line production also holds." Which to pick comes down to one question: if this assertion fails, can you tolerate release keeping on running. Logic is wrong but continuing won't immediately cause a memory-safety problem, use `DCHECK`, catch in debug, let go in release, save the cost. If a failed assertion means "next thing that happens is a use-after-free or worse undefined behavior," then release has to stop right now too, and that's `CHECK`. + +### WeakPtr's call: CHECK on operator*, DCHECK on IsValid + +With that principle in hand, look at two key WeakPtr assertions. They point opposite ways, but the logic behind them is the same one. + +`operator*` and `operator->` use `CHECK`, release aborts too: + +```cpp +T& operator*() const { + CHECK(ref_.IsValid()); // invalidated and still dereferenced → release also aborts + return *ptr_; +} +``` + +Why `CHECK` here? Think it through. Dereferencing an already-invalidated WeakPtr means you're holding a pointer to an object that **may have destructed long ago**, and you're about to touch its memory. That's the direct predecessor of use-after-free; letting the program keep running with a dangling pointer can produce any kind of garbage. This one has to blow up in release too, no slack. So `CHECK`: dereference an invalidated weak pointer, debug or release, the program stops right there, you get a clean crash instead of an untraceable UAF mess. + +`IsValid` uses `DCHECK`, debug only: + +```cpp +bool Flag::IsValid() const { + DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); // sequence violation → debug only + return !invalidated_.IsSet(); +} +``` + +Why step down a tier here? A sequence violation is a **usage contract** problem, not an "immediate memory safety" problem. Call `IsValid` from the wrong sequence and the result may be stale, you read a state that's behind. But it doesn't itself dereference a dangling pointer; the genuinely dangerous deref is already locked down at the `operator*` layer with `CHECK`. So `IsValid` uses `DCHECK` here: catch the sequence violation in debug to help you find the bug, let it go in release to save the check. And besides, the atomic operations themselves guarantee `IsValid`'s read won't tear, so even if release misses a sequence violation, you won't get a data-race-level UB out of it. + +Put the two side by side and WeakPtr's safety layering stands up: **memory safety (deref of an invalidated handle) uses `CHECK`, the sky can fall and it still holds; usage contract (sequence binding) uses `DCHECK`, caught in development, trusted to the developer in production.** Every assertion in the hands-on pieces ahead will run into this same call, so get it clear in your head early. + +## References + +- [Chromium `base/sequence_checker.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/sequence_checker.h) +- [Chromium threading & sequences documentation](https://chromium.googlesource.com/chromium/src/+/main/docs/threading_and_tasks.md) +- [cppreference: std::atomic and memory_order](https://en.cppreference.com/w/cpp/atomic/memory_order) +- [Chromium `base/check.h` — CHECK/DCHECK](https://source.chromium.org/chromium/chromium/src/+/main:base/check.h) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-04-weak-ptr-concepts-and-requires.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-04-weak-ptr-concepts-and-requires.md new file mode 100644 index 000000000..aba7e8690 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-04-weak-ptr-concepts-and-requires.md @@ -0,0 +1,177 @@ +--- +chapter: 0 +cpp_standard: +- 20 +description: "Builds on the 01 series concepts foundation and zooms in on how WeakPtr uses std::convertible_to and a member-function requires clause to weld const correctness and upcast legality into the type signature" +difficulty: intermediate +order: 4 +platform: host +prerequisites: +- OnceCallback prerequisites (IV): Concepts and requires constraints +- WeakPtr prerequisite (0): weak references and the lifetime puzzle +reading_time_minutes: 10 +related: +- WeakPtr hands-on (I): motivation and API design +- WeakPtr prerequisite (V): template friend and uintptr_t type erasure +tags: +- host +- cpp-modern +- intermediate +- concepts +- 类型安全 +- weak_ptr +title: "WeakPtr prerequisite (IV): concepts and requires inside WeakPtr" +--- +# WeakPtr prerequisite (IV): concepts and requires inside WeakPtr + +## Lay the tools on the table first + +The [previous piece](../../01_once_callback/full/pre-04-once-callback-concepts-and-requires.md) already covered the basics of concepts: how a `requires` clause attaches, how constraints short-circuit. We won't repeat that here. Instead we go straight to the two real jobs WeakPtr delegates to concepts: policing whether an upcast is legal (can a `WeakPtr` feed a `WeakPtr`), and enforcing const correctness (a const factory is not allowed to hand out a mutable weak pointer). + +Both spots are only a handful of lines in Chromium's `weak_ptr.h`, and we nearly slid past them on the first read. They are the most typical engineering use of concepts. The reader glances at the `requires(...)` on the signature and immediately knows under what type relationships that constructor exists, with no comment-chasing or implementation-diving. We recap the idea briefly, then take each in turn. + +One-line recap (details back in the 01 piece): a concept is a compile-time predicate, and `requires(expr)` hangs it on a template parameter or member function, meaning "this template / overload only exists when the predicate is true." WeakPtr leans on two predicates here. `std::convertible_to` asks whether `U*` can implicitly convert to `T*`. Same type and derived-to-public-base both count, which is exactly the upcast case. `std::is_const_v` asks whether `T` has a top-level const, and paired with `!` it separates `WeakPtrFactory` from `WeakPtrFactory`. + +--- + +## The converting constructor: WeakPtr\ to WeakPtr\ upcast + +Start with the plainest need. You hold a `WeakPtr` and need a `WeakPtr`. The instinct is that it should just work. `Derived*` already converts to `Base*`. The reverse does not (`Base*` cannot stretch into `Derived*`), and nonsense like `WeakPtr` to `WeakPtr` is even less thinkable. + +Chromium carves that rule into the signature with one `requires` clause (`weak_ptr.h:211-214`): + +```cpp +template +class WeakPtr { +public: + // ... + template + requires(std::convertible_to) + WeakPtr(const WeakPtr& other) : ref_(other.ref_), ptr_(other.ptr_) {} + + template + requires(std::convertible_to) + WeakPtr(WeakPtr&& other) + : ref_(std::move(other.ref_)), ptr_(std::move(other.ptr_)) {} + // the corresponding operator= follows the same pattern +}; +``` + +A few details to catch. First, this is a member template, not an ordinary constructor. The outer `WeakPtr` already fixed `T`; the inner layer templates out a fresh `U`, meaning "construct me from any `WeakPtr`." Then `requires(std::convertible_to)` hangs on that member template, and the overload only participates when the pointers convert. One more spot that's easy to miss: this is a separate path from the default copy and move constructors (the comment spells out "separate from the (implicit) copy and move constructors"). Don't tangle them together. + +The effect looks like this: + +```cpp +struct Base { virtual ~Base() = default; }; +struct Derived : Base {}; + +WeakPtr wd = factory_derived.get_weak_ptr(); +WeakPtr wb = wd; // OK: Derived* -> Base* is legal + +WeakPtr wb2 = wb; // OK: same type, also passes convertible_to (B* -> B*) +WeakPtr wd2 = wb; // FAIL: Base* -> Derived* is illegal, this constructor drops out, compile error +WeakPtr wi = wb; // FAIL: Base* -> int* is illegal, compile error +``` + +The last two errors die at compile time. Because this uses concepts rather than SFINAE, the compiler points straight at "constraints not satisfied" instead of dumping a template-substitution stack on you. Move the type contract onto the signature, and whether a conversion is allowed is readable right there. + +### Why not SFINAE + +The old way looked like this: + +```cpp +// Old-style SFINAE: poor readability, awful error messages +template >> +WeakPtr(const WeakPtr& other) : ref_(other.ref_), ptr_(other.ptr_) {} +``` + +Functionally equivalent. But the `typename = std::enable_if_t<...>` trick of jamming a fake default template parameter in reads far worse than `requires(...)`, and on a constraint failure the compiler can only dig through substitution-failure details for you. Once Chromium moved to C++20, new code went concepts across the board, and these few lines in WeakPtr are the migrated product. + +--- + +## Member-function requires: const correctness and the mutable overload + +WeakPtrFactory has a more interesting move. It hangs `requires` directly on a member function and picks the overload based on whether `T` is const. Look at `GetWeakPtr` (`weak_ptr.h:374-384`): + +```cpp +template +class WeakPtrFactory : public internal::WeakPtrFactoryBase { +public: + // const overload: factory is const, can only hand out WeakPtr + WeakPtr GetWeakPtr() const { + return WeakPtr(weak_reference_owner_.GetRef(), + reinterpret_cast(ptr_)); + } + + // non-const overload: factory is not const, hands out WeakPtr (mutable) + WeakPtr GetWeakPtr() + requires(!std::is_const_v) + { + return WeakPtr(weak_reference_owner_.GetRef(), + reinterpret_cast(ptr_)); + } + // ... +}; +``` + +There's a small trick here we did not catch on the first pass. `WeakPtrFactory` carries two `GetWeakPtr` overloads at once. One is a `const` member function returning `WeakPtr`; the other is a non-`const` member function returning `WeakPtr`, and that second one carries `requires(!std::is_const_v)`. + +Why does it need that `requires`? Think about `WeakPtrFactory`. Now `T = const Foo`, so `std::is_const_v` is true. Without the constraint, the non-const `GetWeakPtr()` would instantiate as `WeakPtr GetWeakPtr()` (a non-const member) with the same return type as the const version but different constness, and overload resolution either goes ambiguous or picks wrong. `requires(!std::is_const_v)` cuts this non-const overload out when `T` is itself const, leaving only the const version. The semantics clean up: if the factory is const, or `T` is const, you only ever get `WeakPtr`; only when the factory is non-const and `T` is non-const do you get a mutable `WeakPtr`. + +This constraint shoves const correctness deep into the type system. From a const object, you cannot obtain a `WeakPtr` that points at its mutable state at the type level. No runtime discipline needed, the compiler minds it for you. `GetMutableWeakPtr()` (`weak_ptr.h:386-391`) uses the same `requires(!std::is_const_v)` to guarantee the "mutable" path exists only when the type allows it. + +### A minimal reproduction + +Let's roll our own minimal version and verify the constraint really bites at compile time: + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include + +struct Base { virtual ~Base() = default; }; +struct Derived : Base {}; + +template +class MiniWeakPtr { +public: + MiniWeakPtr() = default; + // upcast converting constructor + template + requires(std::convertible_to) + MiniWeakPtr(const MiniWeakPtr&) {} +}; + +int main() { + MiniWeakPtr wd; + MiniWeakPtr wb = wd; // OK + // MiniWeakPtr wd2 = wb; // FAIL: Base* -> Derived* does not satisfy convertible_to + return 0; +} +``` + +Uncomment that line and the compiler jumps on it (Clang reports `constraints not satisfied`, GCC reports `conversion from ... to non-scalar type ... requested`; the wording differs, the meaning is the same: "constraint not satisfied"). Which type relationships are legal and which are not has moved out of comments and into the compiler's checklist. + +--- + +## Why this deserves its own piece + +You might be muttering: it's two lines of `requires`, does it really need a whole piece? It does. These two spots are the safety net sitting on WeakPtr's type signature, and they carry more weight than they look. + +The converting-constructor constraint blocks "doing an unsafe downcast through WeakPtr." This is an overlooked UAF entry point. Downcast to the wrong type and one dereference is an out-of-bounds access or UB, and at runtime you won't catch it. Hang `requires(std::convertible_to)` on it and that class of error dies at compile time. The const-overload constraint minds the other end: it makes the rule "a const object cannot be mutated through its weak reference" something the type system holds up for us, with nobody standing guard. + +The deeper value is that they model the engineering use of concepts. Not a parlor trick. Semantic constraints written out in the type system so the interface explains itself. When we build the skeleton in 02-2 we copy these two `requires` clauses almost verbatim from the Chromium source. + +--- + +That covers both uses of concepts inside WeakPtr. The converting constructor carries `requires(std::convertible_to)`, making `WeakPtr -> WeakPtr` legal and rejecting the reverse and unrelated conversions at compile time. The member functions `GetWeakPtr` / `GetMutableWeakPtr` carry `requires(!std::is_const_v)`, pushing const correctness into the type system so a const object cannot obtain a mutable weak pointer. Both demonstrate the same point: the real payoff of concepts is moving semantic contracts onto the signature, so the compiler minds the rules a human used to have to. + +WeakPtr has one more template trick up its sleeve, using `template friend` to solve cross-type private access, and there's the question of why `WeakPtrFactory` stores its pointer as a `uintptr_t`. That's in the piece right after this one. + +## References + +- [cppreference: std::convertible_to](https://en.cppreference.com/w/cpp/concepts/convertible_to) +- [cppreference: requires clause](https://en.cppreference.com/w/cpp/language/constraints) +- [OnceCallback prerequisites (IV): Concepts and requires constraints](../../01_once_callback/full/pre-04-once-callback-concepts-and-requires.md) +- [Chromium `base/memory/weak_ptr.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-05-weak-ptr-template-friend-and-uintptr-t.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-05-weak-ptr-template-friend-and-uintptr-t.md new file mode 100644 index 000000000..e57331561 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-05-weak-ptr-template-friend-and-uintptr-t.md @@ -0,0 +1,183 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 14 +- 17 +- 20 +description: "Two template-engineering tricks behind WeakPtr, unpacked: template friend for cross-type private access, and uintptr_t to sink pointer storage into a non-template base and curb template bloat, plus the RAW_PTR_EXCLUSION tradeoff." +difficulty: intermediate +order: 5 +platform: host +prerequisites: +- 'WeakPtr prerequisite (IV): concepts and requires in WeakPtr' +- 'WeakPtr prerequisite (I): intrusive refcount and scoped_refptr' +reading_time_minutes: 10 +related: +- 'WeakPtr hands-on (II): the core skeleton and control block' +tags: +- host +- cpp-modern +- intermediate +- 模板 +- 泛型 +- 内存管理 +- weak_ptr +title: "WeakPtr prerequisite (V): template friend and uintptr_t type erasure" +--- +# WeakPtr prerequisite (V): template friend and uintptr_t type erasure + +Two details in WeakPtr have been sitting off to the side while we covered everything else, and now they need their own turn. One hides inside the converting constructor: when `WeakPtr` gets promoted to `WeakPtr`, the constructor reaches straight into the other type's private members, and what makes that legal is a single `template friend` line. The other sits in `WeakPtrFactory`'s base class, which stores the pointer as `uintptr_t` rather than `T*`. That sounds pointless until you chase it down, and then it turns out to be a real move for keeping template bloat in check. + +Neither of these is language-level showmanship. Once we are through them, the slightly twisted class hierarchy of WeakPtr falls into place. + +--- + +## Cross-type friendship: `WeakPtr` reaching into `WeakPtr` + +Here again is the converting constructor from [prerequisite (IV)](./pre-04-weak-ptr-concepts-and-requires.md): + +```cpp +template + requires(std::convertible_to) +WeakPtr(const WeakPtr& other) : ref_(other.ref_), ptr_(other.ptr_) {} +``` + +The first time through we did not see anything off about it, until it clicked that it reads `other.ref_` and `other.ptr_` directly, and those are private members of `WeakPtr`. Private means only the class itself and its friends can touch them; and `WeakPtr` and `WeakPtr` (when `U != T`) are two completely different types that cannot see into each other by default. + +So how does this compile? Through one line of template friendship (`weak_ptr.h:291-292`): + +```cpp +template +class WeakPtr { +public: + // ... +private: + template + friend class WeakPtr; // every WeakPtr is a friend of WeakPtr + + internal::WeakReference ref_; + RAW_PTR_EXCLUSION T* ptr_ = nullptr; +}; +``` + +The line `template friend class WeakPtr;` says: whatever `U` you plug in, the resulting `WeakPtr` counts as a friend of this class. One stroke, and the private walls between every instantiation come down. + +This is a routine move in templated code. Whenever "the same template, different instantiation" needs to reach across and touch private state (converting constructors, `WeakPtr` and `WeakPtr` copying internals back and forth), template friend is the answer. The only thing separating it from a plain `friend class Foo;` is the extra `template `, and the meaning is "list every instantiation of this template as a friend." + +For completeness, Chromium also lists `WeakPtrFactory` as a friend (`weak_ptr.h:293-294`), because the factory has to directly construct `WeakPtr` and write its private members when it mints one. That friend list is, in essence, the roster of "who needs to touch the internals." + +--- + +## uintptr_t: storing a pointer as an integer + +The class hierarchy of `WeakPtrFactory` looks like this (`weak_ptr.h:332-340`): + +```cpp +namespace internal { +class BASE_EXPORT WeakPtrFactoryBase { +protected: + WeakPtrFactoryBase(uintptr_t ptr); + ~WeakPtrFactoryBase(); + internal::WeakReferenceOwner weak_reference_owner_; + uintptr_t ptr_; +}; +} // namespace internal + +template +class WeakPtrFactory : public internal::WeakPtrFactoryBase { +public: + explicit WeakPtrFactory(T* ptr) + : WeakPtrFactoryBase(reinterpret_cast(ptr)) {} + // ... +}; +``` + +Look at the `uintptr_t ptr_;` line. The base class does not store the pointer as `T*`; it stores it as an integer. The derived constructor flattens the `T*` into an integer with `reinterpret_cast(ptr)` on the way in, and later, when `GetWeakPtr` reads it back, it casts the other way with `reinterpret_cast(ptr_)` (`weak_ptr.h:375,383`). + +Could it just store `T*`? Functionally, yes. So why the detour? To curb template bloat. + +`WeakPtrFactory` is a template, and every distinct `T` gets its own full instantiation of the class. If `ptr_` were `T*`, then "store pointer, read pointer", an operation that has nothing to do with `T`, would get generated once per `T`. And the generated machine code would be identical every time, because a pointer is a pointer and `T*` at the machine level is just an address. + +Swap `ptr_` for `uintptr_t` (an integer type that says nothing about `T`) and sink it into a non-template base `WeakPtrFactoryBase`, and the trick lands: the "store a pointer" logic now belongs to a non-template base, so no matter what `T` is, that code is generated exactly once. The template-derived class keeps only the work that actually involves `T`: type conversion, the return type of `GetWeakPtr`. Pulling "type-agnostic grunt work" out of the template and dropping it into a non-template base is an old lever for shrinking binary size. In something like a browser, with thousands of `WeakPtrFactory` instantiations, the bytes saved are visible. + +The safety side is fine too. `reinterpret_cast` round-tripping between `T*` and `uintptr_t` is recognized by the standard; converting between pointers and a sufficiently large integer type is exactly what `uintptr_t` was put in the language to do. The one precondition is that the `T` cast back out matches the `T` originally stored, and that is enforced by `WeakPtrFactory`'s own type. + +--- + +## RAW_PTR_EXCLUSION: why `ptr_` does not use raw_ptr + +This section is a Chromium peculiarity, but we think it earns its own turn, because it lays out the case of "a general-purpose tool that actively backfires in one specific spot" very cleanly. + +When Chromium hardened `//base` for memory safety, it swapped a lot of raw `T*` for `raw_ptr`, a smart-pointer wrapper that hangs a PartitionAlloc backup-ref count off the pointee. The win is use-after-free detection: freed memory does not get reused right away, it sits in a quarantine, and if anything reaches in after free it gets caught on the spot. + +But `WeakPtr::ptr_` does not use `raw_ptr`. It keeps the raw `T*`, and it carries a `RAW_PTR_EXCLUSION` annotation to say so out loud (`weak_ptr.h:311`): + +```cpp +// This pointer is only valid when ref_.is_valid() is true. Otherwise, its +// value is undefined (as opposed to nullptr). The pointer is allowed to +// dangle as we verify its liveness through `ref_` before allowing access to +// the pointee. We don't use raw_ptr here to prevent WeakPtr from keeping +// the memory allocation in quarantine, as it can't be accessed through the +// WeakPtr. +RAW_PTR_EXCLUSION T* ptr_ = nullptr; +``` + +The comment says it all: `ptr_` is allowed to dangle by design. WeakPtr is built on "the object can die first; as long as the flag flips, deref gets gated by `IsValid()` before it lands." So after the object destructs, `ptr_` legitimately points at freed memory for some stretch of time. That is the design asking for the dangle, not a bug. + +What would happen with `raw_ptr`? The dangling pointer would make PartitionAlloc hold that memory in quarantine without reclaiming it (the backup-ref count stays nonzero) until every last WeakPtr is destroyed. A type that is allowed to dangle by design, wrapped that way, drags along a whole swath of memory for nothing. So WeakPtr falls back to the raw pointer and uses `RAW_PTR_EXCLUSION` to spell it out: there is a dangling risk here, we know, it is part of the design, do not wrap it in raw_ptr. + +We chewed on this one for a while. A safety tool does not get safer the more you stack it; what matters is whether it fits the object's lifetime model. WeakPtr's "dangling allowed, flag at the gate" and raw_ptr's "never dangles, quarantine on free" are two models that push against each other. Forcing them together buys no extra safety, and the memory cost shows up first. + +--- + +## A minimal reproduction: non-template base plus template derived + +Talking through it is one thing; pulling the layering out as a minimal skeleton and running it makes it stick: + +```cpp +// Platform: host | C++ Standard: C++17 +#include +#include + +namespace internal { +class FactoryBase { +protected: + FactoryBase(uintptr_t p) : ptr_(p) {} + ~FactoryBase() { ptr_ = 0; } // non-template base: this code is generated once + uintptr_t ptr_; // pointer stored as an integer, independent of T +}; +} // namespace internal + +template +class Factory : public internal::FactoryBase { +public: + explicit Factory(T* p) : FactoryBase(reinterpret_cast(p)) {} + + T* get() const { + return reinterpret_cast(ptr_); // cast back; the type is upheld by the template + } +}; + +struct Foo { int x = 42; }; + +int main() { + Foo f; + Factory fac(&f); + std::cout << fac.get()->x << '\n'; // 42 + return 0; +} +``` + +`FactoryBase` has no idea what `T` is; it only "stores an integer." What is left of template bloat is the thin `Factory` derived layer, while the storage logic is shared by everyone. The real WeakPtr is more involved than this (it also carries the refcount flag), but the layering idea is exactly the same. + +That closes out the two template-engineering tricks. `template friend class WeakPtr;` lets different instantiations of the same template (`WeakPtr` and `WeakPtr`) reach into each other's privates, which is what lets the converting constructor read `other.ref_` and `other.ptr_` directly. `WeakPtrFactoryBase` stores its pointer as `uintptr_t` and sinks it into a non-template base, so the type-agnostic "store a pointer" logic is generated once and template bloat stays down. And `RAW_PTR_EXCLUSION` is the reverse trade: the model where `ptr_` is allowed to dangle runs head-on into raw_ptr's quarantine, so WeakPtr deliberately steps back to a raw pointer. + +One prerequisite left: `TRIVIAL_ABI`, the attribute that lets a WeakPtr with a non-trivial destructor still get passed in registers like a trivial type. + +## References + +- [cppreference: friend declaration and template friend](https://en.cppreference.com/w/cpp/language/friend) +- [cppreference: uintptr_t](https://en.cppreference.com/w/cpp/types/integer) +- [Chromium `base/memory/weak_ptr.h`, class hierarchy](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [Chromium MiraclePtr / raw_ptr design document](https://chromium.googlesource.com/chromium/src/+/main/docs/unsafe_relocations.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md new file mode 100644 index 000000000..ff67ef210 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md @@ -0,0 +1,102 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: "Breaking down [[clang::trivial_abi]] / TRIVIAL_ABI: the semantics, the cost, and why a refcount-holding type like WeakPtr can safely carry the annotation because the design positions every part to qualify, not because the property is inherent." +difficulty: intermediate +order: 6 +platform: host +prerequisites: +- 'WeakPtr prerequisite (I): intrusive refcount and scoped_refptr' +- 'WeakPtr prerequisite (V): template friend and uintptr_t type erasure' +reading_time_minutes: 11 +related: +- 'weak_ptr design guide (I): motivation, API, and the control block' +- 'weak_ptr design guide (III): test strategy and performance' +tags: +- host +- cpp-modern +- intermediate +- 零开销抽象 +- 优化 +- weak_ptr +title: "WeakPtr prerequisite (VI): TRIVIAL_ABI and trivial relocatability" +--- +# WeakPtr prerequisite (VI): TRIVIAL_ABI and trivial relocatability + +Across the last few pieces we mapped out WeakPtr's members: a `WeakReference` (holding a `scoped_refptr`, the refcounted handle) and a `T* ptr_`. It has a non-trivial destructor, because destruction has to dec the flag's refcount. Under C++'s default ABI rules, that bars the type from passing by value in a register on a function call. It has to round-trip through memory, either the stack or a hidden reference parameter. + +Then you open Chromium's `weak_ptr.h` and spot an attribute called `TRIVIAL_ABI` hanging off both `WeakPtr` and `WeakReference` (`weak_ptr.h:101,203`). With it on, a type that has a non-trivial destructor gets treated as trivial for the calling convention, and that means register passing, cheap by-value handoff. WeakPtr is exactly two pointers wide; once annotated, handing one between functions costs about the same as handing two pointers. + +The first time we saw this, it did not add up. A refcount-holding type, tagged "trivial"? There is a counterintuitive point here worth a whole piece: what `trivial_abi` actually is, what it costs, and how WeakPtr meets its safety conditions. The answer is not "the refcount does not matter." The answer is that the design deliberately put every part where it could be safely annotated. + +--- + +## Trivial vs non-trivial types: the ABI view + +C++ sorts types into "trivial" and "non-trivial", and the calling convention treats them differently. + +Trivial types are things like `int`, `std::pair`, POD structs. Copy, move, and destruction "do nothing", or are bitwise-equivalent to memcpy. At a call site these can be stuffed straight into a register and passed or returned by value, with overhead small enough to ignore. + +Non-trivial types are anything with a non-trivial copy constructor, move constructor, or destructor. Passing one usually goes through memory: a slot on the stack, or a hidden reference parameter. The compiler has to make sure those non-trivial operations (dec a refcount on destruction, say) actually run where they are supposed to, so a plain memcpy will not do. + +WeakPtr holds a `scoped_refptr` (its destructor decs the refcount), so it is non-trivial. Under the default ABI, passing one means going through memory; no register. + +--- + +## What [[clang::trivial_abi]] is + +`[[clang::trivial_abi]]` is a Clang type attribute, and Chromium wraps it in a `TRIVIAL_ABI` macro. When `__has_cpp_attribute(clang::trivial_abi)` is true the macro expands to the attribute, otherwise to nothing (`base/compiler_specific.h`). + +Stated plainly, it does one thing: make a non-trivial type get treated as trivial by the calling convention. Two effects follow. First, by-value parameters and return values can go through registers, on the same footing as a trivial type. Second, the object becomes "relocatable", meaning the two steps "move plus destroy the source" collapse into a single memcpy. + +There is a precondition the Clang documentation is explicit about: the annotated type must be trivially relocatable. That means: bitwise-moving the object from one place to another and then skipping the source's destructor must be semantically equivalent to running a move construction plus destroying the source. When that holds, the compiler can substitute memcpy for move-plus-destroy and reach for a register without worry. + +--- + +## The cost: destructor timing and location change + +`trivial_abi` is not a free lunch. It moves where and when the destructor runs. + +For an ordinary non-trivial type, the ABI pins down exactly which stack frame the temporary destructs in. Slap `trivial_abi` on it and the object may get constructed in the caller's frame, packed into a register, handed over, and destructed in the callee's frame. The landing site of the destructor shifts. If the type really is trivially relocatable, that does not matter (move-plus-destroy is equivalent to memcpy anyway). If it is not, say the destructor has a side effect that cannot be skipped, then annotating it anyway will produce bugs. + +That is the cost. The Clang documentation warns about it on purpose: `trivial_abi` only goes on types that genuinely qualify. It is not something to paste on at random. + +--- + +## Why WeakPtr can carry the annotation safely + +So WeakPtr holds a refcount (through `scoped_refptr`). How does it satisfy "trivially relocatable"? Let us take it apart piece by piece. + +First, `T* ptr_`. It is a raw pointer. Copy, move, and destruction are all trivial (bitwise), and they do not touch inc or dec at all. A raw pointer is trivially relocatable on its own, no obstacle to `trivial_abi`. + +The real question is the `scoped_refptr` inside `WeakReference ref_`. It is not trivial: copy incs the refcount, destruction decs it. Is it trivially relocatable? + +Walk through it. What does scoped_refptr's move constructor do? It steals the source's raw pointer (`ptr_ = other.ptr_; other.ptr_ = nullptr;`) without inc-ing. When the source is then destroyed, its `ptr_` is already nullptr, so `release()` walks out as a no-op. Net effect across the two steps: the raw pointer is bitwise moved over, no inc, no dec, the refcount does not budge. + +That is exactly the definition of "trivially relocatable." So scoped_refptr is non-trivial but trivially relocatable. WeakPtr holds one and inherits the property; together with the always-trivial `ptr_`, the whole WeakPtr satisfies the `trivial_abi` precondition. + +There is one more guarantee that is easy to miss. `trivial_abi` makes the destruction location indeterminate, so a given WeakPtr instance may end up being destroyed on an "unexpected" thread (the caller's frame, say), and the scoped_refptr it holds will dec the flag's refcount there. That requires the flag's refcount operations to be cross-thread safe, which is exactly what `RefCountedThreadSafe` (atomic inc/dec) backs up, as covered in [prerequisite (I)](./pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md). Add the `HasOneRef()` cross-thread destruction exemption inside `Flag::Invalidate`, and the flag dropping to zero and destructing on any thread is safe. Without that layer, `trivial_abi` on top of "destructor site drift" would eventually bite in a cross-thread scenario. + +--- + +## This is design avoidance, not an inherent property + +There is a conclusion worth stressing here, so you do not take the same idea to some other type and break things. + +`trivial_abi` is not inherently safe for types that hold a refcount handle. WeakPtr can be annotated because its members were deliberately made trivially relocatable. That is different from "any refcounted type can slap this attribute on." + +A counterexample. Suppose you write a class that manages its own refcount by hand (`++count_` in the copy constructor, `--count_` in the destructor with cleanup on zero) and you do not guarantee "move plus destroy-source equals memcpy." Forcing `trivial_abi` onto it lets the compiler drop a destructor at the wrong place or time. The refcount drifts, and at the mild end you leak, at the bad end you double-free. The Clang documentation and Chromium's own comments both warn about this. + +Back to WeakPtr. Its safety stands on three pillars. `ptr_` is a raw pointer, trivial by nature. `scoped_refptr` is non-trivial but trivially relocatable, with move-plus-destroy equivalent to memcpy. `Flag` uses `RefCountedThreadSafe`, with `HasOneRef()` covering the cross-thread destruction case. Pull any pillar and the whole thing falls. So when you see WeakPtr tagged `TRIVIAL_ABI` and reaping the register-passing win, that "zero overhead" is what the design work up front paid for. The overhead did not vanish, it got dissolved by design ahead of time. This is Chromium's zero-overhead abstraction philosophy. + +That closes out the seven prerequisites: pre-00 on weak references in general, plus pre-01 through pre-06 on the six parts, intrusive refcount, atomics and memory order, sequences and DCHECK/CHECK, concepts, template friend and uintptr_t, and TRIVIAL_ABI. The parts are collected. Next we start bolting them together into the core skeleton of a real WeakPtr, and see how the seven pieces mesh into one industrial-grade weak pointer. + +## References + +- [Clang documentation: the `trivial_abi` attribute](https://clang.llvm.org/docs/AttributeReference.html#trivial-abi) +- [cppreference: TrivialType and triviality](https://en.cppreference.com/w/cpp/named_req/TrivialType) +- [Chromium `base/compiler_specific.h`, the TRIVIAL_ABI macro](https://source.chromium.org/chromium/chromium/src/+/main:base/compiler_specific.h) +- [Chromium `base/memory/weak_ptr.h`, annotations at lines 101/203](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/01-weak-ptr-design.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/01-weak-ptr-design.md new file mode 100644 index 000000000..8346ac84d --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/01-weak-ptr-design.md @@ -0,0 +1,133 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "For readers already comfortable with templates and concurrency — a fast walkthrough of Chromium WeakPtr's design motivation, API, and the two-layer control block. The condensed design-guide version of the full/ series." +difficulty: advanced +order: 1 +platform: host +prerequisites: +- Move semantics and perfect forwarding +- std::atomic and memory_order +- C++20 concepts and requires +- 'OnceCallback hands-on (IV): the cancellation token' +reading_time_minutes: 8 +related: +- 'weak_ptr design guide (II): step-by-step implementation' +- 'weak_ptr design guide (III): test strategy and performance' +tags: +- host +- cpp-modern +- advanced +- 智能指针 +- weak_ptr +- 内存管理 +title: "weak_ptr Design Guide (I): Motivation, API, and the Control Block" +--- +# weak_ptr Design Guide (I): Motivation, API, and the Control Block + +> Hands-on track. Assumes you're already comfortable with acquire/release, concepts, and intrusive refcounting; if not, skim the [full/ prerequisites](../full/pre-00-weak-ptr-weak-reference-and-lifetime.md) first. + +In the [OnceCallback design guide](../../01_once_callback/hands_on/01-once-callback-design.md) we hand-rolled a cancellation token: an atomic flag, set when the object destructs, checked before the callback runs. It solves the "callback outlives the object" dangling problem, but it leaves a loose end: who owns the flag's lifetime, and how does it reach the callback? Chromium's `//base` ships the industrial-strength answer, and it's `WeakPtr`. Implementation and tests come in the next two pieces. + +## Where `std::weak_ptr` breaks down in async callbacks + +`std::weak_ptr` is a general, correct design, but in a system built on "post tasks + don't take ownership + serialized execution" it has four sharp edges: + +1. **Forces `shared_ptr`, so it drags in ownership.** Want a weak reference? You must rewrite the object to live behind a `shared_ptr`, distorting a clean single-owner model. +2. **Non-intrusive control block.** Either two heap allocations, or `make_shared` fuses them, but then a long-lived `weak_ptr` pins the entire object's memory. +3. **Can't invalidate a batch at once.** Invalidation is a refcount side effect (the last `shared_ptr` dying); there's no explicit invalidate-all. You can't express "the object still exists, but it's entered a state where it must not be called back." +4. **No sequence affinity.** Atomics are safe in themselves, but whether a dereference needs synchronization is on you, and in a "tasks run on a sequence" world that freedom is a footgun. + +## The Chromium WeakPtr design philosophy + +All four have a matching answer in WeakPtr; the tradeoffs are in the table: + +| Sharp edge | WeakPtr's answer | +|---|---| +| Forces shared_ptr | **Doesn't take ownership** — observer, not owner | +| Non-intrusive control block | **Intrusive refcount** — Flag is `RefCountedThreadSafe`, one allocation | +| Can't batch-invalidate | **Shared Flag** — one factory invalidate, all WeakPtrs drop together | +| No sequence affinity | **Sequence-bound** — deref/invalidate must hit the bound sequence; DCHECK catches violations in debug | + +The move is straightforward: we peel “is the object still alive?” off the object itself and make it a small refcounted thing (Chromium calls it Flag) that the factory and every WeakPtr share. Split it out this way and “one invalidate, all observers drop” follows naturally; when the pointed-at object destructs is none of WeakPtr’s business. + +## The API + +The public surface is small: + +```cpp +// Weak handle: doesn't extend lifetime, can check liveness +template class [[clang::trivial_abi]] WeakPtr { +public: + WeakPtr() = default; + WeakPtr(std::nullptr_t); + + template requires(std::convertible_to) // upcast + WeakPtr(const WeakPtr&); + + T* get() const; // invalidated → nullptr, no crash + T& operator*() const; // invalidated → CHECK/assert + T* operator->() const; + explicit operator bool() const; + void reset(); + + bool maybe_valid() const; // cross-sequence hint: negative trusted, positive not + bool was_invalidated() const; // distinguish "invalidated" from "manually reset" +}; + +// The mint: hangs on the observed object, batch-invalidates +template class WeakPtrFactory { +public: + explicit WeakPtrFactory(T* ptr); + WeakPtr get_weak_ptr(); // non-const overload requires(!is_const_v) + WeakPtr get_weak_ptr() const; + void invalidate_weak_ptrs(); // invalidate + mint a fresh Flag + void invalidate_weak_ptrs_and_doom(); // invalidate + stop minting (cheaper) + bool has_weak_ptrs() const; +}; +``` + +A few signature calls we made (see [full/02-1](../full/02-1-weak-ptr-motivation-and-api-design.md)): `operator*`/`operator->` use `CHECK` (dereferencing an invalidated handle is a definite bug, crashes in release too), `get()` returns a raw pointer as the escape hatch; `operator==`/`<=>` are deliberately absent (weak-reference comparison is unstable); `WeakPtrFactory` uses composition, not inheritance, and must be the last member. + +## Internals: the two-layer control block + +The whole mechanism stacks into four layers, isomorphic to OnceCallback's `BindState` (type erasure + refcount at the bottom, a lightweight handle on top): + +```mermaid +flowchart TB + subgraph FAC["WeakPtrFactory<T> — mint + batch invalidate"] + RO["WeakReferenceOwner
Flag issuer (holds flag; destruct = invalidate)"] + end + subgraph WP["WeakPtr<T> — user handle (TRIVIAL_ABI, 16 bytes)"] + subgraph WR["WeakReference — ref side (scoped_refptr<const Flag>)"] + Flag["Flag — refcounted + atomic liveness (acquire/release)"] + end + end + RO -.-> Flag +``` + +**Flag** is the lifeline: `RefCountedThreadSafe` (cross-sequence sharing) + `AtomicFlag invalidated_` (the liveness bit, release-Set / acquire-IsSet). `Invalidate` does a release-store, `IsValid` does an acquire-load. That pair establishes happens-before: if you read invalidated, you also see every write the object made before entering the invalidated state, with no extra locking. Flag also carries a `SEQUENCE_CHECKER` (lazy-bound, zero bytes as a no-op in release). + +Flag's refcount only governs how long Flag itself lives (as long as any WeakPtr holds it, Flag stays); the pointed-at object destructs whenever it destructs, Flag doesn't care. + +## Key design decisions + +| Decision | Choice | Reason | +|---|---|---| +| Assert level on invalidated deref | `CHECK` (crashes in release too) | UAF in the making; fail immediately | +| Assert level on sequence violation | `DCHECK` (debug only) | Contract violation, not immediate memory-safety; debug catch is enough | +| `ptr_` as raw pointer + `RAW_PTR_EXCLUSION` | Dangling allowed | `IsValid` gates before deref; raw_ptr quarantine would bloat memory | +| Storing the pointer as `uintptr_t` (in factory base) | Sunk into a non-template base | Shrinks template bloat (each T instantiates only a thin derived layer) | +| `WeakPtrFactory` composition vs inheritance | Composition | Flexible, works for any type, doesn't pollute the inheritance chain | +| `[[clang::trivial_abi]]` | Annotated | `ptr_` raw trivial + scoped_refptr trivially relocatable; passes in registers | + +The architecture and signatures are on paper; in the next piece we turn these promises into code, line by line. + +## References + +- [Chromium `base/memory/weak_ptr.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [weak_ptr design guide (II): step-by-step implementation](./02-weak-ptr-implementation.md) +- [WeakPtr prerequisite (0): weak references and the lifetime puzzle](../full/pre-00-weak-ptr-weak-reference-and-lifetime.md) +- [OnceCallback design guide (I): motivation and API design](../../01_once_callback/hands_on/01-once-callback-design.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/02-weak-ptr-implementation.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/02-weak-ptr-implementation.md new file mode 100644 index 000000000..391040e1d --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/02-weak-ptr-implementation.md @@ -0,0 +1,301 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "Build the WeakPtr quartet layer by layer: RefCountedThreadSafe, AtomicFlag, Flag, WeakReference, scoped_refptr, WeakPtr, and WeakPtrFactory, with sequence checking and lazy binding. Code-dense, talk-light." +difficulty: advanced +order: 2 +platform: host +prerequisites: +- 'weak_ptr Design Guide (I): Motivation, API, and the Control Block' +- 'WeakPtr prerequisite (I): intrusive refcounting and scoped_refptr' +- 'WeakPtr prerequisite (II): std::atomic and memory_order' +reading_time_minutes: 13 +related: +- 'weak_ptr design guide (III): test strategy and performance' +- 'WeakPtr hands-on (II): the core skeleton and control block' +tags: +- host +- cpp-modern +- advanced +- 智能指针 +- weak_ptr +- atomic +- 引用计数 +title: "weak_ptr Design Guide (II): Step-by-Step Implementation" +--- +# weak_ptr Design Guide (II): Step-by-Step Implementation + +> Hands-on track, code-dense. For the full argument behind each line, see [full/02-2](../full/02-2-weak-ptr-core-skeleton-and-control-block.md) and [full/02-3](../full/02-3-weak-ptr-factory-and-last-member.md). The companion compilable project lives in `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/` (`16_weak_ptr_skeleton.cpp`, `17_weak_ptr_factory.cpp`). + +The previous piece nailed down the architecture and the why behind it: four layers stacking cleanly into Flag, WeakReference, WeakPtr, and WeakPtrFactory. Getting it straight on paper is one thing; writing the thing line by line is another, and the gotchas outnumber what you'd expect. While putting this together I kept asking myself how the Chromium folks landed on details like "make the destructor private to block external delete," why `WeakPtrFactory` has to sit as the last member, and where exactly the `uintptr_t` template-slimming trick pays off. We'll write the code and pick those apart as we go, from the bottommost refcount all the way up to the factory. + +## Layer 0: refcount + atomic flag + +You don't build a house without a foundation. The bottom two bricks we already named last time: a thread-safe intrusive refcount base, and a one-shot release/acquire atomic flag. Don't rush past them; these two have their own subtleties. + +```cpp +// Platform: host | C++ Standard: C++17 +#pragma once +#include +#include +#include +#include +#include + +namespace tamcpp::chrome::internal { + +class RefCountedThreadSafe { +public: + void add_ref() const noexcept { ref_count_.fetch_add(1, std::memory_order_relaxed); } + bool release() const noexcept { + return ref_count_.fetch_sub(1, std::memory_order_acq_rel) == 1; // true when decremented to 0 + } + bool has_one_ref() const noexcept { return ref_count_.load(std::memory_order_acquire) == 1; } +protected: + RefCountedThreadSafe() = default; + ~RefCountedThreadSafe() = default; +private: + mutable std::atomic ref_count_{0}; +}; + +// Mirrors base::AtomicFlag: one-shot, release-Set / acquire-IsSet +class AtomicFlag { +public: + void Set() noexcept { flag_.store(1, std::memory_order_release); } + bool IsSet() const noexcept { return flag_.load(std::memory_order_acquire) != 0; } +private: + std::atomic flag_{0}; +}; + +} // namespace tamcpp::chrome::internal +``` + +The bit worth dwelling on is why `release` uses `acq_rel` and not `release`. The decrement has to read the freshest count, otherwise two threads racing the subtraction can drive it negative. At the moment it hits zero it also has to publish every pre-destructor write to whichever thread takes ownership of the delete. Both ends matter, so `acq_rel`. `AtomicFlag` deliberately narrows `std::atomic` down to a one-shot semantic: no public clear, release/acquire paired. The meaning is "writes before Set are visible to reads after IsSet," and that is the foundation every WeakPtr relies on to see the invalidation synchronously after `Invalidate`. + +## Layer 0.5: the sequence checker (debug-only) + +Foundation done. Before we raise the Flag we need a debug-only sequence checker. Chromium's real thing is SequenceToken, a finer notion than thread id that can tell "two sequences running on the same thread" apart. The teaching version here fakes it with thread id, enough to make the idea clear. The neat trick is that under `NDEBUG` the whole class collapses into two no-ops and costs zero bytes. We'll lean on that move repeatedly. + +```cpp +#if defined(NDEBUG) +class SequenceChecker { +public: + void detach_from_sequence() noexcept {} + bool called_on_valid_sequence() const noexcept { return true; } +}; +#else +class SequenceChecker { +public: + void detach_from_sequence() noexcept { bound_ = std::thread::id{}; } + bool called_on_valid_sequence() const noexcept { + if (bound_ == std::thread::id{}) { bound_ = std::this_thread::get_id(); return true; } + return bound_ == std::this_thread::get_id(); + } +private: + mutable std::thread::id bound_; +}; +#endif +``` + +## Layer 1: Flag, the refcounted liveness + +Foundation in place. Time to raise the Flag we kept invoking last piece. It derives from `RefCountedThreadSafe`, holds one `AtomicFlag` as the liveness bit, plus a lazily bound `SequenceChecker`. Liveness checks, invalidation, and cross-sequence teardown all hang off this single object. + +```cpp +namespace tamcpp::chrome::internal { + +class Flag : public RefCountedThreadSafe { +public: + Flag() { seq_.detach_from_sequence(); } // unbound at construction (lazy) + + void Invalidate() noexcept { + // Same sequence, or only one ref left (cross-thread teardown allowed) + assert(seq_.called_on_valid_sequence() || has_one_ref()); + invalidated_.Set(); // release-store + } + bool IsValid() const noexcept { + assert(seq_.called_on_valid_sequence()); // first touch → binds + return !invalidated_.IsSet(); // acquire-load + } + bool MaybeValid() const noexcept { + return !invalidated_.IsSet(); // no sequence assert, callable from any sequence + } +private: + template friend class scoped_refptr; // allow delete when refcount hits zero + ~Flag() = default; // private: controlled destruction + mutable SequenceChecker seq_; + AtomicFlag invalidated_; +}; + +} // namespace tamcpp::chrome::internal +``` + +This block is the heart of the whole mechanism. A few spots I kept tripping over. + +Look at the constructor first. That `seq_.detach_from_sequence()` in `Flag()` reads counter-intuitively; detach right after construction? It's lazy binding. A Flag can be constructed on any sequence. The sequence only gets recorded the first time someone touches it (calling `IsValid` or `Invalidate`). The reason is that a Flag is often constructed on one sequence and then handed to WeakPtrs running on another, so binding eagerly at construction time would be wrong. + +Making `~Flag()` private is the move that blocks "someone grabbed a raw pointer and delete'd it." A Flag's life is governed solely by its refcount; nobody else gets a say. The `assert`s inside `Invalidate` and `IsValid` mirror Chromium's `DCHECK`, asserting that these calls default to landing on the bound sequence, otherwise the lazy binding breaks. The `assert` in `Invalidate` carries an extra `|| has_one_ref()` escape hatch to permit the legitimate case of "the thread holding the last reference tears it down cross-sequence." + +The easy one to miss is `MaybeValid`, which deliberately never touches `seq_`. Why? Because the whole point of that entry point is "let any sequence peek at a rough answer." If it went through the same acquire-plus-sequence-assert path as `IsValid`, a cross-sequence call would mis-bind `seq_`, and every future liveness answer would become untrustworthy. So it walks a looser channel: read the atomic bit, skip the sequence contract. Hold on to that distinction; we come back to it in the testing piece. + +## Layer 2: WeakReference + scoped_refptr + +With Flag standing, something has to manage its refcount. `scoped_refptr` is the standard intrusive-refcount idiom; here's a minimal version, the complete one lives in the prerequisites. `WeakReference` is the reference side of Flag: it holds a `scoped_refptr` and re-exposes three operations, `IsValid`, `MaybeValid`, `Reset`, basically forwarding Flag's interface. + +```cpp +namespace tamcpp::chrome::internal { + +template +class scoped_refptr { // simplified, full version in pre-01 +public: + scoped_refptr() noexcept = default; + explicit scoped_refptr(T* p) noexcept : ptr_(p) { if (ptr_) ptr_->add_ref(); } + scoped_refptr(const scoped_refptr& o) noexcept : ptr_(o.ptr_) { if (ptr_) ptr_->add_ref(); } + scoped_refptr(scoped_refptr&& o) noexcept : ptr_(o.ptr_) { o.ptr_ = nullptr; } + ~scoped_refptr() { if (ptr_ && ptr_->release()) delete ptr_; } + scoped_refptr& operator=(scoped_refptr r) noexcept { T* t = ptr_; ptr_ = r.ptr_; r.ptr_ = t; return *this; } + T* get() const noexcept { return ptr_; } + explicit operator bool() const noexcept { return ptr_ != nullptr; } +private: + T* ptr_ = nullptr; +}; + +class WeakReference { +public: + WeakReference() = default; + explicit WeakReference(const scoped_refptr& flag) : flag_(flag) {} + bool IsValid() const noexcept { return flag_ && flag_->IsValid(); } + bool MaybeValid() const noexcept { return flag_ && flag_->MaybeValid(); } + void Reset() noexcept { flag_ = nullptr; } +private: + scoped_refptr flag_; +}; + +} // namespace tamcpp::chrome::internal +``` + +## Layer 3: WeakPtr\, the user handle + +We finally reach the API users actually touch. WeakPtr holds two things internally: a `WeakReference` and a raw pointer `ptr_`. The first decides liveness, the second is what you dereference. The `[[clang::trivial_abi]]` we mentioned last time sits on this class. + +```cpp +namespace tamcpp::chrome { + +template class WeakPtrFactory; + +template +class [[clang::trivial_abi]] WeakPtr { +public: + WeakPtr() = default; + WeakPtr(std::nullptr_t) noexcept {} + + template requires(std::convertible_to) // upcast + WeakPtr(const WeakPtr& o) noexcept : ref_(o.ref_), ptr_(o.ptr_) {} + template requires(std::convertible_to) + WeakPtr(WeakPtr&& o) noexcept : ref_(std::move(o.ref_)), ptr_(o.ptr_) {} + + T* get() const noexcept { return ref_.IsValid() ? ptr_ : nullptr; } + T& operator*() const { assert(ref_.IsValid()); return *ptr_; } // Chromium uses CHECK + T* operator->() const { assert(ref_.IsValid()); return ptr_; } + explicit operator bool() const noexcept { return get() != nullptr; } + void reset() noexcept { ref_.Reset(); ptr_ = nullptr; } + + bool maybe_valid() const noexcept { return ref_.MaybeValid(); } + bool was_invalidated() const noexcept { return ptr_ && !ref_.IsValid(); } +private: + template friend class WeakPtr; + friend class WeakPtrFactory; + WeakPtr(internal::WeakReference&& ref, T* ptr) noexcept : ref_(std::move(ref)), ptr_(ptr) { + assert(ptr); // only the factory can reach this + } + internal::WeakReference ref_; + T* ptr_ = nullptr; +}; + +} // namespace tamcpp::chrome +``` + +The real trap is the `[[clang::trivial_abi]]` annotation. It tells the compiler: this type has a non-trivial destructor, but you may pass it as if it were trivial, stuff it into registers, memcpy it around. That carries risk. Destructor timing moves forward, and a moved-from object can be observed invalid earlier than you'd expect. Chromium can afford the annotation because `ptr_` is a raw trivial pointer and the `scoped_refptr` half is trivially relocatable, so the whole thing relocates without breaking its invariant. The full argument for that safety precondition lives in [full/pre-06](../full/pre-06-weak-ptr-trivial-abi.md); I'll only flag it here: don't get dazzled and start pasting this onto your own types. Get it wrong and you have a use-after-free. + +One more thing to call out: the private constructor plus `friend class WeakPtrFactory` means only the factory can mint a WeakPtr. Nobody outside can conjure one out of thin air. That's the seam where the "minting authority" gets locked down. + +## Layer 4: WeakPtrFactory\ + +The top layer, and the one users actually instantiate. It owns a `WeakReferenceOwner` (the Flag issuer) plus a member holding the observed object's pointer. The two cooperate on minting, batch invalidation, and destructor-time cleanup. + +```cpp +namespace tamcpp::chrome { + +class WeakReferenceOwner { // Flag issuer +public: + WeakReferenceOwner() : flag_(new internal::Flag()) {} + ~WeakReferenceOwner() { if (flag_) flag_->Invalidate(); } // destruction invalidates all + internal::WeakReference GetRef() const { return internal::WeakReference(flag_); } + void Invalidate() { flag_->Invalidate(); flag_ = internal::scoped_refptr(new internal::Flag()); } // invalidate + fresh Flag + void InvalidateAndDoom() { flag_->Invalidate(); flag_ = nullptr; } // invalidate + stop minting + bool HasRefs() const { return !flag_->has_one_ref(); } +private: + internal::scoped_refptr flag_; +}; + +template +class WeakPtrFactory { +public: + WeakPtrFactory() = delete; + explicit WeakPtrFactory(T* ptr) : ptr_(reinterpret_cast(ptr)) { assert(ptr); } + WeakPtrFactory(const WeakPtrFactory&) = delete; + WeakPtrFactory& operator=(const WeakPtrFactory&) = delete; + + WeakPtr get_weak_ptr() const { + return WeakPtr(owner_.GetRef(), reinterpret_cast(ptr_)); + } + WeakPtr get_weak_ptr() requires(!std::is_const_v) { + return WeakPtr(owner_.GetRef(), reinterpret_cast(ptr_)); + } + void invalidate_weak_ptrs() { assert(ptr_); owner_.Invalidate(); } + void invalidate_weak_ptrs_and_doom() { assert(ptr_); owner_.InvalidateAndDoom(); ptr_ = 0; } + bool has_weak_ptrs() const { return ptr_ && owner_.HasRefs(); } +private: + WeakReferenceOwner owner_; + uintptr_t ptr_; // non-template-dependent pointer storage (can sink into a base to shrink bloat) +}; + +} // namespace tamcpp::chrome +``` + +Three details here are worth stopping on. + +First, the `WeakReferenceOwner` destructor calls `Invalidate`. That single line is the root of the "factory must be the last member" rule. C++ constructs members in declaration order and destroys them in reverse; put the factory last and it's destroyed first, while every other member of the object is still alive. Flag flips, every WeakPtr drops, and that all happens before the rest of the members start falling apart. Put the factory earlier and you get a window: the factory destructs, Flag invalidates, but then `buf_` and friends destruct too, and somewhere in between a WeakPtr can still read as valid while the object's insides are already coming apart. This is not stylistic preference. It's memory safety. The full race argument is in [full/02-3](../full/02-3-weak-ptr-factory-and-last-member.md). + +Second, `ptr_` is stored as `uintptr_t`. Looks redundant when it's conceptually a `T*`, so why the `reinterpret_cast` detour? The goal is to sink pointer storage into a non-template base. Think about it: `WeakPtrFactory`, `WeakPtrFactory`, `WeakPtrFactory`, each instantiating an identical copy of the pointer-manipulation code. That's serious template bloat. Move that part into a non-template base and each T is left with a thin derived slice; the binary savings add up. Chromium has a handful of these "trade `uintptr_t` for template slimming" tricks. + +Third, the difference between `Invalidate` and `InvalidateAndDoom`. The former invalidates and immediately mints a fresh Flag, so the factory keeps handing out new WeakPtrs. The latter nulls the Flag pointer outright and stops minting. It's for the "this factory will never be used again" case and saves one heap allocation. The `invalidate_weak_ptrs_and_doom` line also zeroes `ptr_`, so any later call to it trips the `assert`. That guards against misuse after a use-after-free. + +## Wiring it together + +All four layers up. Let's snap them together and see what using it actually looks like. The Controller below is a typical case: a member function that gets called back asynchronously, a factory parked as the last member, WeakPtrs handed out to callers. + +```cpp +struct Controller { + void on_done(int v) { /* ... */ } + std::vector buf_; + WeakPtr get_weak() { return weak_factory_.get_weak_ptr(); } + WeakPtrFactory weak_factory_{this}; // last member +}; + +// After the object dies, the callback no-ops (ties into 01's OnceCallback, see full/02-5) +auto task = bind_weak_once(&Controller::on_done, ctrl.get_weak(), 42); +std::move(task).run(); // ctrl alive → calls; ctrl dead → silent no-op +``` + +That `bind_weak_once` line is where the OnceCallback thread from the previous piece finally ties off. The core idea is a direct translation of the industrial `InvokeHelper::MakeItSo`: before the callback runs, `if (!receiver) return;`. The receiver is a WeakPtr, so `operator bool` calls `get()`, `get()` checks `IsValid`, and the whole chain lands on an accurate same-sequence liveness read. Object alive means a real call; object dead means a silent no-op. The full callback integration story, how compile-time `kIsWeakMethod` wires up, why `MaybeValid` needs its own channel, where the void-return constraint comes from, lives in [full/02-5](../full/02-5-weak-ptr-bind-integration.md), where I take it apart properly. + +The code is all written now. Flag's acquire/release keeps the deref lock-free, lazy sequence binding works, the factory destructor catches teardown, `TRIVIAL_ABI` lands in registers. Every promise landed. But this piece only cared about writing it correctly; it never seriously verified whether the thing holds up at runtime. Does the `MaybeValid` side channel actually race? Is the cross-sequence teardown window really closed? Does a misordered factory actually blow up? Those need tests and TSan to answer, and that's exactly where the next piece goes for the throat. + +## References + +- [Chromium `base/memory/weak_ptr.{h,cc}`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [weak_ptr design guide (III): test strategy and performance](./03-weak-ptr-testing.md) +- [WeakPtr hands-on (II): the core skeleton and control block](../full/02-2-weak-ptr-core-skeleton-and-control-block.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/03-weak-ptr-testing.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/03-weak-ptr-testing.md new file mode 100644 index 000000000..052245fe9 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/03-weak-ptr-testing.md @@ -0,0 +1,145 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "WeakPtr's test strategy: design cases around six invariants, then quantify object size, allocation, and call cost against std::weak_ptr and real Chromium to see where the teaching version saves and what it gives up" +difficulty: advanced +order: 3 +platform: host +prerequisites: +- weak_ptr design guide (II): step-by-step implementation +- OnceCallback design guide (III): test strategy and performance +reading_time_minutes: 7 +related: +- weak_ptr design guide (I): motivation, API, and the control block +- WeakPtr hands-on (VI): tests and performance +tags: +- host +- cpp-modern +- advanced +- 智能指针 +- weak_ptr +- 测试 +- 优化 +title: "weak_ptr Design Guide (III): Test Strategy and Performance" +--- +# weak_ptr Design Guide (III): Test Strategy and Performance + +We finished the implementation in the last piece, and honestly we weren't entirely at ease with it. Code that compiles is one thing; correct semantics is another. WeakPtr is exactly the kind of thing that "looks like it runs": you test a happy path, it goes green, and the real traps hide on the boundaries. UAF, destruction races, the source-object state after a move. This piece pins the six invariants we promised last time back into actual test cases, and then puts real numbers next to `std::weak_ptr` and real Chromium to see where the teaching version saves and what it sacrifices. The approach is the same one we took in [OnceCallback design guide (III)](../../01_once_callback/hands_on/03-once-callback-testing.md): invariants drive the cases, numbers do the talking, no hand-waving. + +## Six invariants into a test matrix + +| # | Invariant | Assertion that must hold | +|---|---|---| +| 1 | Basic usability | While the object is alive, `wp` is truthy and `get()` returns the real address | +| 2 | Move semantics | After move, the source is empty (`operator bool == false`) | +| 3 | Invalidated on demand | After `invalidate_weak_ptrs()`, every minted `wp.get()==nullptr` | +| 4 | CHECK-on-deref | Dereferencing an invalidated `wp` trips an assertion (debug assert / release CHECK) | +| 5 | maybe_valid asymmetry | Negative is trusted (false ⇒ definitely invalidated), positive is not | +| 6 | Factory destruction invalidates | After the factory destructs, all wp are invalid; the last member guards during member destruction | + +## Key cases (Catch2 style) + +Six invariants sound abstract. On the ground they collapse to the boundaries that blow up the moment you get them wrong. We pick three that lock down the semantics hardest: collective invalidation through the shared Flag, `was_invalidated` separating invalidated from manually reset, and the destruction order of the last member. The runnable demos in `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/` are `12` through `18`; wiring the Catch2 test target is left as an extension. For now, here is what the cases look like: + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include "weak_ptr/weak_ptr.hpp" +using namespace tamcpp::chrome; + +struct Foo { int x = 42; }; + +TEST_CASE("invalidate kills all weak ptrs sharing the flag", "[weak_ptr]") { + Foo foo; WeakPtrFactory fac(&foo); + auto wp1 = fac.get_weak_ptr(); + auto wp2 = fac.get_weak_ptr(); // same factory → shared Flag + fac.invalidate_weak_ptrs(); + REQUIRE_FALSE(wp1); // invariant 3: collective invalidation + REQUIRE_FALSE(wp2); +} + +TEST_CASE("was_invalidated vs reset", "[weak_ptr]") { + Foo foo; WeakPtrFactory fac(&foo); + auto wp_a = fac.get_weak_ptr(); + fac.invalidate_weak_ptrs(); + REQUIRE(wp_a.was_invalidated()); // invalidated + auto wp_b = fac.get_weak_ptr(); + wp_b.reset(); + REQUIRE_FALSE(wp_b.was_invalidated()); // manual reset is not invalidation +} + +// invariant 6: last member guards during member destruction +struct Good { // ✓ factory declared last + std::vector buf_; + WeakPtrFactory fac_{this}; +}; +struct Bad { // ✗ factory declared first → destructs last + WeakPtrFactory fac_{this}; + std::vector buf_; +}; +TEST_CASE("last-member idiom: destruction order", "[weak_ptr][.death]") { + // Good: fac_ destructs first → WeakPtr invalidates → buf_ destructs + // Bad: buf_ destructs first → fac_ destructs after → window where WeakPtr is still + // valid (a dangling deref is possible) + // Verify with TSan/AddressSanitizer in an isolated death test that Good does not UAF +} +``` + +All three stare at semantic boundaries, not API surface. The shared-Flag case checks whether "one invalidate, everyone drops" actually holds. The `was_invalidated` case is finer: a manual `reset()` should not count as invalidation, and we deliberately contrast `wp_b` with `wp_a` so the two flavors of "became empty" don't blur together. The last-member case is the destruction-order crux, and we treat it on its own. + +Invariants 4 (CHECK-on-deref) and 6 (destruction order) have a snag: they abort. Drop them into an ordinary TESTCASE and the whole binary goes down with them. So they have to be isolated as death tests that crash in a child process. This is the same trick 01-6 used for OnceCallback's single-consumption assertion; we already walked through it there. + +## Performance: object size + +Start with the most direct question: how many bytes does a `WeakPtr` actually cost? We pin it with `static_assert`; if it doesn't compile, it's wrong: + +```cpp +static_assert(sizeof(WeakPtr) == sizeof(void*) * 2); // 16 bytes (x86-64) +``` + +| Type | sizeof | Composition | +|---|---|---| +| `WeakPtr` | 16 | `WeakReference` (scoped_refptr, 1 ptr) + `T*` (1 ptr) | +| `std::weak_ptr` | 16 | object pointer + control block pointer | + +The sizeof comes out equal, and we were a little surprised at first. `std::weak_ptr` has a reputation; we expected it to be tighter. But once you think about it the symmetry is obvious: both are two pointers, one to the object and one to the control block or Flag, structurally identical. The real gap is in allocation behavior (table below), not size. + +There's also a gain `sizeof` can't show. `TRIVIAL_ABI` lets WeakPtr travel entirely in registers when passed by value (two registers is enough), which `std::weak_ptr` cannot do. That's an ABI-layer effect a benchmark won't necessarily catch, but on a hot path it saves real stack traffic. + +## Performance: allocation and calls + +Same size, and the real gap opens up in allocation count and liveness-check cost. We lined the two up side by side: + +| Dimension | `std::weak_ptr` | `WeakPtr` | +|---|---|---| +| Pointed-at object allocation | `shared_ptr(new T)` 2 allocs; `make_shared` 1 but fuses the memory | Not forced: Flag is 1 intrusive alloc + the object allocates its own way | +| Liveness check cost | `lock()`: atomic read of strong count + if alive, inc + build a temporary shared_ptr | `get()`: 1 atomic acquire-load, returns a raw pointer | +| Cross-sequence deref | `lock()` is thread-safe | Same-sequence only (contract + DCHECK) | +| Batch invalidation | None | **One invalidate drops all** (shared Flag) | + +The row worth sitting with is the liveness-check cost. `get()` is lighter than `lock()` for a real reason, not by luck. `lock()` has to atomically read the strong count, increment it if the object is alive, and then hand you back a temporary `shared_ptr` (which has to decrement again on the way out). That's several atomic round trips. `get()` is a single acquire-load that returns a raw pointer and is done. The price is real too: what you get back is a raw pointer, nobody synchronizes for you, and the "same sequence" contract is what backs it up. We think the trade is worth it. The contract is enforced by a DCHECK in debug, so a real violation blows up during development instead of leaking to production. + +## vs real Chromium: teaching-version tradeoffs + +| Dimension | Chromium | Teaching version | +|---|---|---| +| Flag refcount | `RefCountedThreadSafe` | Same | +| Atomic flag | `base::AtomicFlag` | `std::atomic` + memory_order (equivalent) | +| Sequence checking | `SEQUENCE_CHECKER` + SequenceToken | Simplified (thread id as a stand-in) | +| `SafeRef` | Full | Not implemented | +| `BindOnce` integration | Full `InvokeHelper` dual specialization | Simplified trampoline | +| `InvalidateAndDoom` | Full | Kept | +| `BindToCurrentSequence` | Full | Omitted | + +The tradeoff logic for the teaching version goes like this. Trim what's peripheral: `SafeRef`, `BindToCurrentSequence`, and friends get cut; sequence checking stands in with a thread id. But the core mechanism stays untouched. Refcounted Flag, the acquire/release pairing, the sequence contract, compile-time weak dispatch stay exactly as they should be. The reasoning is simple. The periphery is engineering convenience; the core is the correctness load-bearing wall. Cutting the periphery makes the thing awkward to use. Cutting the core means it isn't WeakPtr anymore. + +That closes the design, implementation, and verification trilogy for the WeakPtr component. Looking back, it's a sibling piece to OnceCallback: the cancellation token we threw in for convenience back in 01-4 has its industrial-strength answer in this WeakPtr system, and the loop is now closed. + +## References + +- [Chromium `base/memory/weak_ptr_unittest.cc`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr_unittest.cc) +- [Catch2 documentation](https://github.com/catchorg/Catch2/tree/devel/docs) +- [weak_ptr design guide (I): motivation, API, and the control block](./01-weak-ptr-design.md) +- [OnceCallback design guide (III): test strategy and performance](../../01_once_callback/hands_on/03-once-callback-testing.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/index.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/index.md new file mode 100644 index 000000000..d94a236cd --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/index.md @@ -0,0 +1,34 @@ +# WeakPtr: weak-pointer design, learned from Chromium + +This directory implements a Chromium-style `WeakPtr` component and works through the modern C++ design of observing whether an object is still alive, without taking ownership. It sits alongside the [OnceCallback series](../01_once_callback/) — the industrial-strength answer to the hand-rolled cancellation token from 01-4 is `WeakPtr`. + +## Full tutorial (full/) + +For readers starting fresh, from weak-reference concepts and prerequisites through to a complete implementation. + +Prerequisites (7 chapters): + +- [weak references and the lifetime puzzle](./full/pre-00-weak-ptr-weak-reference-and-lifetime.md) +- [intrusive refcounting and scoped_refptr](./full/pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md) +- [std::atomic and memory_order](./full/pre-02-weak-ptr-atomic-and-memory-order.md) +- [sequences, SEQUENCE_CHECKER, and DCHECK/CHECK](./full/pre-03-weak-ptr-sequence-checker-dcheck-check.md) +- [concepts and requires, advanced](./full/pre-04-weak-ptr-concepts-and-requires.md) +- [template friends and uintptr_t type erasure](./full/pre-05-weak-ptr-template-friend-and-uintptr-t.md) +- [TRIVIAL_ABI and trivial relocatability](./full/pre-06-weak-ptr-trivial-abi.md) + +Hands-on (6 chapters): + +- [motivation and API design](./full/02-1-weak-ptr-motivation-and-api-design.md) +- [core skeleton and the control block](./full/02-2-weak-ptr-core-skeleton-and-control-block.md) +- [WeakPtrFactory and the last-member idiom](./full/02-3-weak-ptr-factory-and-last-member.md) +- [sequence affinity and lazy binding](./full/02-4-weak-ptr-sequence-affinity-and-lazy-binding.md) +- [callback integration — closing OnceCallback's loop](./full/02-5-weak-ptr-bind-integration.md) +- [testing and performance](./full/02-6-weak-ptr-testing-and-perf.md) + +## Hands-on design guide (hands_on/) + +For readers comfortable with C++ templates and concurrency, a quick walkthrough of the design motivation, implementation, and testing: + +- [motivation, API, and the control block](./hands_on/01-weak-ptr-design.md) +- [step-by-step implementation](./hands_on/02-weak-ptr-implementation.md) +- [test strategy and performance](./hands_on/03-weak-ptr-testing.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-1-flat-map-motivation-and-api-design.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-1-flat-map-motivation-and-api-design.md new file mode 100644 index 000000000..248cf5f1a --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-1-flat-map-motivation-and-api-design.md @@ -0,0 +1,203 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "Start from the cache-miss pain in a config table, work out exactly what flat_map has to fill in, and pin down the target API in one pass: CHECK-based at, a transparent comparator by default, a sorted_unique constructor, and the rest of the load-bearing decisions" +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- 'flat_map prerequisite (0): ordered associative containers and std::map''s red-black tree' +- 'flat_map prerequisite (2): complexity and amortized analysis' +reading_time_minutes: 11 +related: +- 'flat_map in practice (II): the flat_tree core skeleton' +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 优化 +title: "flat_map in practice (I): motivation and API design" +--- +# flat_map in practice (I): motivation and API design + +In [prerequisite (0)](./pre-00-flat-map-ordered-assoc-container-intro.md) we left a question hanging: every node in `std::map`'s red-black tree costs one malloc, and a lookup chases pointers all the way down, each step a likely cache miss. The asymptotic complexity is `O(log n)`, no argument there, but once you land in the small-to-medium, read-heavy regime the constant factor runs away with the performance. This piece picks that pain back up, works out what flat_map actually has to fill in, and pins down the target API in one pass. + +What flat_map is after fits in a sentence: give the write-once-read-many ordered map a cache-friendly implementation. It has no ambition to take std::map's job; "large and churned constantly" stays std::map's territory. What it fills is the read-heavy gap. Across this series we tear down how Chromium implements it and hand-roll a teaching version alongside, so the tradeoffs land more easily when you see both sides. + +--- + +## Starting from a real pain: the config table + +Say we're writing a command dispatch table. The program loads a pile of command-to-callback mappings from a config file at startup, and after that it only ever looks them up: + +```cpp +std::map commands; +for (auto& [name, handler] : load_commands()) { + commands.emplace(name, handler); // built once at startup +} + +// runtime: each command is a lookup only +auto it = commands.find(cmd_name); +if (it != commands.end()) it->second(args); +``` + +Nothing obviously wrong here, and `std::map`'s `O(log n)` lookup sounds "fast enough." Run perf on it and you'll stare: a lot of time piles up in `std::map::find`, and the table might hold a few dozen entries. What `O(log n)` doesn't tell you is that each of those `log n` steps in a `find` is a pointer dereference that probably cache-misses. A few dozen elements means `log n` is six or seven steps, each one a miss, and the lookup is shot. + +We tore down the root cause in [pre-00](./pre-00-flat-map-ordered-assoc-container-intro.md): the red-black tree nodes are scattered across the heap, each carrying 32 bytes of metadata plus a heap allocation, and `node = node->left_` during a lookup is a data-dependent dereference. The CPU can't prefetch reliably because the next address depends on data it hasn't read yet. At small sizes this constant factor outweighs `log n` by a lot. That is exactly where std::map bites you in this scenario. + +--- + +## Why three off-the-shelf answers all fall short + +The pain is clear. Can we get away with an existing wheel? Let's take them in turn. + +`std::map` needs no more said; the cache miss above is its work, and one-malloc-per-node is incurable. + +What about `std::unordered_map`? A hash table averages `O(1)` lookup, which sounds great. At small sizes its constant factor doesn't necessarily win either; hash computation and collision handling still cost. Worse, it's unordered, so you can't iterate keys in sorted order, can't `lower_bound` into a range, can't do ordered interval queries. The day the product asks "list all commands" or "filter by prefix," unordered_map is stuck on the spot. Chromium's own container guide says as much: it doesn't recommend `std::unordered_map`, and its performance loses to absl's hash maps. + +The remaining path is to roll your own: keep a `vector>`, sort after each insert, and `std::lower_bound` for lookup. Functionally that's flat_map. But you're left to handle deduplication yourself, keep things sorted yourself, remember iterator invalidation yourself, and if you want a sorted_unique optimization you bolt on a tag dispatch yourself. It's reinventing the wheel, and every step is a place to get it wrong. I rolled one years ago and realized I was just re-stepping through the traps flat_map was built to handle. + +All three fall short: map cache-hurts, unordered_map is unordered and officially discouraged, and hand-rolling is duplicate work that's easy to get wrong. Chromium's answer is blunt: bundle this functionality into one container with a std::map-style interface and call it `flat_map`. + +--- + +## Chromium's answer: the flat_map design philosophy + +The design philosophy comes down to two things. First, storage is one contiguous sorted array (by default a `vector>`), and lookup is a binary search (`lower_bound`). Contiguity buys cache friendliness; binary search buys `O(log n)` lookup. Second, it does not chase insert performance. A single insert is `O(n)` of shifting, and it accepts that; what it buys in return is a one-shot sort at construction (bulk construction is `O(N log N)`) and that cache-friendly low constant factor at lookup time. + +Those two lines draw flat_map's fit: write-once-read-many, or ordered maps whose size stays small. If your scenario is "large and churned constantly," `O(n)` inserts will hurt enough to make you question your choices. That's std::map's home turf; don't crash the party. + +### Architecture overview: flat_tree is the only implementation + +The implementation has an elegant layering that stopped me cold the first time I read it: there's really only one class at the core, `flat_tree`, a general "sorted array associative container." `flat_map` and `flat_set` are both thin shells over it. + +How thin? `flat_map` inherits from `flat_tree`, where `GetFirst` is an extractor that pulls `first` out of a `pair` to use as the key (flat_map.h:194-195, 24-29). `flat_set` is blunter still: a `using =` alias for `flat_tree`, where `std::identity` treats the value itself as the key (flat_set.h:159-163). + +Sit with that design for a second. One flat_tree implementation, just by swapping the "key extractor" line, produces both a map face and a set face. This is the classic strategy-object play. Once you understand flat_tree, flat_map and flat_set come for free. So the hands-on half of this series is mostly about tearing down flat_tree; everything that differs between flat_map and flat_set lives on that one extractor line. + +--- + +## The target API + +Motivation covered. Let's pin down the target API in one pass, then come back and dig out the decision behind each signature. Naming stays in the `tamcpp::chrome` namespace, snake_case, matching the OnceCallback and WeakPtr series. + +### Construction + +```cpp +#include "flat_map/flat_map.hpp" +using namespace tamcpp::chrome; + +// from unordered data (sorted and deduped internally) +flat_map m1 = {{1, "a"}, {3, "c"}, {2, "b"}}; + +// move-construct from an existing vector (bulk construction, efficient) +std::vector> raw = {{1,"a"}, {2,"b"}, {3,"c"}}; +flat_map m2(std::move(raw)); + +// sorted_unique construction (data already sorted, skip the sort) +flat_map m3(sorted_unique, std::vector>{{1,"a"},{2,"b"},{3,"c"}}); +``` + +### Lookup and modification + +```cpp +flat_map m; +m[1] = load(1); // operator[]: inserts if missing +m.insert_or_assign(2, x); // insert or overwrite +m.try_emplace(3, arg1, arg2);// construct mapped only if key absent + +auto it = m.find(1); // O(log n) binary search +if (it != m.end()) use(it->second); + +m.at(99); // out of range -> CHECK crash (not throw, see decision analysis) +``` + +### Heterogeneous lookup (transparent comparator) + +```cpp +flat_map sm; // default Compare = std::less<> (transparent) +sm.find("timeout"); // look up with const char* directly, no temporary std::string +``` + +--- + +## Decision analysis behind the API + +The API is pinned down, but each signature hides a tradeoff. Let's pull the "why" out of each one. + +### Why at() uses CHECK instead of throw + +`std::map::at(key)` throws `std::out_of_range` on a missing key; that's the standard library's rule. `flat_map::at(key)` on a missing key fails a `CHECK` and aborts the program outright (flat_map.h:293/302), no negotiation. Why so harsh? + +Because an out-of-range access usually means the caller's logic is wrong: either you should have checked with `find` first, or you're certain the key has to be in there. That kind of bug needs to blow up in release too, not surface as an exception that some upstream `try/catch` papers over, which usually buries a real logic error under fallback handling. This is Chromium's consistent error-handling style: definite logic errors get CHECK, exceptions are not the safety net. [WeakPtr's `operator*` using CHECK to guard a dereference of an invalidated handle](../../02_weak_ptr/full/02-1-weak-ptr-motivation-and-api-design.md) is the same philosophy; we covered it there too. + +### Why the default comparator is the transparent std::less<> + +`std::map` defaults to `Compare = std::less`, non-transparent; flat_map swaps in `std::less<>`, transparent (flat_map.h:192). That one swap opens the door to heterogeneous lookup: you can `find` a `std::string` map with a `const char*` without constructing a temporary `std::string`. On a hot path the accumulated cost of those temporaries is real; see [pre-03](./pre-03-flat-map-comparator-and-transparent.md). Modern C++ recommends transparent comparators as the default, and flat_map just does it. + +### Why we store pair and not pair + +The underlying storage is `std::vector>`, with a non-const key (flat_map.h:193). This counterintuitive choice is forced by vector's shifting: insert and erase relocate whole pairs, which means the pair has to be move-assignable, and `pair` is not move-assignable. The cost is that the key is exposed as mutable; an iterator could in principle rewrite a key and break the sorted invariant, and only user discipline stops that. The full accounting is in [pre-05](./pre-05-flat-map-enua-ebo-and-pair-storage.md). + +### Why we provide a sorted_unique constructor + +If you can guarantee the data is already sorted, constructing with the `sorted_unique` tag skips the `O(N log N)` sort and drops to `O(N)`. Better still, in debug builds a `DCHECK` verifies you didn't lie: you claimed sorted, it actually goes and checks. This is a real zero-cost abstraction; see [pre-04](./pre-04-flat-map-tag-dispatch-and-sorted-unique.md). + +--- + +## Our implementation vs Chromium's tradeoffs + +Like the previous two series, the teaching version keeps the core machinery (the flat_tree adapter, sorted vector, sorted_unique, transparent comparison) and simplifies where it can. A preview of the tradeoffs; 03-6 closes the loop with measurements: + +| Axis | Chromium | Our teaching version | +|---|---|---| +| Underlying Container | `std::vector` | same | +| Sort | `std::stable_sort` + unique + erase | same | +| Transparent comparison | `KeyT` + `KeyValueCompare` two overloads | simplified to a direct template | +| `DCHECK(is_sorted_and_unique)` | full | approximated with `assert` | +| `[[no_unique_address]]` comparator | annotated | annotated (both GCC and Clang support it) | +| `replace` / `extract` | full | omitted (left as an extension) | + +We build the core out of the pure standard library (`std::vector`, `std::sort`, `std::lower_bound`) and copy Chromium's design philosophy wholesale. The Chromium-specific complexity, the `raw_ptr_exclusion` annotations and `NO_UNIQUE_ADDRESS` macro machinery, gets cut entirely. That's their engineering plumbing; the teaching version doesn't need it. + +--- + +## Environment setup + +flat_map leans on C++20 concepts (`requires`, `std::convertible_to`), ranges (`std::ranges::lower_bound`), and the `[[no_unique_address]]` attribute. So the floor is C++20. + +### Compiler requirements + +GCC 11+ or Clang 12+ both work; compile with `-std=c++20`. `[[no_unique_address]]` is supported on both GCC and Clang, and its EBO behavior for empty types is equivalent and correct, so you can stop worrying about that one. + +### Verification code + +```cpp +#include +#include +#include + +static_assert(__cpp_lib_ranges >= 201911L); // ranges available + +constexpr bool check_nua_works() { + struct Empty {}; + struct H { [[no_unique_address]] Empty e; int i; }; + return sizeof(H) == sizeof(int); // EBO folds Empty away +} +static_assert(check_nua_works()); +``` + +If this compiles clean on your machine, the environment is ready. The project scaffold stays in `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/`; starting from 03-2 we drop in the `19_` through `22_` batch of flat_map samples. + +Motivation and API are straight on paper now. But straight on paper and actually writing flat_tree line by line are different things. How the sorted vector adapter hangs together, how the sorted invariant is defended, how the key extractor slots in, all of that is the trapfield the next piece walks through. We start writing in the next piece. + +## References + +- [Chromium `base/containers/flat_map.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [Chromium `base/containers/flat_tree.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/README.md` — container selection guide](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/README.md) +- [flat_map prerequisite (0): ordered associative containers and std::map's red-black tree](./pre-00-flat-map-ordered-assoc-container-intro.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-2-flat-map-flattree-skeleton.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-2-flat-map-flattree-skeleton.md new file mode 100644 index 000000000..1ee6a0529 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-2-flat-map-flattree-skeleton.md @@ -0,0 +1,282 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "Building the flat_tree core skeleton: the sorted-vector adapter, a key-extractor policy, the ordered invariant, a nested value_compare, and how flat_map/flat_set inherit it" +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- 'flat_map hands-on (I): motivation and API design' +- 'flat_map prerequisite (1): std::vector internals and growth' +- 'flat_map prerequisite (V): NO_unique_ADDRESS, EBO, and pair storage' +reading_time_minutes: 12 +related: +- 'flat_map hands-on (III): lookup and insertion' +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 内存管理 +title: "flat_map hands-on (II): the flat_tree core skeleton" +--- +# flat_map hands-on (II): the flat_tree core skeleton + +In the previous piece we pinned down flat_map's target API and offhandedly said the whole thing really comes down to one class: `flat_tree`. This time we build that "ordered-array associative container adapter" by hand. Once it's standing you'll notice a number that's hard to un-notice: Chromium's flat_set.h is 191 lines, total. The set gets off that cheaply because everything reusable is swallowed by flat_tree, and flat_set itself barely writes any code. + +`flat_tree` serves both map and set from one skeleton through three tricks working together. The first is a generic key-extractor policy (`GetKeyFromValue`): given the same value type, it can pull out the key or hand the value back unchanged, and that choice is what decides whether the container wears a map or set hat. The second is the ordered invariant: after every mutation it quietly patches the ordering back up. The third is a nested `value_compare` that translates a comparison on values back into a comparison on keys. We'll take them one at a time. + +## The flat_tree template signature + +`flat_tree`'s signature (flat_tree.h:104-105) looks like this: + +```cpp +template +class flat_tree { +protected: + Container body_; // underlying sorted container (default: vector) + [[no_unique_address]] KeyCompare comp_; // key comparator (EBO, zero overhead) + // ... +}; +``` + +Four template parameters, and we'll go through them. `Key` is the key type, no surprise there. `GetKeyFromValue` is the secret weapon of this design: it's a functor exposing `const Key& operator()(const Value&)`, so given a value (a `pair` for map, a `K` itself for set) it returns the key. The same flat_tree can act as map or set, and the entire difference rides on this one typename; we'll see the concrete spellings below. `KeyCompare` is the key comparator, defaulting to `std::less<>`. `Container` is the underlying sequence container, defaulting to `std::vector`: map uses `vector>`, set uses `vector`. + +Two data members: `body_` is the underlying container, `comp_` is the comparator. `comp_` carries `[[no_unique_address]]` so an empty comparator costs zero bytes; we walked through the why of that in [pre-05](./pre-05-flat-map-enua-ebo-and-pair-storage.md), no need to repeat it here. + +--- + +## The key extractor: GetFirst vs std::identity + +The key extractor is what lets map and set share one codebase. Let's look at the two concrete implementations. + +flat_map uses `GetFirst` (flat_map.h:24-29): + +```cpp +struct GetFirst { + template + constexpr const Key& operator()(const std::pair& p) const { + return p.first; // value is a pair, take first as the key + } +}; +``` + +flat_set is even thriftier and just reuses the standard library's `std::identity`, returning its argument as-is: + +```cpp +// flat_set.h:163 is equivalent to: +using flat_set = flat_tree>; +// std::identity's operator()(const T&) returns T itself, so value is the key +``` + +When flat_tree needs to compare two values internally, it first calls the extractor on each to get the key, then compares keys with `comp_`. So the same flat_tree code, under `GetFirst`, treats `pair` as a map, and under `std::identity` treats `K` as a set. That stopped us for a second when we first read it: the entire implementation fork between map and set lands on a single typename. + +--- + +## value_compare: translating a value comparison into a key comparison + +flat_tree also exposes a nested `value_compare` (flat_tree.h:122-130). Its job is to let the outside world compare by value; for instance, if you want to feed a value array straight to `std::sort`, you need a functor that compares values in hand: + +```cpp +struct value_compare { + constexpr bool operator()(const value_type& left, const value_type& right) const { + GetKeyFromValue extractor; + return comp(extractor(left), extractor(right)); // pull each key, then compare + } + [[no_unique_address]] key_compare comp; // EBO again +}; +``` + +What it does is "extract the key on both sides, then hand off to `comp`." For map that means comparing the `first` of two pairs; for set it means comparing the two keys directly (because the extractor is identity, a passthrough). This nested struct is what lets flat_tree offer a comparison interface at the value level while reusing the same key comparator underneath, with no need to write a separate value-comparison path. + +--- + +## The ordered invariant: sorted and unique after every mutation + +The single invariant flat_tree guards is this: `body_` is always strictly ascending under `comp_`, with no duplicates. It's maintained in two places: once during construction (a bulk sort), and once per insert (an incremental guard). + +### Construction: sort_and_unique + +A normal constructor (taking unordered input) calls `sort_and_unique` (flat_tree.h:147-149; implementation at 567/578/586/594): + +```cpp +void sort_and_unique() { + std::stable_sort(body_.begin(), body_.end(), value_comp()); // sort (O(N log N)) + auto it = std::ranges::unique(body_, equiv); // dedup (equiv = !comp && !comp) + body_.erase(it.end(), body_.end()); // chop off the duplicate tail +} +``` + +`stable_sort` orders by `value_comp`, then `unique` shuffles equivalent elements to the end, and `erase` lops off that tail. One note on why this is `stable_sort` rather than `sort`: if equivalent elements (multiple values under one key) had an original ordering, `stable_sort` preserves their relative order. flat_map only keeps one after dedup, but the stable semantics are safer in edge cases, for example when the value carries state. After construction, `body_` is in a clean sorted-and-unique state. + +### Single-point insert: lower_bound + insert + +Inserting one element at runtime (flat_tree.h:1060, `unsafe_emplace`) does a `lower_bound` to find the position (keeping order), then `insert`. lower_bound finds "the first position not less than the key," so inserting there stays sorted by construction; if the key is already present, `lower_bound` points at that equal element, and the `unique` contract requires the insert to be rejected to avoid a duplicate. The exact mechanics of this find-then-insert, and the genuinely painful shift cost that comes with it, we save for 03-3. + +--- + +## Constructors: plain vs sorted_unique + +flat_tree's constructors split into two families, and the split hides a real performance tradeoff. We'll pull it apart. + +The plain family takes unordered data and dutifully calls `sort_and_unique` internally: + +```cpp +flat_tree(InputIterator first, InputIterator last, const Compare& comp) { + body_.insert(body_.end(), first, last); + sort_and_unique(); // sort + dedup +} +``` + +The sorted_unique family takes a `sorted_unique_t` tag as your word that the data is already sorted and unique, and it skips the sort, running only a DCHECK: + +```cpp +flat_tree(sorted_unique_t, InputIterator first, InputIterator last, const Compare& comp) { + body_.insert(body_.end(), first, last); + DCHECK(is_sorted_and_unique(body_, comp)); // debug-only check, no sort +} +``` + +The whole difference between the two families is that one `sort_and_unique` call: either it really sorts, or it takes your word. The latter skips an O(N log N) pass when you know the source is already ordered (moving out of another sorted container, for example), and it's the escape hatch flat_tree leaves for performance-sensitive paths. The mechanism behind tag dispatch itself we covered in [pre-04](./pre-04-flat-map-tag-dispatch-and-sorted-unique.md). + +--- + +## How flat_map and flat_set inherit flat_tree + +With the flat_tree skeleton in place, flat_map and flat_set have almost nothing left to write. + +flat_map (flat_map.h:194-195) inherits and fills in its key extractor: + +```cpp +template , + class Container = std::vector>> +class flat_map : public flat_tree { + // inherits all the generic operations from flat_tree (find/insert/erase/lower_bound...) + // adds only map-specific ones: operator[], at, insert_or_assign, try_emplace +}; +``` + +flat_set (flat_set.h:159-163) goes further and doesn't even define a class, it's just an alias: + +```cpp +template , + class Container = std::vector> +using flat_set = flat_tree; +// no code of its own, set is just flat_tree with key=value +``` + +The map-specific operations flat_map adds (`operator[]`, `at`, `insert_or_assign`, `try_emplace`) we'll cover in 03-3 alongside lookup and insertion. flat_set, where key is value, genuinely has nothing to add; one `using` and it's done. Look back at those 191 lines of flat_set.h and you can feel how much this abstraction buys you. + +--- + +## A minimal flat_tree of our own + +Reading Chromium's code only gets you so far, so let's build a minimal version by hand and feel how the "key extractor + ordered invariant" pair actually meshes: + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include +#include +#include +#include + +namespace tamcpp::chrome::internal { + +template +class flat_tree { +public: + using value_type = typename Container::value_type; + using iterator = typename Container::iterator; + using const_iterator = typename Container::const_iterator; + + // Plain constructor: unordered data, sort + dedup internally + flat_tree(Container data, KeyCompare comp = KeyCompare()) + : body_(std::move(data)), comp_(comp) { + sort_and_unique(); + } + + // Lookup: O(log n) binary search + const_iterator find(const Key& key) const { + auto it = std::ranges::lower_bound( + body_, key, + [&](const value_type& v, const Key& k) { return comp_(GetKeyFromValue{}(v), k); }, + [&](const Key& k, const value_type& v) { return comp_(k, GetKeyFromValue{}(v)); }); + if (it != body_.end() && !comp_(key, GetKeyFromValue{}(*it))) return it; + return body_.end(); + } + + std::size_t size() const { return body_.size(); } + const value_type& front() const { return body_.front(); } + +private: + void sort_and_unique() { + GetKeyFromValue ext; + std::stable_sort(body_.begin(), body_.end(), + [&](const value_type& a, const value_type& b) { + return comp_(ext(a), ext(b)); + }); + body_.erase(std::unique(body_.begin(), body_.end(), + [&](const value_type& a, const value_type& b) { + auto ka = ext(a), kb = ext(b); + return !comp_(ka, kb) && !comp_(kb, ka); + }), + body_.end()); + } + + Container body_; + [[no_unique_address]] KeyCompare comp_; +}; + +} // namespace tamcpp::chrome::internal +``` + +This minimal version holds onto two things: sort-and-dedup at construction, and binary search at lookup. The key-extractor policy runs through both `find` and `sort_and_unique`, translating value to key each time. Next step is adding insertion and erasure, and that shift cost is flat_map's real soft spot. + +--- + +## Assembling map and set out of flat_tree + +```cpp +// map: stores pair, extracts the key with GetFirst +struct GetFirst { + template + constexpr const K& operator()(const std::pair& p) const { return p.first; } +}; + +template +using mini_flat_map = internal::flat_tree, + std::vector>>; + +// set: stores K, extracts the key with std::identity +template +using mini_flat_set = internal::flat_tree, std::vector>; + +int main() { + mini_flat_map m{std::vector>{ + {2, "b"}, {1, "a"}, {3, "c"}}}; + std::cout << m.size() << " elements, front key=" << m.front().first << "\n"; // 3, 1 (sorted) + + mini_flat_set s{std::vector{3, 1, 2, 1}}; // the duplicate 1 gets deduped + std::cout << s.size() << " elements\n"; // 3 + return 0; +} +``` + +Run it and you'll see `3 elements, front key=1` (sort worked) and `3 elements` (dedup worked). One flat_tree: under a `GetFirst` hat it's a map, swap in `std::identity` and it's a set. + +--- + +The skeleton stands now. The signature `flat_tree` is the common base of the ordered-array associative containers; the key-extractor policy decides whether it wears a map or set hat; the ordered invariant is held by two gates, `sort_and_unique` at construction and `lower_bound + insert` at insertion; and `value_compare` translates value comparison back to key comparison. flat_map adds a handful of map-specific operations on top of this, and flat_set is done with a single `using`, which is how flat_set.h ends up at 191 lines. + +Next we write flat_tree's lookup and insertion for real. The O(log n) binary search is the easy part; the O(n) shift is what we actually want to measure for you, because that cost is where the ache is. + +## References + +- [Chromium `base/containers/flat_tree.h`: the flat_tree class and value_compare](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/flat_map.h`: GetFirst and the flat_map subclass](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [Chromium `base/containers/flat_set.h`: the flat_set alias](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_set.h) +- [flat_map hands-on (I): motivation and API design](./03-1-flat-map-motivation-and-api-design.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-3-flat-map-lookup-and-insert.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-3-flat-map-lookup-and-insert.md new file mode 100644 index 000000000..434c1e79c --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-3-flat-map-lookup-and-insert.md @@ -0,0 +1,214 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "Implement flat_tree lookup (lower_bound, O(log n)) and insert (lower_bound + emplace, O(n) shift); cover flat_map's operator[]/insert_or_assign/try_emplace, and measure the shift cost by hand." +difficulty: intermediate +order: 3 +platform: host +prerequisites: +- "flat_map hands-on (II): the flat_tree core skeleton" +- "flat_map prerequisite (II): complexity and amortized analysis" +reading_time_minutes: 12 +related: +- "flat_map hands-on (IV): sorted_unique construction optimization" +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 优化 +title: "flat_map hands-on (III): lookup and insert" +--- +# flat_map hands-on (III): lookup and insert + +[03-2](./03-2-flat-map-flattree-skeleton.md) stood the skeleton up. This piece fills in the two things you'll actually do with it: how to look things up, and how to insert. One of these is flat_map's selling point, the other is its cost, and we'll take them apart separately. + +Lookup is where flat_map genuinely shines. The data is already contiguous and sorted, so a binary search lands in `O(log n)`, and the memory it touches almost entirely sits in cache. The selling point is real. Insert is where you have to be careful. The `O(n)` shift isn't a documentation warning to scare you; it bites. At the end we'll run an experiment so you can see that shift curve with your own eyes, and turn the abstract claim into a cost you can feel. Both operations stand on top of the complexity analysis from [pre-02](./pre-02-flat-map-complexity-and-amortized.md) and the vector behavior from [pre-01](./pre-01-flat-map-vector-internals-and-growth.md); if you haven't worked through those, circle back first. + +## Lookup: lower_bound, O(log n) + +flat_tree exposes a whole family of lookup interfaces (`find`/`contains`/`lower_bound`/`equal_range`), and they all converge on the same thing: a binary search. Chromium uses `std::ranges::lower_bound` with a `KeyValueCompare` comparator object (flat_tree.h:1027) to find the first position not less than the key on the sorted array: + +```cpp +// Core of flat_tree::find (simplified; passes a single binary comparator (value,key)->bool) +const_iterator find(const Key& key) const { + auto it = std::lower_bound(body_.begin(), body_.end(), key, + [&](const value_type& v, const Key& k) { return comp_(GetKeyFromValue{}(v), k); }); + // lower_bound gives "first not-less-than key"; still need to confirm equality + if (it != body_.end() && !comp_(key, GetKeyFromValue{}(*it))) return it; + return body_.end(); +} +``` + +> Note: `std::ranges::lower_bound(range, value, comp)` accepts only **one** comparator. Chromium's `KeyValueCompare` (flat_tree.h:439-462) is a class with **two `operator()` overloads** (v insert(const value_type& value) { + const Key& key = GetKeyFromValue{}(value); + auto it = std::lower_bound(body_.begin(), body_.end(), key, + [&](const value_type& v, const Key& k) { return comp_(GetKeyFromValue{}(v), k); }); // 1. find position, O(log n) + if (it != body_.end() && !comp_(key, GetKeyFromValue{}(*it))) { + return {it, false}; // key already present, don't insert (unique-key invariant) + } + auto inserted = body_.emplace(it, value); // 2. insert, O(n) shift + return {inserted, true}; +} +``` + +Read the code and the two steps are clear: first use `lower_bound` to find where to insert, then `vector::emplace` constructs the element there. The second step is where the real cost lives. `vector::emplace(pos, value)` has to shift every element after `pos` back by one slot. Under the hood that's a `std::move_backward` relocation, then construct the new element in the freed slot. How much gets moved depends on the number of trailing elements, which averages to `n/2`, and the big-O is `O(n)`. + +This is the bill flat_map insert has to settle every time: shift half the elements on every insert. Asymptotic complexity `O(n)`, and there's no amortization argument here. It isn't an occasional hit, it's every single insert; nothing escapes it. + +--- + +## flat_map specifics: operator[], insert_or_assign, try_emplace + +flat_tree itself is generic. flat_map layers a few map-specific operations on top of it. Let's walk through them one at a time. + +### operator[] (flat_map.h:313, 326) + +```cpp +mapped_type& operator[](const Key& key) { + auto it = lower_bound(key); // find the position + if (it == end() || comp_(key, GetKeyFromValue{}(*it))) { + it = unsafe_emplace(it, ...); // missing -> insert a default-constructed mapped + } + return it->second; +} +``` + +What `m[key]` does: look it up, and if the key isn't there, insert a default-constructed `mapped_type()`, then return the reference; if it is there, just return the reference to the existing one. The semantics match `std::map::operator[]` exactly. One thing worth flagging on its own: it mutates the container (it can actually insert something), so it doesn't work on a `const flat_map`, and the compiler will catch that for you at build time. + +### insert_or_assign (flat_map.h:334-355) + +```cpp +template +std::pair insert_or_assign(const Key& key, M&& obj) { + auto result = emplace_key_args(key, std::forward(obj)); // try insert first + if (!result.second) { + // key already present -> overwrite mapped + result.first->second = std::forward(obj); // assignment (needs pair to be non-const!) + } + return result; +} +``` + +What `insert_or_assign(key, val)` does: if the key isn't there, insert it; if it is there, **overwrite the value**. It returns `{iterator, inserted_bool}`, where `inserted=false` means this was actually an overwrite. + +When I first read the source, the line that actually stopped me was the overwrite afterwards, `result.first->second = forward(obj)`. It relies on `pair`'s second being assignable. That is exactly why flat_map has to store `pair` internally and not `pair`. The latter's second isn't assignable, and this path would be dead. This storage choice, which looks trivial, is a hard constraint pushed back out by the `insert_or_assign` API. I've put the details in [pre-05](./pre-05-flat-map-enua-ebo-and-pair-storage.md). + +### try_emplace (flat_map.h:392-413) + +```cpp +template +std::pair try_emplace(const Key& key, Args&&... args) { + // Construct mapped(args...) only when the key is absent + auto [it, inserted] = emplace_key_args(key, std::piecewise_construct, + std::forward_as_tuple(key), + std::forward_as_tuple(std::forward(args)...)); + return {it, inserted}; +} +``` + +`try_emplace(key, args...)` has the opposite temperament from the one above: only when the key is absent does it use `args...` to construct mapped; if the key is already there, it **leaves the existing value completely untouched**. That's the real difference between it and `insert_or_assign`, one overwrites, the other ignores. The implementation has a little subtlety to it: it uses `std::piecewise_construct + forward_as_tuple` to defer the pair's construction to the moment it's actually needed, so the mapped you pass in doesn't get constructed for nothing and then thrown away in the "key already exists" case. + +--- + +## erase: O(n) shift + +Don't stare only at insert; erase is also `O(n)`. It's the symmetric cost of contiguous storage. `erase` is handed straight to vector (flat_tree.h:914/921, `body_.erase`): + +```cpp +iterator erase(const_iterator pos) { + return body_.erase(pos); // vector::erase, shifts following elements forward by one, O(n) +} +``` + +`erase(pos)` removes one position, `erase(first, last)` removes a range, and both do the same thing: shift the following elements forward by one slot as a batch. The `erase(key)` overload has one extra step. It has to `lower_bound` to find the position first (`O(log n)`), then `erase` to shift elements (`O(n)`), which together are `O(n) + O(log n)`, and the big-O is still `O(n)`. + +--- + +## Measured: how expensive is that O(n) shift, really + +Saying `O(n)` probably gives you a feel for it, but not the pain. Let's run an experiment and turn that shift curve from an abstract claim into a cost you can see. The idea is simple: insert at the head of a vector 100,000 times (`emplace(begin)`), so every insert shifts all the following elements back by one; then compare against `push_back` at the tail (amortized `O(1)`): + +```cpp +// Platform: host | C++ Standard: C++17 +#include +#include +#include + +int main() { + constexpr int N = 100'000; + + // Head insert: O(n) shift every time + std::vector a; + auto t1 = std::chrono::steady_clock::now(); + for (int i = 0; i < N; ++i) a.emplace(a.begin(), i); + auto t2 = std::chrono::steady_clock::now(); + std::cout << "emplace(begin) x" << N << ": " + << std::chrono::duration_cast(t2 - t1).count() + << " ms\n"; + + // Tail insert: amortized O(1) + std::vector b; + auto t3 = std::chrono::steady_clock::now(); + for (int i = 0; i < N; ++i) b.push_back(i); + auto t4 = std::chrono::steady_clock::now(); + std::cout << "push_back x" << N << ": " + << std::chrono::duration_cast(t4 - t3).count() + << " ms\n"; + return 0; +} +``` + +Real output on this machine (GCC 16, -O2): + +```text +emplace(begin) x100000: 264 ms +push_back x100000: 0 ms +``` + +Two orders of magnitude apart, sitting right there in front of you. That's the curve you step on when you treat flat_map like std::map and insert into it frequently. Every insert pays the bill for that 264ms `emplace(begin)` trajectory, proportionally. + +So why did the earlier pieces keep repeating the "read-heavy, write-light" usage precondition? It isn't a documentation pleasantry. It's a hard constraint forced out by this O(n) shift curve. Write too much and you'll watch the performance curve crawl toward that 264ms trajectory with your own eyes. + +--- + +## Tying it together: a complete lookup-and-insert example + +```cpp +// Using the mini_flat_map from 03-2 +mini_flat_map m{std::vector>{ + {1, "one"}, {3, "three"}, {5, "five"}}}; + +auto it = m.find(3); +if (it != m.end()) std::cout << it->second << "\n"; // three + +// Insert (sorted position decided automatically, O(n) shift) +// Here shown with flat_tree's insert (simplified) +// m.insert({4, "four"}); // inserts between 3 and 5, shifts 5 +``` + +The zero-cost construction path for flat_map is saved for later, how `sorted_unique` skips the sort_and_unique step. + +## References + +- [Chromium `base/containers/flat_tree.h`: lower_bound / unsafe_emplace / erase](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/flat_map.h`: operator[]/insert_or_assign/try_emplace](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [cppreference: std::lower_bound](https://en.cppreference.com/w/cpp/algorithm/lower_bound) +- [flat_map prerequisite (II): complexity and amortized analysis](./pre-02-flat-map-complexity-and-amortized.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-4-flat-map-sorted-unique-construction.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-4-flat-map-sorted-unique-construction.md new file mode 100644 index 000000000..32d5b585d --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-4-flat-map-sorted-unique-construction.md @@ -0,0 +1,183 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: "Implement the sorted_unique construction optimization: use tag dispatch to skip sort_and_unique, drop bulk construction from O(N log N) to O(N), back it with an honest DCHECK contract, and know when to reach for it." +difficulty: intermediate +order: 4 +platform: host +prerequisites: +- "flat_map hands-on (III): lookup and insert" +- "flat_map prerequisite (IV): tag dispatch and sorted_unique_t" +reading_time_minutes: 10 +related: +- "flat_map hands-on (V): iterator invalidation and bulk construction" +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 零开销抽象 +title: "flat_map hands-on (IV): sorted_unique construction optimization" +--- +# flat_map hands-on (IV): sorted_unique construction optimization + +[03-3](./03-3-flat-map-lookup-and-insert.md) tore single-element insert apart. Every `insert` is an `O(n)` shift, and that looks harmless when you add things one at a time. Try building a large flat_map with it, though, like loading a config table at startup, and the `O(N²)` total cost will grind you to a halt. We burned a whole afternoon on this once with a 100k-element config. Startup was absurdly slow, and the profiler showed the shift eating every cycle. + +This piece is about how to walk around that wall at construction time. The first path is bulk construction: pile the data into a vector, move it into flat_map in one shot, pay `O(N log N)` for a single sort at the end. The real headliner is `sorted_unique` construction. If your data is already sorted, the sort step gets skipped entirely and the cost drops to `O(N)`. This is [pre-04 tag dispatch](./pre-04-flat-map-tag-dispatch-and-sorted-unique.md) landing on the ground, and we'll walk the whole path from end to end. + +--- + +## The trap: building with insert is O(N²) + +The obvious way to build the map is a loop that inserts one element at a time. It looks innocent enough: + +```cpp +flat_map m; +for (auto& [k, v] : load_data()) { + m.insert({k, v}); // every call is an O(n) shift +} +``` + +Spread the cost out and look at it. The first insert is `O(1)`, the second is `O(2)`, climbing all the way to the Nth at `O(N)`, for a total of `O(1) + O(2) + ... + O(N) = O(N²)`. Scale the data up and it turns into a disaster. With 100k elements the shift count lands around `10⁸`, and measured runs chew through several seconds. That was the exact spot where we got burned. + +The flat_map authors clearly knew about this, so they left cheaper paths open specifically for construction. + +--- + +## Bulk construction: fill a vector, move it in, O(N log N) + +The way around `O(N²)` is to batch. Dump everything into a vector first, then move the whole vector into flat_map: + +```cpp +std::vector> raw; +raw.reserve(N); +for (auto& [k, v] : load_data()) raw.emplace_back(k, v); // vector push_back, amortized O(1) + +flat_map m(std::move(raw)); // move construction, one sort inside +``` + +The `flat_map(container_type&& items)` constructor (around flat_tree.h:578) does two things. It takes over the vector's storage in an `O(1)` move, then calls `sort_and_unique` once for `O(N log N)`. Total construction cost drops to `O(N log N)`. Compare that against per-element insert's `O(N²)` for the same 100k elements: `N log N ≈ 1.7×10⁶` against `N² = 10¹⁰`, four orders of magnitude apart. + +This is the construction posture flat_map officially recommends. Pile the data in a vector, enjoy push_back's amortized `O(1)`, then move it in with one cut. The flat_map.h:61-62 docs say it straight out: "If possible, construct a flat_map in one operation by inserting into a container and moving that container into the flat_map constructor." + +--- + +## sorted_unique: skip the sort, O(N) + +Now suppose the data you're holding is already sorted and has no duplicates. That `O(N log N)` `sort_and_unique` inside bulk construction is wasted work. Just take the vector over as-is. That is exactly what the `sorted_unique` constructor is for: + +```cpp +std::vector> raw = load_already_sorted_data(); // already sorted +flat_map m(sorted_unique, std::move(raw)); // skip sort_and_unique, O(N) +``` + +Pass a `sorted_unique` tag as the first argument and flat_map routes to the sort-skipping overload (flat_tree.h:606-646). It does exactly two things: take over the vector in an `O(1)` move, then run `DCHECK(is_sorted_and_unique(...))` for a debug check. In release builds the DCHECK compiles away to nothing, so the whole cost is the takeover. Pure `O(N)`. + +### The five sorted_unique overloads + +flat_tree ships five overloads for `sorted_unique`, covering every input source: + +- `flat_map(sorted_unique, InputIterator first, last, comp)` +- `flat_map(sorted_unique, from_range_t, Range&&, comp)` (C++23 ranges) +- `flat_map(sorted_unique, const container_type&, comp)` +- `flat_map(sorted_unique, container_type&&, comp)` ← the one used above +- `flat_map(sorted_unique, initializer_list, comp)` + +They differ from the corresponding plain constructors in exactly one way: no `sort_and_unique`. The mechanism is tag dispatch, which we pulled apart in [pre-04](./pre-04-flat-map-tag-dispatch-and-sorted-unique.md). `sorted_unique_t` is an empty tag type, and the compiler picks a different function during overload resolution based on whether you pass the tag. Zero runtime cost. + +--- + +## DCHECK(is_sorted_and_unique): an honest contract + +Here's the catch. You tell flat_map "the data is sorted," but what if it isn't? flat_map catches the lie in debug with a `DCHECK` (flat_tree.h:612/624/633/642): + +```cpp +flat_tree(sorted_unique_t, container_type&& body, const Compare& comp) + : body_(std::move(body)), comp_(comp) { + DCHECK(is_sorted_and_unique(body_, comp_)); // debug check +} +``` + +We saw `is_sorted_and_unique` (flat_tree.h:55-62) in [pre-04](./pre-04-flat-map-tag-dispatch-and-sorted-unique.md): + +```cpp +template +constexpr bool is_sorted_and_unique(const Range& range, Comp comp) { + return std::ranges::adjacent_find(range, std::not_fn(comp)) == + std::ranges::end(range); +} +``` + +It scans adjacent elements to confirm each one is strictly less than the next, with no equals and no inversions. One `O(N)` pass, debug only. Lie to it and the debug test aborts in your face. In release the `DCHECK` compiles to nothing and not a single byte gets checked. It trusts you completely. + +We call this an honest contract. flat_map hands you the `O(N)` optimization, and the price is that you guarantee the data really is sorted. Debug polices that guarantee for you; release lets go and trusts it. So when the data source is not reliable, user input or something scraped off the network, don't force `sorted_unique`. Use plain bulk construction and let flat_map sort it for you. + +--- + +## When to reach for sorted_unique + +The test comes down to one line: can your data source credibly guarantee sorted, duplicate-free data? + +The trustworthy cases are easy to spot. The data comes out of another sorted container, like an export from another flat_map or a `std::set`. Or you just ran `std::sort` plus `unique` over it yourself. Or it's a compile-time constant, like a config table written as an initializer_list where you stared at the keys while writing them. In all of these `sorted_unique` sits comfortably. + +Flip it around. If the data comes from user input, a file, or the network, the order is not under your control, so don't risk it. There's a class of slip that's easy to miss here: you're not sure whether duplicates sneak in. Plain construction de-duplicates for you, `sorted_unique` does not. Let a duplicate slip through and you've broken flat_map's invariant with your own hands. Lookups after that turn into astrology. When you're not sure, fall back to plain bulk construction and let flat_map sort and de-duplicate. The cost is `O(N log N)`, still miles faster than per-element insert. + +--- + +## A minimal reproduction + +Theory's done. Let's hand-roll a minimal MiniMap that exercises both construction paths, so you can see the difference plainly: + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include +#include + +struct sorted_unique_t {}; +inline constexpr sorted_unique_t sorted_unique{}; + +class MiniMap { +public: + // Plain construction: sort and de-duplicate + MiniMap(std::vector data) : data_(std::move(data)) { + std::sort(data_.begin(), data_.end()); + data_.erase(std::unique(data_.begin(), data_.end()), data_.end()); + } + // sorted_unique construction: skip the sort, debug-check the claim + MiniMap(sorted_unique_t, std::vector data) : data_(std::move(data)) { + assert(is_sorted_unique()); // catch the lie in debug + } + std::size_t size() const { return data_.size(); } +private: + bool is_sorted_unique() const { + for (std::size_t i = 1; i < data_.size(); ++i) + if (!(data_[i-1] < data_[i])) return false; + return true; + } + std::vector data_; +}; + +int main() { + MiniMap a{std::vector{3, 1, 2, 1}}; // plain construction, sort + dedup → 3 elements + MiniMap b(sorted_unique, std::vector{1, 2, 3, 4}); // skip the sort → 4 elements + // MiniMap c(sorted_unique, std::vector{1, 3, 2}); // lying! debug abort + return 0; +} +``` + +--- + +That's the `O(n)` wall walked around. Bulk construction fills a vector and moves it in for an `O(N log N)` finish; if the data is already sorted, pass the `sorted_unique` tag, skip the sort, and pay `O(N)` for a plain takeover, with a debug `DCHECK` standing guard. That is real money saved by tag dispatch, at construction time. + +Two things in flat_map still deserve a full teardown: iterator invalidation rules, and the broader set of bulk construction patterns. We pick those up next. + +## References + +- [Chromium `base/containers/flat_tree.h`: sorted_unique overloads and is_sorted_and_unique](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/flat_map.h`: bulk construction advice](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [flat_map prerequisite (IV): tag dispatch and sorted_unique_t](./pre-04-flat-map-tag-dispatch-and-sorted-unique.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-5-flat-map-iterator-invalidation-and-bulk-build.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-5-flat-map-iterator-invalidation-and-bulk-build.md new file mode 100644 index 000000000..79e75b446 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-5-flat-map-iterator-invalidation-and-bulk-build.md @@ -0,0 +1,152 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: "flat_map's iterator-invalidation rules are stricter than std::map's: it takes the conservative line that every mutation invalidates everything. We unpack why that bluntness is the right call, then walk through the extract/replace bulk-rebuild pattern." +difficulty: intermediate +order: 5 +platform: host +prerequisites: +- "flat_map hands-on (IV): sorted_unique construction optimization" +- "flat_map prerequisite (I): std::vector internals and growth" +reading_time_minutes: 10 +related: +- "flat_map hands-on (VI): testing and performance comparison" +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 内存管理 +title: "flat_map hands-on (V): iterator invalidation and bulk construction" +--- +# flat_map hands-on (V): iterator invalidation and bulk construction + +The first time we swapped `std::map` for `flat_map`, we tripped on something embarrassingly plain: held onto an iterator, touched the container in the middle, came back to use it, and it was dangling. That kind of thing basically never happens with `std::map`. Node containers keep to themselves; inserting one node doesn't move anyone else's. But `flat_map` is a `vector` underneath. One grow relocates the whole block, one erase shifts everything after it, and the iterator is just gone. + +Our first instinct was to hit the docs and figure out exactly which operations invalidate and which don't. Chromium gave us an answer that stopped us cold: it doesn't lay out fine-grained rules for you at all. It just throws one blunt line at you, "assume every operation invalidates all iterators." This piece unpacks why a rule like that has to be written this way, and along the way we'll work through the bulk-rebuild API (`extract`/`replace`) that ships with `flat_map`, so that when you need to overhaul the container you're not stuck doing insert one at a time. + +## std::map vs flat_map: iterator stability + +`std::map` is a node container. Each element gets its own heap allocation, and the tree nodes are strung together with pointers. Insert or erase a node and the other nodes don't budge; only pointers move. So the iterators, pointers, and references pointing at them all stay alive: + +```cpp +std::map m = /* ... */; +auto it = m.find(3); +m[99] = load(99); // insert a new element; it still valid (node didn't move) +it->second; // OK +``` + +Reference stability is a genuine, concrete benefit of `std::map`: you can hold a reference to an element, the container adds and removes things behind your back, and the reference stays valid. + +`flat_map` is the other way around. The storage is a contiguous `vector`, so an insert can trigger a grow (the whole block relocates), and an erase shifts everything after it forward. Either of these turns iterators, pointers, and references into dangling ones: + +```cpp +flat_map m = /* ... */; +auto it = m.find(3); +m[99] = load(99); // may grow -> it invalidated! +it->second; // UB! possibly dangling +``` + +Cache friendliness is bought at the expense of reference stability. Contiguous storage buys you performance and costs you stable references. That's the trade, and you want it firmly in your head. + +## flat_tree's conservative rule + +So what does `flat_map`'s invalidation rule actually look like? If you tried to nail it down precisely by `vector`'s behavior, it gets intricate fast: `reserve` only invalidates when `n > capacity`, `insert` invalidates only from the insertion point onward, `push_back` doesn't invalidate when it doesn't grow. That rulebook is a memory test for the caller; before every insert you'd have to weigh "is this one going to grow or not." + +flat_tree doesn't hand you that exam at all. It takes a cleaver to every mutation and marks them all invalid (flat_tree.h:151/217/231/273/306/319/374). The source comment says, verbatim: + +> Assume that every operation invalidates iterators and references. + +The operations covered: `reserve`, `shrink_to_fit`, `insert`, `erase`, `swap`, move construction, move assignment, `extract`, `replace`, `clear`. One line. Mutated means treat it all as dead. + +Our first reaction to that rule was "isn't that wasteful." A `push_back` that doesn't grow clearly leaves iterators valid, so why call it invalidated? It took getting bitten by a stretch of code just like this in code review before it clicked: the fine-grained rules are the real trap. Callers can't hold them straight, they guess wrong, assume something doesn't invalidate when it does, and it's straight to UB. Nobody misremembers the blunt rule. "Mutated means don't reuse the old iterator" is always safe. This deliberate safety slack is a genuinely cheap engineering tradeoff: better to let you "throw away" an iterator that still works than to let you guess wrong once. + +Chromium pastes the UB counterexample right into the source comment (flat_map.h:57-60), one line: + +```cpp +container["new element"] = it.second; // UB: operator[] may grow, it invalidated +``` + +This kind of "mutate while iterating" code is straight-up undefined behavior in `flat_map`. flat_tree states the rule bluntly precisely so you never even start down the path of "will this particular mutation grow or not." You assume invalidation, and that whole class of bug gets choked off at the root. + +Operationally, there are two habits we'd suggest you burn into muscle memory. One: don't hold iterators, pointers, or references across a mutation. Get a `find` result, use it this once, throw it away, and `find` again next time. Drop the thought "I'll hang onto this iterator, I'll need it again later." Two: if you genuinely need to hold a stable reference, say a callback that has to grip a pointer to an element long-term, then `flat_map` is the wrong tool. Reach for `std::map`; only node containers give you reference stability, and that's a scenario `flat_map` just can't serve. + +--- + +## The bulk-construction pattern (revisited) + +[03-4](./03-4-flat-map-sorted-unique-construction.md) covered bulk construction. Here we'll take the same idea from a different angle, looking at it again through the lens of "dodging iterator invalidation." If you want to dump a batch of elements into a `flat_map`, do not iterate-and-insert as you go. Each insert invalidates every iterator, and the complexity balloons to O(N²); you get hit from both sides. The right shape is to gather into a `vector` first, then move it in all at once: + +```cpp +// 1. Gather into a vector (push_back amortized O(1), no invalidation issue +// because you hold a vector, not a flat_map iterator) +std::vector> batch; +batch.reserve(N); +for (...) batch.emplace_back(k, v); + +// 2. Move into flat_map in one shot (bulk construction, sort once O(N log N)) +flat_map m(std::move(batch)); +``` + +`push_back` on a `vector` is amortized O(1), and what you're holding is a `vector`, not a `flat_map` iterator, so that whole invalidation headache can't reach you. If you're doing a large batched update against an existing `flat_map`, then you want the next pair of APIs: extract the contents out, modify them, then replace them back. + +--- + +## extract() and replace(): bulk rebuild + +flat_tree gives you two APIs that prop up a "pull the data out, overhaul it, hand it back" bulk-rebuild pattern. The first time we ran into this pair it struck us as a clever design. It unloads `flat_map`'s ordering constraint entirely, lets you run wild on a raw `vector`, and when you're done thrashing on it you hand it back. + +### extract() && (flat_tree.h:894) + +```cpp +container_type extract() && { + return std::exchange(body_, container_type{}); // hand out the internal vector whole, body_ cleared +} +``` + +`extract()` is rvalue-qualified, callable only on a dying `flat_map` (an rvalue). It `std::exchange`s the underlying `vector` out to you whole and leaves the original `flat_map` empty. Once you have that `vector`, do whatever you like with it. `push_back`, `sort`, `unique`, mutate elements. Those operations on a `vector` don't carry `flat_map`'s ordering constraint, so you're far freer. Sort it out, then `replace` it back. + +### replace(container_type&&) (flat_tree.h:899-905) + +```cpp +void replace(container_type&& body) { + DCHECK(is_sorted_and_unique(body, comp_)); // verify new data is sorted and unique + body_ = std::move(body); // take ownership +} +``` + +`replace(body)` is the inverse of `extract`: it hands a new `vector` back for `flat_map` to take over. It first runs `DCHECK(is_sorted_and_unique)` to verify the new data is sorted and free of duplicates (the same contract as sorted_unique construction), and only then takes ownership. In and out this way, you get "pull the vector, modify freely, sort and dedupe, hand back," and the whole path sidesteps the O(n) shift cost and iterator invalidation of `flat_map`'s single-element operations. + +### A typical bulk-rebuild flow + +```cpp +flat_map m = /* ... */; + +// 1. extract the vector (call on an rvalue) +std::vector> raw = std::move(m).extract(); + +// 2. Modify freely on the vector (no ordering constraint, no shift cost) +for (...) raw.emplace_back(k, v); + +// 3. Sort and dedupe +std::sort(raw.begin(), raw.end(), by_key); +raw.erase(std::unique(raw.begin(), raw.end(), equiv), raw.end()); + +// 4. Replace back into flat_map (sorted_unique-style check) +m.replace(std::move(raw)); +``` + +This shape suits scenarios where you need to make heavy structural modifications to a `flat_map`. It's far more efficient than `m.insert`/`m.erase` one at a time (each O(n) shift, plus a pile of invalidated iterators). One thing we have to flag: `replace` requires the new data to be sorted and unique, and that's on you to guarantee. It only runs the `DCHECK` under debug builds; release won't catch you. The contract is the same as sorted_unique, an honest contract, and you have to be honest about it. + +The rest is lining `flat_map` up against `std::map` and `absl::btree_map` and measuring them. That's the next piece. + +## References + +- [Chromium `base/containers/flat_tree.h`: iterator-invalidation comment + extract/replace](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/flat_map.h`: UB example](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [flat_map prerequisite (I): std::vector internals and growth](./pre-01-flat-map-vector-internals-and-growth.md) +- [flat_map hands-on (IV): sorted_unique construction optimization](./03-4-flat-map-sorted-unique-construction.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-6-flat-map-testing-and-perf.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-6-flat-map-testing-and-perf.md new file mode 100644 index 000000000..acfbc4884 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/03-6-flat-map-testing-and-perf.md @@ -0,0 +1,156 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "Test flat_map around its invariants with Catch2, then measure object size, per-item overhead, and lookup/insert performance against std::map and absl::btree_map, and derive selection criteria." +difficulty: intermediate +order: 6 +platform: host +prerequisites: +- "flat_map hands-on (V): iterator invalidation and batch construction" +reading_time_minutes: 12 +related: +- "flat_map prerequisite (0): ordered associative containers and std::map's red-black tree" +- "flat_map prerequisite (II): complexity and amortized analysis" +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 测试 +- 优化 +title: "flat_map hands-on (VI): testing and performance comparison" +--- +# flat_map hands-on (VI): testing and performance comparison + +The code is done. Two questions left: is it correct, and how fast is it. This piece stays on those two. The first half designs tests around invariants and walks through sorting, lookup, insert, dedup, and invalidation one by one. The second half puts flat_map on the scale next to `std::map` and `absl::btree_map`, measuring how big the object is, how fast lookup runs, how much insert hurts. That "small N favors flat_map, big N with heavy writes tips back to std::map" judgment has to land on real numbers, and once the runs finish you'll see exactly where. + +## Six invariants, none of them optional + +Whether flat_map counts as "correct" comes down to whether these six invariants hold. We'll take them together. + +Sorting and dedup are the first two, and the easiest to verify by eye: the keys you get from a walk must be strictly ascending, with no duplicates. The third is that lookup semantics stay clean, with `find`, `contains`, `operator[]`, and `at` each doing their job, and `at` on an out-of-range key should CHECK and crash right in front of you. That kind is a definite bug, and it had better blow up in release too. The fourth targets two write interfaces people mix up: `insert_or_assign` overwrites when the key already exists, `try_emplace` leaves it alone. One moves, one doesn't, and the semantics are not interchangeable. The fifth is the `sorted_unique` "liar's shortcut" path. You claim the data is already sorted and deduped, so it skips the sort; lie about it and it aborts in debug. The last one is iterator invalidation. Any mutation invalidates old iterators under the coarse rule, which the previous piece covered in depth, so here we just verify it. + +## Key test cases (Catch2-style sketch) + +Below are Catch2-style sketches. The runnable examples in the project right now are the `19` through `22` demo .cpp files under `code/.../chrome_design/`; wiring up a Catch2 test target is left as an extension. + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include "flat_map.hpp" +using namespace tamcpp::chrome; + +TEST_CASE("flat_map is sorted+unique after construction", "[flat_map]") { + flat_map m{{3,30}, {1,10}, {3,30}, {2,20}}; // duplicate 3, unsorted + // Invariant 1+2: ordered and deduped + std::vector keys; + for (auto& [k, v] : m) keys.push_back(k); + REQUIRE(keys == std::vector{1, 2, 3}); +} + +TEST_CASE("at out-of-range CHECKs", "[flat_map][.death]") { + flat_map m{{1,10}}; + // Invariant 3: out-of-range CHECK crash (isolated death test) + // REQUIRE_DEATH(m.at(99)); +} + +TEST_CASE("insert_or_assign overwrites, try_emplace leaves alone", "[flat_map]") { + flat_map m{{1,10}}; + auto [it1, ins1] = m.insert_or_assign(1, 99); // exists -> overwrite + REQUIRE_FALSE(ins1); + REQUIRE(it1->second == 99); + // try_emplace leaves an existing key alone (can't verify directly in the same test; semantics in 03-3) +} +``` + +These cases all aim at semantic edges: the sort and dedup, the `at` CHECK, the `insert_or_assign` overwrite. The `at` death test has to run isolated because it really does abort, which is not the same thing as an ordinary assertion. + +## Performance: object size and per-item overhead + +First let's pin down "how much memory." flat_map's body is a single `vector>` plus a zero-byte comparator; `std::map` runs on a red-black tree, where every node carries 3 pointers plus a color bit on top of the data. We use `sizeof` for the container skeleton, then look at the per-item overhead spread across 1 million elements: + +```text +sizeof(flat_map) ≈ sizeof(vector>) = 24 bytes (three pointers, 64-bit) +sizeof(std::map) ≈ 48 bytes (tree root + comparator + sentinel node) + +Extra overhead for a 1M-element map (the 8MB of data itself not counted): + flat_map: ~0 extra (data contiguous, no node metadata) + std::map: ~32MB (32B per node x 1M, and one malloc per node) +``` + +flat_map almost freeloads on per-item cost: data sits in one contiguous run, there's no node metadata, and it mallocs once. `std::map` spends 32B per node on metadata alone and needs a million heap allocations to fill out. The smaller the element and the bigger the collection, the wider this gap tears open. + +## Performance: lookup (cache friendly vs pointer chasing) + +Both sides look up in `O(log n)`, so asymptotically neither beats the other. The constant factor is the real divider: flat_map's data is contiguous, so the comparisons during binary search ride the cache; `std::map`'s nodes are scattered all over, and every hop is a dereference that most likely misses cache. + +Measured on this machine with GCC 16 at `-O2`, using the companion `20_lookup_vs_shift_perf`, a 100K-element `map` doing 100K `find` calls each: + +```text +100K lookups (100K elements): + flat_map: 31 ms + std::map: 34 ms +``` + +Don't jump to conclusions. At N=100K with `int` keys the two sides are basically tied. An `int` compare costs a single cycle, and that little bit of cache goodwill hasn't yet overrun `std::map`'s advantage of a shallower tree. flat_map only pulls away when N gets bigger or the key gets heavier. Switch the key to `std::string`, say, and the compare itself becomes expensive, which magnifies the cost of each cache miss. In standalone large-N tests flat_map being a few times faster is common. So "flat_map lookup is always faster" is not a rule to memorize. It depends on the workload: the bigger N gets and the heavier the key, the more visible the edge. + +## Performance: insert (the O(n) shift wall) + +Lookup still bends with the workload. Insert is where flat_map just clearly loses. Measured by appending 1000 keys into a container already holding 100K elements: + +```text +1000 inserts into a 100K-element container: + flat_map: 2 ms (O(n) shift each time) + std::map: 0 ms (O(log n) node rewiring each time) +``` + +flat_map loses fair and square, and that is the hardest piece of data behind the "read-heavy, write-light" judgment. If your workload is insert-heavy, flat_map's `O(n)` shift will bottleneck you sooner or later, and you should go back to `std::map`. The absolute numbers drift with the machine and with N, but the trend of flat_map insert being slower than `std::map` is stable. The bigger N gets, the wider the gap, because the shift cost is O(n) to begin with. + +## Selection criteria (measured summary) + +With three sets of data on the table, the selection criteria are right there: + +| Workload | Recommendation | Reason | +|---|---|---| +| **Write once, read many** (config tables, command dispatch, lookups) | flat_map | Writes are one-shot (batch construction), reads are cache friendly | +| **Always small** (browser statistics mode around 4 elements) | flat_map | Constant factor dominates at small N, zero-allocation edge is large | +| **Large and frequently modified** (dynamic indexes) | std::map | flat_map's O(n) insert is a wall | +| **Needs pointer/reference stability** | std::map | flat_map iterators all invalidate across mutations | +| **Many ordered keys + frequent changes + large N** | absl::btree_map | B-tree middle ground (but Chromium disables it for code size) | + +One line covers it: read-heavy and write-light goes to flat_map, write-heavy goes back to std::map. Chromium's `//base/containers/README.md` draws the same line. + +## vs std::flat_map (C++23) and absl::btree_map + +First, `std::flat_map` (C++23, P0429). It shares a lineage with this Chromium flat_map, but the standard version stores things differently. It uses split storage, with keys and values each in their own contiguous array, so when you only walk the keys the cache packs tighter and the values don't get in the way. Sounds better. The cost is maintaining two containers in sync, which pushes implementation complexity up. Chromium didn't go split; it stuck with one plain `vector>`. The "looks better" split got dropped by an industrial mainline, because the complexity and the payoff don't balance out. + +Then `absl::btree_map`. It's a B-tree with TargetNodeSize=256B, so each node holds dozens of keys. One cache-line hit then compares several keys, which both fixes the pointer chasing of red-black trees and dodges the sorted vector's `O(n)` insert. It's the answer for the corner of demand that wants ordering, large N, and frequent changes all at once. But it carries a bill you can't ignore: code size. Chromium explicitly bans `absl::btree_map` in `//base`, and that's the reason. + +## The teaching version vs Chromium's tradeoffs + +Like the previous two series, our teaching version takes one simplifying pass: + +| Dimension | Chromium | Teaching version | +|---|---|---| +| Underlying Container | `std::vector` | same | +| Sorting | `std::stable_sort` + unique + erase | same | +| Transparent comparison | `KeyT` + `KeyValueCompare` dual overload | simplified template | +| `DCHECK(is_sorted_and_unique)` | full | `assert` simulation | +| `[[no_unique_address]]` comparator | annotated | annotated | +| `extract`/`replace` | full | simplified/omitted | +| `raw_ptr_exclusion`/Chromium macros | full | omitted | + +The core mechanisms (sorted vector adapter, tag dispatch, transparent comparison, EBO, batch construction) are all here unchanged. + +That closes the loop on flat_map's design, implementation, and verification. From [the red-black tree pain in pre-00](./pre-00-flat-map-ordered-assoc-container-intro.md) through these 13 pieces, we've walked the whole chain of "why a sorted vector can beat a red-black tree." Add in OnceCallback and WeakPtr from before, and the three pieces of industrial-grade C++ design in Chromium `//base` are now in place: callbacks, weak references, and containers. + +## References + +- [Catch2 documentation](https://github.com/catchorg/Catch2/tree/devel/docs) +- [Chromium `base/containers/README.md`: container selection guide](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/README.md) +- [P0429: std::flat_map proposal (C++23)](https://wg21.link/p0429) +- [absl::btree_map documentation](https://abseil.io/docs/cpp/guides/btree) +- [cppreference: std::map](https://en.cppreference.com/w/cpp/container/map) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-00-flat-map-ordered-assoc-container-intro.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-00-flat-map-ordered-assoc-container-intro.md new file mode 100644 index 000000000..ae3ce7da0 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-00-flat-map-ordered-assoc-container-intro.md @@ -0,0 +1,101 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: "Starting from std::map's red-black tree: the per-node malloc and cache misses that hurt at small N, and how flat_map trades the tree for a sorted vector plus binary search" +difficulty: intermediate +order: 0 +platform: host +prerequisites: +- 'WeakPtr prerequisite (0): weak references and the lifetime puzzle' +reading_time_minutes: 10 +related: +- 'flat_map hands-on (I): motivation and API design' +- 'flat_map prerequisite (I): std::vector internals and growth' +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 优化 +title: "flat_map prerequisite (0): ordered associative containers and std::map's red-black tree" +--- +# flat_map prerequisite (0): ordered associative containers and std::map's red-black tree + +You write `std::map` for a config table and probably don't give the internals a second thought. `O(log n)` lookup, textbook says it's fine, it's fine. But if that table has a dozen entries, gets built once at startup, and never moves again, go profile it. It's slower than you'd guess. Not because of the `O(log n)` term, but in the place asymptotic complexity can't hide: every key-value pair costs its own `malloc`, and a lookup hops across the heap from node to node, eating cache misses the whole way. + +Chromium ships its own ordered associative containers in `//base`, `flat_map` and `flat_set`, and takes the exact opposite approach. Pack every element into one contiguous sorted array, look things up by binary search. The asymptotic complexity is still `O(log n)`, but because the data sits together, a single cache line drags a dozen elements into L1 for free, and the constant factor drops by a lot. There's a cost, of course: insert and erase degrade to `O(n)` because the array has to shift. This piece opens up std::map's red-black tree and walks through why flat_map picks the other road. + +## Pinning down "associative container" first + +The line between an associative container and a sequence container is one sentence: a sequence container is accessed by position, you want element 0, element 1; an associative container is accessed by key, you call `m.find("timeout")` and want the value tied to that key. The standard library gives you two flavors, the unordered `std::unordered_map` (a hash table, `O(1)` average lookup) and the ordered `std::map` (a red-black tree, `O(log n)` lookup). + +We're only looking at the ordered kind here. For one thing flat_map itself is ordered. For another, "ordered" is a non-trivial invariant: you can iterate keys in sorted order, carve out a range with `lower_bound`, ask for a predecessor or successor, none of which a hash table can do. Unordered is unordered. So the question "how should an ordered associative container be implemented" deserves a real answer. The standard library's answer is a red-black tree; Chromium's is a sorted array. Let's lay both out. + +## How std::map is built: the red-black tree + +All three major implementations (libstdc++, libc++, MSVC) build `std::map` on a red-black tree, a self-balancing binary search tree. Each key-value pair lives in its own tree node, which on a 64-bit box looks roughly like this: + +```text +struct Node { + color color_; // 1 byte (red/black, for balancing) + Node* left_; // 8 bytes + Node* right_; // 8 bytes + Node* parent_; // 8 bytes + pair data_; // your key + value +}; +``` + +The pointers and the color alone already put you at 25 bytes (with alignment padding, usually 32 in practice), and that's before your key-value. In other words, for every element you store, on top of the data itself you pay 32 bytes of node metadata. + +Lookup is the textbook binary search: start at the root, compare keys, go left if smaller, right if larger. The red-black tree keeps itself balanced, so the height stays `O(log n)`, so lookup is `O(log n)` comparisons. By asymptotic complexity, reasonable. + +Reasonable, except for the part nobody draws on the slide: every comparison has to get the node into cache first. Red-black tree nodes are allocated one at a time on the heap. Each `insert` does a `new Node` underneath. A million-element `std::map` means a million heap allocations, and the addresses come back scattered all over the heap. Lookup is worse. The hop `node = node->left_` dereferences an address nobody has touched before. The CPU pipeline can't prefetch it (the target address isn't known until the previous load finishes), L1 and L2 don't have it, so that hop is a cache miss, and tens to hundreds of cycles are gone. `O(log n)` comparisons, each one a likely miss, is the actual price `std::map::find` pays. + +## The real disease: the constant factor + +Let's stop here and nail this down, because it's the root of the entire flat_map story. + +`std::map::find` is `O(log n)`. `flat_map::find` is also `O(log n)`. The asymptotic complexity is identical. But "same asymptotically" has never meant "same speed". Big-O deliberately throws the constant factor away, and the constant factor is set by how much each comparison actually costs. + +For std::map, every comparison is preceded by dragging the node out of memory and into cache. Nodes are scattered across the heap, so each hop is a probable miss. The comparison itself, two ints compared, is one cycle, give or take. Waiting for the node to arrive from memory is a hundred-plus cycles. The cost of the comparison is almost entirely the cache-miss wait; the one cycle spent actually comparing is noise. + +flat_map goes the other way: every element sits next to its neighbors. The CPU pulls data from memory in cache lines (64 bytes on x86), so when you touch `data[0]`, `data[1]`, `data[2]`, and friends come along into L1 for free. Binary search does jump around (`mid = n/2`), but some contiguous stretch is always hot, so each comparison almost always hits cache and finishes in one cycle. + +Same `O(log n)`, then, but in the small-to-medium data range flat_map's constant factor can be an order of magnitude smaller than std::map's. Chromium didn't build this wheel to win on asymptotic complexity. It built it to win on the constant factor. + +## The other road: a sorted array plus binary search + +flat_map's whole idea is one sentence: drop the tree, use a contiguous sorted array, look things up with binary search. + +```text +flat_map: + data_: [ (1,"a") | (3,"c") | (7,"g") | (9,"i") | ... ] ← one contiguous sorted vector + lookup uses std::lower_bound (binary search, O(log n)) +``` + +Lookup goes through `std::lower_bound`, a binary search over a sorted array, `O(log n)`, same asymptotics as std::map, but far more cache-friendly because the data is contiguous. The cost moves to insert and erase: push something into the middle and the whole tail shifts back by one, `O(n)`, against std::map's `O(log n)` insert. Storage is just one vector, zero extra node metadata, one contiguous allocation. That's the entire skeleton of flat_map, and it puts the classic "red-black tree vs sorted array" tradeoff on the table without dressing it up: the tree trades spatial locality for `O(log n)` insert, the array trades insert complexity for spatial locality. + +So when does the array win? When reads dominate writes. + +The canonical cases are config tables, lookup tables, command dispatch tables: built once at startup, then almost entirely read, with the occasional insert or erase. For a write-once-read-many workload like that, flat_map's `O(n)` insert happens exactly once, during construction (and even that can be batched into a single `O(N log N)` sort, see 03-4); after that, everything is `O(log n)` cache-friendly lookup. std::map, on the other hand, pays the cache-miss constant factor on every single lookup. Writes are a wash on both sides, one-time, but reads on flat_map are far faster. In this setting it's almost free. + +Flip it around: if your set is large and churns constantly (a live index that keeps growing and shrinking), flat_map's `O(n)` insert starts to hurt, and that's std::map's home turf. Chromium's own container-choosing guide draws the line just that bluntly: write-once-read-many, reach for flat_map; write-many and large, stay with std::map. + +## Chromium's call, the standard library's follow-on + +flat_map isn't something Chromium invented out of thin air. The sorted-vector map has been around a while. Alexandrescu published `Loki::AssociationVector` back in 2001 in *Modern C++ Design*, and Boost.Container has carried `boost::flat_map` for years. Chromium moved the idea into `//base` in 2017 and gave it the Chromium-style treatment (`DCHECK`/`CHECK` validation, `raw_ptr_exclusion`, a transparent comparator by default). + +One detail is worth pulling out. Chromium's flat_map packs keys and values together in one array (`vector>`), while the C++23 `std::flat_map` (proposal P0429) goes with split storage: keys live in one contiguous array, values in another. Split storage buys you a denser cache footprint when you only walk the keys, the values aren't tagging along; the cost is implementation complexity, you now keep two containers in sync. Chromium took the non-split, simpler path. The "looks better on paper" split design got set aside by an industrial user, because the implementation complexity it buys back doesn't pay for itself. We'll dig into that trade in 03-6's performance comparison. + +That's the foundation layer. flat_map stores its data in a vector by default, so the next step is to get a firm grip on `std::vector`'s three pointers, its growth strategy, and its iterator invalidation rules. That's the prerequisite for understanding flat_map's behavior. + +## References + +- [Chromium `base/containers/flat_map.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [Chromium `base/containers/README.md`, the container-choosing guide](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/README.md) +- [cppreference: std::map (red-black tree implementation note)](https://en.cppreference.com/w/cpp/container/map) +- [P0429, the std::flat_map proposal (C++23)](https://wg21.link/p0429) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-01-flat-map-vector-internals-and-growth.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-01-flat-map-vector-internals-and-growth.md new file mode 100644 index 000000000..0900c8ae8 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-01-flat-map-vector-internals-and-growth.md @@ -0,0 +1,112 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +description: "flat_map stores its data in a vector by default: this piece walks through vector's three-pointer layout, the cache win of contiguous storage, growth and amortized analysis, and the iterator-invalidation rules that flat_map inherits" +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- flat_map prerequisite (0): ordered associative containers and std::map's red-black tree +reading_time_minutes: 9 +related: +- flat_map prerequisite (2): complexity and amortized analysis +- flat_map in practice (2): the flat_tree core skeleton +tags: +- host +- cpp-modern +- intermediate +- 容器 +- vector +- 优化 +title: "flat_map prerequisite (1): std::vector internals and growth" +--- +# flat_map prerequisite (1): std::vector internals and growth + +In [pre-00](./pre-00-flat-map-ordered-assoc-container-intro.md) we sketched flat_map's shape as "a sorted array plus binary search." What is that "array," really? Peek at Chromium's template signature, `flat_map`, and the default `Container` is `std::vector>` (flat_map.h:193). Put bluntly, flat_map sits on top of a vector. How vector stores data, when it grows, when it invalidates iterators, flat_map follows along. No escape hatch. + +So this piece tears `std::vector`'s internal representation apart. If three pointers, growth, and invalidation are old hat to you, jump straight to "Back to flat_map." If they're not, work through it. Every complexity claim in the later flat_tree chapters has its roots right here. + +## Three pointers: vector's internal layout + +Across libstdc++, libc++, and MSVC, `std::vector` looks nearly identical: three pointers and one contiguous block of memory. The pointers live in a header struct whose details differ slightly between implementations, but the concept is uniform. + +```text + begin end end_of_storage + | | | +memory: [ | | | | | | | | | | | | | | | ] + <-- size --> <-- free --> + <---------- capacity ----------> +``` + +`begin` points at the first element. `end` points one past the last element, the past-the-end slot, so `end - begin` equals `size()`. `end_of_storage` points at the end of the allocated memory, so `end_of_storage - begin` equals `capacity()`, the most this block can hold without growing. + +Two quantities you should keep straight: `size` is how many elements are actually in there right now, `capacity` is how many the block could hold. The stretch between `end` and `end_of_storage` is allocated but unused free space. `push_back` constructs a new element in place on that free ground, no extra allocation needed. + +### Contiguous storage: the root of cache friendliness + +This memory is contiguous, elements packed one after another with no gaps (true for trivially copyable types; alignment padding exists but the layout is still contiguous). This is the root of vector's cache friendliness. The CPU pulls data from memory in cache lines, 64 bytes at a time. Touch `data[0]` and `data[1]`, `data[2]` ride along into L1 for free. Touch them next and you hit L1 in one cycle. [pre-00](./pre-00-flat-map-ordered-assoc-container-intro.md) claimed flat_map's constant factor beats `std::map` by an order of magnitude. That contiguous block is the reason. + +--- + +## Growth: what happens when capacity runs out + +When `push_back` finds `size < capacity`, it constructs the new element right at the `end` slot, bumps `end` forward by one, and goes home in `O(1)`. Happy path. + +When `size == capacity`, the block is full and growth kicks in. The full dance: allocate a larger block (mainstream implementations double up, so new `capacity = old capacity * 2`); move every old element over, one by one, with a subtlety, move versus copy depends on whether the element's move is `noexcept`, safe to move when it is, fall back to copy when it isn't to keep the strong exception guarantee; then destruct the old elements, free the old memory, and repoint all three pointers at the new block. + +All of that is `O(n)`. You move n elements. So a single `push_back` can hit `O(n)` in the worst case. No way around it. + +### Amortized O(1): why push_back is still "fast" + +Worst case `O(n)` sounds scary, but `push_back` is amortized O(1) in engineering terms (amortized constant time). The intuition is simple. The one growth step is `O(n)`, true, but afterward capacity has doubled, so the next n `push_back`s all land on the happy path and none of them grow. Spread that single `O(n)` across those n calls and each one averages `O(1)`. + +This is the classic geometric-growth capacity analysis. Doubling pins push_back's amortized complexity at `O(1)`. Day to day, push_back on a vector is fast. You don't need to fear the `O(n)` worst case. + +### Back to flat_map: no amortization for insert + +The single-element insert on flat_map never gets to cash in on that amortization. + +The problem is the insertion point. flat_map must stay sorted, so `insert(key)` runs `lower_bound` to find the slot, then inserts right there. That slot is usually somewhere in the middle of the array. Insert in the middle and every element after it shifts one slot back, a real `O(n)` shift, and this happens on every insert. Unlike push_back, which pays the cost only on the rare growth step and is `O(1)` the rest of the time. flat_map's single insert is `O(n)`, full stop, with no amortization to speak of. File this away: it's the origin of the "read-heavy, write-light" judgment later on. Write a lot to a flat_map and it gets slow, and this is the source. + +--- + +## Iterator, pointer, and reference invalidation + +vector's invalidation rules are a C++ interview chestnut, but for flat_map they genuinely carry weight. Let's walk through them precisely. + +`push_back` is the interesting one. When it doesn't trigger growth, the elements never move, so every iterator, pointer, and reference stays valid. The moment growth triggers and new memory is allocated, the old block is freed wholesale and every iterator pointing at an old address goes dangling, all invalid. In practice, the conservative move is to treat iterators as invalidated after `push_back` and not bet on whether it grew. `reserve(n)` works the same way: if `n` exceeds the current capacity, growth fires and everything invalidates; otherwise nothing changes. `insert` and `erase` invalidate from the operation point through `end` (elements got shifted), and insert can also trigger growth and invalidate everything on top of that. `clear` invalidates all iterators but usually keeps capacity; to give the memory back, call `shrink_to_fit`. + +### flat_map deliberately coarsens the rules + +In Chromium's flat_tree source, the invalidation rules are stated conservatively on purpose. Every mutation (insert / erase / reserve / shrink_to_fit / swap / move ctor / move assign) gets the same line: "Assume that every operation invalidates iterators and references." The comments in flat_tree.h read exactly that way (lines 151/217/231/273/306/319/374). + +Why not follow vector's fine-grained rules quietly, where reserve only invalidates on a real realloc and insert only from the insertion point on? We turned this over for a while, and the answer is pragmatic. The fine-grained rules are hard on callers. While writing code you'd have to keep asking, "will this insert grow? is this reserve big enough?" The mental load piles up and people misremember. flat_tree just draws a hard line: mutate, treat everything as dead. A coarse rule is less "accurate" than a fine one, but nobody misremembers it, and that's what actually matters in engineering. We'll come back to this in 03-5, which covers iterator invalidation in detail. + +The source even ships a direct UB example (flat_map.h:57-60): + +```cpp +container["new element"] = it.second; // UB: operator[] may trigger growth, it invalidates +``` + +This "iterate and mutate at the same time" pattern is flat-out undefined behavior in flat_map, far stricter than `std::map`, where nodes are stable and iterators survive mutation. + +## reserve and shrink_to_fit + +vector exposes two capacity-management interfaces, and flat_tree passes them through unchanged. + +`reserve(n)` preallocates enough memory for n elements. If you know roughly how many you'll end up with, reserve up front and skip the relocation cost of every later growth step. This matters a lot when bulk-constructing a flat_map (03-5 covers the bulk-construction patterns in full). `shrink_to_fit()` goes the other way, shrinking capacity down to size and handing the excess memory back. It's a non-binding request; the standard lets an implementation ignore it, but mainstream implementations usually cooperate and do a realloc. + +Both invalidate iterators, since either can trigger a realloc. + +--- + +Armed with this, the next piece builds out the complexity toolkit: `O(lg n)` lookup against `O(n)` insert, amortized against single-shot, so the flat_tree complexity conclusions later have a foundation to stand on. + +## References + +- [cppreference: std::vector](https://en.cppreference.com/w/cpp/container/vector) +- [cppreference: vector's iterator invalidation rules](https://en.cppreference.com/w/cpp/container/vector#Iterator_invalidation) +- [Chromium `base/containers/flat_tree.h`: iterator invalidation comments](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Bjarne Stroustrup: vector and cache performance experiments](https://www.stroustrup.com/Software-for-infrastructure.pdf) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-02-flat-map-complexity-and-amortized.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-02-flat-map-complexity-and-amortized.md new file mode 100644 index 000000000..9493992e3 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-02-flat-map-complexity-and-amortized.md @@ -0,0 +1,117 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: "Get big O straight, tell single-shot from amortized cost, and land it on flat_map: O(log n) lookup, O(n) insert, O(N lgN) range construction, plus real measurements that show what the shift actually costs" +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- flat_map prerequisite (I): std::vector internals and growth +reading_time_minutes: 10 +related: +- flat_map in practice (III): lookup and insert +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 优化 +title: "flat_map prerequisite (II): complexity and amortized analysis" +--- +# flat_map prerequisite (II): complexity and amortized analysis + +Back in [pre-00](./pre-00-flat-map-ordered-assoc-container-intro.md) we threw out the line "flat_map is `O(log n)` lookup, `O(n)` insert", and [pre-01](./pre-01-flat-map-vector-internals-and-growth.md) called vector `push_back` amortized `O(1)`. Hiding inside those two sentences is a distinction you can easily skip past, yet it has a real, measurable say in flat_map performance: single-shot cost and amortized cost are not the same thing. This piece takes that tool apart, because every performance conclusion we reach for flat_map downstream roots back in this kind of analysis. Once it clicks, you can judge for yourself when flat_map pays off and when reaching for it is a self-inflicted wound. + +## Big-O: asymptotic complexity + +Big-O describes how the cost of an operation grows with the input size `n`; constant factors and lower-order terms get dropped. The common buckets: `O(1)` is constant time, independent of `n`, like `vector::size()` reading a field; `O(log n)` is logarithmic, which is what you get when binary search on a sorted array halves the range each step; `O(n)` is linear, proportional to `n`, covering a full sweep or inserting in the middle of an array and shuffling every element after it; `O(n log n)` shows up in sorting, or when you binary-search-insert N elements one at a time; `O(n²)` is the nastier one, like inserting N elements at the head one by one, paying `O(n)` each time across N inserts. + +Big-O answers "as `n` runs to infinity, who wins?" But as we flagged in [pre-00](./pre-00-flat-map-ordered-assoc-container-intro.md), big-O throws away the constant factor, and in real programs that factor, how many cycles each operation actually burns, can differ by an order of magnitude. This is exactly why flat_map beats std::map for small N: both have `O(log n)` lookup asymptotically, but flat_map lives in contiguous storage where a cache line holds several elements, while std::map's red-black tree nodes scatter across the heap and a single traversal racks up cache misses. So when you read a complexity claim, big-O is only the first half. The second half is the constant factor, and in our setting that mostly means cache behavior. + +## Single-shot vs. amortized: a distinction that matters + +This is the heart of the piece. The same operation has two complexity lenses: single-shot (single) looks at the worst case for doing it once; amortized looks at the average per operation over N runs in a row, spreading the occasional big spike across all N. + +vector's `push_back` is the textbook example. Single-shot worst case is `O(n)`, because a resize has to move every existing element. Amortized, it is `O(1)`, because growth doubles geometrically: after a resize the next N pushes don't resize again, and that one `O(n)` spike spread over N pushes averages to a constant. The reason `push_back` feels fast day to day is that we cash this amortized check without noticing. + +### flat_map's single-element insert gets no amortized discount + +Here is the catch: flat_map's `insert(key, value)` does not get this deal. Recall from [pre-01](./pre-01-flat-map-vector-internals-and-growth.md) that flat_map must stay sorted, so `insert` runs `lower_bound` to find the slot (usually somewhere in the middle of the array) and then **shifts every element after that slot back by one**. That shift is a real `O(n)`, and it happens on every insert, not just occasionally. + +So flat_map's single-element insert is `O(n)` single-shot and `O(n)` amortized, because every insert pays `O(n)` and there is no "occasional big spike" to spread out. Doing N single-element `insert`s in a row piles up to `O(n²)` total. That is the root of why "constructing a big flat_map by inserting one key at a time" is a trap; in 03-5 we cover how batch construction sidesteps it. + +## O(log n) lookup: binary search + +flat_map's lookup operations, `find`, `contains`, `lower_bound`, `equal_range`, are all `O(log n)`, all riding on binary search over a sorted array. Taking `lower_bound` as the example (flat_tree.h:1027 uses `std::ranges::lower_bound`): + +```cpp +// In the sorted range [first, last), find the first position not less than key +auto it = std::ranges::lower_bound(data, key, comp); +``` + +Each step of binary search halves the search range, so `n` elements need at most `log₂(n)` comparisons. A million elements is roughly 20 comparisons, and each comparison hits cache (contiguous storage) in 1 to 2 cycles, so the total lookup cost is tiny. flat_map lookup is fast on two counts: it is `O(log n)`, and each comparison itself is cheap. + +`find`, `contains`, `lower_bound`, and `equal_range` share their interface semantics with std::map, all inherited by flat_map from flat_tree. `find(key)` is an exact lookup, equal to `lower_bound` followed by one equality check; `contains(key)` is just `find != end`; `lower_bound(key)` gives the first position `>= key`; `equal_range(key)` gives the `[lower_bound, upper_bound)` range. The only difference is that the underlying layer swaps a tree walk for binary search. + +## O(n) insert: the cost of the shift + +flat_map's insert operations (`insert`/`emplace`/`operator[]`/`insert_or_assign`) all walk the same path (flat_tree.h:1060, `unsafe_emplace`): `lower_bound` finds the insertion slot in `O(log n)`, then a `vector::emplace` at that slot shifts every later element back by one, and that shift is `O(n)`. The total is dominated by the shift, landing at `O(n)`. erase works the same way (flat_tree.h:914/921, `body_.erase`): delete one element, shift everything after it forward, `O(n)`. + +### Measurement: how expensive is the shift really + +Saying `O(n)` is abstract, so we ran an experiment: insert at the front of a vector 100k times (`emplace(begin)`), shifting every later element on each call: + +```text +100k vector::emplace(begin) → 264 ms (O(n²) total) +100k vector::push_back → 0 ms (amortized O(1)) +``` + +Two orders of magnitude. If you treat flat_map like std::map and keep inserting in the middle, that 264ms curve is what you see. flat_map's `O(n)` insert is not a textbook warning meant to scare you; it is a wall you will actually hit. + +## Range construction: O(N lg²N) → O(N lgN) + +flat_map does have a cheap construction path. If you can hand it a blob of data in one shot (say, move-constructing from a `vector>`), it skips the per-element insert: append everything first, then sort and deduplicate in a single pass (`sort_and_unique`, flat_tree.h:147-149): + +```text +flat_map construction (N elements): + 1. append all elements O(N) + 2. sort_and_unique: + std::stable_sort O(N log N) + unique + erase O(N) + total O(N log N) (with spare memory; otherwise O(N log²N)) +``` + +`stable_sort` is `O(N log N)` when spare memory is available, because it can grab a scratch buffer and do a merge; when memory is tight it degrades to `O(N log²N)`, since in-place merge pays `O(N log N)` per layer. So flat_map's batch construction is `O(N log N)`, far cheaper than the `O(N²)` of per-element insert. That is the implementation reason flat_map is strictly better for the "write once" pattern. + +### sorted_unique: skip the sort + +One step further: if you can guarantee the input is already sorted and duplicate-free, you can construct with the `sorted_unique_t` tag (flat_tree.h:606-646), and flat_map skips `sort_and_unique` entirely, taking ownership directly, dropping construction to `O(N)`. This is a clean specimen of zero-cost abstraction, and we save it for pre-04 and 03-4. + +## Complexity summary table + +flat_map's complexity story is collected in the table below, every row traceable to comments in flat_tree.h: + +| Operation | Complexity | Notes | +|---|---|---| +| Lookup find/contains/lower_bound/equal_range | `O(log n)` | Binary search, cache-friendly | +| Single insert/emplace | `O(n)` | Includes shift, no amortization | +| erase(position/range) | `O(n)` | Shift | +| erase(key) | `O(n) + O(log n)` | Find then remove | +| operator[]/insert_or_assign/try_emplace | `O(n)` | Same as insert | +| Range construction (plain) | `O(N log²N)` / `O(N log N)` | Depends on spare memory | +| Range construction (sorted_unique) | `O(N)` | Skips sort_and_unique | +| reserve/shrink_to_fit | `O(n)` | Realloc, invalidates iterators | + +Compared with std::map (red-black tree): lookup is `O(log n)`, asymptotically a tie with flat_map but losing on the constant factor; insert/erase is `O(log n)`, beating flat_map asymptotically. So on the asymptotic line std::map wins insert, and on the constant-factor line flat_map wins lookup. In plain terms: read-heavy, write-light, reach for flat_map; large and frequently mutated, reach for std::map. + +In the next piece we look at flat_map's comparator: how it decides element ordering, and how the modern "transparent comparator" skips temporary object construction. + +## References + +- [cppreference: std::lower_bound (binary search)](https://en.cppreference.com/w/cpp/algorithm/lower_bound) +- [cppreference: complexity (amortized analysis)](https://en.cppreference.com/w/cpp/language/complexity) +- [Chromium `base/containers/flat_tree.h` — complexity comments](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-03-flat-map-comparator-and-transparent.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-03-flat-map-comparator-and-transparent.md new file mode 100644 index 000000000..c76012b6f --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-03-flat-map-comparator-and-transparent.md @@ -0,0 +1,159 @@ +--- +chapter: 0 +cpp_standard: +- 14 +- 17 +- 20 +description: "Breaking down flat_map's comparator: the strict weak order contract, std::less vs the transparent std::less<>, and how transparent comparators use ConditionalT to dispatch heterogeneous lookups at compile time" +difficulty: intermediate +order: 3 +platform: host +prerequisites: +- flat_map prerequisite (II): complexity and amortized analysis +reading_time_minutes: 10 +related: +- flat_map prerequisite (V): NO_UNIQUE_ADDRESS + EBO + the pair type +- OnceCallback prerequisite (IV): concepts and requires constraints +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 类型安全 +title: "flat_map prerequisite (III): comparators, strict_weak_order, and transparent lookup" +--- +# flat_map prerequisite (III): comparators, strict_weak_order, and transparent lookup + +flat_map is an ordered container. "Ordered" deserves a follow-up, though: ordered by what? This piece breaks two things apart, and both come down to the comparator. First, a comparator isn't just any old `<` you scribble down; it has to satisfy a mathematical contract called strict weak order, or the container's sorting and lookup go wrong. Second, flat_map's default comparator is `std::less<>` (transparent), not `std::less` (opaque). That gap looks tiny, but on a hot lookup path it costs a whole malloc/free pair. Why modern C++ prefers `std::less<>` has its roots right here. + +## The comparator: a function object that decides order + +Look at flat_map's template signature, `flat_map, Container = ...>` (flat_map.h:190-193). The third template parameter, `Compare`, is the comparator: a function object that takes two arguments and returns whether the first should sort before the second. + +The default is `std::less<>`, meaning "compare with `<`," so `flat_map` ends up ordered by int ascending. You can pass your own, sorting by string length for instance: + +```cpp +struct ByLength { + bool operator()(const std::string& a, const std::string& b) const { + return a.size() < b.size(); + } +}; +flat_map m; // ordered by string length +``` + +But a comparator isn't a free-for-all. Behind it sits a mathematical contract. + +## strict weak order: the comparator's mathematical contract + +For a container to sort and find correctly, the comparator you hand it must satisfy strict weak order. Four properties; we'll go one at a time. + +Irreflexive: `comp(a, a)` must be false, a cannot be less than itself. Antisymmetric: if `comp(a, b)` is true then `comp(b, a)` must be false. Transitive: if `comp(a, b)` and `comp(b, c)` hold, then `comp(a, c)` must hold. The first three are basically the properties of `<`, intuitive enough. + +The one people actually miss is the fourth, intransitivity of incomparability. What's "incomparable"? It's `!comp(a,b) && !comp(b,a)`: a is not less than b, b is not less than a, so the two are "equivalent." The fourth rule says: if a and b are equivalent, and b and c are equivalent, then a and c must be equivalent too. This guarantees "equivalent" is a genuine equivalence relation, transitively closed, so the container can partition elements into equivalence classes and order them class by class. + +We harp on the fourth because it's the one that breaks quietly. Take NaN in floating point: `NaN < x` and `x < NaN` are both false, so by the rule they're "equivalent," but two NaNs don't compare to each other either, intransitivity of incomparability blown right there. Or say you write a comparator with a tolerance, `abs(a-b) < eps` counts as equal: pick eps wrong or compare in an unstable order and "equal" stops being transitive, and the sort results get muddy. Elements can "disappear" during a find, or show up duplicated, and bugs like that are not fun to chase. C++20 codifies this contract as the `std::strict_weak_order` concept, so you can constrain your comparator with it and catch violations at compile time. + +The one-liner: with `<` you don't worry; with a hand-rolled comparator, especially multi-field or tolerance-based, keep those four rules in your head. + +## std::less vs std::less<>: opaque vs transparent + +Here we hit a distinction that matters in modern C++. `std::less` has two faces. One is `std::less`, around since C++98, opaque, accepting only `Key`. The `operator()` on `std::less` has signature `bool operator()(const std::string&, const std::string&)`, and it won't take anything else. The other is `std::less<>`, added in C++14, transparent, with a templated `operator()` that accepts any type and routes through `<` internally, which is why it's also called a transparent comparator. + +One detail you might have glossed over: `std::map` defaults to `std::less` (opaque), while flat_map defaults to `std::less<>` (transparent) (flat_map.h:192). Same standard-library family, one conservative and one aggressive, and this right here is the root of it. The difference looks academic until you see what it does to lookup performance. + +## Transparent lookup: skip the temporary + +Say you have a `flat_map` and you want to look up the key `"timeout"`: + +```cpp +flat_map m; +auto it = m.find("timeout"); // "timeout" is const char[8] +``` + +`"timeout"` is `const char[8]`, not `std::string`. If the comparator is `std::less` (opaque), then `find`'s argument must be a `std::string`, and the container has no choice: it takes your `const char[8]`, constructs a temporary `std::string` (heap allocation, character copy), uses that temporary for the binary search, then destructs it. One lookup, a malloc/free pair for free. + +If the comparator is `std::less<>` (transparent), the picture changes. `find` compares directly with `const char*`, because both `std::string` and `const char*` work with `<` (or the generic path through `std::less::operator()`), so no temporary `std::string` gets built at all. That's the payoff of transparent lookup: one search, one temporary construction skipped. + +For a light key like int, who cares. For a heavy key like `std::string` or a custom type, finding repeatedly on a hot path, those skipped temporaries add up. The first time this clicked for us was profiling a hot config path: the malloc top was a wall of `std::string` temporaries, all coming from map.find. Swap in a transparent comparator and that wall just vanishes. Stuck with me. + +## How flat_map pulls off transparency: compile-time dispatch via KeyT + +How does flat_map know whether the comparator is transparent? Through a compile-time type trait called `is_transparent`. A transparent comparator (like `std::less<>`) carries a nested `is_transparent` type, just an empty struct acting as a tag; an opaque one (like `std::less`) has no such type. flat_tree uses a `KeyT` alias to dispatch at compile time (flat_tree.h:109-111): + +```cpp +template +using KeyT = ConditionalT< + requires { typename KeyCompare::is_transparent; }, // is the comparator transparent? + K, // yes: keep K as the caller passed it + Key>; // no: force fallback to Key +``` + +`ConditionalT` looks like `std::conditional_t`, but its arguments don't depend on each other, so it can be deduced normally. The logic in one line: if the comparator is transparent, `KeyT` equals the K the caller passed in (say `const char*`); if it's opaque, `KeyT` gets forced back to `Key` (say `std::string`). + +So `find`'s signature tracks the comparator: + +```cpp +// transparent comparator (std::less<>): accepts heterogeneous keys +template +auto find(const KeyT& key); // KeyT = K (transparent) + +// opaque comparator (std::less): accepts only Key +template +auto find(const KeyT& key); // KeyT = Key = string +``` + +The caller passes `const char*`: the transparent version eats it raw; the opaque version has to implicitly convert `const char*` into `std::string` (constructing a temporary) to match. All of this happens at compile time, with zero runtime cost. The part we find most elegant is exactly this: one `find` signature, behavior switched entirely by a nested type on the comparator at compile time, and the user's code doesn't change a line. + +## KeyValueCompare: the heterogeneous-comparison internals + +One layer deeper. How does the lower level take a heterogeneous key and compare it against an element storing a `pair`? flat_tree's `KeyValueCompare` (flat_tree.h:439-462) handles this with two `extract_if_value_type` overloads as guards. If one side of the comparison is a `value_type` (i.e., `pair`), it first runs `GetKeyFromValue` to pull out the key and compares that; if one side is a bare `K` (a heterogeneous key like `const char*`), it passes through as-is and compares directly. + +With that, `lower_bound(data, "timeout", comp)` can compare a `const char*` directly against an array of `pair`, with no need to wrap `"timeout"` into a `pair` and no need to crack the whole value out of each element. This is the implementation detail that lets heterogeneous lookup land inside the binary-search loop. It looks unremarkable, but it's the overloads that iron out the mismatch between "a heterogeneous key" and "an array storing value_type." + +## A minimal reproduction + +Let's roll our own minimal version of a transparent comparator, to feel out the compile-time dispatch: + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include +#include +#include + +// transparent comparator (carries the is_transparent tag) +struct TransparentLess { + using is_transparent = void; // the key: marks transparency + template + bool operator()(A&& a, B&& b) const { return std::forward(a) < std::forward(b); } +}; + +// opaque comparator (no is_transparent) +struct OpaqueLess { + bool operator()(const std::string& a, const std::string& b) const { return a < b; } +}; + +template +constexpr bool is_transparent_v = requires { typename Comp::is_transparent; }; + +int main() { + std::cout << std::boolalpha; + std::cout << "TransparentLess transparent? " << is_transparent_v << "\n"; // true + std::cout << "OpaqueLess transparent? " << is_transparent_v << "\n"; // false + return 0; +} +``` + +Drop this `is_transparent_v` into that `ConditionalT` line above, and you have flat_tree's `KeyT` dispatch. The real code is at flat_tree.h:109-111, verbatim. + +That wraps up flat_map's comparator. strict weak order is the mathematical foundation for correct sorting; `std::less<>` beats `std::less` by enabling heterogeneous-key lookup without constructing temporaries; flat_map defaults to the transparent route, using the `is_transparent` tag plus `ConditionalT` to do the dispatch at compile time and charge nothing at runtime. + +flat_map hides another neat zero-cost trick: the `sorted_unique_t` tag, which skips sorting via tag dispatch. We pull that apart next. + +## References + +- [cppreference: std::less (including the transparent form)](https://en.cppreference.com/w/cpp/utility/functional/less) +- [cppreference: strict_weak_order (C++20 concept)](https://en.cppreference.com/w/cpp/concepts/strict_weak_order) +- [cppreference: is_transparent and heterogeneous lookup](https://en.cppreference.com/w/cpp/utility/functional/less_void) +- [Chromium `base/containers/flat_tree.h`: KeyT / KeyValueCompare](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-04-flat-map-tag-dispatch-and-sorted-unique.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-04-flat-map-tag-dispatch-and-sorted-unique.md new file mode 100644 index 000000000..977ba2002 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-04-flat-map-tag-dispatch-and-sorted-unique.md @@ -0,0 +1,176 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: "Pull apart flat_map's sorted_unique_t tag dispatch: an empty tag type steers overload resolution away from sort_and_unique, with a DCHECK guarding the contract, all at zero runtime cost" +difficulty: intermediate +order: 4 +platform: host +prerequisites: +- flat_map prerequisite (III): comparator, strict_weak_order, and transparent lookup +reading_time_minutes: 9 +related: +- flat_map in practice (IV): sorted_unique construction optimization +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 零开销抽象 +title: "flat_map prerequisite (IV): tag dispatch and sorted_unique_t" +--- +# flat_map prerequisite (IV): tag dispatch and sorted_unique_t + +Back in [pre-02](./pre-02-flat-map-complexity-and-amortized.md) we planted a hook: flat_map's batch construction is `O(N log N)`, because it has to grab the data, sort it, then deduplicate (`sort_and_unique`). We said it then and the question writes itself: if the data is already sorted, isn't that sort a pure waste? Chromium's engineers thought the same thing, and their answer is clean. You hand a `sorted_unique_t` tag into the constructor, which is you telling flat_map "trust me, this batch is sorted and has no duplicates," and it skips the sort, dropping construction to `O(N)`. + +This piece pulls apart the mechanism behind that tag, which is called tag dispatch, plus the way flat_map catches, in debug builds, the people who promise "sorted" and then hand in garbage through a `DCHECK`. + +## The problem: does every batch construction have to sort + +flat_map's ordinary constructors, whether you pass a vector, an initializer_list, or a range, all run `sort_and_unique` by default (flat_tree.h:567/578/586/594). It has no choice. It cannot know whether your data is already sorted, so it sorts and dedupes to be safe. + +In real code, "the data is already sorted" shows up constantly. A config table copied out of another sorted container. A point cloud the upstream pipeline already sorted by id. A test fixture you wrote by hand as an ordered list. In all of these, paying `O(N log N)` to re-sort is pure CPU waste: + +```cpp +std::vector> raw = load_config(); // known to be sorted +flat_map m(raw.begin(), raw.end()); // sorts it again anyway! +``` + +`raw` is already sorted, and flat_map still spends `O(N log N)` sorting it. Small datasets, you do not notice. Push into the millions, and that logarithmic factor starts to hurt. + +--- + +## Tag dispatch: pick the function by type + +The pattern that solves this has a name: tag dispatch. The idea is almost embarrassingly plain. You define an empty "tag type," and whether or not you pass that tag at construction time steers the compiler, during overload resolution, toward different functions. The tag itself is an empty struct, it carries nothing at runtime, so the cost is zero. + +This trick is all over the standard library. Pass `std::execution::par` to the parallel `std::sort` and it picks the parallel algorithm; leave it off and it picks the serial one. The tag carries no data. It is purely a "routing signal" for overload resolution. The iterator_category machinery is the same trick: hand `std::random_access_iterator_tag` into an overload set and the algorithm takes the random-access fast path. + +### flat_map's sorted_unique_t + +flat_map uses exactly this pattern (flat_tree.h:28-31): + +```cpp +struct sorted_unique_t { + constexpr sorted_unique_t() = default; +}; + +inline constexpr sorted_unique_t sorted_unique; +``` + +An empty struct with only the default constructor left in, plus a `constexpr` instance `sorted_unique`. When you build a flat_map, you slip `sorted_unique` in as the first argument, and overload resolution routes you to the constructor that skips the sort: + +```cpp +std::vector> raw = load_config(); // known to be sorted +flat_map m(sorted_unique, raw.begin(), raw.end()); // skips the sort! +``` + +The first argument is the tag, the data comes after. The tag carries no payload. Its entire job is to let the compiler say, during overload resolution, "oh, take the path that does not sort." + +--- + +## The five sorted_unique overloads + +flat_tree gives sorted_unique five constructor overloads (flat_tree.h:606-646), one for each input shape: InputIterator range, `from_range_t`, `const container_type&`, `container_type&&`, and `initializer_list`. They differ from the plain constructors in exactly one line: they do not call `sort_and_unique`. + +```cpp +// Plain constructor (around flat_tree.h:567): sort and dedupe +flat_tree(InputIterator first, InputIterator last, ...) { + insert(first, last); + sort_and_unique(); // the expensive sort +} + +// sorted_unique constructor (around flat_tree.h:606): skip the sort +flat_tree(sorted_unique_t, InputIterator first, InputIterator last, ...) { + insert(first, last); + DCHECK(is_sorted_and_unique(...)); // debug-only check, no sort +} +``` + +The only difference between the two parameter lists is the leading `sorted_unique_t`. The compiler routes on it during overload resolution. `sorted_unique_t` is an empty type, the instance takes no space, so passing it is the same as passing nothing. This "choice" does not cost you a single byte at runtime. + +--- + +## DCHECK(is_sorted_and_unique): catch the liars in debug + +Of course, someone who says "sorted" does not always hand in sorted data. flat_map does not just take your word for it. In debug builds it hangs a `DCHECK(is_sorted_and_unique(...))` (flat_tree.h:612/624/633/642) on the path as insurance. `is_sorted_and_unique` (flat_tree.h:55-62) looks like this: + +```cpp +template +constexpr bool is_sorted_and_unique(const Range& range, Comp comp) { + return std::ranges::adjacent_find(range, std::not_fn(comp)) == + std::ranges::end(range); +} +``` + +It pairs `std::ranges::adjacent_find` with `std::not_fn(comp)` and walks the adjacent pairs. The moment any adjacent pair fails "strictly less than" (equal, or out of order), `adjacent_find` lands on that position, the `DCHECK` trips, and you get an abort. + +This is a very Chromium-flavored contract. In debug, flat_map checks the truth of your promise for you, and if you lied it blows up on the spot. In release, `DCHECK` compiles down to nothing, there is no check at all, and flat_map trusts you outright. `is_sorted_and_unique` is itself `O(N)` (one sweep over adjacent pairs), but you only pay that in debug. Release is a true `O(N)` construction: append, then take ownership, no sort, no check. + +--- + +## Zero cost: release pays nothing + +Add up the bill and it clears in one read. In a debug build, the sorted_unique constructor is append (`O(N)`) plus the `O(N)` `DCHECK(is_sorted_and_unique)` sweep, so still `O(N)`. In a release build, the `DCHECK` disappears, and the sorted_unique constructor is just append, pure `O(N)`. + +Set that against the plain constructor: append (`O(N)`) plus `sort_and_unique` (`O(N log N)`, going through `stable_sort`). On a large dataset, `N log N` is slower than `N` by that logarithmic factor. At a million elements, that is roughly a 20x gap. So sorted_unique in release is a true zero-cost abstraction: you reach for it when you are sure the data is sorted, you save the `log N` factor, and in debug you get a free check thrown in for safety. + +--- + +## A minimal re-implementation + +Let us hand-roll a minimal tag dispatch, so the shape of "pick a function by type" is something you can feel directly: + +```cpp +// Platform: host | C++ Standard: C++17 +#include +#include +#include +#include + +struct sorted_unique_t {}; // empty tag type +inline constexpr sorted_unique_t sorted_unique{}; // constant instance + +class MiniMap { +public: + // Plain constructor: assume unsorted data, sort internally + MiniMap(std::vector data) : data_(std::move(data)) { + std::sort(data_.begin(), data_.end()); + std::cout << " [plain constructor] sorted\n"; + } + // sorted_unique constructor: trust the caller, skip the sort (assert in debug) + MiniMap(sorted_unique_t, std::vector data) : data_(std::move(data)) { + assert(is_sorted_unique()); // debug check + std::cout << " [sorted_unique constructor] skipped the sort\n"; + } +private: + bool is_sorted_unique() const { + for (size_t i = 1; i < data_.size(); ++i) + if (!(data_[i - 1] < data_[i])) return false; // must be strictly ascending + return true; + } + std::vector data_; +}; + +int main() { + std::vector a = {3, 1, 2}; + MiniMap m1(a); // plain constructor, sorts + + std::vector b = {1, 2, 3}; // already sorted + MiniMap m2(sorted_unique, b); // skips the sort + return 0; +} +``` + +Run it and you see two lines: `[plain constructor] sorted` and `[sorted_unique constructor] skipped the sort`. The entire mechanism of tag dispatch lives in those two overload signatures. One takes an extra empty tag argument, the compiler routes on it, there is no runtime cost, and there is no clever trick to it. + +That leaves one last piece in the prerequisite run: `[[no_unique_address]]`, the empty base optimization (EBO), and why flat_map stores `pair` instead of `pair`. + +## References + +- [cppreference: tag dispatch](https://en.cppreference.com/w/cpp/named_req/TagDispatch) +- [cppreference: std::sort and the execution policy tag](https://en.cppreference.com/w/cpp/algorithm/sort) +- [Chromium `base/containers/flat_tree.h` — sorted_unique_t and the DCHECK check](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-05-flat-map-enua-ebo-and-pair-storage.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-05-flat-map-enua-ebo-and-pair-storage.md new file mode 100644 index 000000000..8cac97045 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-05-flat-map-enua-ebo-and-pair-storage.md @@ -0,0 +1,169 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: "Two memory optimizations behind flat_map: [[no_unique_address]]/EBO lets a stateless comparator cost zero bytes, and the storage is pair rather than std::map's pair" +difficulty: intermediate +order: 5 +platform: host +prerequisites: +- flat_map prerequisite (III): comparators, strict_weak_order, and transparent lookup +reading_time_minutes: 9 +related: +- flat_map in practice (II): the flat_tree core skeleton +- WeakPtr prerequisite (VI): TRIVIAL_ABI and trivially relocatable +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 内存管理 +- 零开销抽象 +title: "flat_map prerequisite (V): NO_UNIQUE_ADDRESS, EBO, and pair storage" +--- +# flat_map prerequisite (V): NO_UNIQUE_ADDRESS, EBO, and pair storage + +The flat_map class definition has a few small annotations that are easy to skim past on a first read. When we went through the code this time, we stopped to dig into them and found two small memory-level ideas hiding behind them. One lets a stateless comparator (like the default `std::less<>`) cost zero bytes, thanks to `[[no_unique_address]]`. The other swaps the stored element type from `pair` to `pair`. That looks like just dropping a `const`, but it actually decides which APIs flat_map can offer and which it can't. We'll take the two apart in this piece. + +## EBO: an empty object should be zero bytes + +C++ has a piece of historical baggage that made us frown the first time we learned it: an empty object, meaning a class with no data members at all, still has to occupy at least 1 byte. The rule goes like this. Two distinct objects must each occupy their own address, and addresses are counted in bytes, so a 0-byte object can't have an address of its own. That's why `sizeof` of a `struct Empty {}` isn't 0, it's 1. + +```cpp +struct Empty {}; +sizeof(Empty); // 1 (not 0) +``` + +On its own that doesn't hurt. It bites the moment you want to use an empty type as a member. The textbook case is a stateless comparator like `std::less<>`, which holds nothing inside and is purely a call wrapper. If you make it an honest member: + +```cpp +struct Holder { + std::less<> comp; // empty object, but takes 1 byte (+ alignment padding) + int data; +}; +sizeof(Holder); // 8 bytes (1 byte comp + 3 bytes padding + 4 bytes data) +``` + +`comp` is theoretically zero-cost (it has no data at all), yet it eats 1 byte and drags alignment padding along with it. Four bytes of pure bloat. + +EBO (Empty Base Optimization) is the escape hatch C++ leaves for the "empty class as a base" case: as long as the empty class sits in the base position, the compiler may let it share an address with the derived class, and its cost drops to zero: + +```cpp +struct Empty {}; +struct Holder : Empty { // empty class as a base + int data; +}; +sizeof(Holder); // 4 bytes (Empty optimized away) +``` + +Every standard container leans on this trick to keep empty allocators and empty comparators from costing memory. They inherit them as bases instead of stuffing them in as members. But EBO has a boundary: it only kicks in for bases, not for members. Write the empty object as a member and it still pays the byte. + +--- + +## [[no_unique_address]]: extending EBO to members + +C++20 loosens that boundary. The new `[[no_unique_address]]` attribute tells the compiler: this member doesn't need its own address, so if it's an empty type, don't allocate space for it. In other words, EBO moves off the base and onto the member: + +```cpp +struct Empty {}; +struct Holder { + [[no_unique_address]] Empty comp; // annotated, empty member can be zero bytes + int data; +}; +sizeof(Holder); // 4 bytes (comp EBO'd away) +``` + +Chromium doesn't use the attribute raw. It wraps it in the `NO_UNIQUE_ADDRESS` macro, which expands to `[[no_unique_address]]` when the compiler supports it and to nothing otherwise. That keeps conditional compilation out of every call site. + +flat_tree hangs this macro in two spots, both holding a comparator. One is the `key_compare comp_` member of `flat_tree` itself (flat_tree.h:545), where the default `std::less<>` folds to zero. The other sits inside the nested `value_compare` (flat_tree.h:129), same story. So when you write `flat_map m;`, the comparator inside costs not a single byte. The container only pays memory for the `vector>` that actually holds data. The comparator is free. + +--- + +## GCC vs Clang: equivalent and correct for empty-type EBO + +You'll often hear online that Clang handles `[[no_unique_address]]` better than GCC. It's not entirely wrong. In some non-empty-but-overlapping cases (say, several NUA members of the same type in one class) the two really do differ. But on the case flat_map actually cares about, an empty type as a member, we tested both and they behave identically. GCC 16 and Clang 22 both fold it down to 0 bytes. + +```cpp +struct Empty {}; +struct WithNUA { [[no_unique_address]] Empty e; int i; }; +struct WithoutNUA { Empty e; int i; }; +// Measured on GCC 16 / Clang 22: +// sizeof(WithNUA) = 4 bytes (e optimized away) +// sizeof(WithoutNUA) = 8 bytes (e takes 1 + padding 3 + i takes 4) +``` + +So flat_map's empty comparator gets EBO on both GCC and Clang. Don't let that popular claim lead you astray. + +What the comment at flat_tree.h:542-547 actually points at is a real GCC bug that has nothing to do with semantics ([crbug.com/1156268](https://crbug.com/1156268)): under a particular member declaration order, GCC just emits an ICE (internal compiler error). It's not that the EBO folding differs, it's that the compiler crashed. Chromium's workaround is to shuffle where `comp_` is declared. That's a compiler implementation bug with zero to do with language semantics, so don't conflate it with "NUA semantic differences" when you explain it. + +--- + +## pair vs pair: flat_map's storage choice + +The second design point hits harder. It reshapes what flat_map's API even looks like. Look at flat_map's template signature (flat_map.h:193): + +```cpp +template , + class Container = std::vector>> +class flat_map : ...; +``` + +`Container` defaults to `std::vector>`. Read that `pair` carefully: it's `pair`, **not** `pair`. That's the exact opposite of `std::map`, whose elements are `pair`. Once a key lands in a `std::map` node, you can't touch it. + +Why does flat_map take the non-const route? The root is that the backing container is a vector. A vector that wants to stay sorted can't avoid the shift inside `insert`/`erase`, where `std::move_backward` relocates a whole range, and that requires the element type to be move-assignable. But the `first` of a `pair` is const, so it can't be move-assigned: + +```cpp +// Measured (C++17): +static_assert(!std::is_move_assignable_v>); // not move-assignable → vector shift is impossible +static_assert( std::is_move_assignable_v>); // move-assignable +``` + +`pair` isn't move-assignable, and that seals off the vector's shift path. If flat_map wants to function at all, it has to pick the non-const `pair`. + +There's a spot here that's easy to get wrong, and we got dragged astray by it at first. The overwrite in `insert_or_assign` writes `result.first->second = std::forward(obj)` (flat_map.h:339), which **only touches `.second`**. Even if you switched to `pair` it would still compile, because only `.first` is const. So the thing that actually kills the const pair isn't the overwrite. It's the vector shift, which has to move-assign the whole pair, and `pair` can't deliver that. Don't cite the overwrite as the reason. It isn't the root cause. + +The cost side needs to be said plainly. Since the storage is `pair`, `first` is non-const and the key is in principle mutable through an iterator. If you casually write `it->first = new_key`, you've broken the sorted invariant and the container has no idea. This is entirely on the user's discipline, same flavor as [WeakPtr's sequence contract](../../02_weak_ptr/full/pre-03-weak-ptr-sequence-checker-dcheck-check.md). Release builds don't enforce it. You have to keep your own head straight. + +`std::map` doesn't have this worry on its road, because `pair` makes the key unmodifiable by construction. Its luxury comes from the node-based container model: no shift, no assignment, just reconnecting pointers. The two designs each pick one end of the tradeoff. Neither is free. + +--- + +## A minimal reproduction: verifying EBO and the pair type + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include +#include + +struct EmptyLess { + using is_transparent = void; + template + bool operator()(A&& a, B&& b) const { return a < b; } +}; + +struct WithNUA { [[no_unique_address]] EmptyLess c; int i; }; +struct WithoutNUA { EmptyLess c; int i; }; + +int main() { + std::cout << "sizeof(WithNUA) = " << sizeof(WithNUA) << " (NUA folds the empty member)\n"; + std::cout << "sizeof(WithoutNUA) = " << sizeof(WithoutNUA) << " (empty member takes 1 + padding)\n"; + std::cout << "pair move-assignable? " + << std::is_move_assignable_v> << " (false = no, which is why flat_map doesn't store it)\n"; + return 0; +} +``` + +Measured output (GCC 16 / Clang 22): `WithNUA=4`, `WithoutNUA=8`, `pair move-assignable? false`. Two numbers and one false, and the reasoning behind those two flat_map design choices more or less lands on the ground. + +That wraps up the prerequisites (pre-00 intro plus pre-01..05, five building blocks). Next stop is the hands-on track, where we put together flat_tree's core skeleton. + +## References + +- [cppreference: [[no_unique_address]]](https://en.cppreference.com/w/cpp/language/attributes/no_unique_address) +- [cppreference: Empty Base Optimization](https://en.cppreference.com/w/cpp/language/ebo) +- [Chromium `base/containers/flat_tree.h`: NO_UNIQUE_ADDRESS and member declaration comments](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [crbug.com/1156268: GCC member declaration order ICE](https://crbug.com/1156268) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/01-flat-map-design.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/01-flat-map-design.md new file mode 100644 index 000000000..6f7475088 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/01-flat-map-design.md @@ -0,0 +1,89 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "For readers comfortable with templates and performance: a fast walkthrough of flat_map's design motivation, API, and the flat_tree adapter architecture. The condensed design-guide version of the full/ series." +difficulty: advanced +order: 1 +platform: host +prerequisites: +- Move semantics and perfect forwarding +- C++20 concepts and ranges +- 'flat_map prerequisite (0): ordered associative containers and std::map''s red-black tree' +reading_time_minutes: 6 +related: +- 'flat_map design guide (II): step-by-step implementation' +- 'flat_map design guide (III): test strategy and performance comparison' +tags: +- host +- cpp-modern +- advanced +- 容器 +- map +- 优化 +title: "flat_map Design Guide (I): Motivation, API, and the flat_tree Architecture" +--- +# flat_map Design Guide (I): Motivation, API, and the flat_tree Architecture + +> Hands-on track. Assumes you're already comfortable with vector growth, complexity analysis, and C++20 concepts; if not, skim the [full/ prerequisites](../full/pre-00-flat-map-ordered-assoc-container-intro.md) first. + +We were poking at `std::map`'s lookup path recently and the picture wasn't pretty: 32 bytes of metadata per node, one malloc per inserted key, and a lookup that walks `node = node->left_` one pointer chase at a time. Every step is a data-dependent dereference the CPU can't prefetch, so cache misses line up. Chromium ships its own answer in `//base/containers` called `flat_map`, which swaps the red-black tree for a sorted vector plus binary search. Read-heavy, write-light workloads win, and that's the whole pitch. This piece works through the motivation, the API, and the flat_tree adapter architecture. Implementation and tests come in the next two pieces. + +## The problem: where `std::map` gets stuck + +`flat_map` and `std::map` are both `O(log n)` lookup, asymptotically identical, and the textbook doesn't surface a difference. The difference lives entirely in the constant factor. Red-black tree nodes are scattered across the heap, so every `node = node->left_` step in a lookup is a data-dependent dereference the CPU can't prefetch, which most likely becomes a cache miss. A concrete number: for one million entries in a `map`, `std::map` eats roughly 32 MB in node metadata alone plus one million malloc calls. `flat_map` has nearly zero overhead on that axis, with the data laid out in a single contiguous run. Same asymptotics, constant factor off by an order of magnitude. That is the entire reason `flat_map` exists. + +## What the API looks like + +The surface is surprisingly plain: + +```cpp +template , // transparent default + class Container = std::vector>> // non-const Key +class flat_map : public flat_tree; +``` + +The template parameters hide a few tradeoffs you might miss on a first read: + +| Decision | Choice | Reason | +|---|---|---| +| Default comparator | `std::less<>` (transparent) | Heterogeneous lookup; `find("abc")` builds no temporary string | +| Storage | `pair`, non-const | vector must shift and move-assign; `pair` can't move-assign | +| `at()` out of range | CHECK crash (not throw) | Chromium style; logic errors blow up immediately | +| `sorted_unique` constructor | tag dispatch skips sort | When data is already sorted it's O(N), zero cost | +| `extract`/`replace` | rvalue-qualified, batch rebuild | Avoids per-element O(n) shift and iterator invalidation | + +Two of these deserve a closer look. `Compare` defaults to `std::less<>` rather than `std::less`. That's transparent comparison: you can call `find("abc")` on a string-keyed map without constructing a temporary `std::string`, saving one heap allocation. The other is `pair` with a non-const Key. It looks counterintuitive (keys shouldn't change, so why not const?), but a vector has to shift and move-assign on insert and erase, and `pair` can't move-assign at all, which would cripple the whole vector. + +`at()` out of range runs CHECK and crashes instead of throwing. That's Chromium style: a logic error shouldn't limp along, it should fail in your face right there. One thing that took a while to track down was how batch rebuilds work. The standard library's `extract` is rvalue-qualified here as `extract()&&`, paired with `replace(container_type&&)`, so you swap the underlying container in one shot and sidestep the per-element O(n) shift and the iterator-invalidation tangle that comes with it. + +## flat_tree: one piece of code, two containers + +The implementation hides an elegant layering that made us pause on first read. The whole core is a single class, `flat_tree`, a generic "sorted-array associative container." The real map and set are thin shells over it. `flat_map` subclasses `flat_tree`, where the `GetFirst` policy pulls the first element out of a `pair` to use as the key (flat_map.h:194-195). `flat_set` is even more direct: an alias of `flat_tree`, where `std::identity` uses the value itself as the key (flat_set.h:159-163). + +The prettiest move is right here. A single typename-level extractor, `GetFirst` or `std::identity`, makes the same flat_tree serve as both map and set. Textbooks spin out a chapter on policy objects; this code teaches it in one line. Once you understand flat_tree, the only remaining difference between flat_map and flat_set is that one extractor line. The entirety of flat_set.h is 191 lines, and the core is essentially that line. + +## Invariants and costs + +The `body_` array inside flat_tree is always strictly ascending under `comp_` with no duplicates. That invariant is the foundation the whole mechanism sits on. Maintaining it splits into two phases: at construction time you run `sort_and_unique` once (stable_sort to order, unique to dedupe, erase to trim the tail), and during insertion you find the position with `lower_bound` then `emplace`. The cost breakdown: + +| Operation | Complexity | Mechanism | +|---|---|---| +| find/contains/lower_bound | `O(log n)` | std::ranges::lower_bound binary search, cache-friendly | +| insert/emplace/erase | `O(n)` | vector shift, no amortization | +| operator[]/insert_or_assign/try_emplace | `O(n)` | same as insert | +| range constructor | `O(N log²N)` / `O(N log N)` | sort_and_unique | +| sorted_unique constructor | `O(N)` | skips sort, only DCHECK | + +These costs mirror `std::map`'s: lookup wins on the constant factor, while insertion pays `O(n)` for the vector shift. `flat_map` isn't trying to be a general-purpose map. It bets on "read-heavy, write-light." If you have a config table, a routing table, or an enum-to-string mapping that's effectively read-only after construction, the trade pays off. If you're doing high-frequency insert and erase, go back to `std::map`. There's also an escape hatch for "my data is already sorted": the `sorted_unique` constructor tag-dispatches past the sort, and as long as DCHECK verifies the ordering, it's O(N) to come in, at zero cost. + +That's the architecture and the cost model on paper. But paper clarity is one thing, and writing it out line by line surfaces things the page doesn't show: why `sort_and_unique` has to be three separate steps (stable_sort, unique, erase), how `sorted_unique`'s DCHECK avoids eating data in release builds, and where exactly the rvalue-qualified `extract()&&` pays off. The next piece opens up flat_tree's core code. + +## References + +- [Chromium `base/containers/flat_tree.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/flat_map.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [Chromium `base/containers/README.md`: container selection guide](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/README.md) +- [flat_map prerequisite (0): ordered associative containers and std::map's red-black tree](../full/pre-00-flat-map-ordered-assoc-container-intro.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/02-flat-map-implementation.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/02-flat-map-implementation.md new file mode 100644 index 000000000..aab8a5f45 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/02-flat-map-implementation.md @@ -0,0 +1,270 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "Build flat_tree/flat_map layer by layer: signatures, key extractor, sort_and_unique, lookup and insert, sorted_unique construction, and the flat_map-specific API. Code-dense, little filler." +difficulty: advanced +order: 2 +platform: host +prerequisites: +- flat_map design guide (I): motivation, API, and the flat_tree architecture +- flat_map prerequisite (IV): tag dispatch and sorted_unique_t +reading_time_minutes: 13 +related: +- flat_map design guide (III): test strategy and performance comparison +tags: +- host +- cpp-modern +- advanced +- 容器 +- map +- 优化 +title: "flat_map Design Guide (II): Step-by-Step Implementation" +--- +# flat_map Design Guide (II): Step-by-Step Implementation + +The previous piece walked through the motivation and the interface. This time we stop arguing on paper and just write `flat_tree` and `flat_map` line by line. We'll stack layers from the bottom up, starting with the class signature and data members, climbing all the way to the few APIs that belong to `flat_map` itself. Code is dense, explanations only point at the load-bearing parts; for the full reasoning behind each choice, see [full/03-2~03-4](../full/03-2-flat-map-flattree-skeleton.md). The companion project lives in `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/` (`19` through `22`); we kept the tests running next to the editor the whole time. + +## Layer 1: stand the skeleton up + +The first thing to nail down is the `flat_tree` class signature and its data members. Get the foundation wrong and everything above it leaks. + +```cpp +// Platform: host | C++ Standard: C++20 +#pragma once +#include +#include +#include +#include +#include +#include + +namespace tamcpp::chrome::internal { + +template +class flat_tree { +public: + using key_type = Key; + using key_compare = KeyCompare; + using value_type = typename Container::value_type; + using iterator = typename Container::iterator; + using const_iterator = typename Container::const_iterator; + using container_type = Container; + using size_type = typename Container::size_type; + + static constexpr bool is_transparent_comparator = + requires { typename KeyCompare::is_transparent; }; + +protected: + Container body_; + [[no_unique_address]] KeyCompare comp_; // EBO: stateless comparator is zero bytes + + flat_tree() = default; + explicit flat_tree(const KeyCompare& c) : comp_(c) {} + + // Turn a key comparison into a value comparison (extract on both sides) + template + bool less(const A& a, const B& b) const { + GetKeyFromValue ext; + return comp_(extract_if_value(ext, a), extract_if_value(ext, b)); + } + template + static const auto& extract_if_value(Ext& ext, const V& v) { + if constexpr (std::is_same_v, value_type>) return ext(v); + else return v; + } +}; + +} // namespace tamcpp::chrome::internal +``` + +`[[no_unique_address]]` makes an empty comparator (the default `std::less<>`) cost zero bytes. The first time we saw that trick it genuinely felt elegant: an empty object just evaporates. `extract_if_value` is the hinge of heterogeneous comparison. A value goes through the extractor; a bare key passes through untouched, so you can query a `pair` table with a plain `int` without wrapping it first. + +## Layer 2: construction, and the sort_and_unique you can't avoid + +With the skeleton up, the next problem is "a pile of unordered stuff comes in, how do we turn it into a sorted, deduped table." That's `sort_and_unique`. + +```cpp +// Range constructor: append, then sort + dedupe +template +flat_tree(InputIt first, InputIt last, const KeyCompare& c = KeyCompare()) + : body_(first, last), comp_(c) { + sort_and_unique(); +} +// Container move constructor: bulk build (the recommended posture) +flat_tree(Container&& body, const KeyCompare& c = KeyCompare()) + : body_(std::move(body)), comp_(c) { + sort_and_unique(); +} + +void sort_and_unique() { + std::stable_sort(body_.begin(), body_.end(), + [this](const value_type& a, const value_type& b) { return less(a, b); }); + body_.erase(std::unique(body_.begin(), body_.end(), + [this](const value_type& a, const value_type& b) { + return !less(a, b) && !less(b, a); + }), + body_.end()); +} +``` + +`stable_sort` plus `unique` plus `erase`, O(N log N). One point we initially glossed over: why `stable_sort` and not `sort`? Because `stable_sort` preserves the relative order of equal elements and `sort` does not. If data you later `replace` back in depends on that order, `sort` may have already shuffled it. The equality lambda inside `unique` uses `!less(a,b) && !less(b,a)`, meaning "neither less nor less-than," which is the definition of equivalence. It's more robust under heterogeneous comparison than a plain `==`. + +## Layer 3: the sorted_unique constructor, a back door for data already in order + +Here it gets interesting. If your data is already sorted and deduped (poured out of another `flat_map`, say), running `sort_and_unique` again is pure waste. Chromium leaves a back door for exactly this case: the `sorted_unique` tag. + +```cpp +struct sorted_unique_t {}; +inline constexpr sorted_unique_t sorted_unique{}; + +template +flat_tree(sorted_unique_t, InputIt first, InputIt last, const KeyCompare& c = KeyCompare()) + : body_(first, last), comp_(c) { + assert(is_sorted_unique()); // debug check, no sort +} + +bool is_sorted_unique() const { + for (size_type i = 1; i < body_.size(); ++i) + if (!less(body_[i - 1], body_[i])) return false; // must be strictly ascending + return true; +} +``` + +The tag makes overload resolution skip `sort_and_unique` and only DCHECK. O(N) copy, and in release even that `assert` is gone. The first time through, this gave us a small jolt: it's pushing the contract onto you, isn't it. It is. If you feed unsorted data into a `sorted_unique` constructor, debug builds catch it; release builds go to silent corruption. It's an honest contract. You're expected to know what you're doing. + +## Layer 4: lookup, the binary search + +Lookup on a sorted array has no suspense: `std::lower_bound`, O(log n). One detail is worth stopping on, though. + +```cpp +const_iterator find(const Key& key) const { + auto it = std::lower_bound(body_.begin(), body_.end(), key, + [this](const value_type& v, const Key& k) { return less(v, k); }); + if (it != body_.end() && !less(key, *it)) return it; + return body_.end(); +} +bool contains(const Key& key) const { return find(key) != body_.end(); } +size_type count(const Key& key) const { return contains(key) ? 1 : 0; } +``` + +`std::lower_bound` does the binary search in O(log n) and takes only **one** binary comparator `(value, key) -> bool`. With a transparent comparator the `key` can be a heterogeneous type, so querying a `std::string` table with a `std::string_view` needs no conversion. The `!less(key, *it)` line in `find` is the equality trick: `lower_bound` gives you "the first position not less than key," and if that position is not less than key and key is not less than it either, they're equivalent, so return it; otherwise return `end()`. + +A small pitfall we hit while writing this: Chromium's flat_tree uses `std::ranges::lower_bound(*this, key, KeyValueCompare(comp_))`. That `KeyValueCompare` is a comparator class with **two `operator()` overloads** (one for `v < k`, one for `k < v`), not two parallel lambdas; `ranges::lower_bound` also accepts a single comparator object. The "one lambda plus `extract_if_value`" form above is a teaching simplification, behaviorally equivalent, but the Chromium version handles more corners of heterogeneous lookup. + +## Layer 5: insert, the O(n) shift you can't dodge + +Insert is the powder keg of flat_map performance debates. The design piece covered the argument; here we land it. + +```cpp +std::pair insert(value_type v) { + auto it = std::lower_bound(body_.begin(), body_.end(), v, + [this](const value_type& a, const value_type& b) { return less(a, b); }); + if (it != body_.end() && !less(v, *it)) return {it, false}; // already present, don't insert + return {body_.emplace(it, std::move(v)), true}; // O(n) shift, insert succeeds +} +``` + +`lower_bound` finds the spot in O(log n); `emplace` shifts every element after it, O(n). That's the cost of a flat_map insert: a vector insertion in the middle, the tail moves down. The unique-key semantics are baked in too: if the key already exists, return `{it, false}` and don't insert. One thing we want to underline: look at the `bool` that comes back. We got lazy early on and ignored it, then spent half a day debugging a silent duplicate insert that had been swallowed. + +## Layer 6: extract and replace, the two wrenches for bulk rebuild + +Insert is single-element surgery. Sometimes you want to pour a whole batch in, or swap the entire container out. That's what `extract` and `replace` are for. + +```cpp +container_type extract() && { + return std::exchange(body_, container_type{}); // hand the whole thing out +} +void replace(container_type&& body) { + body_ = std::move(body); + assert(is_sorted_unique()); // the sorted_unique honest contract +} +iterator erase(const_iterator pos) { return body_.erase(pos); } // O(n) +``` + +`extract` is qualified `&&`, so it only applies to rvalues: the container empties itself out, you take the contents, it's left a shell. `replace` follows the same honest-contract line as `sorted_unique`: it trusts that the data you pass in is sorted and deduped, `assert`s it only in debug, and takes over directly in release. The two wrenches compose well. If you want to bulk-update a flat_map, you can `extract` it, mutate the contents in whatever order you like outside, sort and dedupe, then `replace` it back, far faster than calling `insert` over and over. + +## Layer 7: the few APIs that actually belong to flat_map + +The first six layers are all `flat_tree`'s business, indifferent to map versus set. What truly belongs to `flat_map` is just the following: `operator[]`, `at`, `insert_or_assign`. This is the point of splitting `flat_tree` and `flat_map`. One core engine, and map wears a thin shell on top. + +```cpp +namespace tamcpp::chrome { + +struct GetFirst { + template + constexpr const K& operator()(const std::pair& p) const { return p.first; } +}; + +template , + class Container = std::vector>> +class flat_map : public internal::flat_tree { + using base = internal::flat_tree; +public: + using mapped_type = Mapped; + using base::base; // inherit flat_tree's constructors / lookup / insert + + mapped_type& operator[](const Key& key) { + auto it = std::lower_bound(this->body_.begin(), this->body_.end(), key, + [this](const value_type& v, const Key& k) { return this->less(v, k); }); + if (it == this->body_.end() || this->less(key, *it)) + it = this->body_.emplace(it, std::piecewise_construct, + std::forward_as_tuple(key), + std::forward_as_tuple()); // default-construct mapped + return it->second; + } + + mapped_type& at(const Key& key) { + auto it = this->find(key); + assert(it != this->body_.end()); // teaching build uses assert; Chromium uses CHECK + return it->second; + } + + template + std::pair insert_or_assign(const Key& key, M&& obj) { + auto it = std::lower_bound(this->body_.begin(), this->body_.end(), key, + [this](const value_type& v, const Key& k) { return this->less(v, k); }); + if (it != this->body_.end() && !this->less(key, *it)) { + it->second = std::forward(obj); // overwrite .second (mapped only, key untouched) + return {it, false}; + } + return {this->body_.emplace(it, key, std::forward(obj)), true}; + } +}; + +template , class Container = std::vector> +using flat_set = internal::flat_tree; + +} // namespace tamcpp::chrome +``` + +`operator[]` inserts a default-constructed mapped when the key is missing, and that's exactly why flat_map uses `vector>` rather than `vector>`: it has to default-construct in place, and `const Key` can't do that. `at` on a missing key hits an `assert`, which is fine for a teaching build; Chromium uses `CHECK` because release builds must crash too. `insert_or_assign` is the interesting one: if the key is present it overwrites `.second`, if not it inserts, and the returned `bool` tells you which path ran. The `flat_set` line at the bottom is one we're particularly fond of: a `using` alias plus a `std::identity` extractor, no extra code. That's the dividend of sinking the core into `flat_tree`. + +## Run it, see it move + +Writing it and not running it feels unsafe. Here's a minimum slice: construct, look up, mutate, and a set on the side. + +```cpp +#include +int main() { + using namespace tamcpp::chrome; + flat_map m{{3,"c"},{1,"a"},{2,"b"}}; // construction sorts + std::cout << m.size() << "," << m[1] << "\n"; // 3,a + m.insert_or_assign(2, "B"); // overwrite 2 + std::cout << m[2] << "\n"; // B + + flat_set s{{3,1,2,1}}; // sort + dedupe + std::cout << s.size() << "\n"; // 3 + return 0; +} +``` + +That's all seven layers. `flat_tree` is the real implementation core; `flat_map` (subclass plus `GetFirst`) and `flat_set` (alias plus `std::identity`) are both thin shells over it. The points we hit along the way, `sort_and_unique` maintaining the sorted invariant, `lower_bound` for binary search, the O(n) shift inside `emplace`, `sorted_unique` skipping the sort, `extract`/`replace` for bulk rebuild, `[[no_unique_address]]` evaporating the empty comparator, that's the entire internal mechanics of flat_map. The code itself isn't much, but every choice behind it has a story, which is what made this piece fun to write. Next time we add the tests and the performance comparison, and see how it actually stacks up against `std::map`. + +## References + +- [Chromium `base/containers/flat_tree.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/flat_map.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [flat_map design guide (III): test strategy and performance comparison](./03-flat-map-testing.md) +- [flat_map hands-on (II): the flat_tree core skeleton](../full/03-2-flat-map-flattree-skeleton.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/03-flat-map-testing.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/03-flat-map-testing.md new file mode 100644 index 000000000..df448dcb9 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/03-flat-map-testing.md @@ -0,0 +1,126 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: "flat_map's test strategy: design cases around invariants, then measure per-item overhead, lookup/insert performance, and lay out the selection criteria for flat_map vs std::map/absl::btree_map." +difficulty: advanced +order: 3 +platform: host +prerequisites: +- 'flat_map design guide (II): step-by-step implementation' +reading_time_minutes: 6 +related: +- 'flat_map design guide (I): motivation, API, and the flat_tree architecture' +- 'flat_map hands-on (VI): testing and performance comparison' +tags: +- host +- cpp-modern +- advanced +- 容器 +- map +- 测试 +- 优化 +title: "flat_map Design Guide (III): Test Strategy and Performance Comparison" +--- +# flat_map Design Guide (III): Test Strategy and Performance Comparison + +The implementation piece is done, and honestly we weren't fully at ease with it. `flat_tree`'s tangle of `lower_bound + emplace + shift` compiling is one thing; whether the semantics actually hold is another. Containers are at their most dangerous when they "look like they run": you throw in a few numbers, iterate them back in order, tests go green, and you ship. Duplicate-key dedup, `insert_or_assign` overwrite, a lying `sorted_unique`, iterator invalidation, all of those land on the boundary. In this piece we press each of the six invariants promised in part one back into tests, then put flat_map on the bench against `std::map` and `absl::btree_map` to see where it actually saves and where it pays. The playbook mirrors the [WeakPtr design guide (III)](../../02_weak_ptr/hands_on/03-weak-ptr-testing.md): invariants drive the tests, numbers settle the rest, no hand-waving. + +## Invariants into a test matrix + +| # | Invariant | Assertion | +|---|---|---| +| 1 | Ordered | Iteration yields strictly ascending keys | +| 2 | Unique | Duplicate keys are deduped | +| 3 | Lookup semantics | find/contains/operator[]/at agree; out-of-range at CHECK/asserts | +| 4 | insert_or_assign/try_emplace | Existing is overwritten, new is inserted | +| 5 | sorted_unique | Skips the sort; lying input aborts in debug | +| 6 | Iterator invalidation | Old iterators are dead after mutation (coarse rule) | + +## Key test cases (Catch2-style sketch) + +The six invariants sound abstract, but once you sit down to test them you're really just picking the boundaries that blow up if you got them wrong. We picked three that pin the semantics hardest: sort+dedup at construction, `insert_or_assign` overwrite, and `sorted_unique` lying to abort in debug. The supporting demos live as `19` through `22` `.cpp` files under `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/`; wiring them into a Catch2 test target is left as an extension. Here is what the cases look like: + +```cpp +TEST_CASE("flat_map sorts+uniques on construction", "[flat_map]") { + flat_map m{{3,30},{1,10},{3,30},{2,20}}; + std::vector keys; + for (auto& [k,v] : m) keys.push_back(k); + REQUIRE(keys == std::vector{1,2,3}); // invariants 1+2 +} + +TEST_CASE("insert_or_assign overwrites existing", "[flat_map]") { + flat_map m{{1,10}}; + auto [it, ins] = m.insert_or_assign(1, 99); + REQUIRE_FALSE(ins); REQUIRE(it->second == 99); // invariant 4 +} + +TEST_CASE("sorted_unique aborts on lying input", "[flat_map][.death]") { + // Invariant 5: pass unsorted data but swear sorted_unique → debug abort + // flat_map m(sorted_unique, std::vector>{{3,3},{1,1}}); +} +``` + +All three stare at semantic boundaries, not API surface. The construction case checks invariants 1 and 2 at once: `{3,30}` is dropped in twice and out of order, so iteration had better come back as exactly `1,2,3`. Off by one and `sort_and_unique` is wrong. The `insert_or_assign` case is finer; we deliberately contrast `ins` being false against `it->second == 99`, because conflating "inserted a new one" with "overwrote an old one" is the classic mistake. The `sorted_unique` lying case stands on its own because it aborts. + +A case that aborts has a headache attached: drop it into a plain TEST_CASE and the whole binary goes down with it. You have to isolate it as a death test and let it crash in a subprocess. This is the same routine 01-6 uses for the OnceCallback single-consume assertion, and the same one WeakPtr uses for CHECK-on-deref. We already walked it in those two pieces. + +## Performance: per-item overhead + +```text +sizeof(flat_map) ≈ 24 bytes (three pointers) +sizeof(std::map) ≈ 48 bytes (tree root + sentinel + comparator) + +Per-item overhead for a 1M-entry map (the 8MB payload not counted): + flat_map: ~0 extra (data is contiguous) + std::map: ~32MB (32B/element × 1M + 1M malloc calls) +``` + +flat_map carries zero per-item metadata; one contiguous allocation and it's done. std::map lugs 32B of metadata per element plus one heap allocation each. That is where the "constant factor off by an order of magnitude" line in part one comes from. Both are `O(log n)` lookup asymptotically, but std::map quietly books 32MB of metadata plus a million mallocs behind your back. + +## Performance: lookup vs insert + +Just reading `sizeof` and allocation counts isn't satisfying enough, so we ran it on the machine. GCC 16 -O2, the `20_lookup_vs_shift_perf` demo, a 100k-element `map`: + +```text +Lookup, 100k calls (100k elements): + flat_map: 31 ms + std::map: 34 ms (int key + 100k: roughly tied) + +Insert, 1000 calls into a 100k-element container: + flat_map: 2 ms (O(n) shift each time) + std::map: 0 ms (O(log n) node relink) +``` + +One number here surprised us the first time we saw it. On lookup, flat_map does not grind std::map into the dirt; they're roughly tied. Think about it for a second and it makes sense: at 100k entries with an int key, the data itself fits in cache, std::map's pointer chasing hasn't started missing in bulk yet, so flat_map's contiguity dividend has nothing to show. To actually see the gap you have to push N higher, or swap the key for something heavy like `std::string`. In independent large-N runs flat_map coming out several times faster is common, but don't take that as dogma. Small N with a light key, the advantage just isn't there, and that's normal. + +Insert flips the picture, and with no suspense. Every flat_map insert has to `O(n)` shift a slab of elements, 2 ms against std::map's 0 ms, and the bigger N gets the wider that gap opens. It's a real wall. That's why flat_map's contract states it plainly: this is a read-heavy, write-light home, not a container for high-frequency writes. + +## Selection criteria + +| Workload | Recommendation | Reason | +|---|---|---| +| Write once, read many (config table, command dispatch) | flat_map | One-shot write, cache-friendly lookup | +| Always small (~4 elements) | flat_map | Constant factor dominates, zero allocation | +| Large and frequently mutated | std::map | O(n) insert is a wall | +| Needs stable refs/pointers | std::map | flat_map invalidates all iterators | +| Large N + frequent mutation + ordered | absl::btree_map | B-tree middle ground (Chromium bans it for code bloat) | + +This table really boils down to one line: read-heavy or always-small, pick flat_map; write-heavy or needing stable references, pick std::map; large, frequently mutated, and ordered, that's absl::btree_map's middle ground, but Chromium itself blocked that path over code bloat. Chromium's `//base/containers/README.md` just writes this table out in prose. + +## vs std::flat_map (C++23) / absl::btree_map + +Two relatives are unavoidable here. C++23's `std::flat_map` (P0429) and Chromium flat_map share an origin and the same idea, but the standard version went with split storage, keys and values in two separate arrays. Chromium declines the split and sticks with a single `vector>`. We get Chromium's tradeoff: split genuinely saves cache when you iterate only keys or only values, but the implementation complexity climbs, and flat_map's main arena is read-heavy small containers where split's payoff never cashes in. Not worth it. + +The other is `absl::btree_map`, a 256B B-tree node holding a handful of keys, sitting between red-black tree and sorted vector. It's the right fit for large N plus frequent mutation plus ordering. But Chromium bans btree inside `//base`, and the stated reason is code bloat. Each key/value type you instantiate pulls in another fat slab of template, and B-tree node split/merge logic is far heavier than sorted vector's `lower_bound + shift`. This is a textbook engineering tradeoff: a technically better option exists, the project-level cost is too high, so they decline. + +That closes the flat_map design, implementation, and verification pieces. Looking back, it slots in as the third tile in vol9/chrome alongside OnceCallback and WeakPtr. The first two cover "how to keep callbacks in line" and "how to keep lifetimes in line," and this one covers "how to store data both cheaply and fast." All three are the industrial-grade C++ fundamentals you find in Chromium `//base`. + +## References + +- [Chromium `base/containers/README.md` — container selection guide](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/README.md) +- [Catch2 documentation](https://github.com/catchorg/Catch2/tree/devel/docs) +- [P0429 — the std::flat_map proposal](https://wg21.link/p0429) +- [absl::btree_map](https://abseil.io/docs/cpp/guides/btree) +- [flat_map design guide (I): motivation, API, and the flat_tree architecture](./01-flat-map-design.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/index.md b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/index.md new file mode 100644 index 000000000..a83094917 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/03_flat_map/index.md @@ -0,0 +1,31 @@ +# flat_map: ordered-container design, learned from Chromium + +This directory takes apart Chromium's `flat_map` / `flat_tree` and works through the industrial-strength design of implementing an associative container on a sorted vector: why an array beats a tree at small N, the read-heavy write-light home turf, the zero-cost sorted_unique construction, transparent comparators, and EBO. It sits alongside [OnceCallback](../01_once_callback/) and [WeakPtr](../02_weak_ptr/), rounding out the container-and-performance piece of vol9/chrome. + +## Full tutorial (full/) + +Prerequisites (6 chapters): + +- [ordered associative containers, introduced](./full/pre-00-flat-map-ordered-assoc-container-intro.md) +- [vector internals and growth](./full/pre-01-flat-map-vector-internals-and-growth.md) +- [complexity and amortization](./full/pre-02-flat-map-complexity-and-amortized.md) +- [comparators and transparent lookup](./full/pre-03-flat-map-comparator-and-transparent.md) +- [tag dispatch and sorted_unique](./full/pre-04-flat-map-tag-dispatch-and-sorted-unique.md) +- [no_unique_address, EBO, and pair storage](./full/pre-05-flat-map-enua-ebo-and-pair-storage.md) + +Hands-on (6 chapters): + +- [motivation and API design](./full/03-1-flat-map-motivation-and-api-design.md) +- [the flat_tree skeleton](./full/03-2-flat-map-flattree-skeleton.md) +- [lookup and insert](./full/03-3-flat-map-lookup-and-insert.md) +- [sorted_unique construction](./full/03-4-flat-map-sorted-unique-construction.md) +- [iterator invalidation and bulk build](./full/03-5-flat-map-iterator-invalidation-and-bulk-build.md) +- [testing and performance](./full/03-6-flat-map-testing-and-perf.md) + +## Hands-on design guide (hands_on/) + +For readers comfortable with templates and performance: + +- [design](./hands_on/01-flat-map-design.md) +- [implementation](./hands_on/02-flat-map-implementation.md) +- [testing and performance](./hands_on/03-flat-map-testing.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-1-no-destructor-motivation-and-api.md b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-1-no-destructor-motivation-and-api.md new file mode 100644 index 000000000..db2cc4d10 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-1-no-destructor-motivation-and-api.md @@ -0,0 +1,220 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: "Start from the pain of a global config table (Chromium bans global ctors/dtors), pin down the hole NoDestructor fills, and lock down the full target API and its signature decisions" +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- 'NoDestructor prerequisite (0): static storage duration, initialization, and destruction' +- 'NoDestructor prerequisite (1): placement new and aligned storage' +reading_time_minutes: 10 +related: +- 'NoDestructor hands-on (II): the core implementation' +tags: +- host +- cpp-modern +- intermediate +- 内存管理 +- RAII +title: "NoDestructor hands-on (I): motivation and API design" +--- +# NoDestructor hands-on (I): motivation and API design + +In [prerequisite (0)](./pre-00-static-storage-and-init.md) we went over why Chromium bans global constructors and destructors: SIOF, shutdown races, startup latency. The problem is that `//base` is full of places that want a global singleton: a default config table, a feature-flag map, a lazily generated random nonce. The rule is on the books, the work still has to get done, and that's where `base::NoDestructor` comes in. This piece works through the motivation and the API; implementation lands in the next one. + +## Start from a global config table + +Say we have a "default config table" that the whole program needs, with fixed contents: + +```cpp +const std::map& DefaultConfig() { + // how do we implement this global? +} +``` + +The obvious move is to throw out a global variable: + +```cpp +const std::map g_default = LoadDefault(); // ❌ banned in Chromium +``` + +It reads cleanly, but it generates a global constructor before `main` (calling `LoadDefault` and constructing the `std::map`), and a global destructor at exit to tear the map down. Chromium's `-Wglobal-constructors` and `-Wexit-time-destructors` flags reject it outright. What else can we do? + +--- + +## Three obvious paths, and why none of them are enough + +**A plain function-local static** is the recipe from Scott Meyers' book, what the community calls a Meyers singleton: + +```cpp +const std::map<...>& DefaultConfig() { + static const std::map g = LoadDefault(); // magic statics: thread-safe init + return g; +} +``` + +This dodges the global constructor: `g` only gets built on the first call to `DefaultConfig()`, and magic statics handle the thread safety (see [pre-00](./pre-00-static-storage-and-init.md)). What it does not dodge is destruction. When `g` goes out of scope at exit, it still destructs. The `std::map` destructor still gets registered as a global destructor. The construction gate passes; the destruction gate doesn't. + +The nastier part is shutdown races. Say `g` holds a reference to another global (some logger pointer), or the other way around: another global, mid-destruction, calls back into `DefaultConfig()`. By then `g` may already have been destroyed, and you're holding a dangling reference. The program hits undefined behavior. Chromium's shutdown paths are twisty enough that this kind of race has bitten me in production more than once. It genuinely hurts. + +**Hand-rolled placement new with no destructor** means writing `alignas(T) char buf[...]`, placement-new'ing the object on top, and just not calling the destructor. It works. But after we sketched one for expediency and looked back at it, the holes were denser than expected: LSan compatibility (see [04-4]), `static_assert` gating, alignment, lifetime, all on us. Write it twice and you're reinventing the wheel. + +So the three paths are: raw global banned, Meyers singleton has shutdown races, hand-rolled placement new is repetitive and error-prone. NoDestructor is Chromium's official tool: it pulls "the destructor nail" out of the second path, and packages up the boilerplate of the third. + +--- + +## Chromium's answer: NoDestructor + +The design idea behind NoDestructor comes down to two lines. + +First, no destructor. Once the object is constructed, `~T()` is never called again. No destructor means no destruction order, which removes shutdown races at the root. The cost is an "intentional leak"; the OS reclaims the memory when the process exits. For a long-running process like a browser, that leak is rounding error. In embedded work or a short-lived tool you'd have to weigh it yourself. + +Second, pair it with magic statics. NoDestructor is usually wrapped in a function-local static, leaning on C++11 magic statics for thread-safe first-construction. + +Put the two together: `static const NoDestructor x(args...);` on one line gives you a thread-safely-constructed, never-destroyed global singleton. The global-ctor gate is sidestepped by deferring construction through the local static; the global-dtor gate is sidestepped by NoDestructor not destructing. This is exactly the pattern the Chromium style guide blesses. + +### Usage example + +```cpp +#include "base/no_destructor.h" + +const std::string& GetDefaultText() { + static const base::NoDestructor s("Hello world!"); + return *s; +} +``` + +`*s` goes through `operator*` and returns `std::string&`. `s` is a NoDestructor: on the first call it constructs the string (thread-safely), and at program exit it doesn't destruct; the memory goes back to the OS. + +If the init is more involved, dropping a lambda in as an IIFE works well, say for a lazily generated random nonce: + +```cpp +const std::string& GetRandomNonce() { + static const base::NoDestructor nonce([] { + std::string s(16); + FillRandom(s.data(), s.size()); + return s; + }()); + return *nonce; +} +``` + +--- + +## What the target API looks like + +Here's the target API, aligned with Chromium: + +```cpp +namespace tamcpp::chrome { + +template +class NoDestructor { +public: + // construct from arbitrary arguments (forwarded to T's constructor) + template + explicit NoDestructor(Args&&... args); + + // copy/move construct directly from T (handy for initializer_list, etc.) + explicit NoDestructor(const T& x); + explicit NoDestructor(T&& x); + + // non-copyable + NoDestructor(const NoDestructor&) = delete; + NoDestructor& operator=(const NoDestructor&) = delete; + + // destructor: defaulted (the key! does not call ~T()) + ~NoDestructor() = default; + + // use it like a T + const T& operator*() const; + T& operator*(); + const T* operator->() const; + T* operator->(); + const T* get() const; + T* get(); +}; + +} // namespace tamcpp::chrome +``` + +Usage is direct: hold it as a function-local static, and treat `*nd` or `nd->` as a T. + +--- + +## A few signature decisions + +The signature looks simple, but every line is something Chromium nailed down after stepping on a real bug. Let's pull apart the parts worth talking about. + +**Why delete the copy operations.** NoDestructor holds an inline buffer `alignas(T) char storage_[sizeof(T)]`, not a pointer. If we allowed copying, we'd have to deep-copy the T inside `storage_` (placement-new a fresh one), and the semantics get muddy fast. T isn't necessarily trivially copyable: a shallow copy is a byte move, a deep copy has to go through a constructor, and which one are we doing? Rather than let users fall into that trap, just `delete` copy and keep the type in its lane as a "static-variable container," not a value type to pass around. + +**Why not inherit from T, and why not expose the internal T& directly.** Inheritance drags NoDestructor into an is-a relationship with T, but it's really a container, and the semantics are wrong; besides, T might be `final`, and then inheritance doesn't even compile. Exposing the inner T& as a member is just as bad, since it leaks the `storage_` detail. Chromium borrows the smart-pointer idiom: `operator*`, `operator->`, `get()`, so NoDestructor behaves like "a pointer to T." That way `static const NoDestructor s(...)` reads almost as naturally as a `std::string*`. + +**`~NoDestructor() = default`: this is the load-bearing line.** It looks unremarkable, but it's the root of the whole design. `= default` has the compiler generate a destructor that destroys the members, and `storage_` is a char array, trivially destructible, so it does nothing. After `~NoDestructor()` runs, `~T()` is never invoked. T was placed via placement new, so its destructor would have to be called by hand, and here we deliberately don't. The first time we read this it stopped us short: why not `= delete`? The answer is that `= delete` blocks the whole object's lifecycle management; NoDestructor couldn't be used as a member or a base. `= default` lets NoDestructor itself live and die normally, while the T inside plays dead. + +If we wanted to be tedious about it, we could write `~NoDestructor() { reinterpret_cast(storage_)->~T(); }`, and then we'd have a Meyers singleton all over again, with shutdown races back intact and the whole tool pointless. So "don't call `~T()`" isn't an oversight; it's the deliberate core choice. + +**Why not the `[[clang::no_destroy]]` attribute.** Clang does have such an attribute; mark a variable with it and its destructor doesn't run: + +```cpp +[[clang::no_destroy]] static const std::string s = "..."; // no destructor +``` + +We wondered the same thing: if the attribute does the job, why wrap it in a class? After reading Chromium's comments, the accounting makes sense. The attribute is Clang-only; it doesn't port to GCC or MSVC, which kills portability. It also only handles the one job of skipping destruction; it can't add `static_assert` gating or catch misuse on trivial types. Worse, the LSan-compatibility hack (see [04-4]) and the type-safe API facade both need to live inside a class. The attribute is lower-level and lighter, but industrial code is safer with a packaged tool, and easier to debug when something goes wrong. + +--- + +## Where the teaching version diverges from Chromium + +Like the earlier series, the teaching version keeps only the core mechanism: placement new + no destructor + magic statics integration + `static_assert` gating. The industrial-grade extras get stripped: + +| Dimension | Chromium | Teaching version | +|---|---|---| +| Storage and placement new | full | same | +| `~NoDestructor()=default` to skip destruction | full | same | +| static_assert gating | 2 (trivial ctor+dtor / trivial dtor) | same | +| LSan reachability hack | `#ifdef LEAK_SANITIZER` holds storage_ptr_ | omitted or noted (see 04-4) | +| Chromium macro `BASE_EXPORT` | yes | omitted | + +The core mechanism is identical. The LSan-compatibility piece involves sanitizer dark arts, and we pull it out to the 04-4 piece so it doesn't distract from the main line here. + +--- + +## Get the environment ready + +NoDestructor itself only needs C++17 (`alignas` / `std::forward`). Some of the `static_assert`s read more cleanly with C++20's `_v` variable templates, but C++17 works too. We use C++20, matching the earlier series. + +### Compiler requirements + +GCC 11+ or Clang 12+, with `-std=c++20`. + +### Verification code + +```cpp +#include +#include + +// verify alignas + placement new work +struct Foo { int x; }; +alignas(Foo) char buf[sizeof(Foo)]; + +int main() { + new (buf) Foo{42}; // placement new constructs on buf + return reinterpret_cast(buf)->x - 42; // 0, verifies access works +} +``` + +If this builds and runs, the environment is ready. The companion project lives in `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/`; starting from 04-2 we add the `23` through `25` batch of NoDestructor samples. + +--- + +## References + +- [Chromium `base/no_destructor.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) +- [Clang `[[clang::no_destroy]]` attribute](https://clang.llvm.org/docs/AttributeReference.html#no-destroy) +- [isocpp FAQ — Meyers singleton and the shutdown problem](https://isocpp.org/wiki/faq/ctors#construct-on-first-use) +- [NoDestructor prerequisite (0): static storage duration, initialization, and destruction](./pre-00-static-storage-and-init.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-2-no-destructor-core-impl.md b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-2-no-destructor-core-impl.md new file mode 100644 index 000000000..0a9d86721 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-2-no-destructor-core-impl.md @@ -0,0 +1,225 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: "Build NoDestructor's core: an alignas storage_ buffer, placement-new construction, reinterpret_cast access, a =default destructor that skips ~T(), and two static_assert gates" +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- 'NoDestructor hands-on (I): motivation and API design' +- 'NoDestructor Prerequisite (I): placement new and aligned storage' +reading_time_minutes: 10 +related: +- 'NoDestructor hands-on (III): when to use, when not to' +tags: +- host +- cpp-modern +- intermediate +- 内存管理 +- RAII +title: "NoDestructor hands-on (II): the core implementation" +--- +# NoDestructor hands-on (II): the core implementation + +In the [previous piece](./04-1-no-destructor-motivation-and-api.md) we pinned down NoDestructor's target API. Now we build it. The whole thing rests on the two old friends from [prerequisite (I)](./pre-01-placement-new-and-aligned-storage.md): placement new and aligned storage. Stack one deliberate "don't destruct" policy on top, add two `static_assert` gates, and you're done. The class is under 50 lines, but every line earns its keep, and we'll walk through the reasoning for each. It looks short. The sharp edges are not. + +## storage_: an aligned inline buffer + +```cpp +// Platform: host | C++ Standard: C++20 +#pragma once +#include +#include +#include + +namespace tamcpp::chrome { + +template +class NoDestructor { + // detailed below +private: + alignas(T) char storage_[sizeof(T)]; +}; + +} // namespace tamcpp::chrome +``` + +The entire state of the class is this one line: `alignas(T) char storage_[sizeof(T)]` (no_destructor.h:122). First time we read it, we paused. A char array, and that's it? That really is it. `sizeof(T)` bytes, exactly enough to hold one T. The `alignas(T)` out front lifts the array's alignment up to T's requirement; without it, placement new could hand back an address that isn't aligned for T, which is straight undefined behavior. The member is private, so nobody on the outside can touch the raw storage. They have to go through `get()`, `operator*`, and friends below. + +That aligned inline buffer is the whole of NoDestructor's "data." No pointer, no heap allocation, no extra overhead. Everything else piles onto this foundation. + +## Construction: placement new with perfect forwarding + +```cpp +public: + // Generic: perfect-forward any arguments to T's constructor + template + explicit NoDestructor(Args&&... args) { + new (storage_) T(std::forward(args)...); + } + + // Construct from an existing T (for initializer_list and friends) + explicit NoDestructor(const T& x) { new (storage_) T(x); } + explicit NoDestructor(T&& x) { new (storage_) T(std::move(x)); } +``` + +The generic constructor needs no long explanation: `template` plus `std::forward(args)...` is textbook perfect forwarding, passing the arguments untouched to T's constructor. `new (storage_) T(...)` is placement new; it constructs a T in place over the `storage_` memory. No allocation happens. The memory is `storage_` itself. + +The interesting part is those two seemingly redundant `const T&` and `T&&` overloads below. Your first reaction, like ours, is probably: the generic template already covers everything, why spell these out? The trap is `initializer_list`. Some initialization paths construct from an existing T, and the generic template's perfect forwarding can fail to match T's copy or move constructor, or produce ambiguity. Chromium writes these two overloads explicitly to keep `NoDestructor> v({1,2,3})` routing correctly into vector's `initializer_list` constructor. Skip this and the compiler error at the call site will eat an afternoon. + +## Access: the legality of reinterpret_cast + +```cpp + const T& operator*() const { return *get(); } + T& operator*() { return *get(); } + const T* operator->() const { return get(); } + T* operator->() { return get(); } + + const T* get() const { return reinterpret_cast(storage_); } + T* get() { return reinterpret_cast(storage_); } +``` + +That `reinterpret_cast(storage_)` inside `get()` made us nervous the first time. Casting a char array's address to `T*`? Is that even legal? It is (no_destructor.h:118-119). The precondition is that placement new has already constructed a real T object in that memory. After that point the memory is being used as a T object, and a pointer to it cast to `T*` is safe. Put another way, legality rides on the "construct first, access second" order. If you cast without the new first, that's a different story. + +`operator*` and `operator->` both forward to `get()`, smart-pointer style. The const overloads return `const T*` / `const T&`; the non-const ones return a mutable reference. That mirrors `std::unique_ptr`'s interface. In practice `NoDestructor` behaves like a `T*`: + +```cpp +static const NoDestructor s("hi"); +s->size(); // operator-> +(*s)[0]; // operator* +s.get(); // explicit pointer +``` + +## Not destructing: =default hides the trick + +```cpp + NoDestructor(const NoDestructor&) = delete; + NoDestructor& operator=(const NoDestructor&) = delete; + + ~NoDestructor() = default; // <- the key: doesn't call ~T()! +``` + +Copy is deleted; the rationale is in [04-1](./04-1-no-destructor-motivation-and-api.md) and we won't repeat it. The load-bearing line is `~NoDestructor() = default`. The whole design hangs on it. + +First time reading that line, we were confused. `= default` means "let the compiler generate the default destructor," so how does that skip `~T()`? It clicked once we matched it against the member list. The compiler-generated default destructor destroys the **members**, and NoDestructor's only member is `char storage_[sizeof(T)]`. A char array has a trivial destructor that does nothing. The compiler has no reason to treat `storage_` as a T and destruct it: T is something that "grows onto" `storage_` after placement new, and as far as the type system is concerned the two have nothing to do with each other. By the time `~NoDestructor()` finishes, `~T()` has not been called once. T just sits there in memory, alive, until the process exits and the OS reclaims the whole address space as ordinary process memory. + +That is the implementation root of "don't destruct." One sentence: make NoDestructor's destructor see only the char member, never T. + +--- + +## static_assert gates: using the type system to block misuse + +```cpp +private: + static_assert(!(std::is_trivially_constructible_v && + std::is_trivially_destructible_v), + "T is trivially ctble+dtble: use constinit T directly, not NoDestructor"); + + static_assert(!std::is_trivially_destructible_v, + "T is trivially destructible: use a plain function-local static T, not NoDestructor"); + + alignas(T) char storage_[sizeof(T)]; +}; +``` + +These two `static_assert`s (no_destructor.h:85-93) are, in our view, the most considerate touch in the whole design. They lock NoDestructor onto the case where it belongs: **only non-trivially-destructible T**. Let's take the cases one at a time. + +First case. T is trivially constructible and trivially destructible. Think `int`, or a POD struct. You just write `static constexpr T x = ...;` or `constinit T x;` and move on; NoDestructor is not needed. If you force it on anyway, the first assertion stops you at compile time. + +Second case. T is trivially destructible but non-trivially constructible, say some class with a non-trivial constructor. This isn't NoDestructor's business either. A function-local `static T x;` is enough; the destructor is trivial, so it generates no global destructor. The second assertion catches you here. + +Third case is where NoDestructor actually earns its keep. T is non-trivially destructible: `std::string`, `std::vector`, `std::map`. Neither assertion fires, and the tool fits. + +What we appreciate about this design is that the assertions don't just block, they tell you how to fix it in the error message. The day you slip and write `NoDestructor`, the compiler hands you a clear instruction at compile time, instead of leaving a latent issue to surface in production six months later. + +--- + +## Full implementation: five layers, run end to end + +Stitched together, the header looks like this: + +```cpp +// no_destructor.hpp +#pragma once +#include +#include +#include + +namespace tamcpp::chrome { + +template +class NoDestructor { +public: + template + explicit NoDestructor(Args&&... args) { + new (storage_) T(std::forward(args)...); + } + explicit NoDestructor(const T& x) { new (storage_) T(x); } + explicit NoDestructor(T&& x) { new (storage_) T(std::move(x)); } + + NoDestructor(const NoDestructor&) = delete; + NoDestructor& operator=(const NoDestructor&) = delete; + ~NoDestructor() = default; + + const T& operator*() const { return *get(); } + T& operator*() { return *get(); } + const T* operator->() const { return get(); } + T* operator->() { return get(); } + const T* get() const { return reinterpret_cast(storage_); } + T* get() { return reinterpret_cast(storage_); } + +private: + static_assert(!(std::is_trivially_constructible_v && + std::is_trivially_destructible_v), + "T trivially ctble+dtble: use constinit T directly"); + static_assert(!std::is_trivially_destructible_v, + "T trivially destructible: use plain function-local static T"); + + alignas(T) char storage_[sizeof(T)]; +}; + +} // namespace tamcpp::chrome +``` + +Here is a small verification program. Let's run it and see what comes out: + +```cpp +#include +#include +#include "no_destructor.hpp" + +struct Noisy { + Noisy(int x) : v(x) { std::puts("Noisy()"); } + ~Noisy() { std::puts("~Noisy()"); } // never printed + int v; +}; + +const std::string& DefaultName() { + static const tamcpp::chrome::NoDestructor s("chromium"); + return *s; +} + +int main() { + std::cout << DefaultName() << "\n"; // chromium + static const tamcpp::chrome::NoDestructor n(42); + std::cout << n->v << "\n"; // 42 + // Program exit: ~NoDestructor runs (trivial); ~string and ~Noisy do not + return 0; +} +``` + +The terminal prints `chromium` and `42`, and **`~Noisy()` never shows up**. The non-destruction is working. + +That wraps the implementation. Five layers, recapped: the aligned `storage_` buffer; placement new with perfect forwarding; `reinterpret_cast` access through `get` / `operator*` / `operator->`; the `= default` destructor that skips `~T()` and destroys only the char member; and two `static_assert` gates that keep the tool aimed at non-trivially-destructible T. Under 50 lines, and we've been through the reasoning for each. + +Implementation is one thing. Where it actually bites is a different question: when you should reach for it, and when you absolutely should not. That's the rich ground for NoDestructor misuse, and it's what the next piece breaks down. + +## References + +- [Chromium `base/no_destructor.h` full source](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) +- [cppreference: placement new](https://en.cppreference.com/w/cpp/language/new#Placement_new) +- [cppreference: is_trivially_destructible](https://en.cppreference.com/w/cpp/types/is_destructible) +- [NoDestructor hands-on (I): motivation and API design](./04-1-no-destructor-motivation-and-api.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-3-no-destructor-when-to-use.md b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-3-no-destructor-when-to-use.md new file mode 100644 index 000000000..3a7ef9429 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-3-no-destructor-when-to-use.md @@ -0,0 +1,154 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: "Pinning down where NoDestructor actually belongs: use it for function-local statics with non-trivially-destructible T, and leave it alone for locals/fields, trivially-destructible T, trivially-constructible T, and rarely-used data. Includes why the constinit global path does not compile, and the magic-statics recap. Decision table at the end." +difficulty: intermediate +order: 3 +platform: host +prerequisites: +- 'NoDestructor hands-on (II): the core implementation' +- 'NoDestructor prerequisite (0): static storage duration, initialization, and destruction' +reading_time_minutes: 9 +related: +- 'NoDestructor hands-on (IV): the LSan leak tradeoff and the reachability hack' +tags: +- host +- cpp-modern +- intermediate +- 内存管理 +- RAII +title: "NoDestructor hands-on (III): when to use it, and when not to" +--- +# NoDestructor hands-on (III): when to use it, and when not to + +NoDestructor is a sharp knife. In the right place it saves you trouble; in the wrong place it plants a mine. Real leaks, wasted memory, bugs hidden for months, any of those can come out of a misuse. Chromium itself spends a whole "Caveats" section in the source comments (no_destructor.h:15-46) listing the traps you must not step on. We read that section three times over, and the boundary turns out to collapse to one sentence: **the only recommended pattern is a function-local static, and only when T is non-trivially destructible**. This piece pulls apart what sits inside and outside that line. + +## Where it belongs: function-local static + non-trivially-destructible T + +The canonical usage looks like this, and we suggest you commit it to memory as the template: + +```cpp +const T& GetGlobal() { + static const base::NoDestructor x(args...); // ✓ function-local static + return *x; +} +``` + +Three conditions have to hold at once before NoDestructor earns a place. First, T has to be non-trivially destructible, the `std::string`, `std::vector`, `std::map` family, where a raw function-local static would generate a global destructor and land squarely on the target NoDestructor exists to eliminate. We have stepped on a counterexample here: `std::mutex` looks non-trivial, but in libstdc++/libc++ it is actually trivially destructible and does not need NoDestructor at all. We wrapped it once on autopilot and only caught it in review. Second, it has to live inside a function-local static, leaning on magic statics for thread-safe first construction; more on that below. Third, the object has to be needed for the whole program lifetime, not just a one-off. Drop any one of the three and NoDestructor goes from helpful to redundant, or actively harmful. + +## Where to leave it alone + +The misuses are listed point by point in the Chromium comments. We reordered them by how hard they are to spot, from the most obvious down to the easiest to miss, and walk through each. + +**The most obvious and the most lethal: using it to hold a local variable or a member.** This shape should make you frown on instinct: + +```cpp +void f() { + base::NoDestructor s("temp"); // ❌ real leak! + // after f returns, s never destructs; the string's heap allocation is never freed (until exit) +} +``` + +The entire premise of NoDestructor is "do not destruct", and that premise assumes the object was supposed to live until exit anyway. Wrap it around a local or a member and the object was supposed to die early, except NoDestructor pins the destructor shut and it never runs, so the memory really does leak. This is not the harmless "reclaimed at exit" kind of leak. It is the "was supposed to be reclaimed and was not" kind. The source comments (no_destructor.h:18-20) are blunt about it: **Must not be used for locals or fields**. + +**One step down: trivially-destructible T.** Here NoDestructor is just dead weight, you don't need it at all: + +```cpp +static const base::NoDestructor x(42); // ❌ int is trivially destructible, no NoDestructor needed +``` + +If T is trivially destructible (say `int`, `double`, a POD struct), its function-local static never produces a global destructor in the first place, so a raw static is enough. NoDestructor's `static_assert` will turn you away (the second assertion), but more important than the compile error is the realization that this thing had no job here. Write it clean: + +```cpp +static const int x = 42; // ✓ trivially destructible, no global destructor +``` + +**Sneakier: T that is both trivially constructible and trivially destructible.** Even a function-local static is overkill here; reach for `constinit`: + +```cpp +static const base::NoDestructor seed(GetRand()); // ❌ +``` + +A `uint64_t` that could be a `constinit` global constant initializer has no business inside a NoDestructor; that is just adding ceremony. The positive example in the source comments (no_destructor.h:33-44) is this: + +```cpp +const uint64_t GetUnstableSessionSeed() { + static const uint64_t kSessionSeed = base::RandUint64(); // ✓ trivially destructible, no NoDestructor needed + return kSessionSeed; +} +// or better: constinit uint64_t g_seed = constexpr_value; +``` + +**The easiest to miss: don't cache rarely-used data behind NoDestructor.** This one never errors and never leaks, it just quietly eats memory: + +```cpp +const BigTable& GetRareTable() { + static const base::NoDestructor t(BuildBigTable()); // ⚠ careful + return *t; +} +``` + +If this table only gets touched once or twice across the whole run, caching it behind NoDestructor is a waste: the compiler reserves space for `BigTable` in bss, and it sits there for the entire run even if you use it a single time. The source comments (no_destructor.h:28-31) say it plainly: create rarely used data on demand, do not cache it. Rewrite it so it destructs once used, and stops holding memory long term: + +```cpp +// create on demand (destructs after use, no long-lived memory) +BigTable GetRareTable() { return BuildBigTable(); } // return by value, transient use +``` + +--- + +## The constinit global path: it does not work + +The canonical NoDestructor pattern is the function-local static. Can we make it a global instead? The source comments (no_destructor.h:22-26) drop one line about "a T that is constinit-constructible can be global, but must be marked constinit." We went and tried it, hit a wall, and **confirmed it does not compile**: + +```cpp +constinit const base::NoDestructor g_data(args...); // ⚠ compile failure +``` + +The reason is buried in the constructor: NoDestructor internally calls placement new (`new (storage_) T(...)`), and placement new is not `constexpr`. So NoDestructor's constructor is explicitly marked non-constexpr (the real header at no_destructor.h:95 spells it out: "Not constexpr"). `constinit` demands a constant-expression initializer, that check fails, and the compiler hands you back `constinit variable does not have a constant initializer`. We also flipped through the Chromium unittests; **there is not a single constinit global NoDestructor in there**, it is function-local statics the whole way. + +So the two things actually worth remembering from this section: if T is constinit-constructible, just write `constinit T g(...)`, do not wrap it in NoDestructor. Constant initialization is done at compile time and the runtime stays clean. If T is not constinit-constructible but is non-trivially destructible, that is the only practical stage where function-local static `NoDestructor` earns its spot. The source comment line about "mark it constinit and go global" reads more like an ideal or a historical leftover; the current implementation (construction through placement new) does not support it. + +--- + +## A magic-statics recap: thread safety comes from that, not from NoDestructor + +One thing needs saying out loud: NoDestructor itself **does not lock anything**, and it has nothing to say about thread safety. It survives concurrency entirely thanks to C++11 magic statics, the initialization of function-local static variables, where the standard carries the thread-safety load for you (details in [pre-00](./pre-00-static-storage-and-init.md)). + +```cpp +const T& GetGlobal() { + static const base::NoDestructor x(args...); // magic statics guarantees x is constructed once, thread-safe + return *x; +} +``` + +There is a causal chain worth tracing here: the moment you step outside the function-local static, say by hanging NoDestructor off a member or making a non-constinit global, the magic-statics protection walks off with it, and multiple threads touching an uninitialized state is a race waiting to happen. So "wrap it in a function-local static" is doing two jobs at once. It dodges the global constructor, and it hands the thread-safety burden to magic statics. Stack those two together and the canonical NoDestructor usage gets pinned down to the single function-local-static shape. + +--- + +## Decision table + +Pulling the scattered judgments above into one table, so the next time you're unsure you can just match the case: + +| Case | Use | Reason | +|---|---|---| +| Global/static + non-trivially-destructible T + needed program-wide | **`static const NoDestructor`** (function-local static) | Skips ctor/dtor + magic-statics thread safety | +| Trivially-destructible T | Raw `static T x = ...;` | No global destructor, no NoDestructor needed | +| Trivially-constructible + trivially-destructible T | `constinit T x = ...;` or `constexpr` | Compile-time init, no runtime code | +| Local variable / member | Plain `T x(...)` or `unique_ptr` | NoDestructor causes a real leak | +| Rarely used data | On-demand function (return by value) | NoDestructor caching wastes memory | +| You need T's destructor side effects (flush/notify) | Function-local `static T x;` (accept the destructor) | NoDestructor skips the destructor side effects | + +That clears the usage boundary for NoDestructor. But it leaves behind a counterintuitive tail: it deliberately keeps objects from destructing, and to LeakSanitizer that reads as one big leak. How do the two coexist, and how does Chromium's reachability hack paper over it? That is what the next piece unpacks. + +--- + +## References + +- [Chromium `base/no_destructor.h`, Caveats section](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) +- [cppreference: constinit (C++20)](https://en.cppreference.com/w/cpp/language/constinit) +- [cppreference: magic statics (thread-safe initialization of variables)](https://en.cppreference.com/w/cpp/language/storage_duration#Static_local_variables) +- [NoDestructor prerequisite (0): static storage duration, initialization, and destruction](./pre-00-static-storage-and-init.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-4-no-destructor-lsan-and-leak.md b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-4-no-destructor-lsan-and-leak.md new file mode 100644 index 000000000..469d514ef --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-4-no-destructor-lsan-and-leak.md @@ -0,0 +1,105 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: "Work through NoDestructor's deliberate-leak tradeoff: LeakSanitizer false-positives it trips, and the storage_ptr_ reachability hack Chromium uses to stay LSan-clean (crbug/40562930)" +difficulty: intermediate +order: 4 +platform: host +prerequisites: +- 'NoDestructor hands-on (II): the core implementation' +- 'NoDestructor hands-on (III): when to use it, and when not to' +reading_time_minutes: 8 +related: +- 'NoDestructor hands-on (I): motivation and API design' +tags: +- host +- cpp-modern +- intermediate +- 内存管理 +- 内存安全 +title: "NoDestructor hands-on (IV): the LSan leak tradeoff and the reachability hack" +--- +# NoDestructor hands-on (IV): the LSan leak tradeoff and the reachability hack + +In [04-2](./04-2-no-destructor-core-impl.md) we broke NoDestructor's core down into "placement new + never call `~T()`." Writing that, something kept nagging at us. The heap resources T holds, the elements a `NoDestructor>`'s vector allocates on the heap, never get released once `~T()` is skipped. They "leak," all the way to process exit. For a global singleton that lives until the program ends, that's fine: the process is gone, the OS reclaims the whole process's memory. The problem is that LeakSanitizer (LSan) sees it as a genuine memory leak and sounds the alarm. + +That pushes us into a tradeoff. Let's lay the cost out first. + +T's destructor normally does two things. One, hand back the resources T itself holds, the vector's heap memory, a file's `close`, that kind of thing. Two, run side effects that matter to the outside world, flushing logs to disk, notifying another process. NoDestructor skips `~T()`, and both stop happening. The first one doesn't matter, the OS covers it. The second is the one to watch: wrap a type whose destructor has to flush logs, and the data is gone. So a fairly hard line: NoDestructor suits types whose destructor just returns resources; it doesn't suit types whose destructor has external side effects. + +What that cost buys, in our view, is the thing most worth having: no destruction-order problem. Shutdown races, in a program the size of Chromium where the shutdown paths twist like spaghetti, are something anyone who's been bitten by them knows about. Skipping the whole category makes the "we don't free those resources" tradeoff easy to accept. As for the LSan alarm, we'll deal with that next. + +--- + +## How LeakSanitizer works + +To see why it fires, we first have to look at how LSan decides something leaked. It's the leak specialist in the AddressSanitizer family, and the technique is **reachability analysis**. The name sounds intimidating; the idea is plain. At program exit it walks every "root," roots being global variables, stack slots, and registers holding pointers. From those roots it follows pointers along, and any heap memory it can reach this way counts as reachable. The heap blocks no live pointer can find are leaks. + +Note LSan doesn't care at all whether this memory was "supposed to be destructed." It checks one thing: is anyone still pointing at this block. If nobody is, it's a leak, even when you know full well it's the harmless "intentional leak, OS covers it" case. + +--- + +## NoDestructor through LSan's eyes + +Take the plainest code: + +```cpp +static const base::NoDestructor> v({1, 2, 3}); +``` + +Inside `v` is `alignas(vector) char storage_[sizeof(vector)]`, a char array, with the vector placed on top via placement new. The vector itself allocates the `{1,2,3}` storage on the heap. The layout is fine, and it looks fine to a human. + +When LSan scans roots it does see `v`'s `storage_`. The catch is that `storage_`'s type is a **char array**. LSan doesn't treat it as "a pointer to the vector's heap memory" and follow it; to LSan it's a blob of raw bytes, even when some of those bytes happen to be the vector's internal pointer. That's where the chain breaks. The `{1,2,3}` the vector allocated on the heap can't be reached from LSan's root set. Reachability analysis hits a dead end here, and the block is judged a leak. + +This is the bug [crbug.com/40562930](https://crbug.com/40562930) records: `NoDestructor>` gets falsely reported as a leak under an LSan build. "Falsely" is doing some work in that sentence. LSan isn't wrong by its own rules. Its rule is reachability, and reachability really is broken here. It's only from the engineering angle that we know this is a harmless, intentional leak. + +--- + +## The reachability hack: storage_ptr_ + +Chromium's workaround, the first time we read it, stopped us cold. We genuinely did not see this coming (no_destructor.h:132-142): + +```cpp +#if defined(LEAK_SANITIZER) + // TODO(crbug.com/40562930): This is a hack to work around the fact + // that LSan doesn't seem to treat NoDestructor as a root for reachability + // analysis. ... + // hold an explicit pointer to the placement-new'd object in leak sanitizer + // mode to help LSan realize that objects allocated by the contained type + // are still reachable. + T* storage_ptr_ = reinterpret_cast(storage_); +#endif +``` + +Under an LSan build it slips an extra `T* storage_ptr_` member in, pointing at the T object placed via placement new. Plainly put, this address is the same one as `storage_`; only the type changes, from `char*` to `T*`. + +Swap that one type and the problem is gone. Where did we get stuck before? On LSan treating `storage_` as raw bytes and not recognizing the pointer inside it. But `storage_ptr_`'s type is **`T*`**, and that makes LSan treat it as a proper "pointer-to-T root." From it, LSan reaches the T object, then follows the T object's pointers all the way to the vector's heap `{1,2,3}`. The whole reachability chain is connected again, the memory goes back to "reachable," and the alarm goes quiet. + +The move, stated plainly: the pointer hiding inside the char array is something LSan can't read, so we also hand it a `T*` it can read, an explicit one. We "re-expose" the placement-new'd object in LSan's view. The address didn't change, the object didn't change, the only thing that changed is whether LSan can recognize this root. + +One more detail worth flagging: this member only exists when `LEAK_SANITIZER` is defined. A normal build doesn't have it at all, zero overhead. Standard conditional-compilation practice; you pay this small cost only in the builds that need it, the test/debug builds with LSan on. + +--- + +## The teaching version's simplification + +Our teaching version drops the LSan hack (implementation in [04-2](./04-2-no-destructor-core-impl.md)). The reasons we worked through came down to a few. + +It only has any effect in an LSan build; a normal compile never touches it. And it's fundamentally an engineering detail about "making a tool not false-report," not a part of NoDestructor's core mechanism. The core is still the placement-new-plus-no-destructor setup; this hack is just a peripheral patch. More practically, if your project genuinely needs to run clean under LSan, you don't necessarily have to change the code: LSan supports suppression files (say `lsan_suppressions.txt`), and listing the NoDestructor leak in there to be explicitly ignored gets you the same result. + +Of course, if you'd rather have the code report zero alarms under an LSan build and not maintain a suppression file, there's nothing wrong with following Chromium and adding the line `#if defined(LEAK_SANITIZER) T* storage_ptr_; #endif`. It's just conditional compilation; whether you add it or not doesn't change NoDestructor's semantics. It's purely for LSan to read. + +--- + +With this, the NoDestructor thread, motivation, implementation, when to use it, LSan compatibility, is complete. Looking back at the whole thing is genuinely interesting: with just placement new plus no destructor, you trade for a fairly solid piece of a solution to the long-standing C++ pain of "global/static objects have no destruction-order problem," and you patch the LSan reachability hole along the way with a single `T*`. In the vol9/chrome series it sits alongside OnceCallback (callbacks), WeakPtr (weak references), and flat_map (containers), making up the four pieces of Chromium's `//base` infrastructure: callbacks, weak references, containers, static lifetime. Each minds its own shop. + +## References + +- [Chromium `base/no_destructor.h`: the LSan hack comment (no_destructor.h:132-142)](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) +- [crbug.com/40562930: NoDestructor LSan false positive](https://crbug.com/40562930) +- [LeakSanitizer documentation](https://clang.llvm.org/docs/LeakSanitizer.html) +- [AddressSanitizer family](https://clang.llvm.org/docs/AddressSanitizer.html) +- [NoDestructor hands-on (II): the core implementation](./04-2-no-destructor-core-impl.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-00-static-storage-and-init.md b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-00-static-storage-and-init.md new file mode 100644 index 000000000..2d3df9a88 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-00-static-storage-and-init.md @@ -0,0 +1,158 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: "Static storage duration, the three phases of static initialization, the Static Initialization Order Fiasco (SIOF), destruction-order problems, magic statics, and constinit. The groundwork for NoDestructor." +difficulty: intermediate +order: 0 +platform: host +prerequisites: +- WeakPtr prerequisite (0): weak references and the lifetime puzzle +reading_time_minutes: 11 +related: +- 'NoDestructor hands-on (I): motivation and API design' +- 'NoDestructor prerequisite (I): placement new and aligned storage' +tags: +- host +- cpp-modern +- intermediate +- 内存管理 +- RAII +title: "NoDestructor prerequisite (0): static storage duration, initialization, and destruction" +--- +# NoDestructor prerequisite (0): static storage duration, initialization, and destruction + +You write `std::map global_table = load();` at file scope and probably think nothing of it. The map gets constructed at startup, destructed at exit, end of story. Yet Chromium's `//base` style guide flatly bans global constructors and destructors. The first time we hit that rule we stopped cold: such a natural thing to write, why on earth is it forbidden? + +The answer only shows up once you go read the standard. Behind that one ban sits a whole chain of old C++ traps: static-storage objects have a weird three-phase initialization, the order of initialization across translation units is completely out of your hands (that's SIOF), and destruction order is just as out of control (shutdown races). This piece drags all of that foundation out into the light and walks through each piece. Once these are clear, why Chromium issues this ban and what the next few `NoDestructor` articles are actually solving falls into place on its own. + +--- + +## Static storage duration + +C++ groups objects by **storage duration**, and storage duration decides when an object is born and when it dies. The three we deal with every day: automatic storage duration for locals (created on function entry, destroyed on function exit, the usual stack citizen); dynamic storage duration for `new`'d objects (you manage them by hand, creation and destruction happen on your say-so); and the protagonist of this piece, static storage duration, which covers both globals and `static` variables. These are created at program startup and destroyed at program exit, living exactly as long as the program itself. + +`NoDestructor` exists to serve that last category. When you write `static std::string s = "...";` or a global `std::map g_table;`, the `std::string` or `std::map` inside has static storage duration and lives as long as the program. The catch is that static-storage objects play by their own initialization and destruction rules, different from ordinary locals, and every problem we're about to talk about grows out of those rules. + +--- + +## The three phases of static initialization + +The standard splits the initialization of a static-storage object into three phases. The first two are the well-behaved ones, they don't make trouble. + +Phase one is zero initialization: the memory gets wiped to all zeros. For builtin types, and for classes with zero-initialization semantics, this is where initialization ends. Phase two is constant initialization: if the initializer is a compile-time constant (a `constexpr`, say), the compiler resolves it during compilation. Both of these count as "static initialization" and produce no runtime code. Together they're "the static part": done at compile time, zero cost. + +The real trouble is phase three, dynamic initialization. When the initializer has to be computed to be known, `static std::string s = "x";` (which has to call `std::string`'s constructor) or `static int n = rand();` (which has to call `rand()` at runtime), the compiler has to defer the work to runtime. This is "the dynamic part" and it costs real cycles. Every pitfall we cover below has its roots here. + +```cpp +// Static initialization (compile time, no cost): +constexpr int kMax = 100; // constant initialization +static int zero; // zero initialization + +// Dynamic initialization (runtime, code has to run): +std::string g_name = "chromium"; // calls std::string's constructor +static int g_seed = rand(); // calls rand() +std::map g_table; // calls std::map's constructor +``` + +For every global or static that goes through dynamic initialization, the compiler emits a tiny "call its constructor at program startup" stub and drops it into the `.init_array` section. That stub has a name: the global constructor, and the runtime walks every entry before `main` even starts. + +--- + +## The Static Initialization Order Fiasco (SIOF) + +Inside one translation unit (one .cpp), global constructors run in declaration order. That much you control. The moment you cross translation units, the order becomes unspecified: the compiler is free to line them up however it likes. + +That single fact is what surfaces the oldest C++ trap in the book, the Static Initialization Order Fiasco, SIOF for short. A plain example: + +```cpp +// a.cpp +extern int b_value; +int a_value = b_value + 1; // dynamic init, depends on b_value + +// b.cpp +int b_value = std::rand(); // dynamic init (rand isn't constexpr, evaluated at runtime) +``` + +The first time we saw this we didn't think much of it. Run it, and the trap shows itself. If `a.cpp`'s `a_value` gets initialized first, it reads `b_value` before `b_value` has had its turn, and at that point `b_value` only has its zero-initialized 0. So `a_value` comes out as 1, not whatever `b_value` was actually supposed to evaluate to. One nuance is worth memorizing: only dynamic initialization is exposed to SIOF. Constant initialization (something like `int b = 42;`) is finished at compile time and always precedes any dynamic initialization, so it's immune. But once two cross-.cpp globals depend on each other and both are dynamically initialized, the order is out of your hands and the result is undefined behavior. The thing that makes this bug so miserable is that it barely reproduces. Change machines, change compiler flags, and the order shifts. Runs clean on your laptop, flakes out on CI. + +The standard-blessed workaround is "construct on first use": tuck that global inside a function as a local static, so its initialization only happens the first time the function is called. + +```cpp +int& a_value() { + static int v = b_value() + 1; // function-local static, initialized on first call + return v; +} +int& b_value() { + static int v = std::rand(); // b_value also becomes a function-local static + return v; +} +``` + +Rewritten this way, the first time `a_value()` is called it goes and calls `b_value()` itself, and that call is what triggers `b_value()`'s construction. The order is now decided by the code you wrote, not by the compiler. This is the fundamental reason NoDestructor recommends function-local statics: it sidesteps SIOF at the root. + +--- + +## Destruction order (the shutdown race) + +Initialization has its order mess; destruction has one too. Static-storage objects get destructed when the program exits (after `main` returns, during `exit`), in the reverse of initialization order. It sounds elegant. The problem hides inside "reverse": if initialization order was never under your control, reverse order isn't either. + +A common shape: some global object's destructor happens to depend on another global that's already been destroyed. A global logger holds a reference to a global string; at shutdown the string destructs first, the logger's destructor then touches the dead reference, instant UAF. Worse landmines live inside destructors themselves. Calling `exit` from inside one skips the destruction of every remaining static object, and is UB under cross-thread or nested calls. Throwing from one during stack unwinding trips `std::terminate`. Every step of the shutdown path can cost you a week. + +Collectively this is the shutdown race. Chromium is a browser, so its shutdown path is messy to begin with: multiple processes, multiple threads, task queues possibly still draining. Global-object destruction races are repeat offenders in its bug tracker. + +Chromium's answer is blunt: don't let global objects destruct at all. That's the core idea behind `NoDestructor`. The object lives as long as the program, but at program exit nothing destructs it. The cost is that the OS reclaims the memory when the process exits, which the OS was going to do anyway, so it's free. Trading one manual "skip the destructor" for the entire class of destruction-order problems is a deal Chromium is happy to take. + +--- + +## Magic statics: the C++11 thread-safety guarantee + +That "function-local static" workaround rests on a premise you might not have scrutinized: if several threads hit the function for the first time at once, the initialization has to be safe, right? Before C++11 this wasn't actually guaranteed. From C++11 on, the standard backs it up directly. It's known as magic statics. Paraphrased, the standard says roughly: + +> If control flow passes concurrently through the declaration of an uninitialized function-local static, other threads **wait** for the in-flight initialization to finish. + +In code, that means this pattern is safe to use: + +```cpp +const std::string& GetDefault() { + static const std::string s = "default"; // thread-safe: concurrent first calls still initialize exactly once + return s; +} +``` + +Any number of threads can pile into `GetDefault()` and `s` is constructed exactly once, with no data race in between. That's a black-and-white guarantee from C++11 (GCC and Clang implement it underneath via `__cxa_guard_acquire`). One thing we want to flag here: NoDestructor can sit there calmly as a singleton precisely because it stands on magic statics. It doesn't add any lock of its own, the language does the work underneath. Get that straight and the NoDestructor implementation later won't trip you up with "why doesn't it lock?" + +--- + +## constinit (C++20): guaranteeing zero-cost initialization + +C++20 hands us a new tool: `constinit`. What it does fits in one sentence. It promises both you and the compiler that this variable's initialization will be constant initialization, finished at compile time, and that **no dynamic initialization code will ever be generated** for it. + +```cpp +constinit int x = 42; // OK: constant initialization +constinit int y = compute(); // compile error: compute() isn't a constant expression → rejected +``` + +We think the keyword is designed cleanly. It isn't advice, it's an assertion: if you can write it, you pass; if the initializer isn't constant, the compiler rejects it on the spot instead of leaving a grenade for runtime. Its value is exactly "force this global to not emit a global constructor." For a constinit-constructible type (a POD with a `constexpr` constructor, say), you can write `constinit T x` and have it both ways: you skip the global constructor and you keep using the bare type, no need to bother NoDestructor at all. That's precisely the "trivial case" in NoDestructor's static_assert recommendations: if T is trivially constructible and trivially destructible, just use constinit directly, and wrapping it in NoDestructor on top would be pointless. + +--- + +## Why Chromium bans global ctors/dtors + +Stack those pieces up and Chromium's ban on global constructors and destructors stops looking arbitrary. The most direct reason is startup performance: before `main`, the runtime has to walk the entire `.init_array`, and in a large project with thousands upon thousands of globals queued up, the startup delay is visible. Add SIOF and destruction races on top (the cross-translation-unit old traps), plus the fact that a browser's shutdown path is complex to begin with (multi-process, multi-thread tangled together), and destruction races in particular are vicious. Three reasons combined, and Chromium just cuts the whole thing off. + +A ban alone is useless without a way to make people obey it. Chromium reaches for clang's `-Wglobal-constructors` and `-Wexit-time-destructors` warnings and locks them down with `-Werror`. Write a global that would emit a global ctor or dtor, and the build fails on the spot, no negotiation. `NoDestructor` is the official escape hatch Chromium shipped alongside the rule, purpose-built to slip past it. + +Use a function-local static to dodge the global constructor (construct on first use, with magic statics guaranteeing thread safety); use `NoDestructor` to dodge the global destructor (don't register one at all). With both "dodges" in hand, you keep the rule and still get a globally visible object. + +The pieces are on the bench. Next up is how NoDestructor turns those two dodges into actual code, and the mechanism it leans on is placement new with aligned storage. + +## References + +- [cppreference: storage duration](https://en.cppreference.com/w/cpp/language/storage_duration) +- [cppreference: static initialization](https://en.cppreference.com/w/cpp/language/initialization) +- [cppreference: constinit (C++20)](https://en.cppreference.com/w/cpp/language/constinit) +- [SIOF, explained (isocpp FAQ)](https://isocpp.org/wiki/faq/ctors#static-init-order) +- [Chromium `base/no_destructor.h` design notes](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) diff --git a/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-01-placement-new-and-aligned-storage.md b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-01-placement-new-and-aligned-storage.md new file mode 100644 index 000000000..cca88232c --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-01-placement-new-and-aligned-storage.md @@ -0,0 +1,171 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: "Breaking down placement new (construct without allocating) and aligned + storage (alignas/alignof), and how NoDestructor uses char storage_[sizeof(T)] plus + reinterpret_cast to manage object lifetime by hand." +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- 'NoDestructor prerequisite (0): static storage duration, initialization, and destruction' +reading_time_minutes: 9 +related: +- 'NoDestructor hands-on (II): the core implementation' +tags: +- host +- cpp-modern +- intermediate +- 内存管理 +- RAII +title: "NoDestructor Prerequisite (I): placement new and aligned storage" +--- +# NoDestructor Prerequisite (I): placement new and aligned storage + +In [pre-00](./pre-00-static-storage-and-init.md) we said NoDestructor "constructs a `T` but never lets it destruct." Sounds cryptic, but on the page it rests on two plain mechanisms: placement new (construct an object at an address you already own, with no allocation), and a `char` buffer aligned for `T` to live in. This piece takes both apart. They are the backbone of NoDestructor, and they are also the everyday tools for any manual lifetime management in C++. You will meet them again the moment you write a memory pool or a container. + +--- + +## Plain `new` vs placement new + +An ordinary `new T(args)` is two steps folded into one. First `operator new(sizeof(T))` carves a `sizeof(T)` block out of the heap, then the constructor of `T` runs on that block. `delete ptr` reverses it: destruct first, then `operator delete(ptr)` hands the memory back. `new` bundles "allocate" and "construct" together, and most of the time that is exactly what you want. But sometimes you already hold a block of memory (a stack array, a pool, an mmap region) and you only want the constructor to run on it, not to fetch fresh memory. That is the problem placement new solves. + +### placement new: construct only, no allocation + +The syntax just tacks `(addr)` onto `new` to say where to build: `new (addr) T(args)`. + +```cpp +#include // placement new needs this + +alignas(int) unsigned char buf[sizeof(int)]; // existing memory (a char array) +int* p = new (buf) int(42); // construct an int in buf, no allocation! +*p == 42; +``` + +It does only the "construct" step, calling `T`'s constructor on the `addr` you passed. `operator new` never wakes up. The memory is yours, and so is the lifetime. + +Destruction is on you now. You call the destructor by hand, `p->~T()`. Never write `delete p` here: `delete` would also try to release memory, and this block was never heap-allocated, so freeing it blows up. + +```cpp +using I = int; +I* p = new (buf) I(42); +p->~I(); // manual destructor (pointless for a trivial type like int, but this is the mechanism) +// buf itself is a stack array; it gets reclaimed automatically, outside placement new's concern +``` + +(Small compiler snag: for a bare built-in type name, the pseudo-destructor call needs a typedef alias. `p->~int()` is rejected by the mainstream compilers; you write `using I=int; p->~I();` instead. The first time I hit that, I stared at it for a while.) + +Here is where placement new really earns its keep. It splits "when does the object live and die" from "whose memory is this, and when does it go back." You can construct on stack memory, on a pool, on a shared segment, on an mmap block, whenever you like, and destruct with one manual call. Almost every manual-lifetime trick in C++ starts from this one line. + +--- + +## Alignment: alignof and alignas + +Placement new comes with a precondition: the address you hand it must satisfy `T`'s alignment requirement. Alignment means "the object's address has to be a multiple of some value." CPUs reach aligned addresses faster, and on some architectures an unaligned access is not slow but flat-out illegal, raising a hardware exception. + +Two keywords split the job. `alignof(T)` asks what `T`'s alignment requirement is in bytes: `alignof(int)` is usually 4, `alignof(double)` is 8. `alignas(N)` goes the other way, letting you impose an alignment on a variable or type: `alignas(16) int x;` forces `x` to 16-byte alignment. One queries, one commands. + +Pass placement new an unaligned address and the behavior is undefined: + +```cpp +unsigned char buf[13]; // address might not be 4-byte aligned +new (buf) int(42); // UB! buf's alignment may be too weak for int +``` + +So when you hand over memory, the alignment must satisfy `T`. Non-negotiable. + +### How NoDestructor writes it: `alignas(T) char storage_[sizeof(T)]` + +NoDestructor gets past the alignment gate like this (no_destructor.h:122): + +```cpp +alignas(T) char storage_[sizeof(T)]; +``` + +One line, two jobs. `char storage_[sizeof(T)]` first opens a char array of `sizeof(T)` bytes, just enough to hold one `T`. `char` is the most permissive type, happy to hold any byte pattern, which makes it the natural choice for a generic buffer. Then `alignas(T)` lifts the array's alignment from char's default of 1 up to `T`'s requirement. Put together, the address of `storage_` is guaranteed to be a multiple of `alignof(T)`, so placement new can build straight on top with no alignment landmine to step on. + +This is the standard idiom for hand-written buffer storage in C++. In older code you will often see `std::aligned_storage` instead. That template was deprecated in C++23 (see LWG3867 / P2967), and `alignas(T) char buf[sizeof(T)]` is now the recommended form: more direct, no template detour. + +### Access: `reinterpret_cast(storage_)` + +Once construction is done, you still have to treat that char memory as a `T`, and that means casting the address to `T*` with `reinterpret_cast(storage_)`. This is legal: after placement new has run, a real, honest `T` object lives in that char memory, so a `reinterpret_cast` pointing at it is well-defined. NoDestructor's `get()` is exactly this (no_destructor.h:118-119): + +```cpp +T* get() { return reinterpret_cast(storage_); } +``` + +--- + +## Manual lifetime: construct but never destruct + +With those pieces in hand, you can see what NoDestructor is doing. It holds a raw `alignas(T) char storage_[sizeof(T)]` buffer, and at construction time it placement-news `T` onto it: `new (storage_) T(args...)`. Then comes the point. It never gives `T` a path to destruct. `~NoDestructor()` is `= default`, and what that destroys is the char array, which is a trivial type that does nothing. `~T()` is never called on this path. + +That is the whole secret of "construct but never destruct." After placement new brings `T` into being, it sits in `storage_` for the rest of the program, until process exit, when the OS reclaims the entire process address space, `T` included, as ordinary memory. Note the reclaimer here is not `T`'s destructor. It is the OS. + +### Is that safe? + +What about the resources `T` itself owns? Take `NoDestructor>`: the vector's heap-allocated elements. Frankly, those are not released by `~T()`, because `~T()` never runs. They ride on the OS reclaiming the whole address space at process exit. During the program's lifetime that memory counts as "leaked," but the program is about to end, so who is there to see the leak? The OS catches it regardless. + +What actually breaks is a different case: when `T`'s destructor has side effects. A destructor that flushes a log to disk, or signals another process "I'm leaving," won't fire, because the destructor doesn't run. So NoDestructor is only safe for types whose destructor is pure resource release. If the destructor carries an observable side effect, don't use it. + +--- + +## A minimal reproduction + +Talk is cheap. We can hand-roll a minimal version and feel placement new plus "no destructor" directly: + +```cpp +// Platform: host | C++ Standard: C++17 +#include +#include +#include +#include + +template +class MiniNoDestructor { +public: + template + explicit MiniNoDestructor(Args&&... args) { + new (storage_) T(std::forward(args)...); // placement new + } + ~MiniNoDestructor() = default; // does NOT call ~T()! + MiniNoDestructor(const MiniNoDestructor&) = delete; + + T& operator*() { return *get(); } + T* operator->() { return get(); } + T* get() { return reinterpret_cast(storage_); } + +private: + alignas(T) char storage_[sizeof(T)]; +}; + +struct Noisy { + Noisy() { std::puts("Noisy()"); } + ~Noisy() { std::puts("~Noisy()"); } // this destructor never runs +}; + +int main() { + { + static const MiniNoDestructor nd; // construct once + // leaving scope / program exit: ~MiniNoDestructor runs (trivial), ~Noisy does not + } + std::puts("(before program exit, ~Noisy is not printed)"); + return 0; +} +``` + +Run it and you will see it: `Noisy()` prints once, but the `~Noisy()` line never appears. That is NoDestructor's "no destructor," in the flesh. + +--- + +The parts are all here. Placement new gives us "construct without allocating," `alignas(T) char storage_[sizeof(T)]` clears the alignment hurdle, and `~NoDestructor() = default` quietly walls off the destruction path. Put the three together and `T` just sits in `storage_`, refusing to leave, until the OS reclaims everything at process exit. In the next piece we assemble NoDestructor for real. Parts alone aren't enough; we still have to see how it covers initialization ordering and the legal path through `reinterpret_cast`. + +## References + +- [cppreference: placement new](https://en.cppreference.com/w/cpp/language/new#Placement_new) +- [cppreference: alignof / alignas](https://en.cppreference.com/w/cpp/language/alignas) +- [cppreference: std::aligned_storage (deprecated since C++23)](https://en.cppreference.com/w/cpp/types/aligned_storage) +- [Chromium `base/no_destructor.h`: storage_ and get()](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) diff --git a/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/01-no-destructor-design-and-impl.md b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/01-no-destructor-design-and-impl.md new file mode 100644 index 000000000..952847fdc --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/01-no-destructor-design-and-impl.md @@ -0,0 +1,123 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: "For readers already comfortable with lifetime management, a fast walkthrough of NoDestructor's motivation, API, and implementation. The condensed design-guide version of the full/ series (NoDestructor is small, so design and implementation sit in one piece)." +difficulty: advanced +order: 1 +platform: host +prerequisites: +- Move semantics and perfect forwarding +- 'NoDestructor prerequisite (0): static storage duration, initialization, and destruction' +reading_time_minutes: 7 +related: +- 'NoDestructor design guide (II): usage boundaries and testing' +tags: +- host +- cpp-modern +- advanced +- 内存管理 +- RAII +title: "NoDestructor Design Guide (I): Motivation, API, and Implementation" +--- +# NoDestructor Design Guide (I): Motivation, API, and Implementation + +> Hands-on track. Assumes you're already comfortable with static storage duration, placement new, and aligned storage; if not, skim the [full/ prerequisites](../full/pre-00-static-storage-and-init.md) first. + +Chromium has a hard rule we initially found odd: global objects are not allowed to have constructors, and they're not allowed to have destructors. Turn on `-Wglobal-constructors` and `-Wexit-time-destructors`, then dare to put a constructed object at global scope, and the compiler throws it back in your face at build time. The reasoning is actually pretty sound. Constructors slow startup, destructors trigger shutdown races, and the static initialization order fiasco (SIOF) is an accounting mess nobody wants to touch. But global singletons are everywhere: default configs, feature flags, random nonces, all resident from the moment the process starts. Ban them, and how do you even write the code? + +We tried every ready-made path, and none clears both gates. A bare global is the first to die; it walks straight into the warning. The Meyers singleton looks clever. `static T& f(){static T x;return x;}` leans on C++11 magic statics for thread-safe lazy construction, and for a while we thought it was the answer. But it only solves the construction half. `~T()` still has to run, so that whole pile of shutdown destructors is still there. The last path is hand-rolled placement new, which works, but every call site has to remember to copy a static_assert block and handle LSan reachability. Miss one spot and it's a time bomb. + +NoDestructor is Chromium's patch for this dead end. Pull the idea apart and it's two moves. Move the object into a function-local static, sidestepping the global-construction gate and picking up magic statics' thread-safe initialization for free. Then simply don't call `~T()`, sidestepping the global-destruction gate too. The cost: the object "leaks on purpose." It doesn't destruct; the OS reclaims it when the process exits. It sounds crude, but for a global singleton that's supposed to live until the very last tick, that's exactly how it should behave. + +## The API + +```cpp +template +class NoDestructor { +public: + template + explicit NoDestructor(Args&&... args); // perfect-forwarding construct + explicit NoDestructor(const T& x); // copy construct (handy for initializer_list) + explicit NoDestructor(T&& x); // move construct + NoDestructor(const NoDestructor&) = delete; + ~NoDestructor() = default; // ← key: doesn't call ~T() + const T& operator*() const; T& operator*(); + const T* operator->() const; T* operator->(); + const T* get() const; T* get(); +}; +``` + +The surface is this small, and it surprised us on the first read. There's a smart-pointer facade with `*`/`->`/`get`, and copy is deleted outright. Otherwise two NoDestructor instances each running `~char[]` is fine, but the moment someone tries to deep-copy the `storage_` inside, everything breaks. It also doesn't inherit T, and that's deliberate: NoDestructor wants to be a container, semantically independent, not entangled in T's inheritance chain. The everyday usage is one line, `static const NoDestructor x(args); return *x;`. Keep that shape in mind and you're set. + +## Implementation (complete, ~50 lines) + +```cpp +// Platform: host | C++ Standard: C++20 +#pragma once +#include +#include +#include + +namespace tamcpp::chrome { + +template +class NoDestructor { +public: + template + explicit NoDestructor(Args&&... args) { + new (storage_) T(std::forward(args)...); // placement new + } + explicit NoDestructor(const T& x) { new (storage_) T(x); } + explicit NoDestructor(T&& x) { new (storage_) T(std::move(x)); } + + NoDestructor(const NoDestructor&) = delete; + NoDestructor& operator=(const NoDestructor&) = delete; + ~NoDestructor() = default; // only destructs the char member, doesn't call ~T() + + const T& operator*() const { return *get(); } + T& operator*() { return *get(); } + const T* operator->() const { return get(); } + T* operator->() { return get(); } + const T* get() const { return reinterpret_cast(storage_); } + T* get() { return reinterpret_cast(storage_); } + +private: + static_assert(!(std::is_trivially_constructible_v && + std::is_trivially_destructible_v), + "T trivially ctble+dtble: use constinit T directly"); + static_assert(!std::is_trivially_destructible_v, + "T trivially destructible: use plain function-local static T"); + + alignas(T) char storage_[sizeof(T)]; + + // Under LEAK_SANITIZER builds Chromium additionally holds a T* storage_ptr_ + // as an LSan reachability root (crbug/40562930); the teaching version omits + // it and uses an LSan suppression file instead. +}; + +} // namespace tamcpp::chrome +``` + +When the code runs, there are a handful of deliberate tradeoffs. We'll point them out one by one so you can match them against what you read: + +| Decision | Implementation | Reason | +|---|---|---| +| Storage as `alignas(T) char[N]` | `alignas(T) char storage_[sizeof(T)]` | Inline buffer, zero heap allocation, alignment satisfies placement new | +| Construct via placement new | `new (storage_) T(forward(args)...)` | Construct in place on storage_, no allocation | +| Destructor `= default` | `~NoDestructor() = default` | Only destructs the char member (trivial), **doesn't call `~T()`**; this is the root of "no destructor" | +| static_assert gating | Two assertions | Only serves non-trivially-destructible T; the trivial case is routed to constinit / a plain static | + +## How "no destructor" actually works + +That one line in the table, `~NoDestructor() = default`, is the heart of the whole mechanism, so we'll pull it out on its own. It plays a neat trick on type visibility. + +`storage_` is declared as `char[]`, the only type the compiler sees when generating the destructor. A `char` destructor is trivial; it does nothing. What about T? T gets constructed later by placement new, in place at `storage_`'s start address. As far as the compiler is concerned, T has no type-level relationship to `storage_`, so when destructing `storage_` it never thinks to recall T. `~T()` is therefore never wired into NoDestructor's destruction path. T just sits there quietly until the process ends, when the OS reclaims everything in one sweep. One shift of type perspective buys the entire "no destructor" semantics, and we think that's a beautiful move. + +## References + +- [Chromium `base/no_destructor.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) +- [NoDestructor in practice (II): core implementation](../full/04-2-no-destructor-core-impl.md) +- [NoDestructor prerequisite (1): placement new and aligned storage](../full/pre-01-placement-new-and-aligned-storage.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/02-no-destructor-usage-and-testing.md b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/02-no-destructor-usage-and-testing.md new file mode 100644 index 000000000..3152df0d8 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/02-no-destructor-usage-and-testing.md @@ -0,0 +1,83 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: "Where NoDestructor actually belongs (one use / four don'ts), constinit global vs function-local static, the LSan reachability hack, and the test invariants" +difficulty: advanced +order: 2 +platform: host +prerequisites: +- 'NoDestructor hands-on (I): motivation, interface, and implementation' +reading_time_minutes: 6 +related: +- 'NoDestructor hands-on (I): motivation, interface, and implementation' +tags: +- host +- cpp-modern +- advanced +- 内存管理 +- 内存安全 +title: "NoDestructor hands-on (II): usage boundaries, LSan, and testing" +--- +# NoDestructor hands-on (II): usage boundaries, LSan, and testing + +In the last piece we pulled apart the "why" and the implementation of NoDestructor. This one is about using it right. We will be honest: it is easier to misuse than you'd think, because the surface where it's actually correct is narrow, and every wrong way to apply it feels natural. Let's stand up the one pattern that's right, then walk the four most common misuses in reverse, and finish with two engineering details you can't dodge: who actually carries the thread-safety, and why LSan gets annoyed at it. + +## The one pattern where it belongs + +```cpp +const T& GetGlobal() { + static const base::NoDestructor x(args...); // ✓ function-local static + non-trivially-destructible T + return *x; +} +``` + +Those three lines look unremarkable, but all three conditions must hold: T is non-trivially destructible (otherwise NoDestructor is just dead weight), it's written as a function-local static (more on that below), and the object is genuinely global, something the whole program needs. Only when all three line up does NoDestructor earn its place. + +## Four places not to use it + +The pattern above is the only one that's right. Yet when we read our own old code, or scan community usage, the wrong applications pile up. Chromium spells them out clearly in the Caveats section of `no_destructor.h:15-46`. Grouped by how often we've seen each one trip people up: + +| Case | Don't use NoDestructor, use | Reason | +|---|---|---| +| Local variable / member | Plain `T` or `unique_ptr` | NoDestructor is a **real leak** (never reclaimed when it should be) | +| Trivially-destructible T | Bare `static T x` | Generates no global destructor, NoDestructor buys nothing | +| Trivially-constructible + destructible T | `constinit T x` / `constexpr` | Initialized at compile time, no runtime code at all | +| Rarely-used data | A create-on-demand function (return by value) | NoDestructor-backed cache wastes bss memory | + +The first row is the easiest to fumble. People reach for NoDestructor as if it were "a fancier unique_ptr" and tuck it into a member, or wrap a local. That's not skipping a destructor, that's genuinely leaking memory: when the object should be reclaimed, nothing reclaims it. The middle two rows are really the same point stated twice. If T's destructor does nothing (POD, trivially destructible), it produces no global destructor in the first place, so wrapping it in NoDestructor is redundant. Push it one step further: if T is also constinit/constexpr-constructible, it's already initialized at compile time with zero runtime code, and constinit is the answer. The last row is rarer but we've hit it: "cache it just in case" cold data, hung off a NoDestructor, sits in bss for the whole process. A plain create-on-demand function is better. + +## constinit global vs function-local static + +We kept stressing "function-local static" in the one-correct-pattern above. Here's the exception, stated plainly. Default to the function-local static. It does two jobs at once: it sidesteps the global-constructor initialization-order trap, and it picks up thread safety from C++11 magic statics, and the code is shorter. Only when T is itself constinit-constructible can you write a global `constinit const NoDestructor g(...)`, which produces no static initializer at all and is genuinely zero-overhead. + +There's a trap here we've stepped on. If T is not constexpr-constructible, don't assume a global `NoDestructor g(...)` is safe. It still generates a static initializer, because NoDestructor's own constructor is not constexpr. There's no other route; you fall back to the function-local static. Put another way, the constinit-global path is open only to constinit-constructible T, and that bar is higher than it looks. + +## magic statics: thread safety comes from them, not from NoDestructor + +This one gets buried under the impression that "NoDestructor makes it thread-safe." NoDestructor adds zero locks. It contributes nothing to thread safety. What carries the load is the C++11 function-local-static initialization guarantee: the first thread to enter the function runs the initialization, concurrent threads block until it finishes. So the repeated emphasis on "function-local static" wasn't a clever trick to dodge the global ctor. It's also the only source of NoDestructor's thread safety. Use it any other way and that guarantee is gone. + +## The LSan leak tradeoff + +"Not destructing" sounds nice; the cost has to be put on the table too. First, the resource isn't released. The OS reclaims everything at process exit, so for a pure-memory T the impact is small. Second, the destructor's side effects don't fire, and that's the dangerous one: if T's destructor flushes to disk, sends a notification, or reports state, skipping it breaks the program's logic. So NoDestructor fits only pure-resource T. Anything whose destructor has side effects, stay away. + +Then there's a fairly ugly LSan false positive. NoDestructor parks the object inside a `char storage_[]`, and LSan can't read that byte array. During reachability analysis it can't see the pointers hidden inside, so it flags all the heap memory NoDestructor holds as leaked. Chromium's fix is a fairly cute hack (`no_destructor.h:132-142`): under `LEAK_SANITIZER` builds it additionally holds a `T* storage_ptr_ = reinterpret_cast(storage_)`, feeding LSan a `T*` root it does understand and reconnecting the reachability chain (crbug/40562930). That field exists only under LSan builds; regular builds pay nothing. Our teaching version skips this trick and just suppresses the false positive with a suppression file when running under LSan. + +## Test invariants + +NoDestructor's correctness, expressed as tests, comes down to five invariants. Let's go through them one by one. + +Construction runs exactly once. That's the magic-statics promise: call the wrapper function many times, and T's constructor should fire exactly once. A constructor-call counter verifies it. Right next to it is its mirror: the destructor never runs. You can't assert this the usual way, because the destructor doesn't happen. The common approach is a noisy T that logs from its destructor, then check after the program finishes that the log never appeared. A death test or a separate isolated process works. + +The third invariant is enforced at compile time. NoDestructor rejects trivially-destructible T, so something like `NoDestructor` should be turned away by a static_assert and fail to compile. The fourth is thread safety: hammer the same wrapper function from multiple threads on first call, and the constructor still runs exactly once. A counter plus concurrent pressure, no race, passes. Last is access semantics. `*nd`, `nd->`, and `nd.get()` must all behave like an ordinary T. This is basic, but we've watched someone tweak the internal storage, skip the access-semantics tests, and roll the change into a crash. Don't save those few lines. + +That closes the NoDestructor series. In hindsight, the vol9/chrome track stacks into four pieces: **OnceCallback** owns callback lifetime and cancellation, **WeakPtr** owns weak references, **flat_map** owns the high-performance container, and **NoDestructor** owns static lifetime. Four different axes of industrial C++ design, and together they cover the parts of Chromium `//base` most worth learning. + +## References + +- [Chromium `base/no_destructor.h` (Caveats + the LSan hack)](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) +- [crbug.com/40562930 (the LSan false positive)](https://crbug.com/40562930) +- [LeakSanitizer documentation](https://clang.llvm.org/docs/LeakSanitizer.html) +- [NoDestructor hands-on (III): when to use it, and when not to](../full/04-3-no-destructor-when-to-use.md) diff --git a/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/index.md b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/index.md new file mode 100644 index 000000000..f15671407 --- /dev/null +++ b/documents/en/vol9-open-source-project-learn/chrome/04_no_destructor/index.md @@ -0,0 +1,14 @@ +# NoDestructor: static lifetime management, learned from Chromium + +This directory takes apart Chromium's `base::NoDestructor` and works through the lifetime management of global and static objects: why Chromium bans global constructors and destructors, how placement new manages lifetime by hand, the thread safety of magic statics, and the "intentional leak" tradeoff. It sits alongside [OnceCallback](../01_once_callback/), [WeakPtr](../02_weak_ptr/), and [flat_map](../03_flat_map/), rounding out the static-lifetime piece of vol9/chrome. + +NoDestructor is a lightweight component (a thin header-only wrapper), so this series is shorter than the other three. + +## Full tutorial (full/) + +- Prerequisites: [static storage duration, init, and destruction](./full/pre-00-static-storage-and-init.md), [placement new and aligned storage](./full/pre-01-placement-new-and-aligned-storage.md) +- Hands-on: [motivation and API](./full/04-1-no-destructor-motivation-and-api.md), [core implementation](./full/04-2-no-destructor-core-impl.md), [when to use](./full/04-3-no-destructor-when-to-use.md), [LSan and leaks](./full/04-4-no-destructor-lsan-and-leak.md) + +## Hands-on design guide (hands_on/) + +For readers comfortable with templates and lifetime: [motivation, interface, and implementation](./hands_on/01-no-destructor-design-and-impl.md), [usage boundaries and testing](./hands_on/02-no-destructor-usage-and-testing.md). diff --git a/documents/en/vol9-open-source-project-learn/index.md b/documents/en/vol9-open-source-project-learn/index.md index 0f89f00d1..802b79ffb 100644 --- a/documents/en/vol9-open-source-project-learn/index.md +++ b/documents/en/vol9-open-source-project-learn/index.md @@ -8,12 +8,6 @@ tags: - intermediate difficulty: intermediate platform: host -translation: - source: documents/vol9-open-source-project-learn/index.md - source_hash: 4698281050b1537b057a2769e911e143e56b397b8894d166be0663d39ca0e9d1 - translated_at: '2026-05-26T12:30:40.345238+00:00' - engine: anthropic - token_count: 92 --- # Volume 9 · Learning from Open Source Projects @@ -25,4 +19,7 @@ By analyzing real open source project source code, we learn the design and imple OnceCallback: Callback Design Learned from Chromium + WeakPtr: Weak-Pointer Design Learned from Chromium + flat_map: Ordered-Container Design Learned from Chromium + NoDestructor: Static Lifetime Management Learned from Chromium diff --git a/documents/vol9-open-source-project-learn/chrome/.pages b/documents/vol9-open-source-project-learn/chrome/.pages index 90d19d729..f1c6b362a 100644 --- a/documents/vol9-open-source-project-learn/chrome/.pages +++ b/documents/vol9-open-source-project-learn/chrome/.pages @@ -1,3 +1,6 @@ title: Chromium nav: - 01_once_callback/ + - 02_weak_ptr/ + - 03_flat_map/ + - 04_no_destructor/ diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-1-once-callback-motivation-and-api-design.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-1-once-callback-motivation-and-api-design.md index 24e8fa7d1..8387ea28c 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-1-once-callback-motivation-and-api-design.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-1-once-callback-motivation-and-api-design.md @@ -24,27 +24,15 @@ title: OnceCallback 实战(一):动机与接口设计 --- # OnceCallback 实战(一):动机与接口设计 -## 引言 - -说实话,笔者在做异步编程的时候,踩过最多的坑就是回调被多次调用。场景很经典——注册一个文件 I/O 完成的回调,期望它跑一次就完事,结果因为某处逻辑手滑多触发了一次,回调里释放的资源被二次访问,直接喜提段错误。这种 bug 的一大特点是——在测试里很难复现,因为正常的异步路径往往只跑一次回调;真正的触发条件是某种竞态或错误重试路径。 - -`std::function` 没法帮我们。它允许多次调用,允许拷贝传播,回调对象可以满天飞。我们需要的是一种**在类型系统层面就约束住回调语义**的机制——让"只能调用一次"这个规则变成编译器的检查项,而不是程序员记忆力的事。 - -这一篇我们从动机出发,拆清楚 `std::function` 到底哪里不对,然后设计我们的目标 API。下一篇再开始写代码。 - -> **学习目标** -> -> - 从一个真实的异步 bug 理解 `std::function` 在回调场景的三大缺陷 -> - 掌握 Chromium OnceCallback 的设计哲学:move-only + 右值限定 + 单次消费 -> - 设计出 OnceCallback 的完整公共接口 +## 从一个 bug 说起 ---- +笔者在异步编程上栽过最多次的坑,叫"回调被多调一次"。场景很俗——封装一个异步文件读取,注册个 I/O 完成的回调,指望它跑一次拉倒。结果某条错误重试路径手滑多触发了一次,回调里 `release_resources()` 跑第二遍,第二次访问的是已经释放的内存,段错误。这玩意儿最恶心的是测试里几乎复现不了,正常异步路径都只触发一次回调,真正的导火索是某种竞态或重试,得在生产环境的高并发下以极低概率冒头。笔者第一次撞这坑的时候盯了半个下午 dump,才发现不是逻辑错,是调用次数根本没人管。 -## 从一个 bug 说起 +`std::function` 在这种场景下帮不上忙。它能被调任意次,能被拷贝到满天飞,回调对象您想约束住——没门。咱们要的是把"只能调一次"这条规矩焊进类型系统,让编译器去查,而不是指望每个写回调的人都长记性。这一篇就先把动机和接口想透,下一篇再动手写代码。 ### 场景:异步文件读取 -假设我们在写一个异步文件读取的封装。用户调用 `read_file_async(path, callback)`,I/O 完成后 `callback` 被触发一次,传入文件内容。 +假设咱们在写一个异步文件读取的封装。用户调用 `read_file_async(path, callback)`,I/O 完成后 `callback` 被触发一次,传入文件内容。 ```cpp void read_file_async(const std::string& path, @@ -59,61 +47,47 @@ void on_file_read(std::string content) { read_file_async("data.txt", on_file_read); ``` -看起来没问题。但如果 I/O 子系统因为某种错误触发了重试——回调被调用了两次。`release_resources()` 执行了两次,第二次访问的是已释放的内存。段错误。在测试环境中,这个重试路径永远不会被触发;只有在生产环境的高并发场景下,这个 bug 才会以极低的概率出现。 +看起来人畜无害。可一旦 I/O 子系统因为某种错误走了重试——回调被触发两次,`release_resources()` 跑两遍,第二遍摸的就是已释放的内存。段错误。测试里这条重试路径永远跑不到,bug 只在生产的高并发下以极低概率冒头。 -### std::function 没有帮我们 +### std::function 没帮上咱们 -问题出在哪里?`std::function` 的类型签名里没有任何信息告诉我们"这个回调应该被调用几次"。类型系统没有提供约束,只能靠运行时的断言——如果你有的话——或者靠程序员的纪律来保证。 +问题出在哪?`std::function` 的类型签名里,压根没藏着"这个回调该被调几次"这层信息。类型系统在这儿是缺席的,约束全靠运行时断言——前提是您写了——要么就靠程序员的纪律。 -更糟糕的是,`std::function` 的特性让这个问题变得更难发现。它是可拷贝的,意味着回调可以被复制到多个地方。如果多个执行路径同时持有同一份回调的副本,竞态条件就埋伏在其中。它的 `operator()` 是 `const` 限定的——调用它不会改变 `std::function` 对象本身的状态,所以你无法通过调用接口来表达"调用即消费"这个语义。 +更要命的是,`std::function` 的几个特性还把这 bug 往更难查的方向推。它是可拷贝的,回调能被复制到任意多个地方。哪天多个执行路径同时攥着同一份副本,竞态就埋那儿了。它的 `operator()` 又是 `const` 限定的,调一下不会改动对象自身状态——所以"调用即消费"这个语义,您想从调用接口上表达出来都表达不了。 --- ## std::function 的三大缺陷 -我们把问题系统化一下。`std::function` 作为通用的可调用对象容器,在设计上是成功的——但在异步回调这个特定场景下,它有三个致命的问题。 - -### 缺陷一:可复制 +把问题系统化一下。`std::function` 作为通用可调用对象容器,本身设计是成功的——这点笔者不否认。但塞进异步回调这个特定场景,它有仨致命的地方。 -`std::function` 天生支持拷贝。当你拷贝一个 `std::function` 时,它内部的类型擦除机制会把存储的可调用对象也拷贝一份。在异步系统中,这意味着一个回调可以被复制到任意多个地方——任务队列里一份、定时器里一份、错误处理器里一份——每份都可以独立调用。 +第一桩,可复制。`std::function` 天生支持拷贝,您拷一份过去,它内部的类型擦除机制会把存着的可调用对象也跟着拷一份。放到异步系统里什么意思?一个回调能被复制到任意多个地方——任务队列里一份、定时器里一份、错误处理器里又一份——每份都能独立调用。要是回调里捕获了 move-only 资源(比如 `std::unique_ptr`),拷贝直接编译挂;捕获的是裸指针或引用呢,多个副本同时跑就是竞态。Chrome 团队的思路直白得很:异步任务回调压根就不该被复制,那就让它在类型层面拷不动。 -如果回调里捕获了 move-only 的资源(比如 `std::unique_ptr`),拷贝直接编译失败。如果捕获的是裸指针或引用,多个副本同时执行就会产生竞态。Chrome 团队的思路很直接:既然异步任务回调从根本上就不应该被复制,那就让它在类型层面不可拷贝。 +第二桩,可重复调用。`std::function::operator()` 对调用次数一句管束都没有,您在同一个对象上调一千遍它都照跑。可异步回调场景里,文件读取完成的回调被调两次就是实打实的逻辑错误——两次资源释放、两次状态转换、两次消息发送,爱哪样来哪样。这种错类型系统一个字都查不出来。 -### 缺陷二:可重复调用 +第三桩,也是最隐蔽的:没法表达消费语义。在 Chrome 的任务投递模型里,一次 `PostTask(FROM_HERE, callback)` 之后,`callback` 就不该再被碰了——它的所有权已经交给了任务系统。可 `std::function::operator()` 是 `const` 限定的,调一下不改对象状态,所以"调用即消费"这个语义,您想从接口上挂出去都挂不上去。 -`std::function::operator()` 对调用次数没有任何约束。你可以在同一个 `std::function` 上调一千次,它照跑不误。但在异步回调场景里,一个文件读取完成的回调被调用两次就是逻辑错误——它可能触发两次资源释放、两次状态转换、两次消息发送。这种错误在类型系统里完全检测不到。 - -### 缺陷三:无法表达消费语义 - -在 Chrome 的任务投递模型中,一个 `PostTask(FROM_HERE, callback)` 调用之后,`callback` 就不应该再被使用——它的所有权已经转移给了任务系统。`std::function` 的 `operator()` 是 `const` 限定的,调用它不会改变 `std::function` 对象本身的状态,所以你无法通过调用接口来表达"调用即消费"这个语义。 - -这三个问题归结到一点:`std::function` 的接口设计无法表达"这个回调只能被调用一次,调用后即失效"这个约束。我们的 OnceCallback 就是为了填补这个语义空白而设计的。 +仨问题其实戳的是同一个点:`std::function` 的接口压根表达不了"这个回调只能调一次,调完即失效"这条约束。咱们的 OnceCallback,就是冲着填这个空缺去的。 --- ## Chromium 的回答:OnceCallback 设计哲学 -Chrome 的回调系统建立在一条核心原则之上:**消息传递优于锁,序列化优于线程**。在这个原则下,每个投递到任务系统的回调都是一个独立的、一次性的消息。投递之后,回调的所有权就从调用方转移到了任务系统;执行之后,回调就被销毁。没有共享,没有复用,没有歧义。 - -这个哲学直接体现在 `OnceCallback` 的类型设计上,三个关键约束: +Chrome 的回调系统压在一条核心原则上:消息传递优于锁,序列化优于线程。顺着这条往下走,投递到任务系统的每个回调都是一条独立的、一次性的消息。投递之后,回调所有权就从调用方挪到任务系统;执行完,回调销毁。没共享,没复用,没歧义。 -**Move-only**:`OnceCallback` 删除了拷贝构造和拷贝赋值,只保留移动操作。从类型层面保证回调在任意时刻只有一个持有者。 - -**右值限定 Run()**:`OnceCallback::Run()` 只能通过右值引用调用。左值调用触发编译错误。从语法层面提醒调用方:"你在消费这个回调,之后别再用了。" - -**单次消费**:`Run()` 内部会通过引用计数机制销毁 `BindState`,使得后续对同一对象的任何访问都是安全的空操作。 +这套哲学直接刻在 `OnceCallback` 的类型设计上。先是 move-only:`OnceCallback` 把拷贝构造和拷贝赋值都删了,只留移动操作,类型层面就保证回调任意时刻只有一个持有者。再是右值限定的 `Run()`——它只能从右值上调,左值调直接编译报错,等于在语法上拽一把调用方:"您在消费这个回调,回头别再碰了"。最后是单次消费,`Run()` 内部靠引用计数机制销毁 `BindState`,调完之后对同一对象的任何访问都是安全的空操作。三条加起来,"只能调一次"这事就从纪律问题变成了类型问题。 ### Chromium 内部架构概览 -Chromium 的回调系统由三个层次组成。底层是 `BindStateBase`——类型擦除的基类,带引用计数,不用虚函数而是用函数指针成员来实现多态。中间层是 `BindState`——模板化的具体类,存储真正的可调用对象和绑定参数。顶层是 `OnceCallback`——用户直接操作的类型,本质上是 `BindState` 的一个智能指针包装,大小只有 8 字节。 +Chromium 的回调系统叠了三层。最底下是 `BindStateBase`,类型擦除的基类,挂引用计数——它不走虚函数那条路,改用函数指针成员来玩多态。中间一层是 `BindState`,模板化的具体类,真正的可调用对象和绑定参数都存这儿。最顶上是 `OnceCallback`,用户直接上手的就是它,本质是 `BindState` 套了个智能指针壳子,大小才 8 字节。 -我们的实现会保留"外层接口 + 内部存储 + 类型擦除"的分层思路,但用 `std::move_only_function` 来替代 Chromium 手写的 `BindState` + 引用计数组合,用 deducing this 来替代双重重载 + `!sizeof` hack。 +咱们的实现会保留"外层接口 + 内部存储 + 类型擦除"这套分层骨架,但动手的地方有两处:用 `std::move_only_function` 替掉 Chromium 手写的 `BindState` + 引用计数组合,用 deducing this 替掉双重重载加 `!sizeof` hack。说白了,老一辈的体力活让现代语法干了。 --- ## 设计目标 API -我们把目标 API 定下来,再回头讨论每个设计决策。这是工程师的工作方式——先想清楚"我要什么",再想"怎么做"。 +工程师的规矩:先把"我要什么"摆出来,再回头抠每个决策的为什么。咱们这就把目标 API 定下来。 ### 构造与调用 @@ -181,29 +155,29 @@ int final_result = std::move(pipeline).run(3, 4); ### 为什么用 run() 而不是 operator() -Chromium 用的是 `Run()`(Google 风格要求大写开头)。我们用 `run()` 符合 snake_case 命名规范。更深层的原因是语义区分——`operator()` 太通用,任何可调用对象都有 `operator()`;`run()` 明确表达了"执行任务"的语义,在代码审查时一眼就能看出这是在消费一个 OnceCallback,而不是调用一个普通函数。 +Chromium 那边是 `Run()`,Google 风格要求首字母大写。咱们用 `run()`,对齐 snake_case 规范。往深一层讲,这其实是语义切分:`operator()` 太通用了,是个可调用对象就有 `operator()`;`run()` 这名字本身就在喊"我在执行一个任务",code review 的时候一眼就能看出这儿在消费一个 OnceCallback,而不是随手调个普通函数。 ### 为什么 run() 必须通过右值 -这是整个设计中最关键的一点。我们用 deducing this 让编译器帮我们拦截左值调用——如果写 `cb.run(args)` 而不是 `std::move(cb).run(args)`,编译器直接报错,错误信息明确告诉你该怎么做。这个机制在前置知识(六)里已经详细讲过了。 +这是整个设计里笔者最在意的一处。咱们用 deducing this 让编译器替咱们拦左值调用——写 `cb.run(args)` 而不是 `std::move(cb).run(args)`,编译器当场报错,错误信息还会明明白白告诉您该怎么改。这套机制前置知识(六)里讲过了,这里不重复。 ### 为什么区分 is_cancelled() 和 maybe_valid() -区别在于安全保证的强弱。`is_cancelled()` 提供确定性回答——只能在回调绑定的序列上调用,保证返回准确的结果。`maybe_valid()` 提供乐观估计——可以从任何线程调用,但结果可能过时。在 Chromium 的完整实现中,这个区分和线程安全保证有关。我们的简化版暂时让两者语义相同,但保留了接口以备后续扩展。 +差别在安全保证的强弱上。`is_cancelled()` 给的是确定性回答,只能在回调绑定的序列上调,保证返回准确结果;`maybe_valid()` 走的是乐观估计,能从任意线程调,但结果可能已经过时。在 Chromium 的完整实现里,这俩的区分跟线程安全保证是绑一块儿的。咱们的简化版暂时让两者语义一致,接口先留着,等后面真要扩了再分。 ### 为什么 then() 消费 *this -`then()` 的语义是"把当前回调的执行结果传给下一个回调"。这要求当前回调在 `then()` 返回的新回调中被完整捕获。如果 `then()` 不消费 `*this`,同一个回调就会同时存在于两个地方——违反 move-only 的语义约束。所以 `then()` 被声明为右值限定成员函数,调用后原回调对象进入已消费状态。 +`then()` 想表达的是"把当前回调的执行结果递给下一个回调"。这就要求当前回调在 `then()` 返回的新回调里被完整吃进去。要是 `then()` 不消费 `*this`,同一个回调就同时待在两个地方了——move-only 的语义当场破功。所以 `then()` 声明成右值限定成员函数,调完原回调就进入已消费态。 --- ## 环境搭建 -开始写代码之前,确认一下工具链。OnceCallback 依赖 `std::move_only_function` 和 deducing this,都是 C++23 特性。 +动手之前先把工具链点一点。OnceCallback 依赖 `std::move_only_function` 和 deducing this,俩都是 C++23 特性,环境不齐后面全白搭。 ### 编译器要求 -GCC 13+ 或 Clang 17+ 可以完整支持上述特性。编译时加 `-std=c++23`。 +GCC 13+ 或 Clang 17+ 能完整撑起上面这套特性,编译加 `-std=c++23`。 ### 验证代码 @@ -225,7 +199,7 @@ int main() { } ``` -如果这段代码编译通过,环境就绑了。 +如果这段代码编译通过,环境就算齐了。 ### CMake 最小配置 @@ -244,11 +218,7 @@ target_include_directories(once_callback INTERFACE --- -## 小结 - -这一篇我们从动机出发,搞清楚了三件事。`std::function` 在异步回调场景有三大缺陷——可复制、可重复调用、无法表达消费语义——根源在于类型系统无法约束"只能调用一次"。Chromium 的 OnceCallback 通过 move-only + 右值限定 Run() + 单次消费来填补这个语义空白。我们设计了一套目标 API,包括构造与调用、参数绑定(`bind_once`)、取消检查(`is_cancelled`/`maybe_valid`)和链式组合(`then()`)四个核心功能。 - -下一篇我们开始搭建核心骨架——从模板偏特化到三态管理,把 OnceCallback 的类骨架搭起来。 +接口和动机到这儿算是捏出形状了。下一篇咱们就动手——从模板偏特化到三态管理,把 OnceCallback 的类骨架一根根搭起来。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-2-once-callback-core-skeleton.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-2-once-callback-core-skeleton.md index ea5f5d4bb..bd284fdfb 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-2-once-callback-core-skeleton.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-2-once-callback-core-skeleton.md @@ -27,23 +27,13 @@ title: OnceCallback 实战(二):核心骨架搭建 --- # OnceCallback 实战(二):核心骨架搭建 -## 引言 +上一篇咱们把"为什么需要 OnceCallback"和目标 API 的长相捋清楚了。动机讲完,笔者就有点手痒——光看接口不过瘾,得自己一行行把它撸出来,才知道哪些设计是真金,哪些是纸面好看。 -上一篇我们搞清楚了"为什么需要 OnceCallback"和"目标 API 长什么样"。现在我们正式上手写代码。这一篇的任务是把 OnceCallback 的类骨架从零搭建起来——不是一口气写完所有功能,而是分五步,每一步在前一步的基础上加一层。搭完骨架之后,后续的 `bind_once`、取消令牌、`then()` 都是往这个骨架上加组件。 - -所有前置知识我们在前面七篇文章里都已经讲透了。这一篇是纯实战——我们直接对照实际源码,把每一个设计决策落实到代码上。 - -> **学习目标** -> -> - 从零搭建 `OnceCallback` 的完整类骨架 -> - 理解每个数据成员和方法的职责 -> - 掌握 `run()` 的 deducing this 实现和 `impl_run()` 的消费逻辑 - ---- +这一篇咱们就动手。先把类骨架从零搭起来,分五步走,每一步只在前一步上加一层。骨架立住了,后面的 `bind_once`、取消令牌、`then()` 全都是往这套骨架上挂件,不会有伤筋动骨的改动。前置知识那七篇咱们默认您已经过完了——函数类型、模板偏特化、`requires`、`move_only_function`、deducing this,底下直接拿来用,不再回头讲。 ## 第一步:主模板与偏特化 -前置知识(一)里我们已经讲过"函数类型 + 模板偏特化"这个模式。现在把它直接应用到 OnceCallback 上。 +前置知识(一)讲过的"函数类型 + 模板偏特化"模式,现在直接落到 OnceCallback 上。 ```cpp namespace tamcpp::chrome { @@ -65,13 +55,13 @@ public: } // namespace tamcpp::chrome ``` -当你写 `OnceCallback` 时,编译器把 `int(int, int)` 匹配到主模板的 `FuncSignature`,然后发现偏特化能把它拆成 `ReturnType = int`、`FuncArgs = {int, int}`,于是选择偏特化版本。`FuncSig` 是一个类型别名,保存了完整的函数签名——后面声明 `std::move_only_function` 时会用到。 +您写 `OnceCallback` 的时候,编译器先把 `int(int, int)` 这个整体喂给主模板的 `FuncSignature`,再回头一看偏特化能把 `int` 拆成 `ReturnType`、把 `{int, int}` 拆成 `FuncArgs`,于是偏特化胜出。这个"先收成整体、再被偏特化拆开"的套路,是这类回调库的通用骨架——`std::function`、Chromium 的 `RepeatingCallback` 都是同一个模子。`FuncSig` 这个类型别名顺手存一份完整签名,后面声明 `std::move_only_function` 时直接拿来用,省得再拼一遍。 --- ## 第二步:数据成员——三个核心存储 -现在往偏特化类里添加数据成员。OnceCallback 需要三个东西来管理自己的状态。 +有了类型骨架,咱们往里头填数据成员。OnceCallback 要管住自己的状态,得靠三样东西: ```cpp template @@ -91,17 +81,17 @@ private: }; ``` -`func_` 是类型擦除的核心——它把各种不同形态的可调用对象(lambda、函数指针、仿函数)统一包装成 `FuncSig` 签名的调用接口。不管你传入什么,`func_` 都能用同一个 `operator()` 调用它。 +三个成员里,`func_` 是类型擦除的核心。lambda、函数指针、仿函数,形态千差万别,`func_` 把它们统一塞进 `FuncSig` 签名的同一个 `operator()`,这就是咱们要的"一套接口接住所有可调用对象"。 -`status_` 是一个三态枚举,区分"从未赋值"、"随时可调用"和"已经调用过了"。为什么不能只靠 `func_` 的判空?因为 `std::move_only_function` 的 `operator bool()` 只能区分"空"和"非空"两种状态,而且移动后的状态未指定——前置知识(五)里已经详细讲过了。 +真正值得多嘴的是 `status_` 这个三态枚举。您可能会问:为什么不能只靠 `func_` 判空?因为 `std::move_only_function` 的 `operator bool()` 只能区分"空"和"非空",而 OnceCallback 的语义要求更细——"从未被赋值"和"已经被 `run()` 消费过"是两件不同的事,后者是个明确的契约违规(回调只能跑一次),它得跟"出厂就是空的"区分开。更要命的是,`move_only_function` 移动之后的状态在标准里是"未指定但有效",靠它判空本身就靠不住。所以笔者老老实实给状态位单开一个枚举,不指望底层容器替咱们管语义。前置知识(五)里讲过这个坑,这里就是落地。 -`token_` 是一个可选的取消令牌,用于在回调执行前检查是否应该取消执行。默认是空指针(不启用取消机制),通过 `set_token()` 方法设置。这个我们后面有专门一篇讲。 +`token_` 是可选的取消令牌,默认空指针(不启用取消机制),要靠 `set_token()` 显式挂上。这一篇笔者先把它摆在这儿占位,取消机制后面有专门一篇细讲。 --- ## 第三步:构造函数与 requires 约束 -接下来添加构造函数。这里的关键点是模板构造函数必须用 `requires` 约束来防止它劫持移动构造函数——前置知识(四)里已经讲过这个问题了。 +数据成员就位,咱们给这个类配上构造函数。这里有个模板构造函数的老坑:它会在重载决议里抢移动构造的活儿,必须拿 `requires` 约束挡一道。前置知识(四)讲过原理,这里看它怎么落地。 ```cpp // not_the_same_t concept:F 退化后不是 T @@ -147,19 +137,17 @@ public: }; ``` -让我们逐个理解这些构造函数。 - -**模板构造函数**是最常用的——当你写 `OnceCallback([](int x) { return x; })` 时调用的就是这个。`Functor` 被推导为 lambda 的闭包类型,`requires not_the_same_t` 确保当传入的是 `OnceCallback` 本身时模板被排除(让移动构造函数来处理)。`std::move(function)` 把传入的可调用对象移入 `func_`,`status_` 设为 `kValid`。 +这段里最常用的是模板构造函数。您写 `OnceCallback([](int x) { return x; })` 的时候,走的就是它——`Functor` 被推导成 lambda 的闭包类型,`requires not_the_same_t` 把"传入的恰好是 `OnceCallback` 本身"这种情形挡在模板外面,让移动构造函数去接手。要是没这一条约束,模板构造函数会贪心地劫持拷贝/移动,编译期重载决议就乱了套。`std::move(function)` 把可调用对象移进 `func_`,`status_` 同时置为 `kValid`。 -**默认构造函数**创建一个空的 OnceCallback——`status_` 是 `kEmpty`(由成员初始化器的默认值决定),`func_` 和 `token_` 都是空的。 +默认构造函数就平淡多了,产出的是一个空回调:`status_` 是 `kEmpty`(成员初始化器给定的默认值),`func_` 和 `token_` 都空着。它存在主要是为了让 OnceCallback 能放进容器、能延迟赋值。 -**移动构造函数**从另一个 OnceCallback 那里偷走所有内容——`func_` 和 `token_` 通过 `std::move` 转移,`status_` 也一起复制过来。关键点是移动后源对象被设为 `kEmpty`——这是我们主动做的,不是依赖 `std::move_only_function` 的移动后状态。 +移动构造这边有个笔者刻意做的取舍。`func_` 和 `token_` 靠 `std::move` 转移,`status_` 直接拷过来,这些都不意外。意外的是源对象被咱们主动设回 `kEmpty`——而不是依赖 `move_only_function` 移动后那个"未指定"的状态。道理前面讲过:语义要握在自己手里,底层容器移动后是空的还是有效的,标准留着口子,咱们不能赌。移动赋值走的是同一套逻辑,多一个自赋值检查。 --- ## 第四步:run() 的 deducing this 实现 -这一步是整个骨架的灵魂。`run()` 利用 deducing this 在编译期拦截左值调用,通过右值调用时转发到内部的 `impl_run()`。 +这一步咱们把"只能跑一次"的契约,用编译期手段卡到调用点上。`run()` 借 deducing this 在编译期拦住左值调用,只有右值(也就是 `std::move(cb).run(...)`)才放行,转发到内部的 `impl_run()`。 ```cpp // 声明(在类体内) @@ -178,9 +166,9 @@ auto OnceCallback::run(this Self&& self, FuncArgs&&... } ``` -当调用方写 `cb.run(args)` 时,`Self` 被推导为 `OnceCallback&`(左值引用),`static_assert` 触发,报错信息直接告诉调用方该怎么做。当写 `std::move(cb).run(args)` 时,`Self` 被推导为 `OnceCallback`(非引用),编译通过,转发到 `impl_run`。 +机制其实很直白。调用方写 `cb.run(args)`(没加 `std::move`)的时候,`Self` 被推导成 `OnceCallback&`——左值引用,`static_assert` 当场炸,而且报错信息里直接把正确写法 `std::move(cb).run(...)` 甩在调用方面前,不用他自己猜。写成 `std::move(cb).run(args)`,`Self` 推导成 `OnceCallback`(非引用),编译通过,转发进 `impl_run`。 -`impl_run` 是真正执行回调的地方: +`impl_run` 才是真正干活的地方: ```cpp template @@ -211,19 +199,19 @@ ReturnType OnceCallback::impl_run(FuncArgs... args) { } ``` -有几个关键细节值得注意。 +这段实现里笔者最想跟您掰扯的,是消费顺序。 -先看消费顺序——`impl_run` 先把 `func_` move 出来作为局部变量 `functor`,然后把 `func_` 置空、`status_` 设为 kConsumed,最后执行 `functor`。这个顺序很重要:先把可调用对象拿出去、状态标记好,再执行。即使可调用对象内部抛出异常,`status_` 也已经是 `kConsumed` 了,回调不会处于不一致的状态。 +`impl_run` 不是直接调用 `func_`,而是先把它 move 出来作为局部变量 `functor`,再把成员 `func_` 置空、`status_` 置 `kConsumed`,最后才执行 `functor`。三步的次序不是随手排的——状态先标记好,可调用对象先脱离成员、落到栈上,然后再跑。这么一来,即便 `functor` 内部抛异常往外冒,`status_` 也早就是 `kConsumed` 了,回调对象不会卡在一个"func_ 还在但状态说没消费"的中间态。异常安全就是这么抠出来的:把"已消费"这个不可逆的状态,提前到执行之前。 -再看 `if constexpr`——void 返回类型不能用常规方式赋值和返回。`if constexpr (std::is_void_v)` 在编译期选择分支,void 的情况走"调用但不赋值"的路径,非 void 的情况走"调用并赋值给 return"的路径。这是我们速查篇里讲过的标准模式。 +取消检查挪到了执行最前面。如果令牌挂上了且已失效,回调直接消费、不执行。这里 void 返回走 `return`,非 void 返回抛 `std::bad_function_call`——后者乍看激进,但您站到调用方的角度想就通了:人家写 `auto x = std::move(cb).run(...)`,指望拿到一个值回去,您这边给不出任何有意义的返回值,与其返回个未定义的东西让人家用出花来,不如抛异常把问题摊在台面上。这是"失败要响亮"的取舍,跟 WeakPtr 里 `operator*` 失效用 `CHECK` 是一类思路。 -最后看取消检查——在执行前检查取消令牌。如果已取消,直接消费回调但不执行。void 返回直接 `return`,非 void 返回抛出 `std::bad_function_call`。非 void 的抛异常行为可能看起来激进,但理由很充分:调用方期望得到一个返回值,但我们无法提供一个有意义的值,所以抛异常比返回未定义值更安全。 +剩下的 `if constexpr` 是为 void 返回类型开的编译期分支。void 没法走"调用然后 return 结果"的常规路径,`if constexpr (std::is_void_v)` 在编译期就选好走哪条路,void 的走"调用但不赋值",非 void 的走"调用并 return"。这是速查篇里讲过的标准模式,这里不展开了。 --- ## 第五步:查询接口 -最后加上一组查询方法,让调用方可以在执行前检查回调的状态。 +骨架还差最后一块——一组查询接口,让调用方在执行前能探一下回调现在到底什么状态。 ```cpp [[nodiscard]] bool is_cancelled() const noexcept { @@ -249,15 +237,15 @@ void set_token(std::shared_ptr token) { } ``` -`is_cancelled()` 的逻辑是:状态不是 kValid 就返回 true(空回调和已消费回调都算"已取消"),如果有令牌且令牌失效也返回 true。`maybe_valid()` 暂时就是 `!is_cancelled()`。`is_null()` 只检查是否从未被赋值。`operator bool()` 综合了空和取消两个条件。 +`is_cancelled()` 这一段判定的口径,笔者得跟您讲清楚:只要状态不是 `kValid`,一律算作"已取消"——空回调和已消费回调在这层语义上归为一类,对调用方来说都是"别指望能跑了"。再叠一层令牌检查,令牌挂了且失效也算取消。`maybe_valid()` 现阶段就是 `!is_cancelled()`,留这个名字是为后面引入跨序列语义时做扩展。`is_null()` 只盯一件事——是否从未被赋值,跟取消是两码事。`operator bool()` 把"非空"和"未取消"两个条件合在一起,是调用点最常用的判活入口。 -所有查询方法都标注了 `[[nodiscard]]`——调用这些方法就是为了拿返回值做判断,忽略返回值的调用大概率是手滑写错了。`explicit` 关键字防止隐式转换到 `bool`。 +几个查询方法清一色挂了 `[[nodiscard]]`。调用方拿这些方法的返回值就是冲着做判断去的,忽略返回值的调用基本等于手滑,编译器得替咱们吼一声。`operator bool()` 那个 `explicit` 是老规矩,挡掉隐式转换,免得 `cb` 悄悄溜进本该要 `int` 的位置。 --- ## 验证核心骨架 -骨架搭完了,我们来快速验证几个基本场景: +骨架搭完,笔者习惯性地先跑几个最朴素的场景压一压——别上来就追求边界,先把基本盘打实: ```cpp #include "once_callback/once_callback.hpp" @@ -292,15 +280,7 @@ int main() { } ``` -如果这四个场景都通过——构造回调能拿到正确的返回值、void 回调能正常执行、捕获 `unique_ptr` 的回调用完之后资源被释放、移动后源对象变空目标对象有效——骨架就没有问题。 - ---- - -## 小结 - -这一篇我们分五步搭建了 OnceCallback 的核心骨架。模板偏特化 `OnceCallback` 通过模式匹配拆解函数类型。三个数据成员各司其职——`func_` 负责类型擦除、`status_` 负责三态管理、`token_` 负责取消机制。构造函数用 `requires not_the_same_t` 保护移动构造函数不被劫持。`run()` 用 deducing this 在编译期拦截左值调用,`impl_run()` 通过"先 move 出 func_ 再执行"的顺序保证消费语义的异常安全。 - -下一篇我们往骨架上加第一个组件——`bind_once()`,实现参数绑定。 +这四个 case 是骨架的最低门槛:非 void 回调要拿到正确的返回值、void 回调的副作用要正常触发、捕获 `unique_ptr` 的 move-only 回调用完资源得释放、移动之后源对象变空目标对象能用。全绿,骨架就能扛住后续的组件挂载;任何一个挂了,不用急着往下走,先回头查这一步。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-3-once-callback-bind-once.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-3-once-callback-bind-once.md index 7d58c2544..828c88ab9 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-3-once-callback-bind-once.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-3-once-callback-bind-once.md @@ -24,24 +24,11 @@ title: OnceCallback 实战(三):bind_once 实现 --- # OnceCallback 实战(三):bind_once 实现 -## 引言 +骨架搭好了,`run()` 也能消费回调了。可笔者写着写着就撞上一种很常见的别扭:每次构造 OnceCallback 都得塞一个签名完整的可调用对象进去,参数全得在调用那一刻才给齐。现实里哪有这么齐整——十有八九是几个参数在创建回调时就捏死了,只有剩下那一两个得留到调用现场。 -核心骨架搭好了,`run()` 能消费回调了。但每次构造 OnceCallback 都得传一个签名叫 `R(Args...)` 的可调用对象,所有参数都得在调用时才传入。现实中经常遇到的情况是:某些参数在创建回调时就已经知道了,只有一部分参数要留到调用时才传入。`bind_once` 就是用来解决这个问题的——它把"已知参数"提前塞进回调里,让调用方只需关心"未知参数"。 +`bind_once` 就是干这事的。它把"已经定下来的参数"提前塞进回调里,调用方只管剩下的那几个。咱们这一篇把它的实现逐行抠一遍,再手动展开一个完整的模板实例化,让您看清编译器在背后到底做了哪些动作。 -这一篇我们逐行拆解 `bind_once` 的实现,手动展开一个完整的模板实例化例子,让你看清编译器在背后做了什么。 - -> **学习目标** -> -> - 理解参数绑定解决了什么问题 -> - 逐行理解 `bind_once` 的完整实现 -> - 手动展开一个具体的模板实例化,看清编译器做了什么 -> - 理解为什么 `Signature` 必须显式指定 - ---- - -## 参数绑定解决了什么问题 - -先看一个没有 `bind_once` 时的场景。假设你有一个三参数函数,但前两个参数在绑定时就能确定: +先看没有 `bind_once` 的样子。假设有个三参函数,前两个参数绑定时就能定: ```cpp int compute(int x, int y, int z) { @@ -53,9 +40,9 @@ auto cb = OnceCallback(compute); int r = std::move(cb).run(10, 20, 30); // r == 60 ``` -如果 `x = 10` 和 `y = 20` 在绑定时就确定了,只有 `z` 要留到调用时传入,我们希望得到一个只需传一个参数的 `OnceCallback`。 +如果 `x = 10` 和 `y = 20` 在绑定时就确定了,只有 `z` 要留到调用时传入,咱们真正想要的是个只收一个参数的 `OnceCallback`。 -不用 `bind_once`,你只能手写一个 lambda 包一层: +不用 `bind_once`,您只能手写一个 lambda 包一层: ```cpp auto wrapped = OnceCallback( @@ -64,18 +51,16 @@ auto wrapped = OnceCallback( int r = std::move(wrapped).run(30); // r == 60 ``` -能用,但如果参数多了、类型复杂了(比如绑定的是 move-only 的 `unique_ptr`),手写 lambda 就会变得很繁琐。`bind_once` 就是把这个"手写 lambda 包一层"的过程自动化了。 +能跑。可参数一多、类型一复杂(比如绑的是 move-only 的 `unique_ptr`),手写 lambda 就开始烦人了。`bind_once` 干的就是把这个"手写 lambda 包一层"的过程自动化掉。 ```cpp auto bound = bind_once(compute, 10, 20); int r = std::move(bound).run(30); // r == 60 ``` ---- - ## bind_once 的完整实现逐行拆解 -对照源码,我们逐行理解 `bind_once` 做了什么。 +先把源码整个摆出来,咱们对着它一段段啃。 ```cpp template @@ -94,15 +79,11 @@ auto bind_once(F&& funtor, BoundArgs&&... args) { } ``` -### 模板参数 - -`bind_once` 有三个模板参数。`Signature` 是目标回调的函数签名(比如 `int(int)`),必须由调用方显式指定。`F` 是可调用对象的类型(lambda 的闭包类型、函数指针类型等),由编译器从第一个函数参数推导。`BoundArgs...` 是绑定参数的类型包,也是编译器推导的。 +## 从模板参数一路看到 lambda 体 -### lambda 捕获列表 +先看模板参数,这是入口。`bind_once` 顶上挂了三个:`Signature` 是目标回调的签名(比如 `int(int)`),必须您手动写,编译器推不出来;`F` 是那个可调用对象的类型(lambda 闭包、函数指针之类),由第一个实参推;`BoundArgs...` 是绑定参数的类型包,跟着后面的实参走。后两个是 CTAD 干的活,只有第一个得您亲自动手。 -捕获列表是整个实现中最精巧的部分。`f = std::forward(funtor)` 用初始化捕获(init capture)把可调用对象完美转发到 lambda 闭包里——如果传入的是右值,它被移动进来;如果传入的是左值,它被拷贝进来。 - -`...bound = std::forward(args)` 是 C++20 引入的 lambda init capture pack expansion。它为 `BoundArgs...` 中的每一个类型生成一个对应的捕获变量,每个变量用 `std::forward` 完美转发初始化。假设 `BoundArgs = {int, std::string}`,展开后等价于: +接下来是捕获列表,这块是整套实现里最精巧的。`f = std::forward(funtor)` 用 init capture 把可调用对象完美转发进闭包:传进来的是右值就移进来,是左值就拷进来,值类别一路保到底。下一行 `...bound = std::forward(args)` 是 C++20 才有的 lambda init capture pack expansion,给 `BoundArgs...` 里每个类型都发一个捕获变量,各自走 `std::forward` 初始化。要是 `BoundArgs = {int, std::string}`,展开完等价于: ```cpp [f = std::forward(funtor), @@ -110,13 +91,11 @@ auto bind_once(F&& funtor, BoundArgs&&... args) { b2 = std::forward(arg2)] ``` -### lambda 参数与 mutable - -`(auto&&... call_args)` 是泛型 lambda 的转发引用参数——运行时传入的参数通过它接收。`auto&&` 在这里等效于模板参数的 `T&&`,是转发引用。 +参数列表 `(auto&&... call_args)` 接的是运行时才传进来的那些。`auto&&` 在这儿等同于模板参数的 `T&&`,是转发引用,不是右值引用,新手很容易看走眼。 -`mutable` 关键字不可省略——lambda 内部需要调用 `std::move(f)` 和 `std::move(bound)...`,这些操作会修改捕获变量。如果 lambda 是 const 的,捕获变量在内部就是 const 的,没法从 const 对象上 move。 +`mutable` 这关键字千万别省。lambda 体里要调 `std::move(f)` 和 `std::move(bound)...`,这俩操作都得改捕获变量。lambda 不写 `mutable` 就是 const 的,捕获变量在里头也跟着 const——您没法从 const 对象上 move,编译器当场给您撅回来。 -### lambda 体 +最后一层是 lambda 体: ```cpp return std::invoke( @@ -126,15 +105,13 @@ return std::invoke( ); ``` -`std::invoke` 统一处理所有类型的可调用对象——前置知识(二)里已经讲过了。`std::move(f)` 把可调用对象以右值方式传出,`std::move(bound)...` 把所有绑定参数以右值方式传出(因为 `mutable` lambda 内部的捕获变量是左值,需要用 `std::move` 转成右值),`std::forward(call_args)...` 把运行时参数完美转发。 +`std::invoke` 在前置知识(二)讲过了,它统一兜住所有形态的可调用对象,成员函数指针也不例外。`std::move(f)` 和 `std::move(bound)...` 把捕获进来的东西按右值甩出去——因为 `mutable` lambda 里的捕获变量本身是左值,想以右值出栈就得靠 `std::move` 显式打一下;`call_args...` 那行则是把运行时参数按原样完美转发。 -绑定参数在前(`std::move(bound)...`),运行时参数在后(`call_args...`),这个顺序很重要——它决定了哪些参数被"预绑定"、哪些参数在调用时才传入。 - ---- +这里有个顺序得盯一眼:绑定参数在前,运行时参数在后。不是随手排的,它直接决定了哪些参数被"预绑定"、哪些留到调用那一刻。排反了,签名和实参就对不上。 ## 手动展开一个具体例子 -让我们用一个具体的调用例子,手动展开模板实例化后的完整代码。假设: +光看源码还是隔着一层。咱们拿一个具体调用,把模板实例化后的样子手动铺开,看看编译器到底生成了什么。假设: ```cpp struct Calc { @@ -146,11 +123,11 @@ auto bound = bind_once(&Calc::multiply, &calc, 5); int r = std::move(bound).run(8); // r == 40 ``` -### 模板参数推导 +## 一步步把模板铺开 -`Signature = int(int)`(显式指定),`F = int (Calc::*)(int, int)`(成员函数指针类型),`BoundArgs = {Calc*, int}`(对象指针 + 第一个参数)。 +先推参数。`Signature = int(int)` 是您写的,没得商量;`F = int (Calc::*)(int, int)`——成员函数指针类型,编译器从 `&Calc::multiply` 推出来的;`BoundArgs = {Calc*, int}`,一个对象指针加头一个参数。 -### lambda 捕获展开 +捕获列表展开长这样: ```cpp [f = std::forward(&Calc::multiply), @@ -158,53 +135,43 @@ int r = std::move(bound).run(8); // r == 40 b2 = std::forward(5)] ``` -`f` 捕获了成员函数指针,`b1` 捕获了对象指针,`b2` 捕获了绑定的整数 5。 - -### lambda 体内的 std::invoke 展开 +`f` 咬住成员函数指针,`b1` 咬住对象指针,`b2` 咬住那个绑定的整数 5。 -当 `bound.run(8)` 被调用时,`call_args = {8}`。`std::invoke` 收到的是: +接着看 `bound.run(8)` 真正被调用时发生了什么。这一刻 `call_args = {8}`,lambda 体里的 `std::invoke` 收到的实参是: ```cpp std::invoke(std::move(f), std::move(b1), std::move(b2), 8) ``` -也就是: +也就是: ```cpp std::invoke(&Calc::multiply, &calc, 5, 8) ``` -`std::invoke` 检测到第一个参数是成员函数指针,第二个参数是指向对象的指针,于是展开为: +`std::invoke` 一看头一个参数是成员函数指针,第二个是指向对象的指针,自动按成员调用规则展开: ```cpp ((*(&calc)).*(&Calc::multiply))(5, 8) ``` -等价于 `calc.multiply(5, 8)`,结果为 `40`。 +等价于 `calc.multiply(5, 8)`,结果 `40`。整个魔法其实就是 `std::invoke` 对成员函数指针那条重载在做兜底。 -### 生命周期陷阱 +## 这里有个生命周期坑 -注意 `b1 = std::forward(&calc)` 捕获的是一个裸指针 `&calc`。`bind_once` 不会管理 `calc` 的生命周期。如果 `calc` 在回调被调用之前被销毁了,lambda 内部持有的就是一个悬空指针,`std::invoke` 通过悬空指针访问已释放的内存——未定义行为。 +`b1 = std::forward(&calc)` 捕的是个裸指针 `&calc`。`bind_once` 压根不替您管 `calc` 的死活。要是 `calc` 在回调跑之前先被销毁了,lambda 里头那个就是一根悬空指针,`std::invoke` 顺着它去摸已经释放的内存——未定义行为,典型的 use-after-free。 -Chromium 用 `base::Unretained` 显式标记裸指针的安全性,用 `base::Owned` 接管所有权,用 `base::WeakPtr` 在对象析构时自动取消回调。我们的简化版暂时把安全责任交给调用方。 - ---- +Chromium 在这块下了三套补丁:用 `base::Unretained` 显式把"我担保它活着"标出来,用 `base::Owned` 直接把所有权接管掉,用 `base::WeakPtr` 让回调在对象析构那一刻自动作废。咱们这个简化版暂时图省事,把担子甩给调用方——真要上生产,这三套总得补一套。 ## 为什么签名必须显式指定 -你可能注意到 `bind_once(...)` 的 `int(int)` 必须手动写。理想情况下,编译器应该能从可调用对象的签名和绑定参数的数量自动推导出剩余签名。但这件事在 C++ 里比想象中困难。 - -对于函数指针 `R(*)(Args...)`,可以通过模板偏特化提取参数列表,然后用编译期的"类型列表切片"去掉前 N 个类型。对于有确定签名的仿函数,可以通过 `decltype(&T::operator())` 提取签名。但对于**泛型 lambda**(`[](auto x) { ... }`),它的 `operator()` 本身是模板,不存在唯一确定的签名——编译器无法在类型层面获取"这个 lambda 接受什么参数"的信息。 - -Chromium 为此写了几百行模板元编程代码来处理各种边界情况。对教学目的来说,让调用方多写一个模板参数 `int(int)` 是更务实的选择。 - ---- +您大概注意到了,`bind_once(...)` 里那个 `int(int)` 必须亲手写上去。理想情况下,编译器该能从可调用对象的签名和绑定参数的个数,自动把剩余签名推出来。但这事在 C++ 里比想象中难缠得多。 -## 小结 +函数指针 `R(*)(Args...)` 还算好办,靠模板偏特化能把参数列表抠出来,再在编译期做"类型列表切片"砍掉前 N 个就行;有固定签名的仿函数也行,`decltype(&T::operator())` 一发入魂。真正的硬骨头是泛型 lambda(`[](auto x) { ... }`),它的 `operator()` 本身就是个模板,压根不存在唯一确定的签名,编译器在类型层面拿不到"这个 lambda 到底吃什么参数"这条信息。 -这一篇我们逐行拆解了 `bind_once` 的实现。它通过 C++20 的 lambda capture pack expansion 把绑定参数展开到 lambda 的捕获列表中,通过 `std::invoke` 统一处理各种可调用对象(特别是成员函数指针),通过 `mutable` 关键字允许 lambda 内部修改捕获变量。我们手动展开了一个成员函数绑定的完整模板实例化过程,看清了 `std::invoke` 是如何把成员函数指针 + 对象指针展开成普通的成员函数调用的。最后讨论了为什么 `Signature` 必须显式指定——泛型 lambda 的存在让自动推导变得极其复杂。 +Chromium 为了把这套边界情况兜住,写了足足几百行模板元编程。教学版就没必要陪它卷了,让调用方多敲一个 `int(int)` 是性价比最高的安排。 -下一篇我们去看取消令牌的设计——一个用 `shared_ptr` 和 `atomic` 实现的轻量级取消机制。 +下一篇咱们去看取消令牌怎么搭——一个用 `shared_ptr` 配 `atomic` 拼出来的轻量级取消机制。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-4-once-callback-cancellation-token.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-4-once-callback-cancellation-token.md index 0838a55a3..6c9c57aca 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-4-once-callback-cancellation-token.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-4-once-callback-cancellation-token.md @@ -26,32 +26,17 @@ title: OnceCallback 实战(四):取消令牌设计 --- # OnceCallback 实战(四):取消令牌设计 -## 引言 +回调这东西,绑出来的瞬间到真跑起来,中间通常隔着一段任务队列的调度。这段空档里啥都可能发生:绑定的对象没了、任务被上层撤了、用户已经关掉那个 tab 了。等回调终于轮到自己执行,它前面那个"我还有没有必要跑"的检查,就是这一篇要做的取消令牌(cancellation token)。 -异步编程里有一个很常见的需求:回调创建之后、执行之前,某个外部条件发生了变化,导致这个回调已经没有意义了——比如回调绑定的对象已经被销毁了,或者任务已经被取消了。这时候我们希望回调在执行前能检查一下"我还该不该执行",而不是傻乎乎地跑一遍。 +Chromium 在 `//base` 里把这事儿做成了 `WeakPtr` 那一整套——对象析构,挂在它身上的回调集体作废。那套机制完整讲下来量不小,咱们这里先做个最小可用版本:一个能被多人共享、一次作废全部失效、还能跨线程检查的轻量标志。等手头这个跑通,您回头看 Chromium 的 `WeakPtr` 会顺很多。 -这就是取消令牌(cancellation token)的用途。这一篇我们来实现一个简化版的取消令牌,然后看它是怎么集成到 OnceCallback 的执行流程中的。 +## 一个标志,怎么共享 -> **学习目标** -> -> - 理解取消令牌的概念和动机 -> - 逐行理解 `CancelableToken` 的实现 -> - 理解取消机制在 `impl_run()` 中的集成方式 -> - 理解 void 和非 void 回调在取消时的不同行为 +取消令牌要解决的核心矛盾就一句话:作废这个动作发生在回调外部,检查这个动作发生在回调内部,两边大概率不在同一处、甚至不在同一线程。所以令牌本身得是个能被两边各拿一份、却又指向同一份状态的东西。 ---- - -## 取消令牌的概念 - -你可以把取消令牌想象成一张"通行证"。创建回调的时候,给回调发一张通行证,通行证上写着"有效"。某个时刻外部条件变化了(比如绑定的对象被销毁),外部代码说"通行证作废了"(调用 `invalidate()`)。之后,所有持有这张通行证的回调在执行前检查时都会发现"通行证已经无效",跳过执行。 - -在 Chromium 里,这个"通行证"就是 `WeakPtr` 内部的控制块——`WeakPtr` 指向的对象被销毁后,控制块中的标志位被清除,所有绑定到这个 `WeakPtr` 的回调自动取消。我们的简化版不需要 `WeakPtr` 那么复杂,只需要一个简单的"有效/无效"标志。 - -### 核心需求 +这意味着两件事必须同时成立。令牌得能拷贝,回调内部持一份,外部想取消它的代码也持一份,两份指到同一处;还有,那份共享状态的读写得是多线程安全的,外部线程调 `invalidate()`、回调线程查 `is_valid()`,这一对操作撞在一起不能读到撕裂的值。 -取消令牌需要满足三个条件:多个回调可以共享同一个令牌(一个 `invalidate()` 让所有回调同时失效)、令牌可以被拷贝和移动(方便在 OnceCallback 内部和外部各持有一份)、失效检查是多线程安全的(外部线程可能在一个线程调用 `invalidate()`,回调在另一个线程检查 `is_valid()`)。 - ---- +咱们先看实现,再回头说为什么长这样。 ## CancelableToken 的完整实现 @@ -83,15 +68,15 @@ public: } // namespace tamcpp::chrome ``` -### 为什么要用嵌套结构体 Flag +### 为什么非得套个嵌套结构体 Flag -你可能觉得奇怪——为什么不直接在 `CancelableToken` 里放一个 `std::atomic`?原因是 `shared_ptr` 管理的是一个堆上的对象。如果直接在 `CancelableToken` 里放 `atomic`,`shared_ptr` 管理的是 `CancelableToken` 本身——但 `CancelableToken` 还有自己的 `flag_` 成员,这就变成了 `shared_ptr` 包含 `shared_ptr` 的循环。 +笔者第一次写这玩意儿的时候手一快,直接在 `CancelableToken` 里塞了个 `std::atomic valid`,心想这不就完了?写完才发现不对劲——`shared_ptr` 这下没法管这个 `valid` 了。要共享它,您得让 `shared_ptr` 指向一个包含 `valid` 的对象。可这个对象如果是 `CancelableToken` 本身,那 `CancelableToken` 里又有个 `shared_ptr` 成员,这就绕成了 `shared_ptr` 包着 `shared_ptr` 的套娃。 -用嵌套的 `Flag` 结构体把需要共享的状态隔离出来,`shared_ptr` 直接管理 `Flag`,`CancelableToken` 的拷贝和移动都通过 `shared_ptr` 的引用计数自动处理——简洁又正确。另一个好处是 `Flag` 结构体方便后续扩展——如果以后需要加更多原子标志(比如取消原因码),直接往 `Flag` 里加就行。 +把要共享的那点状态单独拎出来,塞进一个 `Flag` 结构体,让 `shared_ptr` 直接管它,这层窗户纸才算捅破。`CancelableToken` 的拷贝和移动这时候都不用您操心——默认生成的拷贝构造就是把内部的 `shared_ptr` 浅拷一份,引用计数自增,所有副本自然指向同一个 `Flag`。笔者后来想想,这结构体还有个意外好处:以后真要往里加东西,比如一个取消原因码,直接在 `Flag` 里加字段就行,外头一行都不用动。 -### shared_ptr 的共享机制 +### 共享是怎么落地的 -`CancelableToken` 的拷贝构造和拷贝赋值是编译器默认生成的——它做的就是把 `shared_ptr` 拷贝一份,引用计数 +1。所有通过拷贝创建的令牌副本共享同一个 `Flag` 对象。当任何一个副本调用 `invalidate()` 时,修改的是同一个 `Flag::valid`,所有副本在下次调用 `is_valid()` 时都会看到 `false`。 +光说"拷贝时共享"可能还是抽象。您看下面这段,`token2` 是 `token1` 的拷贝,两者内部那个 `shared_ptr` 指到同一块内存。`token1` 调 `invalidate()` 改的就是那块内存里的 `valid`,`token2` 下次查 `is_valid()` 读的也是同一块,自然就看到 `false`: ```cpp auto token1 = std::make_shared(); @@ -101,17 +86,17 @@ token1->invalidate(); assert(!token2->is_valid()); // token2 也看到了失效 ``` -### memory_order_acquire/release 配对 +这里笔者要提醒一句:示例里外层又套了个 `shared_ptr`,纯是为了写起来短。真正的用法是 `CancelableToken` 本身按值拷贝(它内部已经用 `shared_ptr` 共享状态了),不需要再在外面裹一层智能指针。 -`invalidate()` 用 `memory_order_release` 存储 `false`,`is_valid()` 用 `memory_order_acquire` 加载。这是一对配对的内存序。`release` store 保证了在 store 之前的所有写操作(包括调用 `invalidate()` 之前的任何状态修改)对其他线程可见。`acquire` load 保证了在 load 之后的所有读操作能看到 release store 之前的写入。 +### acquire/release 这对配子 -在我们的场景里,这意味着如果一个线程调用了 `invalidate()`,另一个线程随后调用 `is_valid()` 时一定能看到 `false`——不会有"我刚刚 invalidate 了但 is_valid 还是返回 true"的情况。这是多线程安全的保证。 +`invalidate()` 用 `memory_order_release` 存 `false`,`is_valid()` 用 `memory_order_acquire` 取。这不是随手选的。release 存保证:在它之前的那些写(比如作废前对对象状态的修改),在别的线程通过这次 store 读到新值时,全部变得可见。acquire 取则保证:读到那个新值之后,后续的读都能看到 release 之前的那批写。 ---- +落到咱们这场景里,一个线程调 `invalidate()`,另一个线程紧接着查 `is_valid()`,只要后者读到了 `false`,前者 invalidate 之前干的所有活儿,对后者都是可见的。您不会撞上"我刚 invalidate 完,怎么 is_valid 还说 true"这种鬼事。这也是它敢跨线程用的底气。如果两个序都换成 `memory_order_relaxed`,可见性保证就没了,flag 翻没翻、别的线程什么时候看到,全凭运气,这是新手最容易手滑的地方。 ## 集成到 OnceCallback -取消令牌通过 `set_token()` 方法设置到 OnceCallback 中: +取消令牌得挂到回调身上才有用武之地。挂的入口是一个 `set_token()`: ```cpp void set_token(std::shared_ptr token) { @@ -119,9 +104,9 @@ void set_token(std::shared_ptr token) { } ``` -`token_` 是 `shared_ptr` 类型,默认是空指针(不启用取消机制)。设置之后,取消令牌的所有权被转移到 OnceCallback 内部。 +`token_` 默认是空的 `shared_ptr`,等于"这个回调不参与取消机制"。一旦 `set_token` 进来,令牌就被搬进回调内部,跟着回调一起活一起死。这里笔者特意用了 `shared_ptr` 而不是裸 `CancelableToken`,是因为 OnceCallback 是 move-only 的,它持有的东西要么能整体搬走、要么得是个能廉价拷贝的句柄——`shared_ptr` 正好是后者。 -### is_cancelled() 的完整逻辑 +### is_cancelled() 看的是两个地方 ```cpp [[nodiscard]] bool is_cancelled() const noexcept { @@ -131,9 +116,9 @@ void set_token(std::shared_ptr token) { } ``` -两层检查。第一层:状态不是 kValid 就返回 true——空回调(kEmpty)和已消费回调(kConsumed)都算"已取消"。这很合理——空回调没东西可执行,已消费回调已经执行过了。第二层:如果有取消令牌且令牌失效了,也返回 true。 +这个判断不是只看令牌。它先瞄一眼回调自己的 `status_`:空回调(`kEmpty`)和已经跑过的回调(`kConsumed`)都直接算作"已取消"。这是合理的,空回调里头没东西可执行,跑过的回调不该再跑。`status_` 这一关过了,才轮到令牌出场,有令牌且令牌已失效,也算取消。两道关卡都得在,只看令牌的话,空回调和已消费回调就漏网了。 -### impl_run() 中的取消检查 +### impl_run() 里的那一道门 ```cpp ReturnType impl_run(FuncArgs... args) { @@ -154,21 +139,17 @@ ReturnType impl_run(FuncArgs... args) { } ``` -取消检查在执行可调用对象**之前**进行。如果已取消,直接消费回调但不执行——`status_` 设为 kConsumed,`func_` 置为 nullptr(析构其内部的可调用对象,释放资源)。 +取消检查摆在执行可调用对象**之前**,这个位置很关键。检查一旦命中,回调当场被标记成 `kConsumed`——`func_` 也跟着置空,里头那个 lambda 及其捕获的资源随即释放。从外部看,这次 `run()` 像是消费掉了回调,只是没真正执行函数体。这个"消费但不执行"的语义,是后面 void 和非 void 行为分叉的根。 ---- +## void 和非 void 回调,取消时为啥不一样 -## void 与非 void 回调的取消行为差异 +这是整个设计里笔者觉得最值得停下来说的一处。同样是命中取消检查,void 回调直接 `return`,啥也不报;非 void 回调却抛 `std::bad_function_call`。乍看好像不一致,细想是被迫的。 -这里有一个设计决策值得展开讲——void 回调被取消时直接 return(不执行,也不报错),而非 void 回调被取消时抛出 `std::bad_function_call` 异常。 +void 回调的调用方压根不期待返回值——`std::move(cb).run();` 调完就完了,他根本不知道您执行没执行。所以取消时静默跳过,对调用方是完全透明的,没毛病。 -原因是调用方的期望不同。void 回调的调用方不期望返回值——调用 `std::move(cb).run()` 之后就结束了,不关心回调有没有实际执行。所以被取消的 void 回调直接跳过执行,对调用方是透明的。 +非 void 回调就尴尬了。调用方写的是 `int result = std::move(cb).run();`,他盯着那个返回值呢。回调被取消了,您给啥返回值?随便填个 0?那调用方拿到一个 0,会以为回调正经跑完给了他 0,后续逻辑照着 0 往下走——这种"看起来成功其实啥也没干"的 bug,比崩了还难查。所以这里宁可抛异常,明明白白告诉调用方"这次没跑成,您自己看着办"。 -非 void 回调的调用方期望拿到返回值——`int result = std::move(cb).run()`。如果回调被取消了,我们没法提供一个有意义的返回值。返回一个默认值(比如 0)可能掩盖错误——调用方以为回调正常执行了,实际上什么都没做。抛异常虽然看起来激进,但它明确告诉调用方"出了问题",比默默返回错误值更安全。 - -Chromium 在这里选择直接终止程序(`CHECK` 失败),理由是在 Chrome 的架构中,被取消的回调不应该被调用——调用方应该在调用前检查 `is_cancelled()`。我们选择异常是为了在测试中更容易捕获和验证,而不是直接让程序崩溃。 - ---- +Chromium 的选择更狠:直接 `CHECK` 失败终止进程。它的逻辑是,在我这套架构里,调用方本来就该在调 `run()` 之前自己查 `is_cancelled()`,您都查过了还往里冲,那就是 bug,崩给您看。咱们这里走异常那条路,主要是为了让测试好写,单元测试里 `REQUIRE_THROWS` 就能断言,不至于一跑用例整个进程挂掉。两种选择没有对错,看您这套回调打算用在多严苛的环境里。 ## 使用示例 @@ -198,15 +179,7 @@ std::move(cb2).run(); // 取消的 void 回调不执行,不抛异常 assert(!executed); // 回调没有被执行 ``` -注意第二个例子中——`cb2.run()` 调用了,但回调内部的 lambda 没有执行。`impl_run()` 在执行前检查到令牌已失效,直接消费回调并 return。 - ---- - -## 小结 - -这一篇我们实现了取消令牌并把它集成到了 OnceCallback 中。`CancelableToken` 用 `shared_ptr` + `atomic` 实现了轻量级的取消机制——所有令牌副本共享同一个 `Flag` 对象,一个 `invalidate()` 让所有副本同时失效。集成方式是在 `impl_run()` 执行前检查令牌状态——如果已取消,直接消费回调但不执行。void 回调直接 return,非 void 回调抛出 `std::bad_function_call`,这个差异来自调用方对返回值的不同期望。 - -下一篇我们去看 `then()` 链式组合——OnceCallback 四个功能中所有权设计最精巧的一个。 +第二个例子要看仔细:`cb2.run()` 确实被调了,但里头的 lambda 一行没跑。`impl_run()` 在执行前撞见令牌失效,直接把回调消费掉然后 `return`——`executed` 还是 `false`,这就是 void 回调取消时的透明语义。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-5-once-callback-then-chaining.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-5-once-callback-then-chaining.md index 265f3db8a..7bc1bc518 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-5-once-callback-then-chaining.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-5-once-callback-then-chaining.md @@ -24,24 +24,30 @@ title: OnceCallback 实战(五):then 链式组合 --- # OnceCallback 实战(五):then 链式组合 -## 引言 +`then()` 把两个回调串成一根管道,上一个的输出喂给下一个。Unix 管道的老把戏,您肯定不陌生: -`then()` 允许我们把两个回调串联成一个管道——第一个回调的输出是第二个回调的输入。听起来简单,但它是 OnceCallback 四个功能中所有权设计最精巧的一个。因为 OnceCallback 是 move-only 的,`then()` 必须把原回调的所有权完整地转移到新回调中,不能有任何共享或泄露。 +```bash +# Unix 管道:cmd1 的输出是 cmd2 的输入 +echo "hello" | tr 'h' 'H' | wc -c +``` -这一篇我们从管道思维出发,逐行拆解 `then()` 的实现,重点理解所有权链和 void/非 void 分支的处理。 +落到回调上,就是同一回事——回调 A 的输出交给回调 B: -> **学习目标** -> -> - 理解 `then()` 的管道语义和所有权链设计 -> - 逐行理解 `then()` 的完整实现 -> - 理解 void 前缀回调的特殊处理 -> - 对比 `then()` 用 `&&` 限定和 `run()` 用 deducing this 的选择理由 +```cpp +auto pipeline = OnceCallback([](int a, int b) { + return a + b; // 第一步:3 + 4 = 7 +}).then([](int sum) { + return sum * 2; // 第二步:7 * 2 = 14 +}); ---- +int result = std::move(pipeline).run(3, 4); // result == 14 +``` -## 管道思维:then() 的语义 +咱们一开始想得轻巧,`then()` 不就是把俩回调缝一块嘛。可 OnceCallback 是 move-only 的,原回调的所有权得整副家当搬进新回调里——少了 `func_`、少了 `token_`、少了 `status_`,哪一样都不行。这一篇笔者就带您逐行把 `then()` 拆开,重点盯两件事:所有权链是怎么一节一节接上的,void 和非 void 两套返回类型又是怎么分叉处理的。 -如果你用过 Unix 管道,`then()` 的语义就很直觉了: +## 所有权:then() 的真问题 + +如果您用过 Unix 管道,`then()` 的语义就很直觉了: ```bash # Unix 管道:cmd1 的输出是 cmd2 的输入 @@ -64,18 +70,16 @@ int result = std::move(pipeline).run(3, 4); // result == 14 --- -## 所有权是 then() 的核心挑战 - -串联后的新回调需要持有原回调和后续回调的**所有权**——否则原回调可能在外部被提前消费掉,管道就断了。而 OnceCallback 是 move-only 的,这意味着 `then()` 必须消费 `*this`(原回调)和 `next`(后续回调),把两者的所有权转移到一个新的 lambda 闭包里。 +串联后的新回调得把原回调和后续回调都攥在自己手里。这事儿在普通 `std::function` 上不算难——拷一份就完事——可 OnceCallback 偏偏是 move-only 的,`func_`、`status_`、`token_` 一样都不许复制。`then()` 只能消费 `*this` 和 `next`,把两副家当整个搬进一个新的 lambda 闭包。 -整个所有权链条是这样的: +所有权链条画出来就一根线: ```mermaid graph LR A["新 OnceCallback"] --> B["move_only_function"] --> C["lambda 闭包"] --> D["原回调 + 后续回调"] ``` -每一层都通过移动语义传递所有权,没有任何共享或拷贝。这就是 move-only 语义在 `then()` 中的完整体现。 +每一节都是移动语义在接力,没有拷贝、没有共享。move-only 这套约束在 `then()` 里的完整长相,就是这根线。 --- @@ -115,7 +119,7 @@ auto OnceCallback::then(Next&& next) && { auto then(Next&& next) && ``` -末尾的 `&&` 使其成为右值限定的成员函数——只能通过 `std::move(cb).then(next)` 或临时对象 `.then(next)` 调用。如果调用方写了 `cb.then(next)`(左值调用),编译器直接报"没有匹配的重载函数"。这是表达消费语义的另一种方式——和 `run()` 用 deducing this 不同,`then()` 不需要区分左值和右值给出不同的错误信息,直接用 ref-qualifier 更简洁。 +末尾那个 `&&` 把它做成了右值限定的成员函数,意思是 `then()` 只接受 `std::move(cb).then(next)` 或者临时对象上的 `.then(next)`。谁要是不小心写了 `cb.then(next)` 这种左值调用,编译器当场就甩一句"没有匹配的重载函数",连错都报得直白。这跟 `run()` 走 deducing this 那一套是两条路——`run()` 得在左值和右值上分别给出不同的错误信息,麻烦些;`then()` 不用区分,一个 ref-qualifier 就够了,干净。 ### std::decay_t\:退化去掉引用 @@ -123,36 +127,32 @@ auto then(Next&& next) && using NextType = std::decay_t; ``` -`Next` 可能是 `SomeLambda&&`(右值引用)或 `SomeLambda&`(左值引用),`std::decay_t` 把引用去掉,得到裸的 lambda 类型。后续用 `NextType` 做类型查询。 +`Next` 进来的时候可能是 `SomeLambda&&`,也可能是 `SomeLambda&`,沾着引用就不好做后续的类型推导。`std::decay_t` 把引用扒掉,留下裸的 lambda 类型,后面 `std::invoke_result_t` 就拿这个 `NextType` 去查返回。 ### if constexpr 的两个分支 -`then()` 的核心区别在于原回调的返回类型是不是 void。 +真正让 `then()` 分叉的,是原回调返回类型到底是不是 void。这一刀切下去,两边长得很不一样。 -**非 void 分支**:原回调返回一个值,这个值需要传给后续回调。 +原回调返回一个值的情况——也就是非 void 分支——这个值得接着往下喂给后续回调: ```cpp using NextRet = std::invoke_result_t; ``` -`std::invoke_result_t` 在编译期推导"把 `ReturnType` 类型的值传给 `NextType` 类型的可调用对象,返回什么类型"。这就是新回调的返回类型。 - -lambda 内部的执行流程:先调用原回调拿到中间结果 `mid`,再把 `mid` 传给后续回调。 +`std::invoke_result_t` 在编译期替咱们问一句:把一个 `ReturnType` 类型的值递给 `NextType` 这种可调用对象,它吐回来的又是什么类型?这便是新管道对外的返回类型。lambda 体内的活儿也好讲,先把原回调跑起来拿到中间结果 `mid`,再原样递给后续回调: ```cpp auto mid = std::move(self).run(std::forward(args)...); return std::invoke(std::move(cont), std::move(mid)); ``` -**void 分支**:原回调没有返回值,后续回调不接受参数。 +void 分支则换了个长相。原回调啥也不返回,后续回调自然也就不收参数: ```cpp using NextRet = std::invoke_result_t; ``` -`std::invoke_result_t` 推导的是"不带参数调用 `NextType`,返回什么类型"。 - -lambda 内部的执行流程:先执行原回调(不拿返回值),再执行后续回调(不传参数)。 +这里 `std::invoke_result_t` 推导的是"空着参数列表调 `NextType`,得到啥"。lambda 体内就两步:先跑原回调,结果扔掉不管;再把后续回调掏出来执行,也不传参: ```cpp std::move(self).run(std::forward(args)...); @@ -165,17 +165,15 @@ return std::invoke(std::move(cont)); [self = std::move(*this), cont = std::forward(next)] ``` -`self = std::move(*this)` 是整个所有权链的关键——它把当前 OnceCallback 对象的**所有内容**(`func_`、`status_`、`token_`)移动到 lambda 的闭包对象里。移动之后,当前对象进入"被移走"的状态——`func_` 和 `token_` 已经被搬走了。 - -`cont = std::forward(next)` 把后续回调也搬进 lambda 闭包。`std::forward` 保持 `next` 的值类别——右值就移动,左值就拷贝。 +`self = std::move(*this)` 是整条所有权链的要害。它把当前 OnceCallback 的全部家当——`func_`、`status_`、`token_`,一个不落——搬进 lambda 的闭包里。搬完之后,当前对象就是个被掏空的壳,`func_` 和 `token_` 都不归它了。`cont = std::forward(next)` 把后续回调也接进来,`std::forward` 守着 `next` 本来的值类别:右值就移动,左值就拷贝。 -这个 lambda 又被传给一个新的 `OnceCallback` 构造函数,存入新回调的 `std::move_only_function` 里。`move_only_function` 的类型擦除能力保证了不管 lambda 的实际类型是什么,都能被统一存储。 +这个 lambda 最终被递给一个新的 `OnceCallback` 构造函数,塞进它的 `std::move_only_function`。类型擦除那套机制,使得不管外头这个 lambda 长成什么样,都能被收编进同一个壳子。 --- ## 多级管道 -`then()` 可以链式调用,形成多级管道: +`then()` 自然可以一节一节接下去,接成多级管道: ```cpp using namespace tamcpp::chrome; @@ -191,33 +189,23 @@ std::string result = std::move(pipeline).run(5); // 5 * 2 = 10, 10 + 10 = 20, to_string(20) = "20" ``` -每次 `then()` 都会创建一个新的 OnceCallback,内部嵌套捕获了前一步的回调。调用最外层的 `run()` 时,执行过程是递归展开的:最外层回调被 `run()` → 执行其 lambda → lambda 内部对上一层调用 `std::move(self).run()` → 再对更上一层调用 → 直到底层。 - -性能上,每一层 `then()` 增加一次 `std::move_only_function` 的间接调用。对于 2-3 级的管道来说完全可接受。如果管道层级超过 10 级,可能需要考虑扁平化的管道结构来避免过深的嵌套——但这已经超出我们当前的讨论范围了。 +每调一次 `then()` 都会新铸一个 OnceCallback,里头嵌着捕获了前一步回调的闭包。最外层那次 `run()` 一动,执行就像套娃一样层层展开:最外层被 `run()` → 跑它自己的 lambda → lambda 里头对上一层再 `std::move(self).run()` → 再往上一层 → 一路钻到底。 ---- +代价也有。每多一级 `then()`,就多一次 `std::move_only_function` 的间接调用。两三级管道这点开销完全可以忽略;真要堆到十级以上,嵌套深了恐怕得换扁平化的管道结构——不过那已经离咱们眼下的题目太远,先按下不表。 ## 几个容易踩坑的地方 ### mutable 不可省略 -lambda 内部需要调用 `std::move(self).run()`——这个操作会修改 `self` 的状态(把 status 从 kValid 改为 kConsumed)。如果 lambda 是 const 的(没加 `mutable`),`self` 在内部就是 const 引用,没法在 const 对象上调用修改状态的操作,编译直接失败。 +lambda 里头要调 `std::move(self).run()`,这一下是真改 `self` 的状态——把 status 从 kValid 拨到 kConsumed。lambda 不加 `mutable`,`self` 在里头就是个 const 引用,对 const 对象动手脚这种事编译器见一次拦一次,直接报错。 ### self = std::move(*this) 的状态 -移动之后,当前 OnceCallback 对象的 `func_` 和 `token_` 都已经被 move 走了——它们处于"被移走"的状态。`status_` 没有被显式设为 kEmpty,而是保持原来的值。但因为 `func_` 已经被 move 走了,当前对象实际上已经不可用了——任何对它的操作都是未定义的。`then()` 的 `&&` 限定保证了调用方没法在调用 `then()` 之后继续使用原对象。 +搬完之后,原 OnceCallback 的 `func_` 和 `token_` 都已经离家出走了,落得个"被移走"的下场。`status_` 没人显式把它拨回 kEmpty,原值还挂着。可 `func_` 都空了,这壳子实际已经废了,谁再去碰它一下都是未定义行为。好在 `then()` 那个 `&&` 限定把门守死了,调用方压根没机会在 `then()` 之后接着用原对象。 ### 为什么用 std::invoke 而不是直接调用 -`cont` 是一个普通可调用对象(通常是 lambda),直接 `cont(mid)` 也能工作。但 `std::invoke` 是防御性编程——如果有人传进来一个成员函数指针作为后续回调,直接调用语法会失败,`std::invoke` 不会。统一使用 `std::invoke` 保证了无论传什么可调用对象都能正确工作。 - ---- - -## 小结 - -这一篇我们拆解了 `then()` 的完整实现。它的核心挑战是所有权管理——通过 `self = std::move(*this)` 把整个原回调搬进 lambda 闭包,建立完整的所有权链。`if constexpr` 处理 void 和非 void 返回类型的不同语义——void 回调不传参数给后续回调,非 void 回调传递中间结果。`then()` 用 `&&` 限定表达消费语义(比 `run()` 的 deducing this 更简洁,因为不需要自定义错误信息),`mutable` 关键字不可省略(因为内部需要修改 `self` 的状态)。 - -下一篇是系列的最后一篇——我们用系统化的测试用例来验证整个实现,并对比与 Chromium 原版的性能差异。 +`cont` 多半就是个 lambda,您直接写 `cont(mid)` 也跑得动。可万一哪天有人递进来一个成员函数指针当后续回调,直接调用的语法当场就废了,`std::invoke` 不会。统一走 `std::invoke`,就是图个无论对方使什么家伙式儿,咱们这一套都接得住。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-6-once-callback-testing-and-perf.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-6-once-callback-testing-and-perf.md index 0ff855178..a45967b43 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-6-once-callback-testing-and-perf.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/01-6-once-callback-testing-and-perf.md @@ -24,21 +24,11 @@ title: OnceCallback 实战(六):测试与性能对比 --- # OnceCallback 实战(六):测试与性能对比 -## 引言 - -到这里,OnceCallback 的四个核心功能——核心骨架、`bind_once`、取消令牌、`then()` 链式组合——都已经实现完了。这一篇做两件事:第一,系统化地梳理测试策略,确保实现在各种边界条件下都是正确的;第二,从性能角度分析我们的实现与 Chromium 原版、标准库方案之间的差异,弄清楚我们牺牲了什么、换来了什么。 - -> **学习目标** -> -> - 掌握按不变量组织测试用例的方法 -> - 理解六类测试的设计意图和关键断言 -> - 清楚我们的 OnceCallback 与 Chromium 原版在性能上的取舍关系 - ---- +核心骨架、`bind_once`、取消令牌、`then()` 链式组合——四块都拼完了,代码能编过,跑起来也对。但笔者写完没敢松口气,因为"能跑"和"在各种边角都对"中间还隔着一整套测试。这一篇咱们把测试补齐,顺便把笔者自己最在意的一件事摊开量一量:这套用 `std::move_only_function` 撸出来的东西,跟 Chromium 那两千多行手写的原版比,到底胖了多少、慢了多少,这些肉是从哪儿换来的。 ## 测试框架搭建 -我们使用 Catch2 v3 作为测试框架,通过 CPM(CMake Package Manager)自动拉取依赖。 +笔者用 Catch2 v3 做测试框架,依赖走 CPM(CMake Package Manager)自动拉。 ```cmake # test/CMakeLists.txt @@ -51,15 +41,15 @@ target_compile_options(test_once_callback PRIVATE -Wall -Wextra -Wpedantic) add_test(NAME test_once_callback COMMAND test_once_callback) ``` -Catch2 的 `REQUIRE` 宏比 `assert()` 强在它会报告具体的失败表达式、文件和行号,并且在同一个 `TEST_CASE` 内继续执行后续检查。`REQUIRE_THROWS_AS` 专门用于验证异常类型。 +笔者当初从 `assert()` 换到 Catch2 主要是图两个东西:`REQUIRE` 会把失败的表达式、文件名、行号一起吐出来,而且同一个 `TEST_CASE` 里后续检查还能接着跑(不像 `assert` 一炸就停);`REQUIRE_THROWS_AS` 则专门盯异常类型。盯异常这一条对咱们取消机制那段特别有用,后面会看到。 -运行测试:在 `build/` 目录下 `cmake --build . && ctest`。 +跑测试就是老套路,在 `build/` 下 `cmake --build . && ctest`。 --- ## 六类测试用例 -我们把测试组织成六个类别,每个类别聚焦一个独立的设计不变量。按不变量组织测试比按功能组织更不容易遗漏边界情况。 +笔者把测试拆成六类,每一类只盯一个设计不变量。为什么按不变量分、不按功能分?因为功能列表写完容易自我感动,觉得"该测的都测了",其实边角全漏掉;不变量是"这个东西在任何情况下都必须成立"的硬约束,围着它转,边界自然会冒出来。 ### A 类:基本调用与返回值 @@ -78,7 +68,7 @@ TEST_CASE("void return", "[once_callback]") { } ``` -验证最基本的构造和调用行为——非 void 回调返回正确的值,void 回调正常执行。void 返回走的是 `if constexpr (std::is_void_v)` 的另一条分支。 +最朴素的两个:非 void 回调要把返回值带出来,void 回调得正常跑完。void 这条走的是 `if constexpr (std::is_void_v)` 的另一条分支——笔者特意留着,因为这正是模板里两条路径最容易只测一条的地方。 ### B 类:移动语义 @@ -100,13 +90,13 @@ TEST_CASE("move semantics: source becomes null", "[once_callback]") { } ``` -move-only capture 测试验证了 OnceCallback 真正支持 move-only 的可调用对象——如果底层用 `std::function` 而不是 `std::move_only_function`,这段代码编译失败。移动语义测试验证了移动构造后源对象变为 kEmpty 状态。 +第一段 move-only capture 是笔者心里的硬指标:它直接证明咱们底层确实是 `std::move_only_function` 而不是图省事用的 `std::function`——后者这行压根编不过。第二段测移动构造之后源对象变没,这是 OnceCallback 那套"转移即掏空"的契约。 -有一个容易搞混的概念点——移动操作转移了所有权,但不会触发消费。只有 `run()` 才会消费回调。`OnceCallback cb2 = std::move(cb1)` 只是转移了所有权,回调在 `cb2.run()` 之前一直处于活跃状态。 +这里有个概念笔者一开始自己都绕过弯:移动只是搬家,不消费。真正把回调跑掉的只有 `run()`。`OnceCallback cb2 = std::move(cb1)` 之后,回调活得好好的,只是住址换了,直到 `cb2.run()` 才算被消费掉。把这两件事搞混,后面写取消令牌的时候会一脸懵。 ### C 类:单次调用约束 -这个约束是通过 deducing this + `static_assert` 实现的——`cb.run()` 会触发编译错误,`std::move(cb).run()` 才能通过。不需要运行时测试,编译通过本身就是验证。 +这一类没有运行时测试,因为约束是编译期挡下来的——deducing this 配 `static_assert`,`cb.run()`(没 move)直接编不过,只有 `std::move(cb).run()` 才放行。编译能过,本身就是验证通过。笔者一开始还想给这条补个 `TEST_CASE`,后来想想算了,这条不变量本质就是"写错代码会编译失败",硬要测的话用 `static_assert(!std::is_invocable_v<...>)` 那套反而绕,不如让编译器当裁判。 ### D 类:参数绑定 @@ -128,7 +118,7 @@ TEST_CASE("bind_once with member function", "[bind_once]") { } ``` -覆盖普通 lambda 的部分参数绑定和成员函数绑定。成员函数绑定的生命周期陷阱在前面的文章里已经讲过了——`&calc` 是裸指针,安全责任在调用方。 +两类绑定都过一遍:普通 lambda 的部分参数绑定,加上成员函数绑定。成员函数那条笔者特意把 `&calc` 这种裸指针的写法留在测试里,是想反复提醒一件事——生命周期责任全压在调用方肩上,这块的坑前面文章专门拆过,这里不再展开。 ### E 类:取消机制 @@ -164,7 +154,7 @@ TEST_CASE("cancelled non-void callback throws", "[once_callback]") { } ``` -三个关键行为:令牌有效时不取消、令牌失效后 void 回调不执行、令牌失效后非 void 回调抛出 `std::bad_function_call`。 +取消机制这块笔者花的时间最多,因为它有三个分支得分别测:令牌还活着,不取消;令牌失效配上 void 回调,静默不执行;令牌失效配上非 void 回调,抛 `std::bad_function_call`。第三个为什么必须抛?因为调用方在等一个返回值,您总不能默默吞掉还给个默认值,那 bug 就藏到运行时去了。三个用例正好把这三条分支钉死。 ### F 类:Then 组合 @@ -193,12 +183,14 @@ TEST_CASE("then with void first callback", "[then]") { } ``` -覆盖三种组合模式:两级非 void 管道、多级管道(跨越类型边界从 int 到 string)、void 前缀回调。 +三种组合都得走一遍:两级非 void 管道是最常见的用法;多级管道特意让它跨类型边界(int 走到 string),验证 `then` 的类型推导在返回值类型变化时没掉链子;void 前缀回调那一例是笔者后来补的,因为 `then` 接在 void 回调后面时,下一个环节拿不到前一步的"返回值",得靠外部状态接力——这个边界第一版笔者就漏了。 --- ## 性能对比:与 Chromium 原版 +测试都绿了,接下来是笔者自己最好奇的一段:咱们这版搭出来的 OnceCallback,跟 Chromium 那套手写了引用计数和函数指针表的原版摆一起,到底差多少。先把话说前头——差距是实打实的,笔者不打算粉饰。 + ### 对象大小 ```cpp @@ -214,19 +206,19 @@ std::cout << "sizeof(OnceCallback): " // 预估 56-64 bytes ``` -在 GCC 上,典型值是 `std::function` 约 32 字节,`std::move_only_function` 约 32 字节,我们的 `OnceCallback` 约 56-64 字节。Chromium 的只有 8 字节。 +GCC 上典型数字是这样的:`std::function` 大约 32 字节,`std::move_only_function` 也是 32 字节上下,咱们的 `OnceCallback` 叠上 status 和 token 指针之后撑到 56 到 64 字节。Chromium 那个?8 字节。一个指针的价钱。 -差距的根源在于存储策略。Chromium 把所有状态放在堆上的 `BindState` 里,回调对象只持有一个指针。我们用 `std::move_only_function` 的 SBO 把小对象直接内联存储,避免了堆分配但增大了对象大小。 +差出七倍这事笔者一开始也愣了一下,后来顺着存储策略一捋就明白了。Chromium 把绑定的参数、函数指针、引用计数这些状态一股脑塞进堆上的 `BindState`,回调对象本体只捏一根指针。咱们走的是 `std::move_only_function` 的 SBO 路子,小 lambda 直接内联在对象里,省了一次堆分配,代价是对象本身胖了一圈。 ### 分配行为 -`std::move_only_function` 的 SBO 阈值通常是 2-3 个指针大小(16-24 字节)。捕获少量参数的 lambda 通常能放进 SBO,不会触发堆分配。大 lambda 则在构造时堆分配。 +这一节其实是咱们方案唯一占便宜的地方。`std::move_only_function` 的 SBO 阈值通常落在两三个指针大小(16 到 24 字节),捕获几个参数的 lambda 基本都能塞进去,不触发堆分配。捕获一大坨的大 lambda 才会在构造时去堆上要内存。 -Chromium 总是堆分配(`new BindState`),但分配只发生一次。之后 OnceCallback 的移动操作只是复制一个指针(8 字节),代价极低。我们的方案在小对象时不分配(SBO),但移动操作需要复制 32+ 字节。 +Chromium 那边反过来,永远堆分配(`new BindState`),但只分配一次,之后 OnceCallback 的移动就是复制一根 8 字节指针,轻得不像话。咱们这边小对象虽然不分配,可一旦要移动,得复制 32 字节往上的内联缓冲。一边省分配、一边省移动,各占一头。 ### 间接调用开销 -两种方案的调用开销是一样的——一次间接函数调用。`std::move_only_function::operator()` 和 Chromium 的 `polymorphic_invoke_` 都通过函数指针分派。在 `-O2` 优化下,这个间接调用无法被内联消除。 +真到了调用这一步,两边打平,都是一次间接函数调用。`std::move_only_function::operator()` 和 Chromium 的 `polymorphic_invoke_` 走的是同一种分派方式。`-O2` 下这个间接调用谁也消不掉,跨编译单元的函数指针,编译器不敢内联。 ### 取舍总结 @@ -237,15 +229,9 @@ Chromium 总是堆分配(`new BindState`),但分配只发生一次。之 | 移动代价 | 复制 32+ 字节 | 复制 1 个指针 | | 实现代码量 | ~200 行 | ~2000+ 行 | -我们牺牲了对象的紧凑性和移动操作的极致性能,换来了实现简洁性——不需要手写引用计数、函数指针表、`TRIVIAL_ABI` 注解。小 lambda 的零堆分配在某些低频场景下反而是优势。对教学目的和大多数实际场景来说,这个取舍是值得的。 - ---- - -## 小结 - -这一篇我们做了两件事。测试方面,围绕六个不变量(基本调用、移动语义、单次调用、参数绑定、取消机制、链式组合)设计了 12 个 Catch2 测试用例,覆盖了 OnceCallback 的所有核心行为。性能方面,对比了与 Chromium OnceCallback 在对象大小、分配行为和调用开销上的差异——我们的实现用紧凑性换来了简洁性。 +表里这四行笔者反复看过几遍,觉得最值钱的是最后一行,代码量差了一个数量级。咱们不用手写引用计数,不用维护函数指针表,不用挂 `TRIVIAL_ABI` 注解去跟编译器讨价还价,这些东西全让 `std::move_only_function` 兜了。换来的是对象胖了七倍、移动贵了几倍。小 lambda 零堆分配这条,在投递频率不高的场景里反而是个意外的好处。 -到这里,OnceCallback 组件的设计、实现和验证就全部完成了。13 篇文章从前置知识到实战,覆盖了从 C++11 移动语义到 C++23 deducing this 的完整知识链。希望这个系列能帮助你理解"如何用现代 C++ 设计一个工业级的组件"——不仅仅是写代码,更重要的是理解每一个设计决策背后的原因。 +这笔账划不划算,得看您拿它干嘛。教学、原型、大多数业务回调,笔者觉得值;真要塞进 Chromium 那种一个进程里挂着上万个回调、还要被 `[[clang::trivial_abi]]` 推进寄存器传参的热路径——那还是老老实实去抄人家两千行。咱们这版的定位一直很清楚:把机制讲透,把取舍摆在台面上,至于哪头重,您自己掂量。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-00-once-callback-cpp-basics-review.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-00-once-callback-cpp-basics-review.md index c18c95ce6..e79ddbeb6 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-00-once-callback-cpp-basics-review.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-00-once-callback-cpp-basics-review.md @@ -25,25 +25,15 @@ title: OnceCallback 前置知识速查:C++11/14/17 核心特性回顾 --- # OnceCallback 前置知识速查:C++11/14/17 核心特性回顾 -## 引言 - -说实话,这一篇不是给你"从零讲明白"的——如果你对移动语义、智能指针这些概念完全陌生,建议先回到卷二把对应章节啃完再回来。这一篇的角色是**速查手册**:我们把 OnceCallback 系列后面会反复用到的 C++ 特性全部拉出来过一遍,每个特性只讲三件事——"它是什么"、"怎么用"、"OnceCallback 里哪里会用到"。目的是让你在读后续文章的时候不会因为某个语法细节卡住。 - -> **学习目标** -> -> - 快速回顾 OnceCallback 系列所需的全部 C++11/14/17 基础特性 -> - 理解每个特性在 OnceCallback 设计中的具体应用位置 -> - 建立后续深度学习所需的知识基线 - ---- +笔者得先把这一篇的定位讲清楚:这不是"从零讲明白"的入门教程。如果您对移动语义、智能指针这些概念还完全陌生,回头啃完卷二再来;这一篇默认您学过一遍,只是手生了。OnceCallback 后面会反复用到一批 C++11/14/17 特性——移动语义、完美转发、可变参数模板、智能指针、atomic、lambda、类型特征,咱们在这儿把它们一股脑过一遍。每样东西笔者就讲三件事:是什么、怎么用、OnceCallback 哪儿会用到。读完之后您再翻后续的设计文章,不至于因为某个语法细节当场卡住。 ## 移动语义与 std::move -移动语义是整个 OnceCallback 的根基——它是一个 move-only 类型,核心设计全靠移动语义撑着。我们先快速过一遍核心概念。 +整个 OnceCallback 的根基就在这里。它本身是个 move-only 类型,核心设计全靠移动语义撑着,这一节咱们快速把核心概念捋一遍。 ### 右值引用与移动构造 -C++11 引入了右值引用 `T&&`,它能绑定到临时对象(右值)上。移动构造函数 `T(T&& other)` 的语义是"从 `other` 那里把资源**偷**过来,而不是复制一份"。偷完之后,`other` 进入一个"有效但未指定"的状态——通常是被清空。 +C++11 引入了右值引用 `T&&`,它能绑定到临时对象(右值)上。移动构造函数 `T(T&& other)` 的语义是"从 `other` 那里把资源偷过来,而不是复制一份"。偷完之后,`other` 进入一个"有效但未指定"的状态,通常是被清空。 ```cpp // 一个最简单的移动语义示例 @@ -66,32 +56,31 @@ Buffer a(100); // a 拥有 100 个 int Buffer b = std::move(a); // b 偷走了 a 的资源,a 变空 ``` -### std::move 的本质 +### std::move 到底动了什么 -`std::move` 其实什么都不移动——它只是一个 `static_cast`,把传入的对象无条件转换成右值引用。真正执行"移动"的是移动构造函数或移动赋值运算符。`std::move` 的角色是告诉编译器"我同意把这个对象当作右值对待,你可以从它那里偷资源"。 +`std::move` 其实什么都不移动,它就是一个 `static_cast`,把传入的对象无条件转换成右值引用。真正执行"移动"的是移动构造函数或移动赋值运算符。`std::move` 的角色是举手报告——告诉编译器"我同意把这个对象当右值对待,您可以从它那儿偷资源"。 -### 在 OnceCallback 中的应用 +### 落到 OnceCallback 上 -OnceCallback 的调用方式是 `std::move(cb).run(args...)`——`std::move` 把 `cb` 转成右值,`run()` 通过 deducing this(C++23 特性,后面有专门一篇文章讲)检测到这是一个右值调用,执行回调并把 `cb` 的状态标记为"已消费"。之后任何对 `cb` 的访问都是非法的。整个设计思路就是:**通过类型系统来强制约束"调用一次即失效"的语义**。 +OnceCallback 的调用方式是 `std::move(cb).run(args...)`。`std::move` 把 `cb` 转成右值,`run()` 通过 deducing this(C++23 特性,后面有专门一篇文章讲)检测到这是右值调用,执行回调再把 `cb` 的状态标记成"已消费"。这之后再访问 `cb` 就是非法的。整个设计思路说白了,就是借类型系统强制约束"调用一次即失效"的语义。 -OnceCallback 同时删除了拷贝构造和拷贝赋值(`= delete`),只保留移动操作。这意味着一个 OnceCallback 对象在任意时刻只有一个持有者——你没法复制它,只能通过 `std::move` 转移所有权。 +OnceCallback 同时把拷贝构造和拷贝赋值都 `= delete` 了,只保留移动操作。这么一来一个 OnceCallback 对象在任意时刻只有一个持有者——您没法复制它,只能 `std::move` 把所有权转走。 --- ## 完美转发与 std::forward -完美转发解决的问题是:你写了一个函数模板,它接受参数并原封不动地传给另一个函数。所谓"原封不动"是指保持参数的值类别(左值还是右值)和 const 修饰。 +完美转发想解决的事情是这样的:您写了个函数模板,它接受参数,原封不动地传给另一个函数。所谓"原封不动"是指保持参数的值类别(左值还是右值)和 const 修饰,别传着传着就把右值传成了左值。 ### 转发引用与推导规则 -当函数模板的参数是 `T&&` 且 `T` 是模板参数时,`T&&` 不是普通的右值引用,而是**转发引用**(也叫万能引用)。编译器会根据传入参数的值类别来推导 `T`: +当函数模板的参数写成 `T&&` 且 `T` 是模板参数时,这个 `T&&` 就不是普通的右值引用了,而是转发引用(也有人叫万能引用)。编译器会根据传入参数的值类别推导 `T`: -- 传入左值 `x`(类型 `int`)→ `T = int&`,`T&&` 折叠为 `int&` -- 传入右值 `42`(类型 `int`)→ `T = int`,`T&&` 就是 `int&&` +传入左值 `x`(类型 `int`)的话,`T` 推成 `int&`,`T&&` 折叠为 `int&`;传入右值 `42`(类型 `int`)的话,`T` 推成 `int`,`T&&` 就是 `int&&`。 -### std::forward 的作用 +### std::forward 在干什么 -`std::forward(arg)` 根据模板参数 `T` 的类型决定是返回左值引用还是右值引用: +`std::forward(arg)` 根据模板参数 `T` 的类型,决定返回左值引用还是右值引用: ```cpp template @@ -105,17 +94,17 @@ wrapper(x); // arg 是左值引用,forward 返回左值引用 wrapper(10); // arg 是右值引用,forward 返回右值引用 ``` -如果你不用 `std::forward` 而直接传 `arg`,那 `arg` 在函数内部永远是左值(因为具名变量都是左值),右值信息就丢失了。 +笔者刚学这块的时候踩过一个坑:不用 `std::forward` 而直接传 `arg`,`arg` 在函数内部永远是左值(因为具名变量都是左值),右值信息就这么丢了。`std::forward` 就是把这个信息找回来的。 -### 在 OnceCallback 中的应用 +### 落到 OnceCallback 上 -完美转发在 OnceCallback 里出现了很多次。`bind_once` 函数模板用它来保持绑定参数的值类别——`std::forward(args)...` 确保传入的右值仍然是右值,传入的左值仍然是左值。`run()` 方法的 deducing this 实现里也用到了 `std::forward(self)` 来将 `self` 的值类别完美转发给内部的 `impl_run`。 +完美转发在 OnceCallback 里会反复出现。`bind_once` 函数模板靠它保持绑定参数的值类别——`std::forward(args)...` 确保传入的右值仍然是右值,传入的左值仍然是左值。`run()` 方法的 deducing this 实现里也用了 `std::forward(self)` 把 `self` 的值类别完美转发给内部的 `impl_run`。 --- ## 可变参数模板与参数包展开 -可变参数模板让你写出一个接受任意数量、任意类型参数的函数或类。OnceCallback 的模板签名 `OnceCallback` 就用到了参数包。 +可变参数模板让您写出一个接受任意数量、任意类型参数的函数或类。OnceCallback 的模板签名 `OnceCallback` 本身就挂着参数包。 ### 基本语法 @@ -127,25 +116,25 @@ void print_all(Types... args) { } ``` -`Types...` 叫做参数包(parameter pack),它可以包含零个或多个类型。`args...` 是函数参数包,在调用时展开。`sizeof...(Types)` 是编译期常量,返回包中元素的数量。 +`Types...` 叫做参数包(parameter pack),能装零个或多个类型;`args...` 是函数参数包,调用时展开。`sizeof...(Types)` 是编译期常量,返回包里元素的数量。 ### 展开位置 -参数包可以在多个位置展开:函数参数列表、模板参数列表、初始化列表、捕获列表(C++20 起)等。OnceCallback 里最关键的一个展开位置是 lambda 的捕获列表——这个特性在 C++20 才引入,我们后面有专门一篇文章讲。 +参数包能在好几个地方展开:函数参数列表、模板参数列表、初始化列表,以及 C++20 起的捕获列表。OnceCallback 里最关键的一个展开位置就是 lambda 的捕获列表,这个特性 C++20 才引入,咱们后面有专门一篇文章讲。 -### 在 OnceCallback 中的应用 +### 落到 OnceCallback 上 -`OnceCallback` 的 `Args...` 就是一个参数包,它在类的整个实现中反复出现——构造函数的参数类型、`run()` 的参数类型、内部 `func_` 的签名,全部来自这个包。`bind_once` 的 `BoundArgs...` 是另一个参数包,展开到 lambda 的捕获列表和 `std::invoke` 的调用参数中。 +`OnceCallback` 里那个 `Args...` 就是一个参数包,它在类的整个实现里反复露脸——构造函数的参数类型、`run()` 的参数类型、内部 `func_` 的签名,全都来自这个包。`bind_once` 里的 `BoundArgs...` 是另一个参数包,展开到 lambda 的捕获列表和 `std::invoke` 的调用参数中。 --- ## 智能指针速查 -OnceCallback 内部用到了两种智能指针,我们快速过一下各自的角色。 +OnceCallback 内部用到的智能指针就两种,咱们各看一眼它扮演的角色。 -### std::unique_ptr:独占所有权 +### std::unique_ptr:独占所有权 -`unique_ptr` 是独占式的智能指针——同一时刻只有一个 `unique_ptr` 指向对象。它不可拷贝,只能移动。创建方式是 `std::make_unique(args...)`。 +`unique_ptr` 是独占式的智能指针,同一时刻只有一个 `unique_ptr` 指向对象。它不能拷贝,只能移动,创建方式是 `std::make_unique(args...)`。 ```cpp auto p = std::make_unique(42); @@ -154,11 +143,11 @@ auto p3 = std::move(p); // OK:移动转移所有权 // 此后 p 为 nullptr ``` -在 OnceCallback 中,`unique_ptr` 的意义不在于我们直接使用它,而在于 OnceCallback 必须支持捕获 move-only 对象的 lambda——如果一个 lambda 捕获了 `unique_ptr`,那么包含这个 lambda 的 `std::move_only_function`(OnceCallback 的内部存储)也必须是 move-only 的。这是 `std::function` 做不到的,也是我们选择 `std::move_only_function` 的原因之一。 +在 OnceCallback 里,`unique_ptr` 的意义倒不是咱们直接用它,而是 OnceCallback 必须支持捕获 move-only 对象的 lambda。要是某个 lambda 捕获了 `unique_ptr`,那么装着这个 lambda 的 `std::move_only_function`(OnceCallback 的内部存储)也跟着必须是 move-only 的。这一点 `std::function` 做不到,也是咱们选 `std::move_only_function` 的原因之一。 -### std::shared_ptr:共享所有权 +### std::shared_ptr:共享所有权 -`shared_ptr` 通过引用计数管理对象生命周期。所有指向同一对象的 `shared_ptr` 共享同一个引用计数,最后一个 `shared_ptr` 被销毁时对象也被销毁。 +`shared_ptr` 靠引用计数管理对象生命周期。所有指向同一对象的 `shared_ptr` 共享同一份引用计数,最后一个 `shared_ptr` 销毁时,对象也跟着销毁。 ```cpp auto p1 = std::make_shared(42); @@ -166,17 +155,17 @@ auto p2 = p1; // OK:拷贝,引用计数 +1 // p1 和 p2 都指向同一个 int ``` -在 OnceCallback 中,`shared_ptr` 用于管理取消令牌 `CancelableToken`。令牌需要在 OnceCallback 对象和外部控制方之间共享——外部控制方调用 `invalidate()` 使令牌失效,OnceCallback 在执行回调前通过自己持有的 `shared_ptr` 副本检查令牌状态。`shared_ptr` 的引用计数保证了只要还有人持有令牌,底层的 `Flag` 对象就不会被销毁。 +在 OnceCallback 里,`shared_ptr` 用来管理取消令牌 `CancelableToken`。令牌得在 OnceCallback 对象和外部控制方之间共享——外部控制方调 `invalidate()` 让令牌失效,OnceCallback 在执行回调前通过自己手里的 `shared_ptr` 副本检查令牌状态。引用计数保证了一件事:只要还有人持有令牌,底层的 `Flag` 对象就不会被销毁。 --- ## std::atomic 与 memory_order -取消令牌的内部实现用到了 `std::atomic` 和 `memory_order_acquire/release`。 +取消令牌的内部实现用了 `std::atomic` 加 `memory_order_acquire/release`,咱们把这两样一起过一遍。 ### 原子操作 -`std::atomic` 提供对 `T` 类型变量的原子访问——读和写不会被其他线程的操作打断。基本操作是 `load()`(读)和 `store()`(写),可以指定内存序。 +`std::atomic` 给 `T` 类型变量提供原子访问,读和写不会被其他线程的操作打断。基本操作是 `load()`(读)和 `store()`(写),还能指定内存序。 ```cpp std::atomic flag{true}; @@ -190,15 +179,17 @@ if (flag.load(std::memory_order_acquire)) { } ``` -### acquire/release 语义 +### acquire/release 这一对 + +`memory_order_release` 和 `memory_order_acquire` 是一对配对的内存序。简单说,`release` store 保证 store 之前的所有写操作对其他线程可见;`acquire` load 保证 load 之后的所有读操作能看到 release store 之前的写入。这一对配好了 happens-before。 -`memory_order_release` 和 `memory_order_acquire` 是一对配对的内存序。简单说:`release` store 保证了在 store 之前的所有写操作对其他线程可见;`acquire` load 保证了在 load 之后的所有读操作能看到 release store 之前的写入。在 OnceCallback 的取消令牌中,`invalidate()` 用 `release` store 把 `valid` 设为 `false`,`is_valid()` 用 `acquire` load 读取 `valid`——这保证了如果 `is_valid()` 返回 `true`,令牌相关的所有状态对当前线程都是可见的。 +落到 OnceCallback 的取消令牌上,`invalidate()` 用 `release` store 把 `valid` 设成 `false`,`is_valid()` 用 `acquire` load 读 `valid`——这保证了只要 `is_valid()` 返回 `true`,令牌相关的所有状态对当前线程都是可见的。 --- ## enum class -`enum class` 是 C++11 引入的作用域枚举,解决的是老式 `enum` 的名字污染和隐式转换问题。 +`enum class` 是 C++11 引入的作用域枚举,治的是老式 `enum` 的两个老毛病:名字污染和隐式转换。 ```cpp // 老式 enum:名字污染全局命名空间,可以隐式转成 int @@ -215,13 +206,13 @@ Status s = Status::kValid; // int y = s; // 编译错误:不可隐式转换 ``` -OnceCallback 用 `enum class Status` 来区分回调的三种状态。底层类型指定为 `uint8_t` 是为了节省内存——整个枚举只占 1 个字节。 +OnceCallback 用 `enum class Status` 区分回调的三种状态。底层类型指定成 `uint8_t` 是为了省内存——整个枚举就占 1 个字节。 --- ## Lambda 基础 -Lambda 在 OnceCallback 中无处不在——构造回调、`bind_once`、`then()` 的内部实现全部依赖 lambda。这里快速复习基础语法。 +Lambda 在 OnceCallback 里可以说是无处不在——构造回调、`bind_once`、`then()` 的内部实现全靠它。咱们这儿快速复习一下基础语法。 ```cpp auto add = [](int a, int b) { return a + b; }; @@ -236,22 +227,22 @@ auto f2 = [&x]() { return x; }; auto f3 = [p = std::make_unique(42)]() { return *p; }; ``` -Lambda 生成的闭包类的 `operator()` 默认是 `const` 的——这意味着你不能在 lambda 内部修改值捕获的变量,除非加上 `mutable` 关键字。在 OnceCallback 的 `bind_once` 和 `then()` 实现中,lambda 必须声明为 `mutable`,因为内部需要调用 `std::move(self).run()` 来修改 `self` 的状态。这个细节我们在 Lambda 高级特性那篇文章里会展开讲。 +这里有个坑笔者当时没反应过来:Lambda 生成的闭包类的 `operator()` 默认是 `const` 的,所以您不能在 lambda 内部改值捕获的变量,除非加上 `mutable` 关键字。在 OnceCallback 的 `bind_once` 和 `then()` 实现里,lambda 必须声明成 `mutable`,因为内部要调 `std::move(self).run()` 去改 `self` 的状态。这个细节咱们在 Lambda 高级特性那篇里再展开讲。 -泛型 lambda(C++14 起)允许参数使用 `auto`: +泛型 lambda(C++14 起)允许参数用 `auto`: ```cpp auto generic = [](auto x, auto y) { return x + y; }; // 编译器为 operator() 生成模板版本 ``` -`bind_once` 内部的 lambda 用 `(auto&&... call_args)` 来接受运行时参数——这里的 `auto&&` 是转发引用(因为 `auto` 等同于模板参数)。 +`bind_once` 内部的 lambda 用 `(auto&&... call_args)` 来接运行时参数,这里 `auto&&` 是转发引用(因为 `auto` 等同于模板参数)。 --- -## 类型特征(Type Traits) +## 类型特征(Type Traits) -类型特征是编译期查询和操作类型信息的工具。OnceCallback 里用到了几个关键的 traits,我们快速过一遍。 +类型特征是编译期查询和操作类型信息的工具。OnceCallback 里用到了几个关键的 traits,咱们快速过一遍。 ```cpp #include @@ -274,13 +265,13 @@ static_assert(std::is_void_v); // 通过 static_assert(!std::is_void_v); // 通过 ``` -在 OnceCallback 中,`std::decay_t` 和 `std::is_same_v` 用于 `not_the_same_t` concept——它检查"模板参数退化后是否和 `OnceCallback` 本身是同一类型",用来防止模板构造函数劫持移动构造函数的调用。`std::is_lvalue_reference_v` 用于 `run()` 的 deducing this 实现——检测调用方是否传了左值,如果是就触发 `static_assert` 报错。`std::is_void_v` 用于 `impl_run()` 和 `then()` 中区分 void 和非 void 返回类型的编译期分支。 +在 OnceCallback 里,`std::decay_t` 和 `std::is_same_v` 一起搭出了 `not_the_same_t` concept——它检查"模板参数退化后是否和 `OnceCallback` 本身是同一类型",挡住模板构造函数劫持移动构造函数的调用。`std::is_lvalue_reference_v` 用在 `run()` 的 deducing this 实现里,检测调用方是不是传了左值,如果是就触发 `static_assert` 报错。`std::is_void_v` 则用在 `impl_run()` 和 `then()` 里,区分 void 和非 void 返回类型的编译期分支。 --- ## if constexpr -`if constexpr` 是 C++17 引入的编译期条件分支。它和普通 `if` 的区别在于:条件必须是编译期常量表达式,**未选中的分支不会被编译**——甚至连语法检查都不会做。这个特性在处理 void 返回类型时特别有用。 +`if constexpr` 是 C++17 引入的编译期条件分支。它和普通 `if` 的区别在于,条件必须是编译期常量表达式,而且未选中的分支压根不会被编译,连语法检查都不做。这个特性在处理 void 返回类型时格外顶用。 ```cpp template @@ -296,15 +287,15 @@ R do_something() { } ``` -如果没有 `if constexpr` 而用普通的 `if`,两边的分支都会被编译。此时 void 分支里的 `return result` 会直接报错——void 不是一种可以赋值的类型。`if constexpr` 保证了 void 的情况只生成 `return;` 的代码,非 void 的情况只生成 `return result;` 的代码。 +要是没有 `if constexpr` 而用普通的 `if`,两边的分支都得编译。这时候 void 分支里的 `return result` 就直接报错了——void 不是一种可以赋值的类型。`if constexpr` 保证 void 的情况只生成 `return;` 的代码,非 void 的情况只生成 `return result;` 的代码,两边互不打扰。 -在 OnceCallback 中,`if constexpr (std::is_void_v)` 出现在两个地方:`impl_run()` 的回调执行逻辑,和 `then()` 的链式组合逻辑。两处都是同一个问题——void 返回类型不能用常规方式赋值和返回。 +在 OnceCallback 里,`if constexpr (std::is_void_v)` 出现在两个地方:`impl_run()` 的回调执行逻辑,和 `then()` 的链式组合逻辑。两处都是同一个问题——void 返回类型没法用常规方式赋值和返回。 --- ## decltype(auto) -`decltype(auto)` 是 C++14 引入的返回类型推导方式。它和 `auto` 的区别在于对引用的处理:`auto` 会丢掉引用和顶层 const,`decltype(auto)` 会保留。 +`decltype(auto)` 是 C++14 引入的返回类型推导方式。它和 `auto` 的区别在引用处理上:`auto` 会丢掉引用和顶层 const,`decltype(auto)` 会保留。 ```cpp int x = 10; @@ -314,13 +305,13 @@ auto f1() { return ref; } // 返回 int(丢掉了引用) decltype(auto) f2() { return ref; } // 返回 int&(保留了引用) ``` -在 OnceCallback 中,`bind_once` 和 `then()` 的 lambda 用 `-> decltype(auto)` 作为尾置返回类型。这样做的目的是完美转发可调用对象的返回值——如果被调用的函数返回 `int&&`,`decltype(auto)` 也会返回 `int&&`,不会丢失值类别信息。 +在 OnceCallback 里,`bind_once` 和 `then()` 的 lambda 用 `-> decltype(auto)` 当尾置返回类型。这么写图的是完美转发可调用对象的返回值——要是被调用的函数返回 `int&&`,`decltype(auto)` 也返回 `int&&`,值类别信息一个不丢。 --- ## [[nodiscard]] 属性 -`[[nodiscard]]` 是 C++17 标准化的属性,告诉编译器"这个函数的返回值不应该被忽略"。如果调用方写了 `cb.is_cancelled();` 但没有使用返回值,编译器会发出警告。 +`[[nodiscard]]` 是 C++17 标准化的属性,跟编译器打招呼说"这个函数的返回值不应该被忽略"。要是调用方写了 `cb.is_cancelled();` 却没用返回值,编译器就会发警告。 ```cpp [[nodiscard]] bool is_cancelled() const noexcept; @@ -328,13 +319,13 @@ decltype(auto) f2() { return ref; } // 返回 int&(保留了引用) [[nodiscard]] bool is_null() const noexcept; ``` -OnceCallback 的三个查询方法都标注了 `[[nodiscard]]`。原因很简单——调用这些方法就是为了拿返回值做判断,忽略返回值的调用大概率是手滑写错了(比如把 `if (!cb.is_cancelled())` 写成了 `cb.is_cancelled();`)。`explicit operator bool()` 的 `explicit` 也起类似作用——防止隐式转换到 `bool` 引发的意外行为。 +OnceCallback 的三个查询方法都挂了 `[[nodiscard]]`。原因很直白:调这些方法就是为了拿返回值做判断,忽略返回值的调用大概率是手滑写错了,比如把 `if (!cb.is_cancelled())` 写成了 `cb.is_cancelled();`。`explicit operator bool()` 里的 `explicit` 也起类似作用,挡住隐式转换到 `bool` 引发的意外行为。 --- ## Ref-qualified 成员函数 -C++11 允许对非静态成员函数进行引用限定(ref-qualifier),用 `&` 或 `&&` 标注在函数参数列表后面。`&` 表示只能通过左值调用,`&&` 表示只能通过右值调用。 +C++11 允许给非静态成员函数加引用限定(ref-qualifier),在函数参数列表后面标 `&` 或 `&&`。`&` 表示只能通过左值调用,`&&` 表示只能通过右值调用。 ```cpp class Widget { @@ -348,15 +339,13 @@ public: }; ``` -在 OnceCallback 中,`then()` 方法声明为 `auto then(Next&& next) &&`——末尾的 `&&` 意味着 `then()` 只能通过右值调用(`std::move(cb).then(next)` 或临时对象上的 `.then(next)`)。这是表达消费语义的另一种方式——和 `run()` 用 deducing this 不同,`then()` 不需要区分左值和右值给出不同的错误信息,直接用 ref-qualifier 更简洁。 +在 OnceCallback 里,`then()` 方法声明成 `auto then(Next&& next) &&`——末尾的 `&&` 意味着 `then()` 只能通过右值调用(`std::move(cb).then(next)` 或临时对象上的 `.then(next)`)。这是表达消费语义的另一种方式,和 `run()` 用 deducing this 不同:`then()` 不需要区分左值和右值给出不同的错误信息,直接上 ref-qualifier 反而更简洁。 --- -## 小结 - -这一篇我们把 OnceCallback 系列会用到的所有 C++ 基础特性快速过了一遍。每个特性我们都明确了三点:它是什么、怎么用、在 OnceCallback 的哪里会出现。如果你对某个特性感到陌生,建议回到对应卷的章节系统学习——后续文章不会再重复解释这些基础语法。 +到这里,OnceCallback 系列会用到的 C++ 基础特性咱们就过了一遍。每样东西笔者都讲清了三件事:是什么、怎么用、OnceCallback 哪儿会用到。要是哪个特性您还觉得生,回头去对应卷的章节系统啃一遍——后续文章不会再重复解释这些基础语法。 -接下来我们要进入深度环节了。第一站是"函数类型与模板偏特化"——这是理解 `OnceCallback` 这个古怪写法的关键,也是我们搭建整个模板骨架的入口。 +接下来咱们进深度环节。第一站是"函数类型与模板偏特化",这是理解 `OnceCallback` 这个古怪写法的钥匙,也是咱们搭起整个模板骨架的入口。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-01-once-callback-function-type-and-specialization.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-01-once-callback-function-type-and-specialization.md index b4a263e6c..8fa115a81 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-01-once-callback-function-type-and-specialization.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-01-once-callback-function-type-and-specialization.md @@ -25,27 +25,17 @@ title: OnceCallback 前置知识(一):函数类型与模板偏特化 --- # OnceCallback 前置知识(一):函数类型与模板偏特化 -## 引言 +笔者第一次在 Chromium 源码里撞见 `OnceCallback` 这个写法,盯着看了半天。`int(int, int)` 看起来就像函数声明残骸,可它偏偏待在模板参数的位置上。这玩意儿到底是啥?编译器又怎么从 `int(int, int)` 里读出"返回 int、收两个 int"这些信息的? -如果你第一次看到 `OnceCallback` 这个写法,大概率会觉得有点奇怪——`int(int, int)` 看起来像函数声明,但它出现在模板参数的位置上。这个东西到底是什么?编译器是怎么把 `int(int, int)` 拆解成"返回 int、接受两个 int 参数"这些信息的? +笔者当时没想通,后来才知道这套写法是 `std::function`、`std::move_only_function` 乃至咱们整个 `OnceCallback` 的共同底子。这一篇笔者就把这块拆透——先把"函数类型"这个被忽略的概念摆正,再看主模板加偏特化那套模式匹配是怎么把签名拆开的。咱们会顺手撸一个最小的 `FuncTraits` 把它跑通,最后聊聊为什么标准库集体选了签名式而没用更直白的写法。 -这一篇我们就来拆解这个看似古怪但实则非常优雅的技巧。理解了它,你就能看懂 `std::function`、`std::move_only_function` 和我们的 `OnceCallback` 的模板签名为什么长成这个样子。 +## 函数类型:C++ 里一个容易被错过的类型 -> **学习目标** -> -> - 理解函数类型(function type)是 C++ 中的一种合法类型 -> - 掌握"主模板 + 偏特化"这个反复出现的模板设计模式 -> - 能够自己实现一个最小版本的函数签名拆解工具 +咱们从一个最朴素的问题起手:`int(int, int)` 在 C++ 里算一种类型吗? ---- - -## 函数类型:C++ 中一个容易被忽略的类型 - -先问一个基本问题:`int(int, int)` 在 C++ 里是一种类型吗? - -答案是:是的。`int(int, int)` 是一种叫做**函数类型**(function type)的东西,它描述的是"接受两个 int 参数、返回 int 的函数"。注意,它不是函数指针 `int(*)(int, int)`,也不是函数引用 `int(&)(int, int)`——函数类型是比函数指针更底层的概念。 +算。它有个名字叫函数类型(function type),说的是"收两个 int、返回 int 的函数"。这里笔者要特别点一下,函数类型比函数指针更底层,它跟 `int(*)(int, int)` 这种指针、`int(&)(int, int)` 这种引用都不是一回事。后面咱们会看到,正是这个"底层"让它能被偏特化逮住。 -我们可以用 `static_assert` 来验证: +`static_assert` 一验便知: ```cpp #include @@ -55,23 +45,21 @@ static_assert(!std::is_pointer_v); // 通过:不是 static_assert(std::is_pointer_v); // 通过:这是函数指针 ``` -函数类型在实际代码中出现的场景比你想象的要多。当你写一个函数声明的时候: +函数类型在实际代码里露脸的次数比咱们以为的多。随手写一个函数声明: ```cpp int add(int a, int b); ``` -`add` 的类型就是 `int(int, int)`。你可以把它想象成一种"签名"——它完整描述了这个函数接受什么参数、返回什么类型,但不涉及函数本身存储在哪里。 +`add` 的类型就是 `int(int, int)`。您可以把它当成一种签名,它完整说清了这个函数收什么、吐什么,但不说函数本身存在哪。 -函数类型和函数指针之间有一个隐式转换:函数名在大多数表达式中会自动退化(decay)成指向自身的指针。这就像数组名退化成指针一样——`int arr[5]` 中的 `arr` 在大多数上下文中会变成 `int*`,`int add(int, int)` 中的 `add` 会变成 `int(*)(int, int)`。 +函数类型跟函数指针之间还有个隐式转换:函数名在多数表达式里会自动退化成指向自己的指针。这点跟数组名退化成指针是一路的。`int arr[5]` 里的 `arr` 在多数上下文中变成 `int*`,`int add(int, int)` 里的 `add` 也会变成 `int(*)(int, int)`。 -但作为**模板参数**传入时,函数类型不会退化——编译器原封不动地接收这个类型。这正是我们能够用模板偏特化来拆解它的前提。 +可一旦它作为模板参数传进去,函数类型就不退化了,编译器原模原样收下这个类型。这正是后面能用偏特化拆它的前提。 ---- - -## 主模板 + 偏特化:拆解函数类型的模式 +## 主模板加偏特化:拆解函数类型的套路 -现在我们来看看 `OnceCallback` 的模板声明是怎么写的。它用了一个两步走的设计:先声明一个只接受一个类型参数的主模板,再为"这个类型参数恰好是函数类型"的情形提供一个偏特化版本。 +接下来咱们看 `OnceCallback` 的模板声明是怎么写的。它走的是两步:先甩出一个只收一个类型参数的主模板,再为"这个类型参数恰好是函数类型"这种情形单开一个偏特化版本。 ### 第一步:主模板声明 @@ -80,7 +68,7 @@ template class OnceCallback; // 主模板:只有声明,没有定义 ``` -主模板故意不提供实现。这不是遗忘,而是设计——如果有人不小心写出了 `OnceCallback` 这种用法(传了一个普通的 int 类型而不是函数签名),编译器会在实例化时报错,因为找不到定义。这是一种编译期的安全网。 +主模板故意不给实现。这不是笔者忘写,是刻意的。要是谁手滑写出 `OnceCallback` 这种——传了个普通 int 进来而不是函数签名——实例化的时候直接报错找不到定义。算一道编译期安全网。 ### 第二步:偏特化版本 @@ -91,19 +79,19 @@ class OnceCallback { }; ``` -这个偏特化版本的模板参数列表是 ``,而类名后面跟的 `OnceCallback` 是偏特化的**模式匹配条件**——它说的是:"当 `FuncSignature` 能被拆解成 `ReturnType(FuncArgs...)` 这种形式时,用这个版本。" +这版的模板参数列表是 ``,而类名后头跟的 `OnceCallback` 才是关键,它是偏特化的模式匹配条件,意思就一句:当 `FuncSignature` 能凑成 `ReturnType(FuncArgs...)` 这个样子,就用这版。 -### 编译器的匹配过程 +### 编译器是怎么配对的 -当你写 `OnceCallback` 时,编译器做了这么几件事: +写 `OnceCallback` 的时候,编译器干了几件事。 -首先,它看到你在实例化 `OnceCallback`,模板参数是 `int(int, int)`。然后它去看主模板 `template class OnceCallback`,把 `FuncSignature` 绑定为 `int(int, int)` 这个整体类型。接下来它去检查有没有偏特化版本能用——偏特化要求 `FuncSignature` 能匹配 `ReturnType(FuncArgs...)` 的模式。`int(int, int)` 恰好可以拆成 `ReturnType = int`、`FuncArgs = {int, int}`,匹配成功!于是偏特化版本被选中。 +它先看到您要实例化 `OnceCallback`,模板参数是 `int(int, int)`。然后去对主模板,把 `FuncSignature` 绑成 `int(int, int)` 这个整体。接着回头查有没有偏特化能用。偏特化要求 `FuncSignature` 匹配 `ReturnType(FuncArgs...)` 的模式,`int(int, int)` 恰好拆得开,`ReturnType = int`、`FuncArgs = {int, int}`,配上了,偏特化就被选中。 -你可以把这个过程想象成一种类型层面的模式匹配——就像正则表达式 `(\w+)\((\w+(?:,\s*\w+)*)\)` 可以从字符串 `int(int, int)` 中提取出返回值和参数列表一样,模板偏特化从类型 `int(int, int)` 中提取出返回类型和参数包。 +整个过程您可以当成类型层面的模式匹配。打个比方,正则 `(\w+)\((\w+(?:,\s*\w+)*)\)` 能从字符串 `int(int, int)` 里抠出返回值和参数列表,模板偏特化干的是同样的事,只不过它操作的是类型,不是字符。 -### 和 `std::function` 用的是完全相同的技术 +### 跟 std::function 用的是一模一样的招 -如果你去翻 `std::function` 的标准库实现,你会发现它用了完全一样的模式: +去翻 `std::function` 的标准库实现,您会发现它用的是同一套: ```cpp // std::function 的简化实现 @@ -115,13 +103,11 @@ class function { // 偏特化 }; ``` -`std::move_only_function`(C++23)也是一样的。这个"主模板 + 函数类型偏特化"的模式在标准库里出现了三次,是一个经过充分验证的设计。 +`std::move_only_function`(C++23)也一样。主模板加函数类型偏特化这个组合,在标准库里至少露了三次脸,是个被反复验证过的设计。咱们自己写 `OnceCallback` 的时候,没理由另起炉灶。 ---- +## 动手实践:撸一个 FuncTraits -## 动手实践:实现一个 FuncTraits - -光看不练容易忘。我们现在自己动手实现一个最小的函数签名拆解工具,用来巩固理解。目标是:给定一个函数类型 `R(Args...)`,能提取出返回类型 `R` 和参数包 `Args...`。 +光看不练忘得快。咱们自己动手写一个最小的函数签名拆解工具,把刚才那套理解夯结实。目标是这样:丢给它一个函数类型 `R(Args...)`,它能把返回类型 `R` 和参数包 `Args...` 都吐出来。 ```cpp #include @@ -145,9 +131,9 @@ static_assert(std::is_same_v::ReturnType, void>); static_assert(FuncTraits::kArity == 3); ``` -`FuncTraits` 和 `OnceCallback` 使用了完全相同的偏特化模式。唯一的区别是 `FuncTraits` 把拆出来的类型存成了 `using` 别名和 `static constexpr` 常量,而 `OnceCallback` 直接在偏特化类的内部使用这些类型来定义数据成员和方法。 +`FuncTraits` 跟 `OnceCallback` 走的是同一个偏特化套路。区别只有一处:`FuncTraits` 把拆出来的类型存成 `using` 别名和 `static constexpr` 常量留着外面用,`OnceCallback` 则直接在偏特化类内部拿这些类型去定义数据成员和方法。 -试着编译运行这个示例——如果 `static_assert` 全部通过(没有编译错误),说明偏特化正确地把函数类型拆开了。你可以试着加一些更复杂的类型来测试: +咱们编译跑一下这个示例。`static_assert` 全过(没编译错误)就说明偏特化把函数类型拆对了。您也可以再丢几个更复杂的类型进去试: ```cpp // 更复杂的验证 @@ -161,9 +147,9 @@ static_assert(std::is_same_v< --- -## 为什么不用 OnceCallback? +## 为什么不写成 OnceCallback? -你可能会想,既然目的是拿到返回类型和参数列表,为什么不直接写成 `OnceCallback` 这种形式?像这样: +您可能会琢磨,既然要的就是返回类型加参数列表,干嘛不直接写成 `OnceCallback` 这种更直白的样式?像这样: ```cpp template @@ -174,7 +160,7 @@ class OnceCallback { // 使用:OnceCallback cb([](int a, int b) { return a + b; }); ``` -这种写法技术上可行,但用户体验差了一截。对比两种调用方式: +这种写法技术上完全能跑。但用户体验差一截。咱们对比一下两种调用: ```cpp // 签名式:一个模板参数,看起来像函数签名 @@ -184,19 +170,11 @@ OnceCallback cb1([](int a, int b) { return a + b; }); OnceCallback cb2([](int a, int b) { return a + b; }); ``` -第一种更自然——`int(int, int)` 就是一个完整的函数签名,读起来一目了然。第二种需要你在大脑里把第一个 `int` 解读为返回类型、后面的 `int, int` 解读为参数列表,这增加了认知负担。标准库的选择也是签名式——`std::function` 而不是 `std::function`。 - -签名式写法还有一个微妙的好处:它和 C++ 的类型系统更一致。`int(int, int)` 是一个真实的类型,而"一个返回类型加上一组参数类型"不是一个类型——它只是几个类型的罗列。用函数类型作为模板参数,是在类型系统的层面上操作,而不是在语法糖的层面上操作。 - -当然,签名式写法也有一个缺点——编译器没法从可调用对象自动推导出完整的签名。这就是为什么 `bind_once` 的第一个模板参数 `Signature` 必须手动指定的原因,这个取舍我们在后续的 `bind_once` 实现篇里会详细讨论。 - ---- - -## 小结 +第一种读起来自然。`int(int, int)` 就是一个完整函数签名,一眼到底。第二种得在脑子里分一下:头一个 `int` 是返回类型,后头俩 `int, int` 才是参数,凭空加了认知负担。标准库也选了签名式——`std::function`,不是 `std::function`。 -这一篇我们搞清楚了三件事。函数类型 `int(int, int)` 是 C++ 中的一种合法类型,它完整描述了函数的签名,不是函数指针也不是函数引用。"主模板 + 偏特化"这个模式通过模式匹配把函数类型拆解成返回类型和参数包,`std::function`、`std::move_only_function` 和我们的 `OnceCallback` 都用了同样的技巧。签名式写法 `OnceCallback` 比参数罗列式 `OnceCallback` 更自然、更符合 C++ 类型系统的设计哲学。 +签名式还有个微妙好处,它跟 C++ 类型系统更对得上号。`int(int, int)` 是个真实存在的类型;而"一个返回类型加一坨参数类型"不算一个类型,只是几个类型摆一块儿。拿函数类型当模板参数,操作的是类型系统本身,不是在语法糖上打补丁。 -下一篇我们去看 `std::invoke`——它是让 `bind_once` 能够统一处理函数指针、成员函数指针和 lambda 的关键工具。 +不过签名式也有个让人不痛快的角落:编译器没法从可调用对象自己推出完整签名。这就是 `bind_once` 第一个模板参数 `Signature` 非得手写的原因。这个取舍笔者留到 `bind_once` 实现篇再展开聊。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-02-once-callback-invoke-and-callable.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-02-once-callback-invoke-and-callable.md index a05b6d7cb..12871e820 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-02-once-callback-invoke-and-callable.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-02-once-callback-invoke-and-callable.md @@ -24,25 +24,17 @@ title: OnceCallback 前置知识(二):std::invoke 与统一调用协议 --- # OnceCallback 前置知识(二):std::invoke 与统一调用协议 -## 引言 +咱们在上一篇把函数类型和模板偏特化理顺了,这篇要解决一个更烦人的问题:可调用对象的调用语法。 -假设你在写一个回调系统——就像我们正在做的 OnceCallback。你的系统需要接受各种各样的"可调用对象":普通函数指针、lambda、仿函数(重载了 `operator()` 的类对象),甚至成员函数指针。问题来了,这些可调用对象的调用语法各不相同。普通函数直接 `f(args...)`,成员函数指针必须写成 `(obj.*pmf)(args...)`。如果你的代码里有十种不同的可调用对象,是不是要写十个 `if-else` 分支来分别处理? +写回调系统最直觉的诉求是,管传进来什么,函数指针也好、lambda 也好、成员函数指针也好,咱们都想用同一种写法把它跑起来。可 C++ 偏不给您这个面子。普通函数直接 `f(args...)` 就行,成员函数指针却非得 `(obj.*pmf)(args...)`,连指向数据成员的指针都得走 `obj.*pmd` 那一套。十种可调用对象,您就得在模板里写十套分支判断它到底属于哪一类。这事写多了血压会上来。 -`std::invoke`(C++17)就是为了消灭这种分裂而生的。它提供了一种统一的调用语法,让所有可调用对象都能用同一种方式调用。OnceCallback 的 `bind_once` 和 `then()` 内部全部依赖它来实现"不管传进来什么可调用对象,都能正确调用"这个需求。 - -> **学习目标** -> -> - 理解为什么需要统一调用协议——各种可调用对象的调用语法差异 -> - 掌握 `std::invoke` 的完整分派规则 -> - 学会使用 `std::invoke_result_t` 在编译期推导调用结果的类型 - ---- +`std::invoke`(C++17)就是来把这堆语法糊成一层的。OnceCallback 的 `bind_once` 和 `then()` 内部全靠它,才能做到管您传进来什么都能正确调用。 ## 问题:可调用对象的调用语法分裂 -C++ 中至少有四种常见的可调用对象,它们的调用语法各不相同。我们逐一看看。 +咱们把几种常见的可调用对象摆一起看看,您就明白这套分裂有多扎眼。 -### 普通函数指针 +普通函数指针最老实,怎么写都行: ```cpp int add(int a, int b) { return a + b; } @@ -52,7 +44,7 @@ int result = fp(3, 4); // 直接调用 int result2 = (*fp)(3, 4); // 解引用后调用(等价) ``` -### Lambda / 仿函数 +lambda 和仿函数走的是 `operator()`,语法上跟普通函数长得几乎一样,这部分还算友好: ```cpp auto lam = [](int a, int b) { return a + b; }; @@ -65,9 +57,7 @@ Adder fn; int result2 = fn(3, 4); // 同样通过 operator() 调用 ``` -### 成员函数指针 - -这里语法开始变得古怪了。成员函数指针不能像普通函数那样直接调用——你必须有一个对象实例,然后用 `.*` 或 `->*` 运算符来调用。 +事情到成员函数指针这里就开始拧巴了。它没法像普通函数那样直接 `()`,您得先有个对象实例,然后用 `.*` 或 `->*` 这两个生僻运算符把它套上去。笔者第一次写这玩意儿翻了半天书: ```cpp struct Calculator { @@ -81,9 +71,7 @@ int (Calculator::*pmf)(int, int) = &Calculator::multiply; int result = (calc.*pmf)(3, 4); // result == 12 ``` -### 指向数据成员的指针 - -是的,C++ 允许你获取数据成员的"指针"——它其实是一个偏移量。访问方式也是通过 `.*` 运算符。 +还有一类更冷门的,指向数据成员的指针。C++ 允许您取数据成员的「指针」,它本质是个偏移量,访问方式同样得走 `.*`: ```cpp struct Point { @@ -96,17 +84,15 @@ double Point::*pmx = &Point::x; double val = p.*pmx; // val == 1.0 ``` -问题很清楚了:如果你在写一个模板函数,需要调用一个"不知道具体是什么类型的可调用对象",你没法写出一个统一的调用语法——因为你不知道它是普通函数还是成员函数指针。`std::invoke` 就是来解决这个问题的。 +您看出来了吧。如果您在写一个模板函数,要调用一个「完全不知道具体类型」的可调用对象,根本没法写出一个统一的语法。您不知道它是函数还是成员指针,写错一个就编译失败。`std::invoke` 就是来堵这个窟窿的。 --- ## std::invoke 的分派规则 -`std::invoke(f, args...)` 的工作是:根据 `f` 和 `args` 的具体类型,选择正确的调用语法。标准规定了以下几种情况(C++ 标准术语叫 INVOKE 表达式): - -### 情况一:成员函数指针 + 对象 +`std::invoke(f, args...)` 干的活儿其实就一句话:盯着 `f` 和 `args` 的具体类型,挑出对的调用语法。标准里把这套规则叫 INVOKE 表达式,分三大类。 -当 `f` 是指向成员函数的指针,`args` 的第一个元素是对象(或对象的引用、或指向对象的指针)时,`std::invoke` 展开为通过对象调用成员函数。 +最棘手、也最值得记牢的是成员函数指针这一类。当 `f` 是指向成员函数的指针,而 `args` 第一个元素是对象本身(可以是引用、可以是值,也可以是指向对象的指针),`std::invoke` 就把它展开成 `(obj.*pmf)(rest...)`: ```cpp struct Calculator { @@ -121,11 +107,9 @@ std::invoke(&Calculator::multiply, calc, 3, 4); // (calc.*multiply)(3, 4) std::invoke(&Calculator::multiply, &calc, 3, 4); // ((*ptr).*multiply)(3, 4) ``` -注意第二种情况——当第一个参数是指针(`&calc`)时,`std::invoke` 会自动解引用指针。这个行为在 `bind_once` 绑定成员函数时非常重要。 +第二行那个 `&calc` 您留意一下。第一个实参是指针的时候,`std::invoke` 会替您把指针解引用再走 `.*`。这行为看着不起眼,但在 `bind_once` 绑定成员函数的时候救命,后文咱们会看到。 -### 情况二:指向数据成员的指针 + 对象 - -当 `f` 是指向数据成员的指针时,`std::invoke` 展开为通过对象访问数据成员。 +指向数据成员的指针走的是同一路子,只是把「调用」换成了「访问」: ```cpp struct Point { double x, y; }; @@ -134,25 +118,21 @@ Point p{1.0, 2.0}; double val = std::invoke(&Point::x, p); // p.*&Point::x == p.x ``` -### 情况三:其他可调用对象 - -当 `f` 是函数指针、lambda、仿函数等"可以直接调用的东西"时,`std::invoke` 就是简单的 `f(args...)`。 +剩下那批,函数指针、lambda、仿函数,凡是能直接 `()` 上去的,`std::invoke` 就老老实实给您 `f(args...)`: ```cpp std::invoke([](int a, int b) { return a + b; }, 3, 4); // lambda(3, 4) ``` -### 统一接口 - -关键在于,不管 `f` 是上面哪种情况,调用语法都是 `std::invoke(f, args...)`。在你的模板代码里,你不需要知道 `f` 的具体类型——`std::invoke` 在内部帮你分派到正确的调用语法。 +三类合起来看,关键就一句话:管您 `f` 落在三类里头哪一类,写出来都是 `std::invoke(f, args...)` 这一种长相。您的模板代码再也不必知道 `f` 到底是什么货色,分派这事儿 `std::invoke` 在内部替您办了。 --- ## std::invoke_result_t:编译期推导返回类型 -光有统一调用还不够——有时候你还需要在编译期知道 `std::invoke(f, args...)` 的返回类型是什么。比如在 `then()` 的实现中,我们需要推导"把前一个回调的返回值传给下一个回调,返回什么类型"。 +光会统一调用还不够。有些场合您得在编译期就把「`std::invoke(f, args...)` 到底返回什么类型」这事问出来,`then()` 的链式实现就是典型。您把上一个回调的返回值往下喂给下一个回调,编译器得提前算出这条链最终吐什么类型,不然类型签名都没法落笔。 -`std::invoke_result_t` 就是干这个的。给定可调用对象类型 `F` 和参数类型 `Args...`,它在编译期计算出 `std::invoke(f, args...)` 的返回类型。 +`std::invoke_result_t` 就是来算这事的。您喂它一个可调用对象类型 `F` 加一组参数类型 `Args...`,它在编译期把 `std::invoke(f, args...)` 的返回类型给您算出来: ```cpp #include @@ -170,31 +150,11 @@ using R2 = std::invoke_result_t; static_assert(std::is_same_v); ``` -### 在 OnceCallback 中的使用 +## 在 OnceCallback 源码里它俩具体怎么用 -`then()` 的实现用 `std::invoke_result_t` 来推导链式调用中新回调的返回类型。具体来说,当 `then()` 接受一个后续回调 `next` 时,它需要知道 `next(上一个回调的返回值)` 会返回什么类型: +咱们直接对着 OnceCallback 的源码看,比抽象讲更清楚。`std::invoke` 在里头出现了两次,分别管 `bind_once` 和 `then()`。 -```cpp -// 在 then() 的非 void 分支中 -using NextRet = std::invoke_result_t; -// NextRet 就是"把 ReturnType 类型的值传给 next,返回什么类型" -``` - -void 分支中,后续回调不接受参数: - -```cpp -// 在 then() 的 void 分支中 -using NextRet = std::invoke_result_t; -// next 不接受参数,直接调用 -``` - ---- - -## 在 OnceCallback 源码中的具体使用 - -让我们对照实际源码,看看 `std::invoke` 在 OnceCallback 中的两个使用场景。 - -### bind_once 中的 std::invoke +先看 `bind_once` 里这一段: ```cpp // bind_once 的 lambda 内部 @@ -205,9 +165,9 @@ return std::invoke( ); ``` -这里 `f` 可能是任何可调用对象——普通 lambda、成员函数指针,甚至指向数据成员的指针。如果不用 `std::invoke` 而是直接写 `f(bound..., call_args...)`,当 `f` 是成员函数指针时就会编译失败——因为成员函数指针不能直接用 `()` 调用。 +这里头那个 `f` 是来者不拒的,它可能是 lambda,可能是成员函数指针,甚至可能是指向数据成员的指针。您要是不用 `std::invoke`,直接写一句 `f(bound..., call_args...)`,遇到成员函数指针当场编不过,成员函数指针压根不能直接套 `()`。 -### then() 中的 std::invoke +`then()` 里这一段同理: ```cpp // then() 的非 void 分支 @@ -215,13 +175,29 @@ auto mid = std::move(self).run(std::forward(args)...); return std::invoke(std::move(cont), std::move(mid)); ``` -`cont`(后续回调)在 `then()` 的设计里是一个普通的可调用对象(通常是 lambda),不是 `OnceCallback`。所以理论上直接 `cont(mid)` 也能工作——大部分情况下确实如此。但使用 `std::invoke` 是一种防御性编程:如果有人传进来一个成员函数指针作为后续回调,直接调用语法会失败,`std::invoke` 不会。统一使用 `std::invoke` 保证了无论传什么可调用对象都能正确工作,不需要额外的代码来处理特殊类型。 +这里的 `cont`(后续回调)按设计就是个普通可调用对象,多半是 lambda,理论上您写 `cont(mid)` 八成也能跑。那笔者干嘛还要套一层 `std::invoke`?当防御性写法用。哪天有人手滑传进来一个成员函数指针当后续回调,直接调用语法当场挂掉,而 `std::invoke` 不会。统一走它,能省下为特殊类型开洞的麻烦。 ---- +至于 `then()` 怎么用 `std::invoke_result_t` 推导返回类型,咱们的需求很具体:链式调用里下一个回调 `next` 拿到上一个回调的返回值后,自己又返回什么。代码读起来是这样: + +```cpp +// 在 then() 的非 void 分支中 +using NextRet = std::invoke_result_t; +// NextRet 就是"把 ReturnType 类型的值传给 next,返回什么类型" +``` + +void 分支那一路,后续回调根本不收参数,调用形态就简化了: + +```cpp +// 在 then() 的 void 分支中 +using NextRet = std::invoke_result_t; +// next 不接受参数,直接调用 +``` ## 踩坑预警:成员函数绑定的生命周期陷阱 -`std::invoke` 能统一处理成员函数指针,但它不会帮你管理对象的生命周期。当你在 `bind_once` 中绑定一个成员函数时: +`std::invoke` 把调用语法统一了,但有一件事它不管:对象什么时候死。这点特别容易栽,因为统一调用的便利性会让人忘了底下还藏着裸指针。 + +您在 `bind_once` 里绑成员函数,写出来是这样的: ```cpp struct Calculator { @@ -232,17 +208,11 @@ Calculator calc; auto bound = bind_once(&Calculator::multiply, &calc, 5); ``` -`&calc` 是一个裸指针,`bind_once` 会把它存到 lambda 的捕获列表里。如果 `calc` 在回调被调用之前就被销毁了,lambda 内部持有的就是一个悬空指针,`std::invoke` 通过悬空指针访问已释放的内存——未定义行为,大概率段错误。 - -Chromium 用 `base::Unretained` 显式标记"我知道这个裸指针的生命周期是安全的",用 `base::Owned` 接管对象的所有权,用 `base::WeakPtr` 在对象析构时自动取消回调。我们的简化版暂时不提供这些保护机制——安全责任在调用方手上。这是一个重要的设计取舍,我们在实战篇里会再提到。 - ---- - -## 小结 +那个 `&calc` 是个裸指针,`bind_once` 把它原样存进 lambda 的捕获列表。要是在回调真正跑起来之前 `calc` 先一步析构了,lambda 里头攥着的就一个悬空指针,`std::invoke` 照常通过它去摸内存,未定义行为,十有八九段错误。这事 `std::invoke` 帮不了您,它压根不知道您传进来的指针合不合法。 -这一篇我们弄清楚了 `std::invoke` 的来龙去脉。核心动机是各种可调用对象的调用语法各不相同——普通函数直接 `f(args...)`,成员函数指针要 `(obj.*pmf)(args...)`,数据成员指针要 `obj.*pmd`。`std::invoke` 把这些全部统一成 `std::invoke(f, args...)` 一种语法,配合 `std::invoke_result_t` 可以在编译期推导调用的返回类型。在 OnceCallback 中,`bind_once` 和 `then()` 都依赖它来实现"不关心可调用对象的具体类型,只要能调用就行"的泛型设计。 +Chromium 在 `//base` 里把这事儿做得很细:`base::Unretained` 让您显式声明「这裸指针的生命周期我自己兜底」,`base::Owned` 干脆把对象所有权交给回调框架接管,`base::WeakPtr` 则在对象析构时自动把回调作废。咱们这版 OnceCallback 是简化教学版,这层保护暂时不给,安全责任先压在调用方身上。这个取舍后头实战篇还会再聊,到时候您就明白为什么 Chromium 非要发明 `WeakPtr` 这么个机制。 -下一篇我们去看 Lambda 的高级特性——特别是 C++20 引入的 lambda init capture 包展开,它是 `bind_once` 得以简洁实现的关键。 +下一篇咱们去看 Lambda 的高级特性,特别是 C++20 的 init-capture 包展开,那玩意儿是 `bind_once` 能写得这么利索的关键。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-03-once-callback-lambda-advanced.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-03-once-callback-lambda-advanced.md index 6a5bafadd..ff0383b2f 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-03-once-callback-lambda-advanced.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-03-once-callback-lambda-advanced.md @@ -26,26 +26,15 @@ title: OnceCallback 前置知识(三):Lambda 高级特性 --- # OnceCallback 前置知识(三):Lambda 高级特性 -## 引言 +上一篇速查里咱们快速过了一遍 lambda 的基础语法。这篇要钻进去。OnceCallback 的实现代码里真正顶事的几个 lambda 特性——`mutable`、初始化捕获、C++20 的 capture pack expansion——真不是什么锦上添花的语法糖。把这三个搞不明白,后面读 `bind_once` 和 `then()` 的代码,您大概率会一边读一边骂笔者写得晦涩。其实是笔者也绕不开它们,绕开了 OnceCallback 就做不出来。 -上一篇速查里我们快速过了一遍 lambda 的基础语法。这篇我们要深入到 OnceCallback 实现中真正用到的三个 lambda 高级特性——它们不是什么"锦上添花"的语法糖,而是 `bind_once` 和 `then()` 得以实现的**关键机制**。如果不理解这些特性,后面的实现代码你会看得很痛苦。 - -具体来说,我们要讲三件事:`mutable` lambda 为什么在 OnceCallback 里不能省、初始化捕获(init capture)怎么让 `then()` 把整个 OnceCallback 对象搬进 lambda 里、以及 C++20 的 lambda capture pack expansion 怎么让 `bind_once` 的代码量缩减到原来的三分之一。 - -> **学习目标** -> -> - 理解 `mutable` lambda 与 const lambda 的行为差异及其在 OnceCallback 中的必要性 -> - 掌握初始化捕获的语法和语义,理解 `self = std::move(*this)` 的所有权转移 -> - 学会 C++20 lambda capture pack expansion,理解 `bind_once` 的简洁实现 -> - 理解泛型 lambda `(auto&&... args)` 的本质 - ---- +咱们一个个拆。先从 `mutable` 说起——它为什么在这套实现里一刀都不能少。 ## mutable lambda:为什么在 OnceCallback 里不能省 -Lambda 默认生成的 `operator()` 是 `const` 的——这意味着 lambda 内部不能修改值捕获的变量。加 `mutable` 关键字后,`operator()` 变成非 const 的,允许修改。 +Lambda 默认生成的 `operator()` 是 `const` 的。换句话说,值捕获进来的变量,您在 lambda 体内只能看、不能摸。加 `mutable` 之后,`operator()` 变成非 const,捕获的副本就任您改了。 -### 行为对比 +看个对照: ```cpp int x = 10; @@ -66,13 +55,11 @@ f2(); // 返回 11,x 的副本被修改 f2(); // 返回 12,同一个 lambda 对象再次调用,x 继续增加 ``` -注意第二个例子——`mutable` lambda 的状态在多次调用之间是保持的。这是因为 lambda 的闭包对象持有捕获变量的副本,`mutable` 让 `operator()` 可以修改这些副本。 - -### 在 OnceCallback 中的角色 +这里有个容易看漏的细节——`mutable` lambda 的状态是**跨调用保持**的。`f2` 第一次返回 11,第二次返回 12。闭包对象持有捕获变量的副本,`mutable` 把这些副本的修改权交给了 `operator()`,改完就留在那儿,下次进来接着改。这一点 OnceCallback 正好用得上。 -`bind_once` 和 `then()` 的 lambda 都必须声明为 `mutable`。原因是这些 lambda 的捕获列表里包含 `OnceCallback` 对象(通过 `self = std::move(*this)` 捕获),而调用 `std::move(self).run()` 会修改 `self` 的内部状态(把 `status_` 从 kValid 改为 kConsumed)。如果 lambda 是 const 的,`self` 在 lambda 内部就是 const 引用,你没法在 const 对象上调用修改状态的操作——编译器会直接报错。 +### 在 OnceCallback 里的角色 -简单说:**一旦 lambda 捕获了需要在调用时被修改的对象(比如 OnceCallback),就必须加 `mutable`**。这不是可选的——不加就编译不过。 +`bind_once` 和 `then()` 内部的 lambda,通通得标 `mutable`,没得商量。原因说白了就一句:这些 lambda 的捕获列表里塞了一个 `OnceCallback` 对象(走的是 `self = std::move(*this)`,下面马上讲),而您一旦调用 `std::move(self).run()`,就得改它的内部状态——把 `status_` 从 kValid 翻成 kConsumed。这玩意儿如果 lambda 是 const 的,`self` 在体内就是个 const 引用,您想在一个 const 对象上跑修改状态的操作?编译器第一个不答应。 ```cpp // then() 内部的 lambda——mutable 不可省略 @@ -88,11 +75,11 @@ f2(); // 返回 12,同一个 lambda 对象再次调用,x 继续增加 ## 初始化捕获(Init Capture):把对象搬进 lambda -C++14 引入了初始化捕获(init capture)语法,允许你在捕获列表中执行表达式并用结果初始化一个捕获变量。语法是 `name = expression`。 +C++14 给了咱们一个新玩具——初始化捕获。语法长这样:`name = expression`。在捕获列表里当场跑一个表达式,拿结果去初始化一个新的捕获变量。听起来不起眼,但它解决了 C++11 lambda 最大的一个痛点。 ### 和简单捕获的区别 -简单捕获 `[x]` 只能捕获已经存在的变量,而且是拷贝或引用语义。初始化捕获 `[name = expr]` 允许你做三件简单捕获做不到的事: +简单捕获 `[x]` 只能抓已经存在的变量,而且要么拷贝、要么引用,二选一。初始化捕获 `[name = expr]` 多了一层,能干三件简单捕获完全做不到的事: ```cpp auto ptr = std::make_unique(42); @@ -109,42 +96,44 @@ auto f2 = [len = s.size()]() { return len; }; // len 是 size_t 类型 auto f3 = [counter = 0]() mutable { return ++counter; }; // counter 是 lambda 自己的变量 ``` +第一条最要命。C++11 的 lambda 没有 move capture,您想往 lambda 里塞一个 `unique_ptr`,得绕一大圈——先存进 `std::function` 或者手工搓个函数对象。P0780 之前,Chromium 的 `base::Bind` 内部就是靠手动写 functor 来扛这个缺口的。初始化捕获一出,这些 hack 基本可以进博物馆了。 + ### 在 OnceCallback 中的使用 -`then()` 的实现用初始化捕获做了两件关键的事情。 +`then()` 的实现里,初始化捕获挑了两副担子。 -第一件是把整个 OnceCallback 对象搬进 lambda: +第一副——把整个 OnceCallback 对象搬进 lambda: ```cpp self = std::move(*this) ``` -`*this` 是当前 OnceCallback 对象,`std::move(*this)` 把它转成右值,初始化捕获 `self = std::move(*this)` 触发 OnceCallback 的移动构造,把 `func_`、`status_`、`token_` 全部搬进 lambda 的闭包对象里。移动之后,`*this`(原来的 OnceCallback 对象)进入"被移走"的状态——`func_` 和 `token_` 已经是空的或 null 了。 +`*this` 是当前的 OnceCallback 对象。`std::move(*this)` 把它转成右值,初始化捕获 `self = std::move(*this)` 就地触发 OnceCallback 的移动构造,把 `func_`、`status_`、`token_` 一股脑搬进 lambda 的闭包对象。搬完之后,外面的 `*this` 就是个被掏空的壳——`func_` 空了,`token_` 是 null,跟"已死"差不多。这一步是 OnceCallback move-only 语义的核心动作,所有权就这么平移进了 lambda。 -第二件是把后续回调搬进来: +第二副——把后续回调搬进来: ```cpp cont = std::forward(next) ``` -`std::forward(next)` 保持 `next` 的值类别——如果传入的是右值,它就是移动;如果传入的是左值,它就是拷贝。通常 `then()` 接受的都是临时 lambda(右值),所以这里是移动。 +`std::forward(next)` 原样保持 `next` 的值类别——传进来的是右值就移动,是左值就拷贝。`then()` 实际用的时候,接的多半是临时 lambda(右值),所以这里通常走移动。 ### 所有权链 -把这两件捕获放在一起看,`then()` 创建的新 lambda 持有了原回调和后续回调的**完整所有权**。这个 lambda 又被存入一个新的 `OnceCallback` 的 `std::move_only_function` 里。整个所有权链条是这样的: +把这两步放一起看,`then()` 造出来的新 lambda,手里攥着原回调和后续回调的完整所有权。这个 lambda 又被塞进一个新的 `OnceCallback` 的 `std::move_only_function`。整条所有权链一层套一层: ```mermaid graph LR A["新 OnceCallback"] --> B["move_only_function"] --> C["lambda 闭包"] --> D["原 OnceCallback + 后续回调"] ``` -每一层都通过移动语义传递所有权,没有任何共享或拷贝。这就是 OnceCallback 的 move-only 语义在 `then()` 中的完整体现——所有权从外到内层层传递,没有破绽。 +每一层都靠移动语义递所有权,不见共享,不见拷贝。OnceCallback 那套 move-only 规矩,在 `then()` 里就这么从外到内一路传到底,没有漏的地方。 --- ## C++20 Lambda Capture Pack Expansion:bind_once 的简洁秘诀 -这是这一篇里最重要的特性,也是 `bind_once` 得以用几行代码实现的关键。C++20 之前,可变参数模板的参数包**不能**直接展开到 lambda 的捕获列表里——你得先用 `std::tuple` 把参数打包存起来,然后在 lambda 内部用 `std::apply` 展开调用。 +这一篇里,真正让 `bind_once` 能用几行代码搞定就是它。C++20 之前,可变参数模板的参数包没法直接展开到 lambda 的捕获列表里——您得先用 `std::tuple` 把参数打包存起来,再在 lambda 内部拿 `std::apply` 展开调用。绕,但没别的办法。 ### 旧方案(C++17):tuple + apply @@ -163,11 +152,11 @@ auto bind_old(F&& f, BoundArgs&&... args) { } ``` -能工作,但代码膨胀了不少——你需要一个中间的 tuple、一个 `std::apply` 调用、以及一个嵌套 lambda 来处理展开。 +能工作,但代码臃肿得可以——中间塞个 tuple、外面套个 `std::apply`、里头还得再嵌一层 lambda 处理展开。三件套全上了。 ### 新语法(C++20):直接在捕获列表里展开包 -C++20 允许在 lambda 的初始化捕获中使用包展开。语法是 `...name = expression`,效果是为参数包中的每一个类型生成一个对应的捕获变量。 +C++20 终于松了口,允许在 lambda 的初始化捕获里做包展开。语法是 `...name = expression`,效果是给参数包里的每个类型单独生成一个捕获变量。 ```cpp template @@ -184,7 +173,7 @@ auto bind_new(F&& f, BoundArgs&&... args) { ### 手动展开一个具体例子 -假设我们调用 `bind_new([](int a, std::string b, int c) { ... }, 10, std::string("hello"))`,此时 `BoundArgs = {int, std::string}`。编译器把包展开 `...bound = std::forward(args)` 展开成: +咱们拿个具体的调用来看看编译器到底在背后做了什么。假设调用 `bind_new([](int a, std::string b, int c) { ... }, 10, std::string("hello"))`,这时 `BoundArgs = {int, std::string}`。编译器会把 `...bound = std::forward(args)` 这个包展开,摊成: ```cpp [f = std::forward(f), @@ -197,17 +186,19 @@ auto bind_new(F&& f, BoundArgs&&... args) { } ``` -每个绑定参数变成了 lambda 闭包中的一个独立成员变量,在 lambda 被调用时通过 `std::move(bound)...` 一起展开传给 `std::invoke`。 +每个绑定参数摇身一变,成了 lambda 闭包里的一个独立成员变量。等 lambda 被调用,再通过 `std::move(bound)...` 一把全展开,递给 `std::invoke`。 ### 为什么用 std::move 而不是 std::forward -你可能注意到 lambda 内部用的是 `std::move(bound)...` 而不是 `std::forward(bound)...`。原因是 lambda 是 `mutable` 的,捕获变量 `bound` 在 lambda 内部是**左值**(具名变量永远是左值)。由于我们希望绑定参数在回调被调用时以右值的方式传出(触发移动语义),所以用 `std::move` 把它们转成右值。如果用 `std::forward`,因为 `bound` 已经是左值了,`std::forward` 只会返回左值引用——移动语义就丢失了。 +这里有个坑,笔者第一次看差点没绕过来。lambda 内部用的是 `std::move(bound)...`,不是 `std::forward(bound)...`。为什么? + +关键在于 lambda 是 `mutable` 的,捕获变量 `bound` 在 lambda 体内是个**左值**——具名变量永远是左值,这没跑。咱们希望绑定参数在回调触发时以右值的方式传出去(触发移动),那就得拿 `std::move` 把它转成右值。要是手滑写成 `std::forward(bound)`,因为 `bound` 已经是左值了,`std::forward` 压根不会动它的值类别——返回的还是左值引用,移动语义当场蒸发。OnceCallback 是 move-only 的,这一步丢移动就等于丢所有权,后面全乱套。 --- ## 泛型 Lambda:auto&& 作为转发引用 -`bind_once` 内部的 lambda 用 `(auto&&... call_args)` 来接受运行时传入的参数。这里的 `auto&&` 是转发引用——因为 `auto` 在 lambda 参数中等同于模板参数,所以 `auto&&` 具有和 `T&&`(T 是模板参数时)相同的推导规则。 +最后说一个 `bind_once` 内部 lambda 的签名:`(auto&&... call_args)`。这套写法是用来接运行时传进来的参数的。这里头 `auto&&` 是转发引用——`auto` 在 lambda 参数里等同于模板参数,所以 `auto&&` 享受跟 `T&&`(T 是模板参数时)一模一样的推导规则。 ```cpp auto f = [](auto&& x) { @@ -221,15 +212,11 @@ f(v); // x 绑定到左值 f(10); // x 绑定到右值 ``` -`auto&&...` 的组合意味着这个 lambda 可以接受任意数量、任意类型的参数,同时保持每个参数的值类别信息。配合 `std::forward(call_args)...`,这些参数可以被完美转发到最终的可调用对象。 +`auto&&...` 这个组合,等于给 lambda 开了个口子,什么数量、什么类型的参数都能往里塞,同时每个参数的值类别(左值还是右值)它都记着。再配上 `std::forward(call_args)...`,这些参数就能被完美转发到最终的可调用对象那里,不丢一点信息。 --- -## 小结 - -这一篇我们掌握了 OnceCallback 实现中最关键的三个 lambda 特性。`mutable` lambda 允许在 lambda 内部修改捕获的对象,OnceCallback 的 `bind_once` 和 `then()` 必须用它才能在 lambda 里调用 `std::move(self).run()` 修改回调状态。初始化捕获 `name = expr` 让 `then()` 能把整个 OnceCallback 对象通过移动语义搬进 lambda 闭包,建立起完整的所有权链。C++20 的 lambda capture pack expansion `...name = expr` 让 `bind_once` 的绑定参数可以直接展开到捕获列表中,替代了 C++17 时代臃肿的 tuple + apply 方案。 - -下一篇我们去看 Concepts 和 `requires` 约束——它们是保护 OnceCallback 的模板构造函数不被错误匹配的关键防御手段。 +下一篇咱们去看 Concepts 和 `requires` 约束——它们是保护 OnceCallback 的模板构造函数不被错误匹配的关键防御手段。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-04-once-callback-concepts-and-requires.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-04-once-callback-concepts-and-requires.md index f6d3f3dff..3c909361d 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-04-once-callback-concepts-and-requires.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-04-once-callback-concepts-and-requires.md @@ -23,9 +23,7 @@ title: OnceCallback 前置知识(四):Concepts 与 requires 约束 --- # OnceCallback 前置知识(四):Concepts 与 requires 约束 -## 引言 - -OnceCallback 的构造函数上有这么一行看起来很多余的约束: +OnceCallback 的构造函数上有这么一行看起来很多余的约束: ```cpp template @@ -33,23 +31,15 @@ template explicit OnceCallback(Functor&& function); ``` -你可能会问——为什么不直接写 `template` 就完事了?多加一个 `requires not_the_same_t` 是在防什么? - -这一篇我们就来回答这个问题。答案涉及 C++ 重载决议中一个不太为人知的陷阱:**模板构造函数可能在某些情况下劫持移动构造函数的调用**。Concepts 和 `requires` 约束是 C++20 给我们的防御武器。 +笔者第一次读到这行的时候,脑子里第一反应是:这不是多此一举吗?直接 `template` 不就完事了,加个 `requires not_the_same_t` 到底在防谁? -> **学习目标** -> -> - 理解模板构造函数与移动构造函数之间的重载竞争问题 -> - 掌握 concept 的基本语法和 `requires` 子句的用法 -> - 能够解读 `not_the_same_t` 的设计意图和每一行代码的含义 - ---- +后来真踩了一脚,才知道它在防一个 C++ 重载决议里相当阴损的坑——**模板构造函数会劫持移动构造函数**。Concepts 和 `requires` 约束,就是 C++20 给咱们留下的防御武器。这一篇就把这个坑从头刨一遍,顺带把 concepts 这套语法也过一遍。 -## 问题引入:模板构造函数的"越位" +## 问题:模板构造函数的"越位" -### 场景还原 +咱们先把这个坑还原一下。 -假设我们有一个简单的包装类,接受任意可调用对象: +假设咱们写了个简单的包装类,接受任意可调用对象: ```cpp template @@ -69,14 +59,11 @@ public: }; ``` -现在我们写 `Callback cb2 = std::move(cb1);`——意图很明显,我们想调用移动构造函数。编译器面前有两条路: +咱们随手写一行 `Callback cb2 = std::move(cb1);`,意图很直白——走移动构造。但编译器面前其实摆着两条路:一条是隐式生成的移动构造函数 `Callback(Callback&&)`,另一条是模板构造函数实例化出来的 `Callback(Callback&&)`(令 `Functor = Callback`)。 -1. 隐式生成的移动构造函数 `Callback(Callback&&)` -2. 模板构造函数实例化 `Callback(Callback&&)`(令 `Functor = Callback`) +直觉上您肯定觉得移动构造稳赢——毕竟它是"专门为这个类型设计的"。可 C++ 的重载决议不走直觉。模板构造函数那个转发引用 `Functor&&` 太贪婪了,它能完美匹配任何东西,包括 `Callback&&` 本身;而移动构造函数的参数类型是写死的 `Callback&&`。在"匹配谁更精确"这件事上,模板实例化出的版本有时候反而看起来更贴。 -直觉上我们会觉得移动构造函数应该优先——毕竟它是"专门为这种类型设计的"。但 C++ 的重载决议规则不是这么简单。在某些情况下,模板实例化出来的函数签名比隐式声明的特殊成员函数是"更精确"的匹配——因为模板参数 `Functor` 可以完美匹配传入参数的类型(包括 `Callback&&`),而移动构造函数的参数类型是固定的 `Callback&&`。 - -当两个重载的匹配程度相同时,C++ 规则规定**非模板函数优先于模板函数**。所以大多数情况下移动构造函数确实会赢。但边缘情况比较微妙——特别是当涉及到转发引用和完美匹配时,有些编译器版本可能会有不同行为。更关键的是,即使移动构造函数赢了,如果模板构造函数也在候选列表中,某些 SFINAE 场景可能导致意外的编译错误。 +好在 C++ 留了条规则兜底:当模板和非模板版本匹配程度相同时,**非模板优先**。所以多数情况下移动构造还是能赢。可这事儿没那么干净——一旦牵扯到转发引用和完美匹配,不同编译器、不同版本的行为就开始飘了;更恶心的是,哪怕移动构造赢了,模板构造函数照样躺在候选列表里,某些 SFINAE 场景会冒出莫名其妙的编译错误。 ### 最小复现 @@ -99,26 +86,26 @@ Wrapper b = std::move(a); // 你期望输出 "move constructor" // 在某些情况下可能输出 "template constructor" ``` -解决方案就是给模板构造函数加约束——让它**不要**匹配 `Wrapper` 自身的类型。 +解法就是给模板构造函数套个约束,让它别去碰 `Wrapper` 自身的类型——这就是 `requires` 子句登场的地方。 --- -## Concept 基础语法 +## Concepts 是个什么东西 + +C++20 引入了 Concepts。官方定义很绕——"一种命名约束的机制"。笔者觉得这么说反而把人绕进去了。concept 这个词字如其名,就是"概念"的意思。 -C++20 引入了 Concepts——一种命名约束的机制。你可以把 concept 想象成"带名字的编译期布尔条件"。这样说如果你感觉不好懂了——笔者认为,concept这个东西字如其名:就是概念的意思,相比之前我们要用enable_if来晦涩的表达是什么,我们可以更加容易的说出他是什么了——他是XXX,XXX就是一个concept。就这么简单。 +咱们退一步想:在 concepts 出来之前,您要表达“我只接受整数类型”,得用 `enable_if` 那一套——`typename std::enable_if::value, int>::type = 0`,一长串晦涩的玩意儿,读的人得先在脑子里转一圈才能明白您想说什么。而 concept 干的事儿,就是让您**直接说出这是个什么概念**:它叫 `Integral`,它就是"整数"这个概念。就这么简单。`T` 满足 `Integral`,`T` 就是整数;不满足,就别想进来。 -### 声明 concept +声明一个 concept 长这样: ```cpp template concept Integral = std::is_integral_v; ``` -`Integral` 是一个 concept,它检查 `T` 是否是整数类型。`std::is_integral_v` 是一个编译期布尔常量。我们这里表达的意思很简单——我们就只要一个整形!拿着这个概念,就能下一步的被requires使用了。 +`Integral` 检查 `T` 是不是整数类型,`std::is_integral_v` 是个编译期的布尔常量。咱们想表达的意思就这么点——我就要个整数!有了这个概念,下一步就能把它喂给 `requires` 用。 -### 使用 requires 子句 - -`requires` 子句可以加在模板声明后面,用来约束模板参数必须满足某个条件: +`requires` 子句往模板声明后面一挂,就给模板参数上了道闸: ```cpp template @@ -131,9 +118,7 @@ foo(42); // OK:int 是整数 foo(3.14); // 编译错误:double 不满足 Integral ``` -### 标准库常用 concept - -C++20 在 `` 头文件中提供了一批预定义的 concept: +`` 头文件里还备了一堆标准库现成的 concept,常用的几个长这样: ```cpp #include @@ -150,32 +135,24 @@ static_assert(std::convertible_to); --- -## not_the_same_t:逐行拆解 +## 把 not_the_same_t 拆开看 -现在我们来看 OnceCallback 中的这个 concept: +现在咱们回头看 OnceCallback 里那个 concept: ```cpp template concept not_the_same_t = !std::is_same_v, T>; ``` -它做的事情用一句话说就是:**F 退化后的类型不是 T**。我们逐个拆解里面的三个关键组件。 +一句话总结:`F` 退化(decay)之后,只要不是 `T`,约束就过。里头有三个零件,咱们挨个拆。 -### std::decay_t\:退化掉引用和 cv 限定符 +先看 `std::decay_t`。它对类型干三件事:去引用(`int&` → `int`)、去顶层 const/volatile(`const int` → `int`)、把数组和函数类型退化掉(`int[5]` → `int*`、`int(int)` → `int(*)(int)`)。在 OnceCallback 这个场景里,最关键的是去引用。咱们写 `OnceCallback cb2 = std::move(cb1)` 的时候,`Functor` 被推导成 `OnceCallback`(不是 `OnceCallback&&`——转发引用的推导规则会把右值推导成非引用);但要是写成 `OnceCallback cb2 = cb1`(拷贝虽被删除,这里只作举例),`Functor` 就会被推导成 `OnceCallback&`。`std::decay_t` 的活儿,就是不管 `Functor` 推导出哪种引用形态,统统退化成裸的 `OnceCallback`,再拿去和 `T = OnceCallback` 比。 -`std::decay_t` 对类型做三件事:去掉引用(`int&` → `int`)、去掉顶层 const/volatile(`const int` → `int`)、数组和函数类型退化(`int[5]` → `int*`,`int(int)` → `int(*)(int)`)。 +再看 `std::is_same_v`。它在 `A` 和 `B` 完全相同时返回 `true`。注意"完全相同"这四个字很严——`int` 和 `const int` 不算同,`int&` 和 `int` 也不算同。这就是为什么前面非得先上 `std::decay_t` 把两边的形式统一了,不然一个带引用一个不带,比出来全是噪音。 -在 OnceCallback 的场景里,最关键的是去掉引用。当我们写 `OnceCallback cb2 = std::move(cb1)` 时,`Functor` 被推导为 `OnceCallback`(不是 `OnceCallback&&`,因为转发引用的推导规则会把右值推导为非引用类型)。但如果是 `OnceCallback cb2 = cb1;`(虽然拷贝被删除了,这里只是举例),`Functor` 就会被推导为 `OnceCallback&`。`std::decay_t` 保证了无论 `Functor` 推导出什么引用形式,退化后都是 `OnceCallback`,和 `T = OnceCallback` 做比较。 +最后那个取反 `!` 是点睛之笔。整个 concept 的值是 `!std::is_same_v, T>`——`F` 退化后要是等于 `T`,取反成 `false`,约束失败,模板被踢出候选;不等于 `T`,取反成 `true`,约束通过,模板正常参与重载决议。就这么个逻辑。 -### std::is_same_v<...>:比较两个类型 - -`std::is_same_v` 在 `A` 和 `B` 完全相同时返回 `true`。注意"完全相同"是很严格的——`int` 和 `const int` 不同,`int&` 和 `int` 也不同。这就是为什么我们需要 `std::decay_t` 先统一形式。 - -### 取反 `!`:F 不是 T 时约束通过 - -整个 concept 的值是 `!std::is_same_v, T>`——取反意味着当 `F` 退化后和 `T` 相同时约束失败(模板被排除),不同时约束通过(模板参与重载决议)。 - -### 加上约束后的效果 +把这个约束挂回去看效果: ```cpp template @@ -183,28 +160,26 @@ template explicit OnceCallback(Functor&& f) : status_(Status::kValid), func_(std::move(f)) {} ``` -当传入的是 `OnceCallback` 本身时(比如移动构造的场景),`not_the_same_t` 求值为 `!true = false`,约束不满足,模板被排除出候选列表,编译器只能选择移动构造函数。当传入的是 lambda、函数指针等其他类型时,约束满足,模板正常参与重载决议,被选为构造函数。 +传进来的是 `OnceCallback` 自身(比如移动构造那个场景),`not_the_same_t` 求值 `!true = false`,约束不满足,模板被晾在一边,编译器只能乖乖选移动构造函数。传进来的是 lambda、函数指针这些别的类型,约束满足,模板正常接活儿,被选为构造函数。就是这么干净。 --- -## 这个模式在标准库中的应用 +## 这不是 OnceCallback 的专利 -这不仅仅是 OnceCallback 的特殊需求。`std::move_only_function` 自己的实现里也有几乎一样的约束——只不过标准库用的是标准 concept `std::constructible_from` 配合 `!std::is_same_v` 的形式。任何 move-only 的类型擦除包装器都需要这个防御——只要你的类同时有"接受任意类型的模板构造函数"和"编译器生成的移动构造函数",就必须加约束来防止两者竞争。 +这玩意儿不是 OnceCallback 独一份的需求。`std::move_only_function` 自己的实现里挂着几乎一模一样的约束,只不过标准库走的是标准 concept `std::constructible_from` 配 `!std::is_same_v` 那一套写法。说穿了,任何 move-only 的类型擦除包装器,都得吃这记防御——只要您的类同时有"接受任意类型的模板构造函数"和"编译器生成的移动构造函数",这两者就一定会掐架,必须拿约束把它们隔开。 ```text 模式总结: 模板构造函数 + requires 排除自身类型 = 保护移动语义的正确匹配 ``` -如果你以后写类似的组件——比如自己的 `unique_function`、`any_invocable` 之类的 move-only 包装器——记住这个模式,它是一个通用的防御手段。 +笔者给您留句话:以后要是自己撸 `unique_function`、`any_invocable` 这类 move-only 包装器,记住这个套路,它是通用的防御手段,省得回头调试半天才发现移动语义被模板截胡了。 --- ## 踩坑预警 -### 如果忘记 std::decay_t - -如果只写 `!std::is_same_v` 而不加 `std::decay_t`,问题出在 `F` 的推导结果可能带引用也可能不带引用,取决于调用上下文。考虑以下场景: +**坑一:漏掉 `std::decay_t`。** 偷懒只写 `!std::is_same_v`,不加 `std::decay_t`,坑就埋下了——`F` 的推导结果可能带引用也可能不带,完全看您怎么调用。看下面这两个场景: ```cpp OnceCallback cb1([](int x) { return x; }); @@ -219,19 +194,13 @@ OnceCallback cb1([](int x) { return x; }); // is_same_v == false → 约束通过 ✗ 错误! ``` -场景 B 中,不加 `decay_t` 的话,`const OnceCallback&` 和 `OnceCallback` 不相同,约束通过,模板构造函数被选中——但语义上我们期望的是编译错误(拷贝已删除)或至少不是模板构造函数。加了 `decay_t` 后,`const OnceCallback&` 退化为 `OnceCallback`,和 `OnceCallback` 相同,约束正确失败。 - -### static_assert(false) 的陷阱 +场景 B 要是漏了 `decay_t`,`const OnceCallback&` 跟 `OnceCallback` 压根不是同一类型,约束反而通过了,模板构造函数被选中——可语义上咱们要的是编译错误(拷贝已删除),至少也不该是模板构造。补上 `decay_t` 之后,`const OnceCallback&` 退化成 `OnceCallback`,两边对上,约束才正确失败。这个坑笔者踩过,debug 了半天才发现是 `decay_t` 漏了。 -在 C++23 之前,`static_assert(false, "...")` 在模板中会导致所有实例化都触发断言失败——即使这个模板从未被调用。这是因为 C++ 标准在 C++23 之前要求 `static_assert(false)` 在模板定义时就立即求值。Chromium 用 `static_assert(!sizeof(*this), "...")` 来绕过这个限制(`!sizeof` 总是 `false`,但依赖 `*this` 的类型所以是依赖型表达式,不会在定义时求值)。C++23 放宽了这个规则,但如果你用 C++20 编译,仍然需要注意这个问题。 +**坑二:`static_assert(false)` 在模板里会"误伤"。** C++23 之前,在模板里写 `static_assert(false, "...")`,所有实例化都会触发断言失败——哪怕这个模板从头到尾没人调用过。因为老标准要求 `static_assert(false)` 在模板定义那一刻就立即求值。Chromium 的绕法是 `static_assert(!sizeof(*this), "...")`:`!sizeof` 永远是 `false`,但它依赖 `*this` 的类型,是个依赖型表达式,定义时不会求值,得等实例化才炸。C++23 把这条规则放宽了,但您要是还在 C++20 编译,这事儿照样得留心。 --- -## 小结 - -这一篇我们搞清楚了 OnceCallback 构造函数上那个看似多余的 `requires not_the_same_t` 约束。它的存在是为了防止模板构造函数在 `OnceCallback cb2 = std::move(cb1)` 这种场景下劫持移动构造函数的调用。`not_the_same_t` 通过 `std::decay_t` 去掉 `F` 上的引用和 const 修饰后与 `T` 比较,取反后确保传入自身类型时模板被排除。这个模式在所有 move-only 的类型擦除包装器中都会用到——`std::move_only_function` 也有类似的约束。 - -下一篇我们去看 `std::move_only_function`——它是 OnceCallback 的核心存储类型,也是我们用标准库设施替代 Chromium 手写 BindState 的关键。 +下一篇咱们去看 `std::move_only_function`——它是 OnceCallback 的核心存储类型,也是咱们拿标准库设施替换 Chromium 手写 BindState 的关键拼图。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-05-once-callback-move-only-function.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-05-once-callback-move-only-function.md index 919539f7e..b48e42fa3 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-05-once-callback-move-only-function.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-05-once-callback-move-only-function.md @@ -24,28 +24,15 @@ title: OnceCallback 前置知识(五):std::move_only_function (C++23) --- # OnceCallback 前置知识(五):std::move_only_function (C++23) -## 引言 - -`std::move_only_function` 是 OnceCallback 的心脏——它承担了所有类型擦除的脏活累活。OnceCallback 的 `func_` 成员就是 `std::move_only_function` 类型,它把 lambda、函数指针、仿函数等各种形态的可调用对象统一包装成同一个已知签名的调用接口。 - -这一篇我们要搞清楚三件事:`std::move_only_function` 和 `std::function` 到底有什么区别、它的 SBO(Small Buffer Optimization)行为是怎么工作的、以及为什么 OnceCallback 不能直接依赖它的判空机制而需要自己搞一套三态管理。 - -> **学习目标** -> -> - 理解 `std::move_only_function` 的设计动机——为什么 `std::function` 不够用 -> - 掌握构造、移动、调用、判空四个核心操作 -> - 理解 SBO 的原理和 `std::move_only_function` 的分配行为 -> - 明白为什么 OnceCallback 需要独立的 `Status` 枚举 - ---- +OnceCallback 的 `func_` 成员,类型是 `std::move_only_function`。这玩意儿干的是脏活——类型擦除:把 lambda、函数指针、仿函数这些五花八门的可调用对象,都收编成一个固定签名的调用入口。咱们这一篇要把它拆开看清楚:它跟老 `std::function` 到底差在哪,SBO(小对象优化)给不给力,以及一个笔者自己踩过的坑——为什么 OnceCallback 还得多养一个自己的 `Status` 枚举,而不能图省事直接用它的判空。 ## 从 std::function 到 std::move_only_function -### std::function 的局限 +### std::function 卡在哪 -`std::function` 是 C++11 引入的通用可调用对象容器,它通过类型擦除把各种可调用对象统一成同一个接口。但 `std::function` 有一个根本性的限制:它要求存储的可调用对象**必须可拷贝**。 +`std::function` 是 C++11 给的通用可调用对象容器,靠类型擦除把一锅可调用对象收成同一接口。但它有个要命的硬约束:要求被存的东西可拷贝。 -原因在于 `std::function` 自身是可拷贝的——当你拷贝一个 `std::function` 时,它需要把内部存储的可调用对象也拷贝一份。如果你试图用一个捕获了 `std::unique_ptr` 的 lambda 来构造 `std::function`,编译器会在拷贝语义上直接报错: +根源在它自己能拷贝。您 copy 一个 `std::function`,它就得连带着把内部那个对象也拷一份。可如果您想塞进去的是一个捕了 `std::unique_ptr` 的 lambda 呢——unique_ptr 独占所有权,根本不让拷。结果就是,这行代码编译就给您拍脸上一个报错: ```cpp #include @@ -57,11 +44,11 @@ auto ptr = std::make_unique(42); std::function f = [p = std::move(ptr)]() { return *p; }; ``` -这个限制在 OnceCallback 的场景里是致命的——OnceCallback 的核心卖点就是 move-only,它必须支持捕获 `unique_ptr` 的 lambda。 +这事儿在 OnceCallback 这里直接顶到墙——OnceCallback 的招牌就是 move-only,它非得支持捕了 `unique_ptr` 的回调不可。 -### std::move_only_function 的解决方案 +### std::move_only_function 怎么解的 -`std::move_only_function`(C++23,定义在 `` 中)就是"move-only 版本的 `std::function`"。它删除了拷贝操作,只保留移动操作,从而不再要求存储的可调用对象可拷贝。 +C++23 的 `std::move_only_function`(还在 `` 里)就是冲着这块来的:把拷贝操作砍了,只留移动,被存的对象自然也不用满足可拷贝。 ```cpp #include @@ -75,15 +62,13 @@ std::move_only_function f = [p = std::move(ptr)]() { return *p; }; int result = f(); // result == 42 ``` -两个类型在接口上的关键区别可以概括为:`std::function` 可拷贝可移动,要求存储对象可拷贝;`std::move_only_function` 不可拷贝只可移动,只要求存储对象可移动。 +两者接口上的差别一句话能说清:`std::function` 能拷能移,被存对象必须能拷;`std::move_only_function` 只能移不能拷,被存对象只要能移就行。 --- -## 四个核心操作 - -### 构造:从可调用对象创建 +## 构造、移动、调用、判空 -`std::move_only_function` 接受任何匹配签名 `R(Args...)` 的可调用对象——lambda、函数指针、仿函数,甚至另一个 `std::move_only_function`: +构造的玩法跟 `std::function` 一个模子:`std::move_only_function` 张开手接任何签名对得上的可调用对象:lambda、函数指针、仿函数,连另一个 `std::move_only_function` 也行。默认构造出来就是空的,判空时跟 `nullptr` 一样。调用也就是熟悉的 `f(args...)` 语法,空对象被调了直接抛 `std::bad_function_call`,该崩让它崩。 ```cpp // 从 lambda 构造 @@ -103,9 +88,7 @@ std::move_only_function f3 = Multiplier{}; std::move_only_function f4; // f4 == nullptr ``` -### 移动:转移所有权 - -移动操作把源对象的可调用对象转移到目标对象。移动之后,源对象的状态是**未指定的**——标准没有保证它一定为空。 +真正值得停下来看一眼的是移动。语义很直白,源对象的可调用物整个搬家到目标。但搬完之后,源对象是个什么状态?标准给的是四个字:未指定(valid but unspecified)。它没保证源一定变空。 ```cpp std::move_only_function f = []() { return 42; }; @@ -114,22 +97,9 @@ auto g = std::move(f); // 不要依赖 f 在移动后的行为 ``` -这一点非常重要——也是 OnceCallback 需要自己的 `Status` 枚举的原因之一。我们后面会展开讲。 - -### 调用:通过 operator() 执行 +笔者顺手在 GCC 16 上跑了一把,实测 `bool(f)` 移动后确实给了 `false`。但请您记住,这是实现给的善意,不是标准给您兜底的承诺——换一家实现,明天它给您个 `true` 也不是不可能。这条尾巴很要紧,等会儿咱们看 OnceCallback 为什么还得养自己的 `Status` 枚举,根子有一半就在这儿。 -调用语法和 `std::function` 一样——直接用 `()` 运算符: - -```cpp -std::move_only_function f = [](int a, int b) { return a + b; }; -int result = f(3, 4); // result == 7 -``` - -如果 `f` 为空(通过默认构造或 `= nullptr`),调用会抛出 `std::bad_function_call` 异常。 - -### 判空:检查是否持有可调用对象 - -通过 `operator bool()` 或与 `nullptr` 比较: +判空走 `operator bool()` 或者直接跟 `nullptr` 比,两者等价。要主动清空就赋个 `nullptr` 进去,之前攥着的可调用对象随之析构: ```cpp std::move_only_function f; @@ -147,47 +117,50 @@ if (f) { } ``` -也可以通过赋值 `nullptr` 来主动清空: - ```cpp f = nullptr; // 清空 f,析构之前持有的可调用对象 ``` --- -## SBO:小对象优化 +## SBO:小对象优化 -### 什么是 SBO - -`std::move_only_function`(和 `std::function` 一样)内部实现了**小对象优化**(Small Buffer Optimization,SBO)。思路很简单:对象内部预留一块固定大小的缓冲区(通常是几个指针大小),如果可调用对象足够小,就把它直接存到缓冲区里,避免堆分配;如果太大,就在堆上分配内存来存储。 +`std::move_only_function` 内部跟 `std::function` 一样,做了小对象优化(Small Buffer Optimization,SBO)。套路不复杂:对象自留一块固定大小的缓冲区——通常是几个指针那么宽——可调用物个头够小,就把它直接塞进缓冲区,堆分配省了;要是太大装不下,退而求其次上堆。 ![SBO 小对象优化内部结构](./pre-05-sbo-structure.drawio) -SBO 的阈值是实现定义的——通常在 2-3 个指针大小(16-24 字节)左右。捕获少量参数的 lambda(比如 `[x = 42]` 或 `[&ref]`)通常能放进 SBO,不会触发堆分配。但如果 lambda 捕获了大量数据(比如一个 `std::string` + 几个 `int`),超过了 SBO 阈值,构造时就会在堆上分配。 +SBO 的阈值是实现自己定的,常见落在 2 到 3 个指针宽(16 到 24 字节)这个区间。捕的东西少的 lambda,像 `[x = 42]` 或 `[&ref]` 这种,基本都能蹭进 SBO,不触发堆分配。但要是 lambda 捕了一坨,比如一个 `std::string` 外加几个 `int`,撑过了阈值,构造时就得老实上堆。 + +### sizeof 实测对比 -### sizeof 对比 +光说没意思,咱们量一下实物。GCC 16 上跑出来是这样: ```cpp #include #include int main() { - std::cout << "sizeof(std::function): " + std::cout << "sizeof(std::function): " << sizeof(std::function) << "\n"; std::cout << "sizeof(std::move_only_function): " << sizeof(std::move_only_function) << "\n"; } ``` -在 GCC 上,典型值是 `std::function` 约 32 字节,`std::move_only_function` 也约 32 字节。两者大小差不多,因为它们使用类似的 SBO 策略。 +```text +sizeof(std::function): 32 +sizeof(std::move_only_function): 40 +``` + +`std::function` 是 32 字节,`std::move_only_function` 多出 8 字节,做到 40。两者底层 SBO 思路相近,但 move-only 那一套——省掉了拷贝路径要做的事、留下了移动 vtable 之类——多出来的这点开销主要花在这儿。 --- -## 为什么 OnceCallback 需要独立的 Status 枚举 +## 为什么 OnceCallback 还得养自己的 Status 枚举 -你可能已经注意到了一个细节——OnceCallback 在 `std::move_only_function` 之外又加了一个自己的 `Status` 枚举来追踪状态。为什么不直接用 `std::move_only_function` 的判空机制? +读到这儿您可能会问:既然 `std::move_only_function` 自己能判空,OnceCallback 怎么还多此一举,又在外面套了个 `Status` 枚举?笔者一开始也想图省事直接用它的判空,真动手才发现不够。 -原因是 `std::move_only_function` 的判空无法区分三种不同的状态: +根子在状态数不够。`operator bool()` 只分得出"空"和"非空",可 OnceCallback 要分辨的是三态: ```cpp enum class Status : uint8_t { @@ -197,19 +170,19 @@ enum class Status : uint8_t { }; ``` -`std::move_only_function` 的 `operator bool()` 只能区分"空"和"非空"两种状态。但 OnceCallback 需要知道一个回调是"从来没被赋过值"(kEmpty)还是"曾经有值但已经被调用了"(kConsumed)。这两种情况在调试时的含义完全不同——kEmpty 意味着"你忘了给回调赋值",kConsumed 意味着"回调已经被正确调用了,你不应该再使用它"。 +"从来没被赋过值"(kEmpty)和"赋过、跑过、已经消化完了"(kConsumed)——这俩在 `operator bool()` 眼里都是空,可含义差远了。调试的时候,kEmpty 通常在提醒您"忘了给回调赋值",是个真 bug;kConsumed 是回调被正常调用完后的预期状态,正常得很。两种情况糊在一起,DCHECK 想说句明白话都没辙。 -还有一个更微妙的问题:`std::move_only_function` 移动后的状态是**未指定的**——标准不保证移动后源对象的 `operator bool()` 返回 `false`。某些实现可能仍然返回 `true`,只是内部数据已经无效了。如果 OnceCallback 依赖 `std::move_only_function` 的判空来判断状态,在移动操作之后可能会得到错误的结果。独立的 `Status` 枚举完全由我们控制——移动构造函数显式把源对象设为 `kEmpty`,不存在歧义。 +还有个更阴的,就是上一节那个"移动后状态未指定"。标准没保证移完之后 `operator bool()` 给 `false`——某些实现大可以还给个 `true`,里头东西却已经搬空了。OnceCallback 真要靠它判状态,移动一发生就可能误判。自己的 `Status` 就踏实了——它完全攥在咱们手里,移动构造的时候咱们显式把源对象标成 `kEmpty`,干净利落,没有半点含糊。 --- -## 与 Chromium BindState 的对比 +## 跟 Chromium BindState 放一块儿看 -Chromium 没有使用标准库的类型擦除设施——它手写了一套 `BindState` 系统。对比一下两种方案的核心差异。 +Chromium 那边没碰标准库的类型擦除,它自己手撸了一套 `BindState`。两套方案放一块儿,差异挺有意思。 -Chromium 的 `BindState` 是一个堆分配的对象,存储了可调用对象和所有绑定参数。`OnceCallback` 本身只持有一个指向 `BindState` 的智能指针(`scoped_refptr`),大小只有 8 字节——一个指针。所有状态都放在堆上的 `BindState` 里,回调对象本身只是一个"瘦代理"。 +Chromium 的 `BindState` 是个堆上的对象,把可调用物和所有绑定参数全揽进去。`OnceCallback` 自己呢,就攥着一个指向 `BindState` 的智能指针(`scoped_refptr`),总共才 8 字节,一个指针那么宽。状态全堆到 `BindState` 那头,回调自己就是个瘦代理。 -我们的方案用 `std::move_only_function` 替代了整个 `BindState` 层——它内部实现了类型擦除和 SBO,省去了我们手写函数指针表、SBO 缓冲区、移动/析构操作的工作。代价是对象大小从 8 字节膨胀到约 32 字节(`std::move_only_function` 本身的大小),再加上 `Status` 枚举和可选的 `CancelableToken` 指针,整个 `OnceCallback` 大约 56-64 字节。 +咱们这版把整个 `BindState` 层换成了 `std::move_only_function`——类型擦除和 SBO 它内部都给您办了,函数指针表、SBO 缓冲区、移动析构那一摊手写活儿全省了。代价是尺寸:从 8 字节长到 40 字节(`std::move_only_function` 自己),再叠上 `Status` 枚举和可选的 `CancelableToken` 指针,一个 `OnceCallback` 大概 56 到 64 字节的样子。 | 指标 | Chromium BindState | 我们的 std::move_only_function | |------|-------------------|-------------------------------| @@ -218,15 +191,9 @@ Chromium 的 `BindState` 是一个堆分配的对象, | 移动代价 | 复制一个指针 | 复制 32+ 字节 | | 实现复杂度 | 很高(手写引用计数+函数指针表) | 低(复用标准库) | -对于教学目的和大多数实际场景,56-64 字节的回调对象完全不是瓶颈。如果你的项目确实需要极致紧凑,可以参考 Chromium 的方案——核心思路我们在后续实战篇里会讲清楚。 - ---- - -## 小结 - -这一篇我们搞清楚了 `std::move_only_function` 的来龙去脉。它是 C++23 引入的 move-only 版本的 `std::function`,删除了拷贝操作以支持 move-only 的可调用对象。内部实现了 SBO 来优化小对象的存储。但它的移动后状态未指定,且只能区分"空"和"非空"两种状态——这就是 OnceCallback 需要独立的三态 `Status` 枚举的原因。与 Chromium 手写的 `BindState` 相比,我们用对象大小的膨胀换来了实现简洁性的大幅提升。 +对教学、对大多数实际场景,五六十字节的回调对象压根儿不是瓶颈。真要把尺寸压到极致,那就走 Chromium 那条路——核心思路咱们在后续实战篇里再细说。 -下一篇我们去看 OnceCallback 的最后一个前置知识点——C++23 的 deducing this(显式对象参数),它是 `run()` 方法实现编译期左值/右值拦截的核心机制。 +下一篇是 OnceCallback 的最后一个前置点:C++23 的 deducing this(显式对象参数),`run()` 方法能靠它在编译期分清左值右值拦截,根子就在它身上。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-06-once-callback-deducing-this.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-06-once-callback-deducing-this.md index 52a49a617..81e684958 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-06-once-callback-deducing-this.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/full/pre-06-once-callback-deducing-this.md @@ -22,29 +22,20 @@ title: OnceCallback 前置知识(六):Deducing this (C++23) --- # OnceCallback 前置知识(六):Deducing this (C++23) -## 引言 +## 先看一眼那行声明 -OnceCallback 的 `run()` 方法是整个组件的灵魂,也是 C++23 特性最密集的一个方法。它的声明长这样: +OnceCallback 的 `run()` 是整个组件里最反直觉的一个方法,也是 C++23 特性最密集的地方。它的声明长这样: ```cpp template auto run(this Self&& self, FuncArgs&&... args) -> ReturnType; ``` -如果你没见过 `this Self&& self` 这种写法——别慌,这一篇就是专门讲它的。这是 C++23 引入的"显式对象参数"特性,官方名称叫 **deducing this**。它让 OnceCallback 用一个函数模板就实现了"左值调用编译报错、右值调用正常执行"的效果,比 Chromium 的方案干净得多。 +`this Self&& self` 这写法笔者头回见的时候愣了两秒——成员函数还能把 `this` 显式写成参数?这是 C++23 的「显式对象参数」,官方名字叫 deducing this。别看就这么一行,它让 OnceCallback 拿一个函数模板就把「左值调用编译报错、右值调用正常跑」这件事给办了,比 Chromium 那套干净一大截。咱们这篇就把这玩意儿拆透——语法、推导规则、和 OnceCallback 怎么借它做编译期拦截。 -> **学习目标** -> -> - 理解 deducing this 的语法和推导规则 -> - 掌握 `run()` 如何利用它实现编译期左值/右值拦截 -> - 理解惰性实例化(lazy instantiation)在 `static_assert` 中的作用 -> - 对比 deducing this 和传统 ref-qualifier 的适用场景 +## 先把问题摆清楚:凭什么 `cb.run()` 不能编过 ---- - -## 问题:如何让 cb.run() 编译失败 - -OnceCallback 的核心语义是"只能调用一次,而且必须通过右值调用"。用代码表达就是: +OnceCallback 的核心语义就一句——「只能调一次,而且必须右值调」。翻译成代码: ```cpp OnceCallback cb([](int x) { return x * 2; }); @@ -53,11 +44,11 @@ cb.run(5); // 应该编译失败:cb 是左值 std::move(cb).run(5); // 应该编译通过:std::move(cb) 是右值 ``` -我们需要一种机制,让 `run()` 能够在编译期区分"通过左值调用"和"通过右值调用",并且对左值调用给出清晰的错误信息。 +咱们要的就是个编译期分流的机制:左值调,直接红给您看,错误信息还得说人话;右值调,放行。 -### Chromium 的旧方案 +### Chromium 没 C++23,只好写两个重载 -Chromium 没有享受 C++23 的福利,它用了一个比较 hack 的方案——两个重载: +Chromium 那会儿没 C++23 可用,只能上 hack——同一件事写两份重载: ```cpp // 右值版本:真正的执行 @@ -73,17 +64,17 @@ R Run() const& { } ``` -为什么用 `!sizeof(*this)` 而不是直接写 `false`?因为在 C++23 之前,`static_assert(false, "...")` 在模板中会导致所有代码路径都触发断言——即使这个函数从未被调用。C++23 放宽了这个限制。`!sizeof(*this)` 利用了 `sizeof` 必须在完整类型上才能求值的特性——它是一个依赖型表达式,只有在模板实例化时才求值,从而实现了"只在实际调用时才触发"的效果。 +这里有个细节笔者当初卡了一阵:为啥不直接 `static_assert(false, "...")`?因为在 C++23 之前,模板里写死 `false` 会无差别触发——哪怕这个重载一辈子没被调用过,编译器在模板定义点就给您炸了。`!sizeof(*this)` 是个绕路:它依赖 `*this` 的类型,是依赖型表达式,得等到模板实例化那一刻才求值。换句话说,真有人写了 `cb.Run()` 才炸,没写就当它不存在。 -能工作,但确实不优雅——需要两个重载函数来处理同一件事,而且 `!sizeof` hack 的可读性不好。 +能跑,真不优雅。两份重载干一件事不说,`!sizeof` 这个 hack 读起来还得反应两秒。C++23 把 deducing this 给落地之后,这事儿就有正经解法了。 --- -## deducing this 的语法与推导规则 +## deducing this:把 `this` 写成参数 -C++23 的 deducing this 让我们可以把 `this` 显式地写成成员函数的第一个参数,并用模板参数来推导它的类型和值类别。 +deducing this 干的事就一句——把原本隐式藏在成员函数里的 `this`,拎出来显式写成第一个参数,顺带给它套个模板推导。 -### 基本语法 +### 语法 ```cpp struct MyStruct { @@ -93,17 +84,15 @@ struct MyStruct { }; ``` -`this auto&& self` 是显式对象参数的声明。关键字 `this` 出现在类型前面,告诉编译器"这不是一个普通参数,而是显式的对象参数"。`auto&&` 是推导占位符——编译器会根据调用时对象的值类别来推导 `self` 的具体类型。 +`this` 关键字往类型前头一搁,等于跟编译器打招呼:后头这玩意儿不是普通参数,是显式的对象参数。`auto&&` 是推导占位符,谁调它、拿啥调它,推导出来的类型就长啥样。 -### 推导规则 +### 推导规则:跟转发引用一模一样 -`self` 的类型推导规则和转发引用(forwarding reference)完全一样——因为 `self` 的推导上下文等效于模板参数: +`self` 的推导规则,跟咱们写模板时碰到的转发引用(forwarding reference)完全是一个模子——因为 `self` 的推导上下文等效于一个模板参数。这点很关键,后面拦截左值就靠它。 -- **左值调用** `obj.f()`:`self` 的类型推导为 `MyStruct&`(左值引用) -- **右值调用** `std::move(obj).f()` 或 `MyStruct{}.f()`:`self` 的类型推导为 `MyStruct`(非引用,纯类型) -- **const 左值调用** `std::as_const(obj).f()`:`self` 的类型推导为 `const MyStruct&` +拿 `obj.f()` 这种左值调来说,`self` 推成 `MyStruct&`,左值引用。换 `std::move(obj).f()` 或者 `MyStruct{}.f()` 这种右值调,`self` 推成 `MyStruct`,纯类型、没引用。再上 `std::as_const(obj).f()` 这种 const 左值调,`self` 就老老实实推成 `const MyStruct&`。记不住没关系,跑一下就出来了。 -### 验证推导结果 +### 跑一下验证 ```cpp #include @@ -130,9 +119,9 @@ int main() { --- -## 在 OnceCallback::run() 中的应用 +## 落到 `run()` 上:deducing this 怎么干活的 -现在我们来看 `run()` 的完整实现,理解它是如何利用 deducing this 来拦截左值调用的。 +看完语法,咱们直接上 `run()` 的完整实现,看它怎么把左值调用摁在编译期。 ```cpp template @@ -144,57 +133,54 @@ auto run(this Self&& self, FuncArgs&&... args) -> ReturnType { } ``` -这段代码做了三件事,我们逐一拆解。 +这几行里塞了三个互相咬合的机关,咱们挨个拆。 ### 拦截左值调用 -`std::is_lvalue_reference_v` 检查 `Self` 是否是左值引用类型。当调用方写 `cb.run(args)` 时,`cb` 是左值,`Self` 被推导为 `OnceCallback&`——这是一个左值引用类型,`is_lvalue_reference_v` 返回 `true`,取反后为 `false`,`static_assert` 失败,编译器报出我们写的那句错误信息:"OnceCallback::run() must be called on an rvalue. Use std::move(cb).run(...) instead." - -当调用方写 `std::move(cb).run(args)` 时,`std::move(cb)` 是右值(严格说是 xvalue),`Self` 被推导为 `OnceCallback`——不是引用类型,`is_lvalue_reference_v` 返回 `false`,取反后为 `true`,`static_assert` 通过,代码继续执行。 +`std::is_lvalue_reference_v` 在问 `Self` 是不是左值引用。调用方写 `cb.run(args)`,`cb` 是左值,`Self` 推成 `OnceCallback&`,左值引用,`is_lvalue_reference_v` 返回 `true`,取反变 `false`,`static_assert` 当场炸,顺手把笔者写的那句人话错误信息甩给调用方:`OnceCallback::run() must be called on an rvalue. Use std::move(cb).run(...) instead.` -### 转发到 impl_run +调换一下,写 `std::move(cb).run(args)`,`std::move(cb)` 是右值(严格说是 xvalue),`Self` 推成 `OnceCallback`,非引用,`is_lvalue_reference_v` 返回 `false`,取反变 `true`,断言过,代码继续往下走。一推一拉,左值右值的命运就在编译期分了叉。 -`std::forward(self)` 根据 `Self` 的类型决定是返回左值引用还是右值引用。由于 `static_assert` 已经排除了左值的情况,到达这里的 `Self` 一定是非引用类型(右值),所以 `std::forward(self)` 返回的是右值引用——确保 `impl_run` 在右值上被调用。 +### 转发给 impl_run -### 惰性实例化(Lazy Instantiation) +过了断言这关,`std::forward(self)` 把 `self` 原样转给真正的执行函数 `impl_run`。因为 `static_assert` 已经把左值的路堵死了,能走到这一步的 `Self` 一定是非引用的右值,`std::forward(self)` 就老实地返回右值引用,保证 `impl_run` 拿到的是个右值。这步看着不起眼,但它是「拦截」和「执行」之间那根接力棒,缺了它语义就漏了。 -这里有一个值得玩味的细节——`static_assert` 的条件依赖模板参数 `Self`,所以它只有在模板实例化时才求值。这意味着: +### 顺带说一句惰性实例化 -- 如果 `run()` 从未被调用,`static_assert` 不会触发——不管 `OnceCallback` 对象本身是左值还是右值 -- 只有在某个具体的调用点上,编译器需要实例化这个模板时,`Self` 的具体类型才会被确定,`static_assert` 才会求值 +这里头有个笔者玩味了挺久的细节——`static_assert` 的条件挂着模板参数 `Self`,所以它只在模板实例化那一刻才求值。反过来说,如果 `run()` 压根没人调,这个 `static_assert` 就一直趴着不动,不管那个 `OnceCallback` 对象本身是左值还是右值都跟它无关。只有真有人在某一行写了 `cb.run(...)`,编译器非得实例化这个模板不可的时候,`Self` 的具体类型才定下来,断言才肯抬眼算一下。 -这叫"惰性实例化"(lazy instantiation),是 C++ 模板的一个基本特性。函数模板只有在使用时才会被实例化——不使用就不实例化,也不做任何检查。这就是为什么 Chromium 不得不用 `!sizeof(*this)` 而不是直接写 `false`——在 C++23 之前,`static_assert(false)` 不依赖模板参数,会在模板定义时就触发,而不是等实例化时才触发。 +这就是模板的惰性实例化(lazy instantiation)——函数模板不使就不实例化,也不查。这也解释了为啥 Chromium 非 `!sizeof(*this)` 不可:在 C++23 之前,`static_assert(false)` 跟模板参数没关系,会在模板定义点就先炸了,根本等不到实例化。 --- -## 与传统 ref-qualifier 的对比 +## 跟传统 ref-qualifier 比,什么时候该用谁 -OnceCallback 里有两个方法表达了"只能通过右值调用"的语义——`run()` 用 deducing this,`then()` 用传统的 ref-qualifier `&&`。为什么不统一用一种方式? +OnceCallback 里有两个方法都表达了「只能右值调」这层意思——`run()` 用 deducing this,`then()` 用传统的 ref-qualifier `&&`。笔者一开始也纳闷,既然 deducing this 这么好,干嘛不一刀切全用它?后来把两个场景的需求摆在一起比,才想通——它们要的精细度根本不一样。 -### then() 用 ref-qualifier +### then() 用 ref-qualifier 就够了 ```cpp template auto then(Next&& next) && -> OnceCallback<...>; ``` -`then()` 的需求很简单——它只接受右值,不接受左值,不需要区分后给出不同的错误信息。如果调用方写了 `cb.then(next)`(左值调用),编译器直接报"没有匹配的重载函数",虽然错误信息不如 deducing this 那么有指导意义,但足够用了。ref-qualifier 写起来也更简洁——一个 `&&` 就完事了。 +`then()` 这边诉求很朴素:右值照收,左值直接拒之门外,也不用费心去解释为啥拒。调用方真写了 `cb.then(next)`(左值调),编译器甩一句「没有匹配的重载」就完事。错误信息虽然糙了点,没 deducing this 那么有指导性,但够用。ref-qualifier 写起来也省事,末尾挂个 `&&`,一个字符搞定。 -### run() 用 deducing this +### run() 非要 deducing this 不可 -`run()` 的需求更精细——它不仅需要拒绝左值调用,还需要给出一个**有指导意义的错误信息**,告诉调用方"你应该用 `std::move(cb).run(...)` 而不是 `cb.run(...)`"。deducing this 让这个需求变得自然——`static_assert` 可以输出我们自定义的错误信息,而不是编译器默认的"no matching function"。 +`run()` 这边就挑剔多了。光把左值拒了不够,还得告诉调用方「您该写的是 `std::move(cb).run(...)`,不是 `cb.run(...)`」——一条能让人立刻改对人话的错误信息。deducing this 配 `static_assert` 恰好把这件事做得很自然:错误信息是笔者自己塞进去的,不是编译器那套「no matching function」的模板。 -### 选择策略 +### 怎么挑 -总结一下:如果你只需要"只接受右值"的约束,用 `&&` 限定更简洁。如果你还需要对左值调用给出自定义的错误信息,用 deducing this 配合 `static_assert` 更合适。 +所以笔者的判断是:只图个「只收右值」的约束,`&&` 够用,简洁。要是还得对左值调用甩一句自定义的人话错误,那就上 deducing this 配 `static_assert`。工具选哪个,看您要不要解释。 --- ## 踩坑预警 -### 显式对象参数不能与 cv-qualifier 或 ref-qualifier 共存 +笔者这里栽过的坑,您顺手避开。 -有显式对象参数的成员函数不能同时声明为 `const`、`volatile` 或带 ref-qualifier(`&`/`&&`)。这是因为显式对象参数已经接管了对象类型和值类别的推导——`const` 和 `&&` 限定变得多余甚至矛盾。 +显式对象参数有个硬规矩——它不能跟 cv-qualifier 或 ref-qualifier 同时出现。道理也好理解:对象类型和值类别已经被显式参数接管了,您再叠个 `const` 或挂个 `&&`,编译器就懵了,这俩到底谁说了算?所以下面这写法直接编不过: ```cpp struct Bad { @@ -203,21 +189,15 @@ struct Bad { }; ``` -### 显式对象参数不能是静态函数 +还有一处容易误会的:显式对象参数函数看着像静态函数,其实不是——它照样得有个对象实例才能调。`this` 参数是编译器从调用表达式里推出来的,不是调用方手动塞进去的,这点别搞反。 -显式对象参数函数不是静态函数——它仍然需要一个对象实例来调用。`this` 参数是由编译器从调用表达式推导出来的,不是由调用方手动传入的。 - -### 编译器支持 - -Deducing this 是 C++23 特性。GCC 14+、Clang 18+、MSVC 19.34+ 支持此特性。如果你的编译器不支持,只能回退到 Chromium 的双重重载方案。 +最后是工具链门槛。deducing this 是 C++23 特性,GCC 14+、Clang 18+、MSVC 19.34+ 才认。您要是还在用老编译器,那就只能回退到 Chromium 那套双重重载的方案了——hack 是 hack 了点,但起码能跑。 --- -## 小结 - -这一篇我们搞清楚了 deducing this 的来龙去脉。它让 `run()` 用一个函数模板就实现了编译期的左值/右值拦截——通过 `Self` 的推导类型判断调用方传的是左值还是右值,配合 `static_assert` 给出有指导意义的错误信息。相比 Chromium 的两个重载 + `!sizeof` hack,deducing this 方案更简洁、更符合 C++ 的设计哲学。而 `then()` 不需要自定义错误信息,用传统的 `&&` 限定更简洁。 +到这里,deducing this 这把刀就算磨完了。OnceCallback 的 `run()` 靠它一个函数模板就把左值右值在编译期分流,Chromium 那两份重载加 `!sizeof` hack 的苦日子总算到头了。`then()` 那边没这讲究,挂个 `&&` 图个省事。工具怎么挑,看您要不要给调用方甩人话错误。 -到这里,所有前置知识都讲完了。下一篇我们正式进入 OnceCallback 的实战环节——从动机分析开始,设计我们的目标 API。 +前置知识到这就收尾,下一篇咱们正式动手搭 OnceCallback 的骨架。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/01-once-callback-design.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/01-once-callback-design.md index 60d1c9755..e120cafce 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/01-once-callback-design.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/01-once-callback-design.md @@ -24,75 +24,61 @@ title: once_callback 设计指南(一):动机与接口设计 --- # once_callback 设计指南(一):动机与接口设计 -## 引言 +## 问题从哪儿来:`std::function` 在异步回调里漏的那几条 -说实话,笔者在做异步编程的时候,踩过最多的坑就是回调被多次调用。场景很经典:注册一个文件 I/O 完成的回调,期望它跑一次就完事,结果因为某处逻辑手滑多触发了一次,回调里释放的资源被二次访问,直接喜提段错误。这种 bug 的一大特点是——在测试里很难复现,因为正常的异步路径往往只跑一次回调;真正的触发条件是某种竞态或错误重试路径。 +笔者做异步编程这些年,踩得最深的坑就是回调被多调一次。场景太典型了:注册一个文件 I/O 完成的回调,指望它跑一遍就收工,结果某条重试路径手滑又触发了一次,回调里刚释放的资源被二次访问,直接段错误。最气人的是这种 bug 在单线程的单元测试里几乎复现不出来,正常路径就跑一次回调,触发条件藏在某种竞态或错误路径里,得跑到线上、压力够大才冒头。 -`std::function` 没法帮我们。它允许多次调用,允许拷贝传播,回调对象可以满天飞。我们在卷二已经拆解过 `std::function` 的内部机制(类型擦除 + SBO)和它的 `LightCallback` 简化实现——那个版本解决了类型擦除的开销问题,但完全没有触及"回调应该被调用几次"这个语义问题。 +`std::function` 在这种地方帮不上咱们。它能拷贝、能反复调,一个回调对象随便复制到几个地方,谁也不拦。卷二咱们拆过它的内部机制(类型擦除 + SBO),也手写过 `LightCallback` 那个简化版,可那一版的精力全花在压类型擦除的开销上,根本没碰"回调到底该被调几次"这件事。语义没管,运行时崩给您看。 -Chromium 团队在设计 `base::OnceCallback` 的时候,给出了一个非常漂亮的回答:**让回调的类型系统本身来约束调用语义**。`OnceCallback` 是 move-only 类型,它的 `Run()` 方法只能通过右值引用调用(`std::move(cb).Run()`),调用一次之后回调对象就被消费掉了,再调就是空操作或者断言失败。这个设计在 Chrome 浏览器每天数百亿次的任务投递中经过了充分验证。 +Chromium 的 `base::OnceCallback` 把这件事在类型层面摁住了:`OnceCallback` 是 move-only,它的 `Run()` 只能通过右值调(`std::move(cb).Run()`),调一次回调就被消费掉,再调就是空操作或断言失败。这套约束每天在 Chrome 的任务系统里扛着数百亿次投递,硬是被它跑通了。 -我们这一系列的目标不是照搬 Chromium 的实现(那个实现非常复杂,涉及手写的引用计数、`TRIVIAL_ABI` 注解、函数指针分派表),而是利用 C++23 的新特性——特别是 `std::move_only_function` 和 deducing this——来实现一个保留了 Chromium 设计精髓、但代码量可控的 `OnceCallback` 组件。 +咱们这一系列要做的,是把 Chromium 那套设计的精髓提出来,用 C++23 的标准设施(`std::move_only_function` 和 deducing this)重做一遍。原版的实现里手写引用计数、`TRIVIAL_ABI` 注解、函数指针分派表全是硬活,咱们换个路子,把代码量压到可控,语义一点不丢。 -> **学习目标** -> -> - 理解"move-only + 一次性消费"为什么是回调的正确语义约束 -> - 设计 `OnceCallback` 的完整公共接口 -> - 分析 Chromium `OnceCallback` 的内部架构,理解每个设计决策背后的原因 +先把 `std::function` 在异步场景下的几处漏子点清楚,再动手。 ---- - -## 我们的问题:`std::function` 在异步场景的三大缺陷 - -在动手设计之前,我们先把问题拆清楚。`std::function` 作为通用的可调用对象容器,在设计上是成功的——但在异步回调这个特定场景下,它有三个让笔者血压拉满的问题。 - -**第一,可复制。** `std::function` 天生支持拷贝,这意味着一个回调可以被复制到任意多个地方。在异步系统中,这等于允许多个执行路径同时持有同一份回调的副本。如果回调里捕获了 move-only 的资源(比如 `std::unique_ptr`),拷贝直接编译失败;如果捕获的是裸指针或引用,多个副本同时执行就会产生竞态。Chrome 团队的思路很直接:既然异步任务回调从根本上就不应该被复制,那就让它在类型层面不可拷贝。 - -**第二,可重复调用。** `std::function::operator()` 对调用次数没有任何约束。你可以在同一个 `std::function` 上调一千次,它照跑不误。但在异步回调场景里,一个文件读取完成的回调被调用两次就是逻辑错误——它可能触发两次资源释放、两次状态转换、两次消息发送。这种错误在类型系统里完全检测不到,只能靠运行时的断言(如果有的话)或者——更常见的情况——靠 bug 现场来发现。 +`std::function` 第一条:它能拷贝。一个回调被复制到几个地方,在异步系统里就等于几条执行路径同时攥着同一份副本。回调里要是捕获了 `std::unique_ptr` 这种 move-only 的东西,拷贝直接编译过不去,这还算好的,编译器替您摁住了;要是捕获的是裸指针或引用,几个副本一起跑,竞态就埋下了。Chrome 的思路利落:异步任务回调压根就不该被复制,那就在类型层面不让它拷。 -**第三,无法表达消费语义。** 在 Chrome 的任务投递模型中,一个 `PostTask(FROM_HERE, callback)` 调用之后,`callback` 就不应该再被使用——它的所有权已经转移给了任务系统。`std::function` 的 `operator()` 是 `const` 限定的,调用它不会改变 `std::function` 对象本身的状态,所以你无法通过调用接口来表达"调用即消费"这个语义。 +第二条,它能被反复调。`std::function::operator()` 对次数完全没意见,您在一个对象上调一千次它照跑。可异步回调不是这么回事——一次文件读完成的回调被调两次,就是逻辑错误:资源释放两遍、状态转两遍、消息发两遍。这种错在类型系统里看不出来,只能靠运行时断言兜,或者更常见的,靠 bug 现场来发现。 -这三个问题归结到一点:`std::function` 的接口设计无法表达"这个回调只能被调用一次,调用后即失效"这个约束。Chrome 的 `OnceCallback` 正是为了填补这个语义空白而设计的。 +第三条藏得最深:`std::function` 没法表达"调用即消费"。Chrome 的任务投递里,`PostTask(FROM_HERE, callback)` 一调,`callback` 的所有权就交给了任务系统,调用方不该再碰它。可 `std::function::operator()` 是 `const` 限定的,调一次不会改变对象状态——您没法从调用接口本身告诉外界"我这次调用把对象吃掉了"。 ---- +三条归到一处:`std::function` 的接口表达不出"这个回调只能调一次,调完即失效"这个约束。`OnceCallback` 就是来填这个空白的。 -## Chromium 的回答:`OnceCallback` 设计哲学 +## Chromium 的回答:`OnceCallback` 的设计取向 -Chrome 的回调系统建立在一条核心原则之上:**消息传递优于锁,序列化优于线程**。在这个原则下,每个投递到任务系统的回调(Chrome 里叫 task)都是一个独立的、一次性的消息。投递之后,回调的所有权就从调用方转移到了任务系统;执行之后,回调就被销毁。没有共享,没有复用,没有歧义。 +Chrome 的回调体系立在一条原则上:消息传递优于锁,序列化优于线程。按这条原则,每个投到任务系统的回调(Chrome 里叫 task)都是一条独立的、一次性的消息——投出去所有权就归了任务系统,执行完就销毁。没有共享,没有复用,也没有歧义。 -这个哲学直接体现在 `OnceCallback` 的类型设计上: +这条哲学顺着落到 `OnceCallback` 的类型设计上,几个关键取舍列在下表,您扫一眼就能对上号: -- **Move-only**:`OnceCallback` 删除了拷贝构造和拷贝赋值,只保留移动操作。这从类型层面保证了回调在任意时刻只有一个持有者。 -- **右值限定 `Run()`**:`OnceCallback::Run()` 只能通过右值引用调用(`std::move(cb).Run(args...)`)。左值调用会触发 `static_assert`,产生一条明确的编译错误。这从语法层面提醒调用方:"你在消费这个回调,之后别再用了。" -- **单次消费**:`Run()` 内部会通过引用计数机制销毁 `BindState`,使得后续对同一对象的任何访问都是安全的空操作。 +| 取舍 | OnceCallback 的选择 | 解决什么 | +|---|---|---| +| 可复制性 | 删除拷贝构造/赋值,仅 move | 类型层面保证任意时刻只有一个持有者 | +| `Run()` 的值类别 | 仅右值可调(`std::move(cb).Run()`) | 语法层面提醒"您在消费它,调完别用" | +| 调用次数 | 内部消费 `BindState`,后续访问是空操作 | 单次消费语义 | -Chrome 实际上还有 `RepeatingCallback`——一个可复制的、可重复调用的版本。两个回调类共享同一套 `BindState` 内部实现,区别仅在于 `Run()` 的值类别限定和 `BindState` 的所有权语义。这种设计允许同一套绑定基础设施同时服务于"一次性任务"和"重复监听器"两种截然不同的使用模式。 +顺带一提,Chrome 还有 `RepeatingCallback`——可复制、可反复调的版本。两个回调类共用同一套 `BindState` 实现,差别只在 `Run()` 的值类别限定和 `BindState` 的所有权语义。一套绑定基础设施同时伺候"一次性任务"和"重复监听器"两种用法,这点设计笔者觉得相当干净。 ### Chromium 内部实现概览 -我们不用深入 Chromium 的每一行源码,但需要理解它的核心架构,因为我们的 `OnceCallback` 会借鉴同样的分层思路,只是用 C++23 的标准设施来简化实现。 +咱们不打算逐行啃 Chromium 的源码,但得把它的核心架构摸清楚——咱们要做的 `OnceCallback` 走的是同一套分层思路,只是用 C++23 的标准设施把实现简化掉。 -Chromium 的回调系统由三个层次组成,从底到顶依次是: +从底往上看,Chromium 的回调系统叠了三层。 -**底层:`BindStateBase`**——类型擦除的基类。它带引用计数,但有趣的是,它**不使用虚函数**。取而代之的是三个函数指针成员:`polymorphic_invoke_`(负责调用)、`destructor_`(负责析构)、`query_cancellation_traits_`(负责取消查询)。Chrome 团队选择函数指针而非虚函数的原因是减少二进制文件膨胀。虚函数会为每个模板实例化生成一个独立的 vtable(虚函数表),如果一个项目里有 100 种不同的 `BindState` 实例化,就会有 100 个 vtable。而函数指针的方式可以复用同一份静态函数,只有指向函数的指针值不同,不会产生额外的代码段。 +最底下一层是 `BindStateBase`,做类型擦除的基类,带引用计数。这里有个笔者第一次读时愣了一下的取舍:它带引用计数,**却不用虚函数**。取而代之的是三个函数指针成员,`polymorphic_invoke_` 管调用、`destructor_` 管析构、`query_cancellation_traits_` 管取消查询。Chrome 团队这么干是为了压二进制膨胀:虚函数会给每个模板实例化生成一个独立的 vtable,项目里要是有一百种 `BindState` 实例化,就有一百个 vtable;函数指针这路子能复用同一份静态函数,只是指针值不同,代码段不跟着涨。 -**中间层:`BindState`**——模板化的具体类,继承自 `BindStateBase`。它存储了真正的可调用对象(`Functor`)和通过 `BindOnce` 绑定的参数(`BoundArgs...`)。你可以把它理解为一个"装着所有东西的盒子":盒子里有你的 lambda、绑定的参数、以及基类要求的那些函数指针。这个类的实例通过 `scoped_refptr`(Chromium 自己实现的 intrusive 引用计数智能指针)管理生命周期——`OnceCallback` 在 `Run()` 时释放引用,`RepeatingCallback` 在每次 `Run()` 时保持引用。 +中间层是 `BindState`,模板化的具体类,继承自 `BindStateBase`。您把它当成一个"装着所有东西的盒子"就行,盒子里有您的 lambda、绑定的参数,以及基类要的那几个函数指针。它的生命周期交给 `scoped_refptr` 管(Chromium 自己的侵入式引用计数智能指针):`OnceCallback` 在 `Run()` 时释放引用,`RepeatingCallback` 每次 `Run()` 都保持引用。 -**顶层:`OnceCallback` 和 `RepeatingCallback`**——用户直接操作的类型。它们本质上是 `BindStateHolder` 的薄包装,而 `BindStateHolder` 只是一个带 `TRIVIAL_ABI` 注解的 `scoped_refptr`。`TRIVIAL_ABI` 是 Clang 的扩展属性,告诉编译器"这个类型可以像 int 一样在寄存器中传递",这使得 `OnceCallback` 的实际大小只有一个指针(8 字节),移动操作仅仅是复制一个指针——极其轻量。 +顶上是 `OnceCallback` 和 `RepeatingCallback`,用户直接打交道的类型。它们其实就是 `BindStateHolder` 的薄包装,而 `BindStateHolder` 不过是一个挂了 `TRIVIAL_ABI` 注解的 `scoped_refptr`。`TRIVIAL_ABI` 是 Clang 的扩展属性,告诉编译器"这个类型可以像 int 一样在寄存器里传",于是 `OnceCallback` 实际大小只有一个指针(8 字节),移动操作就是复制一个指针,轻得离谱。 -这三层之间的关系可以用一句话概括:**顶层回调对象只是一个指向中间层盒子的指针,盒子里装着底层要求的函数指针和真正的数据**。我们接下来设计的 `OnceCallback` 会保留这个"外层接口 + 中间存储 + 类型擦除"的分层思路,但用 `std::move_only_function` 来替代 Chromium 手写的 `BindState` + `scoped_refptr` 组合,用 deducing this 来替代 `const&` 重载 + `static_assert` 的 hack。 +三层关系一句话:顶层的回调对象只是一个指向中间层盒子的指针,盒子里装着底层要的函数指针和真正的数据。咱们接下来设计的 `OnceCallback` 会保留"外层接口 + 中间存储 + 类型擦除"这套分层骨架,但底层用 `std::move_only_function` 替掉 Chromium 手写的 `BindState` + `scoped_refptr`,用 deducing this 替掉 `const&` 重载加 `static_assert` 那套 hack。 --- -## 环境说明 - -先确认一下我们的工具链。`OnceCallback` 依赖以下 C++23 特性: +## 环境与前置 -- **`std::move_only_function`**(``):C++23 引入的 move-only 类型擦除可调用包装器,是我们的核心构建块 -- **Deducing this**(显式对象参数 `this auto&& self`):C++23 特性,允许在成员函数中推导 `this` 的值类别 -- **`if consteval`**:编译期条件判断(部分实现中可能用到) +开工前先确认工具链。`OnceCallback` 这一系列吃的几口 C++23 饭是:`std::move_only_function`(`` 里头,C++23 引入的 move-only 类型擦除可调用包装器,咱们的核心积木)、deducing this(显式对象参数 `this auto&& self`,让成员函数能推导 `this` 的值类别),偶尔还会碰到 `if consteval` 做编译期条件判断。 -编译器要求方面,GCC 12+ 或 Clang 16+ 可以完整支持上述特性。编译时加 `-std=c++23` 即可。可以用下面这段代码快速验证环境: +编译器这块,GCC 12+ 或 Clang 16+ 能完整支持上述特性,编译时挂 `-std=c++23` 就行。下面这段代码可以快速验环境: ```cpp #include @@ -112,24 +98,15 @@ int main() { } ``` -如果这段代码编译通过,说明环境就绑了。不过说实话,截止笔者写这篇文章时,部分编译器的 `std::move_only_function` 实现还有 bug(比如 GCC 12 的早期版本在某些 SFINAE 场景下会编译失败),建议使用 GCC 13+ 或 Clang 17+ 的最新稳定版本。 - -### 前置知识 +这段能编过,环境就绑了。不过说句实在话,笔者写这篇文章时,部分编译器的 `std::move_only_function` 实现还有 bug(GCC 12 早期版本在某些 SFINAE 场景下会编不过),保险起见用 GCC 13+ 或 Clang 17+ 的稳定版。 -我们假设读者已经熟悉以下内容(对应的卷二文章已经覆盖): - -- **移动语义与完美转发**:`OnceCallback` 的核心就是 move-only,如果对 `std::move` 和 `std::forward` 的原理不熟,实现过程中会非常痛苦。对应文章:卷二 ch00 移动语义系列。 -- **`std::function` 的类型擦除与 SBO**:我们直接在 `std::move_only_function` 之上构建,需要理解类型擦除的基本原理和小对象优化是什么、为什么重要。对应文章:卷二 ch03 `std::function` 与可调用对象。 -- **`std::invoke` 与统一调用协议**:`bind_once` 内部用 `std::invoke` 来统一处理函数指针、成员函数指针、仿函数等不同类型的可调用对象。对应文章:同上。 -- **可变参数模板与参数包展开**:`OnceCallback` 的模板特化、`bind_once` 的参数绑定都需要熟悉参数包语法。对应文章:卷二 ch00 完美转发、卷四 模板基础。 -- **`std::invoke` 与统一调用协议**:`bind_once` 内部用 `std::invoke` 来统一处理函数指针、成员函数指针、仿函数等不同类型的可调用对象。对应文章:同上。 -- **可变参数模板与参数包展开**:`OnceCallback` 的模板特化、`bind_once` 的参数绑定都需要熟悉参数包语法。对应文章:卷二 ch00 完美转发、卷四 模板基础。 +前置知识这边,咱们假设您已经熟这几样(卷二都覆盖过):移动语义和完美转发——`OnceCallback` 的核心就是 move-only,`std::move` 和 `std::forward` 的原理不熟的话,实现过程会很难受(卷二 ch00 移动语义系列);`std::function` 的类型擦除和 SBO——咱们直接在 `std::move_only_function` 上头盖,得明白类型擦除是怎么回事、小对象优化为什么重要(卷二 ch03);`std::invoke` 和统一调用协议——`bind_once` 里头用它统一处理函数指针、成员函数指针、仿函数这些不同的可调用对象(同卷二 ch03);还有可变参数模板和参数包展开——`OnceCallback` 的模板特化、`bind_once` 的参数绑定都离不开参数包语法(卷二 ch00 完美转发、卷四模板基础)。 --- -## 设计接口:我们想要什么样的 API +## 设计接口:咱们想要什么样的 API -我们先把目标 API 定下来,再回头讨论每个设计决策。这是工程师的工作方式——先想清楚"我要什么",再想"怎么做"。 +先把目标 API 钉下来,再回头抠每个决策。这是工程师的干活路子——先想清楚"我要什么",再想"怎么做"。 ### 核心用法 @@ -197,17 +174,13 @@ int final_result = std::move(pipeline).run(3, 4); ### 接口设计决策分析 -现在我们逐个讨论这些 API 背后的设计决策。 - -**为什么是 `run()` 而不是 `operator()`?** +API 定下来了,咱们挨个抠背后的决策。 -Chromium 用的是 `Run()`(Google C++ 风格要求大写开头)。我们用 `run()` 符合 snake_case 命名规范。但更深层的原因是语义区分:`operator()` 太通用,任何可调用对象都有 `operator()`;`run()` 明确表达了"执行任务"的语义,在代码审查时一眼就能看出这是在消费一个 `OnceCallback`,而不是调用一个普通的可调用对象。 +先说为什么用 `run()` 而不是 `operator()`。Chromium 用的是 `Run()`(Google C++ 风格要求首字母大写),咱们走 snake_case 用 `run()`。但这不只是命名规范的差别:`operator()` 太通用了,任何可调用对象都能有;`run()` 这个词明确表达"执行任务"的意思,代码审查时一眼就能看出这是在消费一个 `OnceCallback`,不是在调一个普通可调用对象。语义边界划得清楚,读代码的人也省心。 -**为什么 `run()` 必须通过右值调用?** +真正要害的一处:`run()` 凭什么必须通过右值调?这是整个设计里最关键的一环。咱们要的是一种机制,让 `cb.run(args)`(左值调)编不过,让 `std::move(cb).run(args)`(右值调)编得过。Chromium 的实现靠两个重载达成,一个 `Run() &&` 是真执行版,一个 `Run() const&` 里头塞了个 `static_assert(!sizeof(*this))` 拦左值。这 hack 管用,但说实话挺丑。 -这是整个设计中最关键的一点。我们需要一种机制,让 `cb.run(args)`(左值调用)编译失败,而 `std::move(cb).run(args)`(右值调用)编译通过。Chromium 的实现是通过两个重载来达成的:一个 `Run() &&` 是真正的执行版本,一个 `Run() const&` 内部放了一个 `static_assert(!sizeof(*this))` 来产生编译错误。这个 hack 虽然有效但很丑。 - -我们利用 C++23 的 **deducing this**(显式对象参数)可以做得更优雅。简单来说,deducing this 允许我们在成员函数里把 `this` 显式写成一个模板参数,编译器会根据调用时对象是左值还是右值来推导这个参数的类型。利用这个特性,`run(this auto&& self, Args... args)` 通过推导 `self` 的值类别来区分左值和右值调用,在编译期就拦截非法用法: +C++23 的 deducing this(显式对象参数)能让咱们做得更体面。简单讲,它允许成员函数把 `this` 显式写成一个模板参数,编译器会按调用时对象是左值还是右值来推导这个参数的类型。靠着这个,`run(this auto&& self, Args... args)` 就能根据 `self` 推导出的值类别,在编译期把非法用法挡掉: ```cpp template @@ -219,39 +192,31 @@ auto run(this Self&& self, FuncArgs&&... args) -> ReturnType { } ``` -当调用方写 `cb.run(args)` 时,`Self` 被推导为 `OnceCallback&`(左值引用),`static_assert` 触发,报错信息直接告诉调用方该怎么做。当写 `std::move(cb).run(args)` 时,`Self` 被推导为 `OnceCallback`(右值),编译通过。deducing this 的具体工作机制和与 Chromium 方案的详细对比,我们在下一篇的实现篇里会展开讲。 - -**为什么要区分 `is_cancelled()` 和 `maybe_valid()`?** - -这个设计直接来自 Chromium 的 `CancellationQueryMode`。区别在于安全保证的强弱。`is_cancelled()` 提供确定性回答——它只能在回调绑定的序列上调用,保证返回准确的结果。`maybe_valid()` 提供乐观估计——它可以从任何线程调用,但结果可能过时。在实际使用中,`is_cancelled()` 用于"在投递前检查是否还有意义"的判断,`maybe_valid()` 用于"跨线程快速检查是否值得投递"的优化路径。 +调用方写 `cb.run(args)` 时,`Self` 推成 `OnceCallback&`(左值引用),`static_assert` 触发,报错信息直接告诉您该怎么改;写 `std::move(cb).run(args)` 时,`Self` 推成 `OnceCallback`(右值),编译通过。deducing this 具体怎么干活、跟 Chromium 那套方案的细致对比,留到下一篇实现篇再展开。 -在我们的简化实现中,这两个方法都通过 `CancelableToken` 来查询——`is_cancelled()` 检查状态是否有效以及令牌是否仍然有效,`maybe_valid()` 就是 `!is_cancelled()` 的简单包装。后续如果需要更精细的线程安全语义,可以在这两个方法上做区分。 +接下来一个小但容易踩的取舍:`is_cancelled()` 和 `maybe_valid()` 凭什么分两个?这个设计直接来自 Chromium 的 `CancellationQueryMode`,差别在安全保证的强弱。`is_cancelled()` 给确定性回答,只能在回调绑定的序列上调,结果准确;`maybe_valid()` 给乐观估计,能从任何线程调,但结果可能过时。实际用的时候,`is_cancelled()` 用在"投递前查一下还有没有意义"这种判断,`maybe_valid()` 用在"跨线程快速看一眼值不值得投"的优化路径。咱们的简化实现里,这两个方法都走 `CancelableToken` 查询,`is_cancelled()` 查状态有效性和令牌是否还在,`maybe_valid()` 就是 `!is_cancelled()` 的薄包装。往后要是需要更细的线程安全语义,在这两个方法上做区分就行。 -**`then()` 为什么消费 `*this`?** - -`then()` 的语义是"把当前回调的执行结果传给下一个回调"。这要求当前回调在 `then()` 返回的新回调中被完整捕获(capture)。如果 `then()` 不消费 `*this`,就会导致同一个回调同时存在于两个地方——原位置和 `then()` 返回的新回调中——这违反了 move-only 的语义约束。所以 `then()` 被声明为右值限定成员函数(`then(...) &&`),调用后原回调对象进入已消费状态。 +最后是 `then()` 为什么也得吃掉 `*this`。`then()` 的语义是"把当前回调的执行结果传给下一个回调",这要求当前回调在新回调里被完整捕获。要是 `then()` 不消费 `*this`,同一个回调就会同时待在两个地方,原位置和 `then()` 返回的新回调里各一份,move-only 的语义当场就破了。所以 `then()` 被声明成右值限定成员函数(`then(...) &&`),调完原回调进入已消费状态。 --- -## 内部机制:类型擦除的两层架构 +## 内部机制:类型擦除的两层架构 -接口设计好了,我们来看看内部应该怎么组织。Chromium 用了 `BindStateBase` + `scoped_refptr` + 函数指针表这套组合拳来实现类型擦除,效果很好但代码量惊人。我们的策略是用 `std::move_only_function` 来承担类型擦除和小对象优化的脏活累活,把精力集中在消费语义、参数绑定和链式组合这些有趣的部分上。 +接口定好了,该看内部怎么组织。Chromium 那套 `BindStateBase` + `scoped_refptr` + 函数指针表的组合拳做类型擦除,效果是好,但代码量也确实惊人。咱们的路子是让 `std::move_only_function` 把类型擦除和小对象优化这些脏活累活扛掉,把精力集中在消费语义、参数绑定和链式组合这些有嚼头的部分。 ### 为什么选 `std::move_only_function` -`std::move_only_function` 是 C++23 引入的,它的定位就是"move-only 版本的 `std::function`"。它内部实现了类型擦除和 SBO,行为和 `std::function` 类似,但删除了拷贝操作。 +`std::move_only_function` 是 C++23 引入的,定位就是"move-only 版的 `std::function`"。它内部把类型擦除和 SBO 都做好了,行为跟 `std::function` 差不多,只是把拷贝操作删了。 -你可能已经注意到了 `OnceCallback` 这种写法——`R(Args...)` 看起来像一个函数声明,但在模板参数的上下文中,它是一个**函数类型**(function type)。`int(int, int)` 描述的是"接受两个 int 参数、返回 int 的函数",它是一种合法的 C++ 类型。我们通过模板偏特化来拆解这个类型——下一篇会详细讲解这个技巧。 +您可能已经留意到 `OnceCallback` 这种写法——`R(Args...)` 看着像函数声明,但在模板参数的语境里,它是一种合法的 C++ 类型:函数类型。`int(int, int)` 描述的就是"吃两个 int、返回一个 int 的函数"。咱们靠模板偏特化来拆这个类型,下一篇会细讲这个技巧。 -用 `std::move_only_function` 做内部存储有几个好处。它省去了我们手写类型擦除的工作——回想卷二的 `LightCallback`,我们花了一整个章节来手写函数指针表、SBO 缓冲区、移动/析构操作,而 `std::move_only_function` 把这些全部封装好了,直接拿来用。它也天然支持 move-only 的可调用对象——如果我们的回调捕获了 `std::unique_ptr`,`std::function` 会因为拷贝语义的要求直接编译失败,而 `std::move_only_function` 没有这个问题。而且它的 SBO 实现经过了标准库作者的精心调优,在绝大多数情况下不需要堆分配——对于捕获少量参数的 lambda 来说,性能完全够用。 +拿 `std::move_only_function` 做内部存储,几处好处凑一块儿。头一个,它把咱们手写类型擦除的活儿全包了,回想卷二那个 `LightCallback`,咱们花了整整一章手写函数指针表、SBO 缓冲区、移动和析构,`std::move_only_function` 把这些全封装好,直接拿来用。再者它天生支持 move-only 的可调用对象:回调要是捕获了 `std::unique_ptr`,`std::function` 因为拷贝语义的要求会直接编不过,`std::move_only_function` 没这个毛病。它的 SBO 实现也是标准库作者精心调过的,绝大多数情况下不需要堆分配,对捕获少量参数的 lambda 来说,性能完全够用。 ### 三态管理 -引入 `std::move_only_function` 之后,有一个设计问题需要解决:如何区分"空回调"和"已消费回调"? - -`std::move_only_function` 本身可以是空的(默认构造或从 `nullptr` 构造),但"空"和"已被 `run()` 消费过"是两个不同的状态。空回调意味着"从未被赋值过",调用它应该触发一个明确的错误("callback is null")。已消费回调意味着"曾经有值,但已经被调用过了",调用它也应该触发错误("callback already consumed"),但错误信息不同,这对调试很有帮助。 +把 `std::move_only_function` 引进来之后,有个设计问题得解决:怎么区分"空回调"和"已被消费的回调"? -所以我们的内部状态需要三态: +`std::move_only_function` 自己可以是空的(默认构造或从 `nullptr` 构造),但"空"和"被 `run()` 消费过"是两回事。空回调意思是"从没被赋过值",调它该报个明确的错("callback is null");已消费回调意思是"曾经有值,但已经被调过了",也该报错("callback already consumed"),但报错信息不一样。这点差别在调试时很有用,能帮您一眼看出回调到底卡在哪一步。所以咱们的内部状态得是三态: ```cpp enum class Status : uint8_t { @@ -261,7 +226,7 @@ enum class Status : uint8_t { }; ``` -结合 `std::move_only_function`,我们的内部存储结构大致如下: +配上 `std::move_only_function`,内部存储大概长这样: ```cpp template @@ -274,28 +239,17 @@ class OnceCallback { }; ``` -移动构造时,`func_` 和 `status_` 一起移动过去,源对象的状态设为 `kEmpty`。`run()` 执行时,先检查 `status_` 是否为 `kValid`,执行完后将 `func_` 置空、`status_` 设为 `kConsumed`。这样在调试时就能根据 `status_` 的值给出精确的错误信息。 +移动构造时,`func_` 和 `status_` 一起挪过去,源对象状态置成 `kEmpty`。`run()` 执行时先查 `status_` 是不是 `kValid`,执行完把 `func_` 置空、`status_` 设成 `kConsumed`。调试时按 `status_` 的值就能给出精确的报错信息。 ### 与 Chromium 原版的取舍 -用 `std::move_only_function` 做底层存储,我们获得了简洁的实现,但也牺牲了一些东西。Chromium 的 `OnceCallback` 大小只有一个指针(8 字节),这得益于 `TRIVIAL_ABI` 注解和引用计数的 `BindState`——回调对象本身只是一个指向堆上 `BindState` 的指针。我们的 `OnceCallback` 包装了 `std::move_only_function`(通常 32 字节)加上 `Status` 枚举和可选的 `CancelableToken` 指针(16 字节),总大小大约在 56-64 字节左右。 - -另一个差异是引用计数。Chromium 的 `BindState` 是引用计数的,允许多个回调共享同一份绑定状态(这对 `RepeatingCallback` 的拷贝语义是必需的)。我们的实现里,`std::move_only_function` 本身是独占所有权的,不支持共享。对于 `OnceCallback` 的 move-only 语义来说这不是问题,但后续实现 `RepeatingCallback` 时需要重新考虑这个设计。 - -这些取舍是合理的——我们用大小和引用计数的灵活性,换来了大幅降低的实现复杂度。在实际使用中,56-64 字节的回调对象在绝大多数场景下都不是瓶颈,而清晰的代码结构让维护和扩展的成本低得多。 - ---- - -## 小结 +拿 `std::move_only_function` 做底层存储,换来了简洁的实现,代价也不是没有。Chromium 的 `OnceCallback` 大小只有一个指针(8 字节),靠的是 `TRIVIAL_ABI` 注解加引用计数的 `BindState`,回调对象本身只是个指向堆上 `BindState` 的指针。咱们的 `OnceCallback` 包了 `std::move_only_function`(通常 32 字节),加上 `Status` 枚举和可选的 `CancelableToken` 指针(16 字节),总大小约在 56-64 字节。 -这一篇我们完成了 `once_callback` 的设计基础。核心要点: +另一处差别在引用计数。Chromium 的 `BindState` 是引用计数的,允许多个回调共享同一份绑定状态(这对 `RepeatingCallback` 的拷贝语义是必需的)。咱们的实现里,`std::move_only_function` 自己独占所有权,不支持共享。对 `OnceCallback` 的 move-only 语义来说这不碍事,但往后做 `RepeatingCallback` 时得重新琢磨这块。 -- `std::function` 在异步回调场景有三大缺陷:可复制、可重复调用、无法表达消费语义 -- Chromium 的 `OnceCallback` 通过 move-only + 右值限定 `Run()` + 单次消费来约束回调语义 -- 我们的 `OnceCallback` 用 `std::move_only_function` 做底层类型擦除,用 deducing this 实现右值限定的 `run()` -- 内部采用三态管理(`kEmpty` / `kValid` / `kConsumed`)区分空回调和已消费回调 +这些取舍笔者觉得是划得来的:用大小和引用计数的灵活性,换来了大幅降低的实现复杂度。实际用起来,56-64 字节的回调对象在绝大多数场景都不是瓶颈,代码结构清楚,维护和扩展的成本都低得多。 -下一篇我们会进入实现阶段:从核心骨架 `run()` 开始,逐步添加 `bind_once`、取消检查和 `then()` 链式组合。 +下一篇咱们就进实现阶段:从核心骨架 `run()` 开工,逐步把 `bind_once`、取消检查和 `then()` 链式组合加上去。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/02-once-callback-implementation.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/02-once-callback-implementation.md index cc7fcfa39..f56e3776f 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/02-once-callback-implementation.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/02-once-callback-implementation.md @@ -22,34 +22,26 @@ title: once_callback 设计指南(二):逐步实现 --- # once_callback 设计指南(二):逐步实现 -## 引言 +上一篇咱们把 `OnceCallback` 的目标 API 和内部架构定下来了。这一篇该撸代码了。不过笔者先把丑话说在前头:这篇不打算把完整头文件端上来——那玩意儿贴出来几百行,您盯着看容易走神。咱们只挑骨架和真正费脑子的模板技巧过一遍,把"为什么这么写"想透;完整的、能编译的代码留给课后练习和第三篇测试篇。 -上一篇我们完成了动机分析和接口设计,确定了 `OnceCallback` 的目标 API 和内部架构。这一篇我们正式上手写代码。不过先说好——这一篇的重点不是"把完整实现端上来",而是带你理解每一步的设计思路和关键技术选型。我们会看到代码的关键骨架,但不会贴出完整的、可直接编译的头文件——那些细节留给课后练习和第三篇的测试验证。 - -实现分四步,每一步都建立在前一步的基础上:先搞定核心的 `run()` 语义,再加参数绑定,然后是取消检查,最后是 `then()` 链式组合。每一步我们只关注"这个组件长什么样"和"关键的模板技巧是什么",不会逐行解读实现。 - -> **学习目标** -> -> - 理解 `OnceCallback` 的模板偏特化模式和内部存储设计 -> - 掌握 deducing this、requires 约束、lambda capture pack expansion 等高级模板技巧在实际组件中的应用 -> - 理解 `bind_once()` 的参数绑定机制和 `then()` 的所有权链设计 +实现拆四步,层层往上叠:先把 `run()` 语义这条命脉打通,再加 `bind_once()` 参数绑定,接着挂上取消检查,最后接 `then()` 链式组合。每一步只盯着两个问题——这玩意儿长什么样、关键的模板技巧在哪儿。 --- -## 第一步:核心骨架 — 从模板偏特化开始 +## 第一步:核心骨架 — 从模板偏特化起手 ### 为什么是 `OnceCallback` 这种写法 -你可能已经注意到了,我们声明 `OnceCallback` 的方式有点特殊——不是 `OnceCallback`,而是 `OnceCallback`。这种写法叫"签名式模板参数"(signature-style template parameter),`std::function` 和 `std::move_only_function` 也是这么做的。 +您要是扫过一眼标准库,会发现 `std::function`、`std::move_only_function` 都长一个样——模板参数不是把返回值和参数列表分开写,而是塞一整个函数签名进去。咱们 `OnceCallback` 也跟着这么写,图的就是这套"签名式模板参数"的清爽。 -背后的技巧是**模板偏特化**(template partial specialization)。我们先声明一个主模板,只有声明没有定义: +底下干活的招是模板偏特化。咱们先甩个光秃秃的主模板,光声明、不定义: ```cpp template class OnceCallback; // 主模板:不提供实现 ``` -然后为 `FuncSignature` 恰好是函数类型的情形提供一个偏特化版本: +然后单开一个偏特化版本,专门接住"签名恰好是函数类型"这种情形: ```cpp template @@ -58,13 +50,13 @@ class OnceCallback { }; ``` -当用户写 `OnceCallback` 时,编译器把 `int(int, int)` 当作一个整体类型匹配到主模板的 `FuncSignature`,然后发现偏特化版本能将这个整体拆解成返回类型 `ReturnType = int` 和参数包 `FuncArgs... = {int, int}`,于是选择偏特化版本。这个模式的好处是用户可以用一种非常自然的"函数签名"语法来指定回调的类型,而不需要分别传入返回值和参数列表。 +您写 `OnceCallback` 的时候,编译器先把 `int(int, int)` 当一整个类型塞进主模板的 `FuncSignature`,接着发现偏特化能把这个整体拆开——`ReturnType = int`、`FuncArgs... = {int, int}`,于是偏特化中选。这套写法的好处明摆着:用户拿自然的"函数签名"语法就能指定回调类型,不用把返回值和参数列表拆成两份模板参数传进来。 -这里有一个容易混淆的点:`R(Args...)` 看起来像函数声明,但在模板参数的上下文中,它是一个**函数类型**(function type)。`int(int, int)` 是一种合法的 C++ 类型——它描述的是"接受两个 int 参数、返回 int 的函数"。模板偏特化利用了这个类型,通过模式匹配把它拆开,提取出返回值类型和参数包。 +这里有个小坑容易绊人:`R(Args...)` 拼法看着像函数声明,但在模板参数位置上它其实是一个函数类型(function type)。`int(int, int)` 在 C++ 里就是合法的类型,描述"吃两个 int、吐一个 int 的函数"。偏特化就是搭了这个便车,靠模式匹配把它拆包。 -### 内部存储:类的骨架长什么样 +### 内部存储:类的骨架长什么样 -上一篇我们确定了三态架构。现在来看看类的骨架——先不管方法实现,只看数据成员和接口签名: +上一篇咱们定下了三态架构。现在把类的骨架立起来,先别管方法实现,光看数据成员和接口签名长什么样: ```cpp template @@ -112,24 +104,20 @@ private: }; ``` -骨架里的每一个成员都有明确的职责。`func_` 负责类型擦除——把各种不同形态的可调用对象统一成一个已知签名的调用接口。`status_` 是一个三态枚举,区分"从未赋值"(kEmpty)、"随时可调用"(kValid)和"已经调用过了"(kConsumed)。`token_` 是一个可选的取消令牌,用于在回调执行前检查是否应该取消执行。移动操作做指针级别的转移,源对象回到 kEmpty 状态。 +每个成员都有明确分工。`func_` 干的是类型擦除的脏活,不管您喂的是 lambda、函数指针还是仿函数,统统收编成一个已知签名的调用口子。`status_` 是个三态枚举,把"从未赋值"(kEmpty)、"随时能跑"(kValid)、"已经跑过了"(kConsumed)三个阶段分开。`token_` 是个可选的取消令牌,回调真跑之前替您把把门。移动操作走的是指针级转移,源对象挪完回到 kEmpty。 -接下来我们聚焦骨架里两个最精巧的部分:`run()` 的 deducing this 技巧和构造函数的 `requires` 约束。这两个是整个组件里模板技巧最密集的地方,值得单独拿出来讲透。 +骨架立起来后,有两个地方模板技巧密度最高——`run()` 的 deducing this 和构造函数的 `requires` 约束。这两块单独拎出来讲透,剩下的就好读了。 -### deducing this:让编译器帮我们拦截错误调用 +### deducing this:让编译器替咱们挡掉错误的调用 -`run()` 是整个组件的灵魂,也是 C++23 特性最密集的一个方法。先看它的声明: +`run()` 是整个组件的灵魂,也是 C++23 特性最密集的一个方法。先盯它的声明: ```cpp template auto run(this Self&& self, Args... args) -> R; ``` -如果你没见过 `this Self&& self` 这种写法,别慌,我们一步步来。 - -#### 什么是 deducing this - -deducing this 是 C++23 引入的特性,官方名称叫"显式对象参数"(explicit object parameter)。在传统的成员函数里,`this` 是隐式参数——编译器自动传入当前对象的地址,你看不见也摸不着。deducing this 让我们可以把 `this` 显式地写成函数的第一个参数,并且用模板参数来推导它的类型和值类别。 +那个 `this Self&& self` 笔者第一次见的时候愣了一下,后来才搞明白这是 C++23 的 deducing this,官方叫"显式对象参数"(explicit object parameter)。传统成员函数里 `this` 是隐式的——编译器悄悄把当前对象地址塞进来,您看不见摸不着。deducing this 干的事就是把 `this` 显式写成函数的第一个参数,再用模板参数推导它的类型和值类别。 ```cpp // 传统写法:this 是隐式的 @@ -140,15 +128,11 @@ template auto run(this Self&& self, FuncArgs&&... args) -> ReturnType; // self 就是 this ``` -关键在于 `Self&&`——它看起来像右值引用,但实际上是**转发引用**(forwarding reference),因为 `Self` 是模板参数。转发引用的特殊之处在于,它可以根据传入参数的值类别被推导为不同的类型: - -- `cb.run(args)` — `cb` 是左值,`Self` 推导为 `OnceCallback&`(左值引用) -- `std::move(cb).run(args)` — `std::move(cb)` 是右值,`Self` 推导为 `OnceCallback`(纯右值) -- `std::as_const(cb).run(args)` — const 左值,`Self` 推导为 `const OnceCallback&` +门道在 `Self&&` 上,看着是右值引用,其实因为 `Self` 是模板参数,它退化成转发引用(forwarding reference)。转发引用的妙处是会按实参的值类别变脸:`cb.run(args)` 这种左值调用,`Self` 被推导成 `OnceCallback&`;写成 `std::move(cb).run(args)`,`Self` 就成了纯右值 `OnceCallback`;const 左值 `std::as_const(cb).run(args)` 则是 `const OnceCallback&`。三种值类别,一个模板全接住。 -#### 我们怎么利用它 +#### 咱们怎么拿它干活 -知道了 `Self` 的推导规则,拦截左值调用就很简单了: +知道推导规则,拦截左值调用就一句话的事: ```cpp template @@ -160,19 +144,19 @@ auto run(this Self&& self, FuncArgs&&... args) -> ReturnType { } ``` -`std::is_lvalue_reference_v` 是一个编译期常量,检查 `Self` 是不是左值引用类型。当调用方写 `cb.run(args)` 时,`Self` 被推导为 `OnceCallback&`,这是一个左值引用,条件为 `true`,取反后 `static_assert` 失败,编译器直接报错——报错信息就是我们写的那句话。当调用方写 `std::move(cb).run(args)` 时,`Self` 被推导为 `OnceCallback`,不是引用,`static_assert` 通过,进入 `impl_run` 执行真正的逻辑。注意这里用的是 `std::forward(self)` 而不是 `self.run_impl()`,这确保了 `impl_run` 被正确地在右值上调用。 +`std::is_lvalue_reference_v` 是个编译期常量,专查 `Self` 是不是左值引用。调用方写 `cb.run(args)`,`Self` 推成 `OnceCallback&`,是左值引用,条件取反触发 `static_assert`,编译器当场报错,报的就是咱们写的那句"必须用 std::move"。写成 `std::move(cb).run(args)`,`Self` 是纯右值类型,`static_assert` 放行,接着 `std::forward(self).impl_run(...)` 把活儿派给真正的实现。这里特意用 `std::forward(self)` 而不是直接 `self.impl_run(...)`,是为了保证 `impl_run` 是在右值上调用的,值类别不能在这一步丢掉。 -这里有一个值得玩味的细节:`static_assert` 的条件依赖模板参数 `Self`,所以它只有在模板实例化时才求值。这意味着如果 `run()` 从未被调用,`static_assert` 不会触发——不管传的是左值还是右值。只有在某个调用点上编译器需要实例化这个模板时,`Self` 的具体类型才会被确定,`static_assert` 才会求值。这叫"惰性实例化"(lazy instantiation),是模板元编程里非常常见的模式。 +有个细节笔者觉得值得玩味:`static_assert` 的条件挂在模板参数 `Self` 上,所以它只在模板实例化那一刻才求值。换句话说,只要没人调用 `run()`,这条 assert 就不触发,管您传的是左值还是右值。只有真有人在某处写了个调用点,编译器要实例化这个模板了,`Self` 的具体类型才落定,assert 才求值。这套机制叫惰性实例化(lazy instantiation),是模板元编程里的家常便饭。 #### 跟 Chromium 的做法对比 -Chromium 没有享受 C++23 的福利,它用的是两个重载:`Run() &&` 是真正的执行版本,`Run() const&` 里面放了一个 `static_assert(!sizeof(*this), "...")` 来产生编译错误。`!sizeof` 那个 hack 利用了 C++ 的一个性质:`sizeof` 必须在完整类型上才能求值,所以 `!sizeof(*this)` 求值时一定在类的定义内部(`*this` 的类型是完整的),表达式的值一定是 `false`。在 C++23 之前,直接写 `static_assert(false, "...")` 会在所有代码路径上触发(即使这个重载从未被调用),所以 Chromium 不得不用 `!sizeof` 的技巧。C++23 放宽了这个限制,但 Chromium 的代码库还没有全面迁移到 C++23,所以仍然保留着旧写法。 +Chromium 享受不到 C++23 的福利,它走的是两个重载的老路:`Run() &&` 是真正执行的版本,`Run() const&` 里头塞个 `static_assert(!sizeof(*this), "...")` 故意制造编译错误。那个 `!sizeof` hack 利用了 C++ 的一条性质——`sizeof` 只能在完整类型上求值,所以 `!sizeof(*this)` 一旦求值,就说明此刻在类定义内部(`*this` 是完整类型),值必然是 `false`。C++23 之前直接写 `static_assert(false, "...")` 会在所有代码路径上触发,哪怕这个重载从没被调用过,所以 Chromium 只能拿 `!sizeof` 这种绕弯子的写法。C++23 把这条限制松开了,但 Chromium 的代码库还没全量迁 C++23,旧写法就这么留着了。 -我们的 deducing this 方案只需要一个函数模板,通过 `Self` 的推导自然地区分左值和右值,比 Chromium 的两个重载 + `!sizeof` hack 干净得多。 +咱们这套 deducing this 方案,一个函数模板就靠 `Self` 的推导把左值右值分得清清楚楚,比 Chromium 那两个重载加 `!sizeof` hack 干净一大截。这是踩在新标准肩膀上得来的便宜,笔者得说一句公道话。 ### 构造函数的 requires 约束 -构造函数模板上有一行看起来多余的约束: +构造函数模板上有行约束,乍一看像多余的: ```cpp template @@ -180,37 +164,37 @@ template explicit OnceCallback(Functor&& f); ``` -为什么不直接 `template` 就完事了?问题出在模板构造函数和移动构造函数之间的竞争。 +直接 `template` 不就完了?不行,问题出在模板构造函数跟移动构造函数抢活儿上。 -当我们写 `OnceCallback cb2 = std::move(cb1)` 时,编译器面前有两条路:调用隐式声明的移动构造函数 `OnceCallback(OnceCallback&&)`,或者把模板构造函数实例化为 `OnceCallback(OnceCallback&&)`(令 `Functor = OnceCallback`)。直觉上我们会觉得移动构造函数是"更特殊"的匹配,应该优先选择。但 C++ 的重载决议规则不是这么运作的——在某些情况下,模板实例化出来的函数签名比隐式声明的特殊成员函数是"更精确"的匹配,编译器会毫不犹豫地选择模板版本。这可能导致意想不到的行为,比如模板构造函数可能不会正确地将源对象的状态设为 kEmpty。 +咱们写 `OnceCallback cb2 = std::move(cb1)` 的时候,编译器面前摆着两条路:一条是走隐式声明的移动构造 `OnceCallback(OnceCallback&&)`,另一条是把模板构造实例化成 `OnceCallback(OnceCallback&&)`(令 `Functor = OnceCallback`)。直觉上咱们会觉得移动构造"更特殊",理应优先。但 C++ 的重载决议不按直觉来——有些情况下,模板实例化出来的函数签名比隐式声明的特殊成员函数匹配得还更"精确",编译器二话不说就选了模板版本。这一选就出事:模板构造大概率不会老老实实把源对象状态置回 kEmpty。 -我们的实现用了一个自定义 concept `not_the_same_t` 来解决这个问题:`!std::is_same_v, T>` 意味着"当 `F` 的退化类型恰好是 `T` 本身时,排除这个模板"。退化(decay)在这里的作用是去掉 `F` 上的引用和 cv 限定符——因为 `F` 可能是 `OnceCallback&&` 或 `const OnceCallback&`,退化后都变成 `OnceCallback`。加上约束后,当传入的是 `OnceCallback` 本身时模板被排除,编译器才会正确地匹配移动构造函数。 +咱们的实现用一个自定义 concept `not_the_same_t` 把这事儿摁住了:它本质上是 `!std::is_same_v, T>`,意思是"`F` 退化之后恰好等于 `T` 本身时,把这个模板排除掉"。退化(decay)在这儿的作用是剥掉 `F` 身上的引用和 cv 限定——`F` 可能是 `OnceCallback&&`、也可能是 `const OnceCallback&`,退化后统统变回 `OnceCallback`。挂上约束之后,只要传进来的是 `OnceCallback` 自己,模板直接出局,编译器才会乖乖去匹配移动构造。 -这个技巧在实现 move-only 的类型擦除包装器时非常常见——`std::move_only_function` 自己的实现里也有类似的约束。如果你以后写类似的组件,记住这个模式:**模板构造函数 + requires 排除自身类型 = 保护移动语义的正确匹配**。 +这个套路在写 move-only 的类型擦除包装器时太常见了,`std::move_only_function` 自己的实现里就挂着类似的约束。您以后要是也撸这种组件,把这个模式记牢:模板构造加 requires 排除自身类型,就是给移动语义的正确匹配兜底。 ### 消费语义的内部实现思路 -`impl_run` 的实现逻辑很直观——检查状态、处理取消、调用可调用对象、更新状态。有几个细节值得提一下。 +`impl_run` 的主干逻辑一眼就懂:查状态、查取消、跑可调用对象、改状态。但有几个细节,笔者踩过之后才意识到里头的讲究。 -第一个是取消检查在执行前发生。`impl_run` 先检查令牌是否有效——如果已取消,直接消费回调但不执行,void 返回的情况直接 return,非 void 的情况抛出 `std::bad_function_call`。这个抛出异常的行为可能看起来有些激进,但它的理由很充分:调用方期望得到一个返回值,但我们无法提供一个有意义的值,所以抛异常是比返回未定义值更安全的做法。 +头一个,取消检查得赶在执行之前。`impl_run` 先看令牌还有没有效——要是已经取消,直接消费回调但不执行,返回类型是 void 就 return,非 void 就抛 `std::bad_function_call`。这里抛异常乍看有点猛,但理由其实很硬:调用方眼巴巴等一个返回值,咱们没法凭空变出一个有意义的值给他,抛异常比返回一个未定义值体面得多。 -第二个是 `if constexpr (std::is_void_v)` 的分支。当返回类型是 `void` 时,我们不能写 `ReturnType result = func_(args...)`——void 不是一种可以赋值的类型。`if constexpr` 在编译期选择分支,void 的情况走"调用但不赋值"的路径,非 void 的情况走"调用并赋值给 result"的路径。这是 `if constexpr` 处理 void 返回类型的标准模式。 +第二个细节是 `if constexpr (std::is_void_v)` 这条分支。返回类型是 void 的时候,`ReturnType result = func_(args...)` 这种写法编译都过不去——void 压根不是能赋值的类型。`if constexpr` 在编译期挑分支,void 的情况走"调用但不赋值",非 void 走"调用并赋给 result"。这是 `if constexpr` 对付 void 返回的标准套路。 -第三个是消费后置空。`impl_run` 先把 `func_` move 出来作为局部变量,然后将 `func_` 置为 `nullptr`、`status_` 设为 kConsumed,最后执行局部变量里的可调用对象。这个顺序很重要——先把可调用对象拿出去、状态标记好,再执行。这样即使可调用对象内部抛出异常,`status_` 也已经是 kConsumed 了,回调不会处于一个不一致的状态。置空这一步不仅仅是标记状态——它触发了 `std::move_only_function` 析构其内部持有的可调用对象,释放 lambda 捕获的资源(比如 `unique_ptr`)。 +第三个是消费后置空这个动作的顺序,笔者一开始没在意,后来差点被坑。`impl_run` 得先把 `func_` move 到一个局部变量里,再把 `func_` 置 `nullptr`、`status_` 设成 kConsumed,最后才执行局部变量里那个可调用对象。这个顺序断不能颠倒——先把对象挪出来、状态标好,再开跑。这么一来,就算可调用对象内部抛了异常,`status_` 也已经稳稳是 kConsumed,回调不至于卡在一个不上不下的脏状态。置空这一步还不光是改状态——它会触发 `std::move_only_function` 析构它内部持有的可调用对象,顺手把 lambda 捕获的资源(比如 `unique_ptr`)释放掉。 ### 验证核心骨架 -骨架写完之后,快速验证几个场景就够了:基本类型返回、void 返回、move-only 捕获、移动语义。如果这四个场景都通过——构造回调能拿到正确的返回值、void 回调能正常执行、捕获 `unique_ptr` 的回调用完之后资源被释放、移动后源对象变空、目标对象有效——骨架就没有问题。完整的测试用例我们在第三篇统一整理。 +骨架写完,挑四个场景跑一遍就够:基本类型返回、void 返回、move-only 捕获、移动语义。这四样都过——构造回调拿到正确的返回值、void 回调正常执行、捕获 `unique_ptr` 的回调跑完资源被释放、移动后源对象变空目标对象有效——骨架就算立住了。完整的测试用例留到第三篇统一收拾。 --- -## 第二步:参数绑定 — `bind_once()` +## 第二步:参数绑定 — `bind_once()` -### 我们要解决什么问题 +### 咱们要解决什么问题 -`bind_once` 的场景很直观:你有一个三参数的函数 `f(int, int, int)`,但前两个参数在绑定时就能确定(比如 10 和 20),只有第三个参数要等到调用时才传入。你希望拿到一个只需传一个参数的 `OnceCallback`,调用时它自动把 10、20 和你传入的参数拼在一起喂给原函数。 +`bind_once` 的场景一句话能说清:手上有个三参数函数 `f(int, int, int)`,前两个参数绑定时就知道(比如 10 和 20),只有第三个得等到调用那一刻才传。咱们想要的就是一个只吃一个参数的 `OnceCallback`,跑的时候它自动把 10、20 和您传进来的参数凑齐了喂给原函数。 -这就是参数绑定——把"已知参数"提前塞进回调里,让调用方只需关心"未知参数"。Chromium 的 `BindOnce` 在这方面做了大量工作来处理参数的生命周期(`Unretained`、`Owned`、`Passed`、`WeakPtr` 等),我们的简化版只关注核心的参数绑定逻辑。 +这就是参数绑定——把"已知参数"提前塞进回调,让调用方只操心"未知参数"。Chromium 的 `BindOnce` 在这块下了大功夫处理参数生命周期(`Unretained`、`Owned`、`Passed`、`WeakPtr` 一堆帮手),咱们的简化版只管核心的绑定逻辑。 ### `bind_once` 的实现骨架 @@ -231,27 +215,27 @@ auto bind_once(F&& funtor, BoundArgs&&... args) { } ``` -这段代码不长,但里面有好几个值得展开讲的模板技巧。我们逐个拆。 +这段代码不长,里头却藏着好几个能单独拎出来讲的模板技巧。咱们一个一个拆。 ### Lambda Capture Pack Expansion -`...bound = std::forward(args)` 这一行是 C++20 引入的 **lambda 初始化捕获包展开**语法。它是整个 `bind_once` 能够简洁实现的关键。 +`...bound = std::forward(args)` 这一行是 C++20 才放出来的 lambda 初始化捕获包展开语法。整个 `bind_once` 能写得这么利索,全靠它。 -在 C++20 之前,可变参数模板的参数包(parameter pack)不能直接展开到 lambda 的捕获列表里——你没法写 "把 `args...` 的每一个元素分别捕获到 lambda 里" 这样的代码。变通方案是用一个 `std::tuple` 把所有绑定参数打包存起来,然后在 lambda 内部用 `std::apply` 展开成单独的参数再调用。这个方案能用,但代码会膨胀很多——你需要一个额外的 tuple、一个 `std::apply` 调用、以及处理 tuple 元素移动语义的模板辅助代码。 +C++20 之前,可变参数模板的参数包(parameter pack)没法直接展开进 lambda 的捕获列表——您写不出"把 `args...` 每个元素各自捕获进 lambda"这种代码。变通的土办法是拿 `std::tuple` 把绑定参数全打包,lambda 内部再 `std::apply` 拆开调用。能用是能用,但代码会膨胀一大截——额外一个 tuple、一次 `std::apply`、再加上处理 tuple 元素移动语义的模板帮手代码。 -C++20 终于允许了包展开进 lambda 捕获。具体来说,`...bound = std::forward(args)` 的效果是为 `BoundArgs...` 中的每一个类型生成一个对应的捕获变量,每个变量用 `std::forward` 完美转发初始化。举个具体例子,假设 `BoundArgs...` 是 `int, std::string`,那么展开后等价于: +C++20 终于松口了。`...bound = std::forward(args)` 的效果是,为 `BoundArgs...` 里每个类型生成一个对应的捕获变量,各自用 `std::forward` 完美转发初始化。举个具体的例子,假设 `BoundArgs...` 是 `int, std::string`,展开后等价于: ```cpp [b1 = std::forward(arg1), b2 = std::forward(arg2)] ``` -每个捕获变量在 lambda 内部都可以独立使用,而在我们的 `bind_once` 里,它们在 lambda 被调用时通过 `std::move(bound)...` 一起展开传给 `std::invoke`。注意这里用的是 `std::move` 而不是 `std::forward`——因为 lambda 是 `mutable` 的,捕获变量在 lambda 内部是左值,我们想把它们当作右值传出去以触发移动语义。 +每个捕获变量在 lambda 内部都能独立用,在咱们的 `bind_once` 里,它们在 lambda 被调用的那一刻通过 `std::move(bound)...` 一块儿展开喂给 `std::invoke`。这里头有个坑笔者得提醒一句:用的是 `std::move` 不是 `std::forward`——因为 lambda 标了 `mutable`,捕获变量在 lambda 内部是左值,咱们要把它们当右值送出去,才能触发移动语义。 ### `std::invoke` 的统一调用能力 -lambda 内部用 `std::invoke` 而不是直接调用 `f(...)`,原因是 `std::invoke` 能统一处理各种可调用对象。普通函数指针直接调用没问题,但成员函数指针就不一样了——你没法写 `(&Class::method)(obj, args...)`,必须用 `(obj.*method)(args...)` 这种特殊语法。`std::invoke` 把这些差异全部封装了:`std::invoke(&Class::method, &obj, args...)` 等价于 `(obj.*method)(args...)`。 +lambda 内部用的是 `std::invoke`,不是直接 `f(...)`。原因是 `std::invoke` 能把各种可调用对象的差异抹平。普通函数指针直接调没问题,成员函数指针就两码事了——您写不出 `(&Class::method)(obj, args...)`,必须改用 `(obj.*method)(args...)` 这种专门语法。`std::invoke` 把这些花样全收编了:`std::invoke(&Class::method, &obj, args...)` 就等价于 `(obj.*method)(args...)`。 -这意味着 `bind_once` 天然支持成员函数绑定,不需要额外的代码: +这么一来,`bind_once` 天然就支持成员函数绑定,一行额外代码都不用写: ```cpp struct Calculator { @@ -263,29 +247,29 @@ auto bound = bind_once(&Calculator::multiply, &calc, 5); int r = std::move(bound).run(8); // r == 40 ``` -不过这里有一个**生命周期陷阱**需要注意:`&calc` 是裸指针,`bind_once` 不会管理它的生命周期。如果 `calc` 在回调被调用之前就被销毁了,`std::invoke` 会通过悬空指针访问已释放的内存。Chromium 用 `base::Unretained` 显式标记"我知道这个裸指针的生命周期是安全的",用 `base::Owned` 接管所有权,用 `base::WeakPtr` 在对象析构时自动取消回调。我们的简化版里,这个安全责任暂时交给调用方。 +不过这儿埋着个生命周期陷阱,笔者非得提醒您一句不可:`&calc` 是裸指针,`bind_once` 压根不管它的死活。要是 `calc` 在回调真跑之前就先一步析构了,`std::invoke` 就会顺着悬空指针摸到已经释放的内存上,典型的 use-after-free。Chromium 这块配套了一整套帮手——`base::Unretained` 显式声明"这个裸指针的生命周期我心里有数",`base::Owned` 把所有权接管过来,`base::WeakPtr` 在对象析构时顺手把回调作废。咱们的简化版里,这份安全责任暂时压在调用方肩上。 -### 签名推导:为什么需要显式指定 `Signature` +### 签名推导:为什么非得显式指定 `Signature` -你可能注意到了 `bind_once` 的第一个模板参数 `Signature`(比如 `int(int)`)需要调用方显式指定。理想情况下,编译器应该能从 `F` 的可调用签名中自动推导出"去掉已绑定参数后的剩余签名"。但这件事在 C++ 里比想象中复杂得多。 +您大概注意到了,`bind_once` 头一个模板参数 `Signature`(比如 `int(int)`),得调用方自己写明。理想情况下,编译器该能从 `F` 的可调用签名里自动推出"去掉已绑定参数之后的剩余签名"才对。但这事在 C++ 里比想象中难搞得多。 -对于函数指针 `R(*)(Args...)`,可以通过模板偏特化提取参数列表,然后用一种编译期的"类型列表切片"操作去掉前 N 个类型。对于有确定签名的仿函数(functor),也可以通过 `decltype(&T::operator())` 提取签名。但对于**泛型 lambda**(`[](auto x) { ... }`),它的 `operator()` 本身是模板,不存在唯一确定的签名——编译器根本无法在类型层面获取"这个 lambda 接受什么参数"的信息。 +函数指针 `R(*)(Args...)` 这种好办,模板偏特化把参数列表提出来,再做一次编译期的"类型列表切片"砍掉前 N 个类型就行。有确定签名的仿函数(functor)也凑合,`decltype(&T::operator())` 能把签名挖出来。但碰到泛型 lambda(`[](auto x) { ... }`)就歇菜了——它的 `operator()` 本身就是模板,压根不存在唯一确定的签名,编译器没法在类型层面问出"这个 lambda 吃什么参数"这种问题。 -Chromium 为此写了一整套类型操作工具(`MakeUnboundRunType`、`DropTypeListItem` 等),大概有几百行模板元编程代码来处理各种边界情况。对于我们的教学目的,让调用方多写一个模板参数 `int(int)` 是更务实的选择——省去了大量复杂的模板元编程,代码清晰度也更好。 +Chromium 为了这事儿写了一整套类型操作工具(`MakeUnboundRunType`、`DropTypeListItem` 之类的),前前后后几百行模板元编程来应付各种边界情况。咱们教学目的嘛,让调用方多写一个模板参数 `int(int)` 反而更务实——大段复杂的元编程全省了,代码清清爽爽。 --- -## 第三步:取消检查 — `is_cancelled()` 与 `maybe_valid()` +## 第三步:取消检查 — `is_cancelled()` 与 `maybe_valid()` -### 取消令牌的概念 +### 取消令牌是干嘛的 -回调在创建时可以关联一个"取消令牌"(cancellation token)。令牌代表某个外部对象的生命周期——当那个对象被销毁后,令牌失效,通过令牌关联的所有回调都变为"已取消"状态。 +回调创建的时候可以挂上一个"取消令牌"(cancellation token)。令牌背后代表的是某个外部对象的生死——那个对象一旦没了,令牌就跟着失效,所有靠这张令牌关联起来的回调统统进入"已取消"状态。 -你可以把它想象成一张"通行证":创建回调时发一张通行证给它,通行证上写着"有效"。某个时刻外部对象说"通行证作废了"(调用 `invalidate()`),之后所有持有这张通行证的回调在执行前检查时都会发现"通行证已经无效",跳过执行。在 Chromium 里,这个通行证就是 `WeakPtr` 内部的控制块——`WeakPtr` 指向的对象被销毁后,控制块中的标志位被清除,所有绑定到这个 `WeakPtr` 的回调自动取消。 +您就把它当成一张通行证:回调出生的时候发一张,上面盖着"有效"。哪天外部对象说"通行证作废"(调一下 `invalidate()`),之后所有捏着这张通行证的回调,执行前查一眼都会发现"通行证已经盖戳了",自个儿跳过去不跑了。在 Chromium 里,这张通行证就是 `WeakPtr` 内部的控制块——`WeakPtr` 指着的对象一析构,控制块里那个标志位就被翻掉,绑在这枚 `WeakPtr` 上的回调自动作废。 ### `CancelableToken` 的设计思路 -我们的简化版取消令牌只需要三个核心操作:创建(生成有效令牌)、失效(标记为作废)、检查(查询是否还有效)。内部用 `shared_ptr` 管理一个包含 `atomic` 的 `Flag` 结构体: +咱们这版简化令牌,核心就三个动作:创建(发一张有效的)、失效(盖戳作废)、检查(查还有没有效)。内部拿 `shared_ptr` 管着一个装着 `atomic` 的 `Flag` 结构体: ```cpp class CancelableToken { @@ -304,36 +288,36 @@ public: }; ``` -用 `shared_ptr` 而不是裸指针的原因是让令牌可以被拷贝和移动,同时所有副本共享同一个 `Flag`。`atomic` 保证多线程访问的安全性——一个线程可能在执行 `is_valid()` 的同时另一个线程在调 `invalidate()`,`memory_order_acquire/release` 语义保证前者的读一定能看到后者的写。 +为啥用 `shared_ptr` 不用裸指针?图的是令牌能被拷来移去,而所有副本始终共享同一枚 `Flag`。`atomic` 保的是多线程下的安全——一个线程这边正查着 `is_valid()`,另一个线程那边已经把 `invalidate()` 调下去了,`memory_order_acquire/release` 这一对语义恰好把两边对齐:前者的读一定能看到后者的写。 -### 集成到 `OnceCallback` +### 塞进 `OnceCallback` -取消令牌集成到 `OnceCallback` 的方式很直接:在数据成员里加一个可选的 `shared_ptr`,通过 `set_token()` 方法设置,然后在两个地方检查它——`is_cancelled()` 查询时和 `impl_run()` 执行前。 +令牌塞进 `OnceCallback` 的方式很直白:数据成员里挂一个可选的 `shared_ptr`,通过 `set_token()` 设进来,然后两处查它,一处是 `is_cancelled()` 查询时,一处是 `impl_run()` 真跑之前。 -`is_cancelled()` 的逻辑是:状态不是 kValid 就返回 true(空回调和已消费回调都算"已取消"),如果有令牌且令牌失效也返回 true。`impl_run` 里在真正执行可调用对象之前先检查令牌状态——如果已取消,消费回调但不执行,直接返回(void 情况)或者抛出 `std::bad_function_call`(需要返回值的情况)。 +`is_cancelled()` 的逻辑一句话:状态只要不是 kValid 就返回 true(空回调和已消费回调都算"已取消"),另外要是有令牌且令牌已失效,也返回 true。`impl_run` 这边呢,真要动手执行可调用对象之前先扫一眼令牌,要是已取消,消费回调但不执行,void 情况直接返回,需要返回值的情况抛 `std::bad_function_call`。 -`maybe_valid()` 暂时就是 `!is_cancelled()` 的简单包装。在 Chromium 的完整实现中,两者的区别在于线程安全保证的强弱——`is_cancelled()` 只能在回调绑定的序列(即创建回调的线程)上调用,保证返回确定性结果;`maybe_valid()` 可以从任何线程调用,但结果可能过时。我们的简化版暂时不区分这个语义,但保留了两个方法名以备后续在 `RepeatingCallback` 或跨线程场景中扩展。 +`maybe_valid()` 眼下就是 `!is_cancelled()` 套了个壳。Chromium 的完整实现里,这俩的差别在线程安全保证的强弱上——`is_cancelled()` 只能在回调绑定的序列(也就是创建回调那条线)上调用,返回的是确定性结果;`maybe_valid()` 哪条线都能调,但结果可能已经过时。咱们简化版暂时不抠这层语义,但两个方法名都留着,以后在 `RepeatingCallback` 或者跨线程场景里要用。 --- -## 第四步:链式组合 — `then()` +## 第四步:链式组合 — `then()` -### `then()` 的语义 +### `then()` 到底干什么 -`then()` 允许我们把两个回调串联成一个管道。语义很直观:当管道被调用时,先用原始参数执行第一个回调,然后把返回值传给第二个回调。举个例子,回调 A 计算 `3 + 4 = 7`,回调 B 计算 `7 * 2 = 14`,用 `then()` 串联后,你得到一个新回调,调用它时自动走完 A → B 的整个流程。 +`then()` 把两个回调串成一根管子。语义一句话:管子被调用时,先用原始参数跑头一个回调,把它吐的返回值递给第二个回调接着跑。举个例子,回调 A 算 `3 + 4 = 7`,回调 B 算 `7 * 2 = 14`,拿 `then()` 一串,您得到的就是一个新回调,跑它的时候自动把 A → B 整条流程走完。 -听起来简单,但 `then()` 是四个功能里所有权设计最精巧的一个。 +听着简单,但 `then()` 是四个功能里所有权设计最费心思的一个。 -### 所有权是关键 +### 所有权是命门 -串联后的新回调需要持有原回调和后续回调的**所有权**——否则原回调可能在外部被提前消费掉,管道就断了。而 `OnceCallback` 是 move-only 的,这意味着 `then()` 必须消费 `*this`(原回调)和 `next`(后续回调),把两者的所有权转移到一个新的 lambda 闭包里。整个所有权链条是这样的: +串起来的新回调,得把原回调和后续回调的所有权都攥在手里——不然原回调哪天在外头被人提前消费掉,管子当场就断了。偏生 `OnceCallback` 又是 move-only 的,这意味着 `then()` 非得消费 `*this`(原回调)和 `next`(后续回调)不可,把两者的所有权一并挪进一个新的 lambda 闭包里。整条所有权链长这样: ```mermaid graph LR A["新回调"] --> B["move_only_function"] --> C["lambda 闭包"] --> D["原回调 + 后续回调"] ``` -实现思路的骨架大概是这样: +实现骨架大概长这样: ```cpp template @@ -356,21 +340,23 @@ auto then(Next&& next) && // 末尾的 && 使其成为右值限定成员 } ``` -注意这里和 Chromium 原版的一个重要区别:我们对后续回调使用 `std::invoke` 而不是 `.run()`。这是因为 `then()` 接受的 `next` 参数是一个普通可调用对象(比如 lambda),不是 `OnceCallback`——调用方不需要显式地写 `std::move(cont).run()`,`std::invoke` 直接调用就好。只有 `self`(原回调)才需要 `std::move(...).run()` 来表达消费语义。 +这里头有个跟 Chromium 原版的差别笔者得点一句:咱们对后续回调用的是 `std::invoke`,不是 `.run()`。原因是 `then()` 收的 `next` 参数是个普通可调用对象(比如 lambda),不是 `OnceCallback`——调用方没必要费劲写 `std::move(cont).run()`,`std::invoke` 一把梭就行。只有 `self`(原回调)那一步,才需要 `std::move(...).run()` 来表达消费语义。 + +### 几个笔者替您踩过的坑 -### 几个容易踩坑的地方 +`then()` 这块有几个地方笔者栽过跟头,挨个讲。 -**第一,`&&` 限定。** 函数声明末尾的 `&&` 使其成为右值限定的成员函数,只能通过 `std::move(cb).then(next)` 或者临时对象 `.then(next)` 调用。这是另一种表达"消费语义"的方式——和 `run()` 用 deducing this 不同,`then()` 直接用传统的 ref-qualifier。为什么不用 deducing this?因为 `then()` 不需要区分左值和右值给出不同的错误信息——它就是只接受右值,没有中间地带。 +先说末尾那个 `&&`。它把成员函数限定成右值版本,只能通过 `std::move(cb).then(next)` 或者临时对象 `.then(next)` 来调。这是另一种表达"消费语义"的路子,跟 `run()` 用 deducing this 不一样,`then()` 直接走传统的 ref-qualifier。为啥不也用 deducing this?因为 `then()` 用不着在左值右值之间给出不同的报错信息——它就只吃右值,没中间地带,ref-qualifier 已经够干净了。 -**第二,`self = std::move(*this)`。** 这一行把当前 `OnceCallback` 对象的**所有内容**移动到 lambda 的闭包对象里。移动之后,当前对象进入已消费状态(因为我们没有把它设为 kEmpty,而是让它自然地保持一个"被移走"的状态)。闭包对象又被存入返回的新 `OnceCallback` 的 `move_only_function` 里——`move_only_function` 的类型擦除能力保证了不管 lambda 的实际类型是什么,都能被统一存储。 +接着是 `self = std::move(*this)` 这一行。它把当前 `OnceCallback` 对象的所有家当,一股脑儿挪进 lambda 的闭包对象里。挪完之后,当前对象就进了已消费态(咱们没把它设回 kEmpty,让它自然地维持"被搬空"的样子)。这个闭包对象接着又被塞进返回的新 `OnceCallback` 的 `move_only_function` 里——类型擦除的本事保证了不管 lambda 实际是什么类型,都能被统一存进去。 -**第三,`mutable` 关键字不可省略。** Lambda 默认生成的 `operator()` 是 `const` 的——这意味着 lambda 内部不能修改捕获的变量。但我们需要在 lambda 内部对 `self` 调用 `std::move(self).run()`,这个操作会修改对象状态(把 status 从 kValid 改为 kConsumed)。所以 lambda 必须声明为 `mutable`,让 `operator()` 变成非 const 的。 +然后是 `mutable`,这个关键字一个字都不能省。Lambda 默认生成的 `operator()` 是 `const` 的,意味着 lambda 内部不许动捕获的变量。可咱们偏偏要在 lambda 内部对 `self` 调 `std::move(self).run()`,这一步要改对象状态(把 status 从 kValid 拨到 kConsumed)。所以 lambda 必须标 `mutable`,让 `operator()` 变成非 const 的。 -**第四,`if constexpr (std::is_void_v)`。** 和 `impl_run` 里的情况一样——当原回调返回 `void` 时,`then()` 的语义是"先执行原回调,再执行后续回调(无参数传递)"。`if constexpr` 在编译期选择分支,两种情况生成完全不同的代码路径。 +最后是老朋友 `if constexpr (std::is_void_v)`。跟 `impl_run` 里那处是同一回事,原回调返回 `void` 的时候,`then()` 的语义是"先跑原回调,再跑后续回调,中间不传东西"。`if constexpr` 编译期挑分支,两种情形生成两条完全不同的代码路径。 ### 多级管道 -`then()` 可以链式调用,形成多级管道: +`then()` 能一路链下去,拼成多级管子: ```cpp using namespace tamcpp::chrome; @@ -386,21 +372,9 @@ std::string result = std::move(pipeline).run(5); // 5 * 2 = 10, 10 + 10 = 20, "20" ``` -每次 `then()` 调用都会创建一个新的 `once_callback`,内部嵌套捕获了前一步的回调。从外到内的调用顺序是递归展开的:最外层回调被 `run()` → 执行其 lambda → lambda 内部对上一层调用 `std::move(self).run()` → 再对更上一层调用 → 直到底层。性能上,每一层 `then()` 增加一次 `std::move_only_function` 的间接调用,对于 2-3 级管道来说完全可接受。如果管道层级很深(超过 10 级),可以考虑用 `std::variant` 做一个扁平化的管道结构来避免嵌套闭包的开销——但这已经超出我们当前的讨论范围了。 - ---- - -## 小结 - -这一篇我们完成了 `OnceCallback` 四个核心功能的设计走读。和第一篇的接口设计不同,这里的重点是理解"为什么这样写"和"关键的模板技巧是什么"。几个核心知识点回顾一下: - -- **模板偏特化** `OnceCallback` 让用户可以用自然的函数签名语法来指定回调类型,编译器通过模式匹配把函数类型拆解成返回值和参数包 -- **Deducing this** 让 `run()` 通过一个函数模板实现编译期的左值/右值拦截,比 Chromium 的双重重载 + `!sizeof` hack 更干净 -- **`requires` 约束**(通过 `not_the_same_t` concept)解决了模板构造函数与移动构造函数的匹配冲突,是 move-only 类型擦除包装器的标准防御手段 -- **Lambda capture pack expansion** 是 `bind_once` 得以简洁实现的关键,C++20 之前需要用 tuple + apply 的变通方案 -- **`then()` 的核心挑战**是所有权管理——它通过右值限定 + lambda 捕获 move 来保证管道中每个回调的所有权链完整,对后续回调使用 `std::invoke` 统一调用 +每调一次 `then()` 都会生出一只新的 `OnceCallback`,里头嵌套着捕获前一步的回调。从外往里的调用顺序是递归铺开的:最外层被 `run()` → 跑它的 lambda → lambda 内部对上一层调 `std::move(self).run()` → 再对更上一层调 → 一路到底。性能这边,每多一层 `then()` 就多一次 `std::move_only_function` 的间接调用,对 2-3 级的管子来说完全扛得住。真要是管子层数深到 10 级以上,可以考虑拿 `std::variant` 做个扁平化的管道结构,躲开嵌套闭包那点开销——不过这就超出咱们当下的范围了。 -下一篇我们会用系统化的测试用例来验证这些设计,并对比我们与 Chromium 原版在性能上的取舍。 +下一篇咱们上系统化的测试用例,逐条验这些设计,顺便对比一下咱们跟 Chromium 原版在性能上的取舍。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/03-once-callback-testing.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/03-once-callback-testing.md index 2580184d2..9d120c6c8 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/03-once-callback-testing.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/03-once-callback-testing.md @@ -22,27 +22,17 @@ title: once_callback 设计指南(三):测试策略与性能对比 --- # once_callback 设计指南(三):测试策略与性能对比 -## 引言 +代码写到这儿,`OnceCallback` 接口和实现都齐了。但笔者写完没急着收工——这种东西不拿测试压一遍,自己心里都没底。这一篇咱们就把测试策略和性能账一次性算清:它到底对不对、跟 Chromium 原版差多少、差的那些咱们认不认。 -前两篇我们完成了 `OnceCallback` 的设计和实现。这一篇做两件事:第一,系统化地梳理测试策略,给出一套完整的测试用例清单,确保我们的实现在各种边界条件下都是正确的;第二,从性能角度分析我们的实现与 Chromium 原版、标准库方案之间的差异,弄清楚我们牺牲了什么、换来了什么。 +## 按"不变量"切测试 -> **学习目标** -> -> - 掌握 `OnceCallback` 的六类测试用例设计方法 -> - 理解 `sizeof`、SBO 阈值、间接调用开销等性能指标的含义 -> - 清楚我们的 `OnceCallback` 与 Chromium `OnceCallback` 的取舍关系 +测试怎么组织,笔者一开始也犯过嘀咕。按功能分容易漏,因为功能是写给自己看的——您写的时候想得到啥,就测了啥,死角天然存在。后来换了按**不变量**分,这一刀切下去舒服多了:每个不变量本身就是一句"我保证永远成立",测试干的就是把这句话按各种姿势折磨一遍,看它崩不崩。崩了就是真的错,没崩这一类就算过。 ---- - -## 测试策略 - -我们把测试组织成六个类别,每个类别聚焦一个独立的设计不变量。这种按不变量组织测试的方式比按功能组织更不容易遗漏边界情况——因为每个不变量本身就是一种正确性保证,测试的目的就是验证这些保证在各种场景下都成立。 - -我们的实际测试代码使用 Catch2 框架,配合 CMake + CPM 管理依赖。下面列出的测试用例与 `code/volumn_codes/vol9/chrome_design/test/test_once_callback.cpp` 中的实际代码一一对应。 +测试代码挂在 Catch2 上,依赖用 CMake + CPM 拉。下面列的用例跟 `code/volumn_codes/vol9/chrome_design/test/test_once_callback.cpp` 里的实际代码一一对应,您手里有那份代码就能逐条跑。 ### A 类:基本调用与返回值 -这类测试验证 `OnceCallback` 的基本构造和调用行为。 +最基本的:构造一个回调,跑一下,看返回值对不对。 ```cpp TEST_CASE("non-void return", "[once_callback]") { @@ -59,11 +49,11 @@ TEST_CASE("void return", "[once_callback]") { } ``` -最基本的场景——构造一个回调,调用它,验证返回值。void 返回类型走的是 `if constexpr (std::is_void_v)` 的另一条分支,确认我们的编译期分支逻辑是正确的。 +void 返回走的是 `if constexpr (std::is_void_v)` 的另一条分支,这两条用例就是给编译期分支逻辑上保险。 ### B 类:移动语义 -这类测试验证 move-only 约束和移动操作的正确性。 +这一类盯两件事:move-only 约束别假开、移动操作别把状态搞丢。 ```cpp TEST_CASE("move-only capture", "[once_callback]") { @@ -83,13 +73,13 @@ TEST_CASE("move semantics: source becomes null", "[once_callback]") { } ``` -move-only capture 测试(`std::make_unique(42)` 被捕获进 lambda)确认了 `OnceCallback` 真正支持 move-only 的可调用对象——如果底层用的是 `std::function` 而不是 `std::move_only_function`,这段代码直接编译失败。移动语义测试验证了移动构造后源对象变为 `kEmpty` 状态(通过 `is_null()` 检查),目标对象保持有效并可以正常调用。 +move-only capture 那条用例是 `std::make_unique(42)` 塞进 lambda——这玩意儿要是底层退回 `std::function` 而不是 `std::move_only_function`,直接编译都过不去,所以这条用例顺手给"咱们到底有没有真用上 move-only"兜了底。移动语义那条验证移动构造后源对象落回 `kEmpty`、`is_null()` 报真,目标对象还能照常跑。 -有一个容易搞混的概念点——移动操作转移了所有权,但不会触发消费。只有 `run()` 才会消费回调。这个区别在 Chromium 里也很重要:`PostTask(FROM_HERE, std::move(cb))` 只是转移所有权,回调在任务被执行之前一直处于活跃状态。 +这里笔者得拎出一个自己绕过半天的点:移动只是转交所有权,**不消费**。真正消费回调的是 `run()`。这俩看着都"动了 cb",但语义完全两码事。Chromium 那边也是同一套规矩——`PostTask(FROM_HERE, std::move(cb))` 只是把所有权搬进任务队列,回调在被真正执行之前一直活着。 ### C 类:单次调用约束 -这类测试验证"调用一次即消费"的核心语义。在 A 类和 B 类的测试中我们已经覆盖了正常调用路径,C 类聚焦于左值调用的编译拦截。这个约束是通过 deducing this + `static_assert` 实现的——如果写 `cb.run()` 而不是 `std::move(cb).run()`,编译器会直接报错,错误信息明确告诉调用方应该用 `std::move`。这部分不需要运行时测试,编译通过本身就是验证。 +A 类、B 类已经把正常调用路径趟了一遍,C 类专盯一件事:左值调用必须编不过。这个约束咱们是用 deducing this + `static_assert` 拍在签名上的,所以它压根不归运行时管——您要是手滑写成 `cb.run()` 而不是 `std::move(cb).run()`,编译器当场就拦下来,顺手把"得用 std::move"喂到错误信息里。编译过 = 验证过,跑都不用跑。 ### D 类:参数绑定 @@ -111,7 +101,7 @@ TEST_CASE("bind_once with member function", "[bind_once]") { } ``` -`bind_once` 测试覆盖了两种典型场景:普通 lambda 的部分参数绑定和成员函数绑定。成员函数绑定测试特别值得关注——`&Calc::multiply` 是成员函数指针,`&calc` 是对象指针,`std::invoke` 在内部把它展开成 `(calc.*multiply)(5, 8)` 调用。这里有一个生命周期陷阱需要注意:`&calc` 是裸指针,`bind_once` 不会管理它的生命周期。如果 `calc` 在回调被调用之前就被销毁了,`std::invoke` 会通过悬空指针访问已释放的内存。Chromium 用 `base::Unretained` 显式标记裸指针的安全性,用 `base::Owned` 接管所有权,用 `base::WeakPtr` 在对象析构时自动取消回调。我们的简化版里,这个安全责任暂时交给调用方。 +`bind_once` 蹚了两种典型场景:普通 lambda 的部分参数绑定、成员函数绑定。成员函数那条笔者得多说两句——`&Calc::multiply` 是成员函数指针,`&calc` 是对象指针,`std::invoke` 在底下把它展开成 `(calc.*multiply)(5, 8)`。坑在哪儿:`&calc` 是个裸指针,`bind_once` 不管它死活。要是 `calc` 在回调真正跑之前就先一步析构了,`std::invoke` 就会顺着悬空指针摸到一堆已经释放的内存。Chromium 在这里准备了三档保险——`base::Unretained` 显式声明"这指针安全自负"、`base::Owned` 直接接管所有权、`base::WeakPtr` 让对象析构时自动取消回调。咱们这个简化版暂时把这份责任甩给调用方,留到取消令牌那篇再回来收。 ### E 类:取消机制 @@ -147,7 +137,7 @@ TEST_CASE("cancelled non-void callback throws", "[once_callback]") { } ``` -取消测试覆盖了三个关键行为:令牌有效时不取消、令牌失效后 void 回调不执行、令牌失效后非 void 回调抛出 `std::bad_function_call`。第三个测试的行为值得展开说一下——我们的实现在非 void 返回的已取消回调中抛出异常,理由是调用方期望得到一个返回值,但我们无法提供一个有意义的值,所以抛异常是比返回未定义值更安全的做法。Chromium 的实现在这里会直接终止程序(`CHECK` 失败),我们选择异常是因为它在测试中更容易捕获和验证。 +取消这一类压三个动作:令牌还活着时不取消、令牌失效后 void 回调老老实实不执行、令牌失效后非 void 回调抛 `std::bad_function_call`。第三条笔者得停一下解释——咱们对已取消的非 void 回调选择抛异常,原因是调用方眼里它要的是一个返回值,可取消态下咱们手里压根没有"有意义的值"可给。返个默认值骗它?那比抛异常更阴险,bug 会沿着这个假值往后传。Chromium 这一手更狠,直接 `CHECK` 失败把程序拉爆,咱们选异常纯粹是因为它在测试里好抓、好验证——这是教学版的取舍,不是设计上更优。 ### F 类:Then 组合 @@ -176,11 +166,11 @@ TEST_CASE("then with void first callback", "[then]") { } ``` -`then()` 测试覆盖了三种组合模式:两级非 void 管道、多级管道(跨越类型边界——从 `int` 到 `std::string`)、以及 void 前缀回调。多级管道测试特别有趣——`(5*2)+10 = 20`,最终被 `std::to_string` 转换为字符串 `"20"`。这个测试验证了 `then()` 在每一级都正确地推导了返回类型,并且类型擦除(通过 `std::move_only_function`)在不同类型的 lambda 之间正确工作。void 前缀测试验证了 `if constexpr (std::is_void_v)` 分支——第一个回调设置 `value = 7`,第二个回调通过引用读取 `value` 并返回 `21`。 +`then()` 这一类的三条用例各压一种姿势:两级非 void 管道、跨类型的多级管道、void 前缀回调。多级管道这条笔者觉得最能说明问题——`(5*2)+10 = 20` 这个数最后被 `std::to_string` 折成字符串 `"20"`,一路上每一级的返回类型都被 `then()` 推导对了,而 `std::move_only_function` 在几种完全不同类型的 lambda 之间做的类型擦除也没崩。void 前缀那条专门压 `if constexpr (std::is_void_v)` 分支——第一个回调往外部 `value` 写 7,第二个回调靠引用把 `value` 读出来乘 3 得 21。 ### 测试框架与构建配置 -我们使用 Catch2 v3 作为测试框架,通过 CPM(CMake Package Manager)自动拉取依赖。测试的 CMake 配置非常简洁: +测试框架选的 Catch2 v3,依赖让 CPM(CMake Package Manager)自动拉。CMake 配置很省心: ```cmake # test/CMakeLists.txt @@ -193,17 +183,17 @@ target_compile_options(test_once_callback PRIVATE -Wall -Wextra -Wpedantic) add_test(NAME test_once_callback COMMAND test_once_callback) ``` -Catch2 的 `REQUIRE` 宏比 `assert()` 强在它会报告具体的失败表达式、文件和行号,并且在同一个 `TEST_CASE` 内继续执行后续检查(而不是像 `assert()` 那样直接终止程序)。`REQUIRE_THROWS_AS` 则专门用于验证异常类型——在取消机制的测试中,我们需要确认被取消的非 void 回调抛出的是 `std::bad_function_call`,而不是其他异常。 +笔者用 `REQUIRE` 不用 `assert`,理由很实在:`REQUIRE` 报错会甩出失败的表达式、文件、行号,而且同一个 `TEST_CASE` 里后续断言还会继续跑;`assert` 一炸整个程序就停了,您一次只能看一个错。`REQUIRE_THROWS_AS` 专门压异常类型——取消机制那条测试就靠它确认抛的是 `std::bad_function_call` 而不是别的什么。 -运行测试的流程很简单——在 `build/` 目录下 `cmake --build . && ctest`。 +跑测试的姿势就一句:`build/` 目录下 `cmake --build . && ctest`。 --- -## 性能考量:与 Chromium 原版对比 +## 性能账:跟 Chromium 原版对一对 ### 对象大小 -这是最直观的差异。我们用一个简单的程序来测量: +最直观的差就在 sizeof 上。咱们写个最小程序量一下: ```cpp #include @@ -226,35 +216,35 @@ int main() { } ``` -在 GCC 上,典型值如下:`std::function` 约 32 字节,`std::move_only_function` 约 32 字节,我们的 `OnceCallback` 加上 `Status` 枚举和可选的 `CancelableToken` 指针,大约 56-64 字节。Chromium 的 `OnceCallback` 只有 8 字节——一个指向 `BindState` 的 `scoped_refptr`。 +GCC 上跑出来大致是这套数:`std::function` 约 32 字节,`std::move_only_function` 约 32 字节,咱们的 `OnceCallback` 加上 `Status` 枚举和可选的 `CancelableToken` 指针,大约 56-64 字节。Chromium 的 `OnceCallback` 就 8 字节——一个指向 `BindState` 的 `scoped_refptr`,没了。 -差距的根源在于存储策略。Chromium 把所有状态(可调用对象 + 绑定参数)都放在堆上的 `BindState` 里,回调对象本身只持有一个指针。我们用 `std::move_only_function` 的 SBO 把小对象直接内联存储在回调对象内部,避免了堆分配但增大了对象大小。 +差从哪儿来?根子在存储策略。Chromium 把所有东西——可调用对象也好、绑定的参数也好——全塞进堆上的 `BindState`,回调对象自己只捏一个指针。咱们这版靠 `std::move_only_function` 的 SBO 把小对象直接内联塞进回调对象,堆分配是省了,代价是对象本体胖了一圈。 ### 分配行为 -`std::move_only_function` 的 SBO 阈值是实现定义的,通常是 2-3 个指针大小(16-24 字节)。捕获少量参数的 lambda(比如 `[x = 42]` 或 `[&ref]`)通常能放进 SBO,不会触发堆分配。但如果 lambda 捕获了大量数据(比如一个 `std::string` + 几个 `int`),就会在构造时堆分配。 +`std::move_only_function` 的 SBO 阈值是实现定义的,典型在 2-3 个指针(16-24 字节)上下。捕获很轻的 lambda,比如 `[x = 42]` 或 `[&ref]`,一般塞得进 SBO,不触发堆分配;要是 lambda 拉了一票数据进来,比如一个 `std::string` 加几个 `int`,构造时就得多分一次堆。 -Chromium 的方案总是堆分配(`new BindState`),但分配只发生一次——在 `BindOnce` 时。之后 `OnceCallback` 的移动操作只是复制一个指针(8 字节),代价极低。我们的方案在小对象时不分配(SBO),但移动操作需要复制整个 `std::move_only_function`(32 字节)加上 `token_` 指针,代价稍高。 +Chromium 那一套是固定堆分配——`new BindState` 总会跑一次,但**只跑一次**,就发生在 `BindOnce` 那一刻。之后 `OnceCallback` 的移动操作就只是复制一个 8 字节指针,极轻。咱们这版小对象时不分配(SBO 兜住),可一旦要移,就得把整个 `std::move_only_function`(32 字节)加上 `token_` 指针一起搬走,代价明显高一截。 -两种策略在不同场景下各有优势。对于高频投递的小回调(Chrome 浏览器的主场景),Chromium 的方案更优——移动代价低、大小一致有利于 CPU 缓存。对于低频的大回调(比如一次性初始化任务),我们的方案更优——省去一次堆分配。 +两种策略谁也没法通吃。高频投递的小回调(浏览器是 Chrome 的主战场),Chromium 那套占便宜——移得便宜、大小齐整对 CPU 缓存友好。低频的大回调(比如一次性初始化任务),咱们这套反而划算——少分一次堆。挑哪一套,看您项目的频率分布。 ### 间接调用开销 -两种方案的调用开销是一样的:一次间接函数调用。`std::move_only_function::operator()` 内部通过函数指针或虚函数表分派到具体的可调用对象;Chromium 的 `BindState::polymorphic_invoke_` 也是函数指针分派。在 `-O2` 优化下,这个间接调用无法被内联消除,性能上两种方案等价。 +调用开销这两条路是平的:都是一次间接调用。`std::move_only_function::operator()` 底下靠函数指针或虚表派发到具体可调用对象;Chromium 的 `BindState::polymorphic_invoke_` 也是函数指针派发。`-O2` 下这一层间接编译器消不掉,所以两种方案在调用这一环上等价。 -### 我们牺牲了什么,换来了什么 +### 咱们让出了什么、换回了什么 -总结一下取舍。 +把账算明白。 -我们牺牲了对象的紧凑性(56-64 字节 vs 8 字节),换来了实现简洁性——不需要手写引用计数、函数指针表、`TRIVIAL_ABI` 注解。我们牺牲了移动操作的极致性能(复制 32 字节 + 指针 vs 复制 8 字节),换来了小对象的零堆分配。我们牺牲了引用计数共享(无法让多个回调共享同一份 `BindState`),但 `OnceCallback` 本身就是独占语义,不需要共享。 +让出去的是对象的紧凑性(56-64 字节对 8 字节),换回来的是实现干净——不用自己撸引用计数、函数指针表、`TRIVIAL_ABI` 注解。移动那块也付出了代价(搬 32 字节 + 指针 vs 复制 8 字节),换回来的是小对象的零堆分配。引用计数共享这块咱也让了,没法让多个回调共用同一份 `BindState`,但 `OnceCallback` 本来就是独占语义,共享这事儿它压根用不上。 -这些取舍对于教学目的和大多数实际场景来说都是合理的。如果你的项目确实需要 Chromium 级别的极致性能,可以参考 Chromium 的源码做进一步优化——核心思路我们已经在这三篇设计指南里讲清楚了。 +这套取舍在教学场景里、在绝大多数实际项目里都站得住。您的项目要是真压到 Chromium 那种性能要求,可以直接照着 Chromium 源码再榨一层——核心思路前三篇已经摊开了,剩下的是工程细节。 --- -## 完整组件文件一览 +## 文件落在哪儿 -到这里,`OnceCallback` 组件的设计、实现和测试策略都已完成。完整的文件清单: +`OnceCallback` 这一组的设计、实现、测试到这儿算是收口了,完整文件清单如下,您要找对应代码照着摸就行: ```text documents/vol9-open-source-project-learn/chrome/hands_on/ @@ -263,7 +253,7 @@ documents/vol9-open-source-project-learn/chrome/hands_on/ └── 03-once-callback-testing.md # 验证篇:测试与性能 ``` -对应的可编译代码(头文件 + 测试)位于项目代码目录中: +对应的可编译代码(头文件 + 测试)在项目代码目录下: ```text code/volumn_codes/vol9/chrome_design/ @@ -282,11 +272,9 @@ code/volumn_codes/vol9/chrome_design/ --- -## 小结 - -这篇验证篇我们做了两件事。测试方面,围绕六个不变量(基本调用、移动语义、单次调用、参数绑定、取消机制、链式组合)设计了 12 个 Catch2 测试用例,覆盖了 `OnceCallback` 的所有核心行为。性能方面,对比了与 Chromium `OnceCallback` 在对象大小、分配行为和调用开销上的差异——我们的实现用紧凑性换来了简洁性,对绝大多数场景来说这个取舍是值得的。 +测试这一篇围着六个不变量——基本调用、移动语义、单次调用、参数绑定、取消机制、链式组合——拆出 12 条 Catch2 用例,`OnceCallback` 的核心行为差不多都压在底下了。性能那边跟 Chromium 原版一对,大小、分配、调用三环的账都摆出来了:咱们拿紧凑换简洁,这笔交易在绝大多数场景里划算,真要榨到 Chromium 那个量级,再回去啃源码也不迟。 -下一步可以尝试的方向:实现 `RepeatingCallback`(可复制、可重复调用的版本),给 `bind_once` 添加 `Unretained` / `Owned` / `WeakPtr` 等生命周期辅助函数,或者用 Google Benchmark 做精确的性能测量。 +`OnceCallback` 这一组到这里告一段落。后面接着要碰的是 `RepeatingCallback`(可复制、可重复调用的版本),还有把 `Unretained` / `Owned` / `WeakPtr` 这套生命周期辅助函数补到 `bind_once` 上——后者恰好是下一个主题 `WeakPtr` 的入口。 ## 参考资源 diff --git a/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/index.md b/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/index.md index ee80ce1bf..54b1344f6 100644 --- a/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/index.md +++ b/documents/vol9-open-source-project-learn/chrome/01_once_callback/hands_on/index.md @@ -1,6 +1,6 @@ # 进阶设计指南 -本目录包含 OnceCallback 组件的三篇设计指南,面向有 C++ 模板经验的读者。如果你对移动语义、模板偏特化、类型擦除等概念还不熟悉,建议先阅读 [full/](../full/) 目录下的前置知识文章。 +本目录包含 OnceCallback 组件的三篇设计指南,面向有 C++ 模板经验的读者。如果您对移动语义、模板偏特化、类型擦除等概念还不熟悉,建议先阅读 [full/](../full/) 目录下的前置知识文章。 设计指南(一):动机与接口设计 diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-1-weak-ptr-motivation-and-api-design.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-1-weak-ptr-motivation-and-api-design.md new file mode 100644 index 000000000..56e600a10 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-1-weak-ptr-motivation-and-api-design.md @@ -0,0 +1,268 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 从一个真实的悬空回调 bug 出发,回扣 01-4 的取消令牌尾巴,讲清 WeakPtr 要补的洞,定下完整目标 API +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- WeakPtr 前置知识(零):弱引用与生命周期难题 +- OnceCallback 实战(四):取消令牌设计 +- WeakPtr 前置知识(一):侵入式引用计数与 scoped_refptr +reading_time_minutes: 12 +related: +- WeakPtr 实战(二):核心骨架与控制块 +- WeakPtr 前置知识(一):侵入式引用计数与 scoped_refptr +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- 内存管理 +- weak_ptr +title: "WeakPtr 实战(一):动机与接口设计" +--- +# WeakPtr 实战(一):动机与接口设计 + +01-4 手搓取消令牌那会儿,笔者图省事给回调挂了个原子标志——对象活着是 0,析构前置 1,回调跑前先 if 一下。悬空问题当时糊弄过去了。可有个尾巴笔者自己心里一直没踏实:那枚标志到底归谁管?回调又怎么拿到它? + +您琢磨一下这事儿有多别扭。标志是对象 A 创建的,回调跑在别处。要让回调看到标志,就得把标志的指针塞过去。塞裸指针,A 一析构标志跟着没了,回调手里的指针又悬空,绕一圈回原点。塞 `shared_ptr` 呢?那标志就永远不析构了——可这只是管标志,不是管 A 本身,问题根本没解决,只是把洞挪了个位置。 + +这个别扭,其实就是 C++ 弱引用那个经典老问题的具体化身。Chromium 在 `base` 里给了它一个相当硬核的回答,叫 `WeakPtr`。咱们这一篇把动机和要补的洞理清楚,顺手把目标 API 定下来。接口先于实现,代码留给后面几篇。 + +--- + +## 从一个 bug 说起 + +### 场景:异步任务投递 + +假设有个 `Controller`,往线程池投任务,任务完事回来更新自己的状态。 + +```cpp +class Controller { +public: + void start_work(ThreadPool& pool) { + // 投一个任务到线程池,完成后回调 on_work_done + pool.post([this] { this->on_work_done(); }); + } + void on_work_done() { /* 更新状态 */ ++work_count_; } +private: + int work_count_ = 0; +}; +``` + +这段代码您测十次有九次半是好的。可一旦 `Controller` 在任务还没轮到的时候被析构——用户切页面了、所在窗口关了——任务系统里那份回调还死死攥着 `this`。等任务真跑起来,`on_work_done` 解引用的就是一个已经析构的对象。段错误。 + +### 这是我们在 01-4 已经遇过的问题 + +跟当时手搓取消令牌是一码事:给 Controller 挂个标志,析构前置 1,回调执行前先看标志。思路一模一样。 + +可当时笔者偷了个懒,标志的生命周期是靠"回调持 `shared_ptr`、Controller 持原始 flag"硬糊的,因为教学版只想把"检查"这一步讲清楚。这回咱们得认真了。真正的做法,是把"标志"和"指向对象的弱引用"捏成一个东西。回调拿到的不是一个孤零零的标志,而是一个 `WeakPtr`——它既能告诉回调 Controller 死没死,又能在没死的时候直接调 Controller 的方法。 + +这就是 `WeakPtr` 要干的事。 + +--- + +## 三种现成解法为什么都不够 + +动手造轮子之前,先把现成的三条路堵死,您才理解 Chromium 为什么非要自己造。 + +裸指针 `this` 就是上面那段代码,对象析构后回调悬垂,UAF,没什么好说的。 + +`shared_ptr` 也不行。它逼您把 Controller 改成共享所有权,本来一个 owner 清清爽爽,这下变成"理论人人可持"。更要命的是,只要回调还攥着 `shared_ptr`,Controller 就**永远不析构**——任务还在排队,Controller 就赖着不走,资源泄漏加上生命周期失控。这不是修 bug,是换了个 bug。 + +`std::weak_ptr` 呢?它得先有 `shared_ptr` 才能用,绕一圈又回到上一条。而且每次访问都得 `lock()` 拿个临时 `shared_ptr`,在回调这种高频路径上既啰嗦又多一坨原子开销。它在 [前置知识(零)](./pre-00-weak-ptr-weak-reference-and-lifetime.md) 里系统讲过的抽象局限,这里咱们用代码说话就够了。 + +三条路,要么不安全,要么污染所有权,要么绑着非侵入式的代价。Chromium 的诉求很直白:让 Controller 保持原来的单一 owner 模型,给回调一个"不延寿、能判活、能批量失效"的弱引用。 + +--- + +## Chromium 的回答:WeakPtr 的设计哲学 + +Chromium 的 `WeakPtr` 不是一个孤立的小类,它是一套四层结构。咱们从底层往上看。这个分层和 [OnceCallback 实战(一)](../../01_once_callback/full/01-1-once-callback-motivation-and-api-design.md) 里讲过的 `BindState` 是同一个套路——底层做类型擦除加引用计数,顶层甩一个轻量句柄给用户。您会一个,另一个基本就白捡。 + +### 四层架构 + +```mermaid +flowchart TB + subgraph FAC["WeakPtrFactory<T> — 用户创建,管理批量失效"] + RO["WeakReferenceOwner
持有 flag 的「发行方」"] + end + subgraph WP["WeakPtr<T> — 用户传递/解引用的句柄"] + WR["WeakReference — 引用侧, + 指向同一个 flag"] + Flag["Flag — refcounted + + 原子的状态"] + WR --> Flag + end + RO -. "发行 / 失效" .-> Flag +``` + +最底下是 `Flag`,内部类,用户碰不到。它就是那个"对象死没死"的状态,挂了 `RefCountedThreadSafe`,被发行方和所有引用方共享,里头一个原子标志位。往上一层是 `WeakReference`,是对 `Flag` 的引用包装,持一个 `scoped_refptr`,它就是 WeakPtr 内部那个"弱引用"的实体。再往上是 `WeakPtr`,用户操作的句柄,持一个 `WeakReference` 加一个 `T*`,大小就两个指针,标了 `TRIVIAL_ABI` 能进寄存器。最顶上 `WeakPtrFactory` 挂在被观察对象身上当"铸币厂",调 `GetWeakPtr()` 铸一个新 WeakPtr,调 `InvalidateWeakPtrs()` 一次性作废所有已铸出的。 + +这里头有个笔者觉得相当漂亮的设计:**从同一个 factory 铸出的所有 WeakPtr 共享同一枚 Flag**。所以"对象析构时调一次 `InvalidateWeakPtrs()`,所有 WeakPtr 集体失效"几乎是白送的。这正是 `std::weak_ptr` 做不到的"一次失效一批"。 + +### 为什么是这个形状 + +回头看 01-4 那个结:标志怎么传?Chromium 的答案就是这套结构。Flag 用引用计数管自己的命——只要还有 WeakPtr 持着,Flag 就活着;而 Flag 指向的对象该析构就析构,Flag 不拦。两套生命周期彻底分开。 + +对象 Controller 的命由它的 owner 决定,跟 WeakPtr 没半点关系。Flag 的命由引用计数管,从第一个 WeakPtr 铸出到最后一个 WeakPtr 销毁。而"Controller 死没死"这个状态存在 Flag 里,Controller 析构前由 factory 调一次 `Invalidate` 把标志位翻过去。 + +这就是"不介入所有权 + 能判活"的落地。咱们在 [前置知识(零)](./pre-00-weak-ptr-weak-reference-and-lifetime.md) 里列的四条诉求,被这四层结构一条条消化掉了。 + +--- + +## 设计目标 API + +接下来把目标 API 定下来。这是工程师的工作方式——先想清楚"我要什么",再回头讨论每个决策。命名沿用项目的 `tamcpp::chrome` 命名空间,snake_case 风格,跟 OnceCallback 系列保持一致。 + +### 弱指针:WeakPtr\ + +```cpp +#include "weak_ptr/weak_ptr.hpp" +using namespace tamcpp::chrome; + +// 从 factory 铸出来(见下面) +WeakPtr wp = factory.get_weak_ptr(); + +// 判活 + 解引用 +if (wp) { + wp->on_work_done(); // operator-> :对象活着时正常调用 +} + +// 失效后 +wp->on_work_done(); // operator-> :对象死了 → CHECK 失败,程序中止 +wp.get(); // get() :对象死了 → 返回 nullptr,不崩 + +// 重置 +wp.reset(); // 主动松手,之后 wp == nullptr +``` + +### 工厂:WeakPtrFactory\ + +```cpp +class Controller { +public: + void start_work(ThreadPool& pool); + void on_work_done(); + // 对外暴露铸币接口(factory 本身保持 private) + WeakPtr get_weak() { return weak_factory_.get_weak_ptr(); } + ~Controller() = default; +private: + int work_count_ = 0; + // 关键:factory 是最后一个成员,且保持 private(02-3 讲为什么) + WeakPtrFactory weak_factory_{this}; +}; + +// 在别处铸币(通过 public 接口,不直接碰 private 的 factory) +WeakPtr wp = controller.get_weak(); +``` + +> 主动失效(`invalidate_weak_ptrs()`)和查询(`has_weak_ptrs()`)是 factory 自己的方法,通常也通过 Controller 的 public 方法转发——对象自己决定什么时候作废所有观察者,而不是让外部直接戳 `weak_factory_`。不然外部代码一个手滑调了失效,所有观察者集体瞎掉。02-3 会展开这套封装。 + +### 与回调集成(预告 02-5) + +这一步才是整个系列的"眼"。Chromium 里真正的写法不是 `if (wp) wp->...`,而是把 WeakPtr 直接绑进回调,让回调在对象死后**自动**变 no-op: + +```cpp +// 这条任务在 controller 死后会自动静默丢弃,不会悬空解引用 +pool.post(bind_once(&Controller::on_work_done, controller.weak_factory_.get_weak_ptr())); +``` + +到这儿,01-4 那个手搓的取消令牌才真正接进了成体系的回调系统。02-5 咱们把这里的机制拆到汇编级。 + +--- + +## 接口设计决策分析 + +API 定下来了,可每个签名里都藏着决策。咱们把"为什么"逐条说清楚,这一节的每个结论都能在 Chromium 源码里对上一条注释或一行实现,后面实战篇会逐个兑现。 + +### 为什么 get() 返回裸指针,而 operator*/operator-> 用 CHECK + +`WeakPtr` 故意把"检查"和"不检查"两种解引用分得很开。 + +`get()` 返回 `T*`,对象活着给真地址,死了给 `nullptr`,**不崩**,判断权交给您。`operator*` 和 `operator->` 就凶了,对象死了直接 `CHECK` 失败中止程序,release 构建也崩,不是只 debug 崩的 DCHECK。 + +为什么这么狠?因为解引用一个失效的 WeakPtr 是个**确定的逻辑错误**。您明明可以先 `if (wp)` 或者 `get()` 判活,没判就硬解引用,那只能说明代码写错了。这种 bug 在 release 里也得让它立刻爆,而不是带着悬垂指针继续跑出一堆诡异现象。Chromium 的源码注释直接把这条写成了契约(`weak_ptr.h:240-252`)。 + +至于 `get()`,它是给"我自己会判活"留的逃生口,返回裸指针不替您做检查。那些要把判活后的指针喂给不接受 WeakPtr 的旧代码的场景,就靠它。 + +### 为什么不提供 operator== 和 operator<=> + +您可能觉得奇怪,智能指针一般都能比地址,WeakPtr 怎么连 `==` 都不给?Chromium 在源码里专门写了一段注释解释(`weak_ptr.h:196-201`): + +> WeakPtr 故意不实现 `operator==` 和 `operator<=>`,因为弱引用的比较本质上不稳定。 + +两层原因。要是比较考虑有效性,两个 WeakPtr 此刻相等,下一秒一个失效一个没失效,结果随时在变,根本没法拿来排序或做 key。那要是比较只看底层指针值呢?更糟,对象析构后那块地址可能被另一个新对象复用,两个完全不相关的 WeakPtr 因为地址撞车就"相等"了,这是更阴的 bug。 + +所以 WeakPtr 只允许跟 `nullptr` 比,也就是 `if (wp)` 这种判活。其它比较一概不给,从类型层面堵死误用。 + +### 为什么 WeakPtrFactory 是组合,而不是继承 + +Chromium 里其实有两种获得 WeakPtr 的方式。 + +主流的是组合:对象把 `WeakPtrFactory weak_factory_{this}` 当成员,可控、灵活,还能给非类类型用,比如 `WeakPtrFactory`。历史上还有一种继承式,Chromium 当年提供过 `SupportsWeakPtr`,让 T 继承它就能自动获得 `GetWeakPtr()`。这种写法**已从 Chromium 移除**,因为它鼓励了不安全的用法,当前 `//base` 只保留组合式。笔者提这一嘴,是怕您读老代码的时候犯迷糊,新代码千万别用。 + +咱们这个系列只实现组合式,因为它是 Chromium 推荐的主流写法,也是"最后成员"那条著名惯用法的载体,02-3 展开。继承式说白了是它的语法糖,理解了组合式自然就会。 + +### 为什么 factory 必须是最后一个成员 + +这条 02-3 会用析构逆序仔细论证,这里先记结论:`WeakPtrFactory weak_factory_{this}` 必须声明在所有成员的最后。原因在 C++ 的成员析构是按声明逆序来的——factory 放最后,它最先析构,于是在其它成员开始析构之前,所有 WeakPtr 就已经作废了。反过来,要是 factory 放前面,某个成员析构之后 WeakPtr 还有效,别人一解引用就是个半销毁对象。这条惯用法是 WeakPtr 用对用错的分水岭,咱们到 02-3 专门讲。 + +--- + +## 我们的实现与 Chromium 的取舍 + +跟 OnceCallback 系列一样,咱们的教学版会保留核心机制——Flag、WeakReference、WeakPtr、WeakPtrFactory 这四层,但做一些简化。先预告一下取舍,02-6 用实测对比收尾。 + +| 维度 | Chromium 实现 | 我们的教学版 | +|---|---|---| +| Flag 的引用计数 | `RefCountedThreadSafe`(原子,跨序列) | 同(这是核心,不能省) | +| 原子标志 | `base::AtomicFlag`(release/acquire 封装) | 直接用 `std::atomic` 配 memory_order | +| 序列检查 | `SEQUENCE_CHECKER`(release 下 no-op) | 简化为可选的 debug 断言 | +| `SafeRef` | 完整(非空、悬空即崩) | 不实现(留作扩展) | +| `BindOnce` 集成 | 完整的 `InvokeHelper` 分派 | 简化的 trampoline + 与 01 的 OnceCallback 对接 | +| `TRIVIAL_ABI` | 标注 | 标注(clang) | + +咱们拿 `std::atomic` 加显式 memory_order 替掉 Chromium 的 `base::AtomicFlag`,是因为前者是标准库,人人能编译。但有一点得讲清楚:两者在 release/acquire 语义上是等价的,pre-02 专门讲。 + +--- + +## 环境搭建 + +WeakPtr 比 OnceCallback 的工具链要求低一些。它用 C++20 的 concepts 和 requires(转换构造、const 重载),但用不着 C++23 的 `move_only_function` 或 deducing this。 + +### 编译器要求 + +GCC 11+ 或 Clang 12+ 即可,编译加 `-std=c++20`。咱们会在需要 `TRIVIAL_ABI` 的地方用 `[[clang::trivial_abi]]`——这是 **Clang 专属属性,GCC 与 MSVC 都不支持**(实测 GCC 16 仍把它当作被忽略的 scoped 属性,不会报错但也无寄存器传递效果)。咱们的 `TAMCPP_TRIVIAL_ABI` 宏在非 Clang 编译器上展开为空,代码照常编译、行为正确,只是不享受 trivial_abi 的 ABI 优化。 + +### 验证代码 + +```cpp +#include +#include + +// 验证 concepts 可用 +template + requires std::convertible_to +constexpr bool check_convertible() { return true; } + +// 验证 atomic + memory_order 可用(各 order 值是 implementation-defined, +// 标准只保证它们互不相同;这里只断言 distinct,跨编译器都成立) +static_assert(std::memory_order::acquire != std::memory_order::release); + +int main() { return 0; } +``` + +这段能过,环境就齐了。配套的工程脚手架沿用 `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/` 目录,咱们在 02-2 开始往里加 `12_`~`18_` 这一批示例。 + +--- + +## 参考资源 + +- [Chromium `base/memory/weak_ptr.h` 源码与设计注释](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [Chromium `base/memory/weak_ptr.cc` 实现](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.cc) +- [OnceCallback 实战(四):取消令牌设计](../../01_once_callback/full/01-4-once-callback-cancellation-token.md) +- [WeakPtr 前置知识(零):弱引用与生命周期难题](./pre-00-weak-ptr-weak-reference-and-lifetime.md) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-2-weak-ptr-core-skeleton-and-control-block.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-2-weak-ptr-core-skeleton-and-control-block.md new file mode 100644 index 000000000..3d65db495 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-2-weak-ptr-core-skeleton-and-control-block.md @@ -0,0 +1,323 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 动手实现 WeakPtr 的三层骨架——refcounted 的 Flag 控制块、WeakReference 引用包装、 + WeakPtr 用户句柄,落到 std::atomic + memory_order +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- WeakPtr 实战(一):动机与接口设计 +- WeakPtr 前置知识(一):侵入式引用计数与 scoped_refptr +- WeakPtr 前置知识(二):std::atomic 与 memory_order +reading_time_minutes: 14 +related: +- WeakPtr 实战(三):WeakPtrFactory 与"最后成员"惯用法 +- WeakPtr 前置知识(三):序列、SEQUENCE_CHECKER 与 DCHECK/CHECK +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- weak_ptr +- atomic +- 内存管理 +title: "WeakPtr 实战(二):核心骨架与控制块" +--- +# WeakPtr 实战(二):核心骨架与控制块 + +## 三层结构回顾 + +WeakPtr 是一套三层结构:`Flag`(控制块)、`WeakReference`(引用侧)、`WeakPtr`(用户句柄)。前面七篇前置知识把零件都备齐了——侵入式引用计数、acquire/release、CHECK/DCHECK,这一篇咱们就把这些零件焊到一起,看看它们怎么咬合成真实代码。从底往上写,先实现最底层、也是最吃重的 `Flag`。它就是 [前置知识(零)](./pre-00-weak-ptr-weak-reference-and-lifetime.md) 里那个"对象死没死"状态的载体,也是 [01-4 取消令牌](../../01_once_callback/full/01-4-once-callback-cancellation-token.md) 那个手搓 flag 的工业级正身。 + +照着 [02-1](./02-1-weak-ptr-motivation-and-api-design.md) 那张图往下填代码,先把图再摆一遍: + +```mermaid +flowchart TB + subgraph WP["WeakPtr<T> — 用户传递/解引用的句柄"] + subgraph WR["WeakReference — 引用侧,持 scoped_refptr<Flag>"] + Flag["Flag — refcounted + 原子的 liveness"] + end + end +``` + +三层各自的职责不复杂。`Flag` 管"对象死没死"这个状态,一个原子标志位加一份引用计数;`WeakReference` 是对 Flag 的轻量引用,说白了就是 `scoped_refptr` 的一层包装;`WeakPtr` 在 `WeakReference` 之外再补一个 `T*`,凑成用户手里那把句柄。 + +--- + +## 第一层:Flag —— refcounted 的原子状态 + +设计 `Flag` 之前,咱们先把它的处境捋一遍。factory 侧要持它,每个 WeakPtr 侧也要持它,一票人共享——这就注定它得带引用计数。它手里捏的那个状态就一个布尔位,"失效没失效",而这个位偏偏要被不同序列读写,所以非原子不可。再加上它被引用计数管着,最后一个放手的那条序列随时可能负责 delete 它,这又逼着计数本身得跨线程安全。 + +三条压一块儿,正好是 [前置知识(一)](./pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md) 的 `RefCountedThreadSafe` 撞上 [前置知识(二)](./pre-02-weak-ptr-atomic-and-memory-order.md) 的 acquire/release。咱们复用 pre-01 那个最小 `RefCounted`,给它补上 `RefCountedThreadSafe` 的原子计数语义,再往顶上搭 Flag。 + +```cpp +// Platform: host | C++ Standard: C++17 +#pragma once +#include +#include +#include + +namespace tamcpp::chrome::internal { + +// 跨序列安全的侵入式引用计数基类(简化版 RefCountedThreadSafe) +class RefCountedThreadSafe { +public: + void add_ref() const noexcept { + ref_count_.fetch_add(1, std::memory_order_relaxed); + } + bool release() const noexcept { + if (ref_count_.fetch_sub(1, std::memory_order_acq_rel) == 1) { + return true; // 调用方负责 delete this + } + return false; + } + bool has_one_ref() const noexcept { + return ref_count_.load(std::memory_order_acquire) == 1; + } +protected: + RefCountedThreadSafe() = default; + ~RefCountedThreadSafe() = default; +private: + mutable std::atomic ref_count_{0}; +}; + +// 对应 Chromium 的 base::AtomicFlag:一次性、release/acquire 的布尔标志 +class AtomicFlag { +public: + void Set() noexcept { + flag_.store(1, std::memory_order_release); + } + bool IsSet() const noexcept { + return flag_.load(std::memory_order_acquire) != 0; + } +private: + std::atomic flag_{0}; +}; + +} // namespace tamcpp::chrome::internal +``` + +这两个零件齐了,`Flag` 就很薄了: + +```cpp +// Platform: host | C++ Standard: C++17 +namespace tamcpp::chrome::internal { + +class Flag : public RefCountedThreadSafe { +public: + Flag() = default; + + // 失效:release-store 把"对象进入失效态"这条消息连同之前的写一起发布 + void Invalidate() noexcept { + // 教学版省略序列检查;Chromium 在此 DCHECK(seq || HasOneRef()) + invalidated_.Set(); + } + + // 判活(同序列契约):acquire-load + bool IsValid() const noexcept { + return !invalidated_.IsSet(); + } + + // 判活(跨序列 hint):同样是 acquire-load,但调用方自行承担正面结果不可信 + bool MaybeValid() const noexcept { + return !invalidated_.IsSet(); + } + +private: + template friend class scoped_refptr; // 允许计数归零时 delete + ~Flag() = default; // private:外部不能直接 delete + AtomicFlag invalidated_; +}; + +} // namespace tamcpp::chrome::internal +``` + +这段代码有几处得单独点一下。`Flag` 继承 `RefCountedThreadSafe`,原子引用计数是白来的;析构函数刻意写成 `private`,把 delete 的口子收紧到 `release` 路径和友元手里——这正是 [前置知识(一)](./pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md) 末尾那招"堵住外部直接 delete",免得有人手贱。 + +`Invalidate` 配 `IsValid`,一个 release-store 一个 acquire-load,这对搭档咱们在 [前置知识(二)](./pre-02-weak-ptr-atomic-and-memory-order.md) 里推过它的 happens-before:只要读到"失效",在那之前对象的所有写都跑不掉。教学版把序列检查掐了(Chromium 在 `Invalidate` 里挂 `DCHECK(seq.CalledOnValidSequence() || HasOneRef())`、`IsValid` 里挂 `DCHECK_CALLED_ON_VALID_SEQUENCE`),留给 02-4 讲 lazy 绑定时再补;但 acquire/release 这层核心语义,这里一个字都没打折扣。 + +--- + +## 第二层:WeakReference —— 对 Flag 的引用包装 + +往上一层是 `WeakReference`。说白了它就是 `scoped_refptr` 套了层壳,做的事不多:捏着一份指向 Flag 的引用计数句柄,把 `IsValid`/`MaybeValid`/`Reset` 三个动作原样转发过去。 + +```cpp +// Platform: host | C++ Standard: C++17 +namespace tamcpp::chrome::internal { + +// 简化版 scoped_refptr(见 pre-01 完整版) +template +class scoped_refptr { +public: + scoped_refptr() noexcept = default; + explicit scoped_refptr(T* p) noexcept : ptr_(p) { if (ptr_) ptr_->add_ref(); } + scoped_refptr(const scoped_refptr& o) noexcept : ptr_(o.ptr_) { if (ptr_) ptr_->add_ref(); } + scoped_refptr(scoped_refptr&& o) noexcept : ptr_(o.ptr_) { o.ptr_ = nullptr; } + ~scoped_refptr() { if (ptr_ && ptr_->release()) delete ptr_; } + scoped_refptr& operator=(scoped_refptr r) noexcept { T* t = ptr_; ptr_ = r.ptr_; r.ptr_ = t; return *this; } + T* get() const noexcept { return ptr_; } + explicit operator bool() const noexcept { return ptr_ != nullptr; } +private: + T* ptr_ = nullptr; +}; + +class WeakReference { +public: + WeakReference() = default; + explicit WeakReference(const scoped_refptr& flag) : flag_(flag) {} + + bool IsValid() const noexcept { return flag_ && flag_->IsValid(); } + bool MaybeValid() const noexcept { return flag_ && flag_->MaybeValid(); } + void Reset() noexcept { flag_ = nullptr; } + +private: + scoped_refptr flag_; +}; + +} // namespace tamcpp::chrome::internal +``` + +`flag_` 捏的是一份引用计数句柄,多个 WeakReference 共享同一枚 Flag 不用担心打架。Flag 一旦构造出来,它的身份(也就是 `flag_` 到底指哪枚)就再也不动了;唯一会变的是 `AtomicFlag invalidated_` 那一位,而 `Set()` / `IsSet()` 本身就是线程安全的原子操作,跨序列读写压根不用额外加锁。(真身 Chromium 在 `weak_ptr.h:153` 这里用的是 `scoped_refptr`,想从类型层面就把"Flag 身份不可变"喊出来;咱们教学版省了这层 const,配套的 `weak_ptr.hpp` 跟正文保持一致。) + +`Reset()` 把 `flag_` 置空,`IsValid()` 和 `MaybeValid()` 立刻都翻成 false,等于主动松手。 + +--- + +## 第三层:WeakPtr\ —— 用户句柄 + +到了顶层 `WeakPtr`,它在 `WeakReference` 基础上再补一个 `T*`。这根指针的语义有点反直觉:对象活着它指向对象,对象一析构,它允许悬垂——明晃晃地挂着,但不许碰。[前置知识(五)](./pre-05-weak-ptr-template-friend-and-uintptr-t.md) 解释过这里为什么刻意不用 `raw_ptr`,允许悬垂本身就是设计的一部分,守门的事交给 `WeakReference`。 + +```cpp +// Platform: host | C++ Standard: C++20 +#include + +namespace tamcpp::chrome { + +template class WeakPtrFactory; // 前向声明 + +template +class [[clang::trivial_abi]] WeakPtr { +public: + WeakPtr() = default; + WeakPtr(std::nullptr_t) noexcept {} // NOLINT(google-explicit-constructor) + + // 向上转型转换构造(见 pre-04) + template + requires(std::convertible_to) + WeakPtr(const WeakPtr& other) noexcept + : ref_(other.ref_), ptr_(other.ptr_) {} + + template + requires(std::convertible_to) + WeakPtr(WeakPtr&& other) noexcept + : ref_(std::move(other.ref_)), ptr_(other.ptr_) {} + + // 判活 + 解引用的两种姿态 + T* get() const noexcept { return ref_.IsValid() ? ptr_ : nullptr; } + + T& operator*() const { assert(ref_.IsValid()); return *ptr_; } // 教学版用 assert;Chromium 用 CHECK + T* operator->() const { assert(ref_.IsValid()); return ptr_; } + + explicit operator bool() const noexcept { return get() != nullptr; } + + void reset() noexcept { + ref_.Reset(); + ptr_ = nullptr; + } + + bool maybe_valid() const noexcept { return ref_.MaybeValid(); } + bool was_invalidated() const noexcept { return ptr_ && !ref_.IsValid(); } + +private: + template friend class WeakPtr; + friend class WeakPtrFactory; + + // 只有 factory 能调:铸币时用 + WeakPtr(internal::WeakReference&& ref, T* ptr) noexcept + : ref_(std::move(ref)), ptr_(ptr) { + assert(ptr); + } + + internal::WeakReference ref_; + T* ptr_ = nullptr; // RAW_PTR_EXCLUSION:允许悬垂,deref 前由 ref_ 守门 +}; + +} // namespace tamcpp::chrome +``` + +这段代码每一处都不是随手写的,都能对回前面某篇前置知识。咱们挨个捋。 + +类顶上那个 `[[clang::trivial_abi]]` 来自 pre-06,作用是让这个明明有非平凡析构的类型按平凡类型那样走寄存器传参。安全前提 pre-06 论证过:`ptr_` 是裸指针本来就平凡,`ref_` 里的 `scoped_refptr` 平凡还能重定位,两边都满足,标了才不翻车。 + +转换构造上挂的 `requires`(pre-04 的产物)把关转型方向:`WeakPtr` 能往 `WeakPtr` 转,反向和无关类型在编译期就挡掉。紧跟着那个 `template friend class WeakPtr`(pre-05)不是装饰,转换构造要读 `other.ref_` / `other.ptr_`,没这条友元声明根本够不着。 + +`operator*` 和 `operator->` 教学版用的是 `assert`,debug 抓一下;Chromium 真身是 `CHECK`,release 也照崩不误。失效了还硬上解引用,这是铁定的逻辑错误,生产环境必须当场爆出来;咱们到 02-6 会拿一个宏切 release 行为,先记着。 + +最后那对私有构造加 `friend WeakPtrFactory`,是给 factory 留的铸币口。factory 通过它能直接往 `ref_` 和 `ptr_` 里写值,外部谁都摸不到——保证了"只有 factory 能造 WeakPtr"这条契约。 + +### get() 的守门链 + +最关键的一行是 `get()`: + +```cpp +T* get() const noexcept { return ref_.IsValid() ? ptr_ : nullptr; } +``` + +把它展开,整条守门链是: + +```text +get() → ref_.IsValid() → (flag_ && flag_->IsValid()) → !invalidated_.IsSet() + ↑ acquire-load +``` + +一次 `get()` 调用,底下其实就一次原子 acquire-load。读到"未失效",返回 `ptr_`,调用方拿去放心 deref;读到"已失效",老老实实交回 `nullptr`。WeakPtr 全部安全性都拴在这道门上,所有解引用都得、也只能经过 `get()`。`operator*` / `operator->` 看上去直接对 `ptr_` 动手,其实进去之前都先 `CHECK` 了 `ref_.IsValid()`,等价于确认过 `get()` 不会返回空。 + +--- + +## 串起来:一个最小的可用例子 + +factory 还没写(那是下一篇的活),但咱们可以手工拼一个 Flag + WeakReference,先验证三层能跑通。下面这段是讲解用伪代码,它直接调 `WeakPtr` 的私有构造——正常情况只有 `WeakPtrFactory` 通过友元才够得着;真实可编译的版本在配套的 `code/.../chrome_design/16_weak_ptr_skeleton.cpp`,那里走的是 factory 铸币的正路。 + +```cpp +// Platform: host | C++ Standard: C++20 +#include + +struct Foo { int x = 42; }; + +int main() { + using namespace tamcpp::chrome; + using namespace tamcpp::chrome::internal; + + Foo foo; + + // 手工拼一个 Flag + WeakReference(模拟 factory 铸币,02-3 会封装) + auto* flag = new Flag(); + scoped_refptr flag_ref(flag); // ref_count = 1 + WeakReference ref(flag_ref); // ref_count = 2 + WeakPtr wp(std::move(ref), &foo); // 持 ref + &foo + + std::cout << (wp ? "alive" : "dead") << '\n'; // alive + std::cout << wp->x << '\n'; // 42 + + flag->Invalidate(); // 模拟对象析构前的作废 + std::cout << (wp ? "alive" : "dead") << '\n'; // dead + std::cout << wp.get() << '\n'; // 0(nullptr) + + return 0; +} +``` + +跑一下,终端会吐出 `alive` / `42` / `dead` / `0`。`Invalidate` 一调,`wp` 的 `operator bool`(背后走 `get()`)翻成 false,`get()` 老实交回 `nullptr`。[02-1](./02-1-weak-ptr-motivation-and-api-design.md) 当时许下的那条承诺——对象死了,回调拿到的是 nullptr 而不是悬垂指针——到这儿在代码里兑现了。 + +不过咱们这会儿的 Flag 还是手工拼的。谁来铸币、谁在对象析构那一刻负责喊 `Invalidate`?这就轮到 `WeakPtrFactory` 出场了。实现 factory,外加那条出了名的"最后成员"惯用法,下一篇咱们接着拆。 + +## 参考资源 + +- [Chromium `base/memory/weak_ptr.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [Chromium `base/memory/weak_ptr.cc`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.cc) +- [WeakPtr 前置知识(一):侵入式引用计数与 scoped_refptr](./pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md) +- [WeakPtr 前置知识(二):std::atomic 与 memory_order](./pre-02-weak-ptr-atomic-and-memory-order.md) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md new file mode 100644 index 000000000..4472b8ae7 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md @@ -0,0 +1,258 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 实现 WeakPtrFactory——铸币、InvalidateWeakPtrs/AndDoom 的差别,以及为什么它必须 + 是最后一个成员(析构逆序论证),含组合 vs 继承取舍 +difficulty: intermediate +order: 3 +platform: host +prerequisites: +- WeakPtr 实战(二):核心骨架与控制块 +- WeakPtr 前置知识(五):模板友元与 uintptr_t 类型擦除 +reading_time_minutes: 13 +related: +- WeakPtr 实战(四):序列亲和性与 lazy 绑定 +- WeakPtr 实战(一):动机与接口设计 +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- weak_ptr +- 内存管理 +title: "WeakPtr 实战(三):WeakPtrFactory 与「最后成员」惯用法" +--- +# WeakPtr 实战(三):WeakPtrFactory 与「最后成员」惯用法 + +[02-2](./02-2-weak-ptr-core-skeleton-and-control-block.md) 里咱们手搓了一枚 Flag 当铸币的原材料。但说真的,您总不能每次想发个 WeakPtr 都去 new 一个 Flag、自己盯着引用计数吧?那也太累了。Chromium 把这套琐碎活儿打包成了 `WeakPtrFactory`——挂在被观察对象身上的一个"铸币厂":您要 WeakPtr,它铸;对象该走了,它一次性把所有铸出去的 WeakPtr 全部作废。 + +这一篇咱们把 factory 撸出来,顺带啃掉它最出名的那条使用规矩——`WeakPtrFactory weak_factory_{this}` 得排成类的最后一个成员。这条惯用法看着像吹毛求疵,实际是用对用错的分界,笔者第一次见还纳闷至于这么讲究吗,后来真踩过坑才明白人家为什么特意写进头文件顶部 EXAMPLE。咱们用析构逆序把它彻底讲透。 + +## WeakPtrFactory 内部那枚 Flag + +factory 手里攥着的就一样东西:一枚 Flag,所有从它铸出去的 WeakPtr 共享同一枚。所以 factory 内部包了一个 `WeakReferenceOwner`——就是 Flag 的发行方兼持有方(`weak_ptr.cc:82-89`): + +```cpp +// 发行方:持有一枚 Flag,负责失效 +class WeakReferenceOwner { +public: + WeakReferenceOwner() : flag_(make_ref()) {} // 构造即铸一枚新 Flag + ~WeakReferenceOwner() { + if (flag_) flag_->Invalidate(); // 析构时作废所有 WeakPtr + } + WeakReference GetRef() const { return WeakReference(flag_); } // 铸币 + // ... +private: + scoped_refptr flag_; +}; +``` + +这段读起来很平实,但每一行都有戏。构造就铸一枚新 Flag;`GetRef()` 每次吐一个指向同一枚 Flag 的 `WeakReference`;析构那句 `flag_->Invalidate()` 才是真正埋的雷管——它让"factory 死了,所有 WeakPtr 立刻失效"成了 factory 析构函数的自动副作用,您压根不用记得去手动调 `invalidate`。笔者第一次读这儿的反应是:这设计真省心,把最容易忘的那一步塞进析构链里了。 + +`WeakPtrFactory` 在 `WeakReferenceOwner` 之上,只多加了一样东西——一个指向被观察对象的裸指针: + +```cpp +// Platform: host | C++ Standard: C++20 +namespace tamcpp::chrome { + +template +class WeakPtrFactory : public internal::WeakPtrFactoryBase { +public: + WeakPtrFactory() = delete; + explicit WeakPtrFactory(T* ptr) + : WeakPtrFactoryBase(reinterpret_cast(ptr)) {} + + WeakPtrFactory(const WeakPtrFactory&) = delete; + WeakPtrFactory& operator=(const WeakPtrFactory&) = delete; + + // 铸币:const factory 发 WeakPtr + WeakPtr get_weak_ptr() const { + return WeakPtr(weak_reference_owner_.GetRef(), + reinterpret_cast(ptr_)); + } + + // 非 const 重载:发 WeakPtr(pre-04 的 requires) + WeakPtr get_weak_ptr() + requires(!std::is_const_v) + { + return WeakPtr(weak_reference_owner_.GetRef(), + reinterpret_cast(ptr_)); + } + + // 主动批量失效(对象还活着,但想让所有 WeakPtr 失效) + void invalidate_weak_ptrs() { + assert(ptr_); + weak_reference_owner_.Invalidate(); // 失效旧 Flag + 铸一枚新 Flag + } + + void invalidate_weak_ptrs_and_doom() { + assert(ptr_); + weak_reference_owner_.InvalidateAndDoom(); // 失效 + 不再铸新 Flag + ptr_ = 0; + } + + bool has_weak_ptrs() const { return ptr_ && weak_reference_owner_.HasRefs(); } + +private: + internal::WeakReferenceOwner weak_reference_owner_; + // ptr_ 在非模板基类 WeakPtrFactoryBase 里,uintptr_t(prec 见 pre-05) +}; + +} // namespace tamcpp::chrome +``` + +代码里有几处笔者想专门点一下,都是 [pre-05](./pre-05-weak-ptr-template-friend-and-uintptr-t.md) 那篇讲的技巧。`reinterpret_cast(ptr)` 把 `T*` 存成整数,这样能下沉到非模板基类 `WeakPtrFactoryBase`,省得每个 `T` 都重新生成一份模板代码;`get_weak_ptr` 里再用 `reinterpret_cast(ptr_)` 转回来。还有那两个 `get_weak_ptr` 重载,用的是 [pre-04](./pre-04-weak-ptr-concepts-and-requires.md) 的成员函数 `requires(!std::is_const_v)`,const 正确性直接挂到签名上——const factory 出 `WeakPtr`,非 const 出 `WeakPtr`,编译期就分得清清楚楚。 + +## invalidate_weak_ptrs 和 invalidate_weak_ptrs_and_doom,差在哪 + +factory 提供了俩失效方法,光看名字您可能跟我当初一样一脸懵——这不都失效吗?差别就藏在"失效之后 factory 还能不能继续铸币"这事儿上。咱们对着 `WeakReferenceOwner` 的两份实现看(`weak_ptr.cc:103-113`): + +```cpp +void WeakReferenceOwner::Invalidate() { + assert(flag_); + flag_->Invalidate(); // 作废旧 Flag + flag_ = make_ref(); // 铸一枚新 Flag,factory 可继续铸币 +} + +void WeakReferenceOwner::InvalidateAndDoom() { + assert(flag_); + flag_->Invalidate(); // 作废旧 Flag + flag_.reset(); // 不再持新 Flag,factory 进入"已死"态 +} +``` + +差别就在作废旧 Flag 之后那一行。`invalidate_weak_ptrs()` 作废旧 Flag 后,紧接着 `flag_ = make_ref()` 又铸一枚新的——所有已有 WeakPtr 集体失效,但 factory 自己还喘着气,可以接着 `get_weak_ptr()` 铸新的,新铸出来的 WeakPtr 之间共享那枚新 Flag。这套适合"对象进入新阶段,旧观察者该清退了,但后面还要接新观察者"的场景。 + +`invalidate_weak_ptrs_and_doom()` 就狠一点,作废旧 Flag 之后不铸新的,还顺手把 `ptr_` 清零——factory 直接进入"已死"状态,之后您再调 `get_weak_ptr()` 拿到的就是无效结果。它比上一个更省事,连一次 Flag 分配都省了。顾名思义 doom,这是给"这对象彻底不再用了"的收尾场景准备的。 + +这俩的差别在 [02-6](./02-6-weak-ptr-sequence-affinity-and-lazy-binding.md) 性能对比里还会再露一次脸。不过说句实在话,日常九成的场景,您根本不会显式调这俩——光靠下面要讲的 factory 析构自动失效就够用了。 + +--- + +## 重头戏:「最后成员」惯用法 + +接下来这条是这一篇真正想讲的东西。Chromium 的 `weak_ptr.h` 顶部那段 EXAMPLE,专门把这条规矩写在最显眼的位置(`weak_ptr.h:22-26`): + +> Member variables should appear before the WeakPtrFactory, to ensure that any WeakPtrs to Controller are invalidated before its members variable's destructors are executed. + +翻译过来一句话:成员变量得声明在 `WeakPtrFactory` 前面,factory 放最后。笔者第一次读到这条时心里嘀咕——至于吗,成员顺序还能影响正确性?还真能。咱们一条一条拆。 + +### 先垫个底:C++ 的析构是逆序的 + +这是 C++ 一条基础规则,但容易在用 WeakPtr 时被忘掉:对象析构时,成员按声明顺序的**逆序**析构——先声明的后死,后声明的先死。所以如果 `WeakPtrFactory` 是最后一个声明的成员,它就**最先**被析构;反过来您要是把它放最前面,它就成了**最后**才析构的那个。记住这个"逆序",下面推论全靠它。 + +### 再垫一条:factory 析构 = 失效所有 WeakPtr + +前面 `WeakReferenceOwner::~WeakReferenceOwner()` 里那句 `flag_->Invalidate()` 是关键——factory 一析构,它铸出去的所有 WeakPtr 跟着作废。换句话说,"factory 什么时候死"直接决定了"所有 WeakPtr 什么时候失效"。 + +### 两条叠起来:为什么 factory 非得放最后 + +把上面这两条拼一块儿,推论自己就冒出来了。咱们假设 `Controller` 有几个普通成员外加一个 factory,然后故意把两种声明顺序都写一遍对比: + +```cpp +// ✗ 错误顺序:factory 放前面 +class BadController { +public: + void on_work_done() { /* 用 buf_ */ } +private: + WeakPtrFactory weak_factory_{this}; // 先声明 → 最后析构 + std::vector buf_; // 后声明 → 先析构 +}; + +// ✓ 正确顺序:factory 放最后 +class GoodController { +public: + void on_work_done() { /* 用 buf_ */ } +private: + std::vector buf_; // 先声明 → 最后析构 + WeakPtrFactory weak_factory_{this}; // 后声明 → 先析构 +}; +``` + +先看 `BadController` 这版反例。析构按逆序走:`buf_` 先死,然后才轮到 `weak_factory_`,这时候才想起来失效所有 WeakPtr。问题就出在这俩中间那个窗口——`buf_` 已经析构了,可所有 WeakPtr 还活蹦乱跳地"有效"着。偏偏这种时候要是有个异步任务拿着 WeakPtr 解引用 `Controller`,它顺顺当当进 `on_work_done()`,一头撞上已经析构的 `buf_`。UAF,稳稳的。笔者当年在类似场景里查过一个偶现的 ASAN 报红,根因就是这个顺序。 + +`GoodController` 就把顺序倒过来了:`weak_factory_` 最后声明,所以最先析构,一析构就把所有 WeakPtr 作废,然后 `buf_` 才轮到死。等到 `buf_` 真的开始析构时,外头已经没有任何"有效"的 WeakPtr 能碰到它了,后续解引用顶多拿到个 `nullptr`。安全。 + +「最后成员」惯用法,根子就这么一句话:让 factory 在其它成员之前先析构,借它析构时那一下自动失效,把其它成员的析构期给罩住。 + +### 一个容易踩的边界:守护的是成员析构期,不是析构函数体 + +这里有个细节笔者特意要讲清楚,因为它最容易让人误判——咱们研究核验的时候专门确认过这一条。factory 放最后,罩住的是**成员的析构期**;它**不**拦着您在对象自己的析构函数体里用 WeakPtr。咱们把时间线摊开看: + +```text +GoodController 析构: + ① 析构函数体执行(此时所有成员还活着,WeakPtr 仍有效) + ② 成员按逆序析构: + weak_factory_ 先析构 → 所有 WeakPtr 失效 ← 守门发生在这里 + buf_ 后析构 +``` + +也就是说,在 ① 析构函数体执行那会儿,WeakPtr 还有效。这其实是符合直觉的——析构体里您常常还想引用同对象的其它成员(比如通知观察者一句"我要走了"),这种时候 WeakPtr 有效反而顺手。真正作废发生在 ② 进入成员析构之后,目的就是确保后续再有任何 deref 都碰不到半销毁的成员。这条边界 Chromium 源码注释里没明写,笔者是读着代码自己推出来的,您记一下,免得日后以为"对象一进析构 WeakPtr 就立刻失效"而误判。 + +--- + +## 为什么 Chromium 只留了组合式这一条路 + +您翻遍 Chromium 现在的 `//base`,能拿到 WeakPtr 的官方姿势就一种:把 `WeakPtrFactory weak_factory_{this}` 当成员塞进 `Controller`。这就是「最后成员」惯用法的载体,Chromium 选它是有理由的——失效时机您说了算,能对非自身类型用(极端点,`WeakPtrFactory` 都行),也不污染继承链。 + +其实历史上还有过一种继承式的 `SupportsWeakPtr`,继承它就白得一个 `GetWeakPtr()`。听上去更省事对吧?但它把人往不安全的用法上带,后来 Chromium 索性从 `//base` 里挪走了,现在 `weak_ptr.h` 里 grep `SupportsWeakPtr` 已经搜不到。笔者提它一嘴不为别的,就怕您翻老代码、老文档时撞见这名字发懵。新代码一律走组合式,理解了组合式,那套老机制无非是把 factory 藏进基类,本质没差。 + +--- + +## 把 02-1 那个坑用 factory 重写一遍 + +光说不练假把式,咱们把 [02-1](./02-1-weak-ptr-motivation-and-api-design.md) 那个悬空回调的场景,用刚撸好的 factory 重写一遍,看它怎么把 UAF 给堵死的: + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include +#include + +class Controller { +public: + void on_work_done(int v) { + buf_.push_back(v); + std::cout << "got " << v << ", buf size=" << buf_.size() << '\n'; + } + WeakPtr get_weak() { return weak_factory_.get_weak_ptr(); } + + ~Controller() = default; +private: + std::vector buf_; // 先声明 + WeakPtrFactory weak_factory_{this}; // 最后成员! +}; + +int main() { + using namespace tamcpp::chrome; + WeakPtr wp; // 先声明,待会儿填 + + { + Controller c; + wp = c.get_weak(); + std::cout << (wp ? "alive" : "dead") << '\n'; // alive + if (wp) wp->on_work_done(7); // got 7, buf size=1 + } // c 离开作用域:weak_factory_ 先析构 → wp 失效 → buf_ 才析构 + + std::cout << (wp ? "alive" : "dead") << '\n'; // dead + if (wp) { + wp->on_work_done(8); // 不会进这里 + } else { + std::cout << "controller gone, skip\n"; // 走这条 + } + return 0; +} +``` + +咱们跑一下看看。输出会按顺序蹦出来:`alive` → `got 7, buf size=1` → `dead` → `controller gone, skip`。重点盯最后一行——`Controller` 析构之后,`wp` 已经自动失效,`if (wp)` 这道闸把对已析构对象的访问拦在了外面。02-1 那个让人头疼的悬空回调,到这儿被 factory 析构时的自动失效给彻底堵死了,您一行防护代码都不用写。 + +到这里 WeakPtr 的几样核心机制——铸币、失效、解引用守门——算是凑齐了。不过咱们还有一条使用契约一直绕着没讲:**WeakPtr 的解引用和失效,得发生在绑定时的同一个序列上**。这条契约值得正面掰扯清楚,顺带把 factory 的 lazy 序列绑定也讲一下,下一篇见。 + +## 参考资源 + +- [Chromium `base/memory/weak_ptr.h` —— WeakPtrFactory 与顶部 EXAMPLE](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [Chromium `base/memory/weak_ptr.cc` —— WeakReferenceOwner](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.cc) +- [WeakPtr 实战(二):核心骨架与控制块](./02-2-weak-ptr-core-skeleton-and-control-block.md) +- [WeakPtr 前置知识(五):模板友元与 uintptr_t 类型擦除](./pre-05-weak-ptr-template-friend-and-uintptr-t.md) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-4-weak-ptr-sequence-affinity-and-lazy-binding.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-4-weak-ptr-sequence-affinity-and-lazy-binding.md new file mode 100644 index 000000000..7f0d8f621 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-4-weak-ptr-sequence-affinity-and-lazy-binding.md @@ -0,0 +1,208 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 正面讲清 WeakPtr 的序列契约——deref/失效必须在绑定序列,以及 Flag 的 lazy 序列绑定 + 机制(release 下靠自律),把 IsValid 与 MaybeValid 的差别讲准 +difficulty: intermediate +order: 4 +platform: host +prerequisites: +- WeakPtr 实战(三):WeakPtrFactory 与"最后成员"惯用法 +- WeakPtr 前置知识(三):序列、SEQUENCE_CHECKER 与 DCHECK/CHECK +reading_time_minutes: 12 +related: +- WeakPtr 实战(五):与回调集成——关闭 OnceCallback 的环 +- WeakPtr 前置知识(二):std::atomic 与 memory_order +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- weak_ptr +- 并发 +title: "WeakPtr 实战(四):序列亲和性与 lazy 绑定" +--- +# WeakPtr 实战(四):序列亲和性与 lazy 绑定 + +前面几篇笔者带您把 WeakPtr 的骨架搭起来了——铸币、解引用、析构失效,功能上能跑。但有一根线咱们一直没碰,Chromium 在源码顶部专门拿大段注释把它拎出来:`WeakPtr` 可以跨序列传递,可解引用和失效**必须发生在绑定的那个序列上**。这根线笔者想在这一篇正面拆开,因为它下面挂着两件容易踩混的东西:一是 Flag 的 lazy 序列绑定,二是 `IsValid` 和 `MaybeValid` 这对长得像、语义完全不同的查询。弄懂这两件,WeakPtr 在多序列里怎么用才算真正落地。 + +--- + +## 序列契约:为什么 deref/失效要同序列 + +先把契约原话摆出来(对应 `weak_ptr.h:50-54`): + +> Weak pointers may be passed safely between sequences, but must always be dereferenced and invalidated on the same SequencedTaskRunner otherwise checking the pointer would be racey. + +意思一句话:弱指针能安全地跨序列传递,但解引用和失效得始终落在同一个 `SequencedTaskRunner` 上,不然"检查这枚指针"这个动作本身就是 race。 + +您可能会想,`invalidated_` 不是原子的吗,怎么会 race。原子操作本身确实不撕裂,问题出在"`get()` 返回非空 → 调用方拿着 `T*` 去访问"这一段窗口上。想象序列 A 持有 `WeakPtr`,`get()` 刚读到一次"有效",正准备 deref;序列 B 这时调 `Invalidate()`,紧接着 owner 把对象析构了。A 手里那个看似还行的 `T*`,真访问下去踩的可能是半成品、甚至已经被回收的内存。原子性只保证读写不撕裂,保证不了 deref 的窗口里对象没人动它。最干脆的约束就是——让 deref 和失效串在同一个序列上跑,窗口压根不存在。 + +至于跨序列传递,那是允许的。您把一个 `WeakPtr` 从序列 A handed off 到序列 B(比如丢进线程池,再 post 一条回 A 的任务),传递本身只是搬数据,不碰 Flag;只有到了 B 想真正"用"它(deref)或"作废"它的时候,才踩进契约的地界。 + +--- + +## lazy 绑定:Flag 不在构造时绑定序列 + +契约立好了,下一个躲不开的问题——Flag 怎么知道"绑定序列"到底是哪一个?Chromium 的做法是:它根本不在构造时绑,而是等到第一次被人"触碰"才落定。这叫 lazy 绑定。 + +咱们看 `Flag` 的构造函数,它就干一件事——`DETACH_FROM_SEQUENCE`(`weak_ptr.cc:15-20`): + +```cpp +WeakReference::Flag::Flag() { + // Flags only become bound when checked for validity, or invalidated, + // so that we can check that later validity/invalidation operations + // on the same Flag take place on the same sequenced thread. + DETACH_FROM_SEQUENCE(sequence_checker_); +} +``` + +`DETACH_FROM_SEQUENCE` 的意思是"我现在还没绑任何序列,先别检查"。这会儿 Flag 处于未绑定态。等第一次有人调 `IsValid` 或 `Invalidate` 触碰它,`DCHECK_CALLED_ON_VALID_SEQUENCE` 才把当前序列记下来,从此 Flag 就认准了它,后续所有 `IsValid`/`Invalidate` 都得在这个序列上跑: + +```cpp +bool WeakReference::Flag::IsValid() const { + DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); // 首次触碰 → 绑定;之后 → 校验 + return !invalidated_.IsSet(); +} +``` + +为什么非得 lazy 不可?因为 Chromium 里头大量对象是"在序列 A 上构造,却要在序列 B 上用",构造那一刻您压根不知道它最终落在哪条序列上跑。要是 Flag 一构造就绑死构造序列,这堆对象直接没法用了。lazy 绑定让"构造时不表态、第一次真用的时候才认序列"这条路走通,使用门槛一下就降下来了。 + +这里头还藏着一个更细的口子。如果一枚 Flag 当前没有任何 WeakPtr 持有(`!HasRefs()`),`WeakReferenceOwner::GetRef` 会把它重新 detach(`weak_ptr.cc:91-101`): + +```cpp +WeakReference WeakReferenceOwner::GetRef() const { +#if DCHECK_IS_ON() + DCHECK(flag_); + if (!HasRefs()) { + flag_->DetachFromSequence(); // 没人持有 → 解绑,下次可绑到别的序列 + } +#endif + return WeakReference(flag_); +} +``` + +这段只在 `DCHECK_IS_ON()` 下生效,但它给"factory 的所有 WeakPtr 都没了之后,factory 换个序列接着用"开了门——下次铸出来的 WeakPtr 第一次触碰,会重新绑到新序列。源码注释专门点了这层意思(`weak_ptr.h:63-65`):所有 WeakPtr 一旦销毁或失效,factory 就从序列上 unbound,可以在别的序列销毁,也可以重新铸 WeakPtr。 + +--- + +## release 下:零开销 + 靠自律 + +这里要跟 [前置知识(三)](./pre-03-weak-ptr-sequence-checker-dcheck-check.md) 那条关键事实对一下:lazy 绑定加序列检查的全部逻辑,都裹在 `DCHECK_IS_ON()` 里,release 构建直接编译成空。 + +换句话说,release 版的 WeakPtr 一点运行时序列检查都没有。序列契约在 release 里全靠开发者自律,您违反了它程序不会当场崩,只会在某个不凑巧的时机冒出一个 race。所以 debug 构建下认真对待每一条 DCHECK 才这么要紧——那是您抓序列违规几乎唯一的机会。 + +--- + +## IsValid vs MaybeValid:同序列准 vs 跨序列 hint + +lazy 绑定把"序列契约的检查"全压在 `IsValid` 身上,可 WeakPtr 还甩出另一个查询 `MaybeValid`。这俩长得像,语义却差得远,不分清很容易踩坑。 + +先看 `IsValid`,它会做序列断言——您必须在绑定的序列上调它,返回值百分百准:true 就是真的还有效,false 就是真的失效了。`WeakPtr::get()` 和 `operator bool` 走的就是这条路,所以"判活后 deref"才是安全的。还有一层副作用:正是这次 `IsValid` 调用会触发 lazy 绑定(如果之前没绑过)。 + +```cpp +bool WeakReference::Flag::IsValid() const { + DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); // 同序列契约 + return !invalidated_.IsSet(); +} +``` + +`MaybeValid` 不一样,它没有任何序列断言,任意序列都能调。Chromium 源码注释把它的边界说得很直白(`weak_ptr.h:266-283`): + +> Returns false if the WeakReference is confirmed to be invalid. This call is safe to make from any thread, e.g. to optimize away unnecessary work, but `RefIsValid()` must always be called, on the correct sequence, before actually using the pointer. + +它返回值的不对称得记牢。返回 false 是可信的——acquire 读到了 release 写下去的失效位,板上钉钉已经失效。可返回 true 不能全信,只算"也许"还有效:保不齐您刚读到 true,在 deref 之前,绑定序列那边已经把它失效了。所以 `MaybeValid` 真正合适的用途只有一个——从别的序列做一个投机性的"能不能跳过"判断。比如 message loop 派发任务前拿它瞄一眼,返回 false 就知道这任务铁定没意义,直接跳,省一次跨序列投递。但真要动指针,必须回到绑定序列,用 `IsValid` 再过一遍。拿正面结果当 deref 的通行证,迟早翻车。 + +```cpp +bool WeakReference::Flag::MaybeValid() const { + return !invalidated_.IsSet(); // 无序列断言,任意序列可调 +} +``` + +对照表收一下,方便您回头查: + +| 查询 | 序列约束 | 触发 lazy 绑定 | 结果可信度 | +|---|---|---|---| +| `IsValid()` | 必须绑定序列 | 是 | 100% 准确 | +| `MaybeValid()` | 任意序列 | 否 | 负面可信 / 正面不可信 | + +这层差别到了下一篇(02-5,BindOnce 集成)会派上大用场——您会看到 Chromium 的回调取消走的是 `IsValid`(同序列准),而 `MaybeValid` 是另一条独立的"调度器投机查询"通道。 + +--- + +## 把序列检查加进教学版 + +接下来咱们给 02-2 那个 `Flag` 把序列检查补上。教学版用一个简化的 `SequenceChecker`,debug 下记录线程 id: + +```cpp +// Platform: host | C++ Standard: C++17 (debug-only 检查) +#if defined(NDEBUG) +// release:全部 no-op,零字节、零开销 +class SequenceChecker { +public: + void detach_from_sequence() noexcept {} + bool called_on_valid_sequence() const noexcept { return true; } +}; +#else +#include +// debug:记录绑定线程,违规即 abort +class SequenceChecker { +public: + void detach_from_sequence() noexcept { bound_thread_ = std::thread::id{}; } + bool called_on_valid_sequence() const noexcept { + if (bound_thread_ == std::thread::id{}) { + bound_thread_ = std::this_thread::get_id(); // lazy 绑定 + return true; + } + return bound_thread_ == std::this_thread::get_id(); + } +private: + mutable std::thread::id bound_thread_; +}; +#endif +``` + +您可以对照着看它怎么映上 Chromium 的三宏:`detach_from_sequence` 对 `DETACH_FROM_SEQUENCE`,`called_on_valid_sequence` 对 `DCHECK_CALLED_ON_VALID_SEQUENCE`,release 下全是 no-op。教学版拿线程 id 模拟序列,真实 Chromium 用的是更细的 `SequenceToken`,但 lazy 绑定的形态是一致的。 + +然后 `Flag` 把它接上: + +```cpp +class Flag : public RefCountedThreadSafe { +public: + Flag() { seq_.detach_from_sequence(); } // 构造:未绑定 + + void Invalidate() noexcept { + // DCHECK:同序列,或只剩自己一个引用(可跨线程析构) + assert(seq_.called_on_valid_sequence() || has_one_ref()); + invalidated_.Set(); + } + bool IsValid() const noexcept { + assert(seq_.called_on_valid_sequence()); // 首次触碰 → 绑定 + return !invalidated_.IsSet(); + } + bool MaybeValid() const noexcept { + return !invalidated_.IsSet(); // 不碰 seq_,不绑定 + } +private: + // ... + mutable SequenceChecker seq_; + AtomicFlag invalidated_; +}; +``` + +这么一接,debug 下您要在错误的线程上调 `IsValid`,assert 就会把序列违规抓出来;到了 release,这些 assert 连同 `SequenceChecker` 全部消失,零开销。 + +--- + +这一篇把序列契约正面拆完了:弱指针能跨序列传递,可 deref 和失效得落在绑定的同一序列,否则检查本身就是 race;Flag 的 lazy 绑定——构造时 detach、首次触碰才绑、没引用了能 unbound 重用——让"构造时不指定序列"走得通,配 Chromium 一堆"一处构造、另处使用"的对象;这整套检查都裹在 `DCHECK_IS_ON()` 里,release 零开销,契约全靠自律;`IsValid`(同序列、准、触发绑定)和 `MaybeValid`(任意序列、乐观、负面可信正面不可信)这对查询非分清不可,前者是 deref 前的硬门,后者只是调度器投机跳过的 hint。 + +整个系列真正的"眼"在下一篇——咱们把 WeakPtr 接到回调系统里,看 `BindOnce` 怎么拿 `IsValid` 实现对象死后回调自动变 no-op,把 01-4 那个手搓取消令牌留下的尾巴彻底收掉。 + +## 参考资源 + +- [Chromium `base/memory/weak_ptr.h` —— 顶部 Thread-safety 注释(50-69 行)](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [Chromium `base/memory/weak_ptr.cc` —— Flag/WeakReferenceOwner](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.cc) +- [WeakPtr 前置知识(三):序列、SEQUENCE_CHECKER 与 DCHECK/CHECK](./pre-03-weak-ptr-sequence-checker-dcheck-check.md) +- [Chromium `base/sequence_checker.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/sequence_checker.h) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-5-weak-ptr-bind-integration.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-5-weak-ptr-bind-integration.md new file mode 100644 index 000000000..94cb24c40 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-5-weak-ptr-bind-integration.md @@ -0,0 +1,294 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 系列之眼——把 WeakPtr 接进回调系统。拆解 BindOnce 检测 WeakPtr 的编译期接线(kIsWeakMethod) + 与调用期分派(InvokeHelper 的 if(!target) return;),并回扣 01-4 的取消令牌 +difficulty: intermediate +order: 5 +platform: host +prerequisites: +- WeakPtr 实战(四):序列亲和性与 lazy 绑定 +- OnceCallback 实战(四):取消令牌设计 +- OnceCallback 实战(一):动机与接口设计 +reading_time_minutes: 15 +related: +- WeakPtr 实战(六):测试与性能对比 +- WeakPtr 实战(一):动机与接口设计 +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- weak_ptr +- 回调机制 +- 函数对象 +title: "WeakPtr 实战(五):与回调集成——关闭 OnceCallback 的环" +--- +# WeakPtr 实战(五):与回调集成——关闭 OnceCallback 的环 + +咱们总算走到整个系列的"眼"了。还记得 [01-4 取消令牌设计](../../01_once_callback/full/01-4-once-callback-cancellation-token.md) 开篇那个悬空回调吗——任务还在队列里排着,对象先一步析构了,回调跑起来解引用一个空壳。当时笔者图省事,甩了个原子标志加上调用前的 if-check 把它压下去了,可留了个尾巴:那个标志到底怎么递到回调手里、它的命谁来管,笔者那会儿其实没认真想。 + +现在手头有了完整的 `WeakPtr`,可以把它正经接进回调系统了。咱们这一篇就看 Chromium 怎么用 `BindOnce`,让一个绑了 `WeakPtr` 的回调在对象死后**自动**哑掉,变成 no-op。您会发现 01-4 那个笔者手搓的土办法,在工业级实现里几乎是逐行对应——只是多裹了两层工程外衣:类型擦除,以及调度器那点投机的小心思。 + +--- + +## 工业级答案:BindOnce + WeakPtr + +Chromium 里真正的写法,不是 `if (wp) wp->...`,而是把 `WeakPtr` 直接绑进回调: + +```cpp +// 这条任务在 controller 死后会自动静默丢弃,不会悬空解引用 +thread_pool.post( + base::BindOnce(&Controller::on_work_done, + controller.weak_factory_.GetWeakPtr())); +``` + +从外面看,这就是一次普通的 `BindOnce`——成员方法搭一个参数(WeakPtr),打包成可调用对象。可 `BindOnce` 在**编译期**就嗅出了"receiver 是个 WeakPtr",于是悄悄给它挑了一条特殊的分派路径:执行前先 null-check 弱指针,失效就 `return`,什么都不干;没失效才真正调方法。 + +这条"特殊路径"分两段——编译期接线和调用期分派——咱们各拆一段。 + +--- + +## 编译期接线:kIsWeakMethod / IsWeakReceiver + +`BindOnce` 的类型擦除机制([01-1 讲过](../../01_once_callback/full/01-1-once-callback-motivation-and-api-design.md)的 `BindState` 那套)在编译期就得拿定主意:"这个绑定要不要走 weak 分支?" 判定靠一个常量 `kIsWeakMethod`(`bind_internal.h:436-448`): + +```cpp +template +inline constexpr bool kIsWeakMethod = false; + +template +inline constexpr bool kIsWeakMethod = IsWeakReceiver::value; +``` + +它有两个条件,凑齐了才为真:① `is_method`——绑定的是**成员方法**,不是自由函数;② `IsWeakReceiver::value`——receiver 的类型 `T` 是个 `WeakPtr`。`IsWeakReceiver` 的定义直白得很(`bind_internal.h:1925-1926`): + +```cpp +template +struct IsWeakReceiver : std::bool_constant> {}; +``` + +说白了就是"`T` 是不是 `WeakPtr` 模板的某个实例化"。 + +触发条件故意收得很窄,这一点笔者第一次读时还愣了一下:非得是"成员方法 + 第一参数(receiver)是 `WeakPtr`"才行。您要是把 `WeakPtr` 当个普通绑定参数(不是 receiver)塞进去,或者递给一个自由函数,**不会**触发 weak 分支——那时候 WeakPtr 就是个被拷来拷去的值,没那个自动 no-op 的待遇。这条件窄得合理:只有当 WeakPtr 摆明了是"方法的接收者","对象死了就别调"这句话才说得通。 + +--- + +## 调用期分派:InvokeHelper\::MakeItSo + +`kIsWeakMethod` 一旦在编译期为真,就会选中一个特化版的 `InvokeHelper`——它就是 weak 调用的执行器。核心代码短得吓人(`bind_internal.h:939-961`),咱们整段贴出来: + +```cpp +template +struct InvokeHelper { + template + static inline void MakeItSo(Functor&& functor, BoundArgsTuple&& bound, + RunArgs&&... args) { + static_assert(index_target == 0); + // 注意:weak pointer 的有效性必须在 Unwrap 之后再测, + // 否则对允许跨线程、在 Unwrap() 里执行 Lock() 的弱指针实现会造成 race。 + const auto& target = Unwrap(std::get<0>(bound)); + if (!target) { // ← 取消点:对象死后这里 return,回调静默 no-op + return; + } + Traits::Invoke( + Unwrap(std::forward(functor)), target, + Unwrap(std::get(std::forward(bound)))..., + std::forward(args)...); + } +}; +``` + +取消的全部秘密,就藏在 `if (!target) return;` 那一行。`target` 是 `Unwrap(std::get<0>(bound))` 解出来的 receiver——对原生 `WeakPtr` 来说,`Unwrap` 是透传(主模板),所以 `target` 的类型原封不动就是 `WeakPtr`。 + +接下来 `if (!target)` 走的是 `WeakPtr::operator bool`(`weak_ptr.h:255`),它调 `get()`,而 `get()` 内部那句 `ref_.IsValid() ? ptr_ : nullptr`(`weak_ptr.h:238`)才是真正拍板的地方。一层层展开看清楚: + +```text +if (!target) + → target.operator bool() + → target.get() + → target.ref_.IsValid() + → flag_ && flag_->IsValid() ← DCHECK 同序列 + acquire-load +``` + +所以 weak 调用的取消检查,走到底就是咱们 [02-4](./02-4-weak-ptr-sequence-affinity-and-lazy-binding.md) 讲过的那个同序列、100% 准确的 `IsValid`。对象一死,`get()` 交还 `nullptr`,`operator bool` 翻成 false,`MakeItSo` 当场 `return`——回调静默 no-op,那个悬垂指针连碰都没人碰一下。 + +--- + +## 关键:检查走 IsValid,不是 MaybeValid + +这里有个极易写错、而且不少二手资料偏偏就写错的点:weak 调用的取消检查走的是 `IsValid`(同序列、准确),不是 `MaybeValid`。咱们 [02-4](./02-4-weak-ptr-sequence-affinity-and-lazy-binding.md) 已经把这两个分过界了——`IsValid` 是 deref 前那道硬门,`MaybeValid` 不过是跨序列时一个乐观的 hint。`MakeItSo` 里那句 `!target`,经 `operator bool`、`get()` 一路落到 `IsValid`,是同序列上的确定性判断,能担保"判活通过 ⇒ 此刻对象真的还喘着气,可以调"。 + +那 `MaybeValid` 干嘛去了?它在 weak 调用里走的是**另一条独立的通道**,压根不插手 no-op 的判定。Chromium 的 `CallbackCancellationTraits` 专门给 weak receiver 留了一份特化(`bind_internal.h:1985-2006`),把"取消查询"一劈两半: + +```cpp +template + requires internal::kIsWeakMethod<...> +struct CallbackCancellationTraits> { + static constexpr bool is_cancellable = true; + + template + static bool IsCancelled(const Functor&, const Receiver& receiver, const Args&...) { + return !receiver; // 同序列、走 IsValid,准确 + } + + template + static bool MaybeValid(const Functor&, const Receiver& receiver, const Args&...) { + return MaybeValidTraits::MaybeValid(receiver); // 跨序列、走 WeakPtr::MaybeValid + } +}; +``` + +这两路各伺候各的主子。`IsCancelled(!receiver)` 是给 `Callback::IsCancelled()` 用的,在绑定序列上查,结果板上钉钉;`MaybeValid(receiver.MaybeValid())` 则是给 `Callback::MaybeValid()` 用的,任意序列都能查,代价是结果只能当乐观估计看。 + +`MaybeValid` 这条线真正伺候的是**调度器/message loop**:任务派发之前,调度器大可以从任意序列投机地探一耳朵 `MaybeValid`,要是返回 false,它心里就有数了——这回调铁定没意义,直接跳过,顺手省一次跨序列投递的开销。但等到回调**真正执行**那一刻,取消判定走的是 `MakeItSo` 里那行 `!target`(也就是 `IsValid`)——这条线是硬门,准。 + +一句话收住:取消判定有两条路,执行期走 `IsValid`(准),调度器投机走 `MaybeValid`(乐观),执行期那条永远不沾 `MaybeValid` 的边。您把这条界线划清,就比市面上绝大多数二手资料都准了。 + +--- + +## 弱调用强制 void 返回 + +weak 分支还藏着一个小约束,在 `MakeItSo` 的返回类型里:它返的是 `void`。这不是凑巧,是 `WeakCallReturnsVoid` 这个 `static_assert` 拍下来的(`bind_internal.h:1028-1040`): + +```cpp +if constexpr (WeakCallReturnsVoid::value) { + // 走 InvokeHelper::MakeItSo +} +``` + +道理想想就通:回调一旦被取消,执行的是 `return;`(没值),可方法本身要是带返回值呢?取消那一刻您拿什么返?所以 weak 调用**必须返回 `void`**。您写一句 `BindOnce(&Foo::get_value, weak_ptr)`(`get_value` 返 int),编译期直接给您拒了。这是典型的"把歧义在类型层面就堵死"——取消语义要求 void,那签名就强制 void,谁也别含糊。 + +--- + +## "先 Unwrap 再判活"的 race 防御 + +回头再看 `MakeItSo` 那段,有处注释值得单独拎出来(`bind_internal.h:949-951`): + +> Note the validity of the weak pointer should be tested _after_ it is unwrapped, otherwise it creates a race for weak pointer implementations that allow cross-thread usage and perform `Lock()` in `Unwrap()` traits. + +意思是:`target = Unwrap(...)` 这步,非得排在 `if (!target)` **前面**不可。为啥?有些弱指针变体是允许跨线程用的,而且 `Unwrap()` 里头会 `Lock()`(Chromium 内部就有比 `WeakPtr` 更花哨的弱指针实现)。对这种实现,您要是先判 bool、再 Unwrap,两步之间就开了一道 race 的缝——判活那一下对象还在,等到 Unwrap 的时候它可能已经没了。先 Unwrap(把真指针稳稳取出来)、再判活,这道缝就合上了。 + +咱们这位 `WeakPtr` 自己的 `Unwrap` 是透传、不 Lock,所以对它来说这两步谁先谁后都无所谓;但 `MakeItSo` 是个通用模板,得照顾那些更一般的弱指针实现,所以注释里特意把这条 race 防御写进了契约。Chromium 的 `bind_unittest.cc` 里还专门养了个 `MockRacyWeakPtr`(`operator bool()` 恒 true、`Lock()` 恒 nullptr),就是为了验这条"先 Unwrap 再判活"的路径。 + +--- + +## vs Unretained(this):事后安全 no-op vs 事后报警 UAF + +讲到这儿,顺手拎出另一个老被搞混的写法比一比:`base::Unretained(this)`。它也把成员方法绑成回调,可走的却是**完全相反**的路径——`InvokeHelper`,压根不判活: + +```cpp +template +struct InvokeHelper { + template + static inline ReturnType MakeItSo(Functor&& functor, BoundArgsTuple&& bound, + RunArgs&&... args) { + return Traits::Invoke( + Unwrap(std::forward(functor)), + Unwrap(std::get(std::forward(bound)))..., + std::forward(args)...); + // 没有 if (!target) return; —— 对象死了还 Run() 就是悬垂解引用 + } +}; +``` + +`Unretained` 的 receiver 被解包成裸 `T*`,判活那层直接省了。对象死了还跑回调,就是 UAF;它仅剩的防线,是 Chromium 内存安全加固那套 `raw_ptr` 的 PartitionAlloc backup-ref——可那玩意儿是事后报错,不是事前躲开。 + +根本差别,一句话: + +> **`WeakPtr` receiver = 对象死后回调静默 no-op(事前安全);`Unretained` receiver = 对象死后 UAF(事后报警或 UB)。** + +生产代码里,但凡有一丝"对象可能在回调执行前析构"的可能,一律上 `WeakPtr`。只有您**能静态拍胸脯**保证对象绝对活得过回调(比如回调是同步执行、整个在对象作用域里头),才轮得到 `Unretained` 上场。 + +--- + +## 简版实现:在 01 的 OnceCallback 上接 WeakPtr + +咱们把工业那套机制熬浓一下,做个教学版,接到 [01 系列](../../01_once_callback/full/01-1-once-callback-motivation-and-api-design.md) 里实现的 `OnceCallback` 上。核心没别的,就是把 `MakeItSo` 那行翻译过来: + +```cpp +// Platform: host | C++ Standard: C++20 +// 简化:把成员方法 + WeakPtr 绑成一个 void() 回调 +// (这里用 01 系列的 OnceCallback 作返回类型;配套可独立编译的 18_bind_weakptr_cancel.cpp +// 用 std::function 代替,逻辑一致) +template +auto bind_weak_once(void (T::*method)(Bound...), + WeakPtr receiver, + Bound... bound_args) { + return OnceCallback( + [method, receiver = std::move(receiver), + bound = std::make_tuple(std::move(bound_args)...)]() mutable { + if (!receiver) return; // ← 对应 InvokeHelper::MakeItSo 的取消点 + std::apply( + [&](auto&&... args) { (receiver.get()->*method)(args...); }, + bound); + }); +} +``` + +这段看着寒酸,可跟工业级 `MakeItSo` 是同构的:`if (!receiver) return;` 对应的就是 `if (!target) return;`。`receiver` 是个 `WeakPtr`,`!receiver` 经 `operator bool`、`get()` 一路落到 `IsValid`,对象一死就静默 no-op。咱们把它挂到 01 的 OnceCallback 上跑跑看: + +```cpp +class Controller { +public: + void on_work_done(int v) { std::cout << "got " << v << '\n'; } + WeakPtr get_weak() { return weak_factory_.get_weak_ptr(); } +private: + std::vector buf_; + WeakPtrFactory weak_factory_{this}; // 最后成员 +}; + +int main() { + WeakPtr alive_marker; + { + Controller c; + // 绑一条回调,receiver 是 c 的 WeakPtr + auto task = bind_weak_once(&Controller::on_work_done, c.get_weak(), 42); + + // 在 c 还活着时跑 → 调用 on_work_done + std::move(task).run(); // got 42 + } // c 析构 → weak_factory_ 先失效所有 WeakPtr → buf_ 才析构 + + // 现在重新绑一条,在 c 死后跑 + auto task2 = [&] { + // 这里假设从别处拿到一个已失效的 WeakPtr(模拟任务还在队列时对象析构) + return bind_weak_once(&Controller::on_work_done, + WeakPtr{}, 99); // 空 WeakPtr + }(); + std::move(task2).run(); // 静默 no-op,什么都不打印 + return 0; +} +``` + +您会看到 `got 42` 打出来一次,第二个 task 因为 receiver 失效,静默 no-op,啥也不印。01-4 那个悬空回调的 bug,到这儿算是有了彻底的解药——而且**用户这边就多写了一个 `get_weak_ptr()`**,取消的一整套复杂度,全让 `BindOnce` + `WeakPtr` 替您扛走了。 + +--- + +## 闭环:01-4 手搓令牌 vs 工业 WeakPtr + +系列走到这儿,算是闭环了。咱们把 01-4 手搓的取消令牌,跟工业级 WeakPtr 摆一张桌上,一比一对照: + +| 01-4 手搓方案 | 工业级 WeakPtr | +|---|---| +| 一个原子标志(多个回调要手动拷 token 才能共享) | `WeakReference::Flag`(`RefCountedThreadSafe` + `AtomicFlag`),factory 与所有 WeakPtr **自动共享同一枚** | +| 手动管理标志的生命周期 | `scoped_refptr` 引用计数自动管 | +| 调用前 `if (!flag.is_set()) return;` | `InvokeHelper::MakeItSo` 里 `if (!target) return;` | +| 手动把标志塞进回调 | `kIsWeakMethod`/`IsWeakReceiver` **编译期自动**识别 WeakPtr receiver 并选中 weak 分支 | +| 单一检查通道 | 拆成 `IsCancelled`(同序列准,执行期用)和 `MaybeValid`(跨序列 hint,调度器投机用)两条 | +| 取消后回调行为:自定义 | 取消后**强制静默 no-op**;且 weak 调用**强制 void 返回**(取消时无值可返) | + +要论最要紧的进化,笔者觉得就两条。头一条,factory 跟对象身份绑在一起,所有 WeakPtr 自动共享同一枚 Flag——"一次 invalidate、所有回调集体失效"这就白捡了(01-4 手搓版还得用户自己拷 token 才能让多个回调共享,而且 flag 是个独立小对象,跟对象身份不挂钩)。另一条,编译期就自动把线接好(`kIsWeakMethod`):您只管写 `BindOnce(&C::m, weak_factory_.GetWeakPtr())`,取消机制自己就位,不用您再手写一个 if-check。 + +01-4 那个笔者留着的"尾巴"——标志怎么传、谁来管它的命——到这儿彻底收口了:标志就是那枚共享的 Flag,命用侵入式引用计数管着,传递靠 WeakPtr 句柄,接进回调靠编译期接线。六篇前置知识,加五篇实战,Chromium 工程师这套设计,咱们算是把每一颗螺丝都拧下来看过了。 + +--- + +## 参考资源 + +- [Chromium `base/functional/bind_internal.h` —— kIsWeakMethod / InvokeHelper / WeakCallReturnsVoid](https://source.chromium.org/chromium/chromium/src/+/main:base/functional/bind_internal.h) +- [Chromium `base/functional/callback.h` —— IsCancelled/MaybeValid](https://source.chromium.org/chromium/chromium/src/+/main:base/functional/callback.h) +- [OnceCallback 实战(四):取消令牌设计](../../01_once_callback/full/01-4-once-callback-cancellation-token.md) +- [WeakPtr 实战(四):序列亲和性与 lazy 绑定](./02-4-weak-ptr-sequence-affinity-and-lazy-binding.md) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-6-weak-ptr-testing-and-perf.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-6-weak-ptr-testing-and-perf.md new file mode 100644 index 000000000..1cdb9c150 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-6-weak-ptr-testing-and-perf.md @@ -0,0 +1,237 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 用 Catch2 围绕六条不变量测试教学版 WeakPtr,并与 std::weak_ptr、真实 Chromium 做对象大小、 + 分配行为、调用开销、TRIVIAL_ABI 收益的性能对比 +difficulty: intermediate +order: 6 +platform: host +prerequisites: +- WeakPtr 实战(五):与回调集成——关闭 OnceCallback 的环 +- OnceCallback 实战(六):测试与性能对比 +reading_time_minutes: 13 +related: +- WeakPtr 实战(二):核心骨架与控制块 +- WeakPtr 前置知识(六):TRIVIAL_ABI 与平凡可重locate +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- weak_ptr +- 测试 +- 优化 +title: "WeakPtr 实战(六):测试与性能对比" +--- +# WeakPtr 实战(六):测试与性能对比 + +代码撸到这儿,笔者最怕一件事:看着能跑,心里没底。WeakPtr 这种东西,跑通的 demo 永远只覆盖"对象活着"这一条最舒服的路径,真正要出事的全在边界——invalidate 之后还在解引用、factory 析构了还有人持着 WeakPtr、跨序列判活拿到一个假阳性。所以这一篇咱们不写新功能,就干两件事:把该测的性质一条条钉成测试,再把教学版和 `std::weak_ptr`、真实 Chromium 摆一起量一量——对象多大、分配几次、一次判活走多远、`TRIVIAL_ABI` 到底省在哪。和 [01-6 测试与性能对比](../../01_once_callback/full/01-6-once-callback-testing-and-perf.md) 一样,笔者只信真实测量,不空口断言。 + +--- + +## 六条不变量 + +测试要测的是"不变量",说白了就是不管您怎么折腾它,这条性质都得立着。WeakPtr 笔者归纳下来有六条,咱们就围着这六条转。 + +第一条最朴素:factory 铸出来的 WeakPtr,对象还活着的时候就得能正确解引用。这是地基,过不了这条别的都白搭。第二条是 move 语义——WeakPtr 是 move-only 友好的,move 完源对象得变空,不能两边都指着同一个 flag。第三条是 invalidate 之后判活必须失效,一次 `invalidate_weak_ptrs()` 出去,所有已经铸出来的 WeakPtr 的 `get()` 和 `operator bool` 全得返回空和 false,一个都不能漏。 + +第四条是笔者特意拎出来的:解引用一个已经失效的 WeakPtr,必须触发断言。教学版用 `assert`,release 模式下 Chromium 用 `CHECK`——这是 use-after-free 的前兆,没得商量,当场得爆。第五条是 `maybe_valid()` 的不对称,这玩意儿跨序列调用时负面可信、正面不可信:它说"已失效"您能信,它说"还活着"您得掂量。第六条最绕,也是真实工程里最容易翻车的:factory 析构等于把它铸出的所有 WeakPtr 全部作废,而且 factory 作为"最后成员"得反过来守护被指对象的析构期——这条要是没守住,成员析构到一半还有人持着 WeakPtr 解引用,就是经典的 UAF。 + +--- + +## Catch2 测试用例 + +笔者习惯用 [Catch2](https://github.com/catchorg/Catch2) 的 `TEST_CASE` 配 `REQUIRE`,把这六条不变量一个个落到具体用例上。下面挑几条关键的给您过一遍——这些是 Catch2 风格的示意用例,项目当前能跑的示例在 `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/` 下的 `12`~`18` demo .cpp,把 Catch2 作为独立测试目标接进去这件事笔者留作了扩展,您有兴趣可以自己搭。 + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include "weak_ptr/weak_ptr.hpp" + +using namespace tamcpp::chrome; + +struct Foo { + int x = 42; + int get() const { return x; } +}; + +TEST_CASE("WeakPtr basic: alive object dereferences correctly", "[weak_ptr]") { + Foo foo{7}; + WeakPtrFactory fac(&foo); + auto wp = fac.get_weak_ptr(); + + REQUIRE(wp); // 不变量 1:对象活着,wp 判活 + REQUIRE(wp->x == 7); + REQUIRE(wp.get() == &foo); +} + +TEST_CASE("WeakPtr move: source is empty after move", "[weak_ptr]") { + Foo foo{1}; + WeakPtrFactory fac(&foo); + auto wp1 = fac.get_weak_ptr(); + auto wp2 = std::move(wp1); // 不变量 2:move + + REQUIRE_FALSE(wp1); // move 后源为空 + REQUIRE(wp2); + REQUIRE(wp2->x == 1); +} + +TEST_CASE("WeakPtr invalidate: all weak ptrs go null", "[weak_ptr]") { + Foo foo{1}; + WeakPtrFactory fac(&foo); + auto wp1 = fac.get_weak_ptr(); + auto wp2 = fac.get_weak_ptr(); + + fac.invalidate_weak_ptrs(); // 不变量 3:批量失效 + + REQUIRE_FALSE(wp1); + REQUIRE_FALSE(wp2); + REQUIRE(wp1.get() == nullptr); +} + +TEST_CASE("WeakPtr factory destruct: invalidates all weak ptrs", "[weak_ptr]") { + Foo foo{1}; + WeakPtrFactory fac(&foo); + WeakPtr wp; + { + // 模拟 factory 析构:用一个内层作用域 + // (真实"最后成员"场景见下面 BadController/GoodController 用例) + } + // 这里直接测 invalidate_weak_ptrs_and_doom 的"doom"语义 + fac.invalidate_weak_ptrs_and_doom(); + REQUIRE_FALSE(fac.has_weak_ptrs()); +} + +TEST_CASE("WeakPtr was_invalidated distinguishes dead-from-nulled", "[weak_ptr]") { + Foo foo{1}; + WeakPtrFactory fac(&foo); + auto wp = fac.get_weak_ptr(); + REQUIRE_FALSE(wp.was_invalidated()); // 还活着,不是"已失效" + + fac.invalidate_weak_ptrs(); + REQUIRE(wp.was_invalidated()); // 被作废,而非主动 reset + + auto wp2 = fac.get_weak_ptr(); + wp2.reset(); + REQUIRE_FALSE(wp2.was_invalidated()); // 主动 reset 不算"被作废" +} + +#if !defined(NDEBUG) +// 仅 debug:解引用失效应触发断言(教学版用 assert;Chromium 用 CHECK) +TEST_CASE("WeakPtr deref invalid asserts in debug", "[weak_ptr][.assert]") { + Foo foo{1}; + WeakPtrFactory fac(&foo); + auto wp = fac.get_weak_ptr(); + fac.invalidate_weak_ptrs(); + // 不变量 4:这一行在 debug 下应 abort + // 在隔离的 assert 测试里跑:REQUIRE_THROWS(wp->x); + // (Catch2 对 abort 的隔离需要子进程,工程里用 death_test 模式) +} +#endif +``` + +测试设计的路数和 01-6 一脉相承:每条用例盯一个不变量,不去罗列 API。笔者特意把 `was_invalidated` 单拎出来测,因为它要区分"被作废"和"主动 reset"这两种状态——这是 WeakPtr 独有、最容易写歪的语义,写歪了调用方就分不清"对象真没了"还是"我自己放手了"。还有一处笔者踩过坑的:解引用失效的断言测试不能和普通用例同进程跑,`assert` 和 `CHECK` 都会 abort,一炸整批用例全红。工程里要么走 death_test 模式起子进程隔离,要么干脆把这几条用例打上 `[.assert]` 标签单独跑。 + +--- + +## 性能对比:对象大小 + +先拿 `sizeof` 量一量,本机 GCC 13、x86-64 的真实终端输出: + +```cpp +static_assert(sizeof(WeakPtr) == sizeof(void*) * 2); // 16 字节 +``` + +| 类型 | 大小(x86-64) | 组成 | +|---|---|---| +| `WeakPtr`(教学版 / Chromium) | **16 字节** | `WeakReference`(=`scoped_refptr`,1 指针)+ `T*`(1 指针) | +| `std::weak_ptr` | **16 字节** | 对象指针 + 控制块指针 | +| `std::shared_ptr` | 16 字节 | 对象指针 + 控制块指针 | + +三者 sizeof 出来都是 16 字节,看着平起平坐。但别被这个数字骗了,真正拉开差距的不是大小,是组成和分配行为。 + +--- + +## 性能对比:分配行为 + +WeakPtr 和 `std::weak_ptr` 大小一样,真要找差距,得看被指对象本身怎么分配。咱们对比一个被弱引用的对象从无到有要敲几次堆: + +| 方案 | 堆分配次数 | 说明 | +|---|---|---| +| `std::weak_ptr` + `std::shared_ptr(new T)` | **2 次** | 一次给 T,一次给控制块 | +| `std::weak_ptr` + `std::make_shared()` | 1 次 | T 和控制块打包,但 `weak_ptr` 长寿会拖住整块内存(见 [pre-00](./pre-00-weak-ptr-weak-reference-and-lifetime.md)) | +| WeakPtr(被指对象自带 `WeakPtrFactory`) | **1 次**(Flag)+ 被指对象按它自己的方式 | Flag 侵入式引用计数,1 次分配;被指对象不强制 shared,该几次就几次 | + +WeakPtr 这边笔者觉得最舒服的一点是,它不强迫被指对象改用任何特定分配方式。对象本来该怎么管还怎么管,您只要给它挂一个 factory,真正多出来的开销只有 factory 内部那枚 Flag 的一次侵入式分配。`std::weak_ptr` 那边就没这么大方了,要么老老实实两次分配,要么 `make_shared` 图个一次,代价是把整块对象内存跟控制块打包到一起,weak_ptr 一长寿,对象跟着赖着不释放。 + +--- + +## 性能对比:调用开销 + +WeakPtr 的 `get()` 调一次,底层链路是 `ref_.IsValid()` 走到 `flag_->IsValid()`,再走到一次 `invalidated_.IsSet()`——也就是一次原子的 acquire-load。热路径上就这一下,没别的。 + +`std::weak_ptr::lock()` 那边就热闹多了:先原子读一次 strong count 判断对象在不在,在的话还要再原子加一次 strong count 把它临时攥住,最后吐出一个临时 `shared_ptr`。比 `get()` 重,重就重在它得多做一次原子自增,还得临时搭一个 `shared_ptr` 出来。 + +这就是 WeakPtr "不延寿、只判活"的直接红利——它不学 `lock()` 那样临时抬 refcount,就老老实实读一次 flag。当然天下没有白吃的午餐,代价是返回的裸指针不保证对象在您解引用那会儿还活着,所以才需要"同序列 deref"那条契约在背后兜底。 + +--- + +## 性能对比:TRIVIAL_ABI 的收益 + +`TRIVIAL_ABI` 的好处在调用约定这一层,它让 WeakPtr 按值传参时直接进寄存器,而不是绕一道内存。我们在 [pre-06](./pre-06-weak-ptr-trivial-abi.md) 论证过它安全,那能不能量化? + +```cpp +// 把 WeakPtr 按值传进函数 +void sink(WeakPtr wp) { (void)wp; } +``` + +标了 `[[clang::trivial_abi]]` 时,这 16 字节的参数走的是两个寄存器(比如 x86-64 SysV 下 `rdi`/`rsi` 那一票邻近寄存器);没标的话,编译器得在栈上开 16 字节,再隐式塞一个引用进去。`-O2` 下一减,前者省的是栈帧布局里的一份拷贝,外加析构那套调用约定的开销。 + +单看一次调用省得不多。但您想想 Chromium 任务系统那个量级,回调里成天大批大批地传 WeakPtr,这点收益累积起来就很吓人了。这也是为什么 Chromium 专门给 WeakPtr 和 WeakReference 标上这个属性,还把 `IsSet()` 强行 inline 进头文件——源码注释里白纸黑字写着 "measurable performance impact on base::WeakPtr",人家是真量过。 + +--- + +## vs std::weak_ptr:取舍总表 + +| 维度 | `std::weak_ptr` | `WeakPtr` | +|---|---|---| +| 是否介入所有权 | 是(必须配 `shared_ptr`) | **否**(对象该怎么管还怎么管) | +| 控制块/Flag 分配 | 非侵入式(独立或 `make_shared` 合并) | **侵入式**(Flag 一次分配) | +| 线程/序列模型 | 原子操作本身安全,序列由用户管 | **序列亲和**(deref/失效要同序列,debug 抓) | +| 跨线程 deref | `lock()` 线程安全 | 需同序列(靠契约 + DCHECK) | +| 批量失效 | 无(各 weak_ptr 独立 expired) | **一次 `invalidate` 失效所有**(共享 Flag) | +| 调用开销 | `lock()` 两次原子 + 临时 shared_ptr | `get()` 一次原子 acquire-load | +| 大小 | 16 字节 | 16 字节(且 `TRIVIAL_ABI` 进寄存器) | + +一句话讲明白:`std::weak_ptr` 是通用、安全的弱引用;`WeakPtr` 则是为"任务投递、不介入所有权、序列化执行"这一套量身定做的。在 Chromium 这种体系里,后者跟模型贴得严丝合缝;真到了通用 C++ 代码里,前者够用、而且标准库自带,谁也不亏。 + +--- + +## vs 真实 Chromium:我们的取舍 + +和 [01-6 一样](../../01_once_callback/full/01-6-once-callback-testing-and-perf.md),咱们的教学版砍了不少东西,取舍笔者给您摆台面上: + +| 维度 | Chromium 实现 | 我们的教学版 | +|---|---|---| +| Flag 引用计数 | `RefCountedThreadSafe`(原子) | 同(核心,不省) | +| 原子标志 | `base::AtomicFlag`(封装) | `std::atomic` 配 memory_order(等价) | +| 序列检查 | `SEQUENCE_CHECKER` 三宏 + SequenceToken | 简化 `SequenceChecker`(线程 id 模拟) | +| `SafeRef` | 完整(非空、悬空即崩) | 不实现(留作扩展) | +| `BindOnce` 集成 | 完整类型擦除 + `InvokeHelper` 双特化 | 简化 trampoline + 接 01 OnceCallback | +| `TRIVIAL_ABI` | 标注 | 标注(clang) | +| `InvalidateAndDoom` / `BindToCurrentSequence` | 完整 | `AndDoom` 保留;`BindToCurrentSequence` 省略 | + +笔者牺牲掉的是完整度——`SafeRef` 没做,`BindToCurrentSequence` 砍了,真正的 `SequenceToken` 也用了简化版。换回来的是可读性和可编译性:教学版纯靠标准库外加一个 clang 属性就能跑起来。而那些真正承重的机制,refcounted Flag、acquire/release 配对、序列契约、编译期 weak 分派,一个字没动。笔者反复掂量过,对教学这个目的来说,这个买卖划算。 + +到这里,WeakPtr 这个组件从设计、实现一路到验证就全走通了。回望从 [pre-00 弱引用导论](./pre-00-weak-ptr-weak-reference-and-lifetime.md) 算起的 13 篇,咱们其实只做了一件事:把"生命周期"这个老问题,从模糊的工程直觉一步步逼成可编译、可测试、有明确 acquire/release 配对的代码。笔者写这一路反复摔出来的体会就一条:每一个签名、每一条 `requires`、每一对内存序,背后都得能说出一个具体的"为什么",说不出来的那些,早晚会在某个本该很顺的场景里翻车,而且往往是那种最难复现的翻车。这个系列跟 OnceCallback 那条线在这儿收口了,希望您下一个去啃工业级组件的时候,手上有这套直觉兜着。 + +--- + +## 参考资源 + +- [Catch2 文档](https://github.com/catchorg/Catch2/tree/devel/docs) +- [Chromium `base/memory/weak_ptr_unittest.cc` —— 官方测试](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr_unittest.cc) +- [OnceCallback 实战(六):测试与性能对比](../../01_once_callback/full/01-6-once-callback-testing-and-perf.md) +- [cppreference: std::weak_ptr](https://en.cppreference.com/w/cpp/memory/weak_ptr) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-00-weak-ptr-weak-reference-and-lifetime.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-00-weak-ptr-weak-reference-and-lifetime.md new file mode 100644 index 000000000..138d83522 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-00-weak-ptr-weak-reference-and-lifetime.md @@ -0,0 +1,213 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 14 +- 17 +description: 从所有权与生命周期的两难出发,讲清弱引用到底解决什么问题,std::weak_ptr 的机制与四个局限, + 以及 Chromium WeakPtr 想要什么 +difficulty: intermediate +order: 0 +platform: host +prerequisites: +- OnceCallback 实战(四):取消令牌设计 +reading_time_minutes: 11 +related: +- WeakPtr 实战(一):动机与接口设计 +- WeakPtr 前置知识(一):侵入式引用计数与 scoped_refptr +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- 内存管理 +- weak_ptr +title: "WeakPtr 前置知识(零):弱引用与生命周期难题" +--- +# WeakPtr 前置知识(零):弱引用与生命周期难题 + +[OnceCallback 实战(四):取消令牌设计](../../01_once_callback/full/01-4-once-callback-cancellation-token.md) 里咱们手搓过一枚原子标志,对象活着时它是 0,析构前置成 1,回调跑前先瞄一眼,置位就乖乖 no-op。悬空问题是没了,可笔者后来越想越不对劲:这枚标志到底归谁管?它自己活多久?回调又怎么稳稳当当地拿到它?当时一笔带过的尾巴,正是 C++ 里最磨人的一类问题——生命周期与所有权。 + +一个对象 A 想引用对象 B,但不想延长 B 的寿命,还想随时知道 B 是不是还活着。这一篇咱们就把这件事掰开:标准库的 `std::weak_ptr` 是怎么应付的,它在异步回调里为什么不够用,以及 Chromium 凭什么另起炉灶搞了一套 `WeakPtr`。 + +--- + +## 生命周期:所有权光谱的两端 + +先把视角拉到最高。C++ 里"A 引用 B"这件事,在所有权光谱上有两个极端,中间空着一大片——咱们要找的,就是中间某个能落脚的位置。 + +### 强引用:引用即续命 + +`std::shared_ptr` 是强引用的典型。它表达的是共享所有权:只要还有一个 `shared_ptr` 指着 B,B 就不能死;最后一个离开时,B 才被析构。 + +```cpp +auto sp = std::make_shared(); // 引用计数 = 1 +{ + std::shared_ptr sp2 = sp; // 引用计数 = 2 +} // sp2 离开,引用计数回到 1,Foo 没死 +// sp 还在,Foo 还活着 +``` + +规则是安全,可代价也实打实:谁拿到 `shared_ptr`,谁就插手了 B 的寿命。设想 A 是个定时器,B 是业务对象,A 持有 B 的引用好到期时调它的方法。A 拿的是 `shared_ptr` 会怎样?只要定时器还挂着,B 就永远析构不掉,哪怕业务语义早就该让 B 走了。A 本意只是"借来用用",结果变成了"共同拥有"。所有权图被搅浑,后面谁来推理生命周期都得皱眉头。 + +### 裸指针:不拥有,但对方没了也没感觉 + +另一头是裸指针 `T*`。它彻底不介入所有权,B 死活跟 A 无关,想用就用。轻是真轻,险也是真险: + +```cpp +Foo* p = obj; +obj = nullptr; // 别处把对象析构了 +p->do_something(); // 悬垂指针,未定义行为,大概率段错误 +``` + +注意,裸指针的问题不在"不延长寿命",这正是咱们想要的;问题在于它压根没法表达"还想确认对方活着没"。指针就是个地址,地址不会因为对象销毁而变空。`p` 还是那个 `p`,指向的那块内存可能早被别的对象占了,您一访问就是 use-after-free。 + +### 咱们真正想要的是什么 + +两个极端往桌上一摆,想要的就清楚了,光谱中间某处: + +> 不拥有,所以不延长寿命;但手里这张条子能查出来,对方走没走。 + +这就是弱引用(weak reference):只观察存活状态,不参与所有权计数。标准库给了 `std::weak_ptr`,可它背着不小的包袱;Chromium 在 `//base` 里另写了一套 `WeakPtr`。这个系列要做的事,就是把这两套都拆开看明白,最后咱们自己手撸一个教学版。 + +--- + +## std::weak_ptr:标准库的弱引用 + +`std::weak_ptr` 是 C++11 进的标准库。它有个硬规矩:只能从 `shared_ptr` 构造而来,您没法凭空造一个 `weak_ptr` 指向栈对象或者 `new` 出来的裸对象。 + +规矩是从它的机制里长出来的。`shared_ptr` 内部不止一个裸指针,它还指向一块叫控制块(control block)的堆内存,里头存着引用计数。`weak_ptr` 共用同一块控制块,但走的是单独一档计数,叫弱引用计数。 + +```mermaid +flowchart TB + SP["shared_ptr"] + WP["weak_ptr"] + CB["控制块(堆上)
strong count = 1
weak count = 1
ptr to Foo"] + Foo["Foo(堆上)"] + SP --> CB + WP --> CB + CB --> Foo +``` + +关键就在这:`weak_ptr` 不增加 strong count,所以它不插手 Foo 何时析构;可它增加 weak count,这就让控制块自己赖着活着。弱引用由此捡到一个独门本事:对象析构之后,它还能查"对象是不是已经没了"。 + +### 三个核心操作 + +```cpp +auto sp = std::make_shared(); +std::weak_ptr wp = sp; // 从 shared_ptr 构造,不增加 strong count + +wp.use_count(); // 看 strong count 还剩几个 +wp.expired(); // 等价于 use_count() == 0,对象是否已析构 +auto locked = wp.lock(); // 尝试升级回 shared_ptr +``` + +`expired()` 告诉您对象死了没;`lock()` 把弱引用往强引用上凑,对象还活着就给您一个有效的 `shared_ptr`,已经死了就给您一个空的。 + +### 为什么必须用 lock(),而不是 expired() + 构造 + +新手最自然的写法是这样的,也是最容易翻车的: + +```cpp +std::weak_ptr wp = sp; +// ... 别处可能把 sp 释放了 ... + +if (!wp.expired()) { + // 这里 wp 还没过期? + sp->do_something(); // 错!sp 可能已经在 expired() 之后、这一行之前被释放 +} +``` + +`expired()` 返回 `false` 那一刻,对象确实活着;可从您拿到 `false` 到真正解引用之间,另一条线程可能把最后一个 `shared_ptr` 放了,触发析构。这就是教科书级的 TOCTOU(time-of-check-to-time-of-use)竞态,检查的那一刻跟使用的那一刻,中间开着窗。 + +正解是 `lock()`:它把"判活"和"升级成强引用"塞进一个原子操作里,要么给您一个保证对象存活的 `shared_ptr`,要么给空指针,中间没有缝: + +```cpp +if (auto locked = wp.lock()) { + locked->do_something(); // 此时 locked 持有 strong count,对象保证活着 +} +``` + +这一步是 `weak_ptr` 安全使用的命脉,得吃透:`lock()` 把判活和延寿塞进同一个原子操作。Chromium 的 `WeakPtr` 走的是另一条路:它压根不延寿,只判活,所以不指望 `lock()` 那套"判活+延寿原子化"来堵 TOCTOU,而是甩出序列契约——deref 和 invalidate 必须落在同一个序列上,序列内任务串行,窗口从根上没了。这套契约咱们留到 02-4(序列亲和性)展开,这里先种个印象。 + +--- + +## make_shared 与控制块:一个反直觉的内存细节 + +讲到这儿有个细节值得停下来掰扯,因为它直接牵出咱们对"侵入式 vs 非侵入式引用计数"的理解,这正是下一篇的核心动机,这里先把种子埋下。 + +`std::shared_ptr` 的控制块是非侵入式的:一块独立的堆内存,跟对象分开住。所以一句 `std::shared_ptr(new Foo)` 背后其实是两次堆分配,一次给 Foo,一次给控制块。 + +```cpp +std::shared_ptr sp1(new Foo); // 两次堆分配:Foo + 控制块 +auto sp2 = std::make_shared(); // 一次堆分配:Foo 和控制块打包在一起 +``` + +`std::make_shared` 把对象和控制块塞进同一块堆内存,一次分配搞定,这也是它比 `shared_ptr(new)` 快的主要缘故。可这个优化拖出来一个反直觉的副作用:只要还有一个 `weak_ptr` 指着,整块内存(连对象占的那块)都不归还,哪怕对象早析构完了。 + +```cpp +std::weak_ptr wp; +{ + auto sp = std::make_shared(); // 一次分配:控制块 + Foo + wp = sp; +} // sp 离开,Foo 析构,但控制块因为 wp 还在而保留 +// 此时 Foo 的析构已经跑完,但它占的内存还挂在那里,因为控制块和它打包在一起 +auto sp2 = wp.lock(); // 返回空 shared_ptr——对象确实没了 +// 但 make_shared 那块内存要等 wp 也销毁才真正归还 +``` + +为什么会这样?控制块得活到所有 `weak_ptr` 都散了为止(否则 `weak_ptr` 没法安全地查 `expired()`),而 `make_shared` 偏偏把控制块和对象捆成一家。对象析构不等于内存归还,`weak_ptr` 长寿的场景下,就这么拖着一块"死透了却占着"的内存。 + +这不是 `weak_ptr` 的 bug,是非侵入式控制块加上 `make_shared` 合并分配两件事叠一块儿的结果。但它确实是一笔代价,Chromium 的 `WeakPtr` 用侵入式引用计数绕开了它,下一篇咱们展开。 + +--- + +## std::weak_ptr 在异步/回调场景的四个局限 + +视角收回来,落到这个系列真正上心的场景:异步回调与任务投递。`std::weak_ptr` 是通用的、正确的设计,这一条不用洗;可塞进"任务投递 + 不介入所有权 + 序列化执行"这套体系里,它顶您四个地方,而每个地方都是 Chromium 另起炉灶的由头。 + +### 局限一:必须配 shared_ptr,强制介入所有权 + +这条最要命。`weak_ptr` 只能从 `shared_ptr` 来,意思就是您想用弱引用,先把对象改成 `shared_ptr` 管理再说。可很多对象的天然所有权压根不是共享的,它就属于某个 owner,owner 走了它就该走,用不着引用计数那一摊。 + +硬给本不该共享的对象套上 `shared_ptr`,所有权图立刻扭曲:本来清清楚楚的"单一 owner",变成理论上人人都能拿一份。后来维护的人一瞅 `shared_ptr` 就得警惕,这是真共享,还是只为凑个 `weak_ptr` 而硬贴上去的?这份迟疑的成本,看不见但实实在在。 + +### 局限二:控制块非侵入式,带来分配开销 + +上一节已经交代过:`shared_ptr` 要么两次分配,要么 `make_shared` 一次分配但绑死内存。对一个高频创建的弱引用对象(回调的目标就常是这类),这笔开销谈不上友好。Chromium 走的是侵入式路线,引用计数直接做成对象成员,一次分配完事,下一篇细讲。 + +### 局限三:不能"一次失效一批" + +设想对象被十几个回调或定时器引用,各攥着一个 `weak_ptr`。对象析构时,这十几个 `weak_ptr` 理应集体过期,可 `weak_ptr` 没有"主动批量失效"这一手,它们能过期,无非是因为对象最后一个 `shared_ptr` 散了。换句话说,失效是引用计数驱动的副作用,不是您能显式叫停的动作。 + +单纯的生命周期管理里这没毛病。可一旦您想表达"对象还活着,但它进入了不该再被回调的状态",`weak_ptr` 立刻没词了。Chromium 里这种诉求稀松平常,`WeakPtr` 行(`InvalidateWeakPtrs()`,咱们到 02-3 讲)。 + +### 局限四:没有序列亲和性 + +`std::weak_ptr` 的线程模型是"原子操作本身安全,解引用要不要同步您自己看着办"。放通用代码里这是合理的默认。可丢进 Chromium 这种"任务跑在序列上、绝大多数对象只认一个序列"的工程体系,这种自由度反而成坑:它不会提醒您"这对象只该在某个序列上 deref",您迟早忘了哪次解引用该上锁。 + +Chromium 要的是反过来:弱引用可以在序列之间流转,但解引用和失效必须落在绑定的序列上,犯规的至少在 debug 构建下被揪出来。这正是 `WeakPtr` 的 `SEQUENCE_CHECKER` 干的活,02-4 展开。 + +--- + +## Chromium 的取舍:它想要什么样的弱引用 + +四个局限摆一块儿,Chromium 的诉求就清楚得不能再清楚了: + +| `std::weak_ptr` 的局限 | Chromium `WeakPtr` 想要的 | +|---|---| +| 必须配 `shared_ptr` | **不介入所有权**——对象该怎么管还怎么管,WeakPtr 只是观察者 | +| 控制块非侵入式 | **侵入式引用计数**——计数是对象成员,一次分配 | +| 不能一次失效一批 | **共享 flag**——一个 factory invalidate,所有 WeakPtr 集体失效 | +| 没有序列亲和 | **序列绑定**——deref/失效必须在绑定序列,debug 下 DCHECK | + +这张表就是后面六篇实战的路线图。咱们要做的,无非把这四件事一行行落到代码里:一个 `RefCountedThreadSafe` 的 flag(侵入式 + 跨序列安全)、一对 release/acquire 的原子操作(序列间的安全可见性)、一个 `WeakPtrFactory` 担起批量失效、一组 `SEQUENCE_CHECKER` 宏守住院列契约。 + +可在这之前,几块前置知识得先补齐:侵入式引用计数到底怎么回事(`scoped_refptr` / `RefCountedThreadSafe`,下一篇)、原子操作与 memory order(pre-02)、序列与线程亲和(pre-03)、还有 WeakPtr 用到的 concepts 与 `TRIVIAL_ABI`(pre-04~06)。这一篇讲的是那个"为什么",把诉求吃透了,后面每一步实现您都会知道它在补哪个洞。 + +## 参考资源 + +- [cppreference: std::weak_ptr](https://en.cppreference.com/w/cpp/memory/weak_ptr) +- [cppreference: std::shared_ptr 与控制块](https://en.cppreference.com/w/cpp/memory/shared_ptr) +- [cppreference: std::make_shared](https://en.cppreference.com/w/cpp/memory/shared_ptr/make_shared) +- [Chromium `base/memory/weak_ptr.h` 设计注释](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md new file mode 100644 index 000000000..7e69494dd --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md @@ -0,0 +1,282 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 14 +- 17 +- 20 +description: 对照 std::shared_ptr 的非侵入式控制块,讲清 Chromium scoped_refptr + RefCountedThreadSafe + 的侵入式引用计数,以及 WeakPtr::Flag 为何必须用跨序列版本 +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- WeakPtr 前置知识(零):弱引用与生命周期难题 +reading_time_minutes: 12 +related: +- WeakPtr 实战(二):核心骨架与控制块 +- WeakPtr 前置知识(二):std::atomic 与 memory_order +tags: +- host +- cpp-modern +- intermediate +- 智能指针 +- 内存管理 +- RAII +- weak_ptr +- 引用计数 +title: "WeakPtr 前置知识(一):侵入式引用计数与 scoped_refptr" +--- +# WeakPtr 前置知识(一):侵入式引用计数与 scoped_refptr + +上一篇咱们埋了个伏笔:Chromium 的 `WeakPtr` 把"对象死没死"这个状态存在一枚叫 `Flag` 的小对象里,而这枚 Flag 会被对象的 owner、还有所有持有 WeakPtr 的回调共享,它们很可能跑在不同的序列上。一个被多方共享、又得能安全释放的小对象,这正是引用计数的活儿。 + +可 Chromium 偏偏没用 `std::shared_ptr`。它自己搓了一套侵入式引用计数,外壳叫 `scoped_refptr`,基类是 `RefCountedThreadSafe`。笔者第一次翻到这儿挺纳闷的:标准库现成的东西不用,重新发明轮子图啥?这一篇咱们就把这个纳闷拆掉。三件事讲透:`std::shared_ptr` 的非侵入式控制块跟侵入式计数到底差在哪、`scoped_refptr` 这个外壳长什么样,还有 WeakPtr 的 Flag 为什么非得用跨序列安全的版本。 + +--- + +## 引用计数:让多个所有者共享一个对象 + +先把智能指针的语法放一边,只看本质。引用计数要解决的问题就一个——对象被好几个持有者共享,怎么保证最后一个持有者离开时才析构它?机制简单到只有两步:拿到一份新引用,计数器加一;放弃一份引用,计数器减一,要是减到零,说明我是最后一个走的人,顺手把灯关了——也就是析构对象。 + +这套机制本身跟"计数器存哪儿"没关系。可恰恰是"存哪儿"这件事,把 `std::shared_ptr` 和 Chromium 的 `scoped_refptr` 劈成了两条路——一条叫非侵入式,一条叫侵入式。 + +--- + +## 非侵入式:std::shared_ptr 的做法 + +`std::shared_ptr` 把计数器放在对象外面——单独开一块堆内存,叫控制块。智能指针内部存两个指针:一个指向对象,一个指向控制块。 + +```mermaid +flowchart LR + SP1["shared_ptr"] & SP2["shared_ptr"] --> CB["控制块(堆)
strong_count
weak_count"] + CB --> Foo["Foo(堆)"] +``` + +这么干最直接的好处是:对象本身压根不知道自己被引用计数了。您拿任何类型 `T`,往 `shared_ptr` 里一塞,`T` 不用改一行代码。这是非侵入式最大的优点——通用,啥都能装。 + +代价出在分配上。您写 `std::shared_ptr(new Foo)`,底下实际发生两次堆分配:一次给 Foo,一次给控制块。`std::make_shared()` 把这两块合成一次,省了一刀,但咱们在 [前置知识(零)](./pre-00-weak-ptr-weak-reference-and-lifetime.md) 里聊过它的副作用——挂个长寿的 `weak_ptr`,整块对象内存就跟着赖着不释放。 + +还有个更隐蔽的代价:控制块是运行期才挂上去的,所以您手上只有一个 `T*` 的时候,没法回头去找它的引用计数。这听着无伤大雅,可真到"我现在是不是唯一持有者"这种判断面前,它就逼您绕路了。 + +--- + +## 侵入式:计数器是对象的成员 + +侵入式引用计数反着来——让计数器直接变成对象的一部分。最常见的做法是让 `T` 继承一个自带计数器的基类: + +```mermaid +flowchart LR + SR["scoped_refptr"] --> T + subgraph T["T (堆上)"] + direction LR + M["...members..."] + RC["ref_count_ = 2
← 计数器就在对象里"] + end +``` + +这么一来,优缺点跟非侵入式正好掉了个个儿。好处是一次分配——对象和计数器一体,一次 `new` 全搞定,没有多余的控制块。更值钱的是,任何拿到 `T*` 的代码都能顺藤摸瓜查到计数,因为计数本来就是成员;`shared_ptr` 拿一个裸指针是无能为力的。代价当然就是"侵入"本身——`T` 必须继承基类,改一行代码,不是随便什么类型都能塞进来。 + +### 对比表 + +| 维度 | 非侵入式(`shared_ptr`) | 侵入式(`scoped_refptr`) | +|---|---|---| +| 计数器位置 | 独立控制块(堆) | 对象自身成员 | +| 堆分配次数 | 2 次(或 `make_shared` 1 次,但捆死内存) | **1 次** | +| 对象是否需改造 | 不需要 | 需继承基类 | +| `T*` 能否查计数 | 不能 | 能(`HasOneRef()` 等) | +| 弱引用 | 内建 `weak_ptr` | 需另造(WeakPtr 就是干这个的) | + +Chromium 选侵入式,根子上是为了压开销和统一约定。`//base` 里头海量小对象都要走引用计数,每处省一次分配、省一个指针间接,放在浏览器这种规模下是真金白银。而且统一一套侵入式约定,让"从裸指针直接查计数"这种操作变得可行——而这正是 `WeakPtr::Flag::Invalidate` 里那个跨线程析构豁免要用的本事。 + +--- + +## 手写一个最小的侵入式引用计数 + +光说原理不够过瘾,咱们自己手搓一个,顺手把 Chromium 的设计决策一步步逼出来。先把计数基类写出来: + +```cpp +// Platform: host | C++ Standard: C++17 +#include +#include + +// 原子版侵入式引用计数基类(对应 Chromium 的 RefCountedThreadSafe, +// 不是非原子的 RefCounted——后者用于单序列对象,这里要跨序列所以用原子) +class RefCountedThreadSafe { +public: + void add_ref() const noexcept { + ref_count_.fetch_add(1, std::memory_order_relaxed); + } + + bool release() const noexcept { + // release 语义:析构前的写对后续看到 count==0 的线程可见 + if (ref_count_.fetch_sub(1, std::memory_order_acq_rel) == 1) { + return true; // 调用方负责 delete this + } + return false; + } + + bool has_one_ref() const noexcept { + return ref_count_.load(std::memory_order_acquire) == 1; + } + +protected: + RefCountedThreadSafe() = default; + ~RefCountedThreadSafe() = default; + +private: + mutable std::atomic ref_count_{0}; +}; +``` + +几个要点得点一下。计数器是 `mutable` 的——`add_ref` / `release` 逻辑上不改对象的可观察状态,得让它们在 `const` 对象上也能调。`release` 用 `acq_rel` 有讲究:acquire 一侧让它跟其它线程的 `add_ref` / `release` 正确同步(建立 happens-before,不是说"读到最新值"——那得 `seq_cst`),release 一侧在减到 0 时,把这个对象身上的所有写都发布给接管 `delete` 的那个线程。 + +接着让目标类型继承它: + +```cpp +class Flag : public RefCountedThreadSafe { +public: + Flag() = default; + // ... 业务接口 ... +}; +``` + +到这儿 `Flag` 就自带计数器了。可光有计数器还不行——咱们还得配一个智能指针,在拷贝、移动、析构的时候自动去调 `add_ref` / `release`。这玩意儿就是 `scoped_refptr`。 + +--- + +## scoped_refptr:侵入式引用计数的智能指针外壳 + +`scoped_refptr` 干的活儿跟 `std::shared_ptr` 是一个模子——拷贝增计数,析构减计数,减到零删对象。区别在于它不养独立的控制块,而是直接去调 `T` 继承来的 `add_ref` / `release`。一个最小实现长这样: + +```cpp +// Platform: host | C++ Standard: C++17 +template +class scoped_refptr { +public: + scoped_refptr() noexcept = default; + + explicit scoped_refptr(T* p) noexcept : ptr_(p) { + if (ptr_) ptr_->add_ref(); + } + + // 拷贝:+1 + scoped_refptr(const scoped_refptr& other) noexcept : ptr_(other.ptr_) { + if (ptr_) ptr_->add_ref(); + } + + // 移动:不增不减,把对面清空 + scoped_refptr(scoped_refptr&& other) noexcept : ptr_(other.ptr_) { + other.ptr_ = nullptr; + } + + ~scoped_refptr() { release(); } + + scoped_refptr& operator=(scoped_refptr r) noexcept { // copy-and-swap + swap(r); + return *this; + } + + void swap(scoped_refptr& other) noexcept { + T* tmp = ptr_; ptr_ = other.ptr_; other.ptr_ = tmp; + } + + T* get() const noexcept { return ptr_; } + T& operator*() const noexcept { return *ptr_; } + T* operator->() const noexcept { return ptr_; } + explicit operator bool() const noexcept { return ptr_ != nullptr; } + +private: + void release() noexcept { + if (ptr_ && ptr_->release()) { + delete ptr_; // 最后一个引用,析构 + ptr_ = nullptr; + } + } + + T* ptr_ = nullptr; +}; +``` + +`operator=` 这里有个讲究,用的是 copy-and-swap——传值参数 `r` 本身就完成了一次拷贝、增了计数,函数体里跟 `this` 一交换,`r` 析构时把 `this` 原来那份旧引用释放掉。这一手把自赋值和异常安全一锅端了。Chromium 的 `scoped_refptr` 大致就是这个形状,细节更多(inter-type 转换、`raw_ptr` 集成那类),但核心骨架跟咱这个一致。 + +用法跟 `shared_ptr` 几乎没差: + +```cpp +auto p = scoped_refptr(new Flag); // ref_count = 1 +{ + auto p2 = p; // ref_count = 2 +} // p2 析构,ref_count = 1 +// p 还在,Flag 活着 +``` + +可内存上它就分了一次——`Flag` 对象本身,计数器嵌在里头。 + +--- + +## RefCounted vs RefCountedThreadSafe + +Chromium 其实备着两个版本的引用计数基类,差别就在"要不要原子操作"。 + +一个是 `RefCounted`,非原子版本。计数走的是普通整数加减,所以只能在一个序列上用。它内部挂了个 `SequenceChecker`,debug 构建下专门抓"跨序列增减"这种违规。要是引用计数一直是 1、从来不拷贝,对象倒是可以搬到另一个序列上去;可一旦 `add_ref` / `release` 被并发调用,那就是实打实的数据竞争。好处是开销最小。 + +另一个是 `RefCountedThreadSafe`,原子版本。计数用原子指令(`acq_rel` 的 `fetch_add` / `fetch_sub`),多个序列、多个线程并发增减都安全。代价当然比非原子版大——一次原子操作跟一次普通加减不是一回事——可对跨序列共享的对象,这是刚需。 + +取舍逻辑其实很简单:能单序列就单序列,把原子开销省下来;非得跨序列才上原子版。Chromium 不会无脑全用 `ThreadSafe`,浏览器里头绝大多数对象本来就是单序列的,原子操作堆在热路径上,累积成本不容小觑。 + +### HasOneRef():从裸指针查计数的特权 + +侵入式有一样非侵入式干不了的本事——您手上只有对象的 `T*`,就能直接查"我现在是不是唯一引用者": + +```cpp +bool has_one_ref() const noexcept { + return ref_count_.load(std::memory_order_acquire) == 1; +} +``` + +`shared_ptr` 没法这么干,因为它得先有个 `shared_ptr` 才够得着控制块;光给您一个 `T*`,控制块在哪儿都不知道。可侵入式的计数器就长在对象身上,一个 `T*` 就够了。 + +这个能力在 WeakPtr 里被用得很巧——咱们到 [02-2 实战篇] 会看到,`Flag::Invalidate` 里有这么一行: + +```cpp +DCHECK(sequence_checker_.CalledOnValidSequence() || HasOneRef()); +``` + +翻译过来就是:Invalidate 必须在绑定的序列上调用;可要是 `HasOneRef()`——也就是这枚 Flag 已经没任何 WeakPtr 持着了——那它打哪个序列析构都无所谓,放行。这个跨线程析构的口子,只有"侵入式 + 能从 `this` 查计数"两样凑齐了才写得出来。这是侵入式引用计数在 WeakPtr 里最具体、也最值钱的一次兑现。 + +--- + +## 一个必须堵的坑:别让用户直接 new/delete + +侵入式引用计数有一条铁的不变量——对象只能由 `release()` 在计数归零那一刻 `delete`,绝不能被外部直接 `delete`。您想想,用户拿到一个 `scoped_refptr`,转头又去 `delete p.get()`,计数器还在别的 `scoped_refptr` 里头被引用着呢,对象却已经被销毁了,这下立刻悬垂。 + +`std::shared_ptr` 不用操这个心,因为它通过控制块把对象攥在手里。可侵入式不行,它得靠对象自己配合:把析构函数藏成 `private` 或 `protected`,只留出引用计数的 `release` 路径来访问。Chromium 的 `RefCountedThreadSafe` 走的就是这套——通过一个 `friend` 受保护的 `Destroy()` 真正去执行 `delete this`,外部代码碰不到析构函数,误删从源头就被堵死了。 + +```cpp +// 教学版:非模板基类(对应上方 line 123 的 RefCountedThreadSafe) +// 真实 Chromium 是模板形态 RefCountedThreadSafe,见下方说明 +class Flag : public RefCountedThreadSafe { +public: + Flag() = default; + +private: + template friend class scoped_refptr; // 教学版:scoped_refptr 负责 delete + ~Flag() = default; // private:外部没法直接 delete + // ... +}; +``` + +咱们手搓教学版时守的是同一原则——private 析构加受控释放路径——可有一个细节得说清楚:真实 Chromium 的 `release()` 里头,会调一个被 `RefCountedThreadSafe` 友元的 `Destroy()` 静态方法来 `delete this`;而咱这个简化版,把 `delete ptr_` 直接写在了 `scoped_refptr::~scoped_refptr()` 里。所以教学版里 Flag 的友元是 `scoped_refptr`,不是 `RefCountedThreadSafe`——配套代码 `12_intrusive_refcount.cpp` 跟 `weak_ptr.hpp` 就是照这个写的,能直接编译。这套做法跟 RAII 是一对孪生兄弟:资源获取即初始化,释放只准走唯一那条受控的路。 + +--- + +两种形态的引用计数,到这儿算是拆透了。非侵入式的 `std::shared_ptr` 把计数放进独立控制块,通用,代价是多一次分配;侵入式的 `scoped_refptr` 把计数器嵌进对象,一次分配,顺带把"从 `T*` 直接查计数"这个本事送给了您,代价是对象必须继承基类。Chromium 为了压开销、统一约定,`//base` 里全面押注侵入式。两个基类 `RefCounted`(非原子、单序列)和 `RefCountedThreadSafe`(原子、跨序列)的取舍,说到底是能省则省。 + +对 WeakPtr 来说,Flag 会被好几个序列上的 WeakPtr 共享,所以非得用 `RefCountedThreadSafe` 不可;而 `HasOneRef()` 那个"从 `this` 查计数"的本事,正是 `Flag::Invalidate` 能写出跨线程析构豁免的前提。下一块零件是原子操作和 memory order——咱们要把 `RefCountedThreadSafe` 里那个 `acq_rel` 真正弄明白。 + +## 参考资源 + +- [Chromium `base/memory/ref_counted.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/ref_counted.h) +- [Chromium `base/memory/scoped_refptr.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/scoped_refptr.h) +- [cppreference: std::shared_ptr](https://en.cppreference.com/w/cpp/memory/shared_ptr) +- [cppreference: std::atomic 与 memory_order](https://en.cppreference.com/w/cpp/atomic/memory_order) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-02-weak-ptr-atomic-and-memory-order.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-02-weak-ptr-atomic-and-memory-order.md new file mode 100644 index 000000000..64efa7e2b --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-02-weak-ptr-atomic-and-memory-order.md @@ -0,0 +1,203 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: 从数据竞争出发,讲清 std::atomic 的六种 memory_order,重点拆开 acquire/release 配对, + 并落到 WeakPtr/AtomicFlag 的 release-Set 与 acquire-IsSet +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- WeakPtr 前置知识(一):侵入式引用计数与 scoped_refptr +reading_time_minutes: 13 +related: +- WeakPtr 实战(二):核心骨架与控制块 +- WeakPtr 前置知识(三):序列、SEQUENCE_CHECKER 与 DCHECK/CHECK +tags: +- host +- cpp-modern +- intermediate +- atomic +- memory_order +- 并发 +- weak_ptr +title: "WeakPtr 前置知识(二):std::atomic 与 memory_order" +--- +# WeakPtr 前置知识(二):std::atomic 与 memory_order + +上一篇咱们搓那个最小引用计数基类,`add_ref` 塞了 `memory_order_relaxed`,`release` 塞了 `memory_order_acq_rel`。当时笔者一笔带过没展开,留了个尾巴:为什么不是统一用一种?这几个 `memory_order_*` 到底在管什么? + +这尾巴得收。WeakPtr 整套机制最妙的地方就在于它不拿锁,只靠一对 `release`/`acquire` 的原子操作,就做到了"一个序列把对象析构了,另一个序列手里的 WeakPtr 永远 deref 不到已析构对象"。您要吃透这条保证,就得先把 memory order 啃下来,它是后面整条 deref 链路的地基。笔者这篇就把它从数据竞争一路拆到 WeakPtr 的那一对配对。 + +--- + +## 为什么需要原子操作:数据竞争 + +先看一个会出事的最小场景,两个线程同时戳一个普通 `int`: + +```cpp +int counter = 0; +// 线程 A +counter++; +// 线程 B(同时) +counter++; +``` + +`counter++` 长得像一条语句,真跑起来是三步:读 `counter`、加 1、写回。两个线程的三步一旦交错,A 读到 0、B 也读到 0、A 写 1、B 写 1,最后结果是 1 不是 2。这就是数据竞争(data race)。按 C++ 标准,只要撞上数据竞争,程序行为直接未定义,不只是结果错,编译器有权基于"程序无数据竞争"的假定随便发挥,您拿到的可能是一份完全意料之外的二进制。 + +`std::atomic` 管两件事。一是原子性,把那三步捏成不可分割的一步,谁都插不进来;二是可见性和顺序,管一个线程的写在什么条件下能被另一个线程看到,以及编译器和 CPU 能不能把读写重排。原子性这一半大家心里有数,真正难、也真正决定并发对不对的是后一半,也就是 `memory_order`。 + +--- + +## std::atomic 基础 + +`std::atomic` 给出原子的 `load`/`store`/`exchange`/`compare_exchange`(CAS)以及 `fetch_add`/`fetch_sub` 等读-改-写操作: + +```cpp +std::atomic a{0}; +a.store(1, std::memory_order_release); // 原子写 +int v = a.load(std::memory_order_acquire); // 原子读 +a.fetch_add(1, std::memory_order_relaxed); // 原子 +1 +``` + +每个操作都能挂一个 `memory_order` 参数,不写就默认 `std::memory_order_seq_cst`,最强也最贵。C++ 一共给了六种,从弱到强摆开: + +--- + +## 六种 memory order + +| memory_order | 适用操作 | 语义 | +|---|---|---| +| `relaxed` | load/store/RMW | 只保证原子,不建立同步,不限制重排 | +| `consume` | load | 数据依赖版 acquire(实践中近似 acquire,标准淡化,别用) | +| `acquire` | load | 阻止后续读写重排到本次 load 之前;配对 release 建立同步 | +| `release` | store | 阻止之前读写重排到本次 store 之后;配对 acquire 建立同步 | +| `acq_rel` | RMW(fetch_*) | 同时是 acquire 和 release | +| `seq_cst` | 所有 | 额外保证全局总序,最强,默认,最贵 | + +笔者先劝您一句,别想着一次把六种全刻进脑子。真到工程里翻来覆去就三种组合:`relaxed`(只数数、不传信息)、`acquire`+`release`(单写多读的同步)、`seq_cst`(需要全局一致顺序时才上)。下面咱们把 `acquire`/`release` 重点拆开,因为这正是 WeakPtr 用的那一对。 + +--- + +## relaxed:只保证原子,不传递信息 + +`relaxed` 最省心,它只把这次操作变成原子的,对编译器和 CPU 的重排不做任何约束,也不保证这次操作前后的其它读写能被别的线程看到。 + +笔者的经验是,`relaxed` 最自然的归宿就是纯计数器,您只在乎数字加对了,不在乎它和别的内存操作谁先谁后: + +```cpp +std::atomic hits{0}; +// 多个线程都执行: +hits.fetch_add(1, std::memory_order_relaxed); // 只想数对总次数 +``` + +坑也藏在这。`relaxed` 没法用来传递"数据准备好了"这种信号,您要是图省事这么写: + +```cpp +// 线程 A +data = 42; // 普通写 +ready.store(true, std::memory_order_relaxed); + +// 线程 B +while (!ready.load(std::memory_order_relaxed)); +assert(data == 42); // 不保证通过!可能读到 data 的旧值 +``` + +`relaxed` 既不拦"先写 `ready` 再写 `data`"这种重排,也不保证 B 读到 `ready==true` 时 `data==42` 对它可见。assert 翻不翻车全看运气。要传这种信号,就得升级到 `release`/`acquire`。 + +--- + +## acquire / release:建立 happens-before + +这是日常用得最多的一对,也是并发正确性的命门。规矩就两条,笔者给您念一遍。 + +线程做 `release` 的 store 时,本次 store 之前它做过的所有读写,都不许被重排到 store 之后;线程做 `acquire` 的 load 时,本次 load 之后要做的所有读写,都不许被重排到 load 之前。 + +把这两头配起来就精彩了:线程 A 做完一堆写,用 `release` 存一个标志;线程 B 用 `acquire` 读这个标志。一旦 B 的 acquire 读到了 A 的 release 写入的值,A 在 release 之前做的所有写,对 B 全都可见。这条边就叫 happens-before。 + +```cpp +// Platform: host | C++ Standard: C++17 +#include +#include +#include + +int data = 0; +std::atomic ready{false}; + +void producer() { + data = 42; // (1) 普通写 + ready.store(true, std::memory_order_release); // (2) release:把 (1) "发布"出去 +} + +void consumer() { + while (!ready.load(std::memory_order_acquire)) { // (3) acquire:等 release + // spin + } + assert(data == 42); // (4) 保证通过! +} + +int main() { + std::thread t1(producer); + std::thread t2(consumer); + t1.join(); t2.join(); + return 0; +} +``` + +为什么 `assert` 保证通过?因为 (3) 的 acquire 读到了 (2) 的 release 写入,于是 (2) 之前的 (1) 对 (3) 之后的 (4) 可见。release/acquire 配对就这么点事,它把"对象写完了"这条信号连同写进去的数据,原子又有序地递到了另一个线程手里。 + +### 回到 release() 的 acq_rel + +[前置知识(一)](./pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md) 里引用计数的 `release` 用了 `memory_order_acq_rel`,不是单纯的 `release`。原因很简单,`fetch_sub` 是个读-改-写操作,既读旧值又写新值。`acq_rel` 让它两头都顾,acquire 那一半能看到别的线程对此计数器的最新写,release 那一半把自己对对象的写发布给下一个接管 `delete` 的线程。引用计数归零时这套交接刚好够用。 + +--- + +## 回到 WeakPtr:AtomicFlag 的 release/acquire 配对 + +工具齐了,咱们终于可以精确地盯 WeakPtr 的 liveness 机制。Chromium 的 `base::AtomicFlag` 是 `std::atomic` 的一个语义收窄封装,它在 WeakPtr 里用得极其克制,`Set()` 用 release,`IsSet()` 用 acquire,就这么一对: + +```cpp +// base::AtomicFlag 简化等价(我们教学版直接用 std::atomic) +class AtomicFlag { +public: + void Set() { + flag_.store(1, std::memory_order_release); + } + bool IsSet() const { + return flag_.load(std::memory_order_acquire) != 0; + } +private: + std::atomic flag_{0}; +}; +``` + +对应到 WeakPtr 的两个动作,失效这条链子是 `WeakReferenceOwner::Invalidate()` 一路调到 `Flag::Invalidate()`,最后落到 `invalidated_.Set()`,一次 release-store。它发生在 factory 让对象进入不可用状态的那一刻,典型场景有两个:要么是 `WeakPtrFactory` 析构之初(对应"最后成员"惯用法,见 [02-3](./02-3-weak-ptr-factory-and-last-member.md)),要么是您显式调 `InvalidateWeakPtrs()`。这里有个顺序笔者特意提醒一句,是 factory 析构先调 Invalidate 把所有 WeakPtr 失效,然后其它成员才轮到析构,这正是"最后成员"守护成员析构期的机制,而不是反过来"析构完了才 Invalidate"。 + +判活这条链子反着走,`WeakPtr::get()` 调到 `WeakReference::IsValid()`,再调到 `Flag::IsValid()`,本质就是 `!invalidated_.IsSet()`,一次 acquire-load。 + +把这两端套进上一节的 producer/consumer 模型,您会发现它们就是同一对 pattern。线程 A 是 factory 所在序列,析构对象之前先 `Invalidate` 做 release-store,把"对象已不可用"这个状态连同之前的所有写一起发布出去;线程 B 拿着 WeakPtr,想 deref 之前先 `IsValid` 做 acquire-load。如果 B 读到失效,说明 A 那边的 release 已经生效,A 序列在 release 之前的所有写,包括让对象进入不可用状态那些操作,对 B 全都可见,B 心里有数不该 deref,`get()` 老老实实返回 `nullptr`。反过来,如果 B 读到的是未失效,说明 A 还没 release,对象此刻还喘着气,B deref 是安全的。 + +WeakPtr "不用锁也能安全 deref" 的全部秘密就在这一对 release/acquire。它不拦着谁也不等谁,只立起"看到失效位 ⇒ 看到对象被改过的所有状态"这条 happens-before 边,剩下的交给原子读本身。 + +### 那为什么不用 relaxed,也不用 seq_cst + +`relaxed` 想都不用想,前面那个 `data`/`ready` 反例已经把它证死了,光原子不传同步,acquire 一侧可能读到 `ready==true` 却看不到对象被析构前的状态,WeakPtr 直接漏判。 + +`seq_cst` 倒是能工作,但代价笔者觉得划不来。它要求所有 `seq_cst` 操作凑成一个全局一致的总顺序,x86 上要更强的指令撑着,普通 acquire/release 在 x86 上几乎就是普通的 load/store,`seq_cst` 的 store 却得挂 `MFENCE` 或 `LOCK` 前缀。WeakPtr 的 deref 是热路径,每一次 `get()` 都要读一次 flag,换 `seq_cst` 等于把开销无意义地放大一截。acquire/release 在这儿是正好够用的选择,同步建得起来,又不替 `seq_cst` 的全局总序买单。Chromium 源码注释里特意强调"IsSet 必须 inline、对 WeakPtr 有可测性能影响",根源就在这。 + +### MaybeValid 为什么也是 acquire + +到 02-4 咱们会碰上 `MaybeValid()`,它给跨序列提供一个"乐观判活"的查询。您看这名字 maybe,第一反应没准是"那用 `relaxed` 凑合一下呗"。真不行,它内部就是 `!invalidated_.IsSet()`,照样得 acquire 读。道理和上头一模一样,它一旦读到"已失效",得让调用方敢拍胸脯说"对方序列让对象进入失效状态的所有写,我这儿都看见了"。`MaybeValid` 的"maybe"不在 memory order 上,而在于它不检查序列绑定,您可以从任意序列调它,但它只保证负面结果可信,读到失效就是真失效;正面结果(读到未失效)不保证您后续操作还安全。这条区别咱们留到 02-4 展开。 + +--- + +memory order 这块儿算是拆透了。还有最后一块并发地基得补上,就是序列与 `SEQUENCE_CHECKER`,外加 `DCHECK` 与 `CHECK` 那道分界线。三块地基凑齐,02-2 咱们就能动手把 Flag、WeakReference、WeakPtr 一级级搭起来。 + +## 参考资源 + +- [cppreference: std::atomic](https://en.cppreference.com/w/cpp/atomic/atomic) +- [cppreference: std::memory_order](https://en.cppreference.com/w/cpp/atomic/memory_order) +- [Herb Sutter — `atomic<>` Weapons`](https://channel9.msdn.com/Shows/Going+Deep/Cpp-and-Beyond-2012-Herb-Sutter-atomic-Weapons-1-of-2) +- [Chromium `base/synchronization/atomic_flag.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/synchronization/atomic_flag.h) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-03-weak-ptr-sequence-checker-dcheck-check.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-03-weak-ptr-sequence-checker-dcheck-check.md new file mode 100644 index 000000000..505fc02c4 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-03-weak-ptr-sequence-checker-dcheck-check.md @@ -0,0 +1,124 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: 区分线程与序列,讲清 SEQUENCE_CHECKER 三宏的用法与 release 下零开销原理, + 以及 DCHECK 与 CHECK 的取舍——为什么 WeakPtr 的 operator* 用 CHECK +difficulty: intermediate +order: 3 +platform: host +prerequisites: +- WeakPtr 前置知识(二):std::atomic 与 memory_order +reading_time_minutes: 11 +related: +- WeakPtr 实战(四):序列亲和性与 lazy 绑定 +tags: +- host +- cpp-modern +- intermediate +- atomic +- 并发 +- weak_ptr +title: "WeakPtr 前置知识(三):序列、SEQUENCE_CHECKER 与 DCHECK/CHECK" +--- +# WeakPtr 前置知识(三):序列、SEQUENCE_CHECKER 与 DCHECK/CHECK + +[前置知识(二)](./pre-02-weak-ptr-atomic-and-memory-order.md) 把可见性问题摆平了:两个序列同时戳 flag,靠 acquire/release 保证看得见。但 WeakPtr 还有一条契约,写在 `weak_ptr.h` 顶部注释里,笔者第一次读直接略过了,后来踩坑才回头细看——**弱指针随便跨序列传递,但 deref 和失效必须在绑定的那一个序列上**。 + +这条契约靠原子操作守不住,得靠一组叫 `SEQUENCE_CHECKER` 的宏:debug 构建下抓违规,release 构建下蒸发成零开销。咱们这一篇就把这套机制拆透,顺带把 `DCHECK` 和 `CHECK` 这对断言的取舍讲明白——理解了它俩,您才看得懂 WeakPtr 为啥 `operator*` 上 `CHECK`、`IsValid` 却只敢上 `DCHECK`。 + +## 线程 vs 序列:Chromium 的并发模型 + +先纠一个常见误解。好多人一听"线程安全",脑子里冒出来的画面就是"多根线程一起上"。Chromium 不这么算账,它的并发模型建在**序列(sequence)**上。 + +序列是什么?您把它当成一条"虚拟线程"就行。里头的任务排队跑,前一个完事才轮到下一个;但这条虚拟线本身不绑死任何一根 OS 线根,它可以在不同的物理线程上轮流借宿,只要不同时挤一块儿就行。 + +为啥要绕这么一圈?笔者当初也纳闷,直接用线程不就完了。后来想通了:浏览器里绝大多数对象的真实诉求是"我想被串行访问,至于哪根线伺候我,不在乎"。您真用线程,就得伺候它那一堆 TLS、消息泵、生命周期;换成序列,这些破事全甩给任务调度器,您只声明一句"这几坨代码归同一序列",调度器保它们不并发。这套思路在 [OnceCallback 实战(一)](../../01_once_callback/full/01-1-once-callback-motivation-and-api-design.md) 里讲过,核心一句话:**消息传递优于锁,序列化优于线程**。 + +WeakPtr 的契约就长在序列这片土上:弱指针您随便 handed off,把 `WeakPtr` 丢给线程池 post 回调没人拦您;可**真要解引用、要让 factory 失效**,必须落回绑定的那个序列。不然 deref 和 invalidate 两头一挤,就是 race。 + +--- + +## SEQUENCE_CHECKER:盯的是"互斥"不是"同线程" + +`SequenceChecker` 这名字有歧义,容易让人觉得它查"是不是同一根线程"。其实它盯的是更底层的东西——**互斥(mutual exclusion)**。一个 `SequenceChecker` 第一次被碰时记下当前上下文,之后每次再被碰,它就核一遍:现在这个上下文,跟我记的那份,是不是互斥的。 + +"互斥"在 Chromium 里头有三个来路,凑上任意一个就算过关:同一根物理线程、同一个序列(同一个 `SequencedTaskRunner`)、或者同一把被追踪的锁。判定靠的是 `SequenceToken`,这玩意儿塞在线程局部存储(TLS)里头,同一个序列的任务被调度出去时都揣着同一个 token,`SequenceChecker` 比对一下 token 就心里有数了。 + +这套机制值钱的地方在哪?并发 bug 里最坑人的那一类,就是"我以为这段是串行的,结果它被并发了"——难复现, debugger 里跑一百次都不带犯一次,上了线用户一点就崩。`SequenceChecker` 让您几乎零成本在 debug 下逮住这帮家伙。 + +## 三个宏:怎么用 + +Chromium 把 `SequenceChecker` 包成三个宏,debug 和 release 的差异全藏在宏后头: + +```cpp +class Controller { +public: + void do_work() { + DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); // 我必须跑在绑定序列 + // ... 业务 ... + } + +private: + SEQUENCE_CHECKER(sequence_checker_); // 声明一个 checker 成员 +}; +``` + +`SEQUENCE_CHECKER(name)` 负责声明成员,搁哪儿都行,成员或者本地变量都可以。`DCHECK_CALLED_ON_VALID_SEQUENCE(name)` 是真正干活的,它赌现在就在绑定序列上;不在的话,debug 下打印调用栈然后 abort,release 下装死什么也不干。`DETACH_FROM_SEQUENCE(name)` 是显式解绑,跟 checker 喊一嗓子"我现在还没绑序列,等下次 `DCHECK_CALLED_ON_VALID_SEQUENCE` 再绑"。这玩意儿用在对象构造时还不知道自己将来在哪个序列服役的场景,挺常见的。 + +WeakPtr 的 `Flag` 就是这套用法的样板。它的 `sequence_checker_` 一构造就先 detach——言下之意,"我还不知道自己归哪个序列",等头一回被 `IsValid`/`Invalidate` 碰了才绑定,往后所有访问都得落同一个序列。这就是 WeakPtr 的 lazy 序列绑定,笔者留到 02-4 专门拆。 + +## release 下全 no-op:零开销怎么来的 + +这块有个事实特别关键,也好多人没留意:**这三个宏在 release 构建(`DCHECK_IS_ON()` 为 false)下,全部编译成空**。 + +挨个说。`SEQUENCE_CHECKER(name)` 展开成一个 `static_assert(true, "")` 占位,半个成员字节都不占,`sizeof(Controller)` 不因为这个变大。`DCHECK_CALLED_ON_VALID_SEQUENCE(name)` 展开成一个空操作占位(真实 Chromium 里是 `EAT_CHECK_STREAM_PARAMS(...)`,作用就是把附带的流参数吞了,啥也不查)。`DETACH_FROM_SEQUENCE(name)` 同样啥也不剩。 + +笔者第一次读到这个的时候愣了一下:debug 抓违规分文不收,release 里这些检查掏的钱是 0,代码全速跑不带任何检查。这就是 `DCHECK` 体系的根本设计——**开发期抓 bug,生产期零开销**。 + +可零开销也有代价。正因为 release 下一点强制力都没有,WeakPtr 的"序列契约"到了 release 里**完全靠开发者自觉**。`MaybeValid()`(02-4)是唯一一个故意不绑序列、能从任意序列调的查询接口,可它的返回值也就是个"乐观估计"。您在 release 里违反了序列契约,程序不会崩给您看,只会在某个不凑巧的节骨眼上给您一发 race。所以 debug 构建里头,每一个 DCHECK 都得当回事——那是您逮这种 bug 几乎唯一的机会,放过了就真没了。 + +## DCHECK vs CHECK:debug 才崩,还是 release 也崩 + +`DCHECK` 和 `CHECK` 是 Chromium 断言体系里两个等级,差别就一条线。 + +`DCHECK(expr)` 只在 debug 构建(`DCHECK_IS_ON()`)下查 `expr`,挂了就 abort;到了 release,整个表达式连带 `expr` 一起不求值,直接蒸发。`CHECK(expr)` 不挑,debug 还是 release 都查,挂了就 abort。 + +说白了,`DCHECK` 是"开发期契约",`CHECK` 是"生产期也要守的红线"。挑哪个,就看这断言挂了之后,release 里您接不接受它继续跑。逻辑写错了、但接着跑不会马上引发内存安全问题的,上 `DCHECK`,debug 抓 release 放,省开销。要是断言挂了意味着"接下来必定 use-after-free 或者更狠的未定义行为",那 release 里也得立刻停,这种就上 `CHECK`。 + +### WeakPtr 的取舍:operator* 上 CHECK,IsValid 上 DCHECK + +带着上面这条原则,咱们瞧瞧 WeakPtr 两个关键断言,它俩选择正好拧着,可背后的逻辑是同一套。 + +`operator*` 和 `operator->` 上的是 `CHECK`,release 也照崩: + +```cpp +T& operator*() const { + CHECK(ref_.IsValid()); // 失效还硬解引用 → release 也 abort + return *ptr_; +} +``` + +凭啥用 `CHECK`?您想啊,解引用一个已经失效的 WeakPtr,等于手里攥着一个**可能早就析构了**的对象的指针,接下来要去戳它的内存——这就是 use-after-free 的直接前身,程序带着悬垂指针接着跑,指不定吐出啥妖蛾子。这种 bug release 里也得立马爆,一刻都不能含糊。所以这里上 `CHECK`:您硬要解引用失效弱指针,管它 debug 还是 release,程序当场中止,给您一个干净的 crash,而不是一笔追不回去的 UAF 烂账。 + +`IsValid` 上的是 `DCHECK`,只 debug 抓: + +```cpp +bool Flag::IsValid() const { + DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); // 序列违规 → 仅 debug 抓 + return !invalidated_.IsSet(); +} +``` + +为啥这里降一档?序列违规是**使用契约**问题,不是"立刻内存安全"问题。您从错的序列调 `IsValid`,结果可能不准,读到个 stale 状态;可它自个儿不会去 deref 悬垂指针——真正危险的 deref 在 `operator*` 那一层已经用 `CHECK` 死死守住了。所以 `IsValid` 这里用 `DCHECK`:debug 期抓序列违规帮您揪 bug,release 期放过去省检查开销。再说了,原子操作本身保证 `IsValid` 的读不会被撕裂,就算 release 里没逮着序列违规,也不至于冒出数据竞争层面的 UB。 + +这俩断言搁一块,WeakPtr 的安全分层就立起来了:**内存安全(deref 失效)用 `CHECK`,天塌下来也得守;使用契约(序列绑定)用 `DCHECK`,开发期抓、生产期信开发者**。后面实战篇每一处断言都会撞上这套取舍,您趁早把它吃透不亏。 + +## 参考资源 + +- [Chromium `base/sequence_checker.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/sequence_checker.h) +- [Chromium threading & sequences 文档](https://chromium.googlesource.com/chromium/src/+/main/docs/threading_and_tasks.md) +- [cppreference: std::atomic 与 memory_order](https://en.cppreference.com/w/cpp/atomic/memory_order) +- [Chromium `base/check.h` —— CHECK/DCHECK](https://source.chromium.org/chromium/chromium/src/+/main:base/check.h) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-04-weak-ptr-concepts-and-requires.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-04-weak-ptr-concepts-and-requires.md new file mode 100644 index 000000000..b8cf0dd37 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-04-weak-ptr-concepts-and-requires.md @@ -0,0 +1,178 @@ +--- +chapter: 0 +cpp_standard: +- 20 +description: 在 01 系列 concepts 基础上,聚焦 WeakPtr 如何用 std::convertible_to 与成员函数 requires + 子句,把 const 正确性与向上转型约束焊进类型签名 +difficulty: intermediate +order: 4 +platform: host +prerequisites: +- OnceCallback 前置知识(四):Concepts 与 requires 约束 +- WeakPtr 前置知识(零):弱引用与生命周期难题 +reading_time_minutes: 10 +related: +- WeakPtr 实战(一):动机与接口设计 +- WeakPtr 前置知识(五):模板友元与 uintptr_t 类型擦除 +tags: +- host +- cpp-modern +- intermediate +- concepts +- 类型安全 +- weak_ptr +title: "WeakPtr 前置知识(四):concepts 与 requires 在 WeakPtr 里的应用" +--- +# WeakPtr 前置知识(四):concepts 与 requires 在 WeakPtr 里的应用 + +## 先把工具摆桌上 + +[前一篇](../../01_once_callback/full/pre-04-once-callback-concepts-and-requires.md) 把 concepts 的基本盘讲过了——`requires` 子句怎么挂、约束怎么短路。这篇不重复那些,咱们直接看 WeakPtr 把 concepts 拿去干了哪两件实事:管住向上转型的合法性(`WeakPtr` 能不能塞给 `WeakPtr`),还有 const 正确性(const 的 factory 不许发可变弱指针)。 + +这两处在 Chromium 的 `weak_ptr.h` 里就寥寥几行,笔者第一次读差点滑过去。但它们恰恰是 concepts 最典型的工程用法——读者扫一眼签名上的 `requires(...)`,就知道这个构造在什么类型关系下才存在,不用翻注释、不用追实现。咱们先把概念回顾一下,再拆它们各自落在哪。 + +一句话带过(细节回 01 那篇看):concept 是个编译期谓词,`requires(expr)` 把它挂在模板参数或成员函数上,意思是"这个模板/重载只在谓词为真时才存在"。WeakPtr 这回用到两个谓词。`std::convertible_to` 判的是 `U*` 能不能隐式转成 `T*`——同类型、派生类到公有基类都算,正好覆盖向上转型那档子事。`std::is_const_v` 判的是 `T` 有没有顶 const,配个 `!` 就能区分 `WeakPtrFactory` 和 `WeakPtrFactory`。 + +--- + +## 转换构造:WeakPtr\ 到 WeakPtr\ 的向上转型 + +笔者先讲个最直白的诉求。手上有 `WeakPtr`,需要 `WeakPtr`,咱们本能就觉得应该能直接塞过去——`Derived*` 本来就能转成 `Base*`。但反过来不行(`Base*` 撑不成 `Derived*`),`WeakPtr` 想转 `WeakPtr` 这种乱七八糟的更别想。 + +Chromium 把这条规则用一条 `requires` 子句刻在签名上(`weak_ptr.h:211-214`): + +```cpp +template +class WeakPtr { +public: + // ... + template + requires(std::convertible_to) + WeakPtr(const WeakPtr& other) : ref_(other.ref_), ptr_(other.ptr_) {} + + template + requires(std::convertible_to) + WeakPtr(WeakPtr&& other) + : ref_(std::move(other.ref_)), ptr_(std::move(other.ptr_)) {} + // 对应的 operator= 同理 +}; +``` + +注意几个细节。首先这是个成员模板,不是普通构造——外层 `WeakPtr` 已经把 `T` 定下来,内层再模板化出一个 `U`,意思是"从任意 `WeakPtr` 构造我"。然后 `requires(std::convertible_to)` 挂在这个成员模板上,只有指针能转时它才参与重载。还有一处容易看漏:它跟默认的拷贝/移动构造是两条分开的路(注释里专门写了 "separate from the (implicit) copy and move constructors"),别把它们搅一块。 + +效果是这样的: + +```cpp +struct Base { virtual ~Base() = default; }; +struct Derived : Base {}; + +WeakPtr wd = factory_derived.get_weak_ptr(); +WeakPtr wb = wd; // ✓ Derived* → Base* 合法 + +WeakPtr wb2 = wb; // ✓ 同类型,也走 convertible_to(B* → B*) +WeakPtr wd2 = wb; // ✗ Base* → Derived* 非法,这个构造不参与,编译错 +WeakPtr wi = wb; // ✗ Base* → int* 非法,编译错 +``` + +后两种错都在编译期就被挡下了。因为用的是 concepts 而不是 SFINAE,编译器报错会直愣愣地指向"约束不满足",不会甩您一脸模板替换栈。把类型契约挪到签名上,该不该转、能不能转,读签名就明白。 + +### 为什么不用 SFINAE + +老写法是这样的: + +```cpp +// 老式 SFINAE:可读性差,报错信息糟糕 +template >> +WeakPtr(const WeakPtr& other) : ref_(other.ref_), ptr_(other.ptr_) {} +``` + +功能等价,但 `typename = std::enable_if_t<...>` 这种凭空塞默认模板参数的套路,读起来比 `requires(...)` 别扭得多,约束失败时编译器也只能硬抠模板替换失败的细节给您看。Chromium 迁到 C++20 以后,新代码一律走 concepts,WeakPtr 这几行就是迁移后的产物。 + +--- + +## 成员函数 requires:const 正确性与可变重载 + +WeakPtrFactory 那边还有个更有意思的玩法,把 `requires` 直接挂在成员函数上,按 `T` 是不是 const 来挑重载。咱们看 `GetWeakPtr`(`weak_ptr.h:374-384`): + +```cpp +template +class WeakPtrFactory : public internal::WeakPtrFactoryBase { +public: + // const 版本:factory 是 const 的,只能发 WeakPtr + WeakPtr GetWeakPtr() const { + return WeakPtr(weak_reference_owner_.GetRef(), + reinterpret_cast(ptr_)); + } + + // 非 const 版本:factory 不是 const 的,发 WeakPtr(可变) + WeakPtr GetWeakPtr() + requires(!std::is_const_v) + { + return WeakPtr(weak_reference_owner_.GetRef(), + reinterpret_cast(ptr_)); + } + // ... +}; +``` + +这里头藏着个笔者第一次没看明白的小机巧。`WeakPtrFactory` 上同时挂着两个 `GetWeakPtr`:一个 `const` 成员函数返回 `WeakPtr`;另一个非 `const` 成员函数返回 `WeakPtr`,可后者偏偏带个 `requires(!std::is_const_v)`。 + +凭什么要加这个 `requires`?咱们拿 `WeakPtrFactory` 想想——这时候 `T = const Foo`,`std::is_const_v` 是真。如果非 const 版的 `GetWeakPtr()` 没约束,它会被实例化成 `WeakPtr GetWeakPtr()`(非 const 成员),返回类型跟 const 版一模一样,可 const 属性却不一样——重载解析要么歧义,要么选错。`requires(!std::is_const_v)` 在 `T` 本身就是 const 的时候把这个非 const 重载掐掉,只留 const 版本,语义就清爽了:factory 是 const 的、或者 `T` 是 const 的,都只能拿到 `WeakPtr`;只有 factory 非 const 而且 `T` 非 const,才拿得到可变的 `WeakPtr`。 + +这套约束把 const 正确性塞进了类型系统,塞得很深。您从一个 const 对象上,在类型层面就拿不到指向它可变状态的 `WeakPtr`——不靠运行时纪律,编译器替您盯着。`GetMutableWeakPtr()`(`weak_ptr.h:386-391`)用的是同一个 `requires(!std::is_const_v)`,保证"可变"路径只在类型允许时才存在。 + +### 一个最小复刻 + +咱们自己搓个最小版,验证约束真在编译期起作用: + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include + +struct Base { virtual ~Base() = default; }; +struct Derived : Base {}; + +template +class MiniWeakPtr { +public: + MiniWeakPtr() = default; + // 向上转型转换构造 + template + requires(std::convertible_to) + MiniWeakPtr(const MiniWeakPtr&) {} +}; + +int main() { + MiniWeakPtr wd; + MiniWeakPtr wb = wd; // ✓ + // MiniWeakPtr wd2 = wb; // ✗ 编译错:Base* → Derived* 不满足 convertible_to + return 0; +} +``` + +把注释那行打开,编译器当场就跳脚(Clang 报 `constraints not satisfied`,GCC 报 `conversion from ... to non-scalar type ... requested`,措辞不一样,意思都是"约束没满足")。什么类型关系合法、什么不合法,从注释里挪进了编译器的检查项。 + +--- + +## 为什么这事值得单独讲 + +您可能会嘀咕:不就是两行 `requires` 吗,至于单开一篇?至于。这两处是 WeakPtr 类型签名上的安全网,分量比看着重。 + +转换构造那条约束拦的是"用 WeakPtr 做不安全的向下转型"。这是个常被忽视的 UAF 入口——向下转型拿错类型的指针,一访问就是越界或者 UB,运行期您根本抓不到。挂上 `requires(std::convertible_to)` 之后,这种错在编译期就死了。const 重载那条约束管的是另一头:让"const 对象不能被弱引用改状态"这条规矩不用人盯着守,类型系统替咱们兜底。 + +更深一层的价值在于,它们示范了 concepts 的工程用法——不是炫技的花活,是把语义约束用类型系统写出来,让接口自己解释自己。咱们到 02-2 搭骨架的时候,会照抄这两条 `requires`,您会发现它们跟 Chromium 源码几乎一字不差。 + +--- + +WeakPtr 里两处 concepts 的用法到这儿就拆完了。转换构造挂 `requires(std::convertible_to)`,让 `WeakPtr → WeakPtr` 合法,反向和无关转换在编译期就拒掉;成员函数 `GetWeakPtr`/`GetMutableWeakPtr` 挂 `requires(!std::is_const_v)`,把 const 正确性塞进类型系统,const 对象拿不到可变弱指针。两处都在演示同一件事——concepts 的真正价值是把语义契约挪到签名上,让编译器去守那些原本靠人盯的规矩。 + +WeakPtr 还有另一处模板技巧——用 `template friend` 解决跨类型私有访问,以及 `WeakPtrFactory` 为什么把指针存成 `uintptr_t`,放在紧接着的那篇里拆。 + +## 参考资源 + +- [cppreference: std::convertible_to](https://en.cppreference.com/w/cpp/concepts/convertible_to) +- [cppreference: requires clause](https://en.cppreference.com/w/cpp/language/constraints) +- [OnceCallback 前置知识(四):Concepts 与 requires 约束](../../01_once_callback/full/pre-04-once-callback-concepts-and-requires.md) +- [Chromium `base/memory/weak_ptr.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-05-weak-ptr-template-friend-and-uintptr-t.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-05-weak-ptr-template-friend-and-uintptr-t.md new file mode 100644 index 000000000..edcbd0b22 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-05-weak-ptr-template-friend-and-uintptr-t.md @@ -0,0 +1,184 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 14 +- 17 +- 20 +description: 拆解 WeakPtr 的两处模板工程技巧——template friend 解决跨类型私有访问,uintptr_t 把指针 + 存储下沉到非模板基类压膨胀,以及 RAW_PTR_EXCLUSION 的取舍 +difficulty: intermediate +order: 5 +platform: host +prerequisites: +- WeakPtr 前置知识(四):concepts 与 requires 在 WeakPtr 里的应用 +- WeakPtr 前置知识(一):侵入式引用计数与 scoped_refptr +reading_time_minutes: 10 +related: +- WeakPtr 实战(二):核心骨架与控制块 +tags: +- host +- cpp-modern +- intermediate +- 模板 +- 泛型 +- 内存管理 +- weak_ptr +title: "WeakPtr 前置知识(五):模板友元与 uintptr_t 类型擦除" +--- +# WeakPtr 前置知识(五):模板友元与 uintptr_t 类型擦除 + +WeakPtr 读到这里,笔者之前一直绕着没讲的两个细节,这会儿得单独拎出来。一个藏在转换构造里——`WeakPtr` 升格成 `WeakPtr` 时,要直接伸手读对方的私有成员,这事儿靠一行 `template friend` 撑着。另一个藏在 `WeakPtrFactory` 的基类那儿,它把指针存成了 `uintptr_t` 而不是 `T*`,听着像多此一举,真要追下去,是个压模板膨胀的实在招。 + +俩都不是语言层面的花活。咱们看完,WeakPtr 那个看起来拧巴的类层次也就顺了。 + +--- + +## 跨类型友元:WeakPtr\ 要访问 WeakPtr\ + +[前置知识(四)](./pre-04-weak-ptr-concepts-and-requires.md) 里那个转换构造,咱们再贴一遍: + +```cpp +template + requires(std::convertible_to) +WeakPtr(const WeakPtr& other) : ref_(other.ref_), ptr_(other.ptr_) {} +``` + +第一眼笔者没觉得不对——直到反应过来它直接读了 `other.ref_` 和 `other.ptr_`,这俩是 `WeakPtr` 的私有成员。私有嘛,按规矩只有类自己和朋友碰得;而 `WeakPtr` 和 `WeakPtr`(当 `U != T`)是两个完全不同的类型,默认互相看不见。 + +那这构造是怎么编过去的?靠的是一行模板友元(`weak_ptr.h:291-292`): + +```cpp +template +class WeakPtr { +public: + // ... +private: + template + friend class WeakPtr; // 所有 WeakPtr 都是 WeakPtr 的朋友 + + internal::WeakReference ref_; + RAW_PTR_EXCLUSION T* ptr_ = nullptr; +}; +``` + +`template friend class WeakPtr;` 这一句,意思是把任意 `U` 套进去得到的 `WeakPtr`,统统算本类的友元。一刷子把所有实例化版本之间的私有壁垒全拆了。 + +这是模板代码里的常规操作。但凡"同模板不同实例化要互访"——转换构造、`WeakPtr` 和 `WeakPtr` 互拷内部状态——模板友元就是答案。它和非模板的 `friend class Foo;` 区别就多一层 `template `,语义是"把这个模板的所有实例化都列成朋友"。 + +顺带一提,Chromium 把 `WeakPtrFactory` 也列成了友元(`weak_ptr.h:293-294`)——factory 铸币时得直接构造 `WeakPtr`、写它的私有成员。这张友元名单,本质就是"谁需要直接碰内脏"的清单。 + +--- + +## uintptr_t:把指针存成整数 + +`WeakPtrFactory` 的类层次长这样(`weak_ptr.h:332-340`): + +```cpp +namespace internal { +class BASE_EXPORT WeakPtrFactoryBase { +protected: + WeakPtrFactoryBase(uintptr_t ptr); + ~WeakPtrFactoryBase(); + internal::WeakReferenceOwner weak_reference_owner_; + uintptr_t ptr_; +}; +} // namespace internal + +template +class WeakPtrFactory : public internal::WeakPtrFactoryBase { +public: + explicit WeakPtrFactory(T* ptr) + : WeakPtrFactoryBase(reinterpret_cast(ptr)) {} + // ... +}; +``` + +盯着 `uintptr_t ptr_;` 这一行看。基类没把指针存成 `T*`,而是存成了一个整数。派生类构造时拿 `reinterpret_cast(ptr)` 把 `T*` 拍扁成整数塞进去,回头用(`GetWeakPtr`)再 `reinterpret_cast(ptr_)` 转回来(`weak_ptr.h:375,383`)。 + +直接存 `T*` 行不行?功能上完全行。那为什么还要绕这一圈——为了压模板膨胀。 + +`WeakPtrFactory` 是模板,每个不同的 `T` 都得实例化一份完整类代码。`ptr_` 如果是 `T*`,那"存指针、读指针"这套和 `T` 八竿子打不着的操作,会被编译器给每个 `T` 都生成一份。可生成的机器码又完全一样——指针就是指针,`T*` 在机器层面都不过是个地址。 + +把 `ptr_` 换成 `uintptr_t`(一个跟 `T` 无关的整数类型),再把它沉到一个非模板基类 `WeakPtrFactoryBase` 里,这招就好使了:"存指针"的逻辑归了非模板基类,管它 `T` 是什么,基类代码只生成一份。模板派生类只留着和 `T` 真有关系的活——类型转换、`GetWeakPtr` 的返回类型。把"类型无关的脏活"从模板里扒出来,扔进非模板基,这是压二进制体积的老办法。搁浏览器这种有几千个 `WeakPtrFactory` 实例化的工程里,省下来的字节数是真能看见的。 + +安全性也别担心。`reinterpret_cast` 在 `T*` 和 `uintptr_t` 之间往返,标准是认的——指针和足够大的整数类型互转,`uintptr_t` 当年就是为这事儿设计的。唯一的前提是转回来的 `T` 得跟当初存进去的一致,这个由 `WeakPtrFactory` 自己的类型守着。 + +--- + +## RAW_PTR_EXCLUSION:为什么 `ptr_` 不用 raw_ptr + +这一段是 Chromium 家特有的,但笔者觉得值得单独讲——它把"通用工具在特定场景下反而帮倒忙"这事摆得很清楚。 + +Chromium 给 `//base` 做内存安全加固的时候,把大批裸指针 `T*` 换成了 `raw_ptr`——一个挂了 PartitionAlloc backup-ref 计数的智能指针包装。好处是能抓 use-after-free:被释放的内存不会马上被复用,而是留在隔离区里,真有人偷摸访问,当场逮住。 + +可 `WeakPtr::ptr_` 偏偏不用 `raw_ptr`,就留着裸 `T*`,还特意标了个 `RAW_PTR_EXCLUSION`(`weak_ptr.h:311`): + +```cpp +// This pointer is only valid when ref_.is_valid() is true. Otherwise, its +// value is undefined (as opposed to nullptr). The pointer is allowed to +// dangle as we verify its liveness through `ref_` before allowing access to +// the pointee. We don't use raw_ptr here to prevent WeakPtr from keeping +// the memory allocation in quarantine, as it can't be accessed through the +// WeakPtr. +RAW_PTR_EXCLUSION T* ptr_ = nullptr; +``` + +注释把话说透了:`ptr_` 就是允许悬垂的。WeakPtr 的设计本就是"对象可以先死,只要 flag 翻了面,deref 之前会被 `IsValid()` 挡住"。也就是说,对象析构之后,`ptr_` 会在一段时间里合法地指向一块已经释放的内存——这是设计自己要的悬垂,不是 bug。 + +换成 `raw_ptr` 会怎么样?这个悬垂指针会让 PartitionAlloc 把那块内存扣在隔离区里不回收(backup-ref 计数还非零),直到所有 WeakPtr 全销毁。一个本来就允许悬垂的类型,被这么一包,等于白拖一大片内存。所以 WeakPtr 退回到裸指针,拿 `RAW_PTR_EXCLUSION` 明说:这儿有悬垂风险,我心里有数,是设计的一部分,别拿 raw_ptr 套它。 + +笔者在这儿琢磨了半天。安全工具不是堆得越多越稳,得看它跟对象的生命周期模型搭不搭。WeakPtr 那套"允许悬垂 + flag 守门",跟 raw_ptr 那套"绝不悬垂 + 隔离区",俩模型本来就顶着的。硬凑一块,安全没多一分,内存倒先被拖垮。 + +--- + +## 最小复刻:非模板基 + 模板派生 + +光讲不练印象不深,笔者把这套分层扒个最小骨架出来,咱们跑一遍看看: + +```cpp +// Platform: host | C++ Standard: C++17 +#include +#include + +namespace internal { +class FactoryBase { +protected: + FactoryBase(uintptr_t p) : ptr_(p) {} + ~FactoryBase() { ptr_ = 0; } // 非模板基:这份代码只生成一次 + uintptr_t ptr_; // 指针存成整数,和 T 无关 +}; +} // namespace internal + +template +class Factory : public internal::FactoryBase { +public: + explicit Factory(T* p) : FactoryBase(reinterpret_cast(p)) {} + + T* get() const { + return reinterpret_cast(ptr_); // 转回来,类型由模板保证 + } +}; + +struct Foo { int x = 42; }; + +int main() { + Foo f; + Factory fac(&f); + std::cout << fac.get()->x << '\n'; // 42 + return 0; +} +``` + +`FactoryBase` 压根不知道 `T` 是什么,它只管"存一个整数"。模板膨胀只剩那层薄薄的 `Factory` 派生,核心存储逻辑大家共用一份。WeakPtr 真实结构比这复杂(还挂着 refcount 的 flag),但分层思路一模一样。 + +两处模板工程技巧拆完了。`template friend class WeakPtr;` 让同模板的不同实例化(`WeakPtr` 和 `WeakPtr`)互访私有成员,这是转换构造能直接读 `other.ref_`/`other.ptr_` 的前提;`WeakPtrFactoryBase` 把指针存成 `uintptr_t` 沉到非模板基类,让"存指针"这份跟类型无关的逻辑只生成一份,压模板膨胀;`RAW_PTR_EXCLUSION` 则是个反向取舍——WeakPtr 允许 `ptr_` 悬垂的模型跟 `raw_ptr` 的隔离区顶着,所以特意退回裸指针。 + +前置知识就剩最后一块了:`TRIVIAL_ABI`——让有非平凡析构的 WeakPtr 还能按平凡类型传参进寄存器。 + +## 参考资源 + +- [cppreference: friend declaration 与 template friend](https://en.cppreference.com/w/cpp/language/friend) +- [cppreference: uintptr_t](https://en.cppreference.com/w/cpp/types/integer) +- [Chromium `base/memory/weak_ptr.h` —— 类层次](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [Chromium MiraclePtr / raw_ptr 设计文档](https://chromium.googlesource.com/chromium/src/+/main/docs/unsafe_relocations.md) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md new file mode 100644 index 000000000..b09d17b31 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md @@ -0,0 +1,103 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: 拆解 [[clang::trivial_abi]] / TRIVIAL_ABI 的语义、代价,以及 WeakPtr 作为一个有非平凡析构的类型 + 为什么能安全标注——这是设计规避的结果而非属性固有 +difficulty: intermediate +order: 6 +platform: host +prerequisites: +- WeakPtr 前置知识(一):侵入式引用计数与 scoped_refptr +- WeakPtr 前置知识(五):模板友元与 uintptr_t 类型擦除 +reading_time_minutes: 11 +related: +- WeakPtr 实战(一):动机与接口设计 +- WeakPtr 实战(六):测试与性能对比 +tags: +- host +- cpp-modern +- intermediate +- 零开销抽象 +- 优化 +- weak_ptr +title: "WeakPtr 前置知识(六):TRIVIAL_ABI 与平凡可重定位" +--- +# WeakPtr 前置知识(六):TRIVIAL_ABI 与平凡可重定位 + +前面几篇咱们把 WeakPtr 的成员摸清了:一个 `WeakReference`(里头是 `scoped_refptr`,持引用计数),一个 `T* ptr_`。它有非平凡析构——析构时得 dec flag 的引用计数。按 C++ 默认 ABI 规则,这种类型在函数调用里不能直接进寄存器按值传,得绕道走内存(栈或者隐藏的引用参数)。 + +但您要是翻开 Chromium 的 `weak_ptr.h`,会发现 `WeakPtr` 和 `WeakReference` 上都挂了个叫 `TRIVIAL_ABI` 的属性(`weak_ptr.h:101,203`)。挂上之后,有非平凡析构的类型在调用约定里被当平凡类型处理,可以进寄存器、按值高效传递。WeakPtr 一共就两个指针大,标完之后函数间传来传去就跟传两个指针一样便宜。 + +笔者第一次看到这事儿是懵的。一个持引用计数的类型,凭什么敢标"平凡"?这里有个反直觉的点值得咱们花一篇来拆:`trivial_abi` 到底是什么、代价是什么,以及——WeakPtr 凭什么满足它的安全条件。答案不是"引用计数无关紧要",而是这套设计刻意把每块零件都摆到了能安全标注的位置上。 + +--- + +## 平凡类型 vs 非平凡类型:ABI 视角 + +C++ 把类型分成"平凡(trivial)"和"非平凡(non-trivial)"两档,调用约定(ABI)上区别对待。 + +平凡类型比如 `int`、`std::pair`、POD 结构,拷贝、移动、析构都"什么都不做",或者说 bitwise 等价于 memcpy。这种类型函数调用时可以直接塞寄存器按值传,返回也能走寄存器,开销小到几乎可以忽略。 + +非平凡类型——只要非平凡的拷贝构造、移动构造、析构里有任意一个,就算——传递时 ABI 通常走内存:栈上开一份,或者塞个隐藏的引用参数。原因是编译器得保证那些非平凡操作(比如析构时 dec 引用计数)在它该跑的地方跑,不能简单 memcpy。 + +WeakPtr 因为持 `scoped_refptr`(析构要 dec refcount),是非平凡类型。按默认 ABI,传一个 WeakPtr 得走内存,进不了寄存器。 + +--- + +## [[clang::trivial_abi]] 是什么 + +`[[clang::trivial_abi]]` 是 Clang 的一个类型属性,Chromium 把它包成了 `TRIVIAL_ABI` 宏——`__has_cpp_attribute(clang::trivial_abi)` 为真时展开成该属性,否则为空(`base/compiler_specific.h`)。 + +它的语义说白了就一句:让这个非平凡类型在调用约定里被当成平凡类型处理。两个具体效果。一是按值传参或按值返回时可以走寄存器,跟平凡类型一个待遇。二是对象可以被"重定位(relocate)"——把"移动 + 析构源对象"这两步合成一次 memcpy 搬运。 + +这里头有个前提(Clang 文档专门强调了),标的类型必须是平凡可重定位(trivially relocatable)的。意思是这样:把对象从一处 bitwise 搬到另一处、然后忽略原处的析构,这件事在语义上得跟"对它做一次 move 构造 + 析构源对象"完全等价。满足了,编译器就能拿 memcpy 顶替 move+destroy,放心地用寄存器传参。 + +--- + +## 代价:析构时机/位置改变 + +`trivial_abi` 不是白吃的午餐,它动了析构的时机和位置。 + +对一个普通非平凡类型,ABI 严格规定了"这个临时对象在哪一层栈帧析构"。标了 `trivial_abi` 之后,对象可能在调用方栈帧里构造、被装进寄存器传过去、在被调用方栈帧里析构——析构落点变了。类型真要是平凡可重定位,这变化无所谓(move+destroy 反正等价 memcpy)。但类型要不是真正平凡可重定位——比如析构里有不能省的副作用——强标上去就会出 bug。 + +代价就在这。Clang 文档因此专门提醒:`trivial_abi` 只能用在真正满足条件的类型上,不能随手贴。 + +--- + +## WeakPtr 为什么能安全标注 + +那 WeakPtr 持着引用计数(通过 `scoped_refptr`),它凭什么满足"平凡可重定位"?咱们一块一块拆。 + +先看 `T* ptr_`。它是个裸指针,拷贝、移动、析构都是平凡的(bitwise),完全不碰 inc/dec。裸指针本身就是平凡可重定位的,对 `trivial_abi` 没任何障碍。 + +真正的关键在 `WeakReference ref_` 里那个 `scoped_refptr`。它不是平凡类型——拷贝要 inc refcount,析构要 dec。那它是不是"平凡可重定位"? + +咱们推一下。scoped_refptr 的 move 构造干了什么?把源对象的裸指针偷过来(`ptr_ = other.ptr_; other.ptr_ = nullptr;`),不 inc。源对象随后被析构时,它的 `ptr_` 已经是 nullptr,`release()` 走出去是个 no-op。把这两步合起来看,净效果就是把裸指针 bitwise 搬过去,没 inc、没 dec,引用计数纹丝不动。 + +这正好就是"平凡可重定位"的定义。所以 scoped_refptr 虽然非平凡,却平凡可重定位。WeakPtr 持有它,继承了这个性质;加上 `ptr_` 部分天然平凡,整个 WeakPtr 满足 `trivial_abi` 的前提。 + +还有一层容易漏的保证。`trivial_abi` 让析构位置不确定——某个 WeakPtr 实例可能最终在一个"意外"的线程上被析构(比如调用方栈帧里),它持有的 scoped_refptr 会在那个线程 dec flag 的 refcount。这就要求 flag 的引用计数操作跨线程安全,正是 [前置知识(一)](./pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md) 里讲的 `RefCountedThreadSafe`(原子 inc/dec)兜的底。再加上 `Flag::Invalidate` 里 `HasOneRef()` 那个跨线程析构豁免,flag 在任意线程减到 0 析构都安全。没这一层,`trivial_abi` 叠上"析构位置漂移",跨线程场景下迟早翻车。 + +--- + +## 这是设计规避,不是属性固有 + +讲到这里,有个结论笔者得强调一下,免得您拿这套思路去贴别的类型时翻车。 + +`trivial_abi` 对"持有引用计数句柄"的类型,并不是固有安全的。WeakPtr 能标,是因为它的成员被刻意设计成了平凡可重定位——而不是"任何带 refcount 的类型都能随便贴这个属性"。 + +举个反例。您要是自己写个类,手动管理引用计数(拷贝构造里 `++count_`,析构里 `--count_` 且归零时清理),却没保证"move+destroy-source 等价于 memcpy",那强标 `trivial_abi` 就可能让编译器在错误的位置或时机省掉一次析构。引用计数一失准,轻的泄漏,重的 double-free,Clang 文档和 Chromium 自己的注释都专门警告过这一点。 + +回到 WeakPtr,它的安全是三层共同撑起来的。`ptr_` 是裸指针,天然平凡;`scoped_refptr` 虽非平凡但平凡可重定位,move+destroy 等价 memcpy;`Flag` 用 `RefCountedThreadSafe`,跨线程析构有 `HasOneRef()` 兜底。三层缺一不可。所以您在 02-6 看到 WeakPtr 标了 `TRIVIAL_ABI` 享受寄存器传递的好处时,这份"零开销"是前面大量设计换来的——开销没消失,是被设计提前消解掉了。这正是 Chromium 的零开销抽象哲学。 + +前置知识七篇到这就齐了:pre-00 弱引用导论,加上 pre-01~06 这六块零件——侵入式引用计数、原子与 memory order、序列与 DCHECK/CHECK、concepts、模板友元与 uintptr_t、TRIVIAL_ABI。零件凑齐,该动手把 WeakPtr 的核心骨架搭起来了,咱们下一篇就看看这七块零件怎么咬合成一整套工业级弱指针。 + +## 参考资源 + +- [Clang 文档: `trivial_abi` 属性](https://clang.llvm.org/docs/AttributeReference.html#trivial-abi) +- [cppreference: TrivialType 与平凡性](https://en.cppreference.com/w/cpp/named_req/TrivialType) +- [Chromium `base/compiler_specific.h` —— TRIVIAL_ABI 宏](https://source.chromium.org/chromium/chromium/src/+/main:base/compiler_specific.h) +- [Chromium `base/memory/weak_ptr.h` —— 101/203 行标注](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/01-weak-ptr-design.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/01-weak-ptr-design.md new file mode 100644 index 000000000..73cfcb1e7 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/01-weak-ptr-design.md @@ -0,0 +1,137 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 面向有模板与并发经验的读者,快速走读 Chromium WeakPtr 的设计动机、接口与控制块两层架构 + ——本系列 full/ 的精炼设计指南版 +difficulty: advanced +order: 1 +platform: host +prerequisites: +- 移动语义与完美转发 +- std::atomic 与 memory_order +- C++20 concepts 与 requires +- OnceCallback 实战(四):取消令牌设计 +reading_time_minutes: 8 +related: +- weak_ptr 设计指南(二):逐步实现 +- weak_ptr 设计指南(三):测试策略与性能对比 +tags: +- host +- cpp-modern +- advanced +- 智能指针 +- weak_ptr +- 内存管理 +title: "weak_ptr 设计指南(一):动机、接口与控制块设计" +--- +# weak_ptr 设计指南(一):动机、接口与控制块设计 + +> hands-on 轨,默认您已熟 acquire/release、concepts、侵入式引用计数;不熟的话先过一遍 [full/ 前置知识](../full/pre-00-weak-ptr-weak-reference-and-lifetime.md)。 + +上次手搓 OnceCallback 的取消令牌,笔者图省事甩了个原子标志上去——对象析构置位,回调跑前 if 一下,悬空确实没了。但这玩意儿有个笔者一开始没在意的尾巴:那个标志到底归谁管?回调又怎么拿到它?笔者自己当时也没想清楚。Chromium 在 `//base` 里把这事儿做透了,答案就是 `WeakPtr`。咱们这一篇先拆动机、接口和控制块架构;实现和测试留给后两篇,不碰代码细节,只把“为什么这么设计”想明白。 + +## 问题:`std::weak_ptr` 在异步回调的四个局限 + +`std::weak_ptr` 本身是通用的、正确的设计,这一点没得洗。但您要把它塞进"任务投递 + 不介入所有权 + 序列化执行"这套体系里,它会顶您四个地方: + +第一,它绑死了 `shared_ptr`。您想拿弱引用,就得先把对象改成 `shared_ptr` 管理——本来一个 owner 干干净净,这下所有权模型被您亲手扭曲了。 + +第二,它的控制块是非侵入式的。两条路:要么两次堆分配,要么 `make_shared` 把对象和控制块合并到一块内存。后者看似省事,可一旦挂个长寿的 `weak_ptr`,整块对象内存就跟着赖着不释放了,这在嵌入式或长生命周期进程里很扎眼。 + +第三个更隐蔽,笔者也是真正想“批量作废”才撞上的:`std::weak_ptr` 没有"一次失效一批"。它的失效是引用计数驱动的副作用——最后一个 `shared_ptr` 没了,弱引用就自然失效。可您想表达"对象还活着,但它进入了一个不该再被回调的状态"呢?没有这种 API,您得自己拿外层标志绕一圈。 + +第四,它没有序列亲和。原子操作本身是安全的,可"解引用要不要同步"这事儿它不管,丢给您自己拍。在"任务跑在序列上"的体系里,这种自由度反而是个坑——您迟早会忘掉某次解引用该不该加锁。 + +## Chromium WeakPtr 的设计哲学 + +上面这四条,WeakPtr 一条条给您怼回去了。取舍笔者列在下表,您看一眼就能对上号: + +| 局限 | WeakPtr 的目标 | +|---|---| +| 必须配 shared_ptr | **不介入所有权**——观察者而非所有者 | +| 非侵入式控制块 | **侵入式引用计数**——Flag 用 `RefCountedThreadSafe`,一次分配 | +| 不能批量失效 | **共享 Flag**——一个 factory invalidate,所有 WeakPtr 集体失效 | +| 没有序列亲和 | **序列绑定**——deref/失效要在绑定序列,debug 下 DCHECK 抓 | + +笔者觉得最抽象的一步在这里:Chromium 干脆把"对象死没死"这件事从对象身上剥出来,单独做成一个被引用计数的小对象——它管这玩意儿叫 Flag。factory 和所有 WeakPtr 共享同一枚 Flag。这么一拆,"一次 invalidate、所有观察者集体失效"几乎是白送的——您只要让 Flag 翻个面,所有持有它的 WeakPtr 下次判活全都是 false。更妙的是,对象什么时候析构,跟 WeakPtr 再没关系了:对象照常被它的 owner 删,Flag 自己被引用计数管着,两件事彻底解耦。 + +## 接口设计 + +对外能看到的 API 就这么点,笔者第一次读完还挺意外的——这么强大的机制,门面这么朴素: + +```cpp +// 弱指针句柄:不延寿,能判活 +template class [[clang::trivial_abi]] WeakPtr { +public: + WeakPtr() = default; + WeakPtr(std::nullptr_t); + + template requires(std::convertible_to) // 向上转型 + WeakPtr(const WeakPtr&); + + T* get() const; // 失效返回 nullptr,不崩 + T& operator*() const; // 失效 → CHECK/assert + T* operator->() const; + explicit operator bool() const; + void reset(); + + bool maybe_valid() const; // 跨序列 hint:负面可信/正面不可信 + bool was_invalidated() const; // 区分"被作废"与"主动 reset" +}; + +// 铸币厂:挂被观察对象上,批量失效 +template class WeakPtrFactory { +public: + explicit WeakPtrFactory(T* ptr); + WeakPtr get_weak_ptr(); // 非 const 重载 requires(!is_const_v) + WeakPtr get_weak_ptr() const; + void invalidate_weak_ptrs(); // 失效 + 铸新 Flag + void invalidate_weak_ptrs_and_doom(); // 失效 + 不再铸(更高效) + bool has_weak_ptrs() const; +}; +``` + +几个签名上的小取舍,笔者这里顺手讲一下(详细的去看 [full/02-1](../full/02-1-weak-ptr-motivation-and-api-design.md))。`operator*` 和 `operator->` 用 `CHECK` 而不是 `DCHECK`——失效之后还去解引用,这是确定的 bug,release 也得崩给您看;`get()` 则老实返回裸指针,当作逃生口。还有一处笔者刚开始没想通的:为什么不给 `operator==` 和 `<=>`?后来才反应过来——弱引用的比较本来就飘忽不定,两个 WeakPtr 此刻相等、下一刻一个失效一个没失效,这种语义您比了也没意义。另外 `WeakPtrFactory` 走组合不走继承,而且它必须是最后一个成员(析构顺序的事,后面会讲)。 + +## 内部机制:控制块的两层架构 + +这套机制看着复杂,其实可以叠成四层。咱们会发现它跟 OnceCallback 的 `BindState` 是同一个模子出来的——底层做类型擦除 + 引用计数,顶层甩一个轻量句柄出去。会一个,另一个基本白送: + +```mermaid +flowchart TB + subgraph FAC["WeakPtrFactory<T> — 铸币 + 批量失效"] + RO["WeakReferenceOwner
Flag 的发行方(持 flag,析构即 invalidate)"] + end + subgraph WP["WeakPtr<T> — 用户句柄(TRIVIAL_ABI,16 字节)"] + subgraph WR["WeakReference — 引用侧(scoped_refptr<const Flag>)"] + Flag["Flag — refcounted + 原子 liveness(acquire/release)"] + end + end + RO -.-> Flag +``` + +真正干活的就一个东西:Flag。它是 `RefCountedThreadSafe`(要跨序列共享),里头就一个 `AtomicFlag invalidated_` 当 liveness 位。`Invalidate` 做 release-store,`IsValid` 做 acquire-load——这一对配好了 happens-before,意思是"只要读到失效,那就一定看得到对象进入失效态之前的所有写",全程不用加锁。Flag 还顺手挂了个 `SEQUENCE_CHECKER`,是 lazy 绑定的,release 编译下吃 0 字节、纯 no-op,debug 下才真正起作用。 + +还有一点要分清:Flag 的引用计数只管它自己——只要还有 WeakPtr 持有,它就在;被指的对象该析构就析构,Flag 不拦。咱们前面说的“解耦”,就落在这。 + +## 关键设计决策 + +| 决策 | 选择 | 理由 | +|---|---|---| +| deref 失效的断言等级 | `CHECK`(release 也崩) | UAF 前身,必须立即爆 | +| 序列违规的断言等级 | `DCHECK`(仅 debug) | 契约违规非立即内存安全,debug 抓即可 | +| `ptr_` 用裸指针 + `RAW_PTR_EXCLUSION` | 允许悬垂 | deref 前由 `IsValid` 守门;raw_ptr 隔离区会拖内存 | +| 存指针用 `uintptr_t`(在 factory 基类) | 非模板基类下沉 | 压模板膨胀(每 T 只生成薄派生层) | +| `WeakPtrFactory` 组合 vs 继承 | 组合 | 灵活、可对任意类型、不污染继承链 | +| `[[clang::trivial_abi]]` | 标注 | `ptr_` 裸平凡 + scoped_refptr 平凡可重locate;进寄存器传参 | + +架构和签名到这儿算是讲清楚了。但讲清楚是一回事,真要把它一行行撸出来,咱们会撞上不少纸面看不出来的坑——`trivial_abi` 怎么才能不破坏 invariant、`WeakPtrFactory` 为什么非得排最后一个成员、`uintptr_t` 那一手模板瘦身到底省在哪。下一篇咱们就把这些承诺落到代码里。 + +## 参考资源 + +- [Chromium `base/memory/weak_ptr.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [weak_ptr 设计指南(二):逐步实现](./02-weak-ptr-implementation.md) +- [WeakPtr 前置知识(零):弱引用与生命周期难题](../full/pre-00-weak-ptr-weak-reference-and-lifetime.md) +- [OnceCallback 设计指南(一):动机与接口设计](../../01_once_callback/hands_on/01-once-callback-design.md) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/02-weak-ptr-implementation.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/02-weak-ptr-implementation.md new file mode 100644 index 000000000..b05e462a3 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/02-weak-ptr-implementation.md @@ -0,0 +1,302 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 逐层实现 WeakPtr 四件套——RefCountedThreadSafe/AtomicFlag/Flag/WeakReference/WeakPtr/ + WeakPtrFactory,含序列检查与 lazy 绑定,代码密集少废话 +difficulty: advanced +order: 2 +platform: host +prerequisites: +- weak_ptr 设计指南(一):动机、接口与控制块设计 +- WeakPtr 前置知识(一):侵入式引用计数与 scoped_refptr +- WeakPtr 前置知识(二):std::atomic 与 memory_order +reading_time_minutes: 13 +related: +- weak_ptr 设计指南(三):测试策略与性能对比 +- WeakPtr 实战(二):核心骨架与控制块 +tags: +- host +- cpp-modern +- advanced +- 智能指针 +- weak_ptr +- atomic +- 引用计数 +title: "weak_ptr 设计指南(二):逐步实现" +--- +# weak_ptr 设计指南(二):逐步实现 + +> hands-on 轨,代码密集。细节论证翻 [full/02-2](../full/02-2-weak-ptr-core-skeleton-and-control-block.md) 与 [full/02-3](../full/02-3-weak-ptr-factory-and-last-member.md),配套可编译工程在 `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/`(`16_weak_ptr_skeleton.cpp`、`17_weak_ptr_factory.cpp`)。 + +上一篇咱们把架构和"为什么这么设计"想明白了,四层叠出来挺漂亮:Flag、WeakReference、WeakPtr、WeakPtrFactory。可纸面讲清楚是一回事,真要把这套东西一行行撸出来,坑比您预想的多。笔者写这一篇的时候反复在想,Chromium 那帮人是怎么把"析构 private 堵外部 delete"这种细节抠出来的,`WeakPtrFactory` 凭什么非得排最后一个成员,`uintptr_t` 那一手模板瘦身到底省在哪。咱们这一篇就边写边拆,从最底下的引用计数一路搭到 factory。 + +## 第 0 层:引用计数 + 原子标志 + +搭房子先打地基。最底下两块砖咱们上一篇提过:一个跨序列安全的侵入式引用计数基类,一个一次性 release/acquire 的原子标志。先别急着往上叠,这两块本身就有讲究。 + +```cpp +// Platform: host | C++ Standard: C++17 +#pragma once +#include +#include +#include +#include +#include + +namespace tamcpp::chrome::internal { + +class RefCountedThreadSafe { +public: + void add_ref() const noexcept { ref_count_.fetch_add(1, std::memory_order_relaxed); } + bool release() const noexcept { + return ref_count_.fetch_sub(1, std::memory_order_acq_rel) == 1; // 减到 0 返回 true + } + bool has_one_ref() const noexcept { return ref_count_.load(std::memory_order_acquire) == 1; } +protected: + RefCountedThreadSafe() = default; + ~RefCountedThreadSafe() = default; +private: + mutable std::atomic ref_count_{0}; +}; + +// 对应 base::AtomicFlag:一次性、release-Set / acquire-IsSet +class AtomicFlag { +public: + void Set() noexcept { flag_.store(1, std::memory_order_release); } + bool IsSet() const noexcept { return flag_.load(std::memory_order_acquire) != 0; } +private: + std::atomic flag_{0}; +}; + +} // namespace tamcpp::chrome::internal +``` + +这里笔者重点说一下 `release` 为什么用 `acq_rel` 而不是 `release`。减法这一步得读到最新的 count(否则两个线程同时减到负数就完蛋了),归零那一刻又得把"析构前的所有写"发布给接管 delete 的线程,两头都要,所以 `acq_rel`。`AtomicFlag` 这边是故意把 `std::atomic` 收窄成一次性语义,没有 public clear,release/acquire 配对。意思是"Set 之前的写,IsSet 之后的读一定能看见",这正是后面 `Invalidate` 之后所有 WeakPtr 能同步看到失效的根基。 + +## 第 0.5 层:序列检查器(debug-only) + +地基搭完,先别急着上 Flag,咱们还得备一个 debug 用的"序列检查器"。Chromium 真正用的是 SequenceToken,概念上比线程 id 精细,能区分"同一线程跑两个序列"这种情况;教学版笔者就用线程 id 模拟一下,够把意思讲清楚就行。它最妙的地方在于 release 编译下整个类退化成两个 no-op,一个字节都不占。这点优化后面咱们会反复用到。 + +```cpp +#if defined(NDEBUG) +class SequenceChecker { +public: + void detach_from_sequence() noexcept {} + bool called_on_valid_sequence() const noexcept { return true; } +}; +#else +class SequenceChecker { +public: + void detach_from_sequence() noexcept { bound_ = std::thread::id{}; } + bool called_on_valid_sequence() const noexcept { + if (bound_ == std::thread::id{}) { bound_ = std::this_thread::get_id(); return true; } + return bound_ == std::this_thread::get_id(); + } +private: + mutable std::thread::id bound_; +}; +#endif +``` + +## 第 1 层:Flag —— refcounted 的 liveness + +地基备齐,该把上一篇反复念叨的那个 Flag 立起来了。它是 `RefCountedThreadSafe` 的派生,里头就一枚 `AtomicFlag` 当 liveness 位,外加一个 lazy 绑定的 `SequenceChecker`。整套判活、失效、跨序列析构,都靠它一个对象扛。 + +```cpp +namespace tamcpp::chrome::internal { + +class Flag : public RefCountedThreadSafe { +public: + Flag() { seq_.detach_from_sequence(); } // 构造时未绑定(lazy) + + void Invalidate() noexcept { + // 同序列,或只剩一个引用(允许跨线程析构) + assert(seq_.called_on_valid_sequence() || has_one_ref()); + invalidated_.Set(); // release-store + } + bool IsValid() const noexcept { + assert(seq_.called_on_valid_sequence()); // 首次触碰 → 绑定 + return !invalidated_.IsSet(); // acquire-load + } + bool MaybeValid() const noexcept { + return !invalidated_.IsSet(); // 无序列断言,任意序列可调 + } +private: + template friend class scoped_refptr; // 允许计数归零时 delete + ~Flag() = default; // private:受控析构 + mutable SequenceChecker seq_; + AtomicFlag invalidated_; +}; + +} // namespace tamcpp::chrome::internal +``` + +这块是整套机制的心脏,咱们细看几个笔者反复踩过的地方。 + +第一眼看构造函数,`Flag()` 里那句 `seq_.detach_from_sequence()` 有点反直觉——刚构造出来就 detach?其实是 lazy 绑定:Flag 构造在哪条序列都行,真正第一次有人碰它(调 `IsValid` 或 `Invalidate`)的时候才记下当前序列。这么做是因为 Flag 经常在一条序列上构造,然后被另一条序列上的 WeakPtr 拿去用,构造期不能贸然绑死。 + +析构写成 `private ~Flag()` 这一手,是堵"有人拿了裸指针自己 delete"。Flag 的命只能由引用计数管,外面谁都不许插手。`Invalidate` 和 `IsValid` 里的 `assert` 对应 Chromium 的 `DCHECK`,意思是这两个调用默认得在绑定的序列上——否则 lazy 绑定就被破坏了。`Invalidate` 那个 `assert` 还多了个 `|| has_one_ref()` 的后路,是为了允许"最后一个引用的线程跨序列析构"这种合法场景。 + +真正容易看漏的是 `MaybeValid`,它故意不碰 `seq_`。为什么?因为这个接口的设计意图就是"任意序列都能调一下看个大概",一旦它也走 `IsValid` 那套 acquire + 序列断言,就会在跨序列调用时把 `seq_` 错绑过去,正面的判活结果就再也不能信了。所以它走的是另一条更松的通道,只读原子位、不做序列约束——记住这个区分,下一篇讲测试策略时咱们还会回来。 + +## 第 2 层:WeakReference + scoped_refptr + +Flag 立起来之后,得有个东西替它管引用计数。`scoped_refptr` 是侵入式引用计数的标准操作,笔者这里给个极简版,完整版在前置知识里讲过。`WeakReference` 则是 Flag 的引用侧封装:它持有一个 `scoped_refptr`,对外暴露 `IsValid` / `MaybeValid` / `Reset` 三个动作,基本就是把 Flag 的接口转手一遍。 + +```cpp +namespace tamcpp::chrome::internal { + +template +class scoped_refptr { // 简化版,完整见 pre-01 +public: + scoped_refptr() noexcept = default; + explicit scoped_refptr(T* p) noexcept : ptr_(p) { if (ptr_) ptr_->add_ref(); } + scoped_refptr(const scoped_refptr& o) noexcept : ptr_(o.ptr_) { if (ptr_) ptr_->add_ref(); } + scoped_refptr(scoped_refptr&& o) noexcept : ptr_(o.ptr_) { o.ptr_ = nullptr; } + ~scoped_refptr() { if (ptr_ && ptr_->release()) delete ptr_; } + scoped_refptr& operator=(scoped_refptr r) noexcept { T* t = ptr_; ptr_ = r.ptr_; r.ptr_ = t; return *this; } + T* get() const noexcept { return ptr_; } + explicit operator bool() const noexcept { return ptr_ != nullptr; } +private: + T* ptr_ = nullptr; +}; + +class WeakReference { +public: + WeakReference() = default; + explicit WeakReference(const scoped_refptr& flag) : flag_(flag) {} + bool IsValid() const noexcept { return flag_ && flag_->IsValid(); } + bool MaybeValid() const noexcept { return flag_ && flag_->MaybeValid(); } + void Reset() noexcept { flag_ = nullptr; } +private: + scoped_refptr flag_; +}; + +} // namespace tamcpp::chrome::internal +``` + +## 第 3 层:WeakPtr\ —— 用户句柄 + +终于到用户能摸到的 API 了。WeakPtr 内部就两样东西:一个 `WeakReference`、一个裸指针 `ptr_`。判活全靠前者,真正解引用用后者。咱们上一篇提过的那个 `[[clang::trivial_abi]]`,就挂在这个类头上。 + +```cpp +namespace tamcpp::chrome { + +template class WeakPtrFactory; + +template +class [[clang::trivial_abi]] WeakPtr { +public: + WeakPtr() = default; + WeakPtr(std::nullptr_t) noexcept {} + + template requires(std::convertible_to) // 向上转型 + WeakPtr(const WeakPtr& o) noexcept : ref_(o.ref_), ptr_(o.ptr_) {} + template requires(std::convertible_to) + WeakPtr(WeakPtr&& o) noexcept : ref_(std::move(o.ref_)), ptr_(o.ptr_) {} + + T* get() const noexcept { return ref_.IsValid() ? ptr_ : nullptr; } + T& operator*() const { assert(ref_.IsValid()); return *ptr_; } // Chromium 用 CHECK + T* operator->() const { assert(ref_.IsValid()); return ptr_; } + explicit operator bool() const noexcept { return get() != nullptr; } + void reset() noexcept { ref_.Reset(); ptr_ = nullptr; } + + bool maybe_valid() const noexcept { return ref_.MaybeValid(); } + bool was_invalidated() const noexcept { return ptr_ && !ref_.IsValid(); } +private: + template friend class WeakPtr; + friend class WeakPtrFactory; + WeakPtr(internal::WeakReference&& ref, T* ptr) noexcept : ref_(std::move(ref)), ptr_(ptr) { + assert(ptr); // 只有 factory 能调 + } + internal::WeakReference ref_; + T* ptr_ = nullptr; +}; + +} // namespace tamcpp::chrome +``` + +真正的坑在 `[[clang::trivial_abi]]` 这个标注上。它告诉编译器:这个类型虽然析构不平凡,但您可以放心把它当平凡类型传参,塞寄存器、用 memcpy 搬都没事。这事儿本身有风险——析构时机会被前移,Move 后对象可能提前失效。Chromium 敢这么标,是因为 `ptr_` 是裸的平凡指针,`scoped_refptr` 那部分也能平凡 relocate,整套搬过去 invariant 不破。这个安全前提的完整论证在 [full/pre-06](../full/pre-06-weak-ptr-trivial-abi.md),笔者这里只点一句:别看了眼花就给自己的类型乱贴,这玩意儿贴错了是会 UAF 的。 + +还有一处得提:私有构造函数加 `friend class WeakPtrFactory`,意思是只有 factory 能铸出 WeakPtr,外部不能凭空捏一个。这是把"铸币权"收口的关键。 + +## 第 4 层:WeakPtrFactory\ + +最后一层,也是用户真正会 new 出来的那个东西。它先内含一个 `WeakReferenceOwner`(Flag 的发行方),再加一个存被观察对象指针的成员。两个一起协作:铸币、批量失效、析构时兜底。 + +```cpp +namespace tamcpp::chrome { + +class WeakReferenceOwner { // Flag 发行方 +public: + WeakReferenceOwner() : flag_(new internal::Flag()) {} + ~WeakReferenceOwner() { if (flag_) flag_->Invalidate(); } // 析构即失效所有 + internal::WeakReference GetRef() const { return internal::WeakReference(flag_); } + void Invalidate() { flag_->Invalidate(); flag_ = internal::scoped_refptr(new internal::Flag()); } // 失效 + 新 Flag + void InvalidateAndDoom() { flag_->Invalidate(); flag_ = nullptr; } // 失效 + 不再铸 + bool HasRefs() const { return !flag_->has_one_ref(); } +private: + internal::scoped_refptr flag_; +}; + +template +class WeakPtrFactory { +public: + WeakPtrFactory() = delete; + explicit WeakPtrFactory(T* ptr) : ptr_(reinterpret_cast(ptr)) { assert(ptr); } + WeakPtrFactory(const WeakPtrFactory&) = delete; + WeakPtrFactory& operator=(const WeakPtrFactory&) = delete; + + WeakPtr get_weak_ptr() const { + return WeakPtr(owner_.GetRef(), reinterpret_cast(ptr_)); + } + WeakPtr get_weak_ptr() requires(!std::is_const_v) { + return WeakPtr(owner_.GetRef(), reinterpret_cast(ptr_)); + } + void invalidate_weak_ptrs() { assert(ptr_); owner_.Invalidate(); } + void invalidate_weak_ptrs_and_doom() { assert(ptr_); owner_.InvalidateAndDoom(); ptr_ = 0; } + bool has_weak_ptrs() const { return ptr_ && owner_.HasRefs(); } +private: + WeakReferenceOwner owner_; + uintptr_t ptr_; // 非模板依赖的指针存储(可下沉到基类压膨胀) +}; + +} // namespace tamcpp::chrome +``` + +这段代码藏了三个笔者觉得最值得说的细节。 + +头一个是 `WeakReferenceOwner` 的析构函数,它调了 `Invalidate`。这一句就是"factory 必须放最后一个成员"这条铁律的根。C++ 成员按声明顺序构造、逆序析构,factory 排最后,析构时就最先被调用——这时候对象里其它成员还都活着,Flag 翻个面把所有 WeakPtr 失效掉,正好赶在那些成员一个一个被销毁之前。要是 factory 排前面呢?它先析构、Flag 失效,可紧接着 `buf_` 之类的成员也被析构了,这中间有个窗口:WeakPtr 看着还可能 valid,但对象内部已经开始散架。所以这个顺序不是风格偏好,是内存安全。详细的析构期 race 论证见 [full/02-3](../full/02-3-weak-ptr-factory-and-last-member.md)。 + +第二个是 `ptr_` 存成了 `uintptr_t`。这看着多此一举——明明是 `T*`,干嘛绕一圈 reinterpret_cast?目的是把指针存储下沉到一个非模板基类。您想想,`WeakPtrFactory`、`WeakPtrFactory`、`WeakPtrFactory<十几个类型>`,每个都实例化一份一模一样的指针操作代码,模板膨胀很吓人。把这部分挪到非模板基类里,每个 T 就只剩薄薄一层派生,二进制体积省得相当可观。Chromium 里这种"用 uintptr_t 换模板瘦身"的小招挺多的。 + +第三个是 `Invalidate` 和 `InvalidateAndDoom` 的区别。前者失效完马上铸一枚新 Flag,意味着 factory 还能继续发新的 WeakPtr;后者把 Flag 指针直接置空,不再铸——这是给"这个 factory 以后再也不用了"的场景准备的,省一次堆分配。`invalidate_weak_ptrs_and_doom` 那行顺手把 `ptr_` 也清零,后续再调它直接 `assert` 炸掉,防的是 use-after-free 之后的误用。 + +## 串起来跑 + +四层都搭完了,咱们把它们拼起来跑一次,看看实际用起来长什么样。下面这个 Controller 是个典型场景:有个会被异步回调的成员函数,配一个 factory 当最后成员,对外甩 WeakPtr 出去。 + +```cpp +struct Controller { + void on_done(int v) { /* ... */ } + std::vector buf_; + WeakPtr get_weak() { return weak_factory_.get_weak_ptr(); } + WeakPtrFactory weak_factory_{this}; // 最后成员 +}; + +// 对象死后回调自动 no-op(接 01 的 OnceCallback,见 full/02-5) +auto task = bind_weak_once(&Controller::on_done, ctrl.get_weak(), 42); +std::move(task).run(); // ctrl 活着 → 调用;ctrl 死后 → 静默 no-op +``` + +`bind_weak_once` 这一行,是咱们上一篇 OnceCallback 留下的尾巴终于接上的地方。它的核心思路就是把工业级 `InvokeHelper::MakeItSo` 翻译过来:回调跑之前先 `if (!receiver) return;`,这个 receiver 是 WeakPtr,经过 `operator bool` 调 `get()`,`get()` 又查 `IsValid`,一路落到同序列的准确判活。对象活着就正常调用,死了就静默 no-op。完整的回调集成机制——编译期 `kIsWeakMethod` 怎么接线、`MaybeValid` 为什么走独立通道、void 返回约束是怎么来的——见 [full/02-5](../full/02-5-weak-ptr-bind-integration.md),笔者在那篇里专门拆了一遍。 + +代码到这里全撸完了,Flag 的 acquire/release 保无锁 deref、lazy 序列绑定、factory 析构兜底、`TRIVIAL_ABI` 进寄存器,该落的承诺都落了。但这一篇笔者只顾着把东西写对,还没正经验证过它跑起来到底稳不稳——`MaybeValid` 那条独立通道会不会有 race,跨序列析构的窗口到底关没关上,factory 排错位置到底炸不炸。这些得靠测试和 TSan 说话,下一篇咱们就专挑这些地方往死里打。 + +## 参考资源 + +- [Chromium `base/memory/weak_ptr.{h,cc}`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr.h) +- [weak_ptr 设计指南(三):测试策略与性能对比](./03-weak-ptr-testing.md) +- [WeakPtr 实战(二):核心骨架与控制块](../full/02-2-weak-ptr-core-skeleton-and-control-block.md) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/03-weak-ptr-testing.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/03-weak-ptr-testing.md new file mode 100644 index 000000000..55b082129 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/hands_on/03-weak-ptr-testing.md @@ -0,0 +1,145 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: WeakPtr 的测试策略——围绕六条不变量设计用例,并量化对象大小/分配/调用开销, + 与 std::weak_ptr 和真实 Chromium 做取舍对比 +difficulty: advanced +order: 3 +platform: host +prerequisites: +- weak_ptr 设计指南(二):逐步实现 +- OnceCallback 设计指南(三):测试策略与性能对比 +reading_time_minutes: 7 +related: +- weak_ptr 设计指南(一):动机、接口与控制块设计 +- WeakPtr 实战(六):测试与性能对比 +tags: +- host +- cpp-modern +- advanced +- 智能指针 +- weak_ptr +- 测试 +- 优化 +title: "weak_ptr 设计指南(三):测试策略与性能对比" +--- +# weak_ptr 设计指南(三):测试策略与性能对比 + +上一篇撸完实现,笔者心里其实没那么踏实——代码能编过是一回事,语义对不对是另一回事。WeakPtr 这种东西最怕的就是"看起来能跑":您测一个 happy path,绿了;可 UAF、析构竞态、move 后源对象状态这些坑,全藏在边界上。咱们这一篇就把上一篇承诺的六条不变量一条条钉回测试里,顺便拿真实数据跟 `std::weak_ptr` 和真实 Chromium 比一比,看教学版到底省在了哪、又牺牲了什么。思路跟 [OnceCallback 设计指南(三)](../../01_once_callback/hands_on/03-once-callback-testing.md) 一脉相承:不变量驱动,数据说话,不空口。 + +## 六条不变量 → 测试矩阵 + +| # | 不变量 | 必须成立的断言 | +|---|---|---| +| 1 | 基本可用 | 对象活着时 `wp` 判活、`get()` 返回真地址 | +| 2 | move 语义 | move 后源对象为空(`operator bool == false`) | +| 3 | invalidate 后失效 | `invalidate_weak_ptrs()` 后所有已铸 `wp.get()==nullptr` | +| 4 | CHECK-on-deref | 解引用失效 `wp` 触发断言(debug assert / release CHECK) | +| 5 | maybe_valid 不对称 | 负面可信(false⇒必失效)、正面不可信 | +| 6 | factory 析构失效 | factory 析构后所有 wp 失效;最后成员守护成员析构期 | + +## 关键用例(Catch2 风格) + +六条不变量听着抽象,落到测试上其实就挑那些"写错了一定会爆"的边界。笔者这里挑三条最能锁语义的——共享 Flag 的集体失效、`was_invalidated` 区分作废与主动 reset、最后成员的析构顺序。项目当前可运行的是 `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/` 下 `12`~`18` 那几个 demo .cpp,Catch2 测试目标接入留作扩展,这里先看用例长什么样: + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include "weak_ptr/weak_ptr.hpp" +using namespace tamcpp::chrome; + +struct Foo { int x = 42; }; + +TEST_CASE("invalidate kills all weak ptrs sharing the flag", "[weak_ptr]") { + Foo foo; WeakPtrFactory fac(&foo); + auto wp1 = fac.get_weak_ptr(); + auto wp2 = fac.get_weak_ptr(); // 同一 factory → 共享同一 Flag + fac.invalidate_weak_ptrs(); + REQUIRE_FALSE(wp1); // 不变量 3:集体失效 + REQUIRE_FALSE(wp2); +} + +TEST_CASE("was_invalidated vs reset", "[weak_ptr]") { + Foo foo; WeakPtrFactory fac(&foo); + auto wp_a = fac.get_weak_ptr(); + fac.invalidate_weak_ptrs(); + REQUIRE(wp_a.was_invalidated()); // 被作废 + auto wp_b = fac.get_weak_ptr(); + wp_b.reset(); + REQUIRE_FALSE(wp_b.was_invalidated()); // 主动 reset 不算作废 +} + +// 不变量 6:最后成员守护成员析构期 +struct Good { // ✓ factory 最后声明 + std::vector buf_; + WeakPtrFactory fac_{this}; +}; +struct Bad { // ✗ factory 先声明 → 最后析构 + WeakPtrFactory fac_{this}; + std::vector buf_; +}; +TEST_CASE("last-member idiom: destruction order", "[weak_ptr][.death]") { + // Good:fac_ 先析构 → WeakPtr 失效 → buf_ 才析构 + // Bad:buf_ 先析构 → fac_ 后析构 → 中间窗口 WeakPtr 仍有效(可悬垂 deref) + // 用 TSan/AddressSanitizer 在隔离 death test 里验证 Good 不 UAF +} +``` + +这三条盯的都是语义边界,不是 API 表面。共享 Flag 的集体失效,验的是"一个 invalidate 大家一起死"这条承诺到底成不成立;`was_invalidated` 那条更细——主动 `reset()` 不该算作被作废,笔者写的时候特意拿 `wp_b` 跟 `wp_a` 对照,就是怕把两种"变空"混成一回事。最后成员那条是析构顺序的命门,笔者单独拎出来讲。 + +不变量 4(CHECK-on-deref)和 6(析构顺序)有个麻烦:它们会 abort。直接塞进普通 TEST_CASE 里跑,整个二进制都得跟着挂。所以得隔离成 death test,让它在子进程里崩——这套路数跟 01-6 处理 OnceCallback 单次消费断言是同一套,笔者在那篇已经趟过一遍了。 + +## 性能:对象大小 + +先看最直观的——一个 `WeakPtr` 到底吃多少字节。笔者直接用 `static_assert` 定住,编不过就是错: + +```cpp +static_assert(sizeof(WeakPtr) == sizeof(void*) * 2); // 16 字节(x86-64) +``` + +| 类型 | sizeof | 组成 | +|---|---|---| +| `WeakPtr` | 16 | `WeakReference`(scoped_refptr,1 ptr)+ `T*`(1 ptr) | +| `std::weak_ptr` | 16 | 对象指针 + 控制块指针 | + +sizeof 打出来一样,笔者一开始还有点意外——`std::weak_ptr` 名声在外,本以为会更紧凑。但再一琢磨就对上了:两边都是两个指针,一个指对象、一个指控制块/Flag,结构上就是对称的。真正的差别在分配行为(见下表),不在大小。 + +还有一处 `sizeof` 看不出来的收益:`TRIVIAL_ABI` 让 WeakPtr 按值传参时能整个塞进寄存器(两个寄存器就够),`std::weak_ptr` 做不到。这是 ABI 层的事,benchmark 不一定测得到,但在热路径上它是实打实省了栈操作。 + +## 性能:分配与调用 + +大小一样,真正拉开差距的是分配次数和判活开销。笔者把两边逐项对了一遍: + +| 维度 | `std::weak_ptr` | `WeakPtr` | +|---|---|---| +| 被指对象分配 | `shared_ptr(new T)` 2 次;`make_shared` 1 次但捆死内存 | 不强制:Flag 1 次侵入式 + 对象按自身方式 | +| 判活开销 | `lock()`:原子读 strong count + 若存活再 inc + 构造临时 shared_ptr | `get()`:1 次原子 acquire-load,返回裸指针 | +| 跨序列 deref | `lock()` 线程安全 | 需同序列(契约 + DCHECK) | +| 批量失效 | 无 | **一次 invalidate 失效所有**(共享 Flag) | + +表里最值得琢磨的一行是判活开销。`get()` 比 `lock()` 轻,不是巧——`lock()` 得先原子读 strong count,确认存活后再 inc 一次,最后还给您一个临时 `shared_ptr`(出来还得 dec 回去),一来一回好几趟原子操作。`get()` 就一次 acquire-load,返回裸指针,完事。代价当然有:返回的是裸指针,没人帮您管同步,得靠"同序列"这条契约兜底。笔者认为这买卖划算——契约是 debug 下 DCHECK 抓的,真违规了开发阶段就爆,不至于带到线上。 + +## vs 真实 Chromium:教学版取舍 + +| 维度 | Chromium | 教学版 | +|---|---|---| +| Flag refcount | `RefCountedThreadSafe` | 同 | +| 原子标志 | `base::AtomicFlag` | `std::atomic` + memory_order(等价) | +| 序列检查 | `SEQUENCE_CHECKER` + SequenceToken | 简化(线程 id 模拟) | +| `SafeRef` | 完整 | 不实现 | +| `BindOnce` 集成 | 完整 `InvokeHelper` 双特化 | 简化 trampoline | +| `InvalidateAndDoom` | 完整 | 保留 | +| `BindToCurrentSequence` | 完整 | 省略 | + +笔者做教学版时的取舍逻辑是这样的:周边能省的省——`SafeRef`、`BindToCurrentSequence` 这些砍掉,序列检查拿线程 id 模拟;但核心机制一动不动,refcounted Flag、acquire/release 配对、序列契约、编译期 weak 分派,该是怎样还怎样。理由很简单,周边是工程便利,核心是正确性命门,砍周边顶多用着别扭,砍核心那就不是 WeakPtr 了。 + +到这儿,WeakPtr 组件的设计、实现、验证三篇就走完了。回头看,它跟 OnceCallback 是一对姊妹篇:01-4 那会儿笔者图省事甩的取消令牌,工业级正解就是这套 WeakPtr 体系,现在算是闭环了。 + +## 参考资源 + +- [Chromium `base/memory/weak_ptr_unittest.cc`](https://source.chromium.org/chromium/chromium/src/+/main:base/memory/weak_ptr_unittest.cc) +- [Catch2 文档](https://github.com/catchorg/Catch2/tree/devel/docs) +- [weak_ptr 设计指南(一):动机、接口与控制块设计](./01-weak-ptr-design.md) +- [OnceCallback 设计指南(三):测试策略与性能对比](../../01_once_callback/hands_on/03-once-callback-testing.md) diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/index.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/index.md new file mode 100644 index 000000000..9c0c3de15 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/index.md @@ -0,0 +1,34 @@ +# WeakPtr:从 Chromium 学到的弱指针设计 + +本目录实现 Chromium 风格的 `WeakPtr` 弱指针组件,讲透"不介入所有权、却能安全观察对象存活状态"这套现代 C++ 设计。它是 [OnceCallback 系列](../01_once_callback/) 的姊妹篇——01-4 手搓的取消令牌,工业级正解就是 `WeakPtr`。 + +## 完整教程(full/) + +面向零基础读者,从弱引用概念与前置知识开始,逐步引导到完整组件实现。 + +前置知识(7 篇): + +- [弱引用与生命周期难题](./full/pre-00-weak-ptr-weak-reference-and-lifetime.md) +- [侵入式引用计数与 scoped_refptr](./full/pre-01-weak-ptr-intrusive-refcount-and-scoped-refptr.md) +- [std::atomic 与 memory_order](./full/pre-02-weak-ptr-atomic-and-memory-order.md) +- [序列、SEQUENCE_CHECKER 与 DCHECK/CHECK](./full/pre-03-weak-ptr-sequence-checker-dcheck-check.md) +- [concepts 与 requires 进阶](./full/pre-04-weak-ptr-concepts-and-requires.md) +- [模板友元与 uintptr_t 类型擦除](./full/pre-05-weak-ptr-template-friend-and-uintptr-t.md) +- [TRIVIAL_ABI 与平凡可重locate](./full/pre-06-weak-ptr-trivial-abi.md) + +动手实践(6 篇): + +- [动机与接口设计](./full/02-1-weak-ptr-motivation-and-api-design.md) +- [核心骨架与控制块](./full/02-2-weak-ptr-core-skeleton-and-control-block.md) +- [WeakPtrFactory 与"最后成员"惯用法](./full/02-3-weak-ptr-factory-and-last-member.md) +- [序列亲和性与 lazy 绑定](./full/02-4-weak-ptr-sequence-affinity-and-lazy-binding.md) +- [与回调集成——关闭 OnceCallback 的环](./full/02-5-weak-ptr-bind-integration.md) +- [测试与性能对比](./full/02-6-weak-ptr-testing-and-perf.md) + +## 进阶设计指南(hands_on/) + +面向有 C++ 模板与并发经验的读者,快速走读设计动机、实现策略与测试验证: + +- [动机、接口与控制块设计](./hands_on/01-weak-ptr-design.md) +- [逐步实现](./hands_on/02-weak-ptr-implementation.md) +- [测试策略与性能对比](./hands_on/03-weak-ptr-testing.md) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-1-flat-map-motivation-and-api-design.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-1-flat-map-motivation-and-api-design.md new file mode 100644 index 000000000..b6d904302 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-1-flat-map-motivation-and-api-design.md @@ -0,0 +1,204 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 从配置表的 cache miss 痛点切入,讲清 flat_map 要补的洞,定下完整目标 API, + 含 at 的 CHECK、默认透明比较器、sorted_unique 构造等关键决策 +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- flat_map 前置知识(零):有序关联容器与 std::map 的红黑树 +- flat_map 前置知识(二):复杂度与摊还分析 +reading_time_minutes: 11 +related: +- flat_map 实战(二):flat_tree 核心骨架 +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 优化 +title: "flat_map 实战(一):动机与接口设计" +--- +# flat_map 实战(一):动机与接口设计 + +[前置知识(零)](./pre-00-flat-map-ordered-assoc-container-intro.md) 里笔者埋了个问题:std::map 的红黑树每个节点一次 malloc,查找时指针追逐一路踩 cache miss,渐近复杂度是 `O(log n)` 没错,可一到小到中等数据量、查多写少的场景,常数因子就把性能拖垮了。这一篇咱们就回到这个痛点,把 flat_map 到底要补哪些洞想明白,顺手把目标 API 一次定死。 + +flat_map 想干的事其实一句话能说完:给"写一次读多次"的有序映射一个 cache 友好的实现。它没打算抢 std::map 的饭碗,"大且频繁改"那块地盘 std::map 照样赢;它补的是"查多写少"这个空缺。这个系列咱们一边拆 Chromium 怎么实现,一边自己手搓一个教学版,对照着看更容易想明白里头的取舍。 + +--- + +## 从一个性能痛点说起:配置表 + +假设咱们在写一个命令分发表。程序启动时从配置文件加载一堆命令→回调的映射,跑起来之后就只剩查找,不再改: + +```cpp +std::map commands; +for (auto& [name, handler] : load_commands()) { + commands.emplace(name, handler); // 启动期构造一次 +} + +// 运行期:每条命令只查找 +auto it = commands.find(cmd_name); +if (it != commands.end()) it->second(args); +``` + +这段代码看着挑不出毛病,`std::map` 的 `O(log n)` 查找听起来也"够快"。可您拿 perf 一跑就傻眼了:大量时间堆在 `std::map::find` 上,而表里可能就几十个元素。渐近复杂度 `O(log n)` 没告诉您的是,每次 `find` 的那 `log n` 步,每步都是一次大概率 cache miss 的指针解引用——几十个元素,`log n` 也就六七步,每步都 miss 一下,这查找就废了。 + +[pre-00](./pre-00-flat-map-ordered-assoc-container-intro.md) 里咱们拆过根因:红黑树节点散落堆上,每节点 32 字节元数据外加一次堆分配,查找时 `node = node->left_` 是数据相关的解引用,CPU 想预取都摸不准下个地址在哪。数据量一小,这"常数因子"反而比 `log n` 主导得多。这就是 std::map 在这个场景下扎您的地方。 + +--- + +## 三种现成解法为什么都不够 + +痛点清楚了,那现成的轮子能凑合用吗?咱们挨个看。 + +`std::map` 就不用再说了,上面的 cache miss 就是它干的,每节点一次 malloc 的毛病也治不好。 + +那换 `std::unordered_map` 呢?哈希表平均 `O(1)` 查找,听着美。可小数据量下它的常数因子未必讨得到便宜——哈希计算加上处理碰撞的开销摆在那。更要命的是它无序,您没法按 key 顺序遍历,没法 `lower_bound` 找范围,也没法做有序区间查询。命令分发表哪天产品要"列出所有命令"或者"按前缀过滤一下",unordered_map 当场抓瞎。Chromium 自己的容器指南也明说了不推荐 `std::unordered_map`,性能干不过 absl 那套 hash map。 + +剩下一条路是手搓:自己维护一个 `vector>`,每次插入后 `sort`,查找用 `std::lower_bound`。功能上跟 flat_map 没差,可您得自己处理去重、自己保持有序、自己惦记迭代器失效、想要 sorted_unique 优化还得自己加标签 dispatch……纯重复造轮子,而且每一步都容易写错。笔者自己搓过一版,后来发现就是在 flat_map 该有的坑里一个个重踩。 + +三条路都顶不住:map cache 痛,unordered_map 无序又不受推荐,手搓是重复造还容易错。Chromium 的回答干脆——把这些功能打包成一个有 std::map 风格接口的容器,叫 `flat_map`。 + +--- + +## Chromium 的回答:flat_map 设计哲学 + +flat_map 的设计哲学说白了就两件事。第一件,存储用一段连续的有序数组(默认就是 `vector>`),查找走二分(`lower_bound`)——连续换来了 cache 友好,二分换来了 `O(log n)` 查找。第二件,它压根不追求插入性能,单次插入 `O(n)` 的 shift 它认了,换回来的好处是构造期一把排序(批量构造 `O(N log N)`)、查找期那个 cache 友好的低常数因子。 + +这两条就把 flat_map 的适用边界画出来了:写一次读多次,或者数据量始终很小的有序映射。要是您的场景是"大且频繁改",那 `O(n)` 的插入会疼得您怀疑人生——那是 std::map 的主场,别来凑热闹。 + +### 架构概览:flat_tree 是唯一实现 + +flat_map 的实现里有个挺优雅的分层,笔者第一次读到的时候愣了一下:核心其实只有一个类,叫 `flat_tree`,它是个通用的"有序数组关联容器"。`flat_map` 和 `flat_set` 都是它的薄壳。 + +具体怎么薄法?`flat_map` 继承自 `flat_tree`,这里的 `GetFirst` 是个提取器,从 `pair` 里抠出 `first` 当 key(flat_map.h:194-195、24-29)。`flat_set` 更干脆,直接是 `flat_tree` 的别名,一个 `using =`,`std::identity` 把 value 原样当 key(flat_set.h:159-163)。 + +您品一下这个设计——一份 flat_tree 实现,光靠换"key 提取器"这一行,就同时拿出了 map 和 set 两副面孔。这就是策略对象的经典玩法,理解了 flat_tree,flat_map 和 flat_set 也就白送了。所以咱们这个系列的实战篇主要就在拆 flat_tree,flat_map 跟 flat_set 的差异,全在那行提取器上。 + +--- + +## 设计目标 API + +动机讲完了,咱们把目标 API 一次定死,再回头抠每个签名里的决策。命名沿用 `tamcpp::chrome` 命名空间,snake_case 风格,跟 OnceCallback、WeakPtr 那两个系列保持一致。 + +### 构造 + +```cpp +#include "flat_map/flat_map.hpp" +using namespace tamcpp::chrome; + +// 从无序数据构造(内部排序去重) +flat_map m1 = {{1, "a"}, {3, "c"}, {2, "b"}}; + +// 从已有 vector move 构造(批量构造,高效) +std::vector> raw = {{1,"a"}, {2,"b"}, {3,"c"}}; +flat_map m2(std::move(raw)); + +// sorted_unique 构造(数据已有序,跳过排序) +flat_map m3(sorted_unique, std::vector>{{1,"a"},{2,"b"},{3,"c"}}); +``` + +### 查找与修改 + +```cpp +flat_map m; +m[1] = load(1); // operator[]:缺失则插入 +m.insert_or_assign(2, x); // 插入或覆写 +m.try_emplace(3, arg1, arg2);// 仅 key 不存在时构造 mapped + +auto it = m.find(1); // O(log n) 二分 +if (it != m.end()) use(it->second); + +m.at(99); // 越界 → CHECK 崩溃(非 throw,见决策分析) +``` + +### 异构查找(透明比较器) + +```cpp +flat_map sm; // 默认 Compare = std::less<>(透明) +sm.find("timeout"); // 直接用 const char* 查,不构造临时 std::string +``` + +--- + +## 接口设计决策分析 + +API 定是定下来了,可每个签名里都藏着取舍,咱们把"为什么"挨个抠清楚。 + +### 为什么 at() 用 CHECK 而不是 throw + +`std::map::at(key)` 越界会抛 `std::out_of_range`,这是标准库的规矩。可 `flat_map::at(key)` 越界直接 `CHECK` 失败、中止程序(flat_map.h:293/302),连商量的余地都不给。为什么这么狠? + +因为越界访问通常意味着调用方的逻辑写错了——您要么该先用 `find` 查一下,要么就该有把握 key 一定在里头。这种 bug 在 release 里也得当场爆,而不是丢个异常出来让上层的 `try/catch` 糊过去,后者往往把真正的逻辑错误盖在了兜底逻辑底下。这是 Chromium 一以贯之的错误处理风格:确定的逻辑错误上 CHECK,不靠异常兜。[WeakPtr 的 `operator*` 用 CHECK 守失效解引用](../../02_weak_ptr/full/02-1-weak-ptr-motivation-and-api-design.md)跟这儿是同一套哲学,笔者在那篇也聊过。 + +### 为什么默认比较器是透明的 std::less<> + +`std::map` 默认 `Compare = std::less`,不透明;flat_map 默认换成 `std::less<>`,透明的(flat_map.h:192)。这一换,异构查找就开了口子——您拿 `const char*` 去 `find` 一个 `std::string` 的 map,不用再临时构造一个 `std::string` 出来。在热路径上这点临时对象的累积相当可观,详见 [pre-03](./pre-03-flat-map-comparator-and-transparent.md)。现代 C++ 的推荐默认就是透明比较器,flat_map 直接照办。 + +### 为什么存 pair 而非 pair + +底层存的是 `std::vector>`,key 是非 const 的(flat_map.h:193)。这个反直觉的取舍是被 vector 的 shift 逼出来的——insert/erase 要搬迁整对元素,得能对整对做移动赋值,可 `pair` 偏偏不可 move-assign。代价是 key 暴露成可改的,理论上迭代器能把 key 改坏、把有序不变量打破,只能靠用户自律。这笔账的明细在 [pre-05](./pre-05-flat-map-enua-ebo-and-pair-storage.md)。 + +### 为什么提供 sorted_unique 构造 + +要是您能保证数据已经有序,用 `sorted_unique` 标签构造可以把 `O(N log N)` 的排序直接跳掉,降到 `O(N)`。更妙的是 debug 下还有 `DCHECK` 替您校验有没有撒谎——您说有序了,它真去查一遍。这是个正儿八经的零成本抽象,详见 [pre-04](./pre-04-flat-map-tag-dispatch-and-sorted-unique.md)。 + +--- + +## 咱们的实现与 Chromium 的取舍 + +跟前两个系列一样,咱们的教学版保留核心机制(flat_tree 适配器 + sorted vector + sorted_unique + 透明比较),做些力所能及的简化。取舍先打个预告,03-6 会拿实测对比收尾: + +| 维度 | Chromium 实现 | 我们的教学版 | +|---|---|---| +| 底层 Container | `std::vector` | 同 | +| 排序 | `std::stable_sort` + unique + erase | 同 | +| 透明比较 | `KeyT` + `KeyValueCompare` 两重载 | 简化为直接模板 | +| `DCHECK(is_sorted_and_unique)` | 完整 | 用 `assert` 模拟 | +| `[[no_unique_address]]` 比较器 | 标注 | 标注(GCC/Clang 都支持) | +| `replace`/`extract` | 完整 | 省略(留作扩展) | + +咱们用纯标准库(`std::vector`、`std::sort`、`std::lower_bound`)把核心撸出来,Chromium 的设计哲学照搬,但那些 Chromium 特化的 `raw_ptr_exclusion`、`NO_UNIQUE_ADDRESS` 宏的复杂度全砍掉——那些是人家工程化里的私货,教学版用不着。 + +--- + +## 环境搭建 + +flat_map 要用到 C++20 的 concepts(`requires`、`std::convertible_to`)、ranges(`std::ranges::lower_bound`),还有 `[[no_unique_address]]` 这个属性。所以最低门槛是 C++20。 + +### 编译器要求 + +GCC 11+ 或 Clang 12+ 都行,编译加 `-std=c++20`。`[[no_unique_address]]` 在 GCC 和 Clang 上都支持,对空类型的 EBO 行为等价正确,这点您可以放心。 + +### 验证代码 + +```cpp +#include +#include +#include + +static_assert(__cpp_lib_ranges >= 201911L); // ranges 可用 + +constexpr bool check_nua_works() { + struct Empty {}; + struct H { [[no_unique_address]] Empty e; int i; }; + return sizeof(H) == sizeof(int); // EBO 把 Empty 折叠掉 +} +static_assert(check_nua_works()); +``` + +这段能在您机器上过,环境就算齐活了。配套工程脚手架还是用 `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/`,咱们从 03-2 开始往里加 `19_`~`22_` 这一批 flat_map 示例。 + +动机和 API 到这儿算是理顺了。可纸面理顺是一回事,真把 flat_tree 一行行撸出来,sorted vector 适配器怎么搭、有序不变量怎么守、key 提取器怎么塞进去,全是下一篇要踩的坑。咱们下一篇就动手。 + +## 参考资源 + +- [Chromium `base/containers/flat_map.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [Chromium `base/containers/flat_tree.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/README.md` —— 容器选择指南](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/README.md) +- [flat_map 前置知识(零):有序关联容器与 std::map 的红黑树](./pre-00-flat-map-ordered-assoc-container-intro.md) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-2-flat-map-flattree-skeleton.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-2-flat-map-flattree-skeleton.md new file mode 100644 index 000000000..0b8fdcada --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-2-flat-map-flattree-skeleton.md @@ -0,0 +1,283 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 实现 flat_tree 核心骨架——sorted vector 适配器、key 提取器策略、有序不变量、 + value_compare 嵌套结构,以及 flat_map/flat_set 怎么继承它 +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- flat_map 实战(一):动机与接口设计 +- flat_map 前置知识(一):std::vector 内部表示与扩容 +- flat_map 前置知识(五):NO_UNIQUE_ADDRESS、EBO 与 pair 存储 +reading_time_minutes: 12 +related: +- flat_map 实战(三):查找与插入 +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 内存管理 +title: "flat_map 实战(二):flat_tree 核心骨架" +--- +# flat_map 实战(二):flat_tree 核心骨架 + +上一篇咱们把 flat_map 的目标 API 拍了下来,顺嘴提了句这玩意儿的核心其实就一个类:`flat_tree`。这一篇笔者想带您把这个"有序数组关联容器适配器"的骨架亲手搭一遍。搭完您会撞见一个挺有意思的数:Chromium 的 flat_set.h 满打满算 191 行。set 这么省事,是因为它能复用的东西全被 flat_tree 吃进去了,自己那边几乎不用再写代码。 + +flat_tree 之所以能一份骨架同时伺候 map 和 set,靠的是三招配合。第一招是个泛型的 key 提取器策略(`GetKeyFromValue`),同一个 value 它能取出 key 也能原样返回,这决定了容器戴着 map 还是 set 的帽子。第二招是有序不变量,每次 mutation 完它自己悄悄把顺序补回来。第三招是个嵌套的 `value_compare`,把对 value 的比较翻译回对 key 的比较。咱们一个一个拆。 + +## flat_tree 模板签名 + +`flat_tree` 的模板签名(flat_tree.h:104-105)长这样: + +```cpp +template +class flat_tree { +protected: + Container body_; // 底层有序容器(默认 vector) + [[no_unique_address]] KeyCompare comp_; // key 比较器(EBO 零开销) + // ... +}; +``` + +四个模板参数,笔者挨个说一下。`Key` 是 key 类型,没悬念。`GetKeyFromValue` 是这套设计的秘密武器,它是个函数对象,实现 `const Key& operator()(const Value&)`——给定一个 value(对 map 是 `pair`,对 set 是 `K` 本身),吐回它的 key。同一个 flat_tree 能当 map 也能当 set,差别全落在这个 typename 上,后面咱们会看具体写法。`KeyCompare` 是 key 比较器,默认 `std::less<>`。`Container` 是底层序列容器,默认 `std::vector`,map 用 `vector>`、set 用 `vector`。 + +数据成员就俩:`body_` 是底层容器,`comp_` 是比较器。`comp_` 上挂了 `[[no_unique_address]]`,目的是让空比较器吃零字节,这块的来龙去脉笔者在 [pre-05](./pre-05-flat-map-enua-ebo-and-pair-storage.md) 里讲过,这里不重复。 + +--- + +## key 提取器:GetFirst vs std::identity + +key 提取器是 map/set 共用一份代码的关键。咱们直接看两个具体实现。 + +flat_map 这边用的是 `GetFirst`(flat_map.h:24-29): + +```cpp +struct GetFirst { + template + constexpr const Key& operator()(const std::pair& p) const { + return p.first; // value 是 pair,取 first 当 key + } +}; +``` + +flat_set 这边更省事,直接拿标准库自带的 `std::identity`,原样返回: + +```cpp +// flat_set.h:163 等价于: +using flat_set = flat_tree>; +// std::identity 的 operator()(const T&) 返回 T 本身——value 就是 key +``` + +flat_tree 内部要比较两个 value 时,先调提取器取出各自的 key,再用 `comp_` 比 key。所以同一份 flat_tree 代码,`GetFirst` 让它把 `pair` 当 map 处理,`std::identity` 让它把 `K` 当 set 处理。笔者第一次读到这儿愣了一下——map 和 set 的全部实现分野,就落在这一个 typename 上。 + +--- + +## value_compare:把 value 比较转成 key 比较 + +flat_tree 还对外甩了一个嵌套的 `value_compare`(flat_tree.h:122-130)。它的用处是让外部能按 value 比较,比如您想拿 `std::sort` 直接排 value 数组时,手头得有个比 value 的函子才行: + +```cpp +struct value_compare { + constexpr bool operator()(const value_type& left, const value_type& right) const { + GetKeyFromValue extractor; + return comp(extractor(left), extractor(right)); // 取出各自 key 再比 + } + [[no_unique_address]] key_compare comp; // 同样 EBO +}; +``` + +它干的活儿就是"双边提取 key 再交给 `comp`"。对 map 来说是比两个 pair 的 first;对 set 来说是比两个 key 本身(因为提取器是 identity,原样透传)。这个嵌套结构让 flat_tree 能在 value 层面提供比较接口,底层还复用同一份 key 比较器,不用为 value 另写一套。 + +--- + +## 有序不变量:每次 mutation 后保持有序 + 唯一 + +flat_tree 护着的核心不变量只有一条:`body_` 永远按 `comp_` 严格升序,且无重复。这条不变量在两个地方被维护,构造期一把排好,插入期逐个守好。 + +### 构造期:sort_and_unique + +普通构造(传无序数据)调 `sort_and_unique`(flat_tree.h:147-149,实现在 567/578/586/594): + +```cpp +void sort_and_unique() { + std::stable_sort(body_.begin(), body_.end(), value_comp()); // 排序(O(N log N)) + auto it = std::ranges::unique(body_, equiv); // 去重(equiv = !comp && !comp) + body_.erase(it.end(), body_.end()); // 删掉重复尾巴 +} +``` + +`stable_sort` 按 `value_comp` 排序,然后 `unique` 把等价元素挪到末尾,再 `erase` 砍掉尾巴。笔者提一句这里为什么用 stable_sort 而不是 sort:等价元素(同一个 key 的多个 value)如果原来有先后,stable_sort 能保住它们的相对顺序——虽然 flat_map 去完重只留一个,但保序语义在某些边界情况(比如 value 带状态时)更稳。构造完事儿之后,`body_` 就是有序无重复的干净状态。 + +### 单点插入:lower_bound + insert + +运行期插单个元素时(flat_tree.h:1060 `unsafe_emplace`),先 `lower_bound` 找位置(保持有序),再 `insert`。lower_bound 找的是"第一个不小于 key 的位置",插在那儿天然保持有序;要是 key 已经在,`lower_bound` 会指向那个相等元素,`unique` 语义就要求拒绝插入,避免重复。这套查找加插入的具体写法和那个让人肉疼的 shift 代价,咱们留到 03-3 详讲。 + +--- + +## 构造:普通 vs sorted_unique + +flat_tree 的构造分两族,这个分法背后藏着性能取舍,笔者拆给您看。 + +普通构造这边,您传无序数据进来,它内部老老实实调 `sort_and_unique`: + +```cpp +flat_tree(InputIterator first, InputIterator last, const Compare& comp) { + body_.insert(body_.end(), first, last); + sort_and_unique(); // 排序去重 +} +``` + +sorted_unique 构造这边,您拿 `sorted_unique_t` 标签拍胸脯保证数据已经有序无重复,它就跳过排序,只做个 DCHECK: + +```cpp +flat_tree(sorted_unique_t, InputIterator first, InputIterator last, const Compare& comp) { + body_.insert(body_.end(), first, last); + DCHECK(is_sorted_and_unique(body_, comp)); // 只 debug 校验,不排序 +} +``` + +两族的差别就落在一个 `sort_and_unique` 调用上:要么真排,要么信您。后者在您确定数据源已经有序时(比如从另一个有序容器搬过来)能省一次 O(N log N),是 flat_tree 给性能敏感场景留的逃生口。tag dispatch 这个机制本身的来龙去脉,笔者放在 [pre-04](./pre-04-flat-map-tag-dispatch-and-sorted-unique.md) 里讲了。 + +--- + +## flat_map / flat_set 怎么继承 flat_tree + +有了 flat_tree 这层骨架,flat_map 和 flat_set 上头几乎不用再写东西。 + +flat_map(flat_map.h:194-195)走的是继承,把它需要的那一份 key 提取器填进去: + +```cpp +template , + class Container = std::vector>> +class flat_map : public flat_tree { + // 继承 flat_tree 的所有通用操作(find/insert/erase/lower_bound...) + // 自己只加 map 特有的:operator[]、at、insert_or_assign、try_emplace +}; +``` + +flat_set(flat_set.h:159-163)更干脆,连类都懒得定义,直接别名: + +```cpp +template , + class Container = std::vector> +using flat_set = flat_tree; +// 完全没有自己的代码——set 就是"key=value"的 flat_tree +``` + +flat_map 加的那几个 map 特有操作(`operator[]`/`at`/`insert_or_assign`/`try_emplace`),笔者留到 03-3 跟查找插入一起讲。flat_set 这边因为 key 就是 value,真没什么可加的,一个 `using` 就交代完了。您现在回头看 flat_set.h 那 191 行,大概就明白这抽象的杠杆有多大。 + +--- + +## 最小 flat_tree 复刻 + +光看 Chromium 的代码不过瘾,咱们自己动手搓一个最小版,亲手体会一下"key 提取器 + 有序不变量"这两招是怎么咬合的: + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include +#include +#include +#include + +namespace tamcpp::chrome::internal { + +template +class flat_tree { +public: + using value_type = typename Container::value_type; + using iterator = typename Container::iterator; + using const_iterator = typename Container::const_iterator; + + // 普通构造:无序数据,内部排序去重 + flat_tree(Container data, KeyCompare comp = KeyCompare()) + : body_(std::move(data)), comp_(comp) { + sort_and_unique(); + } + + // 查找:O(log n) 二分 + const_iterator find(const Key& key) const { + auto it = std::ranges::lower_bound( + body_, key, + [&](const value_type& v, const Key& k) { return comp_(GetKeyFromValue{}(v), k); }, + [&](const Key& k, const value_type& v) { return comp_(k, GetKeyFromValue{}(v)); }); + if (it != body_.end() && !comp_(key, GetKeyFromValue{}(*it))) return it; + return body_.end(); + } + + std::size_t size() const { return body_.size(); } + const value_type& front() const { return body_.front(); } + +private: + void sort_and_unique() { + GetKeyFromValue ext; + std::stable_sort(body_.begin(), body_.end(), + [&](const value_type& a, const value_type& b) { + return comp_(ext(a), ext(b)); + }); + body_.erase(std::unique(body_.begin(), body_.end(), + [&](const value_type& a, const value_type& b) { + auto ka = ext(a), kb = ext(b); + return !comp_(ka, kb) && !comp_(kb, ka); + }), + body_.end()); + } + + Container body_; + [[no_unique_address]] KeyCompare comp_; +}; + +} // namespace tamcpp::chrome::internal +``` + +这个最小版抓住了两件事:构造时排序去重,以及查找时二分。key 提取器策略那条线,在 `find` 和 `sort_and_unique` 里都能看到它把 value 翻译成 key 的动作。下一步咱们往里加插入和删除,那个 shift 的代价才是 flat_map 真正的软肋。 + +--- + +## 用 flat_tree 拼出 map 和 set + +```cpp +// map:存 pair,用 GetFirst 提 key +struct GetFirst { + template + constexpr const K& operator()(const std::pair& p) const { return p.first; } +}; + +template +using mini_flat_map = internal::flat_tree, + std::vector>>; + +// set:存 K,用 std::identity 提 key +template +using mini_flat_set = internal::flat_tree, std::vector>; + +int main() { + mini_flat_map m{std::vector>{ + {2, "b"}, {1, "a"}, {3, "c"}}}; + std::cout << m.size() << " elements, front key=" << m.front().first << "\n"; // 3, 1(已排序) + + mini_flat_set s{std::vector{3, 1, 2, 1}}; // 重复的 1 会被去重 + std::cout << s.size() << " elements\n"; // 3 + return 0; +} +``` + +跑一下您会看到 `3 elements, front key=1`(排序生效)和 `3 elements`(去重生效)。一份 flat_tree,戴 `GetFirst` 的帽子就是 map,换 `std::identity` 就是 set。 + +--- + +骨架搭到这儿算是立住了。`flat_tree` 这套签名是有序数组关联容器的通用底座,Key 提取器策略决定它戴 map 还是 set 的帽子,有序不变量靠构造期的 `sort_and_unique` 和插入期的 `lower_bound + insert` 两道关守着,`value_compare` 把 value 比较翻译回 key 比较。flat_map 在这之上加几个 map 特有操作,flat_set 干脆一个 `using` 交代完——flat_set.h 那 191 行就是这么来的。 + +接下来咱们把 flat_tree 的查找与插入真正写出来,`O(log n)` 二分好办,`O(n)` 的 shift 才是笔者想实测给您看的——那个代价到底有多肉疼。 + +## 参考资源 + +- [Chromium `base/containers/flat_tree.h` —— flat_tree 类与 value_compare](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/flat_map.h` —— GetFirst + flat_map 子类](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [Chromium `base/containers/flat_set.h` —— flat_set 别名](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_set.h) +- [flat_map 实战(一):动机与接口设计](./03-1-flat-map-motivation-and-api-design.md) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-3-flat-map-lookup-and-insert.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-3-flat-map-lookup-and-insert.md new file mode 100644 index 000000000..a7cebae8b --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-3-flat-map-lookup-and-insert.md @@ -0,0 +1,215 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 实现 flat_tree 的查找(lower_bound O(lg n))与插入(lower_bound + emplace, + O(n) shift),含 flat_map 的 operator[]/insert_or_assign/try_emplace,并实测 shift 代价 +difficulty: intermediate +order: 3 +platform: host +prerequisites: +- flat_map 实战(二):flat_tree 核心骨架 +- flat_map 前置知识(二):复杂度与摊还分析 +reading_time_minutes: 12 +related: +- flat_map 实战(四):sorted_unique 构造优化 +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 优化 +title: "flat_map 实战(三):查找与插入" +--- +# flat_map 实战(三):查找与插入 + +[03-2](./03-2-flat-map-flattree-skeleton.md) 把骨架立起来了,这一篇咱们往里填两个真正会用到的东西:怎么查,怎么插。这两件事一个是 flat_map 的卖点,一个是它的代价,咱们拆开看。 + +查这件事 flat_map 干得漂亮。数据本来就连续有序,二分一上,`O(log n)`,还基本都吃在 cache 上。卖点是实打实的。但插这件事就得小心了——`O(n)` shift 不是写在文档里吓唬人的,是真会咬人。笔者会在最后跑个实验让您亲眼看看那条 shift 曲线长什么样,把抽象结论变成可感知的代价。两个操作的底层都站在 [pre-02](./pre-02-flat-map-complexity-and-amortized.md) 的复杂度分析和 [pre-01](./pre-01-flat-map-vector-internals-and-growth.md) 的 vector 行为上,前置没过的朋友回头补一下。 + +## 查找:lower_bound O(lg n) + +flat_tree 的查找接口一堆(`find`/`contains`/`lower_bound`/`equal_range`),底层都收敛到同一件事:二分。Chromium 用 `std::ranges::lower_bound` 配一个 `KeyValueCompare` 比较器对象(flat_tree.h:1027),在有序数组上找第一个不小于 key 的位置: + +```cpp +// flat_tree::find 的核心(简化;只传一个二元比较器 (value,key)->bool) +const_iterator find(const Key& key) const { + auto it = std::lower_bound(body_.begin(), body_.end(), key, + [&](const value_type& v, const Key& k) { return comp_(GetKeyFromValue{}(v), k); }); + // lower_bound 给的是"第一个不小于 key 的",还要确认是否真的相等 + if (it != body_.end() && !comp_(key, GetKeyFromValue{}(*it))) return it; + return body_.end(); +} +``` + +> 注意:`std::ranges::lower_bound(range, value, comp)` 只接受**一个**比较器。Chromium 的 `KeyValueCompare`(flat_tree.h:439-462)是一个**带两个 `operator()` 重载**的类(v insert(const value_type& value) { + const Key& key = GetKeyFromValue{}(value); + auto it = std::lower_bound(body_.begin(), body_.end(), key, + [&](const value_type& v, const Key& k) { return comp_(GetKeyFromValue{}(v), k); }); // 1. 找位置 O(log n) + if (it != body_.end() && !comp_(key, GetKeyFromValue{}(*it))) { + return {it, false}; // key 已存在,不插入(唯一 key 不变量) + } + auto inserted = body_.emplace(it, value); // 2. 插入,O(n) shift + return {inserted, true}; +} +``` + +看代码就看明白了,就两步:先用 `lower_bound` 找到该插哪儿,再用 `vector::emplace` 在那里把元素构造进去。第二步是真正的代价所在。`vector::emplace(pos, value)` 要把 `pos` 之后的所有元素整批往后挪一格——底层是 `std::move_backward` 搬迁,再在腾出来的位置上构造新元素。挪多少由后面的元素个数决定,平均下来 `n/2`,大 O 记作 `O(n)`。 + +这就是 flat_map 插入时必须认下的一笔账:每次插入都要挪一半元素。渐近复杂度 `O(n)`,而且这里没有摊还一说——不是偶尔挪一次,是每次都挪,没有哪次能逃过去。 + +--- + +## flat_map 特有:operator[]、insert_or_assign、try_emplace + +flat_tree 本身是通用的,flat_map 在它的基础上又叠了几个 map 特有的操作。咱们一个个看。 + +### operator[] 的实现(flat_map.h:313, 326) + +```cpp +mapped_type& operator[](const Key& key) { + auto it = lower_bound(key); // 找位置 + if (it == end() || comp_(key, GetKeyFromValue{}(*it))) { + it = unsafe_emplace(it, ...); // 不存在 → 插入默认构造的 mapped + } + return it->second; +} +``` + +`m[key]` 干的事:查一下,key 不存在就插一个默认构造的 `mapped_type()` 进去,然后返回引用;存在就直接返回已有那个的引用。语义和 `std::map::operator[]` 完全一致。有一点笔者要单独提醒——它会动容器(可能真的插进去一个东西),所以 `const flat_map` 上用不了,编译期就会拦下来。 + +### insert_or_assign(flat_map.h:334-355) + +```cpp +template +std::pair insert_or_assign(const Key& key, M&& obj) { + auto result = emplace_key_args(key, std::forward(obj)); // 先试插 + if (!result.second) { + // key 已存在 → 覆写 mapped + result.first->second = std::forward(obj); // 赋值(需要 pair 非 const!) + } + return result; +} +``` + +`insert_or_assign(key, val)` 的行为:查到 key 不存在就插;查到存在,就**把 value 覆写掉**。返回 `{iterator, inserted_bool}`,`inserted=false` 表示这次其实是覆写。 + +笔者第一次读源码时,真正卡住的是后面这一句覆写——`result.first->second = forward(obj)`。它依赖 `pair` 的 second 是可赋值的。这就是为什么 flat_map 内部必须存 `pair` 而不是 `pair`——后者 second 不能赋值,这条路就堵死了。这个看似无关紧要的存储选择,是被 `insert_or_assign` 这个 API 倒推出的硬约束,详细的笔者放在 [pre-05](./pre-05-flat-map-enua-ebo-and-pair-storage.md)。 + +### try_emplace(flat_map.h:392-413) + +```cpp +template +std::pair try_emplace(const Key& key, Args&&... args) { + // 只在 key 不存在时才构造 mapped(args...) + auto [it, inserted] = emplace_key_args(key, std::piecewise_construct, + std::forward_as_tuple(key), + std::forward_as_tuple(std::forward(args)...)); + return {it, inserted}; +} +``` + +`try_emplace(key, args...)` 跟上面那位性格相反:key 不存在才用 `args...` 构造 mapped;key 已经在了,它就**完全不动**那个已有的 value。这就是它和 `insert_or_assign` 的本质区别——一个会覆写,一个无视。实现上有点讲究,用 `std::piecewise_construct + forward_as_tuple` 把 pair 的构造延迟到真正需要的那一刻,免得您传进来的 mapped 在"key 已存在"的情况下白白构造了一趟又被扔掉。 + +--- + +## erase:O(n) shift + +别光盯着插入,删除也是 `O(n)` 的——这是连续存储的对称代价。`erase` 直接转给 vector(flat_tree.h:914/921 的 `body_.erase`): + +```cpp +iterator erase(const_iterator pos) { + return body_.erase(pos); // vector::erase,把后面元素往前挪一格,O(n) +} +``` + +`erase(pos)` 删一个位置,`erase(first, last)` 删一段,干的都是同一件事:把后面的元素整批往前挪一格。`erase(key)` 这个重载稍微多一道手续,它得先 `lower_bound` 找到位置(`O(log n)`),再 `erase` 挪元素(`O(n)`),合起来 `O(n) + O(log n)`,大 O 还是 `O(n)`。 + +--- + +## 实测:O(n) shift 到底多贵 + +光说 `O(n)` 您大概会有感觉,但没痛感。咱们跑个实验,把这条 shift 曲线从抽象结论变成肉眼可见的代价。思路很简单:在 vector 头部插 10 万次(`emplace(begin)`),每次都把后面所有元素往后挪一格;然后对比尾部 `push_back`(摊还 `O(1)`): + +```cpp +// Platform: host | C++ Standard: C++17 +#include +#include +#include + +int main() { + constexpr int N = 100'000; + + // 头部插入:每次 O(n) shift + std::vector a; + auto t1 = std::chrono::steady_clock::now(); + for (int i = 0; i < N; ++i) a.emplace(a.begin(), i); + auto t2 = std::chrono::steady_clock::now(); + std::cout << "emplace(begin) x" << N << ": " + << std::chrono::duration_cast(t2 - t1).count() + << " ms\n"; + + // 尾部插入:摊还 O(1) + std::vector b; + auto t3 = std::chrono::steady_clock::now(); + for (int i = 0; i < N; ++i) b.push_back(i); + auto t4 = std::chrono::steady_clock::now(); + std::cout << "push_back x" << N << ": " + << std::chrono::duration_cast(t4 - t3).count() + << " ms\n"; + return 0; +} +``` + +本机(GCC 16,-O2)真实输出: + +```text +emplace(begin) x100000: 264 ms +push_back x100000: 0 ms +``` + +两个数量级的差距,实打实地摆在眼前。这就是您把 flat_map 当成 std::map 那样频繁插入会踩到的曲线——每次 insert 都在按比例付那条 `emplace(begin)` 264ms 的账。 + +所以为什么前几篇反复念叨"查多写少"这个使用前提?它真不是文档里的客套话,是被这条 O(n) shift 曲线逼出来的硬约束。写多了,您会亲眼看着性能曲线往那条 264ms 的轨迹靠。 + +--- + +## 串起来:一个完整的查找插入例子 + +```cpp +// 用 03-2 的 mini_flat_map +mini_flat_map m{std::vector>{ + {1, "one"}, {3, "three"}, {5, "five"}}}; + +auto it = m.find(3); +if (it != m.end()) std::cout << it->second << "\n"; // three + +// 插入(有序位置自动确定,O(n) shift) +// 这里用 flat_tree 的 insert(简化展示) +// m.insert({4, "four"}); // 插在 3 和 5 之间,挪动 5 +``` + +flat_map 的零成本构造留给后续——sorted_unique 怎么跳过 sort_and_unique。 + +## 参考资源 + +- [Chromium `base/containers/flat_tree.h` —— lower_bound / unsafe_emplace / erase](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/flat_map.h` —— operator[]/insert_or_assign/try_emplace](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [cppreference: std::lower_bound](https://en.cppreference.com/w/cpp/algorithm/lower_bound) +- [flat_map 前置知识(二):复杂度与摊还分析](./pre-02-flat-map-complexity-and-amortized.md) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-4-flat-map-sorted-unique-construction.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-4-flat-map-sorted-unique-construction.md new file mode 100644 index 000000000..7b3745ecf --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-4-flat-map-sorted-unique-construction.md @@ -0,0 +1,184 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: 实现 sorted_unique 构造优化——用 tag dispatch 跳过 sort_and_unique,把批量构造从 + O(N log N) 降到 O(N),配 DCHECK 诚实契约,并讲清何时该用它 +difficulty: intermediate +order: 4 +platform: host +prerequisites: +- flat_map 实战(三):查找与插入 +- flat_map 前置知识(四):tag dispatch 与 sorted_unique_t +reading_time_minutes: 10 +related: +- flat_map 实战(五):迭代器失效与批量构造 +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 零开销抽象 +title: "flat_map 实战(四):sorted_unique 构造优化" +--- +# flat_map 实战(四):sorted_unique 构造优化 + +上一篇咱们把 flat_map 的单元素插入拆透了,每次 insert 都是 `O(n)` 的 shift。这玩意儿平时一个个插感觉不出什么,可您要是拿它去构造一个挺大的 flat_map,比如启动时加载配置表,那 `O(N²)` 的总代价能把人等死。笔者之前就在一个 10 万元素的配置上栽过,启动慢得离谱,profile 一看全耗在 shift 上。 + +这一篇咱们就专攻构造期怎么绕开这堵墙。先说批量构造这条路,数据先攒进 vector、最后一刀 move 进 flat_map,`O(N log N)` 一次排序收尾。然后是真正的重头戏 sorted_unique 构造,数据本来就有序的话,排序那步直接跳过,降到 `O(N)`。这条优化路子是 [pre-04 tag dispatch](./pre-04-flat-map-tag-dispatch-and-sorted-unique.md) 的落地现场,咱们把它从头到尾走一遍。 + +--- + +## 陷阱:逐个 insert 构造是 O(N²) + +最直觉的写法就是写个循环一个个 insert,看着人畜无害: + +```cpp +flat_map m; +for (auto& [k, v] : load_data()) { + m.insert({k, v}); // 每次都 O(n) shift +} +``` + +咱们把代价摊开看。第 1 次 insert 是 `O(1)`,第 2 次 `O(2)`,一路涨到第 N 次 `O(N)`,总代价 `O(1) + O(2) + ... + O(N) = O(N²)`。数据量一上去就是灾难。拿 10 万元素来说,shift 总次数大概 `10⁸`,实测能磨好几秒,这就是笔者当初踩坑的现场。 + +flat_map 的接口设计者显然也清楚这件事,所以他们给构造期单独留了更便宜的路。 + +--- + +## 批量构造:先填 vector 再 move,O(N log N) + +绕开 `O(N²)` 的办法是批量。先把数据一股脑塞进一个 vector,再把这个 vector 整个 move 进 flat_map: + +```cpp +std::vector> raw; +raw.reserve(N); +for (auto& [k, v] : load_data()) raw.emplace_back(k, v); // vector push_back,摊还 O(1) + +flat_map m(std::move(raw)); // move 构造,内部一次排序 +``` + +`flat_map(container_type&& items)` 这个构造(flat_tree.h:578 附近)干的事很简单:接管 vector 的存储,这是一次 `O(1)` 的 move,然后调一次 `sort_and_unique`,代价 `O(N log N)`。总构造代价就压到 `O(N log N)`。和逐个 insert 的 `O(N²)` 比一比,还是 10 万元素,`N log N ≈ 1.7×10⁶` 对上 `N² = 10¹⁰`,差着四个数量级。 + +这就是 flat_map 官方推荐的构造姿势,数据先在 vector 里攒好,享受 push_back 摊还的 `O(1)`,最后一刀 move 进去。flat_map.h:61-62 那段文档原话就是这么写的:"If possible, construct a flat_map in one operation by inserting into a container and moving that container into the flat_map constructor." + +--- + +## sorted_unique:跳过排序,O(N) + +走到这一步,如果您手里的数据本来就排好序了、也没有重复,那批量构造里那个 `O(N log N)` 的 `sort_and_unique` 就是白干的活。直接接管不就完了。这就是 `sorted_unique` 构造要解决的事: + +```cpp +std::vector> raw = load_already_sorted_data(); // 已有序 +flat_map m(sorted_unique, std::move(raw)); // 跳过 sort_and_unique,O(N) +``` + +第一个参数传个 `sorted_unique` 标签,flat_map 就走那个跳过排序的构造重载(flat_tree.h:606-646)。它总共就两件事:接管 vector,`O(1)` move;然后跑一遍 `DCHECK(is_sorted_and_unique(...))` 做 debug 校验。release 编译下 DCHECK 是空,所以总代价就是接管那一刀,纯 `O(N)`。 + +### 5 个 sorted_unique 重载 + +flat_tree 为 sorted_unique 准备了 5 个重载,各种输入来源都覆盖到了: + +- `flat_map(sorted_unique, InputIterator first, last, comp)` +- `flat_map(sorted_unique, from_range_t, Range&&, comp)` (C++23 ranges) +- `flat_map(sorted_unique, const container_type&, comp)` +- `flat_map(sorted_unique, container_type&&, comp)` ← 上面用的 +- `flat_map(sorted_unique, initializer_list, comp)` + +它们和对应的普通构造只有一个差别:不调 `sort_and_unique`。机制是 tag dispatch,咱们在 [pre-04](./pre-04-flat-map-tag-dispatch-and-sorted-unique.md) 已经拆过了。`sorted_unique_t` 是个空的 tag 类型,编译器靠"您传不传这个 tag"在重载决议期选不同的函数,运行期一分钱开销都没有。 + +--- + +## DCHECK(is_sorted_and_unique):诚实契约 + +问题来了。您跟 flat_map 拍胸脯说"数据有序了",可万一其实没序呢?flat_map 在 debug 下用 `DCHECK` 抓您撒谎(flat_tree.h:612/624/633/642): + +```cpp +flat_tree(sorted_unique_t, container_type&& body, const Compare& comp) + : body_(std::move(body)), comp_(comp) { + DCHECK(is_sorted_and_unique(body_, comp_)); // debug 校验 +} +``` + +`is_sorted_and_unique`(flat_tree.h:55-62)的实现在 [pre-04](./pre-04-flat-map-tag-dispatch-and-sorted-unique.md) 看过: + +```cpp +template +constexpr bool is_sorted_and_unique(const Range& range, Comp comp) { + return std::ranges::adjacent_find(range, std::not_fn(comp)) == + std::ranges::end(range); +} +``` + +它扫一遍相邻元素,确认每个都严格小于下一个,既没有相等的也没有逆序的。一遍 `O(N)`,只在 debug 下跑。您要是撒谎了,debug 测试就 abort 给您看;release 下 `DCHECK` 编译成空,一个字都不校验,完全信您。 + +笔者管这份约定叫诚实契约。flat_map 给您 `O(N)` 构造的优化,交换条件就是您得保证数据真的有序;debug 帮您把关这个保证,release 就放手信任。所以数据来源不靠谱的时候,比如用户输入、网络抓来的东西,就别硬上 sorted_unique,老老实实用普通批量构造让 flat_map 替您排。 + +--- + +## 何时用 sorted_unique + +判断标准其实就一句话:您的数据来源能不能可信地保证有序、无重复。 + +能保证的情况挺好认。数据是从另一个有序容器来的,比如另一个 flat_map 导出、或者一个 `std::set`;或者是您自己刚拿 `std::sort` 加 `unique` 处理过一遍;再或者就是编译期写死的常量,像配置表那种 initializer_list,您手写的时候盯着它是有序的。这些场景 sorted_unique 都用得踏实。 + +反过来,数据要是从用户输入、文件、网络来的,顺序根本不可控,就别冒险。还有一类容易漏的:您吃不准有没有重复。普通构造会帮您去重,sorted_unique 不会,一旦有重复元素溜进去,flat_map 的不变量就被您亲手破坏了,后面查找行为直接变成玄学。拿不准的时候,就用普通批量构造让 flat_map 自己排序去重,代价也就是 `O(N log N)`,比逐个 insert 还是快得多。 + +--- + +## 一个最小复刻 + +道理讲完了,咱们自己撸一个最小版的 MiniMap,把这两条构造路径都跑一遍,您就看得更清楚: + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include +#include + +struct sorted_unique_t {}; +inline constexpr sorted_unique_t sorted_unique{}; + +class MiniMap { +public: + // 普通构造:排序去重 + MiniMap(std::vector data) : data_(std::move(data)) { + std::sort(data_.begin(), data_.end()); + data_.erase(std::unique(data_.begin(), data_.end()), data_.end()); + } + // sorted_unique 构造:跳过排序,debug 校验 + MiniMap(sorted_unique_t, std::vector data) : data_(std::move(data)) { + assert(is_sorted_unique()); // debug 抓撒谎 + } + std::size_t size() const { return data_.size(); } +private: + bool is_sorted_unique() const { + for (std::size_t i = 1; i < data_.size(); ++i) + if (!(data_[i-1] < data_[i])) return false; + return true; + } + std::vector data_; +}; + +int main() { + MiniMap a{std::vector{3, 1, 2, 1}}; // 普通构造,排序去重 → 3 元素 + MiniMap b(sorted_unique, std::vector{1, 2, 3, 4}); // 跳过排序 → 4 元素 + // MiniMap c(sorted_unique, std::vector{1, 3, 2}); // 撒谎!debug abort + return 0; +} +``` + +--- + +到这里,flat_map 单元素插入那堵 `O(n)` 的墙咱们算是绕开了。批量构造这条路,先填 vector 再一刀 move 进去,`O(N log N)` 收尾;数据本来有序的话,sorted_unique 标签一传,排序那步直接省掉,`O(N)` 纯接管,配 debug 下的 `DCHECK` 把关,这就是 tag dispatch 在构造期省下的真金白银。 + +flat_map 还剩两件事值得讲透,一是迭代器失效规则,二是更多的批量构造模式,咱们后续接着拆。 + +## 参考资源 + +- [Chromium `base/containers/flat_tree.h` —— sorted_unique 重载与 is_sorted_and_unique](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/flat_map.h` —— 批量构造建议](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [flat_map 前置知识(四):tag dispatch 与 sorted_unique_t](./pre-04-flat-map-tag-dispatch-and-sorted-unique.md) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-5-flat-map-iterator-invalidation-and-bulk-build.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-5-flat-map-iterator-invalidation-and-bulk-build.md new file mode 100644 index 000000000..065195aa7 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-5-flat-map-iterator-invalidation-and-bulk-build.md @@ -0,0 +1,153 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: 讲清 flat_map 比 std::map 严格的迭代器失效规则(保守的 Assume every operation + invalidates),以及 extract/replace 的批量重建模式 +difficulty: intermediate +order: 5 +platform: host +prerequisites: +- flat_map 实战(四):sorted_unique 构造优化 +- flat_map 前置知识(一):std::vector 内部表示与扩容 +reading_time_minutes: 10 +related: +- flat_map 实战(六):测试与性能对比 +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 内存管理 +title: "flat_map 实战(五):迭代器失效与批量构造" +--- +# flat_map 实战(五):迭代器失效与批量构造 + +笔者第一次把 `std::map` 换成 `flat_map` 的时候,栽在一个特别朴素的地方:拿着一个迭代器,中间动了下容器,回头再用,悬垂了。这种事在 `std::map` 上根本不会发生,节点容器各管各的,插一个节点不会动到别人。可 `flat_map` 底层是 `vector`,一扩容整个搬,一删除后面全 shift,迭代器说没就没。 + +笔者当时第一反应是去翻文档,想搞清楚到底哪种操作失效、哪种不失效。结果 Chromium 给了个让笔者愣了一下的回答:它压根没给您列精细规则,而是甩过来一句横话,"假设每个操作都失效所有迭代器"。这篇咱们就拆这条规则为啥要这么写,顺便把 `flat_map` 配套的批量重建 API(`extract`/`replace`)讲透,让您在需要大改容器的时候别再一个一个 insert。 + +## std::map vs flat_map:迭代器稳定性 + +`std::map` 是节点容器,每个元素单独堆分配,树节点靠指针串起来。插入、删除一个节点,别的节点纹丝不动,只动指针。所以指向它们的迭代器、指针、引用都活着: + +```cpp +std::map m = /* ... */; +auto it = m.find(3); +m[99] = load(99); // 插入新元素,it 仍然有效(节点没动) +it->second; // OK +``` + +引用稳定是 `std::map` 的一个实打实的好处:您可以持有一个元素的引用,容器在背后增删,引用不失效。 + +`flat_map` 正好反过来。底层是连续 `vector`,插入可能触发扩容(整块搬迁),删除会把后面的元素往前 shift。这些操作都会让指向元素的迭代器、指针、引用变成野的: + +```cpp +flat_map m = /* ... */; +auto it = m.find(3); +m[99] = load(99); // 可能扩容 → it 失效! +it->second; // UB!可能悬垂 +``` + +cache 友好是用语义代价换来的:连续存储换来性能,代价是引用稳定性没了。这笔账您心里得有数。 + +## flat_tree 的保守规则 + +那 `flat_map` 的失效规则到底是个啥样?要按 `vector` 的行为精确推,那可细了:`reserve` 只在 `n > capacity` 时失效、`insert` 只失效从插入点起、`push_back` 不扩容时不失效……这套规则对调用方简直是个记忆力考试,您得每次 insert 前先掂量一下"这次会不会扩容"。 + +flat_tree 压根没给您出这张卷子。它对所有 mutation 一刀切,统统标成失效(flat_tree.h:151/217/231/273/306/319/374),源码注释原文就一句: + +> Assume that every operation invalidates iterators and references. +> (假设每个操作都失效所有迭代器和引用) + +覆盖的操作:`reserve`、`shrink_to_fit`、`insert`、`erase`、`swap`、move 构造、move 赋值、`extract`、`replace`、`clear`。一句话——改了就当全失效。 + +笔者第一次读到这条规则,第一反应是"这也太浪费了吧"。明明 `push_back` 不扩容时迭代器明明有效,凭什么说它失效?后来在代码评审里被一段类似的代码坑过一次,才反应过来:精细规则才是真正的陷阱。调用方记不住,容易误判,以为不失效其实失效了,直接 UB。粗规则没人会记错,"改了就别再用旧迭代器",永远安全。这种故意的安全冗余,其实是工程上很划算的取舍,宁可让您"白白"丢掉一个还能用的迭代器,也别让您赌错一次。 + +Chromium 在源码注释里直接把 UB 的反例贴了出来(flat_map.h:57-60),就一行: + +```cpp +container["new element"] = it.second; // UB:operator[] 可能触发扩容,it 失效 +``` + +这种"边遍历边改"的代码,在 `flat_map` 里是直接未定义行为。flat_tree 把规则说粗,就是为了让您根本不去想"这次改会不会扩容"这种问题,直接假设失效,从根上掐掉这类 bug。 + +落到实操上,两条规矩笔者建议您刻进肌肉记忆。一是跨 mutation 别持有迭代器、指针、引用,拿到 `find` 的结果,用完这一把就丢,下次操作重新 `find`,别想着"反正这个迭代器我等会儿还要用"。二是如果您真有"持有稳定引用"的需求,比如回调里要长期握着一个元素的指针,那就别用 `flat_map`,老老实实上 `std::map`,节点容器才有引用稳定性,这种场景 `flat_map` 给不了您。 + +--- + +## 批量构造模式(再强调) + +[03-4](./03-4-flat-map-sorted-unique-construction.md) 讲过批量构造,这里咱们换个角度,从"躲开迭代器失效"再看一遍。您要是想往 `flat_map` 塞一批元素,千万别边遍历边 insert,每次 insert 都失效所有迭代器不说,复杂度还飙到 O(N²),两头挨打。正确的姿势是先攒到一个 `vector` 里,再一次性 move 进去: + +```cpp +// 1. 攒到一个 vector(push_back 摊还 O(1),无迭代器失效问题——只持有 vector,不持有 flat_map 迭代器) +std::vector> batch; +batch.reserve(N); +for (...) batch.emplace_back(k, v); + +// 2. 一次性 move 进 flat_map(批量构造,O(N log N) 排序一次) +flat_map m(std::move(batch)); +``` + +`push_back` 在 `vector` 上是摊还 O(1),而且您手里握的是 `vector` 不是 `flat_map` 迭代器,失效规则那套烦人的事根本碰不到您。如果是对已有的 `flat_map` 做大批量更新,那就得用下面这对 API,把内容 extract 出来、改完再 replace 回去。 + +--- + +## extract() 与 replace():批量重建 + +flat_tree 给了两个 API,撑起一种"把数据拿出来大改、再交回"的批量重建模式。笔者第一次见这对组合的时候,觉得设计得挺巧,它把 `flat_map` 的有序约束整个卸下来,让您在裸 `vector` 上撒欢,改痛快了再交回去。 + +### extract()&&(flat_tree.h:894) + +```cpp +container_type extract() && { + return std::exchange(body_, container_type{}); // 把内部 vector 整个交出来,body_ 清空 +} +``` + +`extract()` 是右值限定的,只能对将死的 `flat_map`(rvalue)调。它把底层 `vector` 整个 `std::exchange` 出来给您,原 `flat_map` 变空。您拿到这个 `vector` 之后,想怎么折腾都行,`push_back`、`sort`、`unique`、改元素,`vector` 上这些操作没有 `flat_map` 那套有序约束,自由得多。改完再 `replace` 回去就行。 + +### replace(container_type&&)(flat_tree.h:899-905) + +```cpp +void replace(container_type&& body) { + DCHECK(is_sorted_and_unique(body, comp_)); // 校验新数据有序无重复 + body_ = std::move(body); // 接管 +} +``` + +`replace(body)` 是 `extract` 的逆操作,把一个新的 `vector` 交还给 `flat_map` 接管。它会先跑一遍 `DCHECK(is_sorted_and_unique)` 校验新数据有序无重复(契约跟 sorted_unique 构造一样),通过才接管。这么一进一出,您就能"拿出 vector → 自由改 → 排序去重 → 交回",全程绕开 `flat_map` 单元素操作那 O(n) 的 shift 代价和迭代器失效。 + +### 典型批量重建流程 + +```cpp +flat_map m = /* ... */; + +// 1. extract 出 vector(对 rvalue 调) +std::vector> raw = std::move(m).extract(); + +// 2. 在 vector 上自由批量改(没有有序约束,没有 shift 代价) +for (...) raw.emplace_back(k, v); + +// 3. 排序去重 +std::sort(raw.begin(), raw.end(), by_key); +raw.erase(std::unique(raw.begin(), raw.end(), equiv), raw.end()); + +// 4. replace 回 flat_map(sorted_unique 式校验) +m.replace(std::move(raw)); +``` + +这个路子适合那种"要对 `flat_map` 做大量结构性修改"的场景,比逐个 `m.insert`/`m.erase`(每次 O(n) shift 外加失效一堆迭代器)高效太多。有一点笔者得提醒您:`replace` 要求新数据有序无重复,这事儿得您自己保证,它只做 debug 下的 DCHECK 校验,release 里不会帮您兜。契约跟 sorted_unique 一样,是诚实契约,您得诚实。 + +剩下的事就是把 `flat_map` 跟 `std::map`、`absl::btree_map` 摆一起实测量一量了,这是下一篇的内容。 + +## 参考资源 + +- [Chromium `base/containers/flat_tree.h` —— 迭代器失效注释 + extract/replace](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/flat_map.h` —— UB 示例](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [flat_map 前置知识(一):std::vector 内部表示与扩容](./pre-01-flat-map-vector-internals-and-growth.md) +- [flat_map 实战(四):sorted_unique 构造优化](./03-4-flat-map-sorted-unique-construction.md) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-6-flat-map-testing-and-perf.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-6-flat-map-testing-and-perf.md new file mode 100644 index 000000000..fcd656ecd --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/03-6-flat-map-testing-and-perf.md @@ -0,0 +1,157 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 用 Catch2 围绕不变量测试 flat_map,并实测量对象大小、per-item 开销、查找/插入性能, + 对比 std::map 与 absl::btree_map,给出选型判据 +difficulty: intermediate +order: 6 +platform: host +prerequisites: +- flat_map 实战(五):迭代器失效与批量构造 +reading_time_minutes: 12 +related: +- flat_map 前置知识(零):有序关联容器与 std::map 的红黑树 +- flat_map 前置知识(二):复杂度与摊还分析 +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 测试 +- 优化 +title: "flat_map 实战(六):测试与性能对比" +--- +# flat_map 实战(六):测试与性能对比 + +代码撸完了,接下来的问题其实就俩:写对了吗?跑多快?笔者这篇就围着这两件事转。前半篇咱们拿不变量当靶子设计测试,把排序、查找、插入、去重、失效这些行为逐条验过去;后半篇把 flat_map 拉出来跟 `std::map`、`absl::btree_map` 一起上秤,量一量对象多大、查找多快、插入多疼。那条"小 N flat_map 赢、大 N 写多了 std::map 反超"的判据到底落在什么数据上,跑完就清楚了。 + +## 六条不变量,一条都不能松 + +flat_map 能不能算"对",全看这六条不变量撑不撑得住,笔者把它们拢在一块儿讲。 + +排序和去重是头两条,也是最容易被肉眼验的:遍历一遍拿到的 key 必须严格升序,而且不能有重复。第三条是查找语义得齐整,`find`、`contains`、`operator[]`、`at` 各司其职,其中 `at` 越界要直接 CHECK 崩给您看——这种是确定的 bug,release 也得炸。第四条针对两个容易混的写接口:`insert_or_assign` 遇到已存在 key 就覆写,`try_emplace` 则是已存在就别动,一个动一个不动,语义别搞反。第五条是 `sorted_unique` 这条"撒谎者走捷径"的路径——您声称数据已排序去重,它就跳过排序;可您要是撒谎,debug 下直接 abort。最后一条是迭代器失效,任何 mutation 之后旧迭代器都按粗规则作废,这一条上一篇已经讲透,这里只验它。 + +## 关键用例(Catch2 风格示意) + +下面是 Catch2 风格的示意用例(项目当前的可运行示例是 `code/.../chrome_design/` 下 `19`~`22` 的 demo .cpp,Catch2 测试目标接入留作扩展): + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include "flat_map.hpp" +using namespace tamcpp::chrome; + +TEST_CASE("flat_map is sorted+unique after construction", "[flat_map]") { + flat_map m{{3,30}, {1,10}, {3,30}, {2,20}}; // 重复 3,无序 + // 不变量 1+2:有序且去重 + std::vector keys; + for (auto& [k, v] : m) keys.push_back(k); + REQUIRE(keys == std::vector{1, 2, 3}); +} + +TEST_CASE("at out-of-range CHECKs", "[flat_map][.death]") { + flat_map m{{1,10}}; + // 不变量 3:越界 CHECK 崩溃(隔离 death test) + // REQUIRE_DEATH(m.at(99)); +} + +TEST_CASE("insert_or_assign overwrites, try_emplace leaves alone", "[flat_map]") { + flat_map m{{1,10}}; + auto [it1, ins1] = m.insert_or_assign(1, 99); // 已存在 → 覆写 + REQUIRE_FALSE(ins1); + REQUIRE(it1->second == 99); + // try_emplace 对已存在 key 不动(没法在同测试里直接验,语义见 03-3) +} +``` + +这些用例瞄的全是语义边界——排序去重、`at` 那一下 CHECK、`insert_or_assign` 的覆写。`at` 的 death test 得隔离跑,因为它真 abort,跟普通断言不是一回事。 + +## 性能:对象大小与 per-item 开销 + +先把"占多大内存"这件事量清楚。flat_map 的身子骨就一个 `vector>` 加一个零字节的比较器;`std::map` 走的是红黑树,每个节点要扛 3 个指针加一个颜色位,再加数据本身。咱们用 `sizeof` 看容器骨架,再用 100 万元素的占用看分摊到每条的 per-item 开销: + +```text +sizeof(flat_map) ≈ sizeof(vector>) = 24 字节(三指针,64 位) +sizeof(std::map) ≈ 48 字节(树根 + 比较器 + sentinel 节点) + +100 万元素 map 的额外开销(数据本身 8MB 不计): + flat_map: ~0 额外(数据连续,无节点元数据) + std::map: ~32MB(每节点 32B × 100 万,且每节点一次 malloc) +``` + +flat_map 在 per-item 上几乎白嫖:数据连续排布,没有节点元数据,也只 malloc 一次。`std::map` 这边光节点元数据就 32B 一条,还要 100 万次堆分配凑齐。对象越小、集合越大,这道口子撕得越开。 + +## 性能:查找(cache 友好 vs 指针追逐) + +两边查找都是 `O(log n)`,渐近上谁也不比谁快。常数因子才是分水岭:flat_map 数据连续,二分时比较能蹭上 cache;`std::map` 的节点东一个西一个,每跳一次解引用大概率就是一次 cache miss。 + +实测(本机 GCC 16,-O2,配套 `20_lookup_vs_shift_perf`,10 万元素 `map`,各做 10 万次 `find`): + +```text +查找 10 万次(10 万元素): + flat_map: 31 ms + std::map: 34 ms +``` + +别急着下结论。N=10 万、key 是 `int` 的时候,两边几乎打平——int 比较本身只要一个周期,cache 那点红利还没盖过 `std::map` 树高更低的优势。flat_map 真正拉开身位,得等 N 更大、或者 key 更重的时候。比如 key 换成 `std::string`,比较本身就贵,一次 cache miss 的代价占比立刻被放大,独立的大 N 测试里 flat_map 快个几倍很常见。所以"flat_map 查找一定快"这话不能当教条背,它吃工作负载——N 越大、key 越重,优势才越显眼。 + +## 性能:插入(O(n) shift 的墙) + +查找还能看负载脸色,插入这边 flat_map 就是明确输。实测往一个已经塞了 10 万元素的容器里追加插 1000 个 key: + +```text +插入 1000 次到 10 万元素容器: + flat_map: 2 ms (每次 O(n) shift) + std::map: 0 ms (每次 O(log n) 节点重连) +``` + +flat_map 慢得明明白白,这就是"查多写少"判据背后最硬的那块数据。您的活儿要是插入占大头,flat_map 这个 `O(n)` 的 shift 迟早成瓶颈,乖乖回去用 `std::map`。绝对值会随机器和 N 浮动,但 flat_map 插入比 `std::map` 慢这个趋势是稳的——N 越大口子越大,因为 shift 的代价本来就是 O(n)。 + +## 选型判据(实测总结) + +三组数据凑齐了,选型判据也就摆在那儿了: + +| 工作负载 | 推荐 | 理由 | +|---|---|---| +| **写一次读多次**(配置表、命令分发、查表) | flat_map | 写入都是一次性(批量构造),读取 cache 友好 | +| **始终很小**(浏览器统计众数 ~4 元素) | flat_map | 小 N 常数因子主导,零分配优势大 | +| **大且频繁改**(动态索引) | std::map | flat_map 的 O(n) 插入是墙 | +| **需要指针/引用稳定** | std::map | flat_map 迭代器跨变更全失效 | +| **大量有序 key + 频繁改 + 大 N** | absl::btree_map | B-tree 中间解(但 Chromium 因代码膨胀禁用) | + +一句话能说完:查多写少用 flat_map,写多就回到 std::map。Chromium 的 `//base/containers/README.md` 划的也是这条线。 + +## vs std::flat_map(C++23)与 absl::btree_map + +先说 `std::flat_map`(C++23,P0429)。它跟 Chromium 这套 flat_map 同宗同源,但标准版换了个存法——split storage,keys 和 values 各自一条连续数组,只遍历键的时候 cache 排得更密,value 不会插进来添乱。听着更优,代价是维护两套容器同步,实现复杂度上去了。Chromium 没走 split,老老实实一个 `vector>`——"看起来更优"的 split 在工业界主力那儿反而被放弃了,复杂度跟收益对不上账。 + +再说 `absl::btree_map`。它是 B-tree,每个节点 TargetNodeSize=256B,一次能塞几十个 key。这样一来,一次 cache line 命中能比对好几个 key,既治了红黑树指针追逐的毛病,又躲开了 sorted vector 那个 `O(n)` 的插入。属于"既要有序、又要大 N、还要频繁改"这摊需求的解药。可它有笔账没法回避:代码体积。Chromium 在 `//base` 明令禁用 `absl::btree_map`,原因就在这儿。 + +## 教学版与 Chromium 的取舍 + +跟前两个系列一样,咱们的教学版做了一层简化: + +| 维度 | Chromium | 教学版 | +|---|---|---| +| 底层 Container | `std::vector` | 同 | +| 排序 | `std::stable_sort` + unique + erase | 同 | +| 透明比较 | `KeyT` + `KeyValueCompare` 双重载 | 简化模板 | +| `DCHECK(is_sorted_and_unique)` | 完整 | `assert` 模拟 | +| `[[no_unique_address]]` 比较器 | 标注 | 标注 | +| `extract`/`replace` | 完整 | 简化/省略 | +| `raw_ptr_exclusion`/Chromium 宏 | 完整 | 省略 | + +核心机制(sorted vector 适配器、tag dispatch、透明比较、EBO、批量构造)一字不差。 + +到这里,flat_map 这个组件的设计、实现、验证就全跑通了。从 [pre-00 红黑树痛点] 一路到这儿 13 篇下来,咱们把"sorted vector 为什么能干翻红黑树"整条链路走完了。加上前面的 OnceCallback 和 WeakPtr,Chromium `//base` 里工业级 C++ 设计的回调、弱引用、容器三块拼图,也就凑齐了。 + +## 参考资源 + +- [Catch2 文档](https://github.com/catchorg/Catch2/tree/devel/docs) +- [Chromium `base/containers/README.md` —— 容器选择指南](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/README.md) +- [P0429 —— std::flat_map 提案(C++23)](https://wg21.link/p0429) +- [absl::btree_map 文档](https://abseil.io/docs/cpp/guides/btree) +- [cppreference: std::map](https://en.cppreference.com/w/cpp/container/map) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-00-flat-map-ordered-assoc-container-intro.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-00-flat-map-ordered-assoc-container-intro.md new file mode 100644 index 000000000..daea179ac --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-00-flat-map-ordered-assoc-container-intro.md @@ -0,0 +1,102 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: 从 std::map 的红黑树实现切入,讲清每节点 malloc + cache miss 的小 N 痛点, + 以及 flat_map 用有序 vector + 二分如何换一条路 +difficulty: intermediate +order: 0 +platform: host +prerequisites: +- WeakPtr 前置知识(零):弱引用与生命周期难题 +reading_time_minutes: 10 +related: +- flat_map 实战(一):动机与接口设计 +- flat_map 前置知识(一):std::vector 内部表示与扩容 +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 优化 +title: "flat_map 前置知识(零):有序关联容器与 std::map 的红黑树" +--- +# flat_map 前置知识(零):有序关联容器与 std::map 的红黑树 + +您随手写一句 `std::map` 存配置表,八成不会多想它底层长什么样——`O(log n)` 查找嘛,教科书说合理就合理。可要是这张表就十来个条目,启动时构造好、之后再也不动,您真去 profile 一把,会发现它比想象中慢不少。慢不在那个 `O(log n)`,在渐近复杂度藏不住的地方:每存一个 key-value 都得单独 `malloc` 一个节点,查找时在堆上跳来跳去,踩的几乎全是 cache miss。 + +Chromium 在 `//base` 里另起了一套有序关联容器——`flat_map`、`flat_set`——路子完全反过来:用一段连续的有序数组装所有元素,查找走二分。渐近复杂度还是 `O(log n)`,但数据挨在一起,CPU 一条 cache line 顺带就把十几个元素全捎进 L1 了,常数因子小一大截。代价当然有,插入删除退化成 `O(n)`(要挪数组)。咱们这一篇就把 std::map 的红黑树拆开看,讲清楚 flat_map 到底为什么要换这条路。 + +## 先把"关联容器"这词钉在哪儿 + +关联容器(associative container)跟序列容器的分界,说白了就一句:序列容器按位置访问,您要的是第 0 个、第 1 个;关联容器按键访问,您喊的是 `m.find("timeout")`,要的是 key 对应的那条 value。这玩意儿标准库给了两套——无序的 `std::unordered_map`(哈希表,平均 `O(1)` 查找)和有序的 `std::map`(红黑树,`O(log n)` 查找)。 + +咱们这篇只盯着有序的那一类。一来 flat_map 本身就是有序的,二来"有序"是个有分量的不变量:它能按 key 顺序遍历、能拿 `lower_bound` 框一段范围、能做前驱后继查询——这些哈希表统统做不到,无序就是无序。所以"有序关联容器到底该怎么实现"这个问题值得认真想:标准库给的答案是红黑树,Chromium 给的是有序数组。两条路都摆出来看看。 + +## std::map 的红黑树实现 + +主流三大实现(libstdc++、libc++、MSVC)的 `std::map` 底层都是红黑树,一种自平衡二叉搜索树。每个 key-value 对占一个树节点,64 位下节点长这样: + +```text +struct Node { + color color_; // 1 字节(红/黑,平衡用) + Node* left_; // 8 字节 + Node* right_; // 8 字节 + Node* parent_; // 8 字节 + pair data_; // 你的 key + value +}; +``` + +光是指针加颜色就 25 字节起步(算上对齐填充,实际通常 32 字节),这还没算您的 key-value。换句话说,每存一个元素,除了数据本身,您还得替它另外付 32 字节的节点元数据。 + +查找是教科书那套二叉搜索:从根开始比 key,小往左大往右。红黑树自平衡,树高 `O(log n)`,所以查找就是 `O(log n)` 次比较。单看渐近复杂度,合理。 + +合理归合理,坑藏在"每次比较前先把节点搬到 cache"这一步。红黑树节点是逐个堆分配的——您 `insert` 一次,底层 `new` 一个 Node。100 万元素的 `std::map`,光节点就 100 万次堆分配,返回的地址在堆上散得到处都是。查找的时候更扎手:从根 `node = node->left_` 这一跳,要去解引用一个之前根本没碰过的地址。CPU 流水线没法预取它(目标地址得等上一次加载完才知道),L1/L2 里也没有——这一跳就是一次 cache miss,几十上百个周期就出去了。`O(log n)` 次比较,每次都可能踩一次 miss,这才是 `std::map::find` 真实付出的代价。 + +## 真正的病根:常数因子 + +咱们在这儿停一下,把事情抠清楚,因为这是整个 flat_map 故事的根。 + +`std::map::find` 是 `O(log n)`,`flat_map::find` 也是 `O(log n)`,渐近复杂度一模一样。可"渐近一样"从来不等于"一样快"——大 O 记号故意把常数因子抹掉了,而常数因子是由"每次比较实际花多少"决定的。 + +对 std::map,每次比较前都得先把节点从内存搬进 cache。节点散在堆上,每跳一次大概率就是一次 miss。比较这个动作本身(比如两个 int 比大小)1 个周期就完事,可等节点从内存那头过来要 100+ 个周期——比较的"成本"几乎全耗在等 cache miss 上,真比大小的那 1 个周期可以忽略。 + +flat_map 走另一头:所有元素挨在一起。CPU 从内存捞数据按 cache line 捞(x86 一条 64 字节),您访问 `data[0]`,隔壁的 `data[1]`、`data[2]`…一票全免费跟着进 L1。二分查找虽然跳着访问(`mid = n/2`),但总有一段连续区域是热的,每次比较基本都吃 cache,1 个周期了事。 + +所以同样是 `O(log n)`,在"小到中等数据量"这个档,flat_map 的常数因子能比 std::map 小一个数量级。Chromium 造这个轮子,赢的不是渐近复杂度,是常数因子。 + +## 换条路:有序数组 + 二分 + +flat_map 的核心心思一句话讲完:别用树,用一段有序的连续数组,查找用二分。 + +```text +flat_map: + data_: [ (1,"a") | (3,"c") | (7,"g") | (9,"i") | ... ] ← 一段连续的有序 vector + 查找用 std::lower_bound(二分,O(log n)) +``` + +查找走 `std::lower_bound`,在有序数组上二分,`O(log n)`——渐近跟 std::map 一样,但因为数据连续,cache 友好得多。代价在插入和删除:在中间塞一个,后面整段得往后挪一格,`O(n)`;对应 std::map 的 `O(log n)` 插入。存储这边就一个 vector,0 额外节点元数据,一次连续分配。这就是 flat_map 的全部骨架,把"红黑树 vs 有序数组"那组经典权衡原原本本摆台面上:树拿空间局部性换 `O(log n)` 插入,数组拿插入复杂度换空间局部性。 + +那数组什么时候赢?查多写少的时候。 + +最典型的就是配置表、查表、命令分发表这类:启动时构造一次,往后基本只查,极少再插再删。对这种"写一次读多次"的负载,flat_map 那个 `O(n)` 插入只在构造期发生一回(还能批量优化成 `O(N log N)` 一次排序到位,见 03-4),之后全是 `O(log n)` 的 cache 友好查找。std::map 呢,每次查找都得付那个 cache miss 的常数因子。两边写入都是一次性的、不分高下,可读取 flat_map 快得多——这块场景里它几乎是白赚。 + +反过来,要是您的集合又大又频繁变动(比如一个不停增删的索引),flat_map 每次 `O(n)` 插入就开始疼了,那是 std::map 的主场。Chromium 自己的容器选择指南就划得这么直白:写一次读多次用 flat_map,写多次且量大用 std::map。 + +## Chromium 的取舍,标准库的跟进 + +flat_map 不是 Chromium 凭空想出来的。sorted-vector map 历史不短——Alexandrescu 早在 2001 年《Modern C++ Design》就给过 `Loki::AssociationVector`,Boost.Container 也长期挂着 `boost::flat_map`。Chromium 2017 年把这套搬进 `//base`,顺手做了 Chromium 风格的特化(`DCHECK`/`CHECK` 校验、`raw_ptr_exclusion`、默认透明比较器)。 + +有个细节笔者觉得值得拎出来说。Chromium 的 flat_map 把 key-value 挤在一个数组里(`vector>`);而 C++23 入库的 `std::flat_map`(P0429 提案)走的是键值分离(split storage)——keys 和 values 各开一个连续数组。分离的好处是只遍历键时 cache 更密,value 不来插一脚;代价是实现复杂,得维护两套容器同步。Chromium 选了不分离的简单路子——"看起来更优"的 split 方案被工业界主力搁下了,实现复杂度换回来的那点收益不划算。这分歧咱们留到 03-6 性能对比再细抠。 + +底座这层就到这儿。flat_map 默认拿 vector 存数据,所以下一步得先把 `std::vector` 的三指针、扩容、迭代器失效摸透——那是理解 flat_map 行为的前提。 + +## 参考资源 + +- [Chromium `base/containers/flat_map.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [Chromium `base/containers/README.md` —— 容器选择指南](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/README.md) +- [cppreference: std::map(红黑树实现说明)](https://en.cppreference.com/w/cpp/container/map) +- [P0429 —— std::flat_map 提案(C++23)](https://wg21.link/p0429) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-01-flat-map-vector-internals-and-growth.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-01-flat-map-vector-internals-and-growth.md new file mode 100644 index 000000000..55d1a4950 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-01-flat-map-vector-internals-and-growth.md @@ -0,0 +1,113 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +description: flat_map 默认用 vector 存数据,这一篇讲清 vector 的三指针表示、连续存储的 cache 优势、 + 扩容与摊还分析、迭代器失效规则,为 flat_map 行为打底 +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- flat_map 前置知识(零):有序关联容器与 std::map 的红黑树 +reading_time_minutes: 9 +related: +- flat_map 前置知识(二):复杂度与摊还分析 +- flat_map 实战(二):flat_tree 核心骨架 +tags: +- host +- cpp-modern +- intermediate +- 容器 +- vector +- 优化 +title: "flat_map 前置知识(一):std::vector 内部表示与扩容" +--- +# flat_map 前置知识(一):std::vector 内部表示与扩容 + +[pre-00](./pre-00-flat-map-ordered-assoc-container-intro.md) 里咱们把 flat_map 的形状描成了"有序数组 + 二分"。那个"数组"到底是谁?翻一眼 Chromium 的模板签名 `flat_map`,默认那个 `Container` 就是 `std::vector>`(flat_map.h:193)。说白了,flat_map 的底座就是 vector。vector 怎么存数据、什么时候扩容、什么时候把迭代器作废,flat_map 就跟着怎么干,跑不掉。 + +所以这一篇咱们把 `std::vector` 的内部表示彻底拆开。您要是已经对三指针、扩容、迭代器失效这些烂熟,直接跳到后面"回到 flat_map"那一节;不熟的话建议吃透它——后面 flat_tree 每一条复杂度结论,根都埋在这里。 + +## 三指针:vector 的内部表示 + +`std::vector` 在 libstdc++、libc++、MSVC 里长得几乎一样:三个指针,加一段连续内存。三个指针塞在一个头结构里,实现之间细节略有出入,概念是一致的。 + +```text + begin end end_of_storage + ↓ ↓ ↓ +内存: [ | | | | | | | | | | | | | | | ] + ←── size ──→←── 空闲 ──→ + ←────────── capacity ────────→ +``` + +`begin` 指头一个元素;`end` 指最后一个元素的下一格,也就是尾后,`end - begin` 正好等于 `size()`;`end_of_storage` 指已分配内存的末尾,`end_of_storage - begin` 就是 `capacity()`,这块内存不扩容时最多能装多少。 + +笔者要提醒您分清两个量:`size` 是当前真实装了多少个,`capacity` 是这块内存最多能装多少。中间那段(`end` 到 `end_of_storage`)是已经分配下来、但还没排上用场的空闲地。`push_back` 就是直接在这块空地上原地构造新元素,不用再去要内存。 + +### 连续存储:cache 友好的根 + +这段内存是连续的,元素一个挨一个,中间没缝(trivially copyable 类型如此;有 alignment padding,但布局仍是连续)。vector 之所以 cache 友好,根就在这。CPU 从内存捞数据是按 cache line 捞的,一条 64 字节,您访问 `data[0]`,旁边的 `data[1]`、`data[2]` 就被免费顺带进 L1;再访问它们,1 个周期,直接命中。[pre-00](./pre-00-flat-map-ordered-assoc-container-intro.md) 里说 flat_map 的常数因子比 `std::map` 小一个数量级,根就是这一段连续内存。 + +--- + +## 扩容:capacity 不够时怎么办 + +`push_back` 走到 `size < capacity`,一拍大腿直接在 `end` 那格构造新元素,`end` 往后挪一格,`O(1)` 收工。这是快乐路径。 + +可 `size == capacity` 满了怎么办?那就得扩容。整套动作是这样的:先去申请一块更大的新内存,主流实现按 2 倍走,新 `capacity = 旧 capacity * 2`;接着把旧内存里的元素一个一个搬过去——这里有个细节,搬的时候用 move 还是用 copy,取决于元素的 move 是不是 noexcept,是的话放心 move,不是就退回 copy,保住强异常安全;搬完析构旧元素、释放旧内存,再把三个指针拨到新内存上。 + +这一整套是 `O(n)` 的,得搬 n 个元素。所以单次 `push_back` 的最坏情况就是 `O(n)`,绕不开。 + +### 摊还 O(1):为什么 push_back 还是"快的" + +单次最坏 `O(n)` 听着吓人,但 `push_back` 在工程上是摊还 `O(1)`(amortized constant time)。直觉很简单:扩容那一下是 `O(n)` 不假,可扩完 capacity 翻一倍,接下来 n 次 `push_back` 全踩在快乐路径上,一次都不扩。把那一次 `O(n)` 摊到这 n 次头上,每次平均 `O(1)`。 + +这就是经典的几何增长容量分析——2 倍扩容把 push_back 的摊还复杂度钉在 `O(1)`。所以日常拿 vector 做 `push_back` 是很快的,那个 `O(n)` 最坏情况您不用怕。 + +### 回到 flat_map:插入没有摊还 + +但 flat_map 的单元素插入,这笔摊还的好处它一口都吃不到。 + +问题出在插入位置。flat_map 要保有序,`insert(key)` 先拿 `lower_bound` 把位置找出来,然后就在那儿插——而那个位置往往在数组中间。中间插一个,后面所有元素都得往后挪一格,这是实打实的 `O(n)` shift,而且**每次插入都要挪**——不像 push_back 只在扩容那一次付代价,其余都是 `O(1)`。所以 flat_map 的单次 insert 就是 `O(n)`,没有摊还这回事。这条您记住,它是后面"查多写少"判据的出处,flat_map 写多了就慢,源头就在这。 + +--- + +## 迭代器、指针、引用失效 + +vector 的失效规则是 C++ 面试老生常谈,但对 flat_map 它是真的承重,咱们这里精确过一遍。 + +`push_back` 最有意思:没触发扩容时,元素压根没挪窝,所有迭代器、指针、引用一律有效;一旦触发扩容、分配了新内存,旧的整块释放,所有指向旧地址的迭代器瞬间悬垂——全失效。所以工程上保守起见,`push_back` 之后您就把迭代器当作失效处理,别赌它没扩容。`reserve(n)` 同理,`n` 大过当前 capacity 就触发扩容全失效,否则不变。`insert` 和 `erase` 是从操作点开始一直到 `end` 都失效(元素被挪动了),插入还要再叠加一条可能扩容、全失效。`clear` 把所有迭代器都作废,但 capacity 通常留着,要回收得调 `shrink_to_fit`。 + +### flat_map 故意把规则说粗 + +Chromium 的 flat_tree 源码里,失效规则的表述是故意保守的——它对所有 mutation(insert / erase / reserve / shrink_to_fit / swap / move ctor / move assign)都甩同一句"Assume that every operation invalidates iterators and references",假设每个操作都让所有迭代器和引用失效。flat_tree.h 那批注释就是这副口气(151/217/231/273/306/319/374 行)。 + +为什么不安安静静按 vector 的精细规则来——reserve 只在真正 realloc 时失效、insert 只失效从插入点起?笔者琢磨过这事,答案挺务实:那套精细规则对调用方太不友好了,您写代码时得时刻惦记"这次 insert 会不会扩容?这次 reserve 够不够大?",心智负担一重就容易记错。flat_tree 干脆一刀切,改了就当全失效。粗规则不如精细规则"准确",但它没人会记错——而这才是工程上真正要的东西。这件事咱们到 03-5 专门讲迭代器失效时还会回来。 + +源码里甚至直接甩了一个 UB 例子(flat_map.h:57-60): + +```cpp +container["new element"] = it.second; // UB:operator[] 可能触发扩容,it 失效 +``` + +这种"边遍历边改"的写法,在 flat_map 里就是直接未定义行为,比 `std::map`(节点稳定,迭代器跨 mutation 有效)严格一大截。 + +## reserve 与 shrink_to_fit + +vector 给了两个容量管理接口,flat_tree 也原样透传。 + +`reserve(n)` 是预先分配能装下 n 个元素的内存。您要是事先知道最终大概要装多少,提前 reserve 一把,后面那几次扩容的搬迁开销全省了。flat_map 批量构造的时候这条特别有用(03-5 会专门讲批量构造模式)。`shrink_to_fit()` 反过来,把 capacity 缩到 size,把多余内存还回去——不过它是个非绑定请求,标准允许实现直接无视,但主流实现一般会配合做一次 realloc。 + +这俩都会失效迭代器,因为都可能触发 realloc。 + +--- + +带着这些,下一篇咱们去把复杂度这套工具讲清楚——`O(lg n)` 查找对着 `O(n)` 插入、摊还对着单次,给后面 flat_tree 的复杂度结论铺好底。 + +## 参考资源 + +- [cppreference: std::vector](https://en.cppreference.com/w/cpp/container/vector) +- [cppreference: vector 的迭代器失效规则](https://en.cppreference.com/w/cpp/container/vector#Iterator_invalidation) +- [Chromium `base/containers/flat_tree.h` —— 迭代器失效注释](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Bjarne Stroustrup — vector 与 cache 的性能实验](https://www.stroustrup.com/Software-for-infrastructure.pdf) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-02-flat-map-complexity-and-amortized.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-02-flat-map-complexity-and-amortized.md new file mode 100644 index 000000000..bd76a5f78 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-02-flat-map-complexity-and-amortized.md @@ -0,0 +1,118 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: 讲清大 O、单次 vs 摊还复杂度,落到 flat_map 的 O(lg n) 查找、O(n) 插入、 + range 构造的 O(N lgN),并用真实实测佐证 shift 代价 +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- flat_map 前置知识(一):std::vector 内部表示与扩容 +reading_time_minutes: 10 +related: +- flat_map 实战(三):查找与插入 +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 优化 +title: "flat_map 前置知识(二):复杂度与摊还分析" +--- +# flat_map 前置知识(二):复杂度与摊还分析 + +[pre-00](./pre-00-flat-map-ordered-assoc-container-intro.md) 那会儿笔者甩出一句"flat_map 是 `O(log n)` 查找、`O(n)` 插入",[pre-01](./pre-01-flat-map-vector-internals-and-growth.md) 又说 vector `push_back` 是摊还 `O(1)`。这两句话里头藏着一个很容易被您忽略、但又会真金白银决定 flat_map 性能的区分——单次代价和摊还代价是两回事。这一篇笔者就想把这个工具本身给咱们掰开,因为 flat_map 后面所有性能结论,根都扎在这套分析上。理解透了,您自然就能判断什么时候该上 flat_map、什么时候用了是给自己挖坑。 + +## 大 O 记号:渐近复杂度 + +大 O 记号描述的是操作代价随输入规模 `n` 增长的方式,常数因子和低阶项一律忽略。最常用的几档:`O(1)` 是常数时间,跟 `n` 无关,比如 `vector::size()` 直接读个字段;`O(log n)` 是对数时间,有序数组二分查找每步把范围砍半就是这一档;`O(n)` 是线性,跟 `n` 成正比,遍历或者在数组中间插一个要挪后面全部元素都算;`O(n log n)` 常见于排序,或者对 N 个元素逐个二分插入;`O(n²)` 就是更狠的,对 N 个元素逐个在头部插,每次付 `O(n)`、共 N 次。 + +大 O 回答的是"`n` 趋向无穷时谁赢"。但 [pre-00](./pre-00-flat-map-ordered-assoc-container-intro.md) 笔者已经提醒过,大 O 把常数因子扔了,而真实程序里常数因子——也就是每次操作到底烧多少周期——能差出一个数量级。这正是 flat_map 在小 N 下能赢 std::map 的根:两者查找渐近都是 `O(log n)`,可 flat_map 是连续存储,cache 一行能装好几个元素,std::map 的红黑树节点散在堆上,一趟指针跳下来 cache miss 一堆。所以看复杂度结论时,大 O 只是上半场,下半场是常数因子,在咱们这套语境里主要就是 cache 行为。 + +## 单次 vs 摊还:一个关键区分 + +这一篇的核心就在这里。同一个操作,有两种复杂度口径:单次(single)看的是做这一次最坏要多少时间;摊还(amortized)看的是连续做 N 次平均每次多少时间,把偶尔冒出来的大代价摊平到 N 次上。 + +vector 的 `push_back` 是教科书级例子。单次最坏 `O(n)`——触发扩容,得把现有元素全部搬一遍;摊还却是 `O(1)`,因为扩容按 2 倍几何增长,扩完之后接下来 N 次都不用再扩,那一次 `O(n)` 摊到 N 次上,平均下来每次常数。咱们日常觉得 `push_back` 很快,就是不知不觉吃了这个摊还优惠。 + +### flat_map 单元素插入没有摊还优惠 + +问题来了,flat_map 的 `insert(key, value)` 享受不到这茬。回忆 [pre-01](./pre-01-flat-map-vector-internals-and-growth.md):flat_map 得保持有序,`insert` 先 `lower_bound` 找到位置(多半落在数组中间),然后**把那个位置之后的所有元素往后挪一格**。这次挪动是实打实的 `O(n)`,而且每次插入都得挪,不是偶尔来一次。 + +所以 flat_map 的单元素插入单次是 `O(n)`,摊还还是 `O(n)`——因为每次都付 `O(n)`,根本没有"偶尔一次大代价"可以摊平。连续做 N 次单元素 `insert`,总代价堆到 `O(n²)`。这就是"逐个 insert 构造一个大 flat_map"为什么是陷阱的根,后面 03-5 笔者会专门讲怎么用批量构造绕开它。 + +## O(lg n) 查找:二分 + +flat_map 的查找——`find`、`contains`、`lower_bound`、`equal_range`——全部 `O(log n)`,靠的就是有序数组上的二分。以 `lower_bound` 为例(flat_tree.h:1027 用 `std::ranges::lower_bound`): + +```cpp +// 在有序数组 [first, last) 里找第一个不小于 key 的位置 +auto it = std::ranges::lower_bound(data, key, comp); +``` + +二分每步把搜索范围砍半,所以 `n` 个元素最多比较 `log₂(n)` 次。100 万元素大约 20 次比较,每次比较命中 cache(连续存储)只要 1 到 2 个周期,查找的总代价极小。flat_map 查找快就快在这:不光是 `O(log n)`,而且每一次比较本身都便宜。 + +`find`、`contains`、`lower_bound`、`equal_range` 这几个接口语义和 std::map 一致,flat_map 全从 flat_tree 继承——`find(key)` 是精确查找,等于 `lower_bound` 之后再判一次相等;`contains(key)` 就是 `find != end`;`lower_bound(key)` 给第一个 `>= key` 的位置;`equal_range(key)` 给 `[lower_bound, upper_bound)` 区间。区别只在底层把树遍历换成了二分。 + +## O(n) 插入:shift 的代价 + +flat_map 的插入(`insert`/`emplace`/`operator[]`/`insert_or_assign`)走的是同一条路(flat_tree.h:1060 `unsafe_emplace`):先用 `lower_bound` 找到插入位置,这是 `O(log n)`;然后在那位置上来一发 `vector::emplace`,把后面所有元素往后挪一格,这一下就是 `O(n)`。总复杂度被 shift 主导,落地 `O(n)`。erase 同理(flat_tree.h:914/921 `body_.erase`),删一个,后面所有元素往前挪,`O(n)`。 + +### 实测:shift 到底有多贵 + +光说 `O(n)` 不够直观,笔者跑了个实验。在 vector 头部插 10 万次(`emplace(begin)`),每次都得把后面所有元素挪一格: + +```text +10 万次 vector::emplace(begin) → 264 ms (O(n²) 总代价) +10 万次 vector::push_back → 0 ms (摊还 O(1)) +``` + +两个数量级的差距。把 flat_map 当 std::map 那样频繁在中间插,您看到的就是这条 264ms 的曲线。flat_map 的 `O(n)` 插入不是教科书上吓唬人的理论警告,是实打实会撞上去的性能墙。 + +## range 构造:O(N lg²N) → O(N lgN) + +但 flat_map 有一条便宜的构造路径。您要是能一次性喂给它一坨数据(比如从 `vector>` move 构造),它就用不着逐个 insert——而是先把元素全部 append 进去,再一次性排序去重(`sort_and_unique`,flat_tree.h:147-149): + +```text +flat_map 构造(N 个元素): + 1. append 所有元素 O(N) + 2. sort_and_unique: + std::stable_sort O(N log N) + unique + erase O(N) + 总计 O(N log N) (有额外内存时;否则 O(N log²N)) +``` + +`stable_sort` 在有额外内存可用时是 `O(N log N)`——可以开一块临时缓冲做归并;内存不够时退化到 `O(N log²N)`,原地归并每层得付 `O(N log N)`。所以 flat_map 的批量构造是 `O(N log N)`,远比"逐个 insert 的 `O(N²)`"便宜。这就是"写一次"场景下 flat_map strictly better 的实现依据。 + +### sorted_unique:跳过排序 + +再往前一步,如果您能保证输入已经有序且无重复,可以用 `sorted_unique_t` 标签构造(flat_tree.h:606-646),flat_map 连 `sort_and_unique` 都不调,直接接管,构造变成 `O(N)`。这是零成本抽象的典范,笔者留到 pre-04 和 03-4 专门讲。 + +## 复杂度总表 + +flat_map 的复杂度结论汇总在下面这张表里,全部在 flat_tree.h 源码注释里有据可查: + +| 操作 | 复杂度 | 备注 | +|---|---|---| +| 查找 find/contains/lower_bound/equal_range | `O(log n)` | 二分,cache 友好 | +| 单次 insert/emplace | `O(n)` | 含 shift,无摊还 | +| erase(position/range) | `O(n)` | shift | +| erase(key) | `O(n) + O(log n)` | 先找再删 | +| operator[]/insert_or_assign/try_emplace | `O(n)` | 同 insert | +| range 构造(普通) | `O(N log²N)` / `O(N log N)` | 取决于是否有额外内存 | +| range 构造(sorted_unique) | `O(N)` | 跳过 sort_and_unique | +| reserve/shrink_to_fit | `O(n)` | realloc,失效迭代器 | + +横向对比 std::map(红黑树):查找 `O(log n)`,渐近跟 flat_map 平手,但常数因子吃亏;insert/erase `O(log n)`,渐近上赢 flat_map。所以渐近复杂度这条线,std::map 在插入上赢;常数因子这条线,flat_map 在查找上赢。落到大白话就是——查多写少上 flat_map,大且频繁改上 std::map。 + +下一篇咱们看 flat_map 的比较器:它怎么决定元素顺序,以及现代的"透明比较器"怎么省掉临时对象构造。 + +## 参考资源 + +- [cppreference: std::lower_bound(二分查找)](https://en.cppreference.com/w/cpp/algorithm/lower_bound) +- [cppreference: 复杂度(摊还分析)](https://en.cppreference.com/w/cpp/language/complexity) +- [Chromium `base/containers/flat_tree.h` —— 复杂度注释](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-03-flat-map-comparator-and-transparent.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-03-flat-map-comparator-and-transparent.md new file mode 100644 index 000000000..2546b450a --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-03-flat-map-comparator-and-transparent.md @@ -0,0 +1,160 @@ +--- +chapter: 0 +cpp_standard: +- 14 +- 17 +- 20 +description: 拆解 flat_map 的比较器——strict weak order 要求、std::less vs 透明的 std::less<>, + 以及 transparent comparator 如何用 ConditionalT 编译期分流实现异构查找 +difficulty: intermediate +order: 3 +platform: host +prerequisites: +- flat_map 前置知识(二):复杂度与摊还分析 +reading_time_minutes: 10 +related: +- flat_map 前置知识(五):NO_UNIQUE_ADDRESS + EBO + pair 类型 +- OnceCallback 前置知识(四):Concepts 与 requires 约束 +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 类型安全 +title: "flat_map 前置知识(三):比较器、strict_weak_order 与透明查找" +--- +# flat_map 前置知识(三):比较器、strict_weak_order 与透明查找 + +flat_map 是有序容器。但"有序"这俩字得追一句——按什么排?答这篇要拆两件事,都跟比较器有关。一件是,比较器不是随便写个 `<` 就完事的,它得满足一条叫 strict weak order 的数学契约,不然容器排序、查找都可能出错。另一件是,flat_map 默认的比较器是 `std::less<>`(透明的),而不是 `std::less`(不透明的),这个差别看着小,但在查找热路径上能差出一次 malloc/free——现代 C++ 为什么推崇 `std::less<>`,根子就在这。 + +## 比较器:决定顺序的函数对象 + +先看 flat_map 的模板签名,`flat_map, Container = ...>`(flat_map.h:190-193)。第三个模板参数 `Compare` 就是比较器,本质上是个函数对象,接两个参数,返回第一个是不是该排在第二个前面。 + +默认值是 `std::less<>`,意思是拿 `<` 比大小,`flat_map` 默认就按 int 从小到大。您也可以传自己的,比如按字符串长度排: + +```cpp +struct ByLength { + bool operator()(const std::string& a, const std::string& b) const { + return a.size() < b.size(); + } +}; +flat_map m; // 按字符串长度排序 +``` + +但比较器不是您想怎么写就怎么写的,它背后有一条数学契约管着。 + +## strict weak order:比较器的数学契约 + +容器要能正确排序、正确查找,您给的比较器必须满足 strict weak order(严格弱序)。这玩意儿四条性质,笔者一条条说。 + +非自反,`comp(a, a)` 必为 false,a 不能比自己小。反对称,`comp(a, b)` 为 true 时 `comp(b, a)` 必为 false。传递,`comp(a, b)` 且 `comp(b, c)` 成立,`comp(a, c)` 必成立。前三条其实就是 `<` 的性质,直觉上不难接受。 + +真正容易漏的是第四条,不可比传递。什么叫"不可比"?就是 `!comp(a,b) && !comp(b,a)`,即 a 不比 b 小、b 也不比 a 小,也就是俩玩意儿"相等"。第四条要求:如果 a 和 b 相等,b 和 c 相等,那 a 和 c 也必须相等。这条保证"相等"是个货真价实的等价关系,可传递,容器才能把元素划成等价类、一类一类排好。 + +笔者强调第四条,是因为它最容易在不经意间被违反。浮点比较里那个 NaN,`NaN < x` 和 `x < NaN` 都是 false,按理说"相等",可两个 NaN 之间也比不出——不可比传递直接破。再比如您写个带容差的比较器,`abs(a-b) < eps` 就算相等,eps 选不好、比较顺序不稳,"相等"这个关系就不传递了,排序结果就乱。元素可能在一次 find 里"消失",或者重复出现,这种 bug 不好查。C++20 把这条契约编进了 `std::strict_weak_order` concept,您可以拿它约束自己的比较器,编译期就能拦住。 + +一句话:用 `<` 比大小不用操心;自己写比较器,尤其按多字段、带容差的,心里得装着这四条。 + +## std::less vs std::less<>:不透明 vs 透明 + +到这儿进入现代 C++ 的一个重要区别。`std::less` 有两种长相。一种叫 `std::less`,C++98 就有,不透明,只吃 `Key` 这一种类型——`std::less` 的 `operator()` 签名就是 `bool operator()(const std::string&, const std::string&)`,您传别的它不认。另一种叫 `std::less<>`,C++14 加进来的,透明,模板化的 `operator()`,啥类型都接,内部走 `<`,所以也叫 transparent comparator(透明比较器)。 + +这里有个细节您可能没留意:`std::map` 默认用的是 `std::less`,不透明;而 flat_map 默认用的是 `std::less<>`,透明(flat_map.h:192)。同一个标准库家族,一个保守一个激进,根子就在这。这个差别乍看无关紧要,对查找性能的影响却很实在。 + +## 透明查找:不构造临时对象 + +假设您手上一个 `flat_map`,想查 key `"timeout"`: + +```cpp +flat_map m; +auto it = m.find("timeout"); // "timeout" 是 const char[8] +``` + +`"timeout"` 这玩意儿是 `const char[8]`,不是 `std::string`。比较器如果是 `std::less`(不透明),那 `find` 的参数必须是 `std::string`,容器没办法,只能先拿您的 `const char[8]` 构造一个临时 `std::string`——分配堆内存、拷贝字符——再拿这个临时去二分比,比完析构掉。一次查,平白多一对 malloc/free。 + +如果比较器是 `std::less<>`(透明),情况就不一样了。`find` 直接拿 `const char*` 比,因为 `std::string` 和 `const char*` 都能走 `<`(或者 `std::less::operator()` 那条泛型路径),压根不用构造临时 `std::string`。这就是透明查找的价值——查找一次,省一次临时构造。 + +对 int 这种轻 key,无所谓;对 `std::string`、自定义类型这种重 key,在热路径上反复 find,省下来的临时构造能累加成可观的数字。笔者第一次意识到这点是在 profile 一个配置热路径的时候,malloc 榜上一大片 `std::string` 临时,全来自 map.find,换成透明比较器之后那一片直接消失——印象很深。 + +## flat_map 怎么实现透明:KeyT 的编译期分流 + +那 flat_map 怎么知道比较器透不透明?靠一个编译期的类型特征,叫 `is_transparent`。透明比较器(像 `std::less<>`)内部带一个嵌套类型 `is_transparent`,就是个空的 struct 当标记;不透明的(像 `std::less`)没有这个类型。flat_tree 拿 `KeyT` 别名在编译期分流(flat_tree.h:109-111): + +```cpp +template +using KeyT = ConditionalT< + requires { typename KeyCompare::is_transparent; }, // 比较器透明吗? + K, // 透明:保留调用方传的 K + Key>; // 不透明:强制回退到 Key +``` + +`ConditionalT` 跟 `std::conditional_t` 长得像,但参数之间不互相依赖,可以正常推导。逻辑一句话:比较器透明,`KeyT` 就等于调用方传进来的 `K`,比如 `const char*`;比较器不透明,`KeyT` 就被强制掰回 `Key`,比如 `std::string`。 + +于是 `find` 的签名就跟着比较器变了: + +```cpp +// 透明比较器(std::less<>):可接受异构 key +template +auto find(const KeyT& key); // KeyT = K(透明) + +// 不透明比较器(std::less):只接受 Key +template +auto find(const KeyT& key); // KeyT = Key = string +``` + +调用方传 `const char*`,透明版本直接吃下去;不透明版本得把 `const char*` 隐式转成 `std::string`(构造临时)才匹配得上。这一切全在编译期搞定,运行期零开销。笔者认为这套设计最漂亮的地方就在这——同一个 `find` 签名,行为完全靠比较器的一个嵌套类型在编译期切,用户代码一行不用改。 + +## KeyValueCompare:异构比较的实现细节 + +再往下钻一层。底层怎么拿异构 key,去比一个存了 `pair` 的元素?flat_tree 的 `KeyValueCompare`(flat_tree.h:439-462)用了两个 `extract_if_value_type` 重载来挡这件事。比较的某一侧如果是 `value_type`(就是 `pair`),先走 `GetKeyFromValue` 把 key 抠出来再比;某一侧如果是裸 `K`(异构 key,比如 `const char*`),原样放行,直接比。 + +这么一来,`lower_bound(data, "timeout", comp)` 就能对一个存着 `pair` 的数组,直接拿 `const char*` 去比——既不用把 `"timeout"` 包成 `pair`,也不用把元素里整个 value 拆出来。这就是异构查找能落到二分循环里去的实现细节,看着不起眼,但它把"异构 key"和"存 value_type 的数组"这俩看起来对不上的东西,用一层重载抹平了。 + +## 一个最小复刻 + +咱们自己撸个透明比较的最小版,体会一下编译期分流: + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include +#include +#include + +// 透明比较器(带 is_transparent 标记) +struct TransparentLess { + using is_transparent = void; // 关键:标记透明 + template + bool operator()(A&& a, B&& b) const { return std::forward
(a) < std::forward(b); } +}; + +// 不透明比较器(无 is_transparent) +struct OpaqueLess { + bool operator()(const std::string& a, const std::string& b) const { return a < b; } +}; + +template +constexpr bool is_transparent_v = requires { typename Comp::is_transparent; }; + +int main() { + std::cout << std::boolalpha; + std::cout << "TransparentLess 透明? " << is_transparent_v << "\n"; // true + std::cout << "OpaqueLess 透明? " << is_transparent_v << "\n"; // false + return 0; +} +``` + +把这里的 `is_transparent_v` 套进前面那行 `ConditionalT`,就是 flat_tree 的 `KeyT` 分流。真实代码里 flat_tree.h:109-111,一字不差。 + +讲到这里,flat_map 的比较器这块算是拆透了。strict weak order 是排序正确的数学底座,`std::less<>` 比 `std::less` 强在能用异构 key 查找、不构造临时对象,flat_map 默认走透明路线,靠 `is_transparent` 标记加 `ConditionalT` 在编译期把分流做完,运行期一文不收。 + +flat_map 还藏着另一个挺巧的零成本构造——`sorted_unique_t` 标签,走 tag dispatch 跳过排序,下一篇咱们再拆。 + +## 参考资源 + +- [cppreference: std::less(含 transparent 形态)](https://en.cppreference.com/w/cpp/utility/functional/less) +- [cppreference: strict_weak_order(C++20 concept)](https://en.cppreference.com/w/cpp/concepts/strict_weak_order) +- [cppreference: is_transparent 与异构查找](https://en.cppreference.com/w/cpp/utility/functional/less_void) +- [Chromium `base/containers/flat_tree.h` —— KeyT/KeyValueCompare](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-04-flat-map-tag-dispatch-and-sorted-unique.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-04-flat-map-tag-dispatch-and-sorted-unique.md new file mode 100644 index 000000000..774317f2c --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-04-flat-map-tag-dispatch-and-sorted-unique.md @@ -0,0 +1,177 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: 拆解 flat_map 的 sorted_unique_t 标签分发——用空 tag 类型在重载决议期挑函数, + 跳过 sort_and_unique,配 DCHECK 做防御性校验,零运行期开销 +difficulty: intermediate +order: 4 +platform: host +prerequisites: +- flat_map 前置知识(三):比较器、strict_weak_order 与透明查找 +reading_time_minutes: 9 +related: +- flat_map 实战(四):sorted_unique 构造优化 +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 零开销抽象 +title: "flat_map 前置知识(四):tag dispatch 与 sorted_unique_t" +--- +# flat_map 前置知识(四):tag dispatch 与 sorted_unique_t + +[pre-02](./pre-02-flat-map-complexity-and-amortized.md) 那篇笔者顺手埋了个钩子:flat_map 的批量构造是 `O(N log N)`,因为它拿到数据得先排一遍、再去个重(`sort_and_unique`)。笔者当时写完心里就嘀咕,这要是数据本来就是有序的,这一遍岂不是白排?Chromium 的工程师也想到了这茬,他们的答法挺干净:您构造时甩个 `sorted_unique_t` 标签进去,等于跟它说"放心,这批数据有序无重复",它就把排序那步跳了,构造直接掉到 `O(N)`。 + +这一篇咱们就拆这个标签背后的机制,也就是 tag dispatch(标签分发),外加 flat_map 怎么在 debug 下用 `DCHECK` 抓那些嘴上打包票、手上递乱数据的人。 + +## 问题:批量构造每次都要排序吗 + +flat_map 的普通构造——不管是传 vector、initializer_list 还是 range——默认都走 `sort_and_unique`(flat_tree.h:567/578/586/594)。它没办法,它根本不知道您给它的数据是不是排好的,只能先排再去重,保险第一。 + +可工程里"数据本来就排好了"的场景一抓一大把。配置是从另一个有序容器拷过来的、点云是上游 pipeline 处理过按 id 排好的、测试 fixture 是您手写的有序 list。这种时候还让它 `O(N log N)` 排一遍,纯粹白烧 CPU: + +```cpp +std::vector> raw = load_config(); // 已知有序 +flat_map m(raw.begin(), raw.end()); // 还是会再排一次! +``` + +`raw` 明明已有序,flat_map 照样花 `O(N log N)` 再排一遍。数据集小您不在意,真上到百万量级,这个对数因子就肉疼了。 + +--- + +## tag dispatch:用类型挑函数 + +解决这事的套路有个名字,叫 tag dispatch(标签分发)。思路其实朴素得有点过分:定义一个空的"标签类型",构造时传不传这个标签,让编译器在重载决议期挑不同的函数。标签本身是空 struct,运行期什么都不传,自然零开销。 + +这招在标准库里到处都是。`std::sort` 的并行版您传个 `std::execution::par`,它就选并行算法;不传,选串行。标签自己不带任何数据,纯粹是给重载决议一个"分流信号"。iterator_category 那一套也是同源的把戏——`std::random_access_iterator_tag` 往重载里一塞,算法就走随机访问那条快速路径。 + +### flat_map 的 sorted_unique_t + +flat_map 用的就是这一套(flat_tree.h:28-31): + +```cpp +struct sorted_unique_t { + constexpr sorted_unique_t() = default; +}; + +inline constexpr sorted_unique_t sorted_unique; +``` + +一个空 struct(只留着默认构造),外加一个 `constexpr` 实例 `sorted_unique`。您构造 flat_map 时把 `sorted_unique` 当第一个参数塞进去,重载决议就会把您引到那个"跳过排序"的构造函数: + +```cpp +std::vector> raw = load_config(); // 已知有序 +flat_map m(sorted_unique, raw.begin(), raw.end()); // 跳过 sort! +``` + +第一个参数是标签,后面才是数据。标签本身不带负载,它存在的全部意义就是让编译器在重载决议期说一句"哦,走那条不排序的路"。 + +--- + +## 5 个 sorted_unique 重载 + +flat_tree 给 sorted_unique 配了 5 个构造重载(flat_tree.h:606-646),InputIterator range、`from_range_t`、`const container_type&`、`container_type&&`、`initializer_list` 五种输入形态各一个。它们和普通构造的差别只有一行:不调 `sort_and_unique`。 + +```cpp +// 普通构造(flat_tree.h:567 附近):排序去重 +flat_tree(InputIterator first, InputIterator last, ...) { + insert(first, last); + sort_and_unique(); // 花钱的排序 +} + +// sorted_unique 构造(flat_tree.h:606 附近):跳过排序 +flat_tree(sorted_unique_t, InputIterator first, InputIterator last, ...) { + insert(first, last); + DCHECK(is_sorted_and_unique(...)); // 只 debug 校验,不排序 +} +``` + +两个重载参数列表的差别只有打头的那个 `sorted_unique_t`。编译器据此在重载决议期分流。`sorted_unique_t` 是空类型,实例不占空间,传它等于没传,这次"选择"在运行期一个字节都不收您。 + +--- + +## DCHECK(is_sorted_and_unique):debug 抓撒谎的人 + +可嘴上说"有序"的人,手上递的数据未必真有序。flat_map 不傻信您,它在 debug 构建里挂了个 `DCHECK(is_sorted_and_unique(...))`(flat_tree.h:612/624/633/642)当保险。`is_sorted_and_unique`(flat_tree.h:55-62)长这样: + +```cpp +template +constexpr bool is_sorted_and_unique(const Range& range, Comp comp) { + return std::ranges::adjacent_find(range, std::not_fn(comp)) == + std::ranges::end(range); +} +``` + +它用 `std::ranges::adjacent_find` 配 `std::not_fn(comp)` 扫一遍相邻对:任何相邻元素没满足"严格小于"的(相等或逆序),`adjacent_find` 立刻定位到那个位置,`DCHECK` 翻脸,abort 给您看。 + +这是一份很 Chromium 风格的契约:debug 下 flat_map 替您校验您保证的真实性,撒谎当场爆;release 下 `DCHECK` 整个编译成空,完全不校验,直接信任您。`is_sorted_and_unique` 本身是 `O(N)`(扫一遍相邻对),但这个钱只在 debug 付——release 是真正的 `O(N)` 构造,append 完直接接管,不排也不校验。 + +--- + +## 零成本:release 完全不付费 + +代价摊开算一笔就清楚了。debug 构建里 sorted_unique 构造是 append(`O(N)`)加上 `DCHECK(is_sorted_and_unique)` 那次 `O(N)` 校验,合起来还是 `O(N)`。release 构建里 `DCHECK` 直接消失,sorted_unique 构造就只剩 append,纯 `O(N)`。 + +对比一下普通构造:append(`O(N)`)+ `sort_and_unique`(`O(N log N)`,走的是 stable_sort)。大数据集上 `N log N` 比 `N` 慢一个对数因子——100 万元素就是 20 倍的差距。所以 sorted_unique 在 release 下是真·零开销抽象:您只在确信数据有序时用它,省掉那个 `log N` 因子,debug 下还白送一道校验保平安。 + +--- + +## 一个最小复刻 + +咱们自己撸个 tag dispatch 的最小版,亲手感受一下"用类型挑函数"到底长啥样: + +```cpp +// Platform: host | C++ Standard: C++17 +#include +#include +#include +#include + +struct sorted_unique_t {}; // 空 tag 类型 +inline constexpr sorted_unique_t sorted_unique{}; // 常量实例 + +class MiniMap { +public: + // 普通构造:假设数据无序,内部排序 + MiniMap(std::vector data) : data_(std::move(data)) { + std::sort(data_.begin(), data_.end()); + std::cout << " [普通构造] 排序了\n"; + } + // sorted_unique 构造:信任调用方,跳过排序(debug 下可加 assert 校验) + MiniMap(sorted_unique_t, std::vector data) : data_(std::move(data)) { + assert(is_sorted_unique()); // debug 校验 + std::cout << " [sorted_unique 构造] 跳过排序\n"; + } +private: + bool is_sorted_unique() const { + for (size_t i = 1; i < data_.size(); ++i) + if (!(data_[i - 1] < data_[i])) return false; // 必须严格升序 + return true; + } + std::vector data_; +}; + +int main() { + std::vector a = {3, 1, 2}; + MiniMap m1(a); // 普通构造,会排序 + + std::vector b = {1, 2, 3}; // 已有序 + MiniMap m2(sorted_unique, b); // 跳过排序 + return 0; +} +``` + +跑一下您会看到两行输出:`[普通构造] 排序了` 和 `[sorted_unique 构造] 跳过排序`。tag dispatch 的全部机制就藏在这两个重载签名里——一个多接个空 tag 参数,编译器据此分流,没有任何运行期开销,也没有任何花活。 + +前置知识走到这儿就剩最后一块了:`[[no_unique_address]]`、空基类优化(EBO),以及 flat_map 为什么存的是 `pair` 而不是 `pair`。 + +## 参考资源 + +- [cppreference: tag dispatch(标签分发)](https://en.cppreference.com/w/cpp/named_req/TagDispatch) +- [cppreference: std::sort 与 execution policy 标签](https://en.cppreference.com/w/cpp/algorithm/sort) +- [Chromium `base/containers/flat_tree.h` —— sorted_unique_t 与 DCHECK 校验](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-05-flat-map-enua-ebo-and-pair-storage.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-05-flat-map-enua-ebo-and-pair-storage.md new file mode 100644 index 000000000..398b13cf9 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/full/pre-05-flat-map-enua-ebo-and-pair-storage.md @@ -0,0 +1,170 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: 拆解 flat_map 的两个内存优化——[[no_unique_address]]/EBO 让空比较器零空间开销, + 以及为什么 flat_map 存 pair 而非 std::map 的 pair +difficulty: intermediate +order: 5 +platform: host +prerequisites: +- flat_map 前置知识(三):比较器、strict_weak_order 与透明查找 +reading_time_minutes: 9 +related: +- flat_map 实战(二):flat_tree 核心骨架 +- WeakPtr 前置知识(六):TRIVIAL_ABI 与平凡可重locate +tags: +- host +- cpp-modern +- intermediate +- 容器 +- map +- 内存管理 +- 零开销抽象 +title: "flat_map 前置知识(五):NO_UNIQUE_ADDRESS、EBO 与 pair 存储" +--- +# flat_map 前置知识(五):NO_UNIQUE_ADDRESS、EBO 与 pair 存储 + +flat_map 的类定义里挂着几行小注解,头一回读很容易划过去。笔者这次翻代码,特意停下来抠了一下,发现背后藏着两个内存层面的小巧思。一个让无状态比较器(比如默认的 `std::less<>`)一个字节都不占——靠的是 `[[no_unique_address]]`;另一个把存进去的元素从 `pair` 改成了 `pair`,这个看起来只是少了个 const 的改动,实际上决定了 flat_map 哪些 API 能写、哪些写不了。咱们这一篇就把这两处拆开看。 + +## EBO:空对象本该零字节 + +C++ 有个让笔者第一次知道时直皱眉头的历史包袱:空对象——也就是没有任何数据成员的类——也得占至少 1 字节。规矩是这样:两个不同对象必须各占一个独立地址,而地址是按字节算的,0 字节的对象根本没法有独立地址。所以一个 `struct Empty {}` 的 `sizeof` 不是 0,是 1。 + +```cpp +struct Empty {}; +sizeof(Empty); // 1(不是 0) +``` + +这事儿单看不疼,可一旦您想拿空类型当成员使,就难受了。典型场景就是无状态比较器,比如 `std::less<>`——它里面啥也没有,纯粹是个函数调用包装。您要是把它老老实实做成成员: + +```cpp +struct Holder { + std::less<> comp; // 空对象,但占 1 字节(+ 对齐填充) + int data; +}; +sizeof(Holder); // 8 字节(1 字节 comp + 3 字节填充 + 4 字节 data) +``` + +`comp` 理论上零开销(它根本没数据),实际却占了 1 字节,还把对齐填充也带出来了——4 字节无谓的膨胀。 + +EBO(Empty Base Optimization)就是给"空类作基类"这个场景留的口子:只要空类坐在基类的位置上,编译器就允许它跟派生类共享地址,占用归零: + +```cpp +struct Empty {}; +struct Holder : Empty { // 空类作基类 + int data; +}; +sizeof(Holder); // 4 字节(Empty 被优化掉) +``` + +标准库容器都靠这一招让空 allocator、空 comparator 不拖内存——把它们当基类继承,而不是塞成成员。可 EBO 有个边界:只对基类生效,对成员没辙。您把空对象写成成员,该占还得占。 + +--- + +## [[no_unique_address]]:把 EBO 推广到成员 + +C++20 把上面那条边界给松开了。新加的 `[[no_unique_address]]` 属性告诉编译器:这个成员不需要独占一个地址,要是它是空类型,就别给它分空间。换句话说,EBO 那套从基类挪到了成员身上: + +```cpp +struct Empty {}; +struct Holder { + [[no_unique_address]] Empty comp; // 标注后,空成员可零字节 + int data; +}; +sizeof(Holder); // 4 字节(comp 被 EBO 掉了) +``` + +Chromium 没让这个属性裸着用,而是包了一层宏 `NO_UNIQUE_ADDRESS`——编译器支持就展开成 `[[no_unique_address]]`,不支持就空着。这样代码不用到处写条件编译。 + +flat_tree 在两个位置挂了这个宏,都是放比较器的。一个在 `flat_tree` 自身的成员 `key_compare comp_`(flat_tree.h:545),默认那把 `std::less<>` 在这儿就归零;另一个在嵌套的 `value_compare` 里(flat_tree.h:129),同理。所以您写一句 `flat_map m;`,里头的比较器是一字节都不占的——容器只为真正存数据的 `vector>` 付内存,比较器白送。 + +--- + +## GCC vs Clang:对空类型 EBO 等价正确 + +网上常听到一种说法,说 `[[no_unique_address]]` Clang 比 GCC 好。这话不全是错——在某些非空但可重叠的场景(比如同一个类里挂多个 NUA 成员)两者确实不一样。可落到 flat_map 真正关心的场景上,也就是空类型作成员,笔者实测下来两家的行为是一致的:GCC 16 和 Clang 22 都老老实实把它折到 0 字节。 + +```cpp +struct Empty {}; +struct WithNUA { [[no_unique_address]] Empty e; int i; }; +struct WithoutNUA { Empty e; int i; }; +// 实测 GCC 16 / Clang 22: +// sizeof(WithNUA) = 4 字节(e 被优化掉) +// sizeof(WithoutNUA) = 8 字节(e 占 1 + 填充 3 + i 占 4) +``` + +所以 flat_map 的空比较器在 GCC 和 Clang 上都吃到了 EBO,那个流行说法您别被带沟里去。 + +不过 flat_tree.h:542-547 那段注释指向的,是 GCC 一个真实存在但跟语义完全无关的坑([crbug.com/1156268](https://crbug.com/1156268)):特定成员声明顺序下,GCC 会直接吐 ICE(编译器内部错误)——不是 EBO 折叠行为有差,是编译器自己崩了。Chromium 的 workaround 是把 `comp_` 的声明位置挪一挪。这是编译器实现的 bug,跟语言语义半毛钱关系没有,讲课时可别跟"NUA 语义差异"搅在一起讲。 + +--- + +## pair vs pair:flat_map 的存储选择 + +第二个设计点更狠,直接把 flat_map 的 API 长相都改了。咱们看 flat_map 的模板签名(flat_map.h:193): + +```cpp +template , + class Container = std::vector>> +class flat_map : ...; +``` + +`Container` 默认是 `std::vector>`——看清这个 `pair`,是 `pair`,**不是** `pair`。这一笔跟 `std::map` 正好反过来,`std::map` 的元素是 `pair`,key 一进节点就再改不动。 + +为什么 flat_map 要走非 const 的路?根子在底层容器是 vector。vector 要保持有序,就绕不开 `insert`/`erase` 的 shift——`std::move_backward` 整段搬元素,这一下要求元素类型必须可移动赋值。可 `pair` 的 `first` 是 const,move-assign 不了: + +```cpp +// 实测(C++17): +static_assert(!std::is_move_assignable_v>); // 不可移动赋值 → vector shift 做不了 +static_assert( std::is_move_assignable_v>); // 可移动赋值 +``` + +`pair` 不可 move-assign,这就把 vector 的 shift 路给堵死了。flat_map 想活下去,只能选非 const 的 `pair`。 + +这里有个容易搞混的点,笔者一开始也被带歪过。`insert_or_assign` 那段覆写写的是 `result.first->second = std::forward(obj)`(flat_map.h:339),**只动 `.second`**,就算换成 `pair` 也能编过——const 的只是 `.first`。所以真正把 const pair 拍死的不是覆写,是 vector 的 shift:它要把整对 move-assign,而 `pair` 给不了。别拿覆写当理由,那不是病根。 + +代价这边也得说清楚。既然存的是 `pair`,那 `first` 非 const,key 理论上能被迭代器改。您要是随手一个 `it->first = new_key`,就把有序不变量破坏了,容器还不知道。这玩意儿纯靠用户自律——跟 [WeakPtr 的序列契约](../../02_weak_ptr/full/pre-03-weak-ptr-sequence-checker-dcheck-check.md)一个性质,release 下不强制,您自己得有数。 + +`std::map` 这条路上就没这烦恼,`pair` 让 key 天生改不了。它的本钱来自节点容器模型——不用 shift、不用赋值,靠指针重连就行。两条路各取一头,谁也不白吃。 + +--- + +## 一个最小复刻:验证 EBO 与 pair 类型 + +```cpp +// Platform: host | C++ Standard: C++20 +#include +#include +#include + +struct EmptyLess { + using is_transparent = void; + template + bool operator()(A&& a, B&& b) const { return a < b; } +}; + +struct WithNUA { [[no_unique_address]] EmptyLess c; int i; }; +struct WithoutNUA { EmptyLess c; int i; }; + +int main() { + std::cout << "sizeof(WithNUA) = " << sizeof(WithNUA) << " (NUA 折叠空成员)\n"; + std::cout << "sizeof(WithoutNUA) = " << sizeof(WithoutNUA) << " (空成员占 1 + 填充)\n"; + std::cout << "pair 可 move-assign? " + << std::is_move_assignable_v> << " (false = 不可,所以 flat_map 不存它)\n"; + return 0; +} +``` + +实测输出(GCC 16 / Clang 22):`WithNUA=4`,`WithoutNUA=8`,`pair 可 move-assign? false`。两个数字 + 一个 false,flat_map 那两处设计的来由基本就落到地上了。 + +前置知识到这儿凑齐了(pre-00 导论 + pre-01..05 五块零件),下一站该进实战,把 flat_tree 的核心骨架搭起来。 + +## 参考资源 + +- [cppreference: [[no_unique_address]]](https://en.cppreference.com/w/cpp/language/attributes/no_unique_address) +- [cppreference: Empty Base Optimization](https://en.cppreference.com/w/cpp/language/ebo) +- [Chromium `base/containers/flat_tree.h` —— NO_UNIQUE_ADDRESS 与成员声明注释](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [crbug.com/1156268 —— GCC 成员声明顺序 ICE](https://crbug.com/1156268) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/01-flat-map-design.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/01-flat-map-design.md new file mode 100644 index 000000000..5f3118d92 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/01-flat-map-design.md @@ -0,0 +1,90 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 面向有模板与性能经验的读者,快速走读 flat_map 的设计动机、接口与 flat_tree 适配器架构 + ——本系列 full/ 的精炼设计指南版 +difficulty: advanced +order: 1 +platform: host +prerequisites: +- 移动语义与完美转发 +- C++20 concepts 与 ranges +- flat_map 前置知识(零):有序关联容器与 std::map 的红黑树 +reading_time_minutes: 6 +related: +- flat_map 设计指南(二):逐步实现 +- flat_map 设计指南(三):测试策略与性能对比 +tags: +- host +- cpp-modern +- advanced +- 容器 +- map +- 优化 +title: "flat_map 设计指南(一):动机、接口与 flat_tree 架构" +--- +# flat_map 设计指南(一):动机、接口与 flat_tree 架构 + +> hands-on 轨,默认您已熟 vector 扩容、复杂度分析、C++20 concepts;不熟的话先过一遍 [full/ 前置知识](../full/pre-00-flat-map-ordered-assoc-container-intro.md)。 + +笔者前阵子翻 `std::map` 的查找路径,翻着翻着血压就上来了:每节点 32B 元数据,每插一个键一次 malloc,查找时指针一路 `node = node->left_` 追下去,每步都是数据相关的解引用,CPU 想预取都够不着,cache miss 排着队来。Chromium 在 `//base/containers` 里另起了一套叫 `flat_map`,把红黑树换成有序 vector + 二分——查多写少的主场,就这么直白。咱们这一篇先把动机、接口和 flat_tree 适配器架构想明白,实现和测试留给后两篇,不碰代码细节。 + +## 问题:std::map 卡在哪 + +flat_map 跟 std::map 的查找都是 `O(log n)`,渐近一模一样,教科书上写不出差别。差别全在常数因子。红黑树节点散落在堆上各过各的,查找每一步 `node = node->left_` 都是一次数据相关解引用,CPU 没法预取,大概率就是一次 cache miss。给您一个直观的数:100 万个 `map`,std::map 光节点元数据就吃掉大概 32MB,外加 100 万次 malloc;flat_map 这边几乎零额外开销,数据老老实实排成一条。渐近相同,常数因子差一个数量级——这就是 flat_map 存在的全部理由。 + +## 接口长什么样 + +门面其实朴素得让人意外: + +```cpp +template , // 透明默认 + class Container = std::vector>> // 非 const Key +class flat_map : public flat_tree; +``` + +template 参数里藏了几手笔者觉得挺巧的取舍,您一眼扫过去可能注意不到,笔者给您点出来: + +| 决策 | 选择 | 理由 | +|---|---|---| +| 默认比较器 | `std::less<>`(透明) | 异构查找,`find("abc")` 不构造临时 string | +| 存储 | `pair` 非 const | vector 要 shift/赋值,`pair` 不可 move-assign | +| `at()` 越界 | CHECK 崩溃(非 throw) | Chromium 风格,逻辑错误立即爆 | +| `sorted_unique` 构造 | tag dispatch 跳过 sort | 数据已有序时 O(N),零成本 | +| `extract`/`replace` | 右值限定批量重建 | 避开单元素 O(n) shift + 迭代器失效 | + +挑两条说一下。`Compare` 默认是 `std::less<>` 而不是 `std::less`,这一手叫透明比较——您拿字符串键的 map 去查 `find("abc")`,不用临时构造一个 `std::string` 出来,省一次堆分配。还有 `pair` 里 Key 不带 const,这事儿看着反直觉:既然 key 不该改,为什么不 const?因为 vector 插入删除要 shift、要 move-assign,`pair` 根本没法 move-assign,整个 vector 就废了。 + +`at()` 越界直接 CHECK 崩,不抛异常——这是 Chromium 的风格,逻辑错误就别撑着,当场爆给您看。还有一个笔者刚开始找半天的东西:批量重建怎么走?标准库的 `extract` 在 flat_map 这里是右值限定的 `extract()&&`,配合 `replace(container_type&&)`,一次性把底层容器换掉,避开单元素 O(n) shift 和迭代器失效那一坨麻烦。 + +## flat_tree:一份代码当两个容器用 + +flat_map 的实现藏着个挺优雅的分层,笔者第一次读到时愣了一下——核心其实就一个类 `flat_tree`,它是个通用的"有序数组关联容器",真正的 map 和 set 都只是它的薄壳。`flat_map` 是 `flat_tree` 的子类,`GetFirst` 这个策略对象从 `pair` 里把 first 抠出来当 key(flat_map.h:194-195);`flat_set` 更干脆,直接是 `flat_tree` 的别名,`std::identity` 把 value 原样当 key(flat_set.h:159-163)。 + +笔者觉得最漂亮的一手就在这里:一个 typename 提取器——`GetFirst` 还是 `std::identity`——就让同一份 flat_tree 既是 map 又是 set。策略对象这东西教科书讲一大圈,这里就一行代码教完。您把 flat_tree 理解了,flat_map 和 flat_set 之间剩下的差异就只剩那一行提取器;flat_set.h 全文 191 行,核心也就这一行。 + +## 不变量与代价 + +flat_tree 内部那条 `body_` 永远按 `comp_` 严格升序、且没有重复——这是整个机制的地基。维护它的活儿分两段:构造期跑一遍 `sort_and_unique`(stable_sort 排序,unique 去重,erase 截尾),插入期靠 `lower_bound` 找位置再 `emplace`。代价笔者给您列清楚,这样您心里有数: + +| 操作 | 复杂度 | 机制 | +|---|---|---| +| find/contains/lower_bound | `O(log n)` | std::ranges::lower_bound 二分,cache 友好 | +| insert/emplace/erase | `O(n)` | vector shift,无摊还 | +| operator[]/insert_or_assign/try_emplace | `O(n)` | 同 insert | +| range 构造 | `O(N log²N)` / `O(N log N)` | sort_and_unique | +| sorted_unique 构造 | `O(N)` | 跳过 sort,只 DCHECK | + +您看出来了吧,这套代价跟 std::map 是镜像的:查找这边赢了常数因子,插入那边因为 vector shift 赔了 `O(n)`。flat_map 压根不打算当通用 map 用,它把赌注押在"查多写少"上——您手里要是有个配置表、路由表、枚举映射这种构造完基本只读的场景,这笔交易就划算;反过来高频插入删除,老老实实回去用 std::map。它也没忘给"我数据已经有序了"开后门,`sorted_unique` 构造 tag dispatch 跳过排序,只要 DCHECK 验过就 O(N) 进场,零成本。 + +架构和代价到这儿算是理顺了。但纸面讲清楚是一回事,真把它一行行撸出来,有些东西纸上看不出来——`sort_and_unique` 为什么要拆成 stable_sort + unique + erase 三步、`sorted_unique` 的 DCHECK 在 release 下怎么保证不误删数据、`extract()&&` 那一手右值限定到底省在哪。下一篇咱们就把 flat_tree 的核心代码摊开来看。 + +## 参考资源 + +- [Chromium `base/containers/flat_tree.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/flat_map.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [Chromium `base/containers/README.md` —— 容器选择指南](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/README.md) +- [flat_map 前置知识(零):有序关联容器与 std::map 的红黑树](../full/pre-00-flat-map-ordered-assoc-container-intro.md) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/02-flat-map-implementation.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/02-flat-map-implementation.md new file mode 100644 index 000000000..0cdac3f60 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/02-flat-map-implementation.md @@ -0,0 +1,271 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: 逐层实现 flat_tree/flat_map——签名、key 提取器、sort_and_unique、查找插入、 + sorted_unique 构造、flat_map 特有 API,代码密集少废话 +difficulty: advanced +order: 2 +platform: host +prerequisites: +- flat_map 设计指南(一):动机、接口与 flat_tree 架构 +- flat_map 前置知识(四):tag dispatch 与 sorted_unique_t +reading_time_minutes: 13 +related: +- flat_map 设计指南(三):测试策略与性能对比 +tags: +- host +- cpp-modern +- advanced +- 容器 +- map +- 优化 +title: "flat_map 设计指南(二):逐步实现" +--- +# flat_map 设计指南(二):逐步实现 + +上一篇咱们把动机和接口聊透了,这一篇笔者想换个走法:别再纸上谈兵,直接一行行把 `flat_tree` / `flat_map` 给撸出来。咱们会一层一层往上叠——从最底下的签名和数据成员开始,一路堆到 `flat_map` 自己那几个特有 API。代码密集,解释只点到为止,细节论证您去看 [full/03-2~03-4](../full/03-2-flat-map-flattree-skeleton.md);配套工程在 `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/`(`19`~`22`),笔者写的时候全程对着它跑测试。 + +## 第 1 层:把骨架先立起来 + +笔者一开始动手,先把 `flat_tree` 的类签名和那几个数据成员摆好——地基不稳后面全是坑。 + +```cpp +// Platform: host | C++ Standard: C++20 +#pragma once +#include +#include +#include +#include +#include +#include + +namespace tamcpp::chrome::internal { + +template +class flat_tree { +public: + using key_type = Key; + using key_compare = KeyCompare; + using value_type = typename Container::value_type; + using iterator = typename Container::iterator; + using const_iterator = typename Container::const_iterator; + using container_type = Container; + using size_type = typename Container::size_type; + + static constexpr bool is_transparent_comparator = + requires { typename KeyCompare::is_transparent; }; + +protected: + Container body_; + [[no_unique_address]] KeyCompare comp_; // EBO:无状态比较器零字节 + + flat_tree() = default; + explicit flat_tree(const KeyCompare& c) : comp_(c) {} + + // 把 key 比较转成 value 比较(双边提取) + template + bool less(const A& a, const B& b) const { + GetKeyFromValue ext; + return comp_(extract_if_value(ext, a), extract_if_value(ext, b)); + } + template + static const auto& extract_if_value(Ext& ext, const V& v) { + if constexpr (std::is_same_v, value_type>) return ext(v); + else return v; + } +}; + +} // namespace tamcpp::chrome::internal +``` + +`[[no_unique_address]]` 让空比较器(默认 `std::less<>`)零字节,这点笔者第一次见是真觉得优雅——一个空对象就这么白白蒸发了。`extract_if_value` 是异构比较的命门:value 走提取器,裸 key 原样放行,所以您拿一个 `int` 去查 `pair` 的表也不用先包一层。 + +## 第 2 层:构造,以及那个跑不掉的 sort_and_unique + +骨架立起来之后,接下来要把"进来一堆乱序的东西,怎么变成有序去重的表"这件事解决掉。这就是 `sort_and_unique`。 + +```cpp +// 普通 range 构造:append + 排序去重 +template +flat_tree(InputIt first, InputIt last, const KeyCompare& c = KeyCompare()) + : body_(first, last), comp_(c) { + sort_and_unique(); +} +// 容器 move 构造:批量构造(推荐姿势) +flat_tree(Container&& body, const KeyCompare& c = KeyCompare()) + : body_(std::move(body)), comp_(c) { + sort_and_unique(); +} + +void sort_and_unique() { + std::stable_sort(body_.begin(), body_.end(), + [this](const value_type& a, const value_type& b) { return less(a, b); }); + body_.erase(std::unique(body_.begin(), body_.end(), + [this](const value_type& a, const value_type& b) { + return !less(a, b) && !less(b, a); + }), + body_.end()); +} +``` + +`stable_sort` + `unique` + `erase`,O(N log N)。这里有个笔者一开始没在意的点:为什么是 `stable_sort` 而不是 `sort`?因为相等元素的相对顺序,`stable_sort` 给您留着,`sort` 不保证——您要是后面 `replace` 进来的数据依赖这个顺序,`sort` 可能就给您打散了。`unique` 那个 lambda 用的是 `!less(a,b) && !less(b,a)` 判等,意思是"既不小于也不被小于",这正好是等价的定义,比直接 `==` 在异构比较里更稳。 + +## 第 3 层:sorted_unique 构造——给已经排好的数据开个后门 + +事情到这里就有意思了。您要是手头的数据本来就是有序去重的(比如从另一个 `flat_map` 倒出来的),再跑一遍 `sort_and_unique` 纯属浪费。Chromium 给这种场景留了个后门,就是 `sorted_unique` tag。 + +```cpp +struct sorted_unique_t {}; +inline constexpr sorted_unique_t sorted_unique{}; + +template +flat_tree(sorted_unique_t, InputIt first, InputIt last, const KeyCompare& c = KeyCompare()) + : body_(first, last), comp_(c) { + assert(is_sorted_unique()); // debug 校验,不排序 +} + +bool is_sorted_unique() const { + for (size_type i = 1; i < body_.size(); ++i) + if (!less(body_[i - 1], body_[i])) return false; // 必须严格升序 + return true; +} +``` + +标签让重载决议跳过 `sort_and_unique`,只 DCHECK。O(N) 拷贝构造,release 下连那个 `assert` 都没了。笔者第一次看这里的时候心里咯噔了一下——这不是把契约责任甩给您了吗?确实是。您要是拿一堆没排序的数据塞进 `sorted_unique` 构造,debug 下还能抓出来,release 下就 silent corruption 了。这是个诚实契约,您得自己知道自己在干嘛。 + +## 第 4 层:查找,二分的事儿 + +有序数组的查找没什么悬念,`std::lower_bound` 二分,O(log n)。但这里有个细节值得停下来看一眼。 + +```cpp +const_iterator find(const Key& key) const { + auto it = std::lower_bound(body_.begin(), body_.end(), key, + [this](const value_type& v, const Key& k) { return less(v, k); }); + if (it != body_.end() && !less(key, *it)) return it; + return body_.end(); +} +bool contains(const Key& key) const { return find(key) != body_.end(); } +size_type count(const Key& key) const { return contains(key) ? 1 : 0; } +``` + +`std::lower_bound` 二分,O(log n),只传**一个**二元比较器 `(value, key)→bool`——透明比较器时 `key` 可为异构类型,所以您拿 `std::string_view` 查 `std::string` 的表也不用先转。`find` 里那行 `!less(key, *it)` 是判等的小技巧:`lower_bound` 给您的是"第一个不小于 key 的位置",如果这个位置既不小于 key、key 也不小于它,那就是相等,返回它;否则返回 `end()`。 + +这里有个笔者写的时候踩过的小坑要提醒您:Chromium 的 flat_tree 用的是 `std::ranges::lower_bound(*this, key, KeyValueCompare(comp_))`。那个 `KeyValueCompare` 是一个**带两个 `operator()` 重载**的比较器类(`v insert(value_type v) { + auto it = std::lower_bound(body_.begin(), body_.end(), v, + [this](const value_type& a, const value_type& b) { return less(a, b); }); + if (it != body_.end() && !less(v, *it)) return {it, false}; // 已存在,不插 + return {body_.emplace(it, std::move(v)), true}; // O(n) shift,插入成功 +} +``` + +`lower_bound` 找位置 O(log n),`emplace` 在那个位置 shift 后面所有元素 O(n)。这就是 flat_map 插入的代价—— vector 中间插一个,后面全得挪。唯一 key 的语义也在里面了:key 已存在就返回 `{it, false}`,不插。这一步笔者想强调一下:返回的 `bool` 一定要看,笔者自己刚写的时候偷懒没看,后面 debug 了半天才发现是重复插入被静默吞了。 + +## 第 6 层:extract 和 replace——批量重建的两把扳手 + +插入是单元素的细活,但有时候您想一次性倒一大堆进去,或者把整个容器换掉。`extract` 和 `replace` 就是干这个的。 + +```cpp +container_type extract() && { + return std::exchange(body_, container_type{}); // 整体交出 +} +void replace(container_type&& body) { + body_ = std::move(body); + assert(is_sorted_unique()); // sorted_unique 式诚实契约 +} +iterator erase(const_iterator pos) { return body_.erase(pos); } // O(n) +``` + +`extract` 是 `&&` 修饰的——只能用在右值上,意思是"容器把里面的东西掏空,您自己拿去用,它就剩个空壳了"。`replace` 走的是 `sorted_unique` 那条诚实契约路线:它信您传进来的数据是有序去重的,只在 debug 下 `assert` 检一下,release 直接接管。这两把扳手配合起来很有用——比如您想批量更新一个 flat_map,可以先 `extract` 出来,在外面乱序改一通,排序去重后再 `replace` 回去,比一次次 `insert` 快得多。 + +## 第 7 层:flat_map 自己的那几个 API + +前面六层都是 `flat_tree` 的活儿,跟 map 还是 set 无关。真正属于 `flat_map` 的就下面这几个:operator[]、at、insert_or_assign。这也是 `flat_tree` / `flat_map` 分层的意义——核心引擎一套,map 在上面套薄壳。 + +```cpp +namespace tamcpp::chrome { + +struct GetFirst { + template + constexpr const K& operator()(const std::pair& p) const { return p.first; } +}; + +template , + class Container = std::vector>> +class flat_map : public internal::flat_tree { + using base = internal::flat_tree; +public: + using mapped_type = Mapped; + using base::base; // 继承 flat_tree 的构造/查找/插入 + + mapped_type& operator[](const Key& key) { + auto it = std::lower_bound(this->body_.begin(), this->body_.end(), key, + [this](const value_type& v, const Key& k) { return this->less(v, k); }); + if (it == this->body_.end() || this->less(key, *it)) + it = this->body_.emplace(it, std::piecewise_construct, + std::forward_as_tuple(key), + std::forward_as_tuple()); // 默认构造 mapped + return it->second; + } + + mapped_type& at(const Key& key) { + auto it = this->find(key); + assert(it != this->body_.end()); // 教学版用 assert;Chromium 用 CHECK + return it->second; + } + + template + std::pair insert_or_assign(const Key& key, M&& obj) { + auto it = std::lower_bound(this->body_.begin(), this->body_.end(), key, + [this](const value_type& v, const Key& k) { return this->less(v, k); }); + if (it != this->body_.end() && !this->less(key, *it)) { + it->second = std::forward(obj); // 覆写 .second(只改 mapped,不改 key) + return {it, false}; + } + return {this->body_.emplace(it, key, std::forward(obj)), true}; + } +}; + +template , class Container = std::vector> +using flat_set = internal::flat_tree; + +} // namespace tamcpp::chrome +``` + +`operator[]` 缺失就插一个默认构造的 mapped——这就是为什么 flat_map 用 `vector>` 而不是 `vector>`,因为要能默认构造进去,`const Key` 干不了这事儿。`at` 越界走 assert,教学版图省事;Chromium 用的是 CHECK,因为 release 也得崩。`insert_or_assign` 是个有意思的 API——key 在就覆写 `.second`,不在就插,返回的 bool 告诉您到底是插了还是改了。最后 `flat_set` 那一行笔者特别喜欢:`using` 别名 + `std::identity` 提取器,零额外代码,这就是把核心下沉到 `flat_tree` 的红利。 + +## 跑一下,看它动不动 + +写完不跑一下心里没底。咱们来一段最小的——构造、查、改、再来个 set。 + +```cpp +#include +int main() { + using namespace tamcpp::chrome; + flat_map m{{3,"c"},{1,"a"},{2,"b"}}; // 构造即排序 + std::cout << m.size() << "," << m[1] << "\n"; // 3,a + m.insert_or_assign(2, "B"); // 覆写 2 + std::cout << m[2] << "\n"; // B + + flat_set s{{3,1,2,1}}; // 排序去重 + std::cout << s.size() << "\n"; // 3 + return 0; +} +``` + +到这儿七层全撸完了,flat_tree 那一层是真正的实现核心,`flat_map`(子类 + `GetFirst`)和 `flat_set`(别名 + `std::identity`)都是在它上面套薄壳。咱们一路上踩到的那些点——`sort_and_unique` 维护有序不变量、`lower_bound` 二分、`emplace` 那个 O(n) shift、`sorted_unique` 跳过排序、`extract`/`replace` 批量重建、`[[no_unique_address]]` 蒸发空比较器——这些就是 flat_map 全部的内功了。代码本身不算多,但每一处取舍背后都有讲头,这也是笔者写这篇的时候越写越觉得过瘾的地方。下一篇咱们把测试和性能对比补上,看看它跟 `std::map` 真刀真枪比起来到底差多少。 + +## 参考资源 + +- [Chromium `base/containers/flat_tree.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_tree.h) +- [Chromium `base/containers/flat_map.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/flat_map.h) +- [flat_map 设计指南(三):测试策略与性能对比](./03-flat-map-testing.md) +- [flat_map 实战(二):flat_tree 核心骨架](../full/03-2-flat-map-flattree-skeleton.md) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/03-flat-map-testing.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/03-flat-map-testing.md new file mode 100644 index 000000000..aee32df6f --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/hands_on/03-flat-map-testing.md @@ -0,0 +1,127 @@ +--- +chapter: 1 +cpp_standard: +- 17 +- 20 +description: flat_map 的测试策略——围绕不变量设计用例,并实测 per-item 开销、查找/插入性能, + 给出 flat_map vs std::map/absl::btree_map 的选型判据 +difficulty: advanced +order: 3 +platform: host +prerequisites: +- flat_map 设计指南(二):逐步实现 +reading_time_minutes: 6 +related: +- flat_map 设计指南(一):动机、接口与 flat_tree 架构 +- flat_map 实战(六):测试与性能对比 +tags: +- host +- cpp-modern +- advanced +- 容器 +- map +- 测试 +- 优化 +title: "flat_map 设计指南(三):测试策略与性能对比" +--- +# flat_map 设计指南(三):测试策略与性能对比 + +上一篇撸完实现,笔者心里其实没那么踏实——`flat_tree` 那一坨 `lower_bound + emplace + shift` 能编过是一回事,语义对不对是另一回事。容器这种东西最怕“看起来能跑”:您塞几个数进去遍历出来有序,绿了;可重复 key 去重、`insert_or_assign` 的覆写、`sorted_unique` 撒谎、迭代器失效这些坑,全压在边界上。这一篇咱们就把第一篇承诺的六条不变量一条条摁回测试里,再拿真实测量跟 `std::map` 和 `absl::btree_map` 比一比,看 flat_map 到底省在哪、又把代价付在了哪。套路跟 [WeakPtr 设计指南(三)](../../02_weak_ptr/hands_on/03-weak-ptr-testing.md) 一脉相承:不变量驱动,数据说话,不空口。 + +## 不变量 → 测试矩阵 + +| # | 不变量 | 断言 | +|---|---|---| +| 1 | 有序 | 遍历得到严格升序 key | +| 2 | 唯一 | 重复 key 被去重 | +| 3 | 查找语义 | find/contains/operator[]/at 对;at 越界 CHECK/assert | +| 4 | insert_or_assign/try_emplace | 已存在一个覆写一个不动 | +| 5 | sorted_unique | 跳过排序;撒谎 debug abort | +| 6 | 迭代器失效 | mutation 后旧迭代器失效(粗规则) | + +## 关键用例(Catch2 风格示意) + +六条不变量听着抽象,落到测试上其实就挑那些“写错了一定会爆”的边界。笔者这里挑三条最能锁语义的——构造期有序去重、`insert_or_assign` 的覆写、`sorted_unique` 撒谎 debug abort。配套工程在 `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/` 下 `19`~`22` 那几个 demo .cpp,Catch2 测试目标接入留作扩展,这里先看用例长什么样: + +```cpp +TEST_CASE("flat_map sorts+uniques on construction", "[flat_map]") { + flat_map m{{3,30},{1,10},{3,30},{2,20}}; + std::vector keys; + for (auto& [k,v] : m) keys.push_back(k); + REQUIRE(keys == std::vector{1,2,3}); // 不变量 1+2 +} + +TEST_CASE("insert_or_assign overwrites existing", "[flat_map]") { + flat_map m{{1,10}}; + auto [it, ins] = m.insert_or_assign(1, 99); + REQUIRE_FALSE(ins); REQUIRE(it->second == 99); // 不变量 4 +} + +TEST_CASE("sorted_unique aborts on lying input", "[flat_map][.death]") { + // 不变量 5:传未排序数据却宣誓 sorted_unique → debug abort + // flat_map m(sorted_unique, std::vector>{{3,3},{1,1}}); +} +``` + +这三条盯的都是语义边界,不是 API 表面。构造期那条把不变量 1 和 2 一起验——丢进去的 `{3,30}` 重复了一份、顺序也是乱的,遍历出来必须正好是 `1,2,3`,差一个就是 `sort_and_unique` 写错了;`insert_or_assign` 那条更细,笔者特意拿 `ins` 的 false 跟 `it->second==99` 对照,就是怕把“插入了新的”和“覆写了旧的”混成一回事。`sorted_unique` 撒谎那条单独拎出来,因为它会 abort。 + +会 abort 的用例有个麻烦:直接塞进普通 TEST_CASE 里跑,整个二进制都得跟着挂。得隔离成 death test,让它在子进程里崩——这套路数跟 01-6 处理 OnceCallback 单次消费断言、跟 WeakPtr 的 CHECK-on-deref 是同一套,笔者在那两篇已经趟过一遍了。 + +## 性能:per-item 开销 + +```text +sizeof(flat_map) ≈ 24 字节(三指针) +sizeof(std::map) ≈ 48 字节(树根 + sentinel + 比较器) + +100 万元素 map 额外开销(数据 8MB 不计): + flat_map: ~0 额外(数据连续) + std::map: ~32MB(32B/元素 × 100 万 + 100 万次 malloc) +``` + +flat_map 零 per-item 元数据,一次连续分配就齐活;std::map 每元素背着 32B 元数据,还得一次堆分配。这正是第一篇说的“常数因子差一个数量级”的出处——渐近都是 `O(log n)` 查找,但 std::map 在咱们看不到的地方先把 32MB 元数据 + 100 万次 malloc 的账给记上了。 + +## 性能:查找 vs 插入 + +光看 `sizeof` 和分配次数还不过瘾,笔者上机实跑了一把。本机 GCC 16 -O2,配套 `20_lookup_vs_shift_perf`,10 万元素 `map`: + +```text +查找 10 万次(10 万元素): + flat_map: 31 ms + std::map: 34 ms (int key + 10 万:几乎持平) + +插入 1000 次到 10 万元素容器: + flat_map: 2 ms (每次 O(n) shift) + std::map: 0 ms (O(log n) 节点重连) +``` + +这里有个数字笔者第一次看愣了一下——查找上 flat_map 居然没把 std::map 按在地上摩擦,俩几乎持平。后来一琢磨就通了:10 万 + int key 这个量级,数据本身就塞得进 cache,std::map 的指针追逐还没开始大规模 miss,flat_map 的连续红利自然显不出来。真想看出差距,得把 N 再往上推,或者把 key 换成 `std::string` 这种重的。独立大 N 测试里 flat_map 快出几倍是常有的事,但您别拿这当教条,小 N 轻 key 下它就是优势不明显,这很正常。 + +插入这边画风就反过来了,而且没什么悬念。flat_map 每插一个就得 `O(n)` shift 一大片,2 ms 对 std::map 的 0 ms;N 越大这个差距只会越拉越开,是面实打实的墙。所以 flat_map 的契约写得很明白:查多写少的主场,别拿它当高频写的容器使。 + +## 选型判据 + +| 负载 | 推荐 | 理由 | +|---|---|---| +| 写一次读多次(配置表/命令分发) | flat_map | 写入一次性,查找 cache 友好 | +| 始终很小(~4 元素) | flat_map | 常数因子主导,零分配 | +| 大且频繁改 | std::map | O(n) 插入是墙 | +| 需引用/指针稳定 | std::map | flat_map 迭代器全失效 | +| 大 N + 频繁改 + 有序 | absl::btree_map | B-tree 中间解(Chromium 因代码膨胀禁用) | + +这张表其实就是一句话的事:查多写少、或者容器一直很小,选 flat_map;写多、或者要引用指针稳定,选 std::map;又大又频繁改还非要有序,那就是 absl::btree_map 的中间解,但 Chromium 自己因为代码膨胀把这条路堵了。Chromium `//base/containers/README.md` 的划法,也就是把这张表落成了文字。 + +## vs std::flat_map(C++23)/ absl::btree_map + +聊到这儿,绕不开两个亲戚。C++23 的 `std::flat_map`(P0429)跟 Chromium flat_map 同源,思路是一脉的,但标准版选了 split storage——键和值分两个数组存。Chromium 偏不 split,老老实实用单个 `vector>`。笔者理解 Chromium 的取舍:split 在“只遍历 key”或“只遍历 value”时确实更省 cache,但实现复杂度上去了,而 flat_map 的主战场是查多写少的小容器,split 的收益在这儿根本兑现不出来,不划算。 + +另一个是 `absl::btree_map`,256B 的 B-tree 节点里塞一把 key,介于红黑树和 sorted vector 之间,大 N + 频繁改 + 有序的场景它最合适。但 Chromium 在 `//base` 里把 btree 禁用了,理由是代码膨胀——每实例化一种 key/value 类型都得多生成一大坨模板,B-tree 节点的拆装逻辑比 sorted vector 那套 `lower_bound + shift` 重得多。这是工程权衡的典型样本:技术上有更优解,但项目级别的代价吃不消,就宁可不要。 + +到这儿,flat_map 组件的设计、实现、验证三篇就走完了。回头看,它跟 OnceCallback、WeakPtr 凑成了 vol9/chrome 的第三块拼图——前两块讲的是“怎么把回调管住”“怎么把生命周期管住”,这一块讲的是“怎么把数据存得既省又快”,都是 Chromium `//base` 工业级 C++ 的基本功。 + +## 参考资源 + +- [Chromium `base/containers/README.md` —— 容器选择指南](https://source.chromium.org/chromium/chromium/src/+/main:base/containers/README.md) +- [Catch2 文档](https://github.com/catchorg/Catch2/tree/devel/docs) +- [P0429 —— std::flat_map 提案](https://wg21.link/p0429) +- [absl::btree_map](https://abseil.io/docs/cpp/guides/btree) +- [flat_map 设计指南(一):动机、接口与 flat_tree 架构](./01-flat-map-design.md) diff --git a/documents/vol9-open-source-project-learn/chrome/03_flat_map/index.md b/documents/vol9-open-source-project-learn/chrome/03_flat_map/index.md new file mode 100644 index 000000000..f823508c0 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/03_flat_map/index.md @@ -0,0 +1,31 @@ +# flat_map:从 Chromium 学到的有序容器设计 + +本目录拆 Chromium 的 `flat_map` / `flat_tree`,讲透"用有序 vector 实现关联容器"这套工业级设计:为什么小数据量下数组打败树、查多写少的主场、sorted_unique 的零成本构造、透明比较器与 EBO。它是 [OnceCallback](../01_once_callback/) 与 [WeakPtr](../02_weak_ptr/) 的姊妹篇,补 vol9/chrome 的容器与性能这维。 + +## 完整教程(full/) + +前置知识(6 篇): + +- [有序关联容器入门](./full/pre-00-flat-map-ordered-assoc-container-intro.md) +- [vector 内部与扩容](./full/pre-01-flat-map-vector-internals-and-growth.md) +- [复杂度与摊还](./full/pre-02-flat-map-complexity-and-amortized.md) +- [比较器与 transparent](./full/pre-03-flat-map-comparator-and-transparent.md) +- [tag dispatch 与 sorted_unique](./full/pre-04-flat-map-tag-dispatch-and-sorted-unique.md) +- [NUA EBO 与 pair 存储](./full/pre-05-flat-map-enua-ebo-and-pair-storage.md) + +动手实践(6 篇): + +- [动机与 API 设计](./full/03-1-flat-map-motivation-and-api-design.md) +- [flat_tree 骨架](./full/03-2-flat-map-flattree-skeleton.md) +- [查找与插入](./full/03-3-flat-map-lookup-and-insert.md) +- [sorted_unique 构造](./full/03-4-flat-map-sorted-unique-construction.md) +- [迭代器失效与批量构建](./full/03-5-flat-map-iterator-invalidation-and-bulk-build.md) +- [测试与性能](./full/03-6-flat-map-testing-and-perf.md) + +## 进阶设计指南(hands_on/) + +面向有模板与性能经验的读者: + +- [设计](./hands_on/01-flat-map-design.md) +- [实现](./hands_on/02-flat-map-implementation.md) +- [测试与性能](./hands_on/03-flat-map-testing.md) diff --git a/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-1-no-destructor-motivation-and-api.md b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-1-no-destructor-motivation-and-api.md new file mode 100644 index 000000000..06ada06e0 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-1-no-destructor-motivation-and-api.md @@ -0,0 +1,221 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: 从全局配置表的痛点切入(Chromium 禁全局 ctor/dtor),讲清 NoDestructor 要补的洞, + 定下完整目标 API 与接口决策 +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- NoDestructor 前置知识(零):静态存储期、初始化与析构 +- NoDestructor 前置知识(一):placement new 与对齐存储 +reading_time_minutes: 10 +related: +- NoDestructor 实战(二):核心实现 +tags: +- host +- cpp-modern +- intermediate +- 内存管理 +- RAII +title: "NoDestructor 实战(一):动机与接口设计" +--- +# NoDestructor 实战(一):动机与接口设计 + +[前置知识(零)](./pre-00-static-storage-and-init.md) 里咱们把 Chromium 禁全局 ctor/dtor 的账翻了一遍——SIOF、关停竞态、启动延迟。可问题是,`//base` 满地都是"需要一个全局单例"的场景:默认配置表、feature flag 表、按需生成的随机 nonce。规矩立在那儿,活儿还得干,这就轮到 `base::NoDestructor` 上场了。咱们这一篇先把动机和接口掰扯清楚,实现留给下一篇。 + +## 从一张全局配置表说起 + +假设咱们手头有一张"默认配置表",程序各处都要用,内容固定: + +```cpp +const std::map& DefaultConfig() { + // 怎么实现这个全局? +} +``` + +最直觉的写法是甩一个全局变量出去: + +```cpp +const std::map g_default = LoadDefault(); // ❌ Chromium 禁 +``` + +读起来很顺,可它在 `main` 之前就要生成全局构造器(调 `LoadDefault` + 构造 `std::map`),程序退出时还得跑全局析构器去拆 map。Chromium 挂着的 `-Wglobal-constructors` / `-Wexit-time-destructors` 两个开关会直接把它拒了。那还能怎么改? + +--- + +## 三条现成的路,为什么都不够 + +**裸函数局部静态** —— 这是 Scott Meyers 那本书里推荐的写法,社区俗称 Meyers singleton: + +```cpp +const std::map<...>& DefaultConfig() { + static const std::map g = LoadDefault(); // magic statics:线程安全构造 + return g; +} +``` + +它确实绕开了全局构造器——首次调 `DefaultConfig()` 才构造,magic statics 把线程安全也顺带管了(见 [pre-00](./pre-00-static-storage-and-init.md))。可笔者第一次用的时候没注意到一个尾巴:`g` 退出时还是会析构。`std::map` 的析构器照样会被登记成全局析构。换句话说,构造这关过了,析构这关没过。 + +更扎眼的是关停竞态。假设 `g` 里揣着别的全局对象的引用(比如某个 logger 指针),或者反过来——别的全局对象在析构的当口又回头调了 `DefaultConfig()`。这会儿 `g` 说不定已经析构过了,您手里捏的是一具无效引用,程序直接撞上未定义行为。Chromium 的关停路径本来就绕,这种竞态笔者在生产里见过不止一次,是真痛。 + +**手写 placement new + 跳过析构** —— 自己撸 `alignas(T) char buf[...]`,placement new 把对象摁上去,析构索性不调。这路子能跑,但笔者图省事写了一版之后回头一看,坑比想的密:LSan 兼容(见 [04-4])、`static_assert` 把关、对齐、生命周期,全得自己兜。写两遍就开始重复造轮子了。 + +三条路摆这儿——裸全局被禁、Meyers singleton 有析构竞态、手写 placement new 重复易错。NoDestructor 就是 Chromium 把第二种路子里"那颗析构的钉子"拔掉、再把第三种路子的样板代码封装好的官方工具。 + +--- + +## Chromium 的回答:NoDestructor + +NoDestructor 的设计思想笔者觉得可以收成两句话。 + +第一,不析构。对象一旦构造出来,`~T()` 就再也不会被调用——没有析构,就没有析构顺序,关停竞态从根上没了。代价是"故意泄漏",靠 OS 在进程退出时一把回收内存。在浏览器这种长跑进程里,这点泄漏根本不算账;在嵌入式或者短命工具里您得自己掂量。 + +第二,和 magic statics 配着用。NoDestructor 通常包在函数局部静态里,靠 C++11 magic statics 把首次构造的线程安全兜住。 + +两句话合起来就是:`static const NoDestructor x(args...);` 一行,您拿到一个线程安全构造、永不析构的全局单例。全局 ctor 这关靠局部静态延迟构造绕过去了,全局 dtor 这关靠 NoDestructor 不析构绕过去了。这正是 Chromium 风格指南认可的写法。 + +### 用法示例 + +```cpp +#include "base/no_destructor.h" + +const std::string& GetDefaultText() { + static const base::NoDestructor s("Hello world!"); + return *s; +} +``` + +`*s` 走的是 `operator*`,返回 `std::string&`。`s` 是 NoDestructor,首调时构造 string(线程安全),程序退出的时候不析构,内存交给 OS 回收。 + +初始化要是复杂点,塞个 lambda 进去跑 IIFE 也很顺手,比如要按需生成一段随机 nonce: + +```cpp +const std::string& GetRandomNonce() { + static const base::NoDestructor nonce([] { + std::string s(16); + FillRandom(s.data(), s.size()); + return s; + }()); + return *nonce; +} +``` + +--- + +## 目标 API 长什么样 + +笔者先把目标 API 摆出来,跟 Chromium 对齐: + +```cpp +namespace tamcpp::chrome { + +template +class NoDestructor { +public: + // 从任意参数构造(转发给 T 的构造函数) + template + explicit NoDestructor(Args&&... args); + + // 从 T 直接拷贝/移动构造(方便 initializer_list 等) + explicit NoDestructor(const T& x); + explicit NoDestructor(T&& x); + + // 不可拷贝 + NoDestructor(const NoDestructor&) = delete; + NoDestructor& operator=(const NoDestructor&) = delete; + + // 析构:默认(关键!不调 ~T()) + ~NoDestructor() = default; + + // 像 T 一样用 + const T& operator*() const; + T& operator*(); + const T* operator->() const; + T* operator->(); + const T* get() const; + T* get(); +}; + +} // namespace tamcpp::chrome +``` + +用法很直接:拿它当函数局部静态,`*nd` 或者 `nd->` 当 T 用就行。 + +--- + +## 几处签名决策 + +签名看着简单,可每一处都是 Chromium 实打实踩过坑之后定下来的。笔者挑几处值得说的拆一下。 + +**为什么把拷贝删掉。** NoDestructor 持的是一段内联缓冲 `alignas(T) char storage_[sizeof(T)]`,不是指针。允许拷贝的话,得对 storage_ 里的 T 做深拷贝(placement new 一份新的),语义一下子就乱了——T 未必 trivially copyable,浅拷贝是搬字节、深拷贝要走构造,到底哪种?与其让用户踩这个坑,不如直接 `delete` 掉拷贝,把它收在"静态变量容器"这个定位上,别当值类型传来传去。 + +**为什么不直接继承 T,也不公开 T&。** 继承会把 NoDestructor 拽成 T 的 is-a 关系,而它本质上是个容器,语义不对;再说 T 可能是 `final`,继承根本走不通。直接公开内部 T& 成员也不行,那等于把 storage_ 的细节漏出去了。Chromium 选的是智能指针那一套——`operator*` / `operator->` / `get()`,让 NoDestructor 行为上像"指向 T 的指针"。这么一来,`static const NoDestructor s(...)` 用起来跟 `std::string*` 几乎一样自然。 + +**`~NoDestructor() = default`,这一处是命门。** 这一行看着平平无奇,却是整套设计的根。`= default` 让编译器生成的析构函数去析构它的成员 `storage_`,而 `storage_` 是 char 数组,平凡析构,什么都不做。所以 `~NoDestructor()` 跑完,`~T()` 根本不会被调——T 是 placement new 摁上去的,析构得手动调,可这里偏不调。笔者第一次读到这儿愣了一下:那为啥不直接 `= delete`?后来想通了,`= delete` 会拦住整个对象的生命周期管理,作为成员或者基类时根本用不了;`= default` 才能让 NoDestructor 自己正常生死,只是让里头的 T 装聋作哑。 + +要是不嫌麻烦,改成 `~NoDestructor() { reinterpret_cast(storage_)->~T(); }`,那就跟 Meyers singleton 没两样了,关停竞态原封不动地回来,整个工具白做。所以"不调 `~T()`"不是疏忽,是刻意的核心选择。 + +**为什么不用 `[[clang::no_destroy]]` 属性。** Clang 其实有这么个属性,标在变量上就让它的析构不跑: + +```cpp +[[clang::no_destroy]] static const std::string s = "..."; // 不析构 +``` + +笔者一开始也嘀咕,既然属性能干这活,何必再裹一层类?后来扒了 Chromium 的注释才明白账得这么算。属性是 Clang 专属,GCC 和 MSVC 上根本搬不动,可移植性这一关就过不去。它也只管析构这一件事,加不了 `static_assert` 把关,拦不住对平凡类型的误用。更要命的是 LSan 兼容那一套 hack(见 [04-4])、还有类型安全的 API 门面,这些非得封装成类才能塞进去。属性更底层、更轻,但工业代码用封装好的工具更稳,出了问题也好排查。 + +--- + +## 教学版和 Chromium 的取舍 + +跟前几个系列一样,教学版咱们只保核心机制——placement new + 不析构 + magic statics 配合 + `static_assert` 把关,外围那些工业级的零碎先扒掉: + +| 维度 | Chromium | 教学版 | +|---|---|---| +| 存储与 placement new | 完整 | 同 | +| `~NoDestructor()=default` 跳过析构 | 完整 | 同 | +| static_assert 把关 | 2 条(平凡 ctor+dtor / 平凡 dtor) | 同 | +| LSan reachability hack | `#ifdef LEAK_SANITIZER` 持 storage_ptr_ | 省略或注明(见 04-4) | +| Chromium 宏 `BASE_EXPORT` | 有 | 省略 | + +核心机制一字不差。LSan 兼容这一块涉及 sanitizers 的黑魔法,单独拎出来到 04-4 那篇讲,免得这一篇主线上分心。 + +--- + +## 环境先搭一下 + +NoDestructor 本身用 C++17(`alignas` / `std::forward`)就够,部分 `static_assert` 用 C++20 的 `_v` 变量模板写起来更利落,但 C++17 也能写。咱们用 C++20,跟前几个系列对齐。 + +### 编译器要求 + +GCC 11+ 或 Clang 12+,`-std=c++20`。 + +### 验证代码 + +```cpp +#include +#include + +// 验证 alignas + placement new 可用 +struct Foo { int x; }; +alignas(Foo) char buf[sizeof(Foo)]; + +int main() { + new (buf) Foo{42}; // placement new 在 buf 上构造 + return reinterpret_cast(buf)->x - 42; // 0,验证访问正确 +} +``` + +这段能跑过,环境就算齐活了。配套工程在 `code/volumn_codes/vol9/full_tutorial_codes/chrome_design/`,咱们从 04-2 开始往里加 `23`~`25` 这一批 NoDestructor 示例。 + +--- + +## 参考资源 + +- [Chromium `base/no_destructor.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) +- [Clang `[[clang::no_destroy]]` 属性](https://clang.llvm.org/docs/AttributeReference.html#no-destroy) +- [isocpp FAQ —— Meyers singleton 与关停问题](https://isocpp.org/wiki/faq/ctors#construct-on-first-use) +- [NoDestructor 前置知识(零):静态存储期、初始化与析构](./pre-00-static-storage-and-init.md) diff --git a/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-2-no-destructor-core-impl.md b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-2-no-destructor-core-impl.md new file mode 100644 index 000000000..e820ef72f --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-2-no-destructor-core-impl.md @@ -0,0 +1,226 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: 实现 NoDestructor 核心——alignas storage_ + placement new 构造 + reinterpret_cast 访问 + + =default 析构跳过 ~T() + static_assert 把关 +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- NoDestructor 实战(一):动机与接口设计 +- NoDestructor 前置知识(一):placement new 与对齐存储 +reading_time_minutes: 10 +related: +- NoDestructor 实战(三):何时用、何时不用 +tags: +- host +- cpp-modern +- intermediate +- 内存管理 +- RAII +title: "NoDestructor 实战(二):核心实现" +--- +# NoDestructor 实战(二):核心实现 + +上一篇咱们把 NoDestructor 的目标 API 敲定了。接下来动手撸实现——笔者这里直接说一句,核心就 [前置知识(一)](./pre-01-placement-new-and-aligned-storage.md) 里那两个老伙计:placement new 加对齐存储。再叠一层刻意的"不析构"策略,外加两条 static_assert 把关,就齐了。整个类拢共不到 50 行,可每一行笔者都得跟您掰扯清楚它的理由——这玩意儿看着短,踩坑点一个不少。 + +## storage_:一段对齐好的内联缓冲 + +```cpp +// Platform: host | C++ Standard: C++20 +#pragma once +#include +#include +#include + +namespace tamcpp::chrome { + +template +class NoDestructor { + // 详细见下 +private: + alignas(T) char storage_[sizeof(T)]; +}; + +} // namespace tamcpp::chrome +``` + +整个类的状态就这么一行:`alignas(T) char storage_[sizeof(T)]`(no_destructor.h:122)。笔者第一次读的时候愣了一下——就这么个 char 数组,没了?还真就没了。`sizeof(T)` 个字节,容量刚好装一个 T;前面挂个 `alignas(T)`,是把数组的对齐拉到 T 那一档,否则 placement new 拿到的地址可能没对齐,直接撞向未定义行为。私有,外部谁都碰不到原始存储,只能乖乖走下面那几个 `get()`、`operator*`。 + +说穿了,NoDestructor 全部"数据"就是这一段对齐好的内联缓冲。没指针、没堆分配、没额外开销。后面那些花活,都建在这块地基上。 + +## 构造:placement new 加完美转发 + +```cpp +public: + // 通用:从任意参数完美转发给 T 的构造函数 + template + explicit NoDestructor(Args&&... args) { + new (storage_) T(std::forward(args)...); + } + + // 从 T 直接拷贝/移动构造(方便 initializer_list 等场景) + explicit NoDestructor(const T& x) { new (storage_) T(x); } + explicit NoDestructor(T&& x) { new (storage_) T(std::move(x)); } +``` + +通用构造这块笔者不多解释,`template` 加 `std::forward(args)...`,标准完美转发,把参数原封不动甩给 T 的构造函数。`new (storage_) T(...)` 是 placement new,在 `storage_` 那块内存上原地构造 T——不分配内存,内存本身就是 `storage_`。 + +笔者真正想多嘴一句的是下面那两个看似多余的 `const T&` / `T&&` 重载。您可能跟笔者第一次一样纳闷:通用模板不是已经包打天下了吗,干嘛还单写这俩?坑就在 initializer_list 上。有些初始化场景得从一个现成的 T 构造,通用模板的完美转发这时候可能匹配不上 T 的拷贝/移动构造,或者干脆产生歧义。Chromium 干脆显式把这俩重载摆出来,就是为了保 `NoDestructor> v({1,2,3})` 这类写法能正确走到 vector 的 initializer_list 构造。少这一手,真到用时编译器报错能让人翻半天。 + +## 访问:reinterpret_cast 的合法性 + +```cpp + const T& operator*() const { return *get(); } + T& operator*() { return *get(); } + const T* operator->() const { return get(); } + T* operator->() { return get(); } + + const T* get() const { return reinterpret_cast(storage_); } + T* get() { return reinterpret_cast(storage_); } +``` + +`get()` 里头那一手 `reinterpret_cast(storage_)`,笔者第一次看的时候心里咯噔一下——把 char 数组地址硬转成 `T*`,这玩意儿能合法?能(no_destructor.h:118-119)。前提是 placement new 已经在这块内存上把 T 真正构造出来了:那之后这块内存就被当作 T 对象在用,指向它的指针转 `T*` 是安全的。换句话说,合法性靠"先构造、再访问"的顺序撑着——您要是没 new 就 cast,那就是另一回事了。 + +`operator*` 和 `operator->` 全部转发到 `get()`,智能指针风格。const 版本吐 `const T*`/`const T&`,非 const 版本吐可变引用,跟 `std::unique_ptr` 的接口对齐。用法上,`NoDestructor` 行为基本就是个 `T*`: + +```cpp +static const NoDestructor s("hi"); +s->size(); // operator-> +(*s)[0]; // operator* +s.get(); // 显式取指针 +``` + +## 不析构:=default 这个动作藏着玄机 + +```cpp + NoDestructor(const NoDestructor&) = delete; + NoDestructor& operator=(const NoDestructor&) = delete; + + ~NoDestructor() = default; // ← 关键:不调 ~T()! +``` + +拷贝删除,理由 [04-1](./04-1-no-destructor-motivation-and-api.md) 里讲过,这里不重复。真正承重的是底下那行 `~NoDestructor() = default`——整个设计的命脉就压在这儿。 + +笔者头一次读这行是懵的:`= default` 不就是"让编译器生成默认析构"吗,这怎么能跳过 `~T()`?后来把它跟成员表对上才反应过来。编译器生成的默认析构,析构的是**成员**;而 NoDestructor 唯一的成员是 `char storage_[sizeof(T)]`。char 数组的析构是平凡的,什么都不做。编译器更不会自作主张把 `storage_` 当 T 来析构——T 是 placement new 后期才“长”在 `storage_` 上的,跟 `storage_` 在类型系统里的“身份”八竿子打不着。`~NoDestructor()` 跑完,`~T()` 一次都没被调用过;T 就这么在内存里"活着",一直活到程序退场,被操作系统当普通进程内存一把回收掉。 + +这就是"不析构"的实现根因,说穿了就一句话:让 NoDestructor 的析构只看到 char 成员,看不到 T。 + +--- + +## static_assert 把关:用类型系统拦住误用 + +```cpp +private: + static_assert(!(std::is_trivially_constructible_v && + std::is_trivially_destructible_v), + "T 平凡可构造且平凡析构:请直接用 constinit T,不需要 NoDestructor"); + + static_assert(!std::is_trivially_destructible_v, + "T 平凡析构:请直接用函数局部静态 T,不需要 NoDestructor"); + + alignas(T) char storage_[sizeof(T)]; +}; +``` + +这两条 static_assert(no_destructor.h:85-93),笔者觉得是整个设计里最体贴的一手——它把 NoDestructor 卡死在"该用它的场景"上:**只服务非平凡析构的 T**。咱们一种一种看。 + +头一种,T 平凡可构造又平凡析构——典型是 `int`、POD struct 这类。这种您直接 `static constexpr T x = ...;` 或者 `constinit T x;` 就完事了,根本用不着 NoDestructor。硬要套上去,第一条断言当场把您拦下来。 + +再一种,T 平凡析构但非平凡可构造,比如某些带非平凡构造的类。这种也不归 NoDestructor 管——函数局部静态 `static T x;` 就够了,反正析构是平凡的,不会产生全局析构器。这种情况下用 NoDestructor,第二条断言拦。 + +最后才是 NoDestructor 真正该出手的情形:T 非平凡析构,像 `std::string`、`std::vector`、`std::map` 这种。两条断言都不触发,它才是个恰当工具。 + +笔者特别欣赏这套设计的地方:它不光拦,还顺手在报错信息里告诉您该怎么改。读者哪天手滑写个 `NoDestructor`,编译期就吃一记明确提示,而不是留个隐患在生产里跑半年才撞见。 + +--- + +## 完整实现:五层合起来跑一遍 + +把上面五层拼到一起,完整就是这么一份头文件: + +```cpp +// no_destructor.hpp +#pragma once +#include +#include +#include + +namespace tamcpp::chrome { + +template +class NoDestructor { +public: + template + explicit NoDestructor(Args&&... args) { + new (storage_) T(std::forward(args)...); + } + explicit NoDestructor(const T& x) { new (storage_) T(x); } + explicit NoDestructor(T&& x) { new (storage_) T(std::move(x)); } + + NoDestructor(const NoDestructor&) = delete; + NoDestructor& operator=(const NoDestructor&) = delete; + ~NoDestructor() = default; + + const T& operator*() const { return *get(); } + T& operator*() { return *get(); } + const T* operator->() const { return get(); } + T* operator->() { return get(); } + const T* get() const { return reinterpret_cast(storage_); } + T* get() { return reinterpret_cast(storage_); } + +private: + static_assert(!(std::is_trivially_constructible_v && + std::is_trivially_destructible_v), + "T trivially ctble+dtble: use constinit T directly"); + static_assert(!std::is_trivially_destructible_v, + "T trivially destructible: use plain function-local static T"); + + alignas(T) char storage_[sizeof(T)]; +}; + +} // namespace tamcpp::chrome +``` + +笔者这里直接给一段验证代码,咱们上机看看效果: + +```cpp +#include +#include +#include "no_destructor.hpp" + +struct Noisy { + Noisy(int x) : v(x) { std::puts("Noisy()"); } + ~Noisy() { std::puts("~Noisy()"); } // 永不打印 + int v; +}; + +const std::string& DefaultName() { + static const tamcpp::chrome::NoDestructor s("chromium"); + return *s; +} + +int main() { + std::cout << DefaultName() << "\n"; // chromium + static const tamcpp::chrome::NoDestructor n(42); + std::cout << n->v << "\n"; // 42 + // 程序退出:~NoDestructor 跑(平凡),~string 和 ~Noisy 都不跑 + return 0; +} +``` + +您会看到终端里打出 `chromium` 和 `42`,但 **`~Noisy()` 那行从头到尾不打印**——不析构生效了。 + +到这里 NoDestructor 的实现就算撸通了。拢共五层,笔者再带您过一遍:`storage_` 那段对齐缓冲;placement new 加完美转发的构造;`reinterpret_cast` 的访问(`get`/`operator*`/`->`);`= default` 这一手跳过 `~T()`、只析构 char 成员的析构策略;最后两条 static_assert 把关,把工具卡在非平凡析构的 T 上。整个类不到 50 行,每一行笔者都跟您掰扯过它的理由。 + +实现归实现,真正容易翻车的是另一种事:什么时候该用、什么时候绝对别用。这正是 NoDestructor 用错的富矿,下一篇咱们就专门拆它。 + +## 参考资源 + +- [Chromium `base/no_destructor.h` 完整源码](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) +- [cppreference: placement new](https://en.cppreference.com/w/cpp/language/new#Placement_new) +- [cppreference: is_trivially_destructible](https://en.cppreference.com/w/cpp/types/is_destructible) +- [NoDestructor 实战(一):动机与接口设计](./04-1-no-destructor-motivation-and-api.md) diff --git a/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-3-no-destructor-when-to-use.md b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-3-no-destructor-when-to-use.md new file mode 100644 index 000000000..760926106 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-3-no-destructor-when-to-use.md @@ -0,0 +1,155 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: 讲清 NoDestructor 的使用边界——该用(函数局部静态+非平凡析构 T)、不该用(局部变量/成员、 + 平凡析构、平凡可构造、很少用)、constinit 全局与 magic statics 的取舍,附决策表 +difficulty: intermediate +order: 3 +platform: host +prerequisites: +- NoDestructor 实战(二):核心实现 +- NoDestructor 前置知识(零):静态存储期、初始化与析构 +reading_time_minutes: 9 +related: +- NoDestructor 实战(四):LSan 泄漏权衡与 reachability hack +tags: +- host +- cpp-modern +- intermediate +- 内存管理 +- RAII +title: "NoDestructor 实战(三):何时用、何时不用" +--- +# NoDestructor 实战(三):何时用、何时不用 + +NoDestructor 是把锋利的刀,用对地方省心,用错地方埋雷——真泄漏、白占内存、把 bug 藏起来都干得出来。Chromium 自己在源码注释里用整整一节 "Caveats"(no_destructor.h:15-46)列了哪些坑不能踩。笔者把这一节翻来覆去读了三遍,发现它的边界其实就一句话:**唯一推荐姿势是函数局部静态,且 T 非平凡析构**。这篇咱们就把这条线内外的事情掰开讲。 + +## 该用的场景:函数局部静态 + 非平凡析构 T + +标准用法长这样,笔者建议您把它当模板背下来: + +```cpp +const T& GetGlobal() { + static const base::NoDestructor x(args...); // ✓ 函数局部静态 + return *x; +} +``` + +三个条件同时成立才轮得到它出场。第一,T 得是非平凡析构的,像 `std::string`、`std::vector`、`std::map` 这类,直接做函数局部静态会产生全局析构器,正中 NoDestructor 要消掉的靶心。这里有个笔者踩过的反例坑:`std::mutex` 看着像非平凡,可它在 libstdc++/libc++ 实现里其实是平凡析构的,根本不需要 NoDestructor,笔者第一次手滑给它套了一层,review 时被指出来才反应过来。第二,得包在函数局部静态里,靠 magic statics 保证首次构造线程安全,这事儿后面单独说。第三,这对象得是整个程序生命周期都要用的,临时凑一下的不算。三个条件缺一个,NoDestructor 就从省心变成多余甚至有害。 + +## 哪些地方别动它 + +NoDestructor 误用的场景,Chromium 的注释一条条点过来了。笔者按"坑的隐蔽程度"重新排了个序,从最显眼到最容易看漏,咱们挨个过。 + +**最显眼也最致命的:拿它装局部变量或成员变量。** 您一看这写法就该本能地皱眉: + +```cpp +void f() { + base::NoDestructor s("temp"); // ❌ 真泄漏! + // f 返回后,s 不析构,string 的堆分配永远不释放(直到程序退出) +} +``` + +NoDestructor 的全部意义就是"不析构",这前提是对象本来就该活到程序结束。您把它套在局部变量或成员上,对象本来该早早销毁,可析构被 NoDestructor 摁住永远不跑,内存就真的漏了。这不是"程序退出统一回收"那种无害泄漏,是"该回收的时候不回收"的真泄漏。源码注释(no_destructor.h:18-20)把这事儿说得很硬:**Must not be used for locals or fields**。 + +**再往下:平凡析构的 T。** 这种情况 NoDestructor 是多余的,您压根不需要它: + +```cpp +static const base::NoDestructor x(42); // ❌ int 平凡析构,不需要 NoDestructor +``` + +T 平凡析构(像 `int`、`double`、POD struct),它的函数局部静态本来就不产生全局析构器,直接裸静态就行。NoDestructor 的 static_assert 会拒绝您(第二条断言),但比编译报错更要紧的是:您一开始就没认清这玩意儿在这儿没活干。直接写干净: + +```cpp +static const int x = 42; // ✓ 平凡析构,无全局析构器 +``` + +**更隐蔽的一类:T 既平凡可构造又平凡析构。** 这种连函数局部静态都嫌重,该上 `constinit`: + +```cpp +static const base::NoDestructor seed(GetRand()); // ❌ +``` + +`uint64_t` 这种能直接用 `constinit` 做全局常量初始化的,套 NoDestructor 纯属给自己加戏。源码注释(no_destructor.h:33-44)给的正面例子是这样的: + +```cpp +const uint64_t GetUnstableSessionSeed() { + static const uint64_t kSessionSeed = base::RandUint64(); // ✓ 平凡析构,无需 NoDestructor + return kSessionSeed; +} +// 或更好:constinit uint64_t g_seed = constexpr_value; +``` + +**最容易看漏的:很少用到的数据,别拿 NoDestructor 缓存。** 这一条坑在它不会报错、不会泄漏,只是悄悄吃内存: + +```cpp +const BigTable& GetRareTable() { + static const base::NoDestructor t(BuildBigTable()); // ⚠ 慎用 + return *t; +} +``` + +这张表要是整个程序生命周期只用一两次,拿 NoDestructor 缓存它就是浪费内存——编译器在 bss 段给 `BigTable` 预留了空间,程序运行期间一直占着,哪怕您只用一次。源码注释(no_destructor.h:28-31)的建议很直接:rarely used data 按需创建,别缓存。改成这样,用完即析构,不长期占内存: + +```cpp +// 按需创建(用完即析构,不长期占内存) +BigTable GetRareTable() { return BuildBigTable(); } // 返回值,临时用 +``` + +--- + +## constinit 全局这条路,走不通 + +NoDestructor 的标准用法是函数局部静态。那能不能做成全局?源码注释(no_destructor.h:22-26)提到一笔"constinit 可构造的 T 可作全局,但要标 constinit"——笔者一开始真去试,结果撞墙了,**实测编不过**: + +```cpp +constinit const base::NoDestructor g_data(args...); // ⚠ 编译失败 +``` + +原因藏在构造函数里:NoDestructor 内部要调 placement new(`new (storage_) T(...)`),而 placement new 压根不是 `constexpr`,于是 NoDestructor 的构造函数被显式标成非 constexpr(真实头 no_destructor.h:95 注释白纸黑字写着 "Not constexpr")。`constinit` 要求初始化器是常量表达式,这一条过不去,编译器甩给您一句 `constinit variable does not have a constant initializer`。笔者还顺手翻了 Chromium 的 unittest,**里头没有任何一处 constinit 全局 NoDestructor**,清一色函数局部静态。 + +所以这一节真正要记住的就两句话:constinit 可构造的 T,直接用 `constinit T g(...)`,别拿 NoDestructor 包它,常量初始化在编译期就办妥了,运行时一片干净;非 constinit 可构造、但非平凡析构的 T,才是函数局部静态 `NoDestructor` 的唯一实用舞台。源码注释里那条"标 constinit 做全局",更像是理想或者历史遗留,当前实现(placement new 构造)压根撑不起来。 + +--- + +## magic statics 复盘:线程安全靠它,不靠 NoDestructor + +有件事得点破:NoDestructor 自己**不加锁**,它一个字都没提线程安全。它能在并发下安然无恙,全靠 C++11 的 magic statics,也就是函数局部静态变量的初始化,标准给您兜了并发安全这一层(细节见 [pre-00](./pre-00-static-storage-and-init.md))。 + +```cpp +const T& GetGlobal() { + static const base::NoDestructor x(args...); // magic statics 保 x 只构造一次,线程安全 + return *x; +} +``` + +这里有个笔者想强调的因果:一旦您脱离函数局部静态,比如把 NoDestructor 挂成成员、或者做非 constinit 全局,magic statics 的保护就跟着没了,多线程并发去碰一个还没初始化的状态,竞态就冒出来了。所以"包在函数局部静态里"这件事同时干了两活:一是绕开全局构造器,二是把线程安全的担子转交给 magic statics。这两件事叠在一起,才把 NoDestructor 的标准用法限定在函数局部静态这一种姿势上。 + +--- + +## 决策表 + +把上面散在各处的判断收拢成一张表,您下次拿不准的时候直接对号入座: + +| 场景 | 用什么 | 理由 | +|---|---|---| +| 全局/静态 + 非平凡析构 T + 整个程序要用 | **`static const NoDestructor`**(函数局部静态) | 绕开 ctor/dtor + magic statics 线程安全 | +| 平凡析构 T | 裸 `static T x = ...;` | 不产生全局析构器,无需 NoDestructor | +| 平凡可构造 + 平凡析构 T | `constinit T x = ...;` 或 `constexpr` | 编译期初始化,无运行时代码 | +| 局部变量 / 成员变量 | 普通 `T x(...)` 或 `unique_ptr` | NoDestructor 会真泄漏 | +| 很少用的数据 | 按需创建函数(返回值) | NoDestructor 缓存浪费内存 | +| 需要 T 的析构副作用(刷盘/通知) | 函数局部静态 `static T x;`(接受析构) | NoDestructor 跳过析构副作用 | + +到此 NoDestructor 的使用边界就清楚了。但它还给咱们留了个反直觉的尾巴:它故意让对象不析构,LeakSanitizer 看在眼里就是一坨泄漏,这两个该怎么共存?Chromium 的 reachability hack 是怎么把这事儿圆过去的?这就是下一篇要拆的。 + +--- + +## 参考资源 + +- [Chromium `base/no_destructor.h` —— Caveats 节](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) +- [cppreference: constinit(C++20)](https://en.cppreference.com/w/cpp/language/constinit) +- [cppreference: magic statics(变量初始化线程安全)](https://en.cppreference.com/w/cpp/language/storage_duration#Static_local_variables) +- [NoDestructor 前置知识(零):静态存储期、初始化与析构](./pre-00-static-storage-and-init.md) diff --git a/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-4-no-destructor-lsan-and-leak.md b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-4-no-destructor-lsan-and-leak.md new file mode 100644 index 000000000..ad8b6b7cf --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/04-4-no-destructor-lsan-and-leak.md @@ -0,0 +1,106 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: 讲清 NoDestructor "故意泄漏"的权衡——LeakSanitizer 会误报,Chromium 用 storage_ptr_ + reachability hack 兼容 LSan(crbug/40562930) +difficulty: intermediate +order: 4 +platform: host +prerequisites: +- NoDestructor 实战(二):核心实现 +- NoDestructor 实战(三):何时用、何时不用 +reading_time_minutes: 8 +related: +- NoDestructor 设计指南(一):动机与实现 +tags: +- host +- cpp-modern +- intermediate +- 内存管理 +- 内存安全 +title: "NoDestructor 实战(四):LSan 泄漏权衡与 reachability hack" +--- +# NoDestructor 实战(四):LSan 泄漏权衡与 reachability hack + +[04-2](./04-2-no-destructor-core-impl.md) 里咱们把 NoDestructor 的核心拆成了"placement new + 不调 `~T()`"。笔者当时写完那段,心里其实还堵着一件事:T 持有的那些堆资源——比如 `NoDestructor>` 里 vector 自己在堆上分配的元素——既然 `~T()` 不跑了,它们就永远没人释放,一路"泄漏"到进程结束。对一个活到程序最后的全局单例,这其实无所谓,进程都退了 OS 自然会把整块进程内存收回去。可问题是,在 LeakSanitizer(LSan)眼里,这玩意儿就是货真价实的"内存泄漏",会拉响告警。 + +这就把咱们逼到了一个权衡面前。笔者先把代价摆清楚。 + +T 的析构平时其实干两件事:一是把 T 自己持有的资源还回去——vector 的堆内存、文件的 close 都算;二是跑一些"对外有影响"的副作用,比如把日志 flush 到磁盘、给别的进程发个通知。NoDestructor 跳过 `~T()`,这两件事就都断了。第一件无所谓,反正 OS 兜底;第二件才是真要小心的——您要是拿它包一个析构必须 flush 日志的类型,数据就丢了。所以一条比较硬的边界:NoDestructor 适合"析构只是还资源"的类型,不适合"析构有外部副作用"的类型。 + +不过这代价换来的是笔者觉得最值钱的东西:没有析构顺序问题。关停竞态这事儿,在 Chromium 这种关停路径绕得要命的大型程序里,谁踩过谁知道——能整个绕开,资源那点"不释放"完全能接受,LSan 告警嘛,咱们下面就来收拾它。 + +--- + +## LeakSanitizer 怎么工作 + +要理解为什么会告警,咱们得先看一眼 LSan 是怎么判定泄漏的。它是 AddressSanitizer 家族里专门管泄漏的那位,干的事叫**可达性分析(reachability analysis)**——名字唬人,思路其实朴素:程序退出时,它先把所有"根"摸一遍,根就是全局变量、栈上和寄存器里的那些指针;然后从这些根出发,顺着指针一路追,凡是追得到的堆内存都算"可达";剩下那些没有任何活指针能找到的堆块,就是泄漏。 + +注意这里 LSan 完全不关心"这块内存该不该析构"——它只看一件事:这块内存还有没有人指着。没人指,就判泄漏,哪怕您心里清楚这是"故意泄漏 + OS 会兜底"的无害情况。 + +--- + +## NoDestructor 在 LSan 视角下的问题 + +来看这段最朴素的代码: + +```cpp +static const base::NoDestructor> v({1, 2, 3}); +``` + +`v` 内部是 `alignas(vector) char storage_[sizeof(vector)]`,一段 char 数组,vector 是 placement new 上去的。vector 自己又在堆上分配了 `{1,2,3}` 那块存储。布局没问题,人看着也没问题。 + +LSan 扫根的时候,确实会看到 `v` 的 `storage_`——可关键是,`storage_` 的类型是 **char 数组**。LSan 不会把它当作"指向 vector 堆内存的指针"来追,在它眼里那就是一坨原始字节,哪怕那几个字节恰好就是 vector 内部的指针。这一步就断了:vector 在堆上分配的 `{1,2,3}`,从 LSan 的根集出发谁也摸不着它——可达性分析走到这儿就死胡同,自然判泄漏。 + +这就是 [crbug.com/40562930](https://crbug.com/40562930) 记的那个坑:`NoDestructor>` 在 LSan 构建下会被误报泄漏。说是"误报",其实 LSan 没判错——它的规则就是看可达性,可达性在这儿确实断了;只是从工程角度,咱们知道这是无害的故意泄漏。 + +--- + +## reachability hack:storage_ptr_ + +Chromium 的 workaround,笔者第一次读到的时候愣了一下——是真没想到还能这么干(no_destructor.h:132-142): + +```cpp +#if defined(LEAK_SANITIZER) + // TODO(crbug.com/40562930): This is a hack to work around the fact + // that LSan doesn't seem to treat NoDestructor as a root for reachability + // analysis. ... + // hold an explicit pointer to the placement-new'd object in leak sanitizer + // mode to help LSan realize that objects allocated by the contained type + // are still reachable. + T* storage_ptr_ = reinterpret_cast(storage_); +#endif +``` + +在 LSan 构建下,它额外塞了一个 `T* storage_ptr_` 成员进去,指向 placement new 上去的那个 T 对象。说白了,这地址跟 `storage_` 是同一个,只不过类型从 `char*` 换成了 `T*`。 + +换这一个类型,问题就解了。前面咱们卡在哪?卡在 LSan 把 `storage_` 当原始字节看,里头的指针它不认。可 `storage_ptr_` 的类型是 **`T*`**——这一下 LSan 就把它当成正经的"指向 T 对象的指针根"了。从它出发,LSan 能追到 T 对象,再顺着 T 对象里的指针,一路追到 vector 在堆上的 `{1,2,3}`。整条可达链就这么接上了,那些内存重新变成"可达",告警消失。 + +说白了就是这么个手法:char 数组里藏着的指针 LSan 看不懂,那咱们就再给它一个它看得懂的、显式的 `T*`——把 placement new 上去的对象在 LSan 视角下"显式化"一遍。地址没变,对象没变,变的只是"LSan 能不能识别这个根"。 + +还有个细节值得点一下:这个成员只在 `LEAK_SANITIZER` 定义时才存在,普通构建里压根没这玩意儿,零开销。条件编译的标准做法——只为需要它的构建(开了 LSan 的测试/调试构建)付这点代价。 + +--- + +## 教学版的简化 + +咱们的教学版把这个 LSan hack 省掉了(实现见 [04-2](./04-2-no-destructor-core-impl.md))。理由笔者想了想,有这么几条。 + +它只在开了 LSan 的构建里才有影响,普通编译根本碰不到。而且它本质上是"让工具别误报"的工程细节,不是 NoDestructor 的核心机制——核心还是 placement new 加上不析构那一套,这个 hack 只是外围补丁。更实际的是,如果您的工程真要在 LSan 下跑干净,不一定非得改代码:LSan 自己支持 suppression 文件(比如 `lsan_suppressions.txt`),在里面把 NoDestructor 的那块泄漏显式忽略掉,效果一样。 + +当然,如果您就是想让代码在 LSan 构建下零告警、不想维护 suppression 文件,照 Chromium 加上那一行 `#if defined(LEAK_SANITIZER) T* storage_ptr_; #endif` 也完全没问题——它就是个条件编译,加或不加都不影响 NoDestructor 的语义,纯粹是给 LSan 看的。 + +--- + +到这里,NoDestructor 这条线——动机、实现、什么时候用、LSan 兼容——就讲完整了。回头看它的全貌其实挺有意思:就用 placement new 加上不析构这么点代码,换来了"全局/静态对象没有析构顺序问题"这个 C++ 老大难里相当实在的一块解法,顺手还把 LSan 那个可达性的坑用一行 `T*` 补上了。在 vol9/chrome 这个系列里,它跟 OnceCallback(回调)、WeakPtr(弱引用)、flat_map(容器)搭在一起,正好凑成 Chromium `//base` 那套基础设施的四块——回调、弱引用、容器、静态生命周期,各管一摊。 + +## 参考资源 + +- [Chromium `base/no_destructor.h` —— LSan hack 注释(no_destructor.h:132-142)](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) +- [crbug.com/40562930 —— NoDestructor LSan 误报](https://crbug.com/40562930) +- [LeakSanitizer 文档](https://clang.llvm.org/docs/LeakSanitizer.html) +- [AddressSanitizer 家族](https://clang.llvm.org/docs/AddressSanitizer.html) +- [NoDestructor 实战(二):核心实现](./04-2-no-destructor-core-impl.md) diff --git a/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-00-static-storage-and-init.md b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-00-static-storage-and-init.md new file mode 100644 index 000000000..0a78b7e33 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-00-static-storage-and-init.md @@ -0,0 +1,159 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: 讲清静态存储期、三种静态初始化、静态初始化顺序灾难(SIOF)、析构顺序问题、 + magic statics 与 constinit,为 NoDestructor 打底 +difficulty: intermediate +order: 0 +platform: host +prerequisites: +- WeakPtr 前置知识(零):弱引用与生命周期难题 +reading_time_minutes: 11 +related: +- NoDestructor 实战(一):动机与接口设计 +- NoDestructor 前置知识(一):placement new 与对齐存储 +tags: +- host +- cpp-modern +- intermediate +- 内存管理 +- RAII +title: "NoDestructor 前置知识(零):静态存储期、初始化与析构" +--- +# NoDestructor 前置知识(零):静态存储期、初始化与析构 + +您在程序里随手写一行 `std::map global_table = load();`,大概觉得没什么——map 启动时构造,退出时析构,天经地义。可 Chromium 的 `//base` 风格指南明令禁止全局构造函数和析构函数。笔者第一次看到这条规则也愣了一下:这么自然的写法,凭什么不让用? + +愣完去翻标准,才发现这一条禁令背后压着一串 C++ 老坑——静态存储期对象有个奇怪的三段式初始化、跨翻译单元的初始化顺序根本不受您控制(这就是 SIOF)、析构顺序同样失控(关停竞态)。咱们这一篇就专门把这些地基翻出来晒晒,把每一块都讲透。等您真看明白这些,Chromium 为什么下这种禁令、以及接下来几篇 `NoDestructor` 到底在解决什么,自然就清楚了。 + +--- + +## 静态存储期 + +C++ 把对象按**存储期(storage duration)**分类,storage duration 决定一个对象什么时候生、什么时候灭。咱们日常打交道的主要是三种。局部变量进函数创建、出函数销毁,这是自动存储期,栈上的常客。`new` 出来的对象归您手动管,创建销毁全凭您一句话,这是动态存储期。还有一种是这一篇的主角——静态存储期,全局变量和 `static` 变量都归这一类,程序启动时创建、程序退出时销毁,生命周期跟整个程序一样长。 + +`NoDestructor` 服务的对象就是这一类。您写一句 `static std::string s = "...";` 或一个全局 `std::map g_table;`,里面的 `std::string`、`std::map` 都是静态存储期,活得跟程序一样久。问题是,静态存储期的对象有它自己一套初始化和析构规则,跟普通局部变量不一样——所有麻烦就是从这套规则里长出来的。 + +--- + +## 三段式静态初始化 + +静态存储期对象的初始化,标准给它分了三个阶段。先说前两个,这俩是乖孩子,不惹事。 + +第一段是零初始化(zero initialization),就是把这块内存整个清零。内置类型和有零初始化语义的类,走到这一步初始化就算完了。第二段是常量初始化(constant initialization),如果初始化表达式是个编译期常量——比如您写了个 `constexpr`——那编译期就把它做完,这一段也属于"静态初始化",不产生任何运行时代码。这两段合起来就是"静态的",编译期完成,零开销。 + +真正的麻烦在第三段——动态初始化(dynamic initialization)。如果初始化得算一算才知道结果,比如 `static std::string s = "x";` 得调 `std::string` 的构造函数、`static int n = rand();` 得在运行期调 `rand()`,编译器就只能把这件事推迟到运行期去办。这一段是"动态的",有运行期开销。咱们下面要讲的坑,根全扎在这一段上。 + +```cpp +// 静态初始化(编译期,无开销): +constexpr int kMax = 100; // 常量初始化 +static int zero; // 零初始化 + +// 动态初始化(运行期,要执行代码): +std::string g_name = "chromium"; // 要调 std::string 构造函数 +static int g_seed = rand(); // 要调 rand() +std::map g_table; // 要调 std::map 构造函数 +``` + +每一个走动态初始化的全局或静态变量,编译器都得额外给它生成一段"程序启动时调它构造函数"的代码,塞进 `.init_array` 段里。这段代码有个名号——全局构造器(global constructor),`main` 之前由运行时统一拉着跑一遍。 + +--- + +## 静态初始化顺序灾难(SIOF) + +全局构造器的执行顺序,在同一个翻译单元(同一个 .cpp)内部是按声明顺序来的——这条您能掌控。可一旦跨了翻译单元,顺序就变成了未指定的(unspecified),编译器爱怎么排怎么排。 + +这一下就翻出了 C++ 里那个老得掉渣的坑——Static Initialization Order Fiasco,简称 SIOF。看个最朴素的例子: + +```cpp +// a.cpp +extern int b_value; +int a_value = b_value + 1; // 动态初始化,依赖 b_value + +// b.cpp +int b_value = std::rand(); // 动态初始化(rand 非 constexpr,运行期才求值) +``` + +笔者刚见到这例子的时候没觉得有什么,跑一遍才发现玄机。如果 `a.cpp` 的 `a_value` 抢在前面先初始化,它去读 `b_value` 时,`b_value` 还没轮上动态初始化,只有零初始化的 0,于是 `a_value` 算出来是个 1,而不是 `b_value` 真正求值后该有的结果。这里有个分寸您得记牢:必须是动态初始化才会撞上 SIOF——常量初始化(像 `int b = 42;` 这种)在编译期就办完了,永远排在动态初始化前面,跟 SIOF 无缘。可一旦两个跨 .cpp 的全局对象互相依赖、又都是动态初始化,顺序就完全不受控,结果是未定义行为。这种 bug 折磨人的地方在于它极难复现——您换台机器、换个编译选项,顺序就变了,本地跑得好好的,CI 上偶发抽风。 + +标准给 SIOF 配的解法叫"首次使用时构造(construct on first use)":把那个全局变量塞进函数里做成局部静态,等函数头一次被调用时才动手初始化。 + +```cpp +int& a_value() { + static int v = b_value() + 1; // 函数局部静态,首次调用时初始化 + return v; +} +int& b_value() { + static int v = std::rand(); // b_value 也改成函数局部静态 + return v; +} +``` + +这么一改,`a_value()` 第一次被调到的时候,它自己主动去调 `b_value()`——这一调,`b_value()` 才开始构造。顺序这下由您写的代码决定,不再是编译器拍板。这就是 NoDestructor 推荐用函数局部静态的根本原因,它从根上绕开了 SIOF。 + +--- + +## 析构顺序问题(关停竞态) + +初始化有顺序的烂账,析构也有一份。静态存储期对象在程序退出时(从 `main` 返回后、`exit` 时)被析构,顺序是初始化顺序的逆序。听上去挺优雅,可问题就藏在"逆序"两个字背后——初始化顺序本来就不受您控制,逆序自然也跟着失控。 + +举个最常见的情形:某个全局对象在析构时,正好依赖另一个已经被析构掉的对象。比如一个全局 logger 持有一个全局 string 的引用,关停时 string 抢先析构了,轮到 logger 析构再去碰那个引用,直接 UAF。还有更阴的雷埋在析构函数内部——在里头调 `exit` 会跳过其余静态对象的析构,跨线程或嵌套调用场景下是 UB;在里头抛异常,赶上栈展开就会触发 `std::terminate`。关停路径上每一颗都够您喝一壶。 + +这套毛病统称关停竞态(shutdown race)。Chromium 是个浏览器,关停路径本来就乱:多进程、多线程,任务队列可能还在排空,全局对象的析构竞态在它的 bug 追踪器里是反复出现的老客。 + +Chromium 给出的解法简单粗暴:干脆不让全局对象析构。这就是 `NoDestructor` 的核心思路。它让对象活得跟程序一样久,但程序结束时不去析构它,代价是进程退出时由操作系统统一回收内存——这件事操作系统本来就会做,白捡。一个手工放弃析构,换掉整类析构顺序的麻烦,这笔账 Chromium 觉得值。 + +--- + +## magic statics:C++11 的线程安全保证 + +刚才那个"函数局部静态"的解法,有个前提您可能没细想:多线程同时头一次调进这个函数,初始化得是安全的吧?C++11 之前这事儿其实没保证,是 C++11 起标准才给它兜了底,江湖人称 magic statics。标准的原话大致是: + +> 如果控制流并发地经过一个未初始化的函数局部静态变量的声明,其它线程会**等待**正在进行的初始化完成。 + +翻译成代码就是这种写法您可以放心用: + +```cpp +const std::string& GetDefault() { + static const std::string s = "default"; // 线程安全:多线程首次并发调用,只有一个会初始化 + return s; +} +``` + +多少个线程一块儿调 `GetDefault()` 都行,`s` 只会被构造一次,中间不会有数据竞争。这是 C++11 白纸黑字给的保证(GCC/Clang 在底下用 `__cxa_guard_acquire` 给您实现)。笔者要在这儿点一句:NoDestructor 之所以能稳稳当当用作单例,根就扎在 magic statics 上——它自己可没给您加锁,是语言在底下兜的底。这一点想透了,后面看 NoDestructor 的实现就不会被"它怎么不加锁"这种问题绊住。 + +--- + +## constinit(C++20):保证零运行时初始化 + +C++20 给了咱们一个新工具——`constinit`。这玩意儿干的事就一句话:它向您和编译器保证,这个变量的初始化一定是常量初始化、在编译期做完,**绝对不会生成动态初始化代码**。 + +```cpp +constinit int x = 42; // OK:常量初始化 +constinit int y = compute(); // 编译错:compute() 不是常量表达式 → 拒绝 +``` + +笔者觉得这关键字设计得挺干脆——它不是建议,是断言:您写得出就让您过,初始化表达式要不是常量,编译器当场拒绝,不会留个雷到运行期才炸。它的价值就在"强制保证这个全局变量不生成全局构造器"。对那些 constinit-可构造的类型(比如 `constexpr` 构造的 POD),您直接 `constinit T x` 就两全其美了——既躲开了全局构造器,又用着裸类型,根本不用劳驾 NoDestructor。这恰恰是 NoDestructor 的 static_assert 推荐里那种"平凡情况":T 平凡可构造加平凡可析构,直接 constinit 上场,再套一层 NoDestructor 反而是多此一举。 + +--- + +## Chromium 为何禁全局 ctor/dtor + +把上面这几块拼起来,Chromium 禁全局构造和析构的动机就摆在眼前了。最直白的一条是启动性能——`main` 之前得把所有 `.init_array` 跑一遍,大型项目里成千上万个全局对象排队构造,启动延迟肉眼可见。再叠加 SIOF 和析构竞态这俩跨翻译单元的老坑,加上浏览器关停路径本身复杂(多进程多线程搅一块),析构竞态尤其要命。三条加起来,Chromium 干脆一刀切。 + +可光下禁令没用,得有手段逼着大家守规矩。Chromium 搬出来的是 clang 的 `-Wglobal-constructors` 和 `-Wexit-time-destructors` 两个警告,配上 `-Werror` 一锁——您只要写出一个会生成全局 ctor/dtor 的全局对象,编译当场报错,连商量都不带商量的。`NoDestructor` 就是 Chromium 配这条规则一起发的官方逃生口,专门绕开它: + +用函数局部静态避开全局构造器(首次使用时才构造,加上 magic statics 保证线程安全);再用 `NoDestructor` 避开全局析构器(压根不注册析构)。两个"避免"凑齐,既守了规则又拿到了全局可见的对象。 + +零件凑齐了,接下来就看 NoDestructor 具体怎么把这两个"避免"实现到代码里。下一块要补的零件是 placement new 与对齐存储——NoDestructor 实现的核心机制就靠它撑着。 + +## 参考资源 + +- [cppreference: storage duration(存储期)](https://en.cppreference.com/w/cpp/language/storage_duration) +- [cppreference: static initialization(静态初始化)](https://en.cppreference.com/w/cpp/language/initialization) +- [cppreference: constinit(C++20)](https://en.cppreference.com/w/cpp/language/constinit) +- [SIOF 经典解释 —— isocpp FAQ](https://isocpp.org/wiki/faq/ctors#static-init-order) +- [Chromium `base/no_destructor.h` 设计注释](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) diff --git a/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-01-placement-new-and-aligned-storage.md b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-01-placement-new-and-aligned-storage.md new file mode 100644 index 000000000..b1710e4ed --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/full/pre-01-placement-new-and-aligned-storage.md @@ -0,0 +1,170 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 17 +- 20 +description: 拆解 placement new(不分配、只构造)与对齐存储(alignas/alignof), + 以及 NoDestructor 如何用 char storage_[sizeof(T)] + reinterpret_cast 手动管理生命周期 +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- NoDestructor 前置知识(零):静态存储期、初始化与析构 +reading_time_minutes: 9 +related: +- NoDestructor 实战(二):核心实现 +tags: +- host +- cpp-modern +- intermediate +- 内存管理 +- RAII +title: "NoDestructor 前置知识(一):placement new 与对齐存储" +--- +# NoDestructor 前置知识(一):placement new 与对齐存储 + +[pre-00](./pre-00-static-storage-and-init.md) 咱们说 NoDestructor "构造 T 但不让它析构"。这话听着玄,落到代码上其实就两个底层机制在撑着:placement new(在您指定的地址上构造对象,不分配内存),外加一段按 T 对齐好的 `char` 数组当 T 的家。咱们这一篇就把这俩拆开揉碎——它们不光是 NoDestructor 的核心,也是 C++ 手动生命周期管理的家常工具,以后写内存池、写容器都得跟它们打交道。 + +--- + +## 普通 new vs placement new + +普通的 `new T(args)` 其实是两步合一:先 `operator new(sizeof(T))` 从堆上抠一块 `sizeof(T)` 的内存,再在那块内存上敲 `T` 的构造函数。`delete ptr` 反过来——先析构,再 `operator delete(ptr)` 把内存还回去。`new` 把"分配"和"构造"打包在一起,绝大多数时候这正是您想要的。可有时候您手上已经有一块内存了(栈数组、内存池、mmap 来的),只想让构造函数在上面跑一遍,别再替您要新内存。这就是 placement new 要解决的问题。 + +### placement new:只构造,不分配 + +语法就是在 `new` 后面多塞个 `(addr)`,告诉它往哪儿构造:`new (addr) T(args)`。 + +```cpp +#include // placement new 需要 + +alignas(int) unsigned char buf[sizeof(int)]; // 已有内存(一段 char 数组) +int* p = new (buf) int(42); // 在 buf 上构造 int,不分配! +*p == 42; +``` + +它只干"构造"那一步,在您给的 `addr` 上敲 `T` 的构造函数,`operator new` 根本没被叫起来。内存是您的,生命周期也归您管。 + +析构这一侧就得您自己动手了——手动调析构函数 `p->~T()`。千万别写成 `delete p`,`delete` 会再去试着释放内存,可这块内存压根不是从堆上分配的,一释放就出错: + +```cpp +using I = int; +I* p = new (buf) I(42); +p->~I(); // 手动析构(对 int 这种平凡类型其实没必要,但机制如此) +// buf 本身是栈数组,自动回收,不归 placement new 管 +``` + +(这里有个小编译器梗:对裸内置类型名,伪析构调用得套个 typedef 别名——`p->~int()` 主流编译器不接受,得 `using I=int; p->~I();`。笔者第一次撞见的时候愣了好一会儿。) + +placement new 真正值钱的地方在这儿——它把"对象什么时候生、什么时候灭"和"内存是谁的、什么时候还"拆成了两件事。您可以在栈内存上构造,也可以在内存池、共享内存、mmap 来的块上构造,想什么时候构造就什么时候构造,想什么时候析构就手动调一下析构。手动生命周期管理的活儿,基本都从这一句起手。 + +--- + +## 对齐:alignof 与 alignas + +placement new 还有个前提:您递过去的地址得满足 `T` 的对齐要求。所谓对齐(alignment),就是"对象地址必须是某个值的整数倍"——CPU 访问对齐的地址更快,某些架构上访问没对齐的地址,直接给您一个硬件异常,连跑都不让跑。 + +两个关键字分一下工。`alignof(T)` 查询 `T` 的对齐要求是多少字节,`alignof(int)` 通常拿到 4,`alignof(double)` 拿到 8。`alignas(N)` 则反过来,是您主动给某个变量或类型指定对齐,`alignas(16) int x;` 就是逼着 `x` 凑齐 16 字节对齐。一个问、一个答。 + +要是您给 placement new 递了个没对齐的地址,行为是未定义的: + +```cpp +unsigned char buf[13]; // 地址可能不是 4 字节对齐 +new (buf) int(42); // UB!buf 的对齐可能不够 int +``` + +所以递内存这步,对齐必须满足 `T`,没得商量。 + +### NoDestructor 的写法:`alignas(T) char storage_[sizeof(T)]` + +NoDestructor 是这么把对齐这关过去的(no_destructor.h:122): + +```cpp +alignas(T) char storage_[sizeof(T)]; +``` + +就这一行,干了两件事。`char storage_[sizeof(T)]` 先开出一块 `sizeof(T)` 字节的 char 数组,容量刚好够装一个 T——`char` 是最"宽容"的类型,任意字节都能塞,当通用缓冲最合适。`alignas(T)` 再把这块数组的对齐从 char 默认的 1 提到 `T` 那一档。两下一凑,`storage_` 的地址保证是 `alignof(T)` 的整数倍,placement new 直接往上招呼就行,不用再担心踩到对齐的雷。 + +这是 C++ 手写缓冲存储的标准写法。老代码里您还会常看到 `std::aligned_storage` 这套模板——它在 C++23 里被标了弃用(见 LWG3867/P2967),`alignas(T) char buf[sizeof(T)]` 才是现在的推荐写法,直观,不绕模板那道弯。 + +### 访问:`reinterpret_cast(storage_)` + +构造完了,还得把这块 char 内存当 `T` 用——靠 `reinterpret_cast(storage_)` 把地址转成 `T*`。这步是合法的:placement new 跑过之后,这块 char 内存里头确实住着一个货真价实的 T 对象,`reinterpret_cast` 指过去有定义。NoDestructor 的 `get()` 就是这么写的(no_destructor.h:118-119): + +```cpp +T* get() { return reinterpret_cast(storage_); } +``` + +--- + +## 手动生命周期:构造了不析构 + +把前面几样凑齐,就看明白 NoDestructor 在干什么了。它手里攥着一块 `alignas(T) char storage_[sizeof(T)]` 的裸缓冲,构造的时候 `new (storage_) T(args...)` 把 T placement-new 上去。然后——重点来了——它压根不给 T 留一条析构的路。`~NoDestructor()` 是 `= default`,析构的是那个 char 数组,而 char 数组是平凡类型,什么都不做。`~T()` 在这条路径上永远不会被叫起来。 + +这就是"构造了不析构"的全部秘密:T 被 placement new 捏出来之后,就一直在那块 `storage_` 里待着,直到进程退出,操作系统把整块进程内存——包括里头那个 T——当普通内存一起回收。注意这个回收者不是 T 的析构函数,是 OS。 + +### 这安全吗? + +T 自己持有的资源怎么办?比如 `NoDestructor>`,vector 在堆上分配的那一堆元素。坦白说,这些资源不会被 `~T()` 释放——因为 `~T()` 压根没跑。它们靠的是 OS 在进程退出时统一回收整片地址空间。程序运行期间这块内存算是"泄漏"了,可程序都要结束了,泄漏给谁看?OS 反正会兜底。 + +真正会出事的是另一种情况:T 的析构带副作用。比如有个析构函数负责把日志 flush 到磁盘、或者通知另一个进程"我走了"。这种副作用不会发生,因为析构没跑。所以 NoDestructor 只适合那种"析构就是释放资源"的类型——析构一跑副作用就没了的,别用。 + +--- + +## 一个最小复刻 + +光说不练假把式,咱们自己撸个最小版,亲手摸一下 placement new 加"不析构"是个什么手感: + +```cpp +// Platform: host | C++ Standard: C++17 +#include +#include +#include +#include + +template +class MiniNoDestructor { +public: + template + explicit MiniNoDestructor(Args&&... args) { + new (storage_) T(std::forward(args)...); // placement new + } + ~MiniNoDestructor() = default; // 不调 ~T()! + MiniNoDestructor(const MiniNoDestructor&) = delete; + + T& operator*() { return *get(); } + T* operator->() { return get(); } + T* get() { return reinterpret_cast(storage_); } + +private: + alignas(T) char storage_[sizeof(T)]; +}; + +struct Noisy { + Noisy() { std::puts("Noisy()"); } + ~Noisy() { std::puts("~Noisy()"); } // 这个析构永远不跑 +}; + +int main() { + { + static const MiniNoDestructor nd; // 构造一次 + // 离开作用域/程序退出:~MiniNoDestructor 跑(平凡),~Noisy 不跑 + } + std::puts("(程序退出前 ~Noisy 不会打印)"); + return 0; +} +``` + +跑一下您就看见了:`Noisy()` 打印一次,可 `~Noisy()` 那行——一行都打不出来。这就是 NoDestructor 的"不析构",实打实的。 + +--- + +零件齐了。placement new 给咱们"只构造、不分配"的能力,`alignas(T) char storage_[sizeof(T)]` 把对齐这关趟过去,`~NoDestructor()=default` 又把析构这条路悄悄堵死——三样一凑,T 就这么在 `storage_` 里赖着不走,直到 OS 在进程退出时统一收拾。下一篇咱们该动手把 NoDestructor 真正组装起来了,光有零件不行,还得看它怎么把初始化顺序、`reinterpret_cast` 的合法路径这些边角都兜住。 + +## 参考资源 + +- [cppreference: placement new](https://en.cppreference.com/w/cpp/language/new#Placement_new) +- [cppreference: alignof / alignas](https://en.cppreference.com/w/cpp/language/alignas) +- [cppreference: std::aligned_storage(C++17 起 deprecated)](https://en.cppreference.com/w/cpp/types/aligned_storage) +- [Chromium `base/no_destructor.h` —— storage_ 与 get()](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) diff --git a/documents/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/01-no-destructor-design-and-impl.md b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/01-no-destructor-design-and-impl.md new file mode 100644 index 000000000..57617f39d --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/01-no-destructor-design-and-impl.md @@ -0,0 +1,123 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: 面向有生命周期管理经验的读者,一篇走完 NoDestructor 的动机、接口与实现 + ——本系列 full/ 的精炼设计指南版(NoDestructor 体量小,合并设计+实现) +difficulty: advanced +order: 1 +platform: host +prerequisites: +- 移动语义与完美转发 +- NoDestructor 前置知识(零):静态存储期、初始化与析构 +reading_time_minutes: 7 +related: +- NoDestructor 设计指南(二):使用边界与测试 +tags: +- host +- cpp-modern +- advanced +- 内存管理 +- RAII +title: "NoDestructor 设计指南(一):动机、接口与实现" +--- +# NoDestructor 设计指南(一):动机、接口与实现 + +> hands-on 轨,默认您已熟静态存储期、placement new 和对齐存储;不熟的话先过一遍 [full/ 前置知识](../full/pre-00-static-storage-and-init.md)。 + +Chromium 这头有个笔者一开始觉得反常的硬规矩:全局对象不许有构造,也不许有析构。`-Wglobal-constructors` 和 `-Wexit-time-destructors` 两个开关一开,您敢在全局作用域摆个带构造函数的对象,编译期就给您甩脸上。理由其实挺实在——构造拖慢启动、析构触发关停竞态,静态初始化顺序(SIOF)那笔糊涂账更是谁碰谁知道。可问题是全局单例到处都要:默认配置、feature flag、随机 nonce,哪个不是一启动就常驻。禁了,代码还怎么写? + +笔者把现成的几条路都试了一遍,没一条能同时过两关。裸全局第一个就被否,直接撞枪口上。Meyers singleton 看着讨巧,`static T& f(){static T x;return x;}` 借 C++11 的 magic statics 把构造的线程安全和惰性都解决了,笔者一度以为它就是答案。可它只解决了构造那一半,`~T()` 该跑还得跑,关停时那一坨析构竞态照样在。最后一条是手撸 placement new,能成,但每个地方都得记得抄一套 static_assert、记得处理 LSan reachability,抄漏一处就是定时炸弹。 + +NoDestructor 就是 Chromium 给这条死路打的补丁。思路拆开看其实就两手:把对象挪进函数局部静态,绕开全局构造那道关,顺便蹭上 magic statics 的线程安全初始化;然后干脆不调 `~T()`,把全局析构那道关也一起绕过去。代价呢?对象"故意泄漏",不析构了,等进程退出时让操作系统兜底回收。听上去糙,可对一个要活到进程最后一刻的全局单例来说,这正是它该有的样子。 + +## 接口 + +```cpp +template +class NoDestructor { +public: + template + explicit NoDestructor(Args&&... args); // 完美转发构造 + explicit NoDestructor(const T& x); // 拷贝构造(方便 initializer_list) + explicit NoDestructor(T&& x); // 移动构造 + NoDestructor(const NoDestructor&) = delete; + ~NoDestructor() = default; // ← 关键:不调 ~T() + const T& operator*() const; T& operator*(); + const T* operator->() const; T* operator->(); + const T* get() const; T* get(); +}; +``` + +接口就这么点,笔者第一次读完还挺意外。`*`/`->`/`get` 一套智能指针门面,拷贝直接 delete 掉——不然两个 NoDestructor 各自 `~char[]` 还好,要是有人想深拷贝里头的 `storage_` 那就乱套了。也不继承 T,这点是有意的:NoDestructor 想当的是个容器,语义上得保持独立,不掺和 T 的继承链。日常用法就一行,`static const NoDestructor x(args); return *x;`,记住这个模子就够了。 + +## 实现(完整,~50 行) + +```cpp +// Platform: host | C++ Standard: C++20 +#pragma once +#include +#include +#include + +namespace tamcpp::chrome { + +template +class NoDestructor { +public: + template + explicit NoDestructor(Args&&... args) { + new (storage_) T(std::forward(args)...); // placement new + } + explicit NoDestructor(const T& x) { new (storage_) T(x); } + explicit NoDestructor(T&& x) { new (storage_) T(std::move(x)); } + + NoDestructor(const NoDestructor&) = delete; + NoDestructor& operator=(const NoDestructor&) = delete; + ~NoDestructor() = default; // 只析构 char 成员,不调 ~T() + + const T& operator*() const { return *get(); } + T& operator*() { return *get(); } + const T* operator->() const { return get(); } + T* operator->() { return get(); } + const T* get() const { return reinterpret_cast(storage_); } + T* get() { return reinterpret_cast(storage_); } + +private: + static_assert(!(std::is_trivially_constructible_v && + std::is_trivially_destructible_v), + "T trivially ctble+dtble: use constinit T directly"); + static_assert(!std::is_trivially_destructible_v, + "T trivially destructible: use plain function-local static T"); + + alignas(T) char storage_[sizeof(T)]; + + // Chromium 在 LEAK_SANITIZER 构建下额外持 T* storage_ptr_ 作 LSan reachability 根 + // (crbug/40562930);教学版省略,用 LSan suppression 文件替代。 +}; + +} // namespace tamcpp::chrome +``` + +代码跑下来就这几处刻意为之的取舍,笔者逐条点一下,您看代码时就能对上号: + +| 决策 | 实现 | 理由 | +|---|---|---| +| 存储用 `alignas(T) char[N]` | `alignas(T) char storage_[sizeof(T)]` | 内联缓冲,零堆分配,对齐满足 placement new | +| 构造用 placement new | `new (storage_) T(forward(args)...)` | 在 storage_ 上构造,不分配 | +| 析构 `= default` | `~NoDestructor() = default` | 只析构 char 成员(平凡),**不调 `~T()`**——这是"不析构"的根 | +| static_assert 把关 | 两条断言 | 只服务非平凡析构 T;平凡情况引导用 constinit/裸静态 | + +## "不析构"到底是怎么做到的 + +表格里那一行 `~NoDestructor() = default` 是整个机制的心脏,笔者单独拎出来讲,因为它玩的是个挺妙的视角差。 + +`storage_` 声明的类型是 `char[]`,这是编译器在生成析构函数时唯一能看到的类型。`char` 的析构是平凡的,什么都不用做。T 呢?T 是后来用 placement new 按 `storage_` 起始地址原地构造上去的,对编译器来说它和 `storage_` 的"类型"没有半点关系,析构 `storage_` 时根本不会顺带想起它。于是 `~T()` 永远不会被排进 NoDestructor 的析构路径——T 就这么安安静静地活到进程结束,最后一刻由操作系统统一回收。一个"类型视角差",换来整个"不析构"的语义,笔者觉得这招相当漂亮。 + +## 参考资源 + +- [Chromium `base/no_destructor.h`](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) +- [NoDestructor 实战(二):核心实现](../full/04-2-no-destructor-core-impl.md) +- [NoDestructor 前置知识(一):placement new 与对齐存储](../full/pre-01-placement-new-and-aligned-storage.md) diff --git a/documents/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/02-no-destructor-usage-and-testing.md b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/02-no-destructor-usage-and-testing.md new file mode 100644 index 000000000..5860c609c --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/hands_on/02-no-destructor-usage-and-testing.md @@ -0,0 +1,84 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +- 20 +description: NoDestructor 的使用边界(该用/四个别用)、constinit 全局 vs 函数局部静态、 + LSan reachability hack、测试不变量 +difficulty: advanced +order: 2 +platform: host +prerequisites: +- NoDestructor 设计指南(一):动机、接口与实现 +reading_time_minutes: 6 +related: +- NoDestructor 设计指南(一):动机、接口与实现 +tags: +- host +- cpp-modern +- advanced +- 内存管理 +- 内存安全 +title: "NoDestructor 设计指南(二):使用边界、LSan 与测试" +--- +# NoDestructor 设计指南(二):使用边界、LSan 与测试 + +上一篇咱们把 NoDestructor 的"为什么"和实现抠了一遍。这一篇管"怎么用对"。笔者老实讲,这玩意儿比想象中更容易用错,因为能用它的场景其实非常窄,但每一处用错的姿势都挺自然。咱们先把唯一该用的场景立住,再倒着扫一遍四个最常见的误用,最后聊聊两个绕不开的工程细节:线程安全到底靠谁、LSan 为什么会对它发脾气。 + +## 该用的唯一场景 + +```cpp +const T& GetGlobal() { + static const base::NoDestructor x(args...); // ✓ 函数局部静态 + 非平凡析构 T + return *x; +} +``` + +这三行看着平平无奇,但三个条件一个都不能少:T 是非平凡析构的(不然用 NoDestructor 纯属多余),写成函数局部静态(这点下面单独讲),而且它得是整个程序都要用的全局性对象。三条凑齐,才轮到 NoDestructor 出场。 + +## 四个别用 + +真正该用 NoDestructor 的就上面那一种。可笔者翻自己以前的代码、看社区里的用法,错用的姿势倒有一大堆。Chromium 源码在 `no_destructor.h:15-46` 的 Caveats 里把它们列得挺清楚,咱们按踩坑频率归成四类: + +| 场景 | 别用 NoDestructor,改用 | 理由 | +|---|---|---| +| 局部变量/成员 | 普通 `T` 或 `unique_ptr` | NoDestructor 会**真泄漏**(该回收时不回收) | +| 平凡析构 T | 裸 `static T x` | 不产生全局析构器,无需 NoDestructor | +| 平凡可构造+析构 T | `constinit T x` / `constexpr` | 编译期初始化,无运行时代码 | +| 很少用的数据 | 按需创建函数(返回值) | NoDestructor 缓存浪费 bss 内存 | + +头一条是最容易糊的:有人把 NoDestructor 当成"高级的 unique_ptr"往成员里塞,或者干脆包局部变量。这一塞不是省析构,是真把内存漏了,对象该回收的时候没人回收它。中间两条其实是同一个意思的两种说法:如果 T 析构啥也不干(POD、trivially destructible),那它压根不产生全局析构器,您再套一层 NoDestructor 等于多此一举;再进一步,要是它还能 constinit/constexpr 构造,编译期就初始化好了,连运行时代码都没有,那 constinit 才是正解。最后一条偏冷门但笔者亲测过:一些"以防万一先缓存着"的冷数据,用 NoDestructor 一挂就是整个 bss 段,不如老老实实按需造。 + +## constinit 全局 vs 函数局部静态 + +"该用的唯一场景"里笔者一直强调函数局部静态,这里把例外也讲明白。默认就老老实实写函数局部静态,它一举两得:绕开了全局构造器带来的初始化顺序坑,又借 C++11 magic statics 拿到线程安全,代码还短。只有当 T 本身能 constinit 构造的时候,您才能写全局 `constinit const NoDestructor g(...)`,这样不产生任何静态初始化器,是真零开销。 + +这里有个笔者踩过的暗坑。T 要是不能 constexpr 构造,您别以为写个全局 `NoDestructor g(...)` 就万事大吉,它照样会生成静态初始化器,因为 NoDestructor 自己的构造函数不是 constexpr 的。这种情况没别的路,必须退回函数局部静态。换句话说,constinit 全局这条路只对 constinit 可构造的 T 敞开,门槛比想象中高。 + +## magic statics:线程安全靠它,不靠 NoDestructor + +这一点容易被"用了 NoDestructor 就线程安全了"的错觉盖过去。NoDestructor 自己一行锁都没加,它对线程安全零贡献。真正撑场子的是 C++11 函数局部静态那套初始化保证:首个线程进入函数时初始化,其他并发线程被挡住等它完成。所以前面反复强调"函数局部静态"不只是绕开 ctor 的小聪明,更是 NoDestructor 线程安全的唯一来源。换个姿势用,这条保证就没了。 + +## LSan 泄漏权衡 + +"不析构"听着爽,代价也得摊开讲。一是资源不释放,好在进程退出时 OS 会兜底回收,所以纯内存型 T 影响不大;二是析构的副作用不发生,这才是要命的:如果 T 的析构里有刷盘、通知、状态上报这类动作,您跳过它,程序逻辑就坏了。所以 NoDestructor 只适合纯资源型 T,凡是析构有副作用的,一律别碰。 + +然后是个挺恶心的 LSan 误报。NoDestructor 把对象塞在 `char storage_[]` 里,这个 byte 数组 LSan 看不懂,它做可达性分析时认不出里头藏着指针,于是把 NoDestructor 持有的堆内存一律判成泄漏。Chromium 的解法是个相当取巧的 hack(`no_destructor.h:132-142`):在 `LEAK_SANITIZER` 构建下,额外再持一个 `T* storage_ptr_ = reinterpret_cast(storage_)`,相当于给 LSan 喂一个它能认识的 `T*` 根,把可达链接接上(crbug/40562930)。这个字段只在 LSan 构建下存在,普通构建零开销。咱们教学版省了这一手,跑 LSan 时用 suppression 文件把误报压掉就行。 + +## 测试不变量 + +NoDestructor 的行为正确性,落到测试上其实是五条不变量。咱们一条条过。 + +构造只跑一次,这是 magic statics 的承诺——多次调用包裹函数,T 的构造函数应当只触发一回,可以用计数构造次数验证。紧接着是它的反面:析构永远不跑。这一条没法用普通断言验,因为析构压根不发生;常见做法是搞一个析构里打 log 的 Noisy T,程序跑完检查那个 log 没出现,death 测试或单独进程隔离跑都行。 + +第三条是编译期就把关的:NoDestructor 拒绝平凡析构的 T,所以 `NoDestructor` 这种用法应当被 static_assert 在编译期拒掉,根本编不过。第四条线程安全,多线程并发首次调用同一个包裹函数,构造函数还是只跑一次,用计数器加多线程压一压,无竞态就算过。最后是访问语义,`*nd`、`nd->`、`nd.get()` 三个口子的行为都得跟普通 T 对得上。这属于基本盘,但笔者见过有人改了内部 storage 后漏测访问语义,翻车的,别省这几行测试。 + +NoDestructor 整个系列到此完结。回头看,vol9/chrome 这一路攒下来正好是四块拼图:**OnceCallback** 管回调的生命周期与取消,**WeakPtr** 管弱引用,**flat_map** 管高性能容器,**NoDestructor** 管静态生命周期。四个不同维度的工业级 C++ 设计,凑一块儿,刚好把 Chromium `//base` 里最值得学的几块都过了一遍。 + +## 参考资源 + +- [Chromium `base/no_destructor.h` —— Caveats + LSan hack](https://source.chromium.org/chromium/chromium/src/+/main:base/no_destructor.h) +- [crbug.com/40562930 —— LSan 误报](https://crbug.com/40562930) +- [LeakSanitizer 文档](https://clang.llvm.org/docs/LeakSanitizer.html) +- [NoDestructor 实战(三):何时用、何时不用](../full/04-3-no-destructor-when-to-use.md) diff --git a/documents/vol9-open-source-project-learn/chrome/04_no_destructor/index.md b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/index.md new file mode 100644 index 000000000..ee1be2554 --- /dev/null +++ b/documents/vol9-open-source-project-learn/chrome/04_no_destructor/index.md @@ -0,0 +1,14 @@ +# NoDestructor:从 Chromium 学静态生命周期管理 + +本目录拆 Chromium 的 `base::NoDestructor`,讲透全局/静态对象的生命周期管理:为什么 Chromium 禁全局构造和析构、placement new 怎么手动管生命周期、magic statics 的线程安全、"故意泄漏"的权衡。它和 [OnceCallback](../01_once_callback/) / [WeakPtr](../02_weak_ptr/) / [flat_map](../03_flat_map/) 是姊妹篇,补 vol9/chrome 的静态生命周期这块。 + +NoDestructor 是个轻量组件(一个 header-only 的薄包装类),本系列比前三者精简。 + +## 完整教程(full/) + +- 前置知识:[静态存储期、初始化与析构](./full/pre-00-static-storage-and-init.md)、[placement new 与对齐存储](./full/pre-01-placement-new-and-aligned-storage.md) +- 动手实践:[动机与 API](./full/04-1-no-destructor-motivation-and-api.md)、[核心实现](./full/04-2-no-destructor-core-impl.md)、[使用边界](./full/04-3-no-destructor-when-to-use.md)、[LSan 与泄漏](./full/04-4-no-destructor-lsan-and-leak.md) + +## 进阶设计指南(hands_on/) + +面向有模板与生命周期经验的读者:[动机、接口与实现](./hands_on/01-no-destructor-design-and-impl.md)、[使用边界与测试](./hands_on/02-no-destructor-usage-and-testing.md)。 diff --git a/documents/vol9-open-source-project-learn/index.md b/documents/vol9-open-source-project-learn/index.md index 6de7b26c5..c9752db7e 100644 --- a/documents/vol9-open-source-project-learn/index.md +++ b/documents/vol9-open-source-project-learn/index.md @@ -11,7 +11,7 @@ platform: host # 卷九 · 开源项目学习 -通过分析真实开源项目源码,学习工业级 C++ 代码的设计与实现。 +通过分析真实开源项目源码,学习工业级 C++ 代码的设计与实现。 ## 项目列表 @@ -19,4 +19,7 @@ platform: host OnceCallback:从 Chromium 学到的回调设计 + WeakPtr:从 Chromium 学到的弱指针设计 + flat_map:从 Chromium 学到的有序容器设计 + NoDestructor:从 Chromium 学到的静态生命周期管理 diff --git a/site/.vitepress/theme/components/OnlineCompilerDemo.vue b/site/.vitepress/theme/components/OnlineCompilerDemo.vue index 2b3c3c188..5ba8a125e 100644 --- a/site/.vitepress/theme/components/OnlineCompilerDemo.vue +++ b/site/.vitepress/theme/components/OnlineCompilerDemo.vue @@ -139,6 +139,7 @@ autocomplete="off" autocorrect="off" autocapitalize="off" + wrap="off" @scroll="onEditorScroll" /> diff --git a/site/.vitepress/theme/custom.css b/site/.vitepress/theme/custom.css index d5f1d47c7..130139bfa 100644 --- a/site/.vitepress/theme/custom.css +++ b/site/.vitepress/theme/custom.css @@ -987,6 +987,7 @@ html.dark .online-compiler-demo__source-highlight .shiki span { line-height: 18.6px; /* 像素级,与 textarea 确定性一致(避免 unitless 在块/表单控件间渲染差) */ tab-size: 4; /* 与 textarea 的 tab-size 一致,Tab 字符宽度对齐 */ white-space: pre; + font-variant-ligatures: none; /* 关连字(==/!= 等),与 textarea 字符等宽对齐 */ letter-spacing: 0; word-spacing: 0; box-sizing: border-box; @@ -1008,6 +1009,7 @@ html.dark .online-compiler-demo__source-highlight .shiki span { line-height: 18.6px; /* 像素级,与 backdrop 确定性一致 */ tab-size: 4; white-space: pre; + font-variant-ligatures: none; /* 关连字,与 backdrop 字符等宽对齐 */ letter-spacing: 0; word-spacing: 0; box-sizing: border-box; From 0dc17ec3e1263ea663b1ca1a238539766613cbb9 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Wed, 8 Jul 2026 17:58:09 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(vol9):=20=E4=BF=AE=2002-3=20=E6=AD=BB?= =?UTF-8?q?=E9=93=BE(02-6=20=E6=96=87=E4=BB=B6=E5=90=8D=E8=AF=AF=E5=86=99?= =?UTF-8?q?=2002-4=20=E4=B8=BB=E9=A2=98)+=20pre-06=20reading=5Ftime=20?= =?UTF-8?q?=E5=81=8F=E5=B7=AE(CI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md | 2 +- .../chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md | 2 +- .../02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md | 2 +- .../chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md index 23ad89e21..94a83e302 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md @@ -126,7 +126,7 @@ The entire difference is the line after invalidating the old Flag. `invalidate_w `invalidate_weak_ptrs_and_doom()` is the meaner sibling. After invalidating the old Flag it does not mint a new one, and it zeroes `ptr_` for good measure. The factory enters a doomed state, and any `get_weak_ptr()` you call after that hands back an invalid result. It is cheaper than the first option by exactly one Flag allocation. The name says it: doom is for the "this object is being retired" cleanup path. -These two show up again in the [02-6](./02-6-weak-ptr-sequence-affinity-and-lazy-binding.md) performance comparison. Truth is, in nine out of ten day-to-day cases you never call either one explicitly. The factory's destructor-time auto-invalidation we are about to cover is enough on its own. +These two show up again in the [02-6](./02-6-weak-ptr-testing-and-perf.md) performance comparison. Truth is, in nine out of ten day-to-day cases you never call either one explicitly. The factory's destructor-time auto-invalidation we are about to cover is enough on its own. --- diff --git a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md index ff67ef210..02e20ec62 100644 --- a/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md +++ b/documents/en/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md @@ -11,7 +11,7 @@ platform: host prerequisites: - 'WeakPtr prerequisite (I): intrusive refcount and scoped_refptr' - 'WeakPtr prerequisite (V): template friend and uintptr_t type erasure' -reading_time_minutes: 11 +reading_time_minutes: 7 related: - 'weak_ptr design guide (I): motivation, API, and the control block' - 'weak_ptr design guide (III): test strategy and performance' diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md index 4472b8ae7..f0d0e27ec 100644 --- a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/02-3-weak-ptr-factory-and-last-member.md @@ -127,7 +127,7 @@ void WeakReferenceOwner::InvalidateAndDoom() { `invalidate_weak_ptrs_and_doom()` 就狠一点,作废旧 Flag 之后不铸新的,还顺手把 `ptr_` 清零——factory 直接进入"已死"状态,之后您再调 `get_weak_ptr()` 拿到的就是无效结果。它比上一个更省事,连一次 Flag 分配都省了。顾名思义 doom,这是给"这对象彻底不再用了"的收尾场景准备的。 -这俩的差别在 [02-6](./02-6-weak-ptr-sequence-affinity-and-lazy-binding.md) 性能对比里还会再露一次脸。不过说句实在话,日常九成的场景,您根本不会显式调这俩——光靠下面要讲的 factory 析构自动失效就够用了。 +这俩的差别在 [02-6](./02-6-weak-ptr-testing-and-perf.md) 性能对比里还会再露一次脸。不过说句实在话,日常九成的场景,您根本不会显式调这俩——光靠下面要讲的 factory 析构自动失效就够用了。 --- diff --git a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md index b09d17b31..bedd691d4 100644 --- a/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md +++ b/documents/vol9-open-source-project-learn/chrome/02_weak_ptr/full/pre-06-weak-ptr-trivial-abi.md @@ -12,7 +12,7 @@ platform: host prerequisites: - WeakPtr 前置知识(一):侵入式引用计数与 scoped_refptr - WeakPtr 前置知识(五):模板友元与 uintptr_t 类型擦除 -reading_time_minutes: 11 +reading_time_minutes: 7 related: - WeakPtr 实战(一):动机与接口设计 - WeakPtr 实战(六):测试与性能对比