Range が Codable に適合してなかったので後付けで適合させてみる話と、Codable のエラーハンドリングについて

  • 11
    いいね
  • 0
    コメント

Oops!

Type 'Hoge' does not confirm to protocol 'Decodable' / 'Encodable'

Range ( 0...10 みたいなやつ) をメンバとして持つ Hoge 型が、コンパイルに失敗してしまいました。
どうも Swift4の Range1Codable に適合していないようです。
困った、どうしたもんでしょうね。

そうだ、 Codable に後付けで適合させる extension 書けばいいじゃない!

extension CountableClosedRange: Codable {
    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let rangeDescription = try container.decode(String.self)
        let rangeElements = rangeDescription.components(separatedBy: "...")

        guard
            rangeElements.count == 2
            else {
                throw DecodingError.dataCorruptedError(
                    in: container,
                    debugDescription: "\"\(rangeDescription)\" is invalid closed range expression"
                )
        }
        guard
            let l = rangeElements.first.flatMap({ Int($0) }) as? Bound,
            let u = rangeElements.last.flatMap({ Int($0) }) as? Bound
            else {
                throw DecodingError.dataCorruptedError(
                    in: container,
                    debugDescription: "\"\(rangeDescription)\" is composed by non-int bounds"
                )
        }

        lowerBound = l
        upperBound = u
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode("\(lowerBound)...\(upperBound)")
    }
}

実行結果

struct Hoge: Codable {
    let range: CountableClosedRange<Int>
}

do {
    let jsonData = """
    {
        "range": "5...10"
    }
    """.data(using: .utf8)!

    let t = try! JSONDecoder().decode(Hoge.self, from: jsonData)
    print(t)
    // Hoge(range: CountableClosedRange(5...10))
}

do {
    let t = Hoge(range: 3...9)
    let data = try! JSONEncoder().encode(t)
    let s = String(data: data, encoding: .utf8)!
    print(s)
    // {"range":"3...9"}
}

do {
    let jsonData = """
    {
        "range": "5..<10"
    }
    """.data(using: .utf8)!

    let t = try! JSONDecoder().decode(Hoge.self, from: jsonData)
    // Swift.DecodingError.dataCorrupted(Swift.DecodingError.Context(
    //   codingPath: [__lldb_expr_100.Hoge.(CodingKeys in _ABCFAB28040915CEF6AA3347992A1FE1).range],
    //   debugDescription: "\"5..<10\" is invalid closed range expression", underlyingError: nil))
}

do {
    let jsonData = """
    {
        "range": "a...z"
    }
    """.data(using: .utf8)!

    let t = try! JSONDecoder().decode(Hoge.self, from: jsonData)
    // Swift.DecodingError.dataCorrupted(Swift.DecodingError.Context(
    //   codingPath: [__lldb_expr_102.Hoge.(CodingKeys in _79FE350F6AE58BCB4510A745A8A0AB74).range],
    //   debugDescription: "\"a...z\" is composed by non-int bounds", underlyingError: nil))
}

Hoge 型が無事にコンパイルを通りました。
しかも、 decode, encode, さらにはJSONで想定外な range 表現が現れたときの例外処理も適切に行われています。

Codable とエラーハンドリング

今回のコードで注目してほしいのは、以下の部分です。

        guard
            rangeElements.count == 2
            else {
                throw DecodingError.dataCorruptedError(
                    in: container,
                    debugDescription: "\"\(rangeDescription)\" is invalid closed range expression"
                )
        }
        guard
            let l = rangeElements.first.flatMap({ Int($0) }) as? Bound,
            let u = rangeElements.last.flatMap({ Int($0) }) as? Bound
            else {
                throw DecodingError.dataCorruptedError(
                    in: container,
                    debugDescription: "\"\(rangeDescription)\" is composed by non-int bounds"
                )
        }

as? Bound によって、具体的な Bound を知らなくても init(from decoder: Decoder) を書くことができました。2
しかも異常な rangeの表現が与えられたときは guard ... else { throw ... } で具体的に失敗要因を知らせて失敗させることもできました。
このように throws を活用した強力なエラーハンドリングとの統合が、 Codable プロトコルの強みです。

If any of those look wrong, then the JSON decoder can throw an error and stop the decode right there. After that, we want to convert from things like JSON arrays, and dictionaries, and strings into your types, commits and authors.
That is, after all, the entire point of this API. But there may be more that we can do, and we call that domain-specific validation. (...) These kinds of things can be difficult to express in Swift's type system, but we do think we have a great tool for handling those, and that's just simply writing more Swift code.

What's New in Foundation - WWDC 2017 - Videos - Apple Developer

image.png

そういうわけで、 Codable ってやっぱりよくできてるなあというお話でした。

おまけ: こういうextensionはダメでした

今回、 Bound がたまたま Int 以外ありえない例だったらからよかったものの、本当なら as? Bound でダウンキャストさせるんじゃなくて、 BoundInt の場合に限定したコードを書きたいものですよね。
じゃあ、こうしてみましょうか。

extension CountableClosedRange<Int>: Codable {
    ...
}

Constrained extension must be declared on the unspecialized generic type 'CountableClosedRange' with constraints specified by a 'where' clause

おっと。怒られました。 OK、 where で制約を書けばいいんだね?
……と思いきや、

extension CountableClosedRange: Codable where Bound == Int {
    ...
}

Extension of type 'CountableClosedRange' with constraints cannot have an inheritance clause

やっぱりダメでした。
Swift4.0 では、制約条件がついている場合、プロトコルへの適合は不可能なのです。

なお、この問題については Swift Evolution-0143 Conditional conformances で解決される予定です。

swift-evolution/0143-conditional-conformances.md · apple/swift-evolution

現在 Accepted 状態です。 Swift4 に入らなかったのは残念……。実装が待ち遠しいですね。



  1. ※ Range と一言で言っても Swift4 では複数の型を指しますが、この記事では本題から逸れるため CountableClosedRange ( e.g. 1...2 ) にだけ言及しています。 

  2. CountableClosedRangeBoundSignedInteger しかありえないと分かっているからこそできる芸当ですが……。