Rust in Everything
基于 pulldown-cmark 0.13.5

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 这条线。

📝NOTE
冷知识:内联容量从 15 字节涨到 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()),多复制一次。

📝NOTE
没有蠢问题

问: 标准库有 Cow<'a, str>,也是 24 字节,为什么还要自己写一个?

答: Cow::Owned 装的是 String,指针、容量、长度三个字长全占满,没有地方放内联字符串。解析器经常要交出一个「不在输入里」的短字符串:智能标点的弯引号、数字实体 &#20013; 解出的一个汉字,都是一个字符,用 Cow 只能各分配一个 String。CowStr 把拥有的那一支换成两个字长的 Box<str>,空出来的空间刚好放 22 字节,这些短字符串就不用碰分配器了。

解析时哪些地方会分配

CowStr 本身不决定分配,决定的是造它的代码。逐个看源码里构造字符串的地方,再用实验 variants 跑一遍(B 是 Borrowed,I 是 Inlined,X 是 Boxed):

情形结果分配
普通文本Borrowed,输入的切片否
正文里的反斜杠转义 a\*b切成 a 和 *b 两个 Borrowed否
命名实体 &amp;Borrowed("&"),借 entities.rs 的静态表否
数字实体 &#20013;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 的设计目标「几乎全部借用」是实测成立的。

❗IMPORTANT
动脑筋:`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 字节;InlineStr 22 字节数组加长度共 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。

💬 关联讨论

发起讨论
加载中...