CowStr
主线位置:行内主线的产物。第一遍和第二遍里凡是要交给事件的字符串,都先做成一个
CowStr,存进Allocations或直接放进Event。这一页讲它的三种形态、为什么是 24 字节、解析过程中哪些地方真的会往堆上分配。
flowchart TB
s["一段要交出去的字符串"] --> q1{"原样在输入里"}
q1 -->|是| bor["Borrowed"]
q1 -->|否| q2{"怎么来的"}
q2 -->|"一个字符"| inl["Inlined"]
q2 -->|"实体静态表"| st["Borrowed static"]
q2 -->|"拼出来的 String"| box["Boxed"]
box -->|"clone 且不超过 22"| inl
bor -->|"into_static"| two["Inlined 或 Boxed"]
strings.rs 一共 432 行,其中约 150 行是测试。实验在 exp-pulldown-cmark/events-exp(M2 Max、macOS 27、rustc 1.99.0,release)。
三个变体
/// A copy-on-write string that can be owned, borrowed
/// or inlined.
///
/// It is three words long.
#[derive(Debug, Eq)]
pub enum CowStr<'a> {
/// An owned, immutable string.
Boxed(Box<str>),
/// A borrowed string.
Borrowed(&'a str),
/// A short inline string.
Inlined(InlineStr),
}
CowStr的Borrowed是输入的切片,不复制。Boxed用Box<str>而不是String:不可变,不需要容量字段,两个字长。Inlined是InlineStr,一个定长字节数组加一个长度字节,字符串直接放在枚举里。
数组有多长由 MAX_INLINE_STR_LEN 决定:
const MAX_INLINE_STR_LEN: usize = 3 * std::mem::size_of::<isize>() - 2;
64 位上是 22,32 位上按公式是 10。实验 sizes 在本机上打印:
CowStr<'static> size 24 align 8
InlineStr size 23 align 1
Option<CowStr<'static>> size 24 align 8
Box<str> size 16 align 8
&'static str size 16 align 8
String size 24 align 8
std::borrow::Cow<'static, str> size 24 align 8
账是这样算的:Box<str> 和 &str 都是指针加长度,16 字节;InlineStr 是 22 字节数组加 1 字节长度,23 字节,对齐 1;三者里最大的是 23,再加 1 字节判别值,正好 24,也就是三个字长。减 2 的那 2 个字节,一个给长度,一个给判别值。Option<CowStr> 也是 24 字节:判别字节只用了 3 个取值,None 借用剩下的取值,不用再加字段。strings.rs 里的单元测试 cowstr_size 断言 3 * word_size,inlinestr_fits_twentytwo 和 inlinestr_not_fits_twentythree 卡住 22 这条线。
InlineStr 最早只有两个字长:常量叫 DOUBLE_WORD_SIZE,长度存在数组的最后一个字节,条件是 len < DOUBLE_WORD_SIZE,最多放 15 字节。那时 cowstr_size 测试已经在断言整个 CowStr 是三个字长,也就是说第三个字长里有 8 字节白白空着。提交 bc78bdd(「Increase maximum inline size of CowStr」,随 PR #274「Smaller tree」合入,首次发布于 0.5.0)把常量改成 MAX_INLINE_STR_LEN = 3 * size_of::<isize>() - 1,长度仍放在最后一个字节,能放 22 字节,枚举大小不变。三年后的提交 9bfba94(「Make InlineStr's len its own field」,提交说明只有「Fixes #581」,直接进了主分支,首次发布于 0.9.2)把长度挪成独立的 len: u8 字段,常量相应改成 - 2、条件改成 <=,容量仍是 22,测试 max_inline_str_len_atleast_five 改名为 max_inline_str_len_atleast_four。
比较、哈希与取值
PartialEq、Hash、Borrow<str> 都按内容算,不看变体:Borrowed("a") == Inlined("a") 为真,CowStr 可以直接当 HashMap 的键用 &str 查(RefDefs 的标签就是这么查的,见链接与图片)。
Deref<Target = str> 让三种形态都能当 &str 用。Inlined 那一支每次都要重新校验 UTF-8:
impl Deref for InlineStr {
type Target = str;
fn deref(&self) -> &str {
let len = self.len as usize;
from_utf8(&self.inner[..len]).unwrap()
}
}
最多 22 字节,代价很小;但上游的 guide(guide/src/dev/string-handling.md)说「Input validation occurs when creating InlineStr」,和代码不符:创建时 TryFrom<&str> 拿到的本来就是合法的 &str,真正的校验发生在每一次 deref。不开 simd feature 时整个 crate 是 forbid(unsafe_code)(lib.rs 开头),这里也就没有用 from_utf8_unchecked。
clone 和 into_static 会换形态
Clone for CowStr 是手写的:
impl<'a> std::clone::Clone for CowStr<'a> {
fn clone(&self) -> Self {
match self {
CowStr::Boxed(s) => match InlineStr::try_from(&**s) {
Ok(inline) => CowStr::Inlined(inline),
Err(..) => CowStr::Boxed(s.clone()),
},
CowStr::Borrowed(s) => CowStr::Borrowed(s),
CowStr::Inlined(s) => CowStr::Inlined(*s),
}
}
}
短的 Boxed 克隆出来是 Inlined,不分配(提交 6ec2bcf「Convert short heap allocated SmortStr to stack on clone」,首次发布于 0.3.0,SmortStr 是 CowStr 当时的名字)。解析器自己就靠这一点:同一个链接引用定义被引用多次时,fetch_link_type_url_title 每次 clone() 一份地址和标题。实验里一个跨行的引用定义标题 "multi\nline" 在 refdefs 里是 Boxed,到了 Start(Link) 事件里变成 Inlined。超过 22 字节的 Boxed 每次引用都要复制一次,这类复制的总量受 link_ref_expansion_limit 约束(输入长度和 100 000 取大者),防的是「一个长地址被引用一万次」的平方级膨胀。
into_static 只对 Borrowed 动手:不超过 22 字节拷进 Inlined,否则复制成 Boxed。实验 quiz 的输出:
into_static 22 bytes: Inlined(InlineStr { inner: [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108], len: 22 })
into_static 23 bytes: Boxed("0123456789abcdefghijklm")
into_static "中文字符串" (15 bytes): Inlined(InlineStr { inner: [228, 184, 173, 230, 150, 135, 229, 173, 151, 231, 172, 166, 228, 184, 178, 0, 0, 0, 0, 0, 0, 0], len: 15 })
上限按字节算,一个汉字 3 字节,7 个汉字就放不下了。事件层面的 into_static 和它在本站语料上的代价见事件模型。
转换方向上有一处不对称:From<CowStr> for String 对 Boxed 直接复用那块堆内存,From<CowStr> for Cow<str> 对 Boxed 却是 Cow::Owned(s.to_string()),多复制一次。
问: 标准库有 Cow<'a, str>,也是 24 字节,为什么还要自己写一个?
答: Cow::Owned 装的是 String,指针、容量、长度三个字长全占满,没有地方放内联字符串。解析器经常要交出一个「不在输入里」的短字符串:智能标点的弯引号、数字实体 中 解出的一个汉字,都是一个字符,用 Cow 只能各分配一个 String。CowStr 把拥有的那一支换成两个字长的 Box<str>,空出来的空间刚好放 22 字节,这些短字符串就不用碰分配器了。
解析时哪些地方会分配
CowStr 本身不决定分配,决定的是造它的代码。逐个看源码里构造字符串的地方,再用实验 variants 跑一遍(B 是 Borrowed,I 是 Inlined,X 是 Boxed):
| 情形 | 结果 | 分配 |
|---|---|---|
| 普通文本 | Borrowed,输入的切片 | 否 |
正文里的反斜杠转义 a\*b | 切成 a 和 *b 两个 Borrowed | 否 |
命名实体 & | Borrowed("&"),借 entities.rs 的静态表 | 否 |
数字实体 中 | Inlined("中") | 否 |
智能标点 "、...、--- | Inlined(SynthesizeChar) | 否 |
| 智能标点里 4 个以上连字符 | Boxed,8 个连字符得到 Boxed("––––") | 是 |
| 行内代码、行内公式跨行 | Boxed,换行换成空格(公式保留换行) | 是 |
| 表格单元格里含「反斜杠加竖线」的行内代码 | Boxed,去掉反斜杠 | 是 |
| 行内 HTML 跨行,且行首有容器前缀 | Boxed(OwnedInlineHtml),去掉 > | 是 |
| 行内 HTML 跨行,没有前缀 | Borrowed | 否 |
| 链接地址、引用定义、围栏 info string 里有转义或实体 | Boxed(unescape) | 是 |
内联链接的标题 [t](/u "t") | 总是 Boxed | 是 |
| 引用定义的标题跨行 | Boxed,存在 refdefs 里 | 是 |
两个分配入口值得单独看。unescape 先建一个空 String(不分配),只有遇到反斜杠转义、实体或 \r 时才往里写;一处都没遇到(mark == 0)就把原来的 CowStr 原样返回。正文里的实体走的是另一条路:第一遍在 parse_line 里遇到 & 就调 scan_entity,命名实体返回静态表里的 &'static str,数字实体返回一个 char 转成的 Inlined,两种都不分配。
内联链接标题「总是 Boxed」是源码里的一个小毛病。scan_link_title 想在「没有转义、没有跨行」时借用输入:
let mut title = String::new();
let mut mark = start_ix + 1;
let mut i = start_ix + 1;
while i < bytes.len() {
let c = bytes[i];
if c == close {
let cow = if mark == 1 {
(i - start_ix + 1, text[mark..i].into())
} else {
title.push_str(&text[mark..i]);
(i - start_ix + 1, title.into())
};
mark 的初值是 start_ix + 1,判断条件却写成了 mark == 1。start_ix 是标题开引号在整段文本里的位置,前面至少还有 [x]( 这几个字节,不可能是 0,所以借用那一支永远走不到。实验里 [ln](/u "t") 的标题是 Boxed("t"),而同一份文档里引用定义的标题 "CommonMark Spec" 是 Borrowed。main 上这一行没有改。
本站语料里
用本站内容仓库的 1099 篇 Markdown(去掉 frontmatter 后共 17 971 168 字节,按本站 mdx.rs 的七个选项解析)数事件里每个字符串的形态,实验 census 的输出:
-- CowStr variants (B Borrowed, I Inlined, X Boxed): count, string bytes
Code B 174263 2014752
Code X 36 966
InlineHtml B 6 31
Start(CodeBlock) B 11393 48543
Start(Link) B 72234 1499100
Text B 262006 13564686
519 938 个字符串里只有 36 个 Boxed,全是跨行的行内代码,合计 966 字节;Inlined 一个也没有。原因在语料本身:数字实体只出现在代码里,本站没开智能标点,没有一个内联链接带标题。对这样的输入,CowStr 的设计目标「几乎全部借用」是实测成立的。
`a\nb` 这个跨行的行内代码,Event::Code 里是哪个变体?对它 clone() 一次又是哪个?不开任何选项。答案在文末。动脑筋答案
实验 quiz 的输出:
code: Boxed("a b")
clone: Inlined(InlineStr { inner: [97, 32, 98, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], len: 3 })
make_code_span 遇到换行就开一个 String::with_capacity(spanned_bytes.len()),把换行换成空格,最后 buf.into() 走 From<String>,得到 Boxed("a b")。这个 Boxed 只有 3 字节,clone() 发现它放得进 InlineStr,克隆出来的是 Inlined,原值还是 Boxed。
小结
CowStr=Borrowed(&str)/Inlined(InlineStr)/Boxed(Box<str>),实测 24 字节;InlineStr22 字节数组加长度共 23 字节,再加判别字节凑满三个字长。- 内联容量 PR #274(0.5.0)从 15 涨到 22,枚举大小不变;
9bfba94(0.9.2)把长度挪成独立字段,容量没变。 - 比较、哈希按内容;
Inlined每次deref都校验 UTF-8,guide 说「创建时校验」不准确。 clone把短的Boxed变成Inlined;into_static把超过 22 字节的Borrowed复制到堆上。- 解析时分配只发生在要改写字符串的地方:跨行的代码、公式、HTML,链接里的转义,长串连字符;内联链接标题因为
mark == 1总是分配。本站语料 52 万个字符串只有 36 个Boxed。