若要透過遞迴 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。
如何實作遞迴列舉?
使用可取得 Foundation 的目前版本 Mac Swift 環境。接下來三個程式碼區塊依序構成一個完整的頂層範例:列舉、轉換函式,以及可執行的檢查常式。所有範例值都在記憶體中建立。研究環境沒有 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()
預期的成功訊息為 所有相容性檢查均已通過。。根鍵檢查可以找出額外的外層案例包裝層。獨立的預期模型可以找出錯誤的案例選擇,包括布林值與數字、外觀像數字的文字與數字之間的混淆。null 檢查可確認 subtitle 仍存在;根節點檢查則涵蓋純量片段,並區分空陣列與空物件。
若要套用至現有桌面模型,請依其合成值調整這組診斷步驟:先驗證預期列舉,再檢查一般形狀,接著重建列舉,最後測試邊界案例。為中間結果命名,讓檢查失敗時能指出是哪一次轉換改變了值。
這種方法有哪些限制?
主要限制在於數值契約。單一 Double 案例無法保證精確保留每個 Foundation 數值輸入。如果精確整數轉換很重要,請在將數值放入此模型前先驗證其可表示性,或重新設計數值案例及其解碼規則。序列化成功並不代表數值已精確保留。Apple 的 Double 說明文件區分了經捨入的轉換與精確轉換。
本例也會在編碼和解碼兩個方向明確拒絕非有限數值。這讓列舉的介面與一般 Foundation 表示方式的文件限制一致。遞迴結構不代表巢狀深度沒有上限,因此請讓合成模型樹維持合理大小,並將轉換錯誤傳給呼叫端功能。
對版面配置預覽任務而言,完成後的結果是一個具型別的遞迴列舉,其自訂編碼會保留一般字串、布林值、有限數值、null、陣列和字串鍵物件。每當變更案例或轉換邏輯時,都應搭配往返檢查與形狀檢查;兩者合用,能讓預期的表示方式具體且便於檢視。
來源
- 含關聯值列舉的 Codable 合成 — Swift 專案。
- 列舉 — Swift 專案。
- 自訂型別的編碼與解碼 — Apple。
- SingleValueDecodingContainer — Apple。
- SingleValueEncodingContainer — Apple。
- unkeyedContainer() — Apple。
- UnkeyedDecodingContainer — Apple。
- JSONEncoder — Apple。
- JSONDecoder — Apple。
- JSONSerialization — Apple。
- ReadingOptions.fragmentsAllowed — Apple。
- WritingOptions.fragmentsAllowed — Apple。
- Double — Apple。