togo

= ひとりごと to go

zumuya の人による机の上系情報サイト

派生型プロパティを Decodable で扱う

  • Apple
  • 開発

最近の Swift では Decodable に準拠したオブジェクトを簡単に JSON から変換できる。

struct Root: Decodable
{
    let texts: [String]
    let isFine: Bool
}

let root = try JSONDecoder().decode(Root.self, from: """
{
    "texts": ["hello", "again"],
    "isFine": true
}
""".data(using: .utf8)!)

root.texts //["hello", "again"]
root.isFine //true

上のようにあらかじめかっちりと定義した通りのデータが与えられるときは自動でやってくれて便利だけど、実際にはそうもいかなくて難しい。

例えば、次のようにバリエーションが複数ある場合:

protocol Parameter: Decodable
{
    var type: ParameterType { get }
}
struct BoolParameter: Parameter
{
    let type: ParameterType
    let defaultValue: Bool
}
struct NumberParameter: Parameter
{
    let type: ParameterType
    let defaultValue: CGFloat
    let minValue: CGFloat
    let maxValue: CGFloat
}
enum ParameterType: String, Decodable
{
    case bool
    case number
}

BoolParameter と NumberParameter は同列な存在でどちらも Parameter に準拠させている。JSON では独自の型情報を定義できないので Parameter.type を用意して区別することにする。

面倒

これをプロパティに持つ場合の Root はこんな感じだろうか:

struct Root: Decodable
{
    var parameter: Parameter
}

だがこれはエラーが出てコンパイルできない。

note: cannot automatically synthesize 'Decodable' because 'Parameter' does not conform to 'Decodable'
var parameter: Parameter
    ^

Parameter プロトコルでなく直接 BoolParameter や NumberParameter を指定すれば動いてくれるものの、どちらか一方にしか使えなくなってしまう。

こういう場合、Root の Decodable を自力で実装するのがよくある解決策のようだ。具体的には:

  1. Root の持つプロパティのうち Decodable を適用させたい対象の一覧を CodingKey に準拠した enum で定義
  2. Root.init(from decoder:) の中で 1 の enum を Decoder.container(keyedBy:) に与えて、全キーの値を持つ KeyedDecodingContainer を取得
  3.  各キーに対して KeyedDecodingContainer.decode(_,forKey:) で値を取得して Root を初期化していく

...面倒! 今回の例では単純化しているけど実際には Root に大量のプロパティがあってもおかしくない。ほんの一部のプロパティで凝ったことをするだけでも、途端にすべてを手動で触らなければいけなくなってしまうなんて。

解決策:Property Wrapper を使おう

そこでたどり着いた解決策は Property Wrapper だ。それを Decodable に準拠させて派生型への分岐処理をここに詰め込み、各々の init(from coder:) で生成したものを wrappedValue としている。

@propertyWrapper struct CustomDecodedParameter: Decodable
{
    public var wrappedValue: Parameter

    enum ParameterCommonKey: CodingKey
    {
        case type
    }
    public init(from decoder: Decoder) throws
    {
        let container = try decoder.container(keyedBy: ParameterCommonKey.self)
        switch try? container.decode(ParameterType.self, forKey: .type) {
        case .bool:
            wrappedValue = try BoolParameter(from: decoder) as Parameter
            break
        case .number:
            wrappedValue = try NumberParameter(from: decoder) as Parameter
            break
        default:
            fatalError()
        }
    }
}

すると Root はこんなにシンプルになる:

struct Root: Decodable
{
    @CustomDecodedParameter var parameter: Parameter
}

let root = try JSONDecoder().decode(Root.self, from: """
{
    "parameter": {
        "type": "bool",
        "defaultValue": false
    }
}
""".data(using: .utf8)!)

assert((root.parameter as! BoolParameter).defaultValue == false)

SwiftUI の印象が強かった Property Wrapper だけど、思ったよりも幅広く役立ちそうだ。

おまけ:複数の場合

プロパティの型が単体の Parameter ではなく [Parameter] だったら...?

@propertyWrapper struct CustomDecodedParameters: Decodable
{
    public var wrappedValue: [Parameter]

    enum ParameterCommonKey: CodingKey
    {
        case type
    }
    public init(from decoder: Decoder) throws
    {
        var result: [Parameter] = []
        var arrayContainerForType = try decoder.unkeyedContainer()
        var arrayContainerForDecoding = arrayContainerForType
        while !arrayContainerForType.isAtEnd {
            let container = try arrayContainerForType.nestedContainer(keyedBy: ParameterCommonKey.self)
            switch try? container.decode(ParameterType.self, forKey: .type) {
            case .bool:
                try result.append(arrayContainerForDecoding.decode(BoolParameter.self))
                break
            case .number:
                try result.append(arrayContainerForDecoding.decode(NumberParameter.self))
                break
            default:
                fatalError()
            }
        }
        wrappedValue = result
    }
}

先ほどと同じような Property Wrapper を作るのだけど、unkeyedContainer を 2 つ用意する点に注意。どうやら内部に現在のパース位置というかカーソルみたいな情報を持っているらしく、type を調べるときとデコードするときの両方でそれが増加してしまうためだ。

struct Root: Decodable
{
    @CustomDecodedParameters var parameters: [Parameter]
}

let root = try JSONDecoder().decode(Root.self, from: """
{
    "parameters": [
        {
            "type": "number",
            "defaultValue": 1.5,
            "minValue": 0.0,
            "maxValue": 2.0
        },
        {
            "type": "bool",
            "defaultValue": false
        }
    ]
}
""".data(using: .utf8)!)

assert((root.parameters.first as! NumberParameter).defaultValue == 1.5)
assert((root.parameters.last as! BoolParameter).defaultValue == false)

2020.7.17 追記

takasek さんがより汎用化を目指したコードを書いてくれた!

元コードから変わった点

  • 型情報を失いたくないのでデータの派生はenumで表現
  • CustomDecodedParameter をデータごとにいちいち作らなくていいようドメイン固有の処理を切り出し、共通部分をprotocol extensionに
  • typeが想定外だったときにfatalErrorだと失敗時にアプリごと死ぬので、適切にCodingErrorをthrow
  • 単体でもArrayでも同じように扱えるように工夫していたらPropertyWrapperが消滅してしまいました…

なるほど、同じ目的の違うアプローチを見るのはとても勉強になる。

Share

リンクも共有もお気軽に。記事を書くモチベーションの向上に役立てます。