Swift Digest
SE-0320 | Swift Evolution

Allow coding of non String / Int keyed Dictionary into a KeyedContainer

Proposal
SE-0320
Authors
Morten Bek Ditlevsen
Review Manager
Tom Doron
Status
Implemented (Swift 5.6)

01 何が問題だったのか

Swift の DictionaryCodable に適合していますが、キーの型が StringInt でない場合、エンコード結果は直感に反する形になります。具体的には、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 プロトコルを追加します。キーの型がこのプロトコルに適合している場合、DictionaryKeyedContainer としてエンコード/デコードされるようになります。

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 をジェネリック制約として書いたときに StringInt が自然に使えるよう、標準ライブラリ側でこれらの型にも適合が追加されます。

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 が StringInt でない型(たとえば Int8 をキーに使いたい場合)では、codingKey とイニシャライザを自前で実装することになります。

適合を追加する際の注意

このプロトコルの導入自体は加算的な変更で、既存コードへの直接的な影響はありません。ただし、すでに Dictionary のキーとしてエンコードされている既存の型に後から CodingKeyRepresentable への適合を追加すると、その型を含むアーカイブのフォーマットが変わり、過去のアーカイブと互換性が無くなります。したがって、CodingKeyRepresentable への適合は、新しい型や新たに Codable にする型に対して追加するのが安全です。

また、標準ライブラリや Foundation の既存の型(たとえば UUID など)には、後方互換性の観点からデフォルトでは CodingKeyRepresentable への適合は追加されません。そうした型をキーとして KeyedContainer にエンコードしたい場合は、独自のラッパー型を用意してそちらを CodingKeyRepresentable に適合させる、という対応が推奨されます。