SwiftのCodableモデルを古いインメモリ表現と互換性を保つには、本当に値が存在しないフィールドをオプショナルにし、デコード時のフォールバックを明示的に実装します。宣言時にデフォルト値を指定するだけでは、エンコードされたデータにフィールドがない場合でもデコードが成功するようにはなりません。
このガイドは、シンプルなデスクトップレイアウトモデルを更新するMac開発者向けです。具体的な目的は、改訂したモデルで古い合成表現を受け入れられるようにしつつ、渡された値を保持し、互換性のない型をエラーとして報告することです。すべての例で、インメモリの値を作成して使用します。
フィールドをオプショナルにしたり、デフォルト値を追加したりすると何が変わりますか?
Codableはエンコードとデコードを組み合わせたものです。格納プロパティが必要なプロトコルに準拠していれば、Swiftはこれらの実装を自動合成できます。これにより定型コードを省けますが、コンパイルが通る変更であっても、モデルが受け入れる表現が変わることがあります。Appleはカスタム型のエンコードとデコードで、自動準拠とカスタム実装の両方を説明しています。
必須のタイトルとサブタイトルを持つレイアウトタイルを考えてみましょう。ここで、サブタイトルをオプショナルにし、新しい列数のデフォルト値を3にしたいとします。これは別々の判断です。サブタイトルには値がない場合がありますが、列数にはデコード後に常に使用可能な値が必要です。
合成デコードの対象となる通常の可変プロパティでは、Swiftはオプショナルプロパティについて、キーの欠落またはnull値を許容します。宣言時の初期値はデコード時のフォールバックではありません。nil以外の値で初期化されたオプショナルプロパティでも、nilとしてデコードされることがあり、宣言時にデフォルト値を指定した非オプショナルプロパティでも、キーがなければ失敗することがあります。SwiftチームによるCodable合成の解説では、この意図的な動作が説明されています。
次の判断表は、これらのルールをフィールドの方針に置き換えたものです。整数フィールドと、Intに変換可能な値が存在するケースを想定しています。最後の2行にはカスタムデコードが必要です。
| フィールドの方針 | キーがない | 明示的なnull | 有効な整数 | 互換性のない型 |
|---|---|---|---|---|
合成されたvar columns: Int |
エラー | エラー | 値を使う | エラー |
合成されたvar columns: Int = 3 |
エラー | エラー | 値を使う | エラー |
合成されたvar columns: Int? |
nil |
nil |
値を使う | エラー |
| 欠落とnullに対するカスタムフォールバック | 3 |
3 |
値を使う | エラー |
| 欠落時のみのカスタムフォールバック | 3 |
エラー | 値を使う | エラー |
この違いはコンテナのメソッドによるものです。decode(_:forKey:)はエントリが存在し、かつnullではなく変換可能な値であることを要求します。decodeIfPresent(_:forKey:)はキーがない場合もnullの場合もnilを返しますが、互換性のない型では引き続きエラーをスローします。後者の結果に?? 3を適用すると、フォールバック値が使われます。
インメモリで互換性の方針を実装して確認する
Foundationが利用可能で、既存のPlaygroundなどトップレベル文をサポートするMac上のSwift実行環境で、この例を使ってください。ブロック全体をまとめて実行します。FoundationのJSONEncoderはData表現を生成し、JSONDecoderはそこから新しい型付きの値を作成します。この例では、リテラルで記述したエンコード済みドキュメントも外部入力も使いません。
選択した方針は明示的です。titleは引き続き必須とし、subtitleがない場合やnullの場合はnilにし、columnsがない場合やnullの場合は3にします。列数の値が存在していて互換性のない型であれば、引き続きエラーになります。カスタムイニシャライザがデコードを処理し、モデルのCodingKeysに基づくエンコードは引き続き合成されます。Appleはカスタム型のエンコードとデコードでこのカスタマイズ方法を説明しています。
合成フィクスチャと確認処理を含む完全なプログラムです。調査環境ではSwiftを利用できなかったため、文書化されたAPIとの整合性は確認しましたが、実行はしていません。
import Foundation
struct LegacyTile: Codable {
var title: String
var subtitle: String
}
struct Tile: Codable, Equatable {
static let defaultColumns = 3
var title: String
var subtitle: String?
var columns: Int = Tile.defaultColumns
enum CodingKeys: String, CodingKey {
case title, subtitle, columns
}
}
extension Tile {
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
title = try values.decode(String.self, forKey: .title)
subtitle = try values.decodeIfPresent(String.self, forKey: .subtitle)
columns = try values.decodeIfPresent(Int.self, forKey: .columns)
?? Self.defaultColumns
}
}
struct MinimalTile: Encodable {
var title: String
}
struct NullTile: Encodable {
enum CodingKeys: String, CodingKey {
case title, subtitle, columns
}
func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode("Circle", forKey: .title)
try values.encodeNil(forKey: .subtitle)
try values.encodeNil(forKey: .columns)
}
}
struct WrongColumnsTile: Encodable {
var title: String
var columns: String
}
let encoder = JSONEncoder()
let decoder = JSONDecoder()
// An older representation keeps its subtitle and gains the fallback.
let legacyBytes = try encoder.encode(
LegacyTile(title: "Circle", subtitle: "Blue")
)
let upgraded = try decoder.decode(Tile.self, from: legacyBytes)
precondition(upgraded.title == "Circle")
precondition(upgraded.subtitle == "Blue")
precondition(upgraded.columns == 3)
// Missing keys and explicit nulls follow the chosen policy.
let minimalBytes = try encoder.encode(MinimalTile(title: "Circle"))
let minimal = try decoder.decode(Tile.self, from: minimalBytes)
precondition(minimal.subtitle == nil)
precondition(minimal.columns == 3)
let nullBytes = try encoder.encode(NullTile())
let explicitNull = try decoder.decode(Tile.self, from: nullBytes)
precondition(explicitNull == minimal)
// A supplied nondefault value survives an encode/decode cycle.
let supplied = Tile(title: "Square", subtitle: "Green", columns: 5)
let suppliedBytes = try encoder.encode(supplied)
let restored = try decoder.decode(Tile.self, from: suppliedBytes)
precondition(restored == supplied)
// Incompatible types must remain visible as decoding failures.
let wrongBytes = try encoder.encode(
WrongColumnsTile(title: "Circle", columns: "wide")
)
do {
_ = try decoder.decode(Tile.self, from: wrongBytes)
preconditionFailure("Expected a type mismatch")
} catch DecodingError.typeMismatch(_, let context) {
precondition(context.codingPath.last?.stringValue == "columns")
}
// A newer representation may no longer satisfy an older reader.
let currentBytes = try encoder.encode(minimal)
do {
_ = try decoder.decode(LegacyTile.self, from: currentBytes)
preconditionFailure("Expected the required subtitle to be missing")
} catch DecodingError.keyNotFound(let key, _) {
precondition(key.stringValue == "subtitle")
}
print("All in-memory compatibility checks passed.")
期待される最終出力はAll in-memory compatibility checks passed.です。予期しないエラーは成功として扱われず、そのまま伝播します。フィクスチャは意図的に異なる表現を網羅しています。MinimalTileはフィールドを省略し、NullTileはencodeNil(forKey:)を使って明示的なnullエントリを作成します。
init(from:)を拡張に置くことで、構造体のメンバーごとのイニシャライザも引き続き使えるため、この例ではTile(title:subtitle:columns:)を構築できます。カスタムのデコード用イニシャライザはすべての格納インスタンスプロパティに値を代入するため、戻る時点で結果は完全に初期化されています。
nullにはデフォルト値を使うべきですか、それともエラーのままにすべきですか?
フィールドの意味に応じて選択します。この例のレイアウトでは、欠落とnullの両方を標準の列数として扱うのが妥当です。ただし、これはアプリケーション側で決めることです。decodeIfPresentはこの2つの状態を意図的にまとめ、フォールバックによって同じ結果を与えます。文書化された戻り値の動作から、その結果が明確に分かります。
欠落は「標準レイアウトを使う」という意味だが、渡されたnullはエラーにしたい場合は、プログラム内のinit(from:)にあるcolumnsの代入だけを、次の断片に置き換えてください。それ以外の前提と定義は同じです。
// Replacement fragment inside Tile.init(from:).
if values.contains(.columns) {
columns = try values.decode(Int.self, forKey: .columns)
} else {
columns = Self.defaultColumns
}
contains(_:)は、nullエントリを含め、エントリが存在するかどうかを報告します。そのため、明示的なnullではdecodeの分岐に進み、valueNotFoundが生成されます。デフォルト値が使われるのは欠落時だけです。この方針に変更した場合、元のプログラムにあるnullフィクスチャの確認も、minimalと等しいことではなく、そのエラーが発生することを期待する形に変更する必要があります。
すべてのデコード失敗を破棄するのが意図した動作でない限り、代入を(try? values.decode(Int.self, forKey: .columns)) ?? 3に変えないでください。この式は、渡された値の型に互換性がない場合も3に置き換えます。try decodeIfPresentを使えば、許容された省略と、フィールドで解釈できない表現との区別を保てます。これはデコードメソッドのエラー契約に定められています。
互換性は双方向に働きますか?
読み取り側ごとに確認してください。このプログラムは、改訂後の読み取り側が古い表現を受け入れることを示します。同時に、旧バージョンの読み取り側にはsubtitleが引き続き必須であり、そのフィールドが省略された新しい表現を拒否するという境界も示しています。Swiftの新しいプロパティをオプショナルにしても、古い型の要件は変わりません。
合成されたオプショナル値のエンコードでは、条件付きエンコードを通じてnil値が省略されます。SwiftのencodeIfPresent(_:forKey:)は、nilではない値だけをエンコードすると説明しています。encodeNilを使えば明示的なnullをエンコードできますが、値を必須とする古い非オプショナルのStringプロパティは、それでも満たせません。
古い読み取り側も引き続き使える必要がある場合は、その要件を満たす明示的な表現を定義してください。この合成例では、アダプターがエンコード前に選択したサブタイトルをLegacyTileに設定できます。サブタイトルがない場合に特定の文字列を割り当てるかどうかは別の製品判断です。汎用フォールバックの中に、その意味を暗黙に作り込まないでください。
値の保持と表現の保持も区別してください。この例では、サブタイトルの省略と明示的なnullのどちらも、同じSwift値nilになります。そのため、モデルからは受け取った入力がこの2つのどちらだったのかを再現できません。同様に、列数がない場合に3としてデコードし、モデルを再エンコードすると、具体的な列数が設定されます。これらは、選択したモデルとデコード方針から直接生じる結果です。
モデルを変更する前に何を確認すべきですか?
フィールドを変更するときは、小さな互換性マトリックスを使います。まず古い表現を用意し、次に一度に1つのフィールドだけを変えます。こうすれば、キーがない場合にのみ起きる失敗を、ラウンドトリップの成功で見落とすことを防げます。
- 古い入力: 改訂後の読み取り側がそれを受け入れ、既存の値を保持することを確認します。
- フィールドの欠落: 結果が意図したオプショナル値、フォールバック、またはエラーになることを確認します。
- 明示的なnull: 省略とは別にテストします。
- 値の指定: デコード時に上書きされないことを確かめるため、デフォルト値以外の値を使います。
- 互換性のない型: 変換方針がない場合、引き続きエラーがスローされることを確認します。
- 古い読み取り側: 改訂後にエンコードされた表現が引き続きその要件を満たすか確認します。
フォールバックを得るためだけに、CodingKeysからフィールドを削除しないでください。Appleの説明によると、プロパティを省略するとエンコードおよびデコード対象の表現から除外され、自動デコード準拠にはデフォルト値が必要です。これは、プロパティを意図的に表現の対象外にする場合に適していますが、そのプロパティに値が指定されても読み取られなくなります。Appleのコーディングキーに関するガイダンスを参照してください。
互換性のルールは、フィールドのデコードロジックのそばに置きます。意味のある欠落にはオプショナルを使い、許容する省略には明示的なフォールバックを設け、必須値には通常のエラーをスローするデコードを使います。インメモリのフィクスチャがあれば、モデルを変更するたびに意図した動作を確認できます。
出典
- カスタム型のエンコードとデコード — Apple
- JSONEncoder — Apple
- JSONDecoder — Apple
- JSON内で欠落しているオプショナル値のデコード — Swift Forums
- decodeIfPresent(_:forKey:) — Apple
- decode(_:forKey:) — Apple
- contains(_:) — Apple
- encodeNil(forKey:) — Apple
- encodeIfPresent(_:forKey:) — Apple