Skip to content

Latest commit

 

History

History
544 lines (408 loc) · 27.3 KB

Swift CodingKeyRepresentableでDictionaryを適切にエンコード、デコードする.md

File metadata and controls

544 lines (408 loc) · 27.3 KB

Swift CodingKeyRepresentableでDictionaryを適切にエンコード/デコードする

収録日: 2022/03/11

概要

Codableを使ってプレーンなStringInt以外の型をDictionaryのキーとして使用すると、期待とは異なる結果になっていた。今回新しくCodingKeyRepresentableプロトコルを導入してその問題を解消する。

内容

問題点

例えば、下記のようなRawValueStringenumDictionaryのキーに指定してJSONEncoderでエンコードするとキーバリューのペア(KeyedContainer)ではなく配列(UnkeyedContainer)になり、JSONDecoderでデコードするとエラーになる。

let json = "{\"key\": \"value\"}"
enum Key: String, Codable {
   case key
}
let jsonData = Data(json.utf8)
let dataToEncode = [Key.key: "value"]
do {
   let decoded = try JSONDecoder().decode([Key: String].self, from: jsonData)

   let encoded = try JSONEncoder().encode(dataToEncode)
   print(String(data: encoded, encoding: .utf8)!) // ①
} catch {
   print(error) // ②
}

①エンコード結果

[ "key", "value" ]

②デコード結果

typeMismatch(Swift.Array<Any>, Swift.DecodingError.Context(codingPath: [], debugDescription: "Expected to decode Array<Any> but found a dictionary instead.", underlyingError: nil))

特に

  • 上記で示したようなenum(特にStringもしくはIntRawValueにしたRawRepresentable)
  • StringのWrapperクラス(例: Tagged)
  • Int8などのInt*

をキーとして使用した場合に多くの人が混乱している。

※ 実際にStringIntは特別扱いされている。 https://github.com/apple/swift/blob/main/stdlib/public/core/Codable.swift#L5616 しかし、この既存の実装に修正を加えると

  1. これまでの動作を破壊してしまい、後方互換性に影響がある(新しいコードは過去のコードをでコードできない、逆も同様)。
  2. この動作は標準ライブラリと結びついているので、OSのバージョンによって動作が異なる。

そこで新しくCodingKeyRepresentableというプロトコルを提供し、これに準拠した型をDictionaryのキーとして使用することで、KeyedContainerとしてエンコード/デコードできるようにする。

解決方法

CodingKeyRepresentable

CodingKeyRepresentableに準拠した型はCodingKeyとして利用できることを示し、KeyedContainerにエンコードするためにそれらで定義されたCodingKeyDictionaryにオプトインで使用することができる。

このオプトインは、プロトコルが利用可能なバージョンのSwiftでのみ発生するため、ユーザは状況を完全に制御できる。例えば、現在、独自のワークアラウンドを使用していても、この機能を備えた特定の将来のSwiftバージョンを実行するiOSバージョンのみをサポートすると、独自のワークアラウンドをスキップして、代わりにこの動作に依存できる。

/// A type that can be converted to and from a coding key.
///
/// With a `CodingKeyRepresentable` type, you can losslessly convert between a
/// custom type and a `CodingKey` type.
///
/// Conforming a type to `CodingKeyRepresentable` lets you opt in to encoding
/// and decoding `Dictionary` values keyed by the conforming type to and from
/// a keyed container, rather than encoding and decoding the dictionary as an
/// unkeyed container of alternating key-value pairs.
@available(SwiftStdlib 5.6, *)
public protocol CodingKeyRepresentable {
  @available(SwiftStdlib 5.6, *)
  var codingKey: CodingKey { get }
  @available(SwiftStdlib 5.6, *)
  init?<T: CodingKey>(codingKey: T)
}

https://github.com/apple/swift/blob/4f7f9f5e615f815800d2c802d6daa39c5e5cf9a2/stdlib/public/core/Codable.swift#L5539

例:

RawValueがStringのenum
let json = "{\"key\": \"value\"}"
enum Key: String, Codable, CodingKeyRepresentable {
   case key
}
let jsonData = Data(json.utf8)
let dataToEncode = [Key.key: "value"]
do {
   let decoded = try JSONDecoder().decode([Key: String].self, from: jsonData)
   print(decoded) // ①
   let encoded = try JSONEncoder().encode(dataToEncode)
   print(String(data: encoded, encoding: .utf8)!) // ②
} catch {
   print(error)
}

①デコード結果

[main.Key.key: "value"]

②エンコード結果

{"key":"value"}
独自のstruct
// 標準ライブラリの_DictionaryCodingKeyと同じ
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, Codable {
    static let knownID1 = ID(stringValue: "<some-identifier-1>")
    static let knownID2 = ID(stringValue: "<some-identifier-2>")

    let stringValue: String

    var codingKey: CodingKey {
        return _AnyCodingKey(stringValue: stringValue)
    }

    init?<T: CodingKey>(codingKey: T) {
        stringValue = codingKey.stringValue
    }

    init(stringValue: String) {
        self.stringValue = stringValue
    }
}

let data: [ID: String] = [
    .knownID1: "...",
    .knownID2: "...",
]

let encoder = JSONEncoder()
try String(data: encoder.encode(data), encoding: .utf8)

/*
{
    "<some-identifier-1>": "...",
    "<some-identifier-2>": "...",
}
*/

let decoder = JSONDecoder()
try decoder.decode([ID: String].self, from: encoder.encode(data))

/*
[
    main.ID(stringValue: "<some-identifier-1>"): "...",
    main.ID(stringValue: "<some-identifier-2>"): "..."
]
*/

CodingKeyRepresentableに準拠した型のDictionaryへのエンコードの実装

} else if #available(SwiftStdlib 5.6, *), 
          Key.self is CodingKeyRepresentable.Type {
    // Since the keys are CodingKeyRepresentable, we can use the `codingKey`
    // to create `_DictionaryCodingKey` instances.
    var container = encoder.container(keyedBy: _DictionaryCodingKey.self)
    for (key, value) in self {
        let codingKey = (key as! CodingKeyRepresentable).codingKey
        let dictionaryCodingKey = _DictionaryCodingKey(codingKey: codingKey)
        try container.encode(value, forKey: dictionaryCodingKey)
    }
} else {
    // Keys are Encodable but not Strings or Ints, so we cannot arbitrarily

https://github.com/apple/swift/blob/4f7f9f5e615f815800d2c802d6daa39c5e5cf9a2/stdlib/public/core/Codable.swift#L5630

CodingKeyRepresentableに準拠した型のDictionaryからのデコードの実装

} else if #available(SwiftStdlib 5.6, *),
          Key.self is CodingKeyRepresentable.Type {
    // The keys are CodingKeyRepresentable, so we should be able to expect
    // a keyed container.
    let container = try decoder.container(keyedBy: _DictionaryCodingKey.self)
    for codingKey in container.allKeys {
        guard let key: Key = keyType.init(codingKey: codingKey) as? Key else {
            throw DecodingError.dataCorruptedError(
                forKey: codingKey,
                in: container,
                debugDescription: "Could not convert key to type \(Key.self)"
            )
        }
        let value: Value = try container.decode(Value.self, forKey: codingKey)
        self[key] = value
    }
} else {
    // Keys are Encodable but not Strings or Ints, so we cannot arbitrarily
    // convert to keys. We can encode as an array of alternating key-value
    // pairs, though.

https://github.com/apple/swift/blob/4f7f9f5e615f815800d2c802d6daa39c5e5cf9a2/stdlib/public/core/Codable.swift#L5694

RawValueがStringまたはIntのRawRepresentableのCodingKeyRepresentableにはデフォルト実装を提供

このプロポーザルの多くのユースケースで、CodingKeyRepresentableに準拠している型は(RawValueStringまたはIntの)RawRepresentableに既に準拠している。そこで、これらのケースで独自実装によって起こる不一致を避けるために、RawValueStringまたはIntの場合のRawRepresentableにデフォルト実装を提供する。

@available(SwiftStdlib 5.6, *)
extension RawRepresentable
where Self: CodingKeyRepresentable, RawValue == String {
    @available(SwiftStdlib 5.6, *)
    public var codingKey: CodingKey {
        _DictionaryCodingKey(stringValue: rawValue)
    }
    @available(SwiftStdlib 5.6, *)
    public init?<T: CodingKey>(codingKey: T) {
        self.init(rawValue: codingKey.stringValue)
    }
}

https://github.com/apple/swift/blob/4f7f9f5e615f815800d2c802d6daa39c5e5cf9a2/stdlib/public/core/Codable.swift#L5548

@available(SwiftStdlib 5.6, *)
extension RawRepresentable where Self: CodingKeyRepresentable, RawValue == Int {
    @available(SwiftStdlib 5.6, *)
    public var codingKey: CodingKey {
        _DictionaryCodingKey(intValue: rawValue)
    }
    @available(SwiftStdlib 5.6, *)
    public init?<T: CodingKey>(codingKey: T) {
        if let intValue = codingKey.intValue {
            self.init(rawValue: intValue)
        } else {
            return nil
        }
    }
}

https://github.com/apple/swift/blob/4f7f9f5e615f815800d2c802d6daa39c5e5cf9a2/stdlib/public/core/Codable.swift#L5560

例えば、下記のように使用できる

// StringWrapperはRawValue == StringなRawRepresentableに既に準拠しているとする
extension StringWrapper: CodingKeyRepresentable {}

内部型の_DictionaryCodingKeyが失敗しないイニシャライザを持つように変更

これは実際失敗することがなく、不要なオプショナルバイディングを減らすことができる.

/// A wrapper for dictionary keys which are Strings or Ints.
internal struct _DictionaryCodingKey: CodingKey {
    internal let stringValue: String
    internal let intValue: Int?

    internal init(stringValue: String) {
        self.stringValue = stringValue
        self.intValue = Int(stringValue)
    }

    internal init(intValue: Int) {
        self.stringValue = "\(intValue)"
        self.intValue = intValue
    }

    fileprivate init(codingKey: CodingKey) {
        self.stringValue = codingKey.stringValue
        self.intValue = codingKey.intValue
    }
}

https://github.com/apple/swift/blob/4f7f9f5e615f815800d2c802d6daa39c5e5cf9a2/stdlib/public/core/Codable.swift#L5509

既存のコードへの影響

このプロトコルを取り入れることは追加なので直接は影響がない。

ただし、以前にDictionaryのキーとしてエンコードされたT型に準拠させると、アーカイブとの後方互換性が失われる可能性があるため、特別な注意が必要。 新しい型またはCodableに新しく準拠した型にCodingKeyRepresentableを準拠させることは常に安全。

下位バージョンでは有効にならない?

※ Xcode13.3 RCにてiOS14.5、iOS15.2のシミュレータで確認

プロトコルにOSのバージョン制約があるため、下位のバージョンでは、問題点で記載した挙動になる。

@available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *)
public protocol CodingKeyRepresentable {
    @available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *)
    var codingKey: CodingKey { get }

    @available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *)
    init?<T>(codingKey: T) where T : CodingKey
}

その他の検討事項

標準ライブラリの型をCodingKeyRepresentableに準拠させる

後方互換性の懸念により、標準ライブラリやFoundationの型をさせることは提案しない。もしエンドユーザのコードが既存の型でこの変換を必要とする場合は、それらの型に代わって準拠するWrapper型を作成することを推奨(例えば、UUIDを含み、CodingKeyRepresentableに準拠してUUIDDictionaryのキーとして直接使用できるようにするMyUUIDWrapper)。

標準ライブラリにAnyCodingKey型を追加

CodingKeyRepresentableに準拠する型はCodingKeyを提供する必要があるので、型の内容から自動で生成できる可能性は高い。これは、初期化時に任意のStringまたはIntを受け取ることができる一般的なキー型を導入する良い機会かもしれない。

Dictionaryは既に内部で_DictionaryCodingKeyを使用している(JSONEncoder/JSONDecoderには_JSONKey、, PropertyListEncoder/PropertyListDecoderには_PlistKey)。そのため、これを汎用化することは役に立つかもしれないと考える。この型の実装は上記で示した_AnyCodingKeyと同じになる。

今回採用されなかった案

なぜ既存のCodingKeyを変更(デフォルト実装を追加)して単に型をCodingKeyに直接準拠させないのか?

2つの理由がある。

  1. CodingKeyに既に準拠している場合に、希に動作を変えてしまうリスクがある
  2. CodingKeystringValueintValueプロパティを露出させる必要があるが、これはエンコード/デコード時にのみ適切。勝手にこれを外部に露出させるのは適切でないと思われる。

https://forums.swift.org/t/codingkeypath-add-support-for-encoding-and-decoding-nested-objects-with-dot-notation/34710/16

https://forums.swift.org/t/codingkeypath-add-support-for-encoding-and-decoding-nested-objects-with-dot-notation/34710/33

なぜRawRepresentableを見直したり、RawRepresentable where RawValue == CodingKey制約を使わない?

※ ロスレス変換が必要になるような厳密さは不要である

型がRawRepresentableに準拠することは、その対象の型とその基になるRawValue型の間でロスレス変換されることを示す。この変換は、多くの場合、対象の型とその基になる表現の間、最も一般的にはraw値を持つenumやオプションセットの間の「標準」変換。

対照的に、CodingKeyとの間の変換は「付属的」なものであり、エンコードおよびデコードプロセス内のみでの表現であることを期待する。protocol CodingKeyRepresentable: RawRepresentable where RawValue == CodingKeyとした場合に必要とするような、型の標準的な基底表現がCodingKeyであることを提案(期待)しない。同様に、CodingKey以外のraw値で既にRawRepresentableの型は、この方法だと準拠できない。今回の機能の大きな推進力は、IntおよびStringをraw値に持つenumDictionaryCodingKeyとして参加できるようにすること。

なぜCodingKeyのassociated typeを使わない?

※ 型チェックのコストと天秤にかけた場合にメリットが上回らない

詳細を知りたい場合はクリック

これはpitch時に提案されており、下記のようなユースケースでは完全に妥当である。

enum MyKey: Int, CodingKey {
    case a = 1
    case b = 3
    case c = 5

    var intValue: Int? { rawValue }

    var stringValue: String {
        switch self {
        case .a: return "a"
        case .b: return "b"
        case .c: return "c"
        }
    }

    init?(intValue: Int) { self.init(rawValue: intValue) }

    init?(stringValue: String) {
        guard let rawValue = RawValue(stringValue) else { return nil }
        self.init(rawValue: rawValue)
    }
}

struct MyCustomType: CodingKeyRepresentable {
    typealias CodingKey = MyKey

    var useB = false

    var codingKey: CodingKey {
        useB ? .b : .a
    }

    init?(codingKey: CodingKey) {
        switch codingKey {
        case .a: useB = false
        case .b: useB = true
        case .c: return nil // .c is unsupported
        }
    }
}

この提案の分析は、利用側でキー値を引き出すために型消去を行うためのコストがゼロではなく、そのコストが見合わないことを示唆している。https://forums.swift.org/t/pitch-allow-coding-of-non-string-int-keyed-dictionary-into-a-keyedcontainer/44593/9

associated typeは利用側でゼロではないコストがかかるため(例えば、キーの型を使用してCodingKeyRepresentableに準拠しているかをチェックする)、associated typeにそのコストに見合うメリットが必要になる。名前がCodingKeyRepresentableだが、CodingKeyRepresentableRawRepresentableの主な違いは、RawValue型の識別子がRawRepresentableにとって重要な一方、CodingKeyRepresentableはそうでもない。

CodingKeyRepresentable.codingKeyの利用側(例えば、Dictionary)では、キー型の識別子が必ずしも十分に役立つとは思えない:

  • .codingKeyの主な用途は、基になるString/Int値の即時取得。Dictionaryはそれらの値をすぐに使用できるように引き出し、元のキーを破棄する
  • 非ジェネリックコンテキスト(またはCodingKeyRepresentableへの準拠を前提としないコンテキスト)では、キーの型を意味のある形で取得することはできない。キーキーの値を取得するために型消去したとしても、型付けされたキーを渡すことができない(そして、その苦痛は、型消去を行うには、これを実行したいすべての利用側で実装(車輪の再発明)を行い、それを実行するための別のプロトコルを追加する必要がある。Optionalの場合は数回実行する必要があり、これは嬉しくない)
  • 具体的なキーの型を取得する必要がある場合でも、この機能の主なユースケースは、列挙型ではない動的な値のキーを提供することになると思われる(例えば、UUIDのようなstruct(実際これはできない)。これらの型の場合、必ずしもCodingKeyenumを定義できるとは限らない。代わりに、AnyCodingKey(定義上識別子を持たない)などのより一般的なキーの型を使用することを推奨する

作成側(例えばMyCustomType)では、十分な有用性が必ずしもあるかどうかもわからない。一般に、CodingKeyRepresentableの大部分で実際に気にするのはキーのString/Intの値のみだと思われる。といういのも、これらは動的に初期化されると思われるため(ここでも、CodingKey.stringValueからのUUIDの初期化を考えている—これは任意のCodingKeyから実行できる)

上記のMyKeyの例はマイナーなユースケースで、associatedtypeの制約なしでも表現できる:

enum MyKey: Int, CodingKey {
    case a = 1, b = 3, c = 5

    // There are several ways to express this, just an example:
    init?(codingKey: CodingKey) {
        if let key = codingKey.intValue.flatMap(Self.init(intValue:)) {
            self = key
        } else if let key = Self(stringValue: codingKey.stringValue) {
            self = key
        } else {
            return nil
        }
    }
}

struct MyCustomType: CodingKeyRepresentable {
    var useB = false

    var codingKey: CodingKey {
        useB ? MyKey.b : MyKey.a
    }

    init?(codingKey: CodingKey) {
        switch MyKey(codingKey: codingKey) {
        case .a: useB = false
        case .b: useB = true
        default: return nil
        }
    }
}

これも同様に表現力豊かだと思われ、特にenumではない型を念頭に置いて、associated typeを必要としないことで、大きな損失なしに柔軟性が向上すると考えられる。

Encoder/Decoderにワークアラウンドを追加する

DictionaryKeyEncodingStrategyJSONEncoderに追加しようとしてみた。apple/swift#26257

新しいエンコード/デコードの「strategy」を提供することにより、JSONEncoderおよびJSONDecoder型で新しい動作へのオプトインを直接表現できるようにするというアイデアがあったが、問題は特定のEncoder/Decoderのペアだけでなく、すべての問題を修正する必要があると思われる。

newtypeの設計を待つ

Taggedライブラリが解決する問題を基本的に解決しようとするnewtypeのデザインについての言及を聞いたことがある。つまり、他のプリミティブ型の周りに型安全なWrapperを作成する。決してこれの専門家ではなく、これがどのように実装されるかはわからないが、SomeTypeStringnewtypeであることがわかった場合、これを使用して、Dictionaryの新しい実装にCodableへの準拠に提供できる。この機能は古いバージョンのSwiftには存在しないため(これがSwiftランタイムの変更を必要とする機能である場合)、これをDictionaryに追加しても、Codableへの準拠が動作を損なうことはない。

しかし、これらは非常に多くのifとbutがあり、人々が遭遇しているように見える問題の1つ(RawRepresentableのWrapperの問題)を解決するだけで、例えば文字列ベースの列挙型やInt8ベースのキーは解決しない。

何もしない

もちろん、エンコード中にこの状況を手動で処理することは可能。

この状況に対応するためのやや邪魔にならない方法は、CodableKeyで提案されているようにProperty Wrapperを使用すること。

この解決策は各Dictionaryに適用する必要があり、非常に洗練された回避策。しかし標準ライブラリで修正できる可能性のある問題のワークアラウンドでもある。

Property Wrapperの解決策のいくつかの欠点は、Pitch段階で発覚した:

  • Int8(またはその他の標準ライブラリの数値の型)をキーとして使用するには、CodingKeyに準拠している必要がある。この準拠は、例えばSwift Packageなど他の場所での準拠の衝突を防ぐために、標準ライブラリで行う必要がある。そして私見では、これらの型はCodingKeyへの準拠を提供するべきではない。
  • 単純にエンコード/デコードするのは簡単ではない。例えば、別のCodable型のプロパティではないDictionary<Int8, String>(上記のリンクされた投稿の中の例でも言及されている)。
  • すでに定義されているオブジェクトにCodableへの準拠を追加することはできない。したがって、あるファイルにDictionary<Int8, String>を持つstruct(MyType)を定義した場合、extension MyType: Codable {/ * ... * /}を別のファイルに単純に配置することはできない。

参考リンク

Forums

プロポーザルドキュメント

実装