Allow coding of non String / Int keyed Dictionary into a KeyedContainer
01 何が問題だったのか
Swift の Dictionary は Codable に適合していますが、キーの型が String や Int でない場合、エンコード結果は直感に反する形になります。具体的には、KeyedContainer(JSON であればオブジェクト)ではなく、UnkeyedContainer(キーと値が交互に並ぶ配列)としてエンコードされます。
たとえば String をベースとした RawRepresentable な enum をキーにしても、オブジェクトにはならず配列にシリアライズされてしまいます。
enum Category: String, Codable {
case food
case book
}
let counts: [Category: Int] = [.food: 3, .book: 2]
let json = try JSONEncoder().encode(counts)
print(String(data: json, encoding: .utf8)!)
// ["food", 3, "book", 2] のように配列で出力される
この挙動は次のようなケースでよく遭遇し、多くの混乱を招いていました。
String/Intを raw value に持つ enum をキーにするときStringをラップした独自型(Tagged ライブラリなどで生成したタグ付き型)をキーにするときInt8などString/Int以外の整数型をキーにするとき
本来は、キーが String / Int にそのまま対応できる場合は KeyedContainer にエンコードされる方が自然です。しかし、いまからデフォルトの挙動を変えてしまうと、
- 新しいコードで古いアーカイブを読めなくなり、逆も同様で、後方互換性を壊す
- 挙動が標準ライブラリに結びついているため、利用側の OS バージョン次第でエンコード結果が変わってしまう
という問題があり、既存の挙動をそのまま変更することはできません。そのため、後方互換性を保ちつつ、ユーザーがオプトインで KeyedContainer へのエンコードを選べる仕組みが必要でした。
02 どのように解決されるのか
標準ライブラリに CodingKeyRepresentable プロトコルを追加します。キーの型がこのプロトコルに適合している場合、Dictionary は KeyedContainer としてエンコード/デコードされるようになります。
public protocol CodingKeyRepresentable {
var codingKey: CodingKey { get }
init?<T: CodingKey>(codingKey: T)
}
適合する型は、自身を表す CodingKey を提供する codingKey プロパティと、CodingKey から自身を復元する失敗可能イニシャライザを実装します。デフォルトの挙動を変えるのではなく、オプトインで新しい挙動を選ぶ形になっているため、既存のアーカイブとの互換性も保たれます。
基本的な使い方
独自の識別子型をキーにしたい場合は、次のように CodingKeyRepresentable に適合させます。コード例にある _AnyCodingKey は、任意の String / Int 値を受け取れる汎用の CodingKey 実装です。
struct AnyCodingKey: CodingKey {
let stringValue: String
let intValue: Int?
init(stringValue: String) {
self.stringValue = stringValue
self.intValue = Int(stringValue)
}
init(intValue: Int) {
self.stringValue = "\(intValue)"
self.intValue = intValue
}
}
struct ID: Hashable, CodingKeyRepresentable {
let stringValue: String
var codingKey: CodingKey {
AnyCodingKey(stringValue: stringValue)
}
init?<T: CodingKey>(codingKey: T) {
stringValue = codingKey.stringValue
}
init(stringValue: String) {
self.stringValue = stringValue
}
}
let data: [ID: String] = [
ID(stringValue: "id-1"): "foo",
ID(stringValue: "id-2"): "bar",
]
let encoder = JSONEncoder()
let json = try String(data: encoder.encode(data), encoding: .utf8)!
// {"id-1": "foo", "id-2": "bar"} のようにオブジェクトとしてエンコードされる
String / Int へのデフォルト適合
CodingKeyRepresentable をジェネリック制約として書いたときに String や Int が自然に使えるよう、標準ライブラリ側でこれらの型にも適合が追加されます。
RawRepresentable 向けのデフォルト実装
String または Int を raw value に持つ RawRepresentable には、CodingKeyRepresentable のデフォルト実装が用意されます。そのため、enum や薄いラッパー型であれば、空の適合を宣言するだけで済みます。
enum Category: String, Codable {
case food
case book
}
extension Category: CodingKeyRepresentable {}
let counts: [Category: Int] = [.food: 3, .book: 2]
let json = try JSONEncoder().encode(counts)
// {"food": 3, "book": 2} のようにオブジェクトとしてエンコードされる
raw value が String や Int でない型(たとえば Int8 をキーに使いたい場合)では、codingKey とイニシャライザを自前で実装することになります。
適合を追加する際の注意
このプロトコルの導入自体は加算的な変更で、既存コードへの直接的な影響はありません。ただし、すでに Dictionary のキーとしてエンコードされている既存の型に後から CodingKeyRepresentable への適合を追加すると、その型を含むアーカイブのフォーマットが変わり、過去のアーカイブと互換性が無くなります。したがって、CodingKeyRepresentable への適合は、新しい型や新たに Codable にする型に対して追加するのが安全です。
また、標準ライブラリや Foundation の既存の型(たとえば UUID など)には、後方互換性の観点からデフォルトでは CodingKeyRepresentable への適合は追加されません。そうした型をキーとして KeyedContainer にエンコードしたい場合は、独自のラッパー型を用意してそちらを CodingKeyRepresentable に適合させる、という対応が推奨されます。