Skip to content

第 14 章 Enum 的四种 tag 策略

14.1 同一个 enum,四种 JSON 编码

Serde 对 enum 的序列化支持四种互不兼容的策略。同一个 enum:

rust
#[derive(Serialize, Deserialize)]
enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}

能编码成四种完全不同的 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}

每种格式都有自己的用途:

  • 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}}

关键特征: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()

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. 变体标识符:和 struct 的字段标识符 __Field 结构相同(见第 13 章)。
  2. 主 Visitor 实现 visit_enum:这是 externally tagged 独有的——Deserializer 会先调用 visit_enum
  3. EnumAccess::variant 读 tag:第一步读出变体名(作为 __Field)。
  4. 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>;
}

这是 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 },
}

序列化成:

json
{"type": "Click", "x": 10, "y": 20}
{"type": "KeyPress", "key": "a"}

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)

注意是 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(...)),
}

关键是 Content 中间类型——它把 JSON 输入先缓存到内存,再两次扫描:

  1. 第一次找 tag
  2. 根据 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 },
}

序列化:

json
{"t": "Start"}
{"t": "Stop"}
{"t": "Move", "c": {"dx": 5, "dy": 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)

注意 &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)

只写 tag,没有 content——匹配 JSON {"t": "Start"}

反序列化serde_derive/src/de/enum_adjacently.rs:324):生成代码很长(324 行的源生成)。核心逻辑:

  1. 外层是 struct Visitor,只允许两个 key:tc
  2. 读 t 时:解析出变体名,存起来。
  3. 读 c 时必须等 t 已经读到,否则先把 c 的内容暂存为 Content(中间类型),然后等 t 出现后再解析。
  4. 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 */,
}

TagOrContentFieldserde::__private::de 里定义的内部 enum——只有两个 variantTag / 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>),
}

序列化:

json
42.0           // Number(42.0)
"hello"        // String("hello")
[1, 2, 3]      // Array([Number(1.0), Number(2.0), Number(3.0)])

完全没有 tag——靠反序列化时尝试每个变体,哪个成功就用哪个。

序列化生成代码serde_derive/src/ser.rs:765):对 Number(42.0)

rust
_serde::Serialize::serialize(&42.0_f64, __serializer)

就是字段的 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"))

思路:把输入先缓存为 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>)>),
}

实测 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-value crate、不要直接用 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 的关键技术——一次缓冲、多次尝试——靠 ContentRefDeserializerserde/src/private/de.rs:1992)实现:

rust
pub struct ContentRefDeserializer<'a, 'de: 'a, E> {
    content: &'a Content<'de>,   // ← 借用 Content,不拷贝
    err: PhantomData<E>,
}

关键是它只借用 &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 被重放
}
// ...

这种**"输入 → 内存对象 → 重放 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_.rs964 种 tag 模式的分派入口(§14.7)
enum_externally.rs212External 反序列化生成
enum_internally.rs106Internal 反序列化生成
enum_adjacently.rs323Adjacent 反序列化生成(最复杂)
enum_untagged.rs135Untagged 反序列化生成(最短)
identifier.rs477__Field 标识符 enum 的生成器(4 种模式都用)
struct_.rs697struct 反序列化(不限 enum)
tuple.rs283tuple struct/variant 处理
unit.rs52unit 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)
    }
}

477 行支持的能力——

  • 三种 visit 路径visit_u64(按索引)、visit_str(按名字)、visit_bytes(按字节字符串)——格式自由选用
  • __ignore variant:处理"不认识的字段"——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),
    }
}

这是经典的"策略模式"——TagType 是策略 enum,每种策略有独立实现。新增 tag 模式只需要加一个 enum 变体和一个模块。

enum_.rs 还提供一些通用工具:

  • prepare_enum_variant_enum:生成所有变体共用的 __Field enum(外部/邻接模式都要用)。
  • 变体名到索引的转换。
  • 变体级别属性的处理(#[serde(rename)] 在变体上)。

14.8 serialize 端的分派

Serialize 侧的分派在 serde_derive/src/ser.rs:421serialize_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(...),
};

和 Deserialize 侧完全对称。实测 ser.rs 里 4 个函数的位置与篇幅——

函数起始行大致长度
serialize_externally_tagged_variant503~73 行
serialize_internally_tagged_variant576~66 行
serialize_adjacently_tagged_variant642~124 行(最长)
serialize_untagged_variant766~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),  // ❌ 编译错!
}

因为 tuple variant 没有字段名,没地方放 tag。check.rs 会拒绝这种组合。

坑 2:Untagged 的 Any 类型

rust
#[serde(untagged)]
enum V {
    Any(serde_json::Value),  // 匹配任何 JSON
    Specific(f64),             // 仅匹配数字
}

Any 永远先匹配成功,Specific 永远不会被用到。变体顺序至关重要——把"更具体"的放前面。

坑 3:Adjacently 的 unit variant

rust
#[serde(tag = "t", content = "c")]
enum E { A, B { x: i32 } }

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 表示只是几个属性:默认、tagtag + contentuntagged。从 serde_derive 源码看,它们是四套不同控制流。

tag 模式Deserialize 源码锚点核心动作为什么不同
Externalserde_derive/src/de/enum_externally.rs:105-113Deserializer::deserialize_enum,让格式提供 varianttag 在对象外层,格式可以直接读到 variant 名
Internalserde_derive/src/de/enum_internally.rs:54-57TaggedContentVisitor 拆出 tag 和 contenttag 混在对象字段里,要先缓存内容
Adjacentserde_derive/src/de/enum_adjacently.rs:170-177先构造 seed,再用 ContentDeserializer 反序列化 contenttag 和 content 两个字段顺序可变,要同时处理重复和缺失
Untaggedserde_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-494TagType 调到 serialize_externally_tagged_variantserialize_internally_tagged_variantserialize_adjacently_tagged_variantserialize_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 最令人困惑也最精妙的一块。

动手实验

  1. 四种 tag 模式对比:写一个 3 变体的 enum,分别用 4 种 tag 属性,cargo expand 观察生成代码差异。对比 Serialize 和 Deserialize 侧各自的差异。
  2. untagged 的歧义:写一个 untagged enum,两个变体都是 struct { x: i32 }struct { x: i32, y: i32 }。测试输入 {"x": 1}{"x": 1, "y": 2} 分别匹配哪个。调换变体顺序再测试。
  3. 读 enum_adjacently.rs:324 行的 Adjacent 反序列化代码,找到处理"tag 和 content 顺序不定"的核心逻辑。这是四种模式里最复杂的生成逻辑。
  4. 思考:如果你要设计第 5 种 tag 模式(比如"tag 在元数据里不在数据里"),你会加什么?为什么 Serde 没加?

延伸阅读

基于 VitePress 构建