再帰的な Swift enum でプレーンな入れ子の値を保持するには、値の種類ごとにカスタムのエンコードとデコードを実装します。この手順では、enum ケースのラッパーを追加せず、メモリ内だけで Foundation オブジェクトとの往復変換を行う、架空の Mac アプリのモデルを作成します。
合成された Codable はなぜ表現を変えるのか?
Mac のデスクトップアプリに、ラベル、数値、スイッチ、リスト、入れ子のグループを含む小さなレイアウトプレビューがあるとします。再帰的な enum を使えば、それらの値を正確に記述できます。ここでの具体的な課題は、その enum をプレーンな Foundation オブジェクトグラフに変換し、対応する値の種類を保持したまま復元することです。
自動的な準拠では、必要な表現を得られません。Swift で実装されている enum の合成設計では、外側の keyed container にケース名を置き、その内側の別のコンテナに関連値を格納します。ラベルのない関連値には、_0 のような生成キーが割り当てられます。その結果、文字列のケースはプレーンな文字列になるのではなく、構造上のラッパーを持つようになります。これらの規則は 関連値を持つ enum の Codable 合成に記載されています。
CodingKeys を変更すればラッパーの名前は変えられますが、合成を使い続ける限り構造モデルも維持されます。対応する方法は、init(from:) と encode(to:) を自分で実装することです。Swift の構造とエンコード後の構造が異なる型では手動で準拠を実装するよう、Apple は カスタム型のエンコードとデコードで明示しています。
再帰性と表現は別々に決めるものです。Swift の indirect 修飾子は再帰的な enum の格納を可能にし、カスタムのコーディングメソッドがエンコード後の形を決めます。言語ガイドでは、関連値のケースに間接参照を適用するため、enum 宣言の前に indirect を置く方法を列挙型で説明しています。
プレーンな表現では何を保持すべきか?
この例では、次の仕様を使います。架空のレイアウトプレビューに適した、小規模な値の語彙にあえて絞っています。Foundation の文書化された変換規則では、文字列、数値、配列、辞書、null オブジェクトが許可され、辞書のキーは文字列である必要があり、有限でない数値は除外されます。JSONSerializationを参照してください。
| Enum ケース | プレーン値 | 互換性の要件 |
|---|---|---|
null |
NSNull |
明示的な null 値を保持する |
bool |
Boolean 値 | 数値のゼロや 1 と区別できるようにする |
number |
有限の Double 値 |
Double の精度制限を許容する |
string |
文字列 | 数値に変換せずテキストを保持する |
array |
プレーン値の配列 | 要素の順序と空配列を保持する |
object |
文字列キーの辞書 | キー、入れ子の値、空オブジェクトを保持する |
ここでいう互換性とは、定めた数値の制限内で、これらのカテゴリと値を保持することです。特定の Foundation 数値サブクラスや辞書キーの表示順を保持する必要はありません。また、明示的な null エントリも維持されなければなりません。"subtitle": NSNull() を含むオブジェクトは、subtitle キーを持たないオブジェクトとはモデル上の意味が異なります。
この仕様を採用する前に、数値を本当に単一の Double ケースで扱えるか判断してください。Apple は Double を 64 ビット浮動小数点型として説明し、通常の変換では丸めが起こり得るため、正確な変換を行うイニシャライザも提供しています。大きな整数を正確に扱う必要があるモデルでは、変換する前に明示的な数値ポリシーを決めてください。整数ケースを追加する場合は、プレーンな数値から Swift の enum ケースを特定できないため、デコード規則も必要です。Doubleを参照してください。
再帰的な enum はどのように実装するか?
Foundation を利用できる、現行の Mac 用 Swift 環境を使います。次の 3 つのコードブロックは、この順番でトップレベルに配置すると、enum、変換関数、実行可能なチェックルーチンからなる完全な例になります。サンプル値はすべてメモリ内で作成します。調査環境では 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でプリミティブ型とジェネリック型のエンコード操作を、カスタム型のエンコードとデコードでコレクションの準拠を説明しています。
配列の判定と配列要素のデコードは意図的に分けています。現在の値が配列でないとき、unkeyed container を要求すると型の不一致がスローされることがあります。配列と判定された後は、各要素を通常の try でデコードするため、要素のデコードに失敗しても、そのエラーは関係のないオブジェクトのデコードを試さずに呼び出し元へ伝わります。文書化された動作については unkeyedContainer() と UnkeyedDecodingContainerを参照してください。
この実装は、Foundation の JSON コーディング経路と、ここで定めた値の語彙を対象としています。他のエンコーダーやデコーダーで使用する前に、同じ仕様に照らして動作をテストしてください。Codable への準拠だけでは、表現形式をまたいで同じ動作をすることは保証されません。
Foundation オブジェクトへ完全にメモリ内で変換するには?
Foundation には便利な境界が 2 つあります。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 はメモリ内に保持されます。これらの関数はシリアライズ処理を行います。enum と Foundation コレクションの間でゼロコピーのキャストを行うわけではありません。型付きモデル操作には enum を使い続け、デスクトップ機能がプレーンなオブジェクトグラフを必要とする境界で変換してください。
再帰的な値のルートがスカラーの場合があるため、.fragmentsAllowed の 2 つのオプションはいずれも重要です。トップレベルの文字列、Boolean、数値、null にも、入れ子の値と同じ扱いが必要です。Foundation はこの動作を ReadingOptions.fragmentsAllowed と WritingOptions.fragmentsAllowedでそれぞれ説明しています。
逆変換関数は Foundation API の入力型に合わせて Any を受け取りますが、対応する値の範囲は引き続き上記の仕様に従います。変換エラーは呼び出し元へ伝わります。変換に失敗したときに空オブジェクトへ置き換えたり、変換に失敗した要素を削除したりしないでください。そうするとモデルが暗黙に変わってしまいます。
互換性を示すには、どのようなチェックが必要か?
往復変換だけでは、最初の問題を見逃すことがあります。合成された enum コーディングでも、ラップされた独自の構造を正常に往復変換できるからです。enum の等価性とプレーンな形状の両方を確認してください。次のルーチンは、独立した Foundation グラフを作成し、期待される enum モデルを検証したうえで、出力の実際のルートキーを確認し、各ルートカテゴリを試します。
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. です。ルートキーのチェックでは、外側にケースラッパーが追加されていないかを検出します。独立した期待モデルでは、Boolean と数値、数値に見えるテキストと数値など、誤ったケース選択を検出します。null のチェックでは subtitle が維持されていることを確認し、ルートのチェックではスカラーフラグメントを網羅するとともに、空配列と空オブジェクトを区別します。
既存のデスクトップモデルでは、この診断手順をモデルの架空の値に合わせて調整してください。まず期待する enum を検証し、次にプレーンな形を調べ、その後 enum を復元し、最後に境界ケースを試します。どの変換で値が変わったか失敗時に特定できるよう、中間結果には名前を付けてください。
この方法にはどのような制限があるか?
主な制限は数値の仕様です。単一の Double ケースでは、Foundation のあらゆる数値入力を正確に保持できるとは限りません。整数を正確に変換する必要がある場合は、このモデルに入れる前に表現可能か検証するか、数値ケースとそのデコード規則を再設計してください。シリアライズが成功しても、数値が正確に保持された証拠にはなりません。Apple の Double のドキュメントでは、丸めを伴う変換と正確な変換が区別されています。
この例では、両方のコーディング方向で有限でない数値も明示的に拒否します。これにより、enum の境界をプレーンな Foundation 表現の文書化された制限に合わせられます。再帰構造が無制限の入れ子に対応するわけではないため、架空のモデルツリーは適度な大きさに保ち、変換エラーは呼び出し元の機能へ伝えてください。
レイアウトプレビューの課題に対する完成形は、カスタムコーディングによってプレーンな文字列、Boolean、有限の数値、null、配列、文字列キーのオブジェクトを保持する、型付きの再帰的な enum です。ケースや変換ロジックを変更するときは、往復変換のチェックと併せて形状チェックも行ってください。両方を使うことで、意図した表現を具体的に確認できます。
出典
- 関連値を持つ enum の 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。