要让递归 Swift 枚举保留普通嵌套值,请为每种值类型实现自定义编码和解码。本指南构建了一个合成的 Mac app 模型,让它完全在内存中与 Foundation 对象往返转换,无需添加枚举用例包装。

为什么合成 Codable 会改变表示形式?

假设一个 Mac 桌面 app 有一个小型布局预览,其中包含标签、数值、开关、列表和嵌套分组。递归枚举可以准确描述这些值。这里的具体任务是将该枚举转换为普通 Foundation 对象图,并在保留所支持值类型的同时将其还原。

自动合规无法提供所需的表示形式。Swift 已实现的枚举合成设计会将用例名称放在外层键控容器中,然后将关联值放在另一个容器中。未命名的关联值会获得诸如 _0 这样的生成键。因此,字符串用例会带上结构性包装,而不是成为普通字符串。相关规则记录在带关联值枚举的 Codable 合成中。

更改 CodingKeys 可以重命名这些包装,但保留合成机制也就保留了它的结构模型。可行的做法是自行实现 init(from:) 和 encode(to:)。Apple 在编码和解码自定义类型中明确说明,对于 Swift 结构与编码结构不同的类型,可以手动实现合规。

递归性和表示形式是两个独立的决定。Swift 的 indirect 修饰符允许递归枚举存储;自定义编码方法则决定编码后的形状。语言指南在枚举中说明,应将 indirect 放在枚举声明前,以便对其关联值用例应用间接存储。

普通表示形式究竟需要保留什么?

本例采用以下约定。它有意支持适用于合成布局预览的一小组值类型。Foundation 记录的转换规则允许字符串、数字、数组、字典和 null 对象,要求字典键为字符串,并排除非有限数值。参见 JSONSerialization。

枚举用例 普通值 兼容性要求
null NSNull 保留显式 null 值
bool 布尔值 与数值 0 和 1 区分开
number 有限 Double 值 接受 Double 的精度限制
string 字符串 保留文本,不进行数值转换
array 普通值数组 保留元素顺序和空数组
object 字符串键字典 保留键、嵌套值和空对象

这里的兼容性意味着在规定的数值限制内保留这些类别及其值。它不要求保留特定的 Foundation 数字子类,也不要求保留字典键的顺序。显式 null 条目也必须保留:包含 "subtitle": NSNull() 的对象与不含 subtitle 键的对象在模型含义上不同。

采用此约定前,应先确定数字是否确实可以共用一个 Double 用例。Apple 将 Double 记录为 64 位浮点类型,并提供精确转换初始化器,因为常规转换可能会舍入。如果模型需要精确表示较大的整数,应在转换前确定明确的数值策略。添加整数用例也需要解码规则,因为普通数值不携带 Swift 枚举用例标识。参见 Double。

如何实现递归枚举?

使用当前的 Mac Swift 环境,并确保可以使用 Foundation。下面的三个代码块依次组成一个完整的顶层示例:枚举、转换函数和可执行的检查例程。所有示例值都在内存中创建。研究环境中没有 Swift,因此示例经过检查,但未执行。

解码器先检查 null,然后尝试支持的基本类型。Apple 的 SingleValueDecodingContainer 提供这些操作,包括 decodeNil()。本地辅助函数只会在类型不匹配时尝试另一种基本类型;其他解码错误都会向上传递。

import Foundation

indirect enum PlainValue: Codable, Equatable {
    case null
    case bool(Bool)
    case number(Double)
    case string(String)
    case array([PlainValue])
    case object([String: PlainValue])

    init(from decoder: Decoder) throws {
        let scalar = try decoder.singleValueContainer()

        if scalar.decodeNil() {
            self = .null
            return
        }

        func matching<T: Decodable>(_ type: T.Type) throws -> T? {
            do {
                return try scalar.decode(type)
            } catch DecodingError.typeMismatch(_, _) {
                return nil
            }
        }

        if let value = try matching(Bool.self) {
            self = .bool(value)
            return
        }
        if let value = try matching(String.self) {
            self = .string(value)
            return
        }
        if let value = try matching(Double.self) {
            guard value.isFinite else {
                throw DecodingError.dataCorrupted(.init(
                    codingPath: decoder.codingPath,
                    debugDescription: "Expected a finite number."
                ))
            }
            self = .number(value)
            return
        }

        var sequence: UnkeyedDecodingContainer?
        do {
            sequence = try decoder.unkeyedContainer()
        } catch DecodingError.typeMismatch(_, _) {
            sequence = nil
        }

        if var items = sequence {
            var values: [PlainValue] = []
            while !items.isAtEnd {
                values.append(try items.decode(PlainValue.self))
            }
            self = .array(values)
            return
        }

        self = .object(try scalar.decode([String: PlainValue].self))
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()

        switch self {
        case .null:
            try container.encodeNil()
        case .bool(let value):
            try container.encode(value)
        case .number(let value):
            guard value.isFinite else {
                throw EncodingError.invalidValue(value, .init(
                    codingPath: encoder.codingPath,
                    debugDescription: "Expected a finite number."
                ))
            }
            try container.encode(value)
        case .string(let value):
            try container.encode(value)
        case .array(let values):
            try container.encode(values)
        case .object(let values):
            try container.encode(values)
        }
    }
}

每个编码分支都会直接写入其负载。null 分支会显式调用 encodeNil(),集合分支则委托给数组和字典的编码实现。Apple 在 SingleValueEncodingContainer 中记录了基本类型和泛型编码操作,并在编码和解码自定义类型中记录了集合合规。

数组检测与数组元素解码是分开的。如果当前值不是数组,请求无键容器可能会抛出类型不匹配错误。确认是数组后,每个元素都会通过常规的 try 解码,因此元素解码失败会直接传出,而不会触发无关的对象解码尝试。相关行为记录在 unkeyedContainer() 和 UnkeyedDecodingContainer 中。

此实现面向 Foundation 的 JSON 编码路径和上述值类型约定。使用其他编码器或解码器之前,应根据相同约定进行测试;仅仅符合 Codable 并不能保证不同表示格式之间的行为一致。

如何完全在内存中转换为 Foundation 对象?

Foundation 提供了两个实用边界:JSONEncoder 会从可编码值生成 Data,JSONDecoder 会从 Data 还原可解码值。JSONSerialization 可以在这种中间表示与 Foundation 对象之间进行转换。结合这些已记录的操作,即可构建以下内存桥接。参见 JSONEncoder、JSONDecoder 和 JSONSerialization。

func foundationObject(from value: PlainValue) throws -> Any {
    let bytes = try JSONEncoder().encode(value)
    return try JSONSerialization.jsonObject(
        with: bytes,
        options: [.fragmentsAllowed]
    )
}

func plainValue(from object: Any) throws -> PlainValue {
    let bytes = try JSONSerialization.data(
        withJSONObject: object,
        options: [.fragmentsAllowed]
    )
    return try JSONDecoder().decode(PlainValue.self, from: bytes)
}

中间的 Data 会保留在内存中。这些函数执行序列化工作;它们不是枚举与 Foundation 集合之间的零拷贝类型转换。应在桌面功能实际需要普通对象图的边界处进行转换,同时保留枚举用于类型化模型操作。

.fragmentsAllowed 的两个选项都很重要,因为递归值可能以标量为根。顶层字符串、布尔值、数字或 null 都需要与嵌套值相同的处理方式。Foundation 在 ReadingOptions.fragmentsAllowed 和 WritingOptions.fragmentsAllowed 中分别记录了这一行为。

反向函数接受 Any,因为这是 Foundation API 的输入类型,但其支持范围仍受上述约定限制。转换错误会传递给调用方。不要把转换失败替换成空对象,也不要移除转换失败的元素;这样会悄无声息地改变模型。

哪些检查可以证明兼容性?

仅检查往返转换可能会漏掉原始问题:合成的枚举编码可以成功往返其自身带包装的结构。应同时检查枚举相等性和普通结构。以下例程会构造一个独立的 Foundation 对象图,验证其预期枚举模型,检查输出的实际根键,并测试每种根类别。

func runExample() throws {
    let plain: [String: Any] = [
        "title": "Tile preview",
        "enabled": true,
        "spacing": 8.5,
        "subtitle": NSNull(),
        "tiles": [
            ["label": "Circle", "size": 24.0],
            ["label": "Square", "size": 32.0]
        ]
    ]

    let expected: PlainValue = .object([
        "title": .string("Tile preview"),
        "enabled": .bool(true),
        "spacing": .number(8.5),
        "subtitle": .null,
        "tiles": .array([
            .object([
                "label": .string("Circle"),
                "size": .number(24)
            ]),
            .object([
                "label": .string("Square"),
                "size": .number(32)
            ])
        ])
    ])

    let model = try plainValue(from: plain)
    precondition(model == expected)

    let output = try foundationObject(from: model)
    guard let object = output as? [String: Any] else {
        preconditionFailure("Expected a plain object root.")
    }
    precondition(Set(object.keys) == Set(plain.keys))
    precondition(object["title"] as? String == "Tile preview")
    precondition(object["subtitle"] is NSNull)
    precondition((object["tiles"] as? [Any])?.count == 2)

    let restored = try plainValue(from: output)
    precondition(restored == expected)

    let roots: [PlainValue] = [
        .null, .bool(false), .bool(true),
        .number(0), .number(1), .number(8.5),
        .string("1"), .array([]), .object([:]),
        expected
    ]
    for root in roots {
        let graph = try foundationObject(from: root)
        let decoded = try plainValue(from: graph)
        precondition(decoded == root)
    }

    do {
        _ = try foundationObject(from: .number(.infinity))
        preconditionFailure("Expected nonfinite number rejection.")
    } catch EncodingError.invalidValue(_, _) {
        // Expected: this model requires finite numbers.
    }

    print("All compatibility checks passed.")
}

try runExample()

预期的成功消息是 All compatibility checks passed.。根键检查可以发现额外添加的外层用例包装。独立的预期模型可以发现错误的用例选择,包括布尔值与数字的混淆,以及看起来像数字的文本与数字的混淆。null 检查确认 subtitle 仍然存在,而根值检查覆盖标量片段,并区分空数组和空对象。

对于现有桌面模型,可以针对其合成值调整此诊断顺序:先验证预期枚举,再检查普通结构,然后还原枚举,最后测试边界情况。给中间结果命名,以便检查失败时能指出哪个转换改变了值。

这种做法有哪些限制?

数值约定是主要限制。单个 Double 用例无法保证精确保留所有 Foundation 数值输入。如果需要精确转换整数,应在进入此模型前验证可表示性,或重新设计数值用例及其解码规则。序列化成功并不能证明数值被精确保留。Apple 的 Double 文档区分了舍入转换与精确转换。

此示例还会在编码和解码两个方向显式拒绝非有限数值。这样可确保枚举边界符合普通 Foundation 表示形式中记录的限制。递归结构并不意味着嵌套容量无限,因此合成模型树应保持合理大小,并让转换错误传递到调用功能。

对于布局预览任务,最终结果是一个类型化递归枚举,其自定义编码能够保留普通字符串、布尔值、有限数值、null、数组和字符串键对象。每次更改用例或转换逻辑时,都应将结构检查与往返检查结合使用;两者共同明确呈现预期表示形式,便于审查。

来源