再帰的な 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 です。ケースや変換ロジックを変更するときは、往復変換のチェックと併せて形状チェックも行ってください。両方を使うことで、意図した表現を具体的に確認できます。

出典