Rust in Everything
基于 serde 1.0.229

取舍汇总

主线位置:三条主线都走完之后回头看。序列化主线上的选择是「数据模型」和「单态化」,反序列化主线上是「Visitor 回调」「deserialize_* 只是提示」和「'de 借用」,缓冲主线是「提示」那条选择的直接后果,「编译期 derive」横跨三条线。

flowchart TB
  model["数据模型"] --> magic["魔法名字"]
  mono["单态化"] --> size["每类型每格式一份"]
  mono --> nodyn["做不成 dyn"]
  visitor["Visitor 回调"] --> verbose["手写啰嗦"]
  hint["提示"] --> content["Content 缓冲"]
  content --> lost["丢信息、多分配"]
  borrow["'de 借用"] --> hrtb["DeserializeOwned 报错"]
  derive["编译期 derive"] --> attrs["属性组合靠运行期"]

这一页不讲新机制,每一节四段:serde 选了什么、代价是什么、证据在哪一页、什么时候另一种做法更好。引用别的页面的输出时只摘一个决定性的数字;本页新跑的实验在 M2 Max、macOS、rustc 1.99.0 上,依赖 serde =1.0.229(derive)、serde_json =1.0.154、bincode =1.3.3、serde_yaml =0.9.34、toml 0.8(锁到 0.8.23)。

选择换来的代价证据
数据模型做中间层M + N29 种类型固定,格式特有的类型靠魔法名字why-serde、Value
泛型单态化零开销、可内联机器码是 M × N;Serializer 不是 dyn compatible本页实验、Serializer
Visitor 回调自描述与非自描述格式共用一套 trait手写 Deserialize 啰嗦Visitor
deserialize_* 只是提示自描述格式一个 deserialize_any 就够缓冲只拿得到 deserialize_any 的信息Content
'de 借用零拷贝生命周期进签名,HRTB 报错难读生命周期
编译期 derive无运行时反射,错误多在编译期属性语言很大,组合行为有漏网属性、flatten

数据模型做中间层

选了什么。 类型只跟 29 种类型的数据模型说话,格式也只跟数据模型说话,M 个类型 × N 种格式变成 M + N 份代码(serde 解决什么问题)。那一页里同一个 Post 写进 JSON、YAML、bincode、postcard 再读回,打印 all equal: true;而改成「先转 serde_json::Value 再交给 bincode」,字节数从 53 涨到 125,还读不回来,因为 Value 抹掉了 struct、map 和变体序号的区别。

代价。 29 种是固定的。数据模型里没有「任意精度数字」「原始 JSON 片段」「日期时间」,格式要传这些,只能把它们伪装成数据模型里已有的东西,再用一个约定好的名字认出来。serde_json 有两个:number::TOKEN 是 "$serde_json::private::Number"(arbitrary_precision),raw::TOKEN 是 "$serde_json::private::RawValue"(raw_value)。写出器的 serialize_struct 先比对名字:

    fn serialize_struct(self, name: &'static str, len: usize) -> Result<Self::SerializeStruct> {
        match name {
            #[cfg(feature = "arbitrary_precision")]
            crate::number::TOKEN => Ok(Compound::Number { ser: self }),
            #[cfg(feature = "raw_value")]
            crate::raw::TOKEN => Ok(Compound::RawValue { ser: self }),
            _ => self.serialize_map(Some(len)),
        }
    }

只有 serde_json 自己认识这两个名字。别的格式看到的就是一个名叫 $serde_json::private::Number、只有一个字段的 struct。另开一个只打开 arbitrary_precision 的实验 crate,把一个 Value 写成 YAML(src/bin/magic_name.rs):

fn main() {
  let v: serde_json::Value = serde_json::from_str("[1, 3.14159265358979323846]").unwrap();
  println!("json      {}", serde_json::to_string(&v).unwrap());
  let yaml = serde_yaml::to_string(&v).unwrap();
  print!("yaml      {yaml}");
  let back: serde_json::Value = serde_yaml::from_str(&yaml).unwrap();
  println!("yaml back {back}");
}
json      [1,3.14159265358979323846]
yaml      - $serde_json::private::Number: '1'
- $serde_json::private::Number: '3.14159265358979323846'
yaml back [1,3.14159265358979323846]

YAML 里每个数字都成了一个单键 map,键就是魔法名字,值是带引号的字符串;连 1 也是,因为这个 feature 下 Number 内部一律存字符串(Value)。读回来又是对的:Value 的 KeyClassifier 认出了这个键。魔法名字在「serde_json → 别的格式 → Value」这条路上是自洽的,但写出来的 YAML 已经不是别人能读的数字了。

这不是 serde_json 独有的做法。toml 的日期时间也是这样过数据模型的:toml_datetime 0.6.11 的 datetime.rs 里有 pub const FIELD: &str = "$__toml_private_datetime";。把一张 TOML 表直接写成 JSON(src/bin/toml_datetime.rs):

fn main() {
  let t: toml::Table = toml::from_str("when = 1979-05-27T07:32:00Z").unwrap();
  println!("toml -> json {}", serde_json::to_string(&t).unwrap());
}
toml -> json {"when":{"$__toml_private_datetime":"1979-05-27T07:32:00Z"}}

魔法名字还有第二个代价:它要求伪装成的那个 map 原样到达认得它的 Visitor。缓冲主线把 map 先读进 Content 再重放,RawValue 要的原始文本就没了。Content 里那一组输出:同一个 Raw { r: Box<RawValue> },直接读是 Ok(Raw { r: RawValue([1, 2]) }),套进内部标签变成 Err(invalid type: newtype struct, expected any valid JSON value)。

什么时候另一种做法更好。 只用一种格式、又需要格式特有的类型时。serde 解决什么问题 的对比表里,borsh 和 rkyv 都没有中间层,类型直接定义自己的字节或归档形式,放弃「一个 derive 配任意格式」,也就没有伪装这回事。在 serde 里,类型只给一种格式用时,#[serde(with = ...)] 或手写 impl 把日期写成字符串,比依赖魔法名字更不容易出意外。

泛型单态化,而不是 trait 对象

选了什么。 Serialize::serialize 的格式是泛型参数 S: Serializer,Serializer 的方法按值拿 self。每一对(类型,格式)在编译期生成一份独立的机器码,调用可以内联,中间没有 vtable(Serializer)。

代价一:机器码是 M × N。 源码是 M + N,生成的代码却不是。下面的实验生成 N 个形状相同的 struct(a: u32、b: String、c: Vec<u64>、d: Option<bool>,都 derive 两个 trait),main 里对每个类型做一次 to_string + from_str,有的再加一次 bincode 往返。依赖已经编好,只重编这一个 bin,cargo build --release(默认 release 配置),每个跑三次取最快,二进制是未 strip 的文件大小:

mono_0_json            build  0.31 s (best of 3)  binary   514160 bytes  -> total 8
mono_50_json           build  2.93 s (best of 3)  binary   789632 bytes  -> total 1500
mono_100_json          build  5.55 s (best of 3)  binary  1058192 bytes  -> total 3000
mono_200_json          build 11.03 s (best of 3)  binary  1562144 bytes  -> total 6000
mono_100_unused        build  0.94 s (best of 3)  binary   435296 bytes  -> total 1
mono_0_json_bincode    build  0.32 s (best of 3)  binary   514288 bytes  -> total 19
  • 编译时间和类型数成正比:每多一个类型约 53 ms,200 个类型 11 秒。
  • 每个类型在二进制里约占 5.4 KB(100 个:(1058192 − 514160) / 100 ≈ 5440 字节;200 个约 5240 字节)。
  • mono_100_unused 是同样 100 个 derive、但没有任何格式调用它们,只用 0.94 秒。derive 展开和类型检查本身不贵,贵的是每个 impl 对一个具体格式实例化之后的代码生成。

1.0.229 里那几个「减少 llvm-lines」的 PR 减的是 serde_derive 自身编译出来的代码,不是这里的生成代码(怎么读这一卷);上面的账在 1.0.229 里没有变。

❗IMPORTANT
动脑筋:加上 bincode,100 个类型的二进制多大?

还是上面的程序,mono_100_json_bincode 对 100 个类型各做一次 JSON 往返和一次 bincode 往返。0 个类型时加不加 bincode 只差 128 字节(514160 对 514288)。100 个类型只用 JSON 是 1058192 字节。加上 bincode 之后是多少?每个类型多出来的那一份,比 JSON 那份大还是小?答案在文末。

代价二:做不成 trait 对象。 想在运行期挑格式、或者把不同类型的值放进一个列表,都要 dyn。src/bin/dyn_serializer.rs:

// Try to pick the output format at run time through a trait object.
fn write_number(out: &mut dyn serde::Serializer) {
  let _ = out;
}

fn main() {}
error[E0038]: the trait `Serializer` is not dyn compatible
 --> src/bin/dyn_serializer.rs:2:27
  |
2 | fn write_number(out: &mut dyn serde::Serializer) {
  |                           ^^^^^^^^^^^^^^^^^^^^^ `Serializer` is not dyn compatible
  |
  = note: the trait is not dyn compatible because it requires `Self: Sized`
  = note: for a trait to be dyn compatible it needs to allow building a vtable
          for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility>

For more information about this error, try `rustc --explain E0038`.
error: could not compile `exp-serde-design` (bin "dyn_serializer") due to 1 previous error

Serializer: Sized 是因为方法按值拿 self(一个 Serializer 只用一次)。另一头的 Serialize 没有 Sized 约束,但它的方法是泛型的。src/bin/dyn_serialize.rs 写 let items: Vec<Box<dyn serde::Serialize>> = vec![Box::new(1u32), Box::new("two")];:

error[E0038]: the trait `Serialize` is not dyn compatible
   --> src/bin/dyn_serialize.rs:3:26
    |
  3 |   let items: Vec<Box<dyn serde::Serialize>> = vec![Box::new(1u32), Box::new("two")];
    |                          ^^^^^^^^^^^^^^^^ `Serialize` is not dyn compatible
    |
note: for a trait to be dyn compatible it needs to allow building a vtable
      for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility>
   --> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/src/ser/mod.rs:265:8
    |
265 |     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    |        ^^^^^^^^^ the trait is not dyn compatible because method `serialize` has generic type parameters

For more information about this error, try `rustc --explain E0038`.
error: could not compile `exp-serde-design` (bin "dyn_serialize") due to 1 previous error

serialize<S> 对每个 S 都是一个不同的函数,vtable 里放不下「所有 S」。要 dyn 只能另起一套 trait 把 S 擦掉,这就是 erased-serde 这个 crate 做的事。

📝NOTE
没有蠢问题

问: 既然 erased-serde 能做出可以 dyn 的版本,serde 为什么不直接把 trait 设计成那样?

答: 看擦掉之后的签名就知道要付什么。本机 cargo 缓存里的 erased-serde 0.4.10,src/ser.rs:

pub trait Serialize: sealed::serialize::Sealed {
    fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>;

它的 Serializer 也改成拿 &mut self,基本类型的方法写成 fn erased_serialize_bool(&mut self, v: bool);。格式自己的 Ok 类型(serde_json 的 Value 写出器返回一个 Value)和 Error 类型在这一层都看不见了,换成 () 和 erased-serde 自己的 Error;每个字段、每个整数都是一次经 vtable 的间接调用,编译器没法跨过它内联。serde 把这条路留给真正需要运行期挑格式的少数场景,默认路径不付这笔钱。serde_json 的 Error 里甚至专门有一段代码,为经过 erased-serde 转了一圈的错误把行列号从消息文本里解析回来(错误)。

什么时候另一种做法更好。 插件系统、异构集合、运行期才知道格式的场景用 erased-serde。类型多、格式也多、编译时间成了问题时,减少每个类型实际用到的格式数比在 derive 上想办法更直接:上面的实验里,二进制和编译时间的增量都跟着「类型数 × 格式数」走。

Visitor:控制权交给格式

选了什么。 反序列化时类型只给格式一个 deserialize_* 提示和一个 Visitor,格式读到什么就回调哪个 visit_*。同一个 Visitor 既能被 serde_json 按输入里的标记驱动,也能被 bincode 按提示驱动,所以四种格式共用一份 derive(Deserialize、Deserializer 与 Visitor)。

代价:手写很啰嗦。 类型要准备好接收格式可能给的任何一种回调。一个只有两个字段的 struct:

#[derive(Deserialize, Debug)]
#[allow(dead_code)]
struct Post {
  title: String,
  tags: Vec<String>,
}

cargo expand 出来的 Deserialize 部分(const _: () = { ... }; 整块)是 218 行(cargo expand 会用 rustfmt 排版):3 个类型(__Field、__FieldVisitor、__Visitor)、4 个 impl、9 个函数。字段名要能按字符串、字节、序号三种方式认(visit_str、visit_bytes、visit_u64),struct 要能从 map 读也能从数组读(visit_map、visit_seq)。derive 把这些都写了,手写时一样不能少,每一步在生成的 Deserialize。

什么时候另一种做法更好。 类型自己读输入(borsh 的 deserialize_reader(reader: &mut R))在只有一种二进制格式时更短更直接。在 serde 里,很多手写需求不必写 Visitor:#[serde(from = "...")] / try_from 先读成一个 derive 好的中间类型再转换(生成的 Deserialize),从内存里的值读用 IntoDeserializer(value 模块)。

deserialize_* 只是提示,缓冲只剩 deserialize_any

选了什么。 Deserializer 的 31 个 deserialize_* 是提示,不是命令。自描述格式可以把它们全转给 deserialize_any,非自描述格式必须照提示读。Deserialize、Deserializer 与 Visitor 里同样两个字节 [44, 1],deserialize_u8 读出 visit_u8(44),deserialize_u16 读出 visit_u16(300);而 Value 只会调 deserialize_any,bincode 回 Err(DeserializeAnyNotSupported)。

代价。 untagged、内部标签、flatten 在读到决定性的信息之前不知道该给哪个提示,只能 deserialize_any 把整段输入读进 Content,再对这棵树重放(入口是隐藏方法 __deserialize_content_v1,默认就是 deserialize_any)。格式按提示做的特殊处理因此全部丢失。Content 里同一个字段直接读和套进内部标签的对比:

字段直接读内部标签
n: u128,输入 7Ok(Big { n: 7 })Err(u128 is not supported)
m: HashMap<u32, bool>,输入 {"1":true}Ok(Keys { m: {1: true} })Err(invalid type: string "1", expected u32)
x: u8,输入 300报错带 at line 1 column 16同样的报错,没有位置
bincode 读 SmallTagged—Bincode does not support the serde::Deserializer::deserialize_any method

还有直接的开销。同一页用计数分配器量的一次 from_str(release,20 万次取平均):

表示分配次数分配字节耗时
直接读 struct5104169 ns
tag = "t"7464284 ns
untagged7464283 ns
flatten7464275 ns
untagged,标题带转义9479323 ns

多出的 2 次是对象的 Content::Map 和数组的 Content::Seq 各一个 Vec;Content 本身 32 字节,一对键值 64 字节。untagged 用借用重放的 ContentRefDeserializer,带转义的字符串要再拷贝一次。untagged 还会把每个变体的失败原因都丢掉,只剩 data did not match any variant of untagged enum ...(untagged)。

什么时候另一种做法更好。 能选表示时选外部标签或标签在前的相邻标签,它们边读边建、不缓冲(enum 的表示与缓冲);输入要给 bincode、postcard 读时只能这样选。serde 自己的方向是 #2912:让格式提供自己的缓冲类型(serde_json 用 Value),按格式自己的规则重放。它在 1.0.229 时还是未合并的草稿,Content 上方那句「Obsoleted by」只是注释(Content)。

'de:借用输入

选了什么。 Deserialize<'de> 带一个输入的生命周期,&'de str、&'de [u8] 字段可以直接指向输入,不拷贝(生命周期 'de 与借用)。同一份输入读成拥有和借用两种 struct,数一次 from_str 的分配(src/bin/borrow_cost.rs,计数分配器和 Content 那一页的相同):

#[derive(Deserialize, Debug)]
#[allow(dead_code)]
struct Owned {
  title: String,
  tags: Vec<String>,
}

#[derive(Deserialize, Debug)]
#[allow(dead_code)]
struct Borrowed<'a> {
  title: &'a str,
  #[serde(borrow)]
  tags: Vec<&'a str>,
}

fn main() {
  let input = r#"{"title":"hello","tags":["a","b","c"]}"#;
  // count() 记下闭包前后的分配次数和字节数
  count("owned", || {
    let v: Owned = serde_json::from_str(input).unwrap();
    std::hint::black_box(v);
  });
  count("borrowed", || {
    let v: Borrowed = serde_json::from_str(input).unwrap();
    std::hint::black_box(v);
  });
}
owned     allocs=5 bytes=104
borrowed  allocs=1 bytes=64

借用之后只剩 Vec<&str> 本身一次分配(容量 4 × 16 字节);debug 和 release 结果相同。

代价。 生命周期出现在每个签名里,而且借不借得成由格式和输入决定,不由类型决定。同一页的两个输出:带转义的字符串借不成,&str 字段报 invalid type: string "say \"hi\"", expected a borrowed string at line 1 column 21;from_reader 没有可借的输入,要求 DeserializeOwned(for<'de> Deserialize<'de>),带 &str 的类型传进去,报错里看不到 DeserializeOwned 这个名字:

error: implementation of `Deserialize` is not general enough

后面两句 note 是「for any lifetime '0」和「for some specific lifetime '1」,要读懂得先知道 HRTB。axum 的 Json<T> 要求 T: DeserializeOwned,handler 里写错也是这一组报错(body 提取器)。

什么时候另一种做法更好。 数据要活得比输入久、输入来自 Read、或者字符串常带转义时,直接用 String:省掉生命周期,代价只是几次分配。想「能借就借、借不成就拷」用 Cow<'a, str> 加 #[serde(borrow)],但只有字段类型字面上就是 Cow 时才真的借,Option<Cow<'a, str>> 加了 borrow 仍是 Owned(生命周期 'de 与借用)。

编译期 derive

选了什么。 所有代码在编译期由 #[derive] 生成,运行期没有反射、没有注册表,大部分配置错误在编译期报出(派生宏的流水线)。

代价。 属性语言越来越大:attr.rs 1 818 行,几乎全是三个 from_ast 里逐个属性的分支(属性)。属性之间的组合要靠 check.rs(477 行、10 个检查函数)拦,没拦住的组合就留到运行期。flatten 加 deny_unknown_fields 是一例:能编译,check.rs 里没有这条检查;flatten 一个内部标签的 enum 时,完全合法的输入也失败(flatten):

7 tagged enum + deny               {"id":1,"kind":"Image","url":"u"}
  Err(unknown field `kind` at line 1 column 33)

什么时候另一种做法更好。 属性叠到三四个、行为开始需要查文档才能确定时,手写那一个字段的 deserialize_with,或者手写整个 impl,行为一眼可见。规则要在运行期变化的(比如按配置决定字段名),derive 做不到,只能读成 Value 或 map 再自己处理。

动脑筋答案

mono_100_json_bincode  build  7.65 s (best of 3)  binary  1372624 bytes  -> total 5100
  • 100 个类型加 bincode 是 1372624 字节,比只用 JSON 的 1058192 多 314432 字节,每个类型约 3144 字节。每个类型 JSON 一份约 5440 字节,bincode 一份更小。
  • 0 个类型时加 bincode 只多 128 字节,100 个类型时多 300 KB:增量跟着「类型数 × 格式数」走,而不是格式数。这就是源码 M + N、机器码 M × N。
  • 编译时间也一样,从 5.55 秒到 7.65 秒,多出的 2.1 秒来自这 100 个类型对 bincode 的实例化。

小结

  • 数据模型换来 M + N;格式特有的类型只能穿着魔法名字过去($serde_json::private::Number、$__toml_private_datetime),别的格式会把伪装原样写出来,缓冲会把它弄坏。
  • 单态化让每次调用都能内联,代价是机器码 M × N:每个类型经 serde_json 往返约 5.4 KB、53 ms 编译时间,加一种格式再加一份。Serializer: Sized、Serialize::serialize<S> 是泛型方法,两者都不是 dyn compatible,需要 dyn 时用 erased-serde。
  • Visitor 让一份 derive 配所有格式,代价是手写啰嗦:两个字段的 struct 展开成 218 行、9 个函数。
  • 提示让自描述格式一个 deserialize_any 就够,代价落在缓冲主线:Content 丢掉 u128、数字键、报错位置,非自描述格式读不回,一次 from_str 从 5 次分配变 7 次。
  • 'de 借用把分配从 5 次降到 1 次,代价是生命周期进签名、from_reader 只收 DeserializeOwned、报错写成「not general enough」。
  • 编译期 derive 没有运行时开销,代价是 1 818 行的属性解析和拦不全的组合,flatten + deny_unknown_fields 能编译、在运行期出错。

💬 关联讨论

发起讨论
加载中...