要让递归 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、数组和字符串键对象。每次更改用例或转换逻辑时,都应将结构检查与往返检查结合使用;两者共同明确呈现预期表示形式,便于审查。
来源
- 带关联值枚举的 Codable 合成 — Swift Project。
- 枚举 — Swift Project。
- 编码和解码自定义类型 — Apple。
- SingleValueDecodingContainer — Apple。
- SingleValueEncodingContainer — Apple。
- unkeyedContainer() — Apple。
- UnkeyedDecodingContainer — Apple。
- JSONEncoder — Apple。
- JSONDecoder — Apple。
- JSONSerialization — Apple。
- ReadingOptions.fragmentsAllowed — Apple。
- WritingOptions.fragmentsAllowed — Apple。
- Double — Apple。