From f0dc493a374265c51b0e46df7540e022274a13cf Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 12 Jun 2026 10:52:55 +0800 Subject: [PATCH 1/2] feat(vol3): add passages for vector/string/char8_t MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 正文系列(卷三 01/02/03): - 01-vector-deep-dive:三指针内部表示、扩容摊还常数与三家增长因子 (libstdc++/libc++ 2×、MSVC 1.5×)、迭代器失效全景、move_if_noexcept 异常安全、C++20 constexpr vector (P0784R7+P1004R2)、erase/erase_if (P1209R0) - 02-string-memory-deep-dive:SSO vs COW 历史 (N2668)、SSO 阈值、 C++23 resize_and_overwrite (P1072R10) - 03-char8-t-utf8:C++20 char8_t (P0482R6) 类型变更、两个迁移坑、 C++23 P2513R4 DR 配套: - 3 个 OnlineCompilerDemo 源 (15_vector/16_string/17_char8_t) - 卷三 index.md 分区:正文系列 + 「待重写文章」分区 站点改进(顺带修复): - mermaid 渲染:节点标签 padding/line-height + foreignObject 不裁剪, 解决 CJK 多行文字被节点框遮挡 - dev-only vite plugin:dev 下服务 code/examples(含路径穿越防护), 让 OnlineCompilerDemo 在 dev 也能运行,build 不受影响 --- README.md | 2 +- .../examples/vol34567/15_vector_deep_dive.cpp | 78 +++++ code/examples/vol34567/16_string_memory.cpp | 44 +++ code/examples/vol34567/17_char8_t.cpp | 33 ++ .../01-vector-deep-dive.md | 296 ++++++++++++++++++ .../02-string-memory-deep-dive.md | 164 ++++++++++ .../vol3-standard-library/03-char8-t-utf8.md | 119 +++++++ documents/vol3-standard-library/index.md | 24 +- site/.vitepress/config/index.ts | 29 ++ site/.vitepress/theme/custom.css | 12 + site/.vitepress/theme/mermaid-client.ts | 9 + 11 files changed, 800 insertions(+), 10 deletions(-) create mode 100644 code/examples/vol34567/15_vector_deep_dive.cpp create mode 100644 code/examples/vol34567/16_string_memory.cpp create mode 100644 code/examples/vol34567/17_char8_t.cpp create mode 100644 documents/vol3-standard-library/01-vector-deep-dive.md create mode 100644 documents/vol3-standard-library/02-string-memory-deep-dive.md create mode 100644 documents/vol3-standard-library/03-char8-t-utf8.md diff --git a/README.md b/README.md index 3015f541a..1df31e2cd 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ --- -![English Coverage](https://img.shields.io/badge/en_coverage-100%25-green.svg) 420/420 docs translated +![English Coverage](https://img.shields.io/badge/en_coverage-99%25-green.svg) 420/423 docs translated ## 这是什么项目 diff --git a/code/examples/vol34567/15_vector_deep_dive.cpp b/code/examples/vol34567/15_vector_deep_dive.cpp new file mode 100644 index 000000000..64b215696 --- /dev/null +++ b/code/examples/vol34567/15_vector_deep_dive.cpp @@ -0,0 +1,78 @@ +// Standard: C++20 +// vector 实现层深入:扩容追踪 / 迭代器失效 / move_if_noexcept / constexpr vector / erase_if +#include +#include + +// ---- move_if_noexcept 观察:move 构造故意不标 noexcept ---- +class Tracked { + public: + int id; + static int move_count; + static int copy_count; + + explicit Tracked(int i) : id(i) {} + Tracked(const Tracked& o) : id(o.id) { ++copy_count; } + Tracked(Tracked&& o) noexcept(false) : id(o.id) { ++move_count; } +}; +int Tracked::move_count = 0; +int Tracked::copy_count = 0; + +// ---- constexpr vector:编译期当临时工作区,只返回标量 ---- +// transient allocation:常量求值期分配的内存必须在该求值内释放, +// 所以不能定义持久 constexpr vector 变量,只能让缓冲在函数内自然析构。 +constexpr int sum_first_n(int n) { + std::vector v; + for (int i = 0; i < n; ++i) { + v.push_back(i + 1); + } + int sum = 0; + for (int x : v) { + sum += x; + } + return sum; +} +static_assert(sum_first_n(100) == 5050); // 全程编译期完成 + +int main() { + std::cout << "== 扩容追踪(push_back 17 次)==\n"; + std::vector v; + for (int i = 0; i < 17; ++i) { + std::size_t cap_before = v.capacity(); + v.push_back(i); + if (v.capacity() != cap_before) { + std::cout << "push " << i << ": capacity " << cap_before << " -> " << v.capacity() + << '\n'; + } + } + + std::cout << "\n== 迭代器失效 ==\n"; + std::vector w{1, 2, 3}; + w.reserve(3); + const int* p = &w[1]; + w.push_back(4); // 余量足够,不扩容 → 指针仍有效 + std::cout << "push_back 不扩容,指针有效? " << (p == &w[1]) << '\n'; + w.reserve(100); // 超过 capacity → 换缓冲 → 指针失效 + std::cout << "reserve 超容量后,指针有效? " << (p == &w[1]) << '\n'; + + std::cout << "\n== move_if_noexcept ==\n"; + std::vector t; + t.reserve(2); + t.emplace_back(1); + t.emplace_back(2); + t.emplace_back(3); // 触发扩容 + std::cout << "扩容时 moves=" << Tracked::move_count << " copies=" << Tracked::copy_count + << "(noexcept(false) → 扩容倾向 copy;改成 noexcept 则变 move)\n"; + + std::cout << "\n== constexpr vector (C++20) ==\n"; + std::cout << "sum_first_n(100) = " << sum_first_n(100) << "(编译期 static_assert 已验证)\n"; + + std::cout << "\n== erase_if (C++20) ==\n"; + std::vector e{1, 2, 3, 4, 5, 6}; + std::size_t removed = std::erase_if(e, [](int x) { return x % 2 == 0; }); + std::cout << "removed=" << removed << " left:"; + for (int x : e) { + std::cout << ' ' << x; + } + std::cout << '\n'; + return 0; +} diff --git a/code/examples/vol34567/16_string_memory.cpp b/code/examples/vol34567/16_string_memory.cpp new file mode 100644 index 000000000..029ef3ba1 --- /dev/null +++ b/code/examples/vol34567/16_string_memory.cpp @@ -0,0 +1,44 @@ +// Standard: C++23 +// string 内存深入:SSO 观察 + resize_and_overwrite 缓冲复用 +#include +#include +#include +#include + +// 判断 string 的 data() 是否落在对象内联缓冲里(SSO 的标志) +bool points_inside_object(const std::string& s) { + const char* obj = reinterpret_cast(&s); + return s.data() >= obj && s.data() < obj + sizeof(std::string); +} + +// 模拟一个 C API:向 buf 最多写 n 字节,返回实际写入数 +std::size_t fake_read(char* buf, std::size_t n) { + static const char msg[] = "hello"; + std::size_t len = std::min(n, sizeof(msg) - 1); + std::memcpy(buf, msg, len); + return len; +} + +int main() { + std::cout << "sizeof(std::string) = " << sizeof(std::string) << '\n'; + + std::string short_s = "hi"; // 很可能走 SSO + std::string long_s(64, 'x'); // 超过 SSO 阈值,出堆 + std::cout << "short_s.data() 在对象内? " << points_inside_object(short_s) << "(SSO)\n"; + std::cout << "long_s.data() 在对象内? " << points_inside_object(long_s) << "(出堆)\n"; + + std::cout << "\n== resize() 旧写法:先把 64 字符全部值初始化(清零),再截断 ==\n"; + std::string old_buf; + old_buf.resize(64); + std::size_t got = fake_read(old_buf.data(), old_buf.size()); + old_buf.resize(got); + std::cout << "old: '" << old_buf << "' (len=" << old_buf.size() << ")\n"; + + std::cout << "\n== resize_and_overwrite (C++23):不清零多余字符,回调报告实际长度 ==\n"; + std::string buf; + buf.resize_and_overwrite(64, [](char* p, std::size_t n) noexcept { + return fake_read(p, n); // 只写实际字节,返回新长度(r ∈ [0, n]) + }); + std::cout << "new: '" << buf << "' (len=" << buf.size() << ")\n"; + return 0; +} diff --git a/code/examples/vol34567/17_char8_t.cpp b/code/examples/vol34567/17_char8_t.cpp new file mode 100644 index 000000000..8012b1261 --- /dev/null +++ b/code/examples/vol34567/17_char8_t.cpp @@ -0,0 +1,33 @@ +// Standard: C++20 +// char8_t 两坑(用注释封印,取消注释即编译失败)+ 两种正确写法 +#include +#include + +// 坑一:u8"" 在 C++20 起类型变为 const char8_t[],不再隐式转 const char* +// const char* p = u8"text"; // ill-formed since C++20 + +// 坑二:标准库显式 =delete 了 char8_t / const char8_t* 的 ostream 插入重载 +// std::cout << u8"text"; // ill-formed since C++20 +// std::cout << u8'z'; // ill-formed since C++20 + +// 正确写法之一:显式逐字节转换(内容不变,仅切换指针类型视角) +void print_as_char(const char* s) { + std::cout << s << '\n'; +} + +// 正确写法之二:用 std::u8string 类型安全地持有 UTF-8,并自定义打印 +std::ostream& operator<<(std::ostream& os, const std::u8string& s) { + return os << reinterpret_cast(s.data()); +} + +int main() { + // 路线 A:把 u8 字面量当 const char* 用(适合喂给只认窄字符的旧接口) + print_as_char(reinterpret_cast(u8"text")); + + // 路线 B:u8string 全程保持 UTF-8 类型,打印时再转 + std::u8string u8s = u8"UTF-8 text"; + std::cout << u8s << '\n'; + + std::cout << "__cpp_char8_t = " << __cpp_char8_t << '\n'; + return 0; +} diff --git a/documents/vol3-standard-library/01-vector-deep-dive.md b/documents/vol3-standard-library/01-vector-deep-dive.md new file mode 100644 index 000000000..5c571f3f0 --- /dev/null +++ b/documents/vol3-standard-library/01-vector-deep-dive.md @@ -0,0 +1,296 @@ +--- +chapter: 7 +cpp_standard: +- 11 +- 14 +- 17 +- 20 +description: "从三指针内部表示出发,讲透 std::vector 的扩容代价、迭代器失效全景、move_if_noexcept 异常安全,以及 C++20 constexpr vector 与 erase/erase_if" +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- '卷一:vector 基础用法(size / capacity / push_back)' +reading_time_minutes: 16 +tags: +- host +- cpp-modern +- intermediate +- vector +title: "vector 深入:三指针、扩容与迭代器失效" +--- + +# vector 深入:三指针、扩容与迭代器失效 + +这一篇,笔者想跟各位好好聊聊 `std::vector` 的实现层。 + +卷一里我们已经把 `vector` 当个"会自己长大的数组"用得很顺了,`push_back`、`size()`、`capacity()`、`reserve()` 信手拈来。但笔者必须说一句实话——用得顺,和真懂,是两码事。不知道各位有没有碰上过这种邪门的情况:一个循环里不停地 `push_back`,绝大多数次都飞快,偏偏某一次卡顿得离谱;或者你小心翼翼缓存了一个迭代器、一个指针,某天它就指向了一片垃圾;又或者你自以为写得很稳的强异常安全,被一次扩容悄悄撕了个口子。 + +这些坑,根全埋在 `vector` 的实现层里。所以这一篇我们不重复卷一那些 API 怎么调(那个您肯定会了),而是把 `vector` 拆成三个指针、一个扩容策略、一张失效规矩表,再顺手接上 C++20 给它开的两扇新门——`constexpr` 和 `erase/erase_if`。 + +------ + +## 三个指针,撑起整个 vector + +主流标准库实现里(libstdc++、libc++、MSVC STL),一个 `vector` 的本体,其实就是仨指针。不是数组、不是链表,就是 `begin` 指向首元素、`end` 指向最后一个有效元素的"下一个"位置、`end_of_storage` 指向已分配缓冲的尽头。(这个知乎上笔者记得有提问,主流实现亦是如此。) + +```mermaid +flowchart LR + BEGIN(["begin
首元素"]) --> S0["v[0]"] + TAIL(["end
size 边界"]) --> S3["空闲槽"] + CAP(["end_of_storage
capacity 边界"]) --> S5["缓冲末尾"] + S0 --- S1["v[1]"] --- S2["v[2]"] --- S3 --- S4["空闲槽"] --- S5 +``` + +顺着这图一推,什么都通了:`size()` 就是 `end - begin`,`capacity()` 就是 `end_of_storage - begin`,而 `capacity() - size()`,正是你还能不扩容就直接塞进去的元素数。标准文本其实并没有规定 `vector` 必须长成这样(它只要求连续存储加一堆接口行为),但你一旦知道底层就是这仨指针,后面所有特性都变得顺理成章: + +1. 扩容,无非是把 `[begin, end)` 这一坨搬到新缓冲; +2. 迭代器失效,无非是缓冲被人换了; +3. `data()` 能直接喂给 C API,无非是因为 `begin` 指向的就是一整块连续裸内存。 + +## 扩容这件事:说是摊还常数,单次可能是 O(n) + +那往一个 `capacity` 已经塞满的 `vector` 里再 `push_back` 会怎样?会触发一次 *reallocation*——申请新缓冲、把旧元素搬过去、释放旧缓冲。标准对这一步的承诺是 `push_back` 的**摊还常数复杂度**,请各位务必咬住"摊还"这两个字,它不是"常数"。 + +这话太容易被读成"每次 `push_back` 都是 O(1)",于是一些朋友就放心地往热循环里塞 `push_back`,结果某一次扩容直接是一次 O(n) 的搬家,性能曲线上"咔"地掉一个尖峰。摊还分析为什么能成立?关键就在于每次扩容时,容量是按一个大于 1 的几何倍数往上翻的,于是那一次昂贵的搬家费用,就被摊到了之前若干次便宜的 `push_back` 头上。 + +(PS:这个地方笔者最近忙的起飞,如果您觉得这个话题有意思,可以试试在本地profile下!) + +```mermaid +flowchart TD + A["push_back(x)"] --> Q{"size < capacity?"} + Q -- "是" --> C["就地构造 x
end++ · O(1)"] + Q -- "否" --> D["申请新缓冲
2x / 1.5x"] + D --> E["搬运旧元素
move 或 copy · O(n)"] + E --> F["释放旧缓冲"] + F --> G["构造 x · end++"] + C --> H["摊还常数 ✓"] + G --> H +``` + +那这个倍数到底是多少?不好意思,**标准没规定**(严格讲叫 *unspecified*,比 *implementation-defined* 还宽松,后者好歹要求实现写进文档)。于是三家各选各的:libstdc++ 和 libc++ 都约摸是 2×(公式分别是 `size()+max(size(),n)` 和 `max(2*capacity(),n)`),MSVC STL 用的是 1.5×(`capacity()+capacity()/2`)。您要是不信,连续 `push_back` 16 个元素自己打印 `capacity()` 看——libstdc++/libc++ 走的是 `0 → 1 → 2 → 4 → 8 → 16 → 32`,MSVC 走的是 `0 → 1 → 2 → 3 → 4 → 6 → 9 → 13 → 19`。 + +MSVC 选 1.5× 不是拍脑袋。当倍数严格小于 2 时,前面几次释放掉的空闲块,是有可能被后面某次分配重新用上的——数学上 + +$$\sum_{i=0}^{k-1} 1.5^i = 2(1.5^k - 1) > 1.5^k$$ + +意思是历史释放的某块够大,能塞下当前请求,分配器就能复用、少制造碎片、RSS 也不至于居高不下。而严格 2× 呢,$\sum_{i=0}^{k-1} 2^i = 2^k - 1 < 2^k$,之前释放的任何一块都装不下当前请求,永远复用不了。代价当然也有:1.5× 搬家次数更多。这是一笔"内存复用 vs 搬家次数"的取舍,两家各有各的算盘。(还有个小边界:头一次 `push_back` 容量从 0 直接蹦到 1,三家一致,纯粹是"初始为 0"的特例,别拿这个去套 2×/1.5×。) + +> ⚠️ 笔者再啰嗦一遍:写性能结论的时候,请用"摊还常数",别图省事写"常数"。单次触发扩容的那个 `push_back`,就是实打实的 O(n)。 + +## 迭代器失效:一张表讲完所有规矩 + +大概没有哪个容器比 `vector` 更容易在"迭代器失效"上栽跟头了——你存了个迭代器、存了个指针,某个操作一过,它就悄悄成了野指针。规矩其实能归纳成一张表: + +| 操作 | 何时失效 | 失效范围 | +|------|---------|---------| +| `push_back` / `emplace_back` | 仅当触发 reallocation | 触发时**全部**失效;没触发(还有余量)则**都不失效** | +| `reserve(n)` | 当 `n > 当前 capacity()` 触发 reallocation | 触发则全部失效;否则不失效 | +| `shrink_to_fit` | 若发生 reallocation | 全部失效 | +| `resize(n)` | `n > capacity()` 触发 reallocation | 触发则全部失效;否则引用/指针不失效,仅 past-the-end 迭代器失效 | +| `erase(p)` / `erase(first, last)` | 必然 | **被删元素及其之后**全部失效 | +| `insert` / `emplace` | 若 reallocation | 触发则全部失效;否则 `pos` 及其之后失效 | +| `clear` | 必然 | 全部失效 | +| `assign` / `assign_range` | 必然 | 全部失效 | +| `swap` | —— | **不失效**:迭代器/指针/引用仍然有效,但它们现在指向"对方"容器里的元素 | + +嫌表密?把它压成一棵决策树就好记了: + +```mermaid +flowchart TD + OP["修改操作"] --> T{"触发 reallocation?"} + T -- "是" --> ALL["全部引用/指针/迭代器失效"] + T -- "否" --> K{"操作类型"} + K -- "push_back / resize / reserve
(未超容量)" --> NONE["都不失效
(past-the-end 除外)"] + K -- "erase" --> AFTER["被删及之后失效"] + K -- "insert" --> POS["pos 及之后失效"] + K -- "swap" --> SWAP["不失效 · 指向对方容器"] +``` + +表里最容易记反的是最后那条 `swap`。它不失效——你换走的是容器里的内容,但迭代器还钉在原来那块内存上,于是它现在指向的,是被换过来的那个容器。理解了这一点,您就能看懂为啥有些库爱写 `vector().swap(v)` 这种看着诡异的代码来"真释放"内存:它换进来一个空的临时对象,把原缓冲连同容量一块带走析构,干干净净。 + +## 扩容时的 move_if_noexcept + +强异常保证要求一次操作要么成、要么状态纹丝不动。`push_back` 触发扩容时要把旧元素一个个搬去新缓冲,这一步本身就是个潜在的抛异常点。那要想"搬一半失败了还能回滚",标准库在扩容时对每个元素下了一个关键判断:**这元素的移动构造要是 `noexcept` 的,就 move;不然,老老实实退回去 copy。** + +判定的依据是 `std::is_nothrow_move_constructible_v`。翻译一下就是——你给类型写了 move 构造,却没标 `noexcept`,`vector` 扩容时就会不放心,宁可走更慢的 copy。为啥?copy 失败了旧缓冲还在,能回滚;move 失败了源元素可能已经被掏空,回天乏术。所以笔者的忠告很朴素:能加 `noexcept` 的 move 构造,一定加上,它在 `vector` 里直接决定了扩容是"搬家"还是"抄家"。标准库为此专门备了个 `std::move_if_noexcept` 工具,不过它真正的舞台,也就是容器内部这种"看异常安全性在 move/copy 之间二选一"的活儿。 + +## C++20 给 vector 开的两扇新门 + +### 一扇叫 constexpr vector + +C++20 终于让 `vector` 能在编译期用了。这背后是两个提案接力:**P0784R7**「More constexpr containers」先把机制铺好——`constexpr` 的 `new`/`delete`、`std::construct_at`/`std::destroy_at`,外加一个叫 *transient constexpr allocation* 的模型;**P1004R2**「Making std::vector constexpr」再在这机制之上,把 `vector`(顺手也把 `string`)的成员函数逐个标成 `constexpr`。想探测支持,看 `__cpp_lib_constexpr_vector` 这个特性宏就行。 + +这里有个**必须掰扯清楚**的限制:transient allocation 模型要求:*常量求值期间分配出来的内存,必须在同一次常量求值结束之前释放掉*,否则程序直接 ill-formed。说人话就是——你没法定义一个持久的 `constexpr std::vector` 变量,把它装着堆对象的缓冲"带出"编译期。那编译期到底怎么用 `vector`?正确姿势是:在一个 `constexpr` 函数里临时把它造出来、做一通操作、最后**只返回一个标量结果**(元素和、元素个数、某个元素值都行),让缓冲在函数返回前自己析构掉。这恰恰合了嵌入式和查表场景的胃口——编译期拿 `vector` 当临时工作区算出一个常量,再把结果搬进 `std::array` 或 `constexpr` 变量里,运行时初始化全省了。 + +### 另一扇叫 erase / erase_if + +老 C++ 里想从 `vector` 中删掉所有满足条件的元素,得手写那个著名的 erase-remove 惯用法:`v.erase(std::remove_if(v.begin(), v.end(), pred), v.end());`。又长又容易写错——第二个参数的 `v.end()` 忘了、外层 `erase` 忘了套,都是笔者见过的事故现场。C++20 用一对自由函数把它收编了:`std::erase(v, value)` 删所有等于 `value` 的,`std::erase_if(v, pred)` 删所有满足谓词的,返回值都是被删掉的元素个数。 + +这对函数来自提案 **P1209R0**,标题就叫「Adopt Consistent Container Erasure from Library Fundamentals 2 for C++20」——光看标题您就明白它的初衷了:把原本待在 Library Fundamentals TS 里的统一擦除 API,正式落地到 C++20。cppreference 上对它俩有一句很干脆的定义性描述:它们 *"erase all elements that compare equal to value / satisfy the predicate from the container"*,替掉的就是那个易错的 erase-remove。有个细节别记岔:序列容器(`vector`、`deque`、`list`、`forward_list`、`string`)同时拿到 `erase` 和 `erase_if`,而关联/无序关联容器只有 `erase_if`——因为它们的成员 `erase(key)` 早就在干"按键删"的活了,再塞一个 `erase(c, value)` 进来会语义打架。探测支持看 `__cpp_lib_erase_if`(C++20,值 `202002`)。 + +------ + +## 上手跑一跑 + +光说不练假把式,下面这几段都标了平台和标准,能单独编译。我们把前面的概念挨个跑一遍。 + +头一个,观察扩容。每次容量变了就打印一行,您能直观看到自家这把到底是 2× 还是 1.5×。 + +```cpp +// Standard: C++17 | Platform: host +#include +#include + +void trace_growth(std::vector& v, int value) +{ + std::size_t cap_before = v.capacity(); + v.push_back(value); + if (v.capacity() != cap_before) { + std::cout << "push " << value << ": size=" << v.size() + << " capacity " << cap_before << " -> " << v.capacity() << '\n'; + } +} + +int main() +{ + std::vector v; + for (int i = 0; i < 17; ++i) { + trace_growth(v, i); + } + return 0; +} +``` + +第二个,迭代器失效的两种情形摆一块对比。`push_back` 在还有余量时不失效,一触发扩容就全失效;`reserve` 一旦超过当前容量,必然换缓冲。 + +```cpp +// Standard: C++17 | Platform: host +#include +#include + +int main() +{ + std::vector v{1, 2, 3}; + v.reserve(3); // 预留:当前已有 3,不触发扩容 + + const int* p = &v[1]; + v.push_back(4); // 还有 1 个余量,不扩容 + std::cout << "no realloc, p valid? " << (p == &v[1]) << '\n'; // 1 + + v.reserve(100); // 超过 capacity,必然换缓冲 + std::cout << "after reserve, p valid? " << (p == &v[1]) << '\n'; // 0,已失效 + return 0; +} +``` + +第三个,`move_if_noexcept`。给一个 move 构造标了 `noexcept` 的类型,扩容时走 move;没标的,退回 copy。 + +```cpp +// Standard: C++17 | Platform: host +#include +#include + +class Tracked { +public: + int id; + static int move_count; + static int copy_count; + + explicit Tracked(int i) : id(i) {} + Tracked(const Tracked& o) : id(o.id) { ++copy_count; } + // 故意不标 noexcept:扩容时不放心,退回 copy + Tracked(Tracked&& o) noexcept(false) : id(o.id) { ++move_count; } +}; +int Tracked::move_count = 0; +int Tracked::copy_count = 0; + +int main() +{ + std::vector v; + v.reserve(2); + v.emplace_back(1); + v.emplace_back(2); + v.emplace_back(3); // 触发扩容 + + std::cout << "moves=" << Tracked::move_count + << " copies=" << Tracked::copy_count << '\n'; + // 未标 noexcept 时多半走 copy;把 noexcept(false) 改成 noexcept 再跑,会变成 move + return 0; +} +``` + +第四个,`constexpr vector`。编译期拿它当临时工作区,只把标量结果带出来。 + +```cpp +// Standard: C++20 | Platform: host +#include + +constexpr int sum_first_n(int n) +{ + std::vector v; + for (int i = 0; i < n; ++i) { + v.push_back(i + 1); // 常量求值期分配,函数返回前必须释放 + } + int sum = 0; + for (int x : v) { + sum += x; + } + return sum; // 只返回标量,缓冲在函数内自然析构 +} + +static_assert(sum_first_n(100) == 5050); // 全程编译期完成 + +int main() { return 0; } +``` + +第五个,`erase_if`,一行干掉 erase-remove。 + +```cpp +// Standard: C++20 | Platform: host +#include +#include + +int main() +{ + std::vector v{1, 2, 3, 4, 5, 6}; + std::size_t removed = std::erase_if(v, [](int x) { return x % 2 == 0; }); + std::cout << "removed " << removed << ", left:"; + for (int x : v) { + std::cout << ' ' << x; + } + std::cout << '\n'; // removed 3, left: 1 3 5 + return 0; +} +``` + +当然,也可以点点这个看看现象! + + + +------ + +## 临了收几句 + +把前面这些拼回工程实践,笔者常嘱咐的就那么几条。一是**能预估规模就 `reserve`**——构造完 `vector` 立马按已知或估摸的最终大小 `reserve` 一下,把好几次扩容压成一次分配,热路径上立竿见影。二是**删元素用 `erase_if`**,别再手写 erase-remove 了,又短又不容易漏掉那个 `v.end()`。三是**编译期算表,拿 `vector` 当临时区**,算完只把标量结果交给 `static_assert` 或者塞进 `constexpr` 变量,舒舒服服享受 transient allocation 给的编译期动态能力,又不越界。 + +最后给各位留个印象:`vector` 的本体约等于三个指针 `{begin, end, end_of_storage}`,`size`/`capacity` 都是从它们算出来的;`push_back` 是摊还常数不是常数,增长倍数标准没规定(libstdc++/libc++ 用 2×、MSVC 用 1.5×);失效的规矩就一张表——扩容型操作"触发才全失效",`erase` 是"被删及之后失效",`swap` 压根不失效;扩容时元素 move 不 move,看 move 构造有没有标 `noexcept`;C++20 让 `vector` 能 `constexpr`(P0784R7 + P1004R2),但受 transient allocation 限制只能当编译期临时区;同一年 `erase`/`erase_if`(P1209R0)替你干掉了 erase-remove。把这些揣兜里,`vector` 的坑基本就踩不到了。 + +------ + +## 参考资源 + +- [std::vector — cppreference](https://en.cppreference.com/w/cpp/container/vector) +- [vector::capacity — cppreference](https://en.cppreference.com/w/cpp/container/vector/capacity) +- [vector::push_back — cppreference](https://en.cppreference.com/w/cpp/container/vector/push_back) +- [std::erase / std::erase_if (vector) — cppreference](https://en.cppreference.com/w/cpp/container/vector/erase2) +- [vector.capacity — eel.is/c++draft](https://eel.is/c++draft/vector.capacity) · [sequence.reqmts — eel.is/c++draft](https://eel.is/c++draft/sequence.reqmts) +- [P0784R7 More constexpr containers](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p0784r7.html) +- [P1004R2 Making std::vector constexpr](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1004r2.pdf) +- [P1209R0 Adopt Consistent Container Erasure from Library Fundamentals 2 for C++20](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1209r0.html) diff --git a/documents/vol3-standard-library/02-string-memory-deep-dive.md b/documents/vol3-standard-library/02-string-memory-deep-dive.md new file mode 100644 index 000000000..fa2b7bed9 --- /dev/null +++ b/documents/vol3-standard-library/02-string-memory-deep-dive.md @@ -0,0 +1,164 @@ +--- +chapter: 7 +cpp_standard: +- 11 +- 14 +- 17 +- 23 +description: "讲透 std::string 的 SSO 与 COW 历史纠葛、C++11 为何禁止 COW、SSO 阈值实现细节,以及 C++23 resize_and_overwrite 的缓冲复用" +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- '卷一:std::string 基础用法' +reading_time_minutes: 14 +tags: +- host +- cpp-modern +- intermediate +- 内存管理 +title: "string 深入:SSO、COW 与 resize_and_overwrite" +--- + +# string 深入:SSO、COW 与 resize_and_overwrite + +`std::string` 大概是标准库里被使唤得最多、却被理解得最浅的类型了。各位随手 `std::string s = "hello";` 写得开开心心,可一旦被人追问——"为什么 `sizeof(std::string)` 在我这机器上是 32?""为什么老代码里两个 string 居然共享同一份缓冲?""C++23 那个 `resize_and_overwrite` 到底省了啥?"——多半就答不上来了。这些问题,根全在 `string` 的内存模型和它的陈年历史里。 + +这一篇,笔者就专门跟各位聊 `string` 的内存与缓冲这条主线:SSO 跟 COW 的历史纠葛、SSO 的实现阈值、还有 C++23 给我们送来的缓冲复用 API `resize_and_overwrite`。(C++20 的 `char8_t` 是另一个独立主题,见卷三 [char8_t 与 UTF-8 字符串](./03-char8-t-utf8)。) + +------ + +## SSO 和 COW:一段 ABI 的陈年旧事 + +要弄明白今天的 `string` 为啥长这样,得先把时钟拨回 C++03。那会儿有种特别诱人的实现路子——**写时复制(Copy-On-Write,COW)**:你写 `string b = a;` 的时候,它压根不真拷字符,而是让 `b` 跟 `a` 共享同一份只读缓冲,只额外维护一个引用计数;非得等到某一方要写入了,才真去深拷贝一份出来。这在大量拷贝只读串的场景里,能省下一大笔内存和时间,早期的 libstdc++(GCC 的 C++ 标准库)就是 COW 的铁杆拥趸。 + +```mermaid +flowchart LR + subgraph COW["COW(旧 libstdc++)"] + direction LR + RC["refcount 引用计数"] --- BUF["共享只读缓冲(堆)"] + SA["string A"] --> BUF + SB["string B"] --> BUF + end + subgraph SSO["SSO(现代实现)"] + direction LR + OBJ["string 对象
sizeof ≈ 32"] --> STORE["内联缓冲(短串)
或 堆 + size + cap"] + end +``` + +可 C++11 一纸标准,就把 COW 判了"违规"。提案 **N2668**「Concurrency Modifications to Basic String」改写了 `[string.require]` 的失效规矩和 `data()`/`c_str()` 的语义,原文里有一句说得斩钉截铁——*"This change effectively disallows copy-on-write implementations."* 那法理上的根因到底是个啥?笔者得提醒一句,很多人以为是"线程安全"或者"`noexcept`",错,那俩顶多算放大矛盾的旁支,真正的判据是底下这三条叠一块儿: + +- **失效规矩**:`[string.require]` 规定,调 `operator[]`、`at`、`front`、`back`、`begin/end` 这些元素访问,还有 `data()` 本身,都不能让已有的引用和迭代器失效。 +- **`data()`/`c_str()` 的连续 null 结尾**:它俩必须返回指向本对象缓冲的、连续且 null 结尾的数组。 +- **非 const 访问要给可写指针**:你一旦 `s[0]` 或者 `s.data()` 拿到的是非 const,COW 就被迫在共享缓冲上 *unshare*(深拷贝),才能塞给你一个独占的、连续的、可写的指针。 + +```mermaid +flowchart TD + A["非 const operator[] / data()"] --> B{"COW 共享缓冲?"} + B -- "是" --> C["必须 unshare(深拷贝)
才能给可写/连续指针"] + C --> D["要么失效既有引用
要么变 O(n)"] + D --> E["违反 [string.require] 失效规则
⇒ C++11 起 non-conforming"] + B -- "否(SSO)" --> F["直接返回本对象缓冲
不失效 · O(1) ✓"] +``` + +您瞧,COW 想同时把"共享""不失效引用""O(1)""连续 null 结尾"全搂进怀里,那是自相矛盾的。标准果断选了后三个,COW 自然就成了 non-conforming。落到现实里更是一波三折:libstdc++ 因为 ABI 兼容的包袱,硬是拖到 **GCC 5(2015)** 才通过 `_GLIBCXX_USE_CXX11_ABI` 这个开关,切到非 COW 实现(新的内联符号叫 `std::__cxx11::basic_string`);而 libc++ 和 MSVC 那套 Dinkumware 实现,打一开始就是 SSO,压根没这段历史债。 + +## SSO 的阈值:sizeof 凭什么是 32 + +COW 退场之后,主流实现齐刷刷转向了 **SSO(Small String Optimization,小字符串优化)**:在 `string` 对象里头预留一小段内联缓冲,短到能塞进这段缓冲的串,就不去堆上分配,直接存在对象自己身上。这同时也回答了"为什么 `sizeof(std::string)` 是 32"——对象得同时装得下内联缓冲、堆指针、size 和 capacity 这些字段,主流实现就把它们一股脑塞进了约 32 字节。 + +笔者得提一句:SSO 的阈值,是**实现细节,标准从不规定**(属于 QoI,质量实现细节)。在主流实现里,libstdc++、libc++、MSVC STL 的阈值大约都在 15 字节上下(libc++ 还另有一种 22 字节的布局变体)。这些数字不是承诺,跨实现、跨版本都可能变,所以——笔者把话放这儿——**别在代码里把阈值当成硬性假设来用**,今天 15、明天换个编译器可能就不是了。 + +## resize_and_overwrite:C++23 终于让你拿 string 当缓冲使了 + +C++23 给 `string` 塞了个相当趁手的成员——`resize_and_overwrite`,提案是 **P1072R10**「basic_string::resize_and_overwrite」。它最典型的用法是:把 `string` 当成一块可写缓冲,去对接那种"写一部分、再告诉你写了多少"的 C API(`read`、`fread`、`getenv` 这一挂的)。 + +签名长这样:`template constexpr void resize_and_overwrite(size_type count, Operation op);`。它先把字符串容量扩到至少 `count`,然后把一个指针 `p`(指向连续存储的首字符)和那个 `count` 一块儿交给回调 `op`,由 `op` 就地把实际内容写进去,再**返回一个整数 r 当作新的长度**(要求 `r ∈ [0, count]`)。好处在哪?跟 `resize(count)` 不同,它**不会**把新增那一段值初始化(清零),省掉一笔多余的写;你只在回调里写真正需要的字节,然后报个实际长度就完事。 + +自由是有代价的,`resize_and_overwrite` 有几条 UB 红线,各位得盯紧:`op` 必须返回落在 `[0, count]` 里的整数,越界就是未定义行为;`op` 抛异常是 UB(所以 `op` 通常标 `noexcept`);`op` 不能去改 `p` 或 `count` 这俩参数本身;最后保留区间 `[p, p+r)` 里每个字符,都得是 `op` 亲手写下的确定值,不能留不确定值。还有个容易忽视的——不管这次调用有没有触发 reallocation,它都会把所有迭代器、指针、引用全失效掉。探测支持看 `__cpp_lib_string_resize_and_overwrite`(C++23,值 `202110L`)。 + +------ + +## 上手跑一跑 + +先看 SSO。把 `sizeof(std::string)` 打出来,再瞅瞅短串和长串的 `data()` 地址,到底落没落在对象里头。 + +```cpp +// Standard: C++17 | Platform: host +#include +#include + +bool points_inside_object(const std::string& s) +{ + const char* obj = reinterpret_cast(&s); + return s.data() >= obj && s.data() < obj + sizeof(std::string); +} + +int main() +{ + std::cout << "sizeof(std::string) = " << sizeof(std::string) << '\n'; + + std::string short_s = "hi"; // 很可能走 SSO + std::string long_s(64, 'x'); // 超过 SSO 阈值,出堆 + + std::cout << "short_s.data() in object? " << points_inside_object(short_s) << '\n'; // 多半是 1 + std::cout << "long_s.data() in object? " << points_inside_object(long_s) << '\n'; // 多半是 0 + return 0; +} +``` + +再看 `resize_and_overwrite` 跟老写法 `resize()` 的对比。笔者这里造了个"模拟 C API"——往缓冲里写死内容、返回实际写入字节数,好让两种写法的差别一目了然。 + +```cpp +// Standard: C++23 | Platform: host +#include +#include +#include +#include + +// 模拟一个 C API:向 buf 最多写 n 字节,返回实际写入数 +std::size_t fake_read(char* buf, std::size_t n) +{ + static const char msg[] = "hello"; + std::size_t len = std::min(n, sizeof(msg) - 1); + std::memcpy(buf, msg, len); + return len; +} + +int main() +{ + // 旧写法:resize(64) 先把 64 个字符全部值初始化(清零),再被覆盖 + std::string old_buf; + old_buf.resize(64); + std::size_t got = fake_read(old_buf.data(), old_buf.size()); + old_buf.resize(got); // 再截回实际长度 + std::cout << "old: '" << old_buf << "' (len=" << old_buf.size() << ")\n"; + + // C++23:resize_and_overwrite 不清零多余字符,回调报告实际长度 + std::string buf; + buf.resize_and_overwrite(64, [](char* p, std::size_t n) noexcept { + return fake_read(p, n); // 只写实际字节,返回新长度 + }); + std::cout << "new: '" << buf << "' (len=" << buf.size() << ")\n"; + return 0; +} +``` + + + +------ + +## 参考资源 + +- [std::basic_string — cppreference](https://en.cppreference.com/w/cpp/string/basic_string) +- [basic_string::data — cppreference](https://en.cppreference.com/w/cpp/string/basic_string/data) +- [basic_string::resize_and_overwrite — cppreference](https://en.cppreference.com/w/cpp/string/basic_string/resize_and_overwrite) +- [N2668 Concurrency Modifications to Basic String](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2008/n2668.htm) +- [P1072R10 basic_string::resize_and_overwrite](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p1072r10.html) diff --git a/documents/vol3-standard-library/03-char8-t-utf8.md b/documents/vol3-standard-library/03-char8-t-utf8.md new file mode 100644 index 000000000..798b015ba --- /dev/null +++ b/documents/vol3-standard-library/03-char8-t-utf8.md @@ -0,0 +1,119 @@ +--- +chapter: 7 +cpp_standard: +- 20 +- 23 +description: "讲透 C++20 char8_t 的引入动机、u8 字面量类型变更的两个坑与迁移写法,以及 C++23 P2513 对数组初始化的放宽" +difficulty: intermediate +order: 3 +platform: host +prerequisites: +- '卷一:std::string 与字符串字面量基础' +reading_time_minutes: 12 +tags: +- host +- cpp-modern +- intermediate +- 类型安全 +title: "char8_t 与 UTF-8 字符串" +--- + +# char8_t 与 UTF-8 字符串 + +在 C++20 之前,UTF-8 字符串字面量 `u8"..."` 的类型是 `const char[N]`——跟普通字符串在类型上压根没区别。这听着好像无所谓,其实是不少坑的老巢:你没法在类型层面分清"这一串是 UTF-8"还是"这一串是本地执行字符集",编译器也帮不上你挡住那种把 UTF-8 当裸字节乱打印的错。C++20 引入 `char8_t`,就是要把 UTF-8 从 `char` 那片模糊地带里独立出来,给它一个专属类型,让类型系统替咱们把关。这改动来自提案 **P0482R6**「char8_t: A type for UTF-8 characters and strings」,探测支持看 `__cpp_char8_t`(C++20,值 `201811L`)。 + +不过——笔者得提前打个预防针——这个"独立类型"的改动是**带破坏性**的:它一把改了 `u8` 字面量的类型,于是一大批在 C++17 下岁月静好的老代码,升到 C++20 直接就编不过了。这一篇,我们就把这俩最常踩的坑、怎么搬代码、还有 C++23 后来补的那一刀,一次讲清楚。 + +------ + +## u8 字面量,类型整个换了灵魂 + +C++20 起,UTF-8 字符串字面量 `u8"..."` 的类型,从 `const char[N]` 变成了 `const char8_t[N]`;UTF-8 字符字面量 `u8'c'` 的类型,也从 `char` 变成了 `char8_t`。这个 `char8_t` 是个**独立的 fundamental 类型**,底层类型(underlying type)是 `unsigned char`,大小、对齐、转换秩都跟 `unsigned char` 一致——但它**不参与别名规则**(不是 [basic.lval] 里允许别名访问的那几个类型之一),也就是说,你不能拿 `char8_t*` 去合法地别名访问别的对象内存。 + +至于为啥要这么较真地单造一个类型?道理很简单:类型一旦分开,编译器就能在"把 UTF-8 串误当本地编码 `char` 串使""把 `char8_t` 当整数打印"这类错误上直接报错,而不是等运行时输出一屏乱码才让你拍大腿。拿类型安全换一点点迁移成本,这笔账 C++20 觉得值。 + +## 两个最经典的坑 + +类型一换,两个迁移坑就浮上水面了。 + +**头一个坑:`u8""` 不能再隐式转 `const char*`。** C++17 里,`const char* p = u8"text";` 完全合法(那会儿 `char` 跟 `char8_t` 还是一家人);到了 C++20,`u8"text"` 成了 `const char8_t[N]`,而 `char8_t` 不会隐式转 `char`,这行直接 ill-formed。所有把 `u8` 字面量塞给期望 `const char*` 的旧接口(构造 `std::string`、传给 C API、`std::filesystem::u8path` 的某些重载等等)统统中招。 + +**第二个坑:标准库故意 `=delete` 了 `char8_t` 的 ostream 重载。** 您可能想——那我直接 `std::cout << u8"text";` 打呗?也不行。C++20 起,标准库对 `char8_t`、`const char8_t*` 这类 UTF-8 字符/字符串,在 `basic_ostream` 和 `basic_ostream` 上的 `operator<<` 重载,是**显式删除**的(注意,不是"忘了实现",是故意的)。于是 `std::cout << u8'z'`、`std::cout << u8"text"` 都会因为命中 deleted 重载而编译失败。这么干,就是为了拦住历史代码把 UTF-8 数据当整数或指针胡乱打印出来。 + +## 老代码怎么搬过来 + +碰上这俩坑,怎么把 C++17 的老代码挪到 C++20?几条路,笔者按代价从低到高给您捋: + +```mermaid +flowchart TD + Q["要传给 const char* 旧 API?"] -- "是" --> OPT1{"能改编译选项?"} + OPT1 -- "能" --> A["-fno-char8_t / /Zc:char8_t-
让 u8 回退为 char"] + OPT1 -- "不能" --> B["显式逐字节转换
reinterpret_cast 到 const char*"] + Q -- "否(新代码)" --> C["std::u8string / u8string_view
+ 自定义 operator<<"] +``` + +最省事的,是**编译选项回退**:GCC/Clang 上加 `-fno-char8_t`、MSVC 上加 `/Zc:char8_t-`,把 `u8` 字面量的类型退回 C++17 的 `char` 语义,老代码立马又能编。这只是过渡期的权宜之计,新代码别长期依赖它。其次,是**显式逐字节转换**:当你确实要喂给一个只认 `const char*` 的接口、且心里有数内容就是 UTF-8 字节时,用 `reinterpret_cast(u8"text")`(或者 C 风格转换)切个视角——字节内容不变,只是换个指针类型,把"头一个坑"绕过去。最"政治正确"的,是**走 `std::u8string` 路线**:用 `u8string`/`u8string_view` 类型安全地持有 UTF-8 文本,要打印时再写个小小的 `operator<<` 把它转出去,把类型安全贯彻到底。 + +## C++23 的 P2513:又补回来一点 + +"头一个坑"里"不能初始化"的范围,后来倒是被收窄了一点点。提案 **P2513R4**「char8_t Compatibility and Portability」作为 C++20 的缺陷报告(DR),在 C++23 落地(`__cpp_char8_t` 的值也跟着改成 `202207L`),**重新允许用 `u8` 字符串字面量去初始化 `char` 或 `unsigned char` 数组**——也就是 `char ca[] = u8"text";` 这种又变回合法了。但请注意,它只放宽了"数组初始化"这一条;`const char8_t*` 到 `const char*` 的指针隐式转换,**至今仍然 ill-formed**,坑一里那个指针赋值的情形,可没被放过。 + +------ + +## 上手跑一跑 + +下面这个 demo,把两个坑(笔者用注释"封印"起来,您取消注释立马编译失败)和两种正确写法摆一块儿,方便对照。 + +```cpp +// Standard: C++20 | Platform: host +#include +#include + +// —— 坑一(取消注释会编译失败):u8"" 不再隐式转 const char* —— +// const char* p = u8"text"; // ill-formed since C++20 + +// —— 坑二(取消注释会编译失败):ostream 显式 =delete 了 char8_t 重载 —— +// std::cout << u8"text"; // ill-formed since C++20 +// std::cout << u8'z'; // ill-formed since C++20 + +// 正确写法之一:显式逐字节转换(内容不变,仅切换指针类型视角) +void print_as_char(const char* s) +{ + std::cout << s << '\n'; +} + +// 正确写法之二:用 std::u8string 类型安全地持有 UTF-8,并自定义打印 +std::ostream& operator<<(std::ostream& os, const std::u8string& s) +{ + return os << reinterpret_cast(s.data()); +} + +int main() +{ + // 路线 A:把 u8 字面量当 const char* 用(适合喂给只认窄字符的旧接口) + print_as_char(reinterpret_cast(u8"text")); + + // 路线 B:u8string 全程保持 UTF-8 类型,打印时再转 + std::u8string u8s = u8"UTF-8 text"; + std::cout << u8s << '\n'; + return 0; +} +``` + + + +------ + +## 参考资源 + +- [char8_t — cppreference](https://en.cppreference.com/w/cpp/keyword/char8_t) +- [String literal — cppreference](https://en.cppreference.com/w/cpp/language/string_literal) +- [operator<<(basic_ostream) — cppreference](https://en.cppreference.com/w/cpp/io/basic_ostream/operator_ltlt2) +- [P0482R6 char8_t: A type for UTF-8 characters and strings](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0482r6.html) +- [P2513R4 char8_t Compatibility and Portability](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2513r4.html) diff --git a/documents/vol3-standard-library/index.md b/documents/vol3-standard-library/index.md index 143187656..3ea6a5d10 100644 --- a/documents/vol3-standard-library/index.md +++ b/documents/vol3-standard-library/index.md @@ -10,18 +10,24 @@ tags: # 卷三:标准库深入 -> 状态:部分内容已有(待重写) - ## 概述 -本卷深入讲解 C++ 标准库。 +本卷深入讲解 C++ 标准库,聚焦容器与字符串的实现层细节。 + + + vector 深入 + string 深入 + char8_t 与 UTF-8 + + +## 待重写文章 -## 现有文章(待重写为通用内容) +以下为早期内容,计划在重写后并入正文章节序列。 - 初始化列表 - 对象大小与平凡类型 - array - span - 自定义分配器 + array(待重写) + 初始化列表(待重写) + span(待重写) + 对象大小与平凡类型(待重写) + 自定义分配器(待重写) diff --git a/site/.vitepress/config/index.ts b/site/.vitepress/config/index.ts index 36c9bee26..a6f096325 100644 --- a/site/.vitepress/config/index.ts +++ b/site/.vitepress/config/index.ts @@ -5,9 +5,38 @@ import { buildSidebar } from './sidebar' import { kbdPlugin } from '../plugins/kbd-plugin' import { cppTemplateEscapePlugin } from '../plugins/escape-cpp-templates' import { mermaidPlugin } from '../plugins/mermaid-plugin' +import { createReadStream, existsSync } from 'node:fs' +import { join, normalize } from 'node:path' +import { fileURLToPath } from 'node:url' + +// dev 模式下把 code/examples 作为静态资源服务,让 OnlineCompilerDemo 等组件在 dev 下也能 fetch 到源码。 +// build 时由 scripts/build.ts 末尾 copy 进 dist,故这里只需覆盖 dev;SITE_BASE 须与下方 base 保持一致。 +const PROJECT_ROOT = fileURLToPath(new URL('../../../', import.meta.url)) +const EXAMPLES_ROOT = normalize(join(PROJECT_ROOT, 'code', 'examples')) +const SITE_BASE = '/Tutorial_AwesomeModernCPP/' + +function serveCodeExamplesInDev() { + return { + name: 'serve-code-examples-in-dev', + apply: 'serve' as const, + configureServer(server: { middlewares: { use: (m: any) => void } }) { + server.middlewares.use((req: any, res: any, next: any) => { + const url = decodeURIComponent(String(req.url ?? '').split('?')[0]) + const rel = url.startsWith(SITE_BASE) ? url.slice(SITE_BASE.length) : url + if (!rel.startsWith('code/examples/')) return next() + // 规范化后必须仍落在 code/examples 内,防止路径穿越 + const filePath = normalize(join(EXAMPLES_ROOT, rel.slice('code/examples/'.length))) + if (!filePath.startsWith(EXAMPLES_ROOT) || !existsSync(filePath)) return next() + res.setHeader('Content-Type', 'text/plain; charset=utf-8') + createReadStream(filePath).pipe(res) + }) + }, + } +} export default withDrawio(defineConfig({ vite: { + plugins: [serveCodeExamplesInDev()], ssr: { external: ['mermaid'], }, diff --git a/site/.vitepress/theme/custom.css b/site/.vitepress/theme/custom.css index 35933198e..ad6ecd914 100644 --- a/site/.vitepress/theme/custom.css +++ b/site/.vitepress/theme/custom.css @@ -824,6 +824,18 @@ height: auto; } +/* 节点/边标签:给足内边距与行高,避免 CJK 多行文字被节点框裁剪 */ +.mermaid-diagram .nodeLabel, +.mermaid-diagram .edgeLabel { + padding: 0.15em 0.35em; + line-height: 1.45; +} + +/* foreignObject 默认裁剪溢出内容;放开让标签自适应,不遮挡 */ +.mermaid-diagram foreignObject { + overflow: visible; +} + .mermaid-error { padding: 12px; border: 1px solid var(--vp-c-danger-2); diff --git a/site/.vitepress/theme/mermaid-client.ts b/site/.vitepress/theme/mermaid-client.ts index 780a1a634..b1f63bc55 100644 --- a/site/.vitepress/theme/mermaid-client.ts +++ b/site/.vitepress/theme/mermaid-client.ts @@ -50,6 +50,15 @@ function initMermaid() { startOnLoad: false, securityLevel: 'loose', theme: 'default', + flowchart: { + htmlLabels: true, + nodeSpacing: 50, + rankSpacing: 50, + padding: 15, + }, + themeVariables: { + fontSize: '15px', + }, }) window.__mermaidInitialized__ = true } From c4c4743e33f61d774a52f4dfc08c779e0745e8b9 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 12 Jun 2026 10:59:19 +0800 Subject: [PATCH 2/2] fix: ci issue --- documents/vol3-standard-library/02-string-memory-deep-dive.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documents/vol3-standard-library/02-string-memory-deep-dive.md b/documents/vol3-standard-library/02-string-memory-deep-dive.md index fa2b7bed9..f95833f4d 100644 --- a/documents/vol3-standard-library/02-string-memory-deep-dive.md +++ b/documents/vol3-standard-library/02-string-memory-deep-dive.md @@ -24,7 +24,7 @@ title: "string 深入:SSO、COW 与 resize_and_overwrite" `std::string` 大概是标准库里被使唤得最多、却被理解得最浅的类型了。各位随手 `std::string s = "hello";` 写得开开心心,可一旦被人追问——"为什么 `sizeof(std::string)` 在我这机器上是 32?""为什么老代码里两个 string 居然共享同一份缓冲?""C++23 那个 `resize_and_overwrite` 到底省了啥?"——多半就答不上来了。这些问题,根全在 `string` 的内存模型和它的陈年历史里。 -这一篇,笔者就专门跟各位聊 `string` 的内存与缓冲这条主线:SSO 跟 COW 的历史纠葛、SSO 的实现阈值、还有 C++23 给我们送来的缓冲复用 API `resize_and_overwrite`。(C++20 的 `char8_t` 是另一个独立主题,见卷三 [char8_t 与 UTF-8 字符串](./03-char8-t-utf8)。) +这一篇,笔者就专门跟各位聊 `string` 的内存与缓冲这条主线:SSO 跟 COW 的历史纠葛、SSO 的实现阈值、还有 C++23 给我们送来的缓冲复用 API `resize_and_overwrite`。(C++20 的 `char8_t` 是另一个独立主题,见卷三 [char8_t 与 UTF-8 字符串](./03-char8-t-utf8.md)。) ------