Appearance
第 14 章 Enum 的四种 tag 策略
14.1 同一个 enum,四种 JSON 编码
Serde 对 enum 的序列化支持四种互不兼容的策略。同一个 enum:
rust
#[derive(Serialize, Deserialize)]
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
}1
2
3
4
5
2
3
4
5
能编码成四种完全不同的 JSON:
json
// 1. Externally tagged(默认)
{"Circle": {"radius": 3.0}}
// 2. Internally tagged:#[serde(tag = "type")]
{"type": "Circle", "radius": 3.0}
// 3. Adjacently tagged:#[serde(tag = "t", content = "c")]
{"t": "Circle", "c": {"radius": 3.0}}
// 4. Untagged:#[serde(untagged)]
{"radius": 3.0}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
每种格式都有自己的用途:
- Externally:Rust 社区默认,最"朴实"。
- Internally:匹配许多 REST API 的惯例(
{"type": "user", "name": "alice"})。 - Adjacently:Rust 互操作场景的工业标准(尤其是和 Rust 生态内的数据库、消息队列交互)。
- Untagged:反序列化时"猜"是哪个变体——用于宽容地接受多种形态输入(如 TOML 配置文件里的 "单个字符串 or 字符串数组")。
四种策略,四种完全不同的序列化/反序列化逻辑。serde_derive 的 de/ 目录里有四个独立文件处理这件事。本章把这四种模式一网打尽——它们的设计意图、生成代码、边缘情况、使用场景。
这是第 10-14 章系列的收尾,读完你就理解了 serde_derive 的全部 enum 处理。
14.2 为什么 enum 有这么多 tag 模式
本质原因是**"识别是哪个变体"这个问题有不同解法**。一个 enum 有多个变体,反序列化时必须知道输入对应哪个变体。"tag"就是这个识别信息——告诉 deserializer "这是 Circle 不是 Rectangle"。
Tag 可以放在哪里?
- 外面(externally):在变体的"外壳"上。形式是
{"VariantName": value}——整个 JSON 对象的 key 就是变体名。 - 里面(internally):和变体字段混在一起。变体是 struct 时,加一个专门字段表示 "type"。
- 相邻(adjacently):分两个字段,一个放 tag,一个放内容。
- 没有(untagged):根本不写 tag。反序列化时试着匹配每个变体,第一个成功的胜出。
每种设计都有权衡:
| 策略 | 优点 | 缺点 |
|---|---|---|
| External | 默认、无歧义、解析最快 | 多一层嵌套 {"V": ...} |
| Internal | 扁平结构(匹配典型 REST API) | 不能用于 tuple variant(字段名冲突风险) |
| Adjacent | 清晰分离、可处理所有变体类型 | 字段名多两个(tag 和 content) |
| Untagged | 最灵活、适合"多种形态接受" | 反序列化慢(要尝试)、有歧义风险 |
本章每一节对应一种模式。先从默认的 externally 开始。
14.3 Externally tagged(默认)
这是 Rust 社区的默认选择——也是 Serde 1.0 唯一支持的模式(Internal/Adjacent 是后来加的)。
JSON 编码:
json
{"Circle": {"radius": 3.0}}
{"Rectangle": {"width": 1.0, "height": 2.0}}1
2
2
关键特征:JSON 对象恰好 1 个 key——这个 key 就是变体名。value 是变体的内容。
序列化生成代码(serde_derive/src/ser.rs:502,externally_tagged_variant):
对 Circle { radius: 3.0 },生成代码调用:
rust
_serde::Serializer::serialize_struct_variant(
__serializer,
"Shape", // enum name
0u32, // variant index
"Circle", // variant name
1, // field count
)
// 然后 serialize_field("radius", &radius), .end()1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
serialize_struct_variant 是 Serializer trait 的专用方法(第 3 章讲过)——它接收所有关键信息(enum名/索引/变体名/字段数),让格式自己决定编码。JSON 格式把它编码成 {"Circle": {"radius": 3.0}}。
反序列化比序列化复杂。看 serde_derive/src/de/enum_externally.rs:213:
生成代码大致是:
rust
impl<'de> Deserialize<'de> for Shape {
fn deserialize<__D>(d: __D) -> Result<Self, __D::Error> where __D: Deserializer<'de> {
// 1. 定义"变体标识符" enum
enum __Field { Circle, Rectangle }
struct __FieldVisitor;
impl<'de> Visitor<'de> for __FieldVisitor {
type Value = __Field;
fn visit_str<E: Error>(self, v: &str) -> Result<__Field, E> {
match v {
"Circle" => Ok(__Field::Circle),
"Rectangle" => Ok(__Field::Rectangle),
_ => Err(Error::unknown_variant(v, VARIANTS)),
}
}
// visit_u64 for index-based encoding, visit_bytes, ...
}
// 2. 主 Visitor
struct __Visitor;
impl<'de> Visitor<'de> for __Visitor {
type Value = Shape;
fn expecting(&self, f: &mut Formatter) -> fmt::Result {
f.write_str("enum Shape")
}
// externally tagged 走 visit_enum
fn visit_enum<__A>(self, __data: __A) -> Result<Shape, __A::Error>
where __A: EnumAccess<'de>
{
// 读 tag(变体名)
let (variant, __variant_data) = __data.variant::<__Field>()?;
match variant {
__Field::Circle => {
// 把 variant_data 当作 Circle 变体的字段数据
VariantAccess::struct_variant(__variant_data, &["radius"], CircleVisitor)
}
__Field::Rectangle => {
VariantAccess::struct_variant(__variant_data, &["width", "height"], RectVisitor)
}
}
}
}
const VARIANTS: &[&str] = &["Circle", "Rectangle"];
d.deserialize_enum("Shape", VARIANTS, __Visitor)
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
关键步骤:
- 变体标识符:和 struct 的字段标识符
__Field结构相同(见第 13 章)。 - 主 Visitor 实现
visit_enum:这是 externally tagged 独有的——Deserializer 会先调用visit_enum。 EnumAccess::variant读 tag:第一步读出变体名(作为__Field)。VariantAccess读变体数据:第二步根据 variant 读对应的 struct/tuple/unit/newtype 数据。
EnumAccess 和 VariantAccess是第 4 章提到的两个状态机 trait:
rust
pub trait EnumAccess<'de> {
type Error: Error;
type Variant: VariantAccess<'de, Error = Self::Error>;
fn variant_seed<V: DeserializeSeed<'de>>(self, seed: V)
-> Result<(V::Value, Self::Variant), Self::Error>;
}
pub trait VariantAccess<'de> {
type Error: Error;
fn unit_variant(self) -> Result<(), Self::Error>;
fn newtype_variant_seed<T: DeserializeSeed<'de>>(self, seed: T) -> Result<T::Value, Self::Error>;
fn tuple_variant<V: Visitor<'de>>(self, len: usize, visitor: V) -> Result<V::Value, Self::Error>;
fn struct_variant<V: Visitor<'de>>(self, fields: &'static [&'static str], visitor: V) -> Result<V::Value, Self::Error>;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这是 Serde 为 enum 反序列化专门设计的两阶段 API:EnumAccess 读 tag、VariantAccess 读内容。格式层面 JSON 看到 {"Circle": {...}} 后,Deserializer 把 "Circle" 作为 tag,{...} 作为 variant data,分别给用户的 Visitor。
14.4 Internally tagged:扁平化
Internally tagged 适合匹配 "REST API" 风格的 JSON:
rust
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Event {
Click { x: i32, y: i32 },
KeyPress { key: char },
}1
2
3
4
5
6
2
3
4
5
6
序列化成:
json
{"type": "Click", "x": 10, "y": 20}
{"type": "KeyPress", "key": "a"}1
2
2
Tag 嵌在变体字段里——和变体的 x、y、key 一起组成一个扁平 JSON 对象。
序列化生成代码(serde_derive/src/ser.rs:575):
对 Click { x: 10, y: 20 },生成:
rust
// 先调用 serialize_map(比变体字段数多 1,因为加了 tag 字段)
let mut state = _serde::Serializer::serialize_struct(__serializer, "Event", 1 + 2)?;
// 先写 tag
SerializeStruct::serialize_field(&mut state, "type", "Click")?;
// 然后写变体字段
SerializeStruct::serialize_field(&mut state, "x", &x)?;
SerializeStruct::serialize_field(&mut state, "y", &y)?;
SerializeStruct::end(state)1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
注意是 serialize_struct 而不是 serialize_struct_variant——因为 tag 和字段混在一起,格式层面不能区分"这是一个 enum variant"。直接伪装成 struct 输出。
反序列化更巧妙(serde_derive/src/de/enum_internally.rs:106)。生成代码大致是:
rust
// 分两阶段:先把 JSON 解析成一个"中间表示",提取 tag,再用 tag 决定走哪个变体
let __content = serde::__private::de::Content::deserialize(__deserializer)?;
// 从 content 里提取 tag 字段
let mut __tag: Option<String> = None;
// 遍历 content 找 "type" key
// ... 找到 tag value 后 ...
match __tag.as_deref() {
Some("Click") => {
// 用 content 构造一个新的 Deserializer,反序列化 Click
Click::deserialize(ContentDeserializer::new(__content))
}
Some("KeyPress") => { ... }
_ => Err(Error::unknown_variant(...)),
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
关键是 Content 中间类型——它把 JSON 输入先缓存到内存,再两次扫描:
- 第一次找 tag
- 根据 tag 决定类型,从缓存的 Content 再反序列化一次
为什么要缓存? 因为输入是流式的,tag 可能在最后({"x": 10, "y": 20, "type": "Click"})。反序列化 Click { x, y } 时需要从头开始读 x、y,但流已经走完。缓存让我们可以任意顺序重读。
缺点:内存占用。对大 JSON 对象,缓存可能显著增加内存。但 Serde 认为这是值得的——换来灵活的 tag 位置。
14.5 Adjacently tagged:清晰分离
Adjacently tagged 把 tag 和 content 放在明确的两个字段:
rust
#[derive(Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
enum Command {
Start,
Stop,
Move { dx: i32, dy: i32 },
}1
2
3
4
5
6
7
2
3
4
5
6
7
序列化:
json
{"t": "Start"}
{"t": "Stop"}
{"t": "Move", "c": {"dx": 5, "dy": 3}}1
2
3
2
3
Unit 变体 Start/Stop 没有 content 字段——因为没有内容。其他变体把所有字段放到 c 下。
这是所有 tag 模式里最稳健的——适用于所有变体类型(unit/newtype/tuple/struct)、tag 位置明确、没有命名冲突风险。
序列化生成代码(serde_derive/src/ser.rs:641):
对 Move { dx: 5, dy: 3 }:
rust
let mut state = _serde::Serializer::serialize_struct(__serializer, "Command", 2)?;
SerializeStruct::serialize_field(&mut state, "t", "Move")?;
SerializeStruct::serialize_field(&mut state, "c", &MoveContent { dx: 5, dy: 3 })?;
SerializeStruct::end(state)1
2
3
4
2
3
4
注意 &MoveContent { dx, dy }——这是一个临时 struct(类似第 12 章的 SerializeWith)。它包装变体字段,使它们能被当作一个整体序列化为 "c" 字段的 value。
对 Unit 变体 Start:
rust
let mut state = _serde::Serializer::serialize_struct(__serializer, "Command", 1)?;
SerializeStruct::serialize_field(&mut state, "t", "Start")?;
SerializeStruct::end(state)1
2
3
2
3
只写 tag,没有 content——匹配 JSON {"t": "Start"}。
反序列化(serde_derive/src/de/enum_adjacently.rs:324):生成代码很长(324 行的源生成)。核心逻辑:
- 外层是 struct Visitor,只允许两个 key:
t和c。 - 读 t 时:解析出变体名,存起来。
- 读 c 时:必须等 t 已经读到,否则先把 c 的内容暂存为 Content(中间类型),然后等 t 出现后再解析。
- t 出现后:根据变体名决定 c 的类型,用对应 Visitor 解析 c。
处理 tag 和 content 顺序不定是这里的关键难点。和 internally tagged 类似用 Content 缓存。
边缘情况:
- t 出现但没有对应的 c:如果变体是 unit,OK;否则错误。
- c 出现但没有 t:错误。
- 其他字段出现:错误(除非 deny_unknown_fields 被关)。
这 324 行代码就在处理这些情况。
14.5.1 TagOrContentField —— Adjacent 复杂度的核心
打开 enum_adjacently.rs:127-160——会看到一个反复出现的 enum:
rust
let mut __rk: Option<TagOrContentField> = None;
match key {
"t" => __rk = Some(TagOrContentField::Tag),
"c" => __rk = Some(TagOrContentField::Content),
_ => /* unknown field */,
}1
2
3
4
5
6
2
3
4
5
6
TagOrContentField 是 serde::__private::de 里定义的内部 enum——只有两个 variant:Tag / Content。它的存在就是为了把 JSON 里的字段名归类到这两个 slot——而不直接 match 字符串(避免每个 variant deserializer 重复写字符串比对)。
源码 :154-157 和 :229-254 之所以重复出现 TagOrContentField match——是因为 Adjacent 反序列化要在两套状态机里工作:
- 状态 A——还没读到 tag、当前在解析"接下来这个 key 是 t 还是 c"
- 状态 B——已经读到 tag、当前在期待 c 出现
两种状态各自需要 match TagOrContentField——所以同一个 enum 在 323 行代码里至少出现 6 次(grep 的输出印证了这点)。这种"同一原语在多状态下复用"的写法——是 derive 生成代码"重复但机械"的根本原因——也是为什么 enum_adjacently.rs 需要 323 行:每种状态转换都要展开一份完整的 match 块。
14.6 Untagged:反序列化的"猜猜看"
最灵活也最慢的模式:
rust
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum JsonValue {
Number(f64),
String(String),
Array(Vec<JsonValue>),
}1
2
3
4
5
6
7
2
3
4
5
6
7
序列化:
json
42.0 // Number(42.0)
"hello" // String("hello")
[1, 2, 3] // Array([Number(1.0), Number(2.0), Number(3.0)])1
2
3
2
3
完全没有 tag——靠反序列化时尝试每个变体,哪个成功就用哪个。
序列化生成代码(serde_derive/src/ser.rs:765):对 Number(42.0):
rust
_serde::Serialize::serialize(&42.0_f64, __serializer)1
就是字段的 serialize——完全忽略 enum 存在。格式层面看到的就是一个 f64。
反序列化(serde_derive/src/de/enum_untagged.rs:135)最精妙:
rust
let __content = Content::deserialize(__deserializer)?; // 先缓存
// 按顺序尝试每个变体
if let Ok(value) = <f64 as Deserialize>::deserialize(ContentRefDeserializer::new(&__content)) {
return Ok(JsonValue::Number(value));
}
if let Ok(value) = <String as Deserialize>::deserialize(ContentRefDeserializer::new(&__content)) {
return Ok(JsonValue::String(value));
}
if let Ok(value) = <Vec<JsonValue> as Deserialize>::deserialize(ContentRefDeserializer::new(&__content)) {
return Ok(JsonValue::Array(value));
}
Err(Error::custom("data did not match any variant of untagged enum JsonValue"))1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
思路:把输入先缓存为 Content,然后逐个尝试反序列化为每个变体的字段类型。第一个成功的返回。
14.6.1 Content<'de> 真身:21 变体的万能缓冲,以及它的退役计划
前面代码里反复出现的 Content 是什么?打开 serde_core/src/private/content.rs:10:
rust
// Used from generated code to buffer the contents of the Deserializer when
// deserializing untagged enums and internally tagged enums.
//
// Not public API. Use serde-value instead.
//
// Obsoleted by format-specific buffer types (https://github.com/serde-rs/serde/pull/2912).
#[doc(hidden)]
pub enum Content<'de> {
Bool(bool),
U8(u8), U16(u16), U32(u32), U64(u64),
I8(i8), I16(i16), I32(i32), I64(i64),
F32(f32), F64(f64),
Char(char),
String(String), Str(&'de str), // ← zero-copy 双形
ByteBuf(Vec<u8>), Bytes(&'de [u8]), // ← zero-copy 双形
None,
Some(Box<Content<'de>>),
Unit,
Newtype(Box<Content<'de>>),
Seq(Vec<Content<'de>>),
Map(Vec<(Content<'de>, Content<'de>)>),
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
实测 22 个变体(按源码:1 个 Bool + 4 个 Uxx + 4 个 Ixx + 2 个 Fxx + Char + String + Str + ByteBuf + Bytes + None + Some + Unit + Newtype + Seq + Map = 22)——几乎把 Serde Data Model 的每个原语都缓冲一份。几个值得展开的点:
1. Str(&'de str) 和 String(String) 并存、Bytes(&'de [u8]) 和 ByteBuf(Vec<u8>) 并存——前者是零拷贝、后者是拥有所有权。反序列化时如果原始输入有足够长的生命周期('de)、就走零拷贝的 Str/Bytes 变体;否则走 String/ByteBuf。同一份 Content 同时能表示借用和拥有——untagged enum 反序列化时想保留 zero-copy 可能性,又要能处理流式输入、只能两套都支持。
2. 源码注释有三条元信息:
"Used from generated code"——这个 enum 只被#[derive(Deserialize)]展开的代码访问、不是给用户用的"Not public API. Use serde-value instead."——如果你想要"运行时可检查的 Serde 值"、官方推荐用serde-valuecrate、不要直接用Content"Obsoleted by format-specific buffer types (PR #2912)"——整个 Content 机制预告要被废弃!将来 untagged enum 会让每个具体格式(serde_json、serde_yaml)自己提供缓冲类型,不再依赖这个"万能但慢"的通用版本。Content因为要处理所有可能的 Data Model 原语、序列化/反序列化成本高;格式特定的缓冲(如serde_json::Value)可以针对 JSON 优化得更快。
3. #[doc(hidden)] + pub 的组合是 Rust 生态里"公开导出但不承诺稳定"的标准手法。类型技术上可访问、文档里不显示、语义上可以任意改动。用户误用 Content 会看到编译器警告:"This item is not intended for use outside of Serde internals."
14.6.2 ContentRefDeserializer 的重放机制
untagged enum 的关键技术——一次缓冲、多次尝试——靠 ContentRefDeserializer(serde/src/private/de.rs:1992)实现:
rust
pub struct ContentRefDeserializer<'a, 'de: 'a, E> {
content: &'a Content<'de>, // ← 借用 Content,不拷贝
err: PhantomData<E>,
}1
2
3
4
2
3
4
关键是它只借用 &Content、不消费——每次 deserialize() 调用都从 content 重新开始"读"一遍、模拟一个全新的反序列化过程。'de: 'a lifetime bound 保证 Content 里的 &'de str 借用能穿透 ContentRefDeserializer 传递到 Visitor。
这让 untagged 的代码写成:
rust
let __content = Content::deserialize(__deserializer)?; // 消费原始 deserializer 一次、缓冲
if let Ok(v) = Variant1::deserialize(ContentRefDeserializer::new(&__content)) {
return Ok(v); // 成功
}
if let Ok(v) = Variant2::deserialize(ContentRefDeserializer::new(&__content)) {
return Ok(v); // 同一个 content 被重放
}
// ...1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
这种**"输入 → 内存对象 → 重放 N 次"** 的两阶段架构是 untagged 慢的根本原因:每次尝试都要遍历 content 树一次、不是简单地前向解析一次输入。PR #2912 要替换掉的正是这种"在 Serde 通用层做缓冲"的架构——让每个格式自己提供更高效的 "多次读同一份输入" 能力(JSON 可以用 serde_json::Value 直接作为 reference、不需要转换)。
两个问题:
1. 性能:每次反序列化都可能尝试多个变体,每个尝试要遍历 Content 一次。对深度嵌套的 untagged enum,性能会比 tagged 模式明显慢——具体倍数取决于变体数量和输入深度,读者应在自己场景实测(第 15 章用过的 criterion 方法可直接套用)。
2. 歧义:如果两个变体能匹配同样的输入会怎么样?比如 Number(42.0) 和 String("42.0")。按变体声明顺序,前者优先。这意味着变体顺序影响反序列化结果——容易踩坑。
Untagged 的典型使用场景:
- 配置文件的"宽容接受":接受
mode: "fast"或mode: {"level": 3}等多种形态。 - API 版本兼容:旧 API 返回
{"id": 1},新 API 返回{"id": 1, "version": 2},untagged 可以同时处理。 - JSON 的 "any value" 类型:
serde_json::Value内部就用 untagged 模式。
14.6.3 de/ 目录的真实文件大小:5 + 3 = 8 个文件
serde_derive/src/de/ 实测包含 9 个文件、共 2381 行——本章前面散见的"X 行"数据集中列出:
| 文件 | 行 | 角色 |
|---|---|---|
enum_.rs | 96 | 4 种 tag 模式的分派入口(§14.7) |
enum_externally.rs | 212 | External 反序列化生成 |
enum_internally.rs | 106 | Internal 反序列化生成 |
enum_adjacently.rs | 323 | Adjacent 反序列化生成(最复杂) |
enum_untagged.rs | 135 | Untagged 反序列化生成(最短) |
identifier.rs | 477 | __Field 标识符 enum 的生成器(4 种模式都用) |
struct_.rs | 697 | struct 反序列化(不限 enum) |
tuple.rs | 283 | tuple struct/variant 处理 |
unit.rs | 52 | unit struct/variant 处理 |
两个反直觉的观察——
1. enum_untagged.rs 只有 135 行——比 enum_adjacently.rs 短一倍多。直觉上 untagged 应该最复杂(要"猜"是哪个 variant)——但实际复杂性外包给了 Content 缓冲 + ContentRefDeserializer 重放机制(§14.6.1-14.6.2),生成代码本身只是"按变体顺序逐个 try"——非常机械。
2. enum_adjacently.rs 323 行最大——超过另外 3 种 tag 文件之和(212+106+135=453、单个 323 占其中 71%)。原因是 §14.5 末尾讲过的"tag 和 content 顺序不定"——必须支持 {"t":...,"c":...} 和 {"c":...,"t":...} 两种 JSON 顺序、且 t 先到 vs c 先到时处理路径完全不同(c 先到必须缓冲到 Content 等 t 出现)。
14.6.4 identifier.rs 477 行——所有 tag 模式的共同基础设施
被本章前文反复使用的 __Field enum 生成器在 serde_derive/src/de/identifier.rs——所有 4 种 tag 模式都依赖它。
它做什么?—— 给定一个 enum 的变体名列表(["Circle", "Rectangle"]),生成一段代码:
rust
enum __Field { __Field0, __Field1, __ignore }
impl<'de> Deserialize<'de> for __Field {
fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where __D: Deserializer<'de> {
struct __FieldVisitor;
impl<'de> Visitor<'de> for __FieldVisitor {
type Value = __Field;
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> { /* 索引模式 */ }
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> { /* 名字模式 */ }
fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E> { /* 字节模式 */ }
}
__deserializer.deserialize_identifier(__FieldVisitor)
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
477 行支持的能力——
- 三种 visit 路径:
visit_u64(按索引)、visit_str(按名字)、visit_bytes(按字节字符串)——格式自由选用 __ignorevariant:处理"不认识的字段"——deny_unknown_fields=false时落到这里、跳过;true时报错- alias 支持:
#[serde(alias = "x")]让__FieldVisitor::visit_str同时认 "x" 和原名 - rename_all 应用:在生成 visit_str 的 match 表里、按 RenameRule 转换的字符串作为 case
- case-insensitive 模式(
field_identifier属性)
为什么这个文件 477 行也算合理——因为它不是 4 种 tag 模式的孤立工具、是所有"按 tag 选 variant"逻辑的共同前置步骤。tag 拿到后必须先转成 __Field 才能 dispatch、所以 identifier 的健壮性直接决定 4 种模式的健壮性。
和章节其他节的连接——§14.3 externally tagged 的代码示例里 let (variant, __variant_data) = __data.variant::<__Field>()?; 那个 __Field 就是 identifier.rs 生成的;§14.4 internally tagged 提取 tag 时也用同一套机制。identifier.rs 是 4 种 tag 模式背后的共同骨架——本章前面没明说、这里补上。
14.7 enum_.rs:分发入口
serde_derive/src/de/enum_.rs(96 行)是 enum 反序列化的分派入口。它根据 cont.attrs.tag() 决定调哪个具体模块:
rust
// serde_derive/src/de/enum_.rs (精简)
pub fn deserialize(
params: &Parameters,
variants: &[Variant],
cattrs: &attr::Container,
) -> Fragment {
match cattrs.tag() {
attr::TagType::External => enum_externally::deserialize(params, variants, cattrs),
attr::TagType::Internal { tag } => enum_internally::deserialize(params, variants, cattrs, tag),
attr::TagType::Adjacent { tag, content } => enum_adjacently::deserialize(params, variants, cattrs, tag, content),
attr::TagType::None => enum_untagged::deserialize(params, variants, cattrs),
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
这是经典的"策略模式"——TagType 是策略 enum,每种策略有独立实现。新增 tag 模式只需要加一个 enum 变体和一个模块。
enum_.rs 还提供一些通用工具:
prepare_enum_variant_enum:生成所有变体共用的 __Field enum(外部/邻接模式都要用)。- 变体名到索引的转换。
- 变体级别属性的处理(
#[serde(rename)]在变体上)。
14.8 serialize 端的分派
Serialize 侧的分派在 serde_derive/src/ser.rs:421 的 serialize_variant 里(第 12 章见过):
rust
let body = match cattrs.tag() {
attr::TagType::External => serialize_externally_tagged_variant(...),
attr::TagType::Internal { tag } => serialize_internally_tagged_variant(..., tag),
attr::TagType::Adjacent { tag, content } => serialize_adjacently_tagged_variant(..., tag, content),
attr::TagType::None => serialize_untagged_variant(...),
};1
2
3
4
5
6
2
3
4
5
6
和 Deserialize 侧完全对称。实测 ser.rs 里 4 个函数的位置与篇幅——
| 函数 | 起始行 | 大致长度 |
|---|---|---|
serialize_externally_tagged_variant | 503 | ~73 行 |
serialize_internally_tagged_variant | 576 | ~66 行 |
serialize_adjacently_tagged_variant | 642 | ~124 行(最长) |
serialize_untagged_variant | 766 | ~38 行(最短) |
serialize_struct_variant_with_flatten(专用辅助) | 969 | ~50 行 |
Adjacently 序列化端依然是 4 种里最长——和反序列化端 (323 行 vs 其他 100-200 行) 形成对称——根因相同:要构造 {"t": "name", "c": {...}} 双字段对象、且 unit/newtype/tuple/struct 四种 variant 形态各有不同 content 结构。
Untagged 序列化端只有 38 行——因为它"完全忽略 enum 存在"(§14.6 提过)——只是 forward 字段值的 serialize、生成代码极简。Untagged 的复杂度全在反序列化端。
Internally tagged 的特殊辅助 serialize_struct_variant_with_flatten(line 969)——专门处理 §14.10 坑 4 提到的 "internal tag + struct variant + skip_serializing_if" 三件套——50 行代码就为这一个边角场景。这是 serde "覆盖所有合法组合"代价的体现——某些组合在生产中可能 100 个用户里只有 1 个用到、但代码必须正确处理。
整个 enum tag 系统用 TagType 枚举统一表达——这是 Serde 把复杂功能放进类型系统的典范。用户写 #[serde(tag = "...")] 时,属性解析器把它变成 TagType::Internal { tag: "..." };代码生成器根据 TagType 分派。中间没有字符串比较,全是类型匹配。
14.9 选择哪种 tag 的指南
用什么场景?实战建议:
默认用 External:
- 如果你只在 Rust 内部存序列化数据,External 是最快最清晰的。Bincode、MessagePack、Postcard 都对它优化得最好。
- 它是 Rust 社区的惯例。
接收现有 API 数据用 Internal/Adjacent:
- 如果你要解析别人的 API(比如 AWS、Stripe),先看他们的文档——他们可能用 internal tag(
{"type": "...", ...})或 adjacent tag({"kind": "...", "data": {...}})。
写人类可读配置用 Untagged:
- TOML/YAML 配置文件里,用户期望简洁。
enum LogLevel { Trace, Debug, Info, ... }用 Untagged 允许用户写log_level = "info"而不是log_level = {"Info": null}。
API 版本兼容用 Untagged:
- 当你需要同时支持"旧数据"和"新数据"两种形态,Untagged 是唯一方案。
不要在关键路径上使用 Untagged:它慢,且容易被恶意输入搞挂(如果 content 很大,缓存和多次尝试成本巨大)。
14.10 边缘情况和坑
坑 1:Internally tagged + Tuple variant 不支持。
rust
#[serde(tag = "type")]
enum E {
Tuple(i32, i32), // ❌ 编译错!
}1
2
3
4
2
3
4
因为 tuple variant 没有字段名,没地方放 tag。check.rs 会拒绝这种组合。
坑 2:Untagged 的 Any 类型。
rust
#[serde(untagged)]
enum V {
Any(serde_json::Value), // 匹配任何 JSON
Specific(f64), // 仅匹配数字
}1
2
3
4
5
2
3
4
5
Any 永远先匹配成功,Specific 永远不会被用到。变体顺序至关重要——把"更具体"的放前面。
坑 3:Adjacently 的 unit variant。
rust
#[serde(tag = "t", content = "c")]
enum E { A, B { x: i32 } }1
2
2
A 的 JSON 是 {"t": "A"}(没有 c),B 的 JSON 是 {"t": "B", "c": {"x": 1}}。但如果反过来 {"t": "A", "c": null} 呢?允许——unit variant 的 content 可以是 null/missing。但 {"t": "B"} 没有 c?报错——struct variant 必须有 content。
坑 4:Internally tagged + Struct variant + skip_serializing_if。
这个组合会让生成的序列化代码复杂到需要专门的 serialize_struct_variant_with_flatten 函数(serde_derive/src/ser.rs:968)。原因是 tag 要和变体字段一起作为平坦 JSON 对象输出,而有些字段可能被 skip——需要运行时计算最终字段数。
14.11 和丛书其他书的关联
tag 模式是协议设计的普适概念,不只 Rust 有:
- **丛书《MCP 协议设计与实现》第 3 章"JSON-RPC 与消息格式"**深入讨论过 JSON-RPC 的消息格式——它本质上是一种 internally tagged 的
enum Message { Request, Response, Notification },tag 字段是method/result/error的组合。Serde 的 internally tagged 模式直接支持这种协议。 - **丛书《LangChain 设计与实现》**里 Agent 的"tool call" 结构——LangChain 的
tool_call在 JSON 里大致是{"type": "function", "function": {"name": "...", "arguments": "..."}},这是经典的 adjacently tagged 模式(type 做 tag,function 做 content)。 - JSON Schema 的 discriminator 关键字:OpenAPI 3.x 的 discriminator 对应 Serde 的 internally tagged——同一个概念,两个生态各自实现。
14.11.1 四种 tag 模式在源码里不是四个配置项
从用户视角看,enum 表示只是几个属性:默认、tag、tag + content、untagged。从 serde_derive 源码看,它们是四套不同控制流。
| tag 模式 | Deserialize 源码锚点 | 核心动作 | 为什么不同 |
|---|---|---|---|
| External | serde_derive/src/de/enum_externally.rs:105-113 | 调 Deserializer::deserialize_enum,让格式提供 variant | tag 在对象外层,格式可以直接读到 variant 名 |
| Internal | serde_derive/src/de/enum_internally.rs:54-57 | 用 TaggedContentVisitor 拆出 tag 和 content | tag 混在对象字段里,要先缓存内容 |
| Adjacent | serde_derive/src/de/enum_adjacently.rs:170-177 | 先构造 seed,再用 ContentDeserializer 反序列化 content | tag 和 content 两个字段顺序可变,要同时处理重复和缺失 |
| Untagged | serde_derive/src/de/enum_untagged.rs:46-57 | 先读成 Content,再按变体顺序逐个尝试 | 输入里没有 tag,只能试错回溯 |
External 最接近 Serde Data Model 的 enum 原语。enum_externally.rs:117-159 再按 variant style 分派:unit 调 unit_variant,newtype 走 newtype helper,tuple 交给 tuple::deserialize,struct 交给 struct_::deserialize。也就是说,External 不需要把整个 JSON 对象先存下来,variant 名一旦确定,就能进入对应 payload 的 visitor。
Internal 和 Adjacent 的复杂度来自"tag 不在 enum 原语原生位置"。Internal 的 TaggedContentVisitor 必须扫描对象字段找到 tag,同时保留剩余内容;Adjacent 还要处理 tag 字段和 content 字段谁先出现、是否重复、unit variant 是否允许没有 content。这就是为什么 §14.12 的表里 Adjacent 文件最大,不是因为它概念最难,而是因为它的输入状态最多。
Untagged 是最灵活也最昂贵的。enum_untagged.rs:46-47 先用 ContentVisitor 把输入完整读成中间内容,再用 ContentRefDeserializer 反复借用这份内容;enum_untagged.rs:51-57 对每个变体尝试一次,成功就返回,全部失败才给统一错误。这个策略保证不消费输入流多次,但代价是需要缓存一份内容,并且变体顺序会变成语义的一部分。
这四种实现解释了本章给出的实践建议:性能敏感默认 External,互操作 API 用 Internal/Adjacent,配置兼容才用 Untagged。它不是风格偏好,而是由源码里的控制流决定的。External 是单次分派;Internal/Adjacent 是缓存后分派;Untagged 是缓存后多次尝试。选择 tag 模式,本质上是在选择反序列化控制流。
Serialize 侧同样按 tag 模式分叉,只是控制流更短。serde_derive/src/ser.rs:475-494 按 TagType 调到 serialize_externally_tagged_variant、serialize_internally_tagged_variant、serialize_adjacently_tagged_variant 或 serialize_untagged_variant。External 的 ser.rs:503-535 会直接调用 serialize_unit_variant / serialize_newtype_variant 等 enum 原语;Internal 的 ser.rs:576-640 会把 tag 当成 struct 字段写进去,并且 tuple variant 已经在前置检查中被排除;Untagged 的 ser.rs:766-803 则退化成直接序列化 payload,unit variant 写成 unit,新类型直接序列化字段。
这说明 enum tag 的不对称性很强:序列化时,Rust enum 的 variant 已经确定,代码只要选一种输出形状;反序列化时,输入形状要反推出 variant,才需要缓存、试错和 ContentDeserializer。理解这个不对称,能解释为什么第 14 章大部分复杂度都在 Deserialize 侧。
工程建议也应从这个不对称出发。写出端你可以轻松改变 tag 风格,读入端却必须兼容历史数据、第三方 API 和错误输入。公开协议一旦发布,tag 模式就是兼容性承诺。Serde 给了四种表示,但不会替你承担协议迁移成本。
如果协议还在设计期,优先选能被清晰校验的表示;如果协议已经发布,优先维护读入兼容。enum tag 看似只是 JSON 形状选择,实际上会决定错误消息、回溯成本、缓存需求和未来演进空间。
把 tag 模式写进公共 API 前,最好用几份坏输入做反序列化测试:未知 tag、缺 content、重复字段、多个变体都可匹配。测试结果往往比格式示例更能暴露长期维护成本。
好的 enum 表示不是最短的表示,而是读入路径最明确、错误最可解释、未来最容易兼容的表示。这个判断标准比示例里少写几个字符更重要,也更接近长期维护的真实成本。
14.12 本章小结
Serde 对 enum 支持四种互不兼容的序列化策略:
| 策略 | 属性 | JSON 形状 | 何时使用 |
|---|---|---|---|
| External | 默认(无) | {"V": ...} | Rust 内部、性能优先 |
| Internal | #[serde(tag = "t")] | {"t": "V", ...} | 匹配 REST API 风格 |
| Adjacent | #[serde(tag = "t", content = "c")] | {"t": "V", "c": ...} | 工业级互操作 |
| Untagged | #[serde(untagged)] | 变体字段裸露 | 宽容接受多形态 |
实现层面:
- 四种策略对应
TagType枚举的四个变体。 - 属性解析把
#[serde(...)]转换成TagType。 - Serialize 和 Deserialize 都按 TagType 分派到独立生成函数。
- Deserialize 侧 Internal/Adjacent/Untagged 都依赖
Content中间类型做缓存(因为 tag 位置不定或完全没有 tag)。
关键权衡:性能(External)vs 灵活性(Untagged)vs 互操作性(Internal/Adjacent)。
第 10-14 章至此完整拆解了 serde_derive——从架构总览、属性系统、Serialize/Deserialize 代码生成到 enum 的 4 种 tag 策略。你现在能够:
- 读懂 serde_derive 任何一段源码,知道它在整个架构里的位置。
- 理解任何
#[serde(...)]属性对生成代码的精确影响。 - 自己实现类似规模的 derive 宏。
下一章进入第五部分 高阶主题。第 15 章讲借用反序列化——&'de str 是怎么从 'de 输入借用的,Serde 的生命周期故事。这是 Serde 最令人困惑也最精妙的一块。
动手实验
- 四种 tag 模式对比:写一个 3 变体的 enum,分别用 4 种 tag 属性,
cargo expand观察生成代码差异。对比 Serialize 和 Deserialize 侧各自的差异。 - untagged 的歧义:写一个 untagged enum,两个变体都是
struct { x: i32 }和struct { x: i32, y: i32 }。测试输入{"x": 1}和{"x": 1, "y": 2}分别匹配哪个。调换变体顺序再测试。 - 读 enum_adjacently.rs:324 行的 Adjacent 反序列化代码,找到处理"tag 和 content 顺序不定"的核心逻辑。这是四种模式里最复杂的生成逻辑。
- 思考:如果你要设计第 5 种 tag 模式(比如"tag 在元数据里不在数据里"),你会加什么?为什么 Serde 没加?
延伸阅读
- Serde "Enum representations" 官方文档:四种 tag 模式的权威说明。
- serde_derive/src/de/enum_*.rs 四个文件:四种 tag 的完整实现源码。
- Content / ContentDeserializer 源码:internally/adjacently/untagged 都依赖的缓存中间类型。
- 丛书《MCP 协议设计与实现》第 3 章:看 internally tagged 模式在真实协议里的应用。
- **丛书《LangChain 设计与实现》**相关章节:看 adjacently tagged 模式在 LLM tool-call 协议里的应用。