のほほん停留所

びぼうろく

型消去を用いて複数の型を含んだ配列をDecodeする

[
    {
        "type": "Cat",
        "name": "Tama"
    },
    {
        "type": "Dog",
        "name": "Pochi",
        "doghouse": "Outside"
    }
]

複数の型を含んだ配列 (Heterogeneous Collection)上のJSONには"animals"のKeyに対して、 Cat型とDog型の複数の型が含んだ配列が対応しています。このような配列は「寄せ集め = Haterogeneous Collection」と呼ばれます。Haterogeneous CollectionはSwiftのコンパイラが出力するエラーメッセージでも確認することができます。

f:id:nonchalant0303:20211027180150p:plain
Haterogeneous Collectionを含んだエラーメッセージ

Haterogeneous Collectionを含んだエラーメッセージこのような配列をSwiftで扱う場合はいくつかのアプローチがあります。1つはエラーメッセージに提案されているように、[Any]型として配列を扱う方法です。しかし、この方法だと配列に含まれる型を制限できず、扱いに困るケースが考えられます。

そこで、よく使われる方法がProtocolを用いて型を制限する方法です。Cat型とDog型の共通要素をAnimalというProtocolを定義して、[Animal]型として扱う方法です。この方法だと、配列にはAnimalのProtocolに準拠している型しか含めることができず、扱う型をコントロールできます。

protocol Animal {
    var name: String { get }
}

struct Cat: Animal, Decodable {
    let name: String
}

struct Dog: Animal, Decodable {
    let name: String
    let doghouse: String
}

let cat = Cat(name: "Tama")
let dog = Dog(name: "Pochi", doghouse: "Outside")

let animals: [Animal] = [cat, dog]

このようにProtocolを用いて配列を扱うことにより、日常的にHatenegeous Collectionを回避していることが分かります。では、本題のどのようにDecodeして、このような配列を生成するかという話に入ります。

1. 全ての型のプロパティを含んだDecode用の型を用いる

let data: [Any] = [
    [
        "type": "Cat",
        "name": "Tama"
    ],
    [
        "type": "Dog",
        "name": "Pochi",
        "doghouse": "Outside"
    ]
]

struct AnimalDecode: Decodable {
    let name: String
    let doghouse: String?

    private let type: AnimalType

    private enum AnimalType: String, Decodable {
        case cat = "Cat"
        case dog = "Dog"
    }

    func convertTo() -> Animal? {
        switch self.type {
        case .cat:
            return Cat(name: name)
        case .dog:
            guard let doghouse = doghouse else {
                return nil
            }

            return Dog(name: name, doghouse: doghouse)
        }
    }
}

let json = try JSONSerialization.data(withJSONObject: data)
let animals = try JSONDecoder().decode([AnimalDecode].self, from: json).compactMap { $0.convertTo() }

AnimalDecode型はAnimalプロトコルで定義されているプロパティと型を特定するためのtypeプロパティをRequiredなプロパティ、Cat型とDog型の個別のプロパティをOptionalなプロパティとして定義しているDecode用の型です。

一旦、配列の各要素をAnimalDecode型としてDecodeして、その後 convertTo メソッドでAnimalに準拠した型に変換することにより、[Animal]型の配列を取得します。

しかし、この方法はAnimalDecode型が肥大する傾向があるという問題があります。Animalに準拠する型が増えれば増えるほど、AnimalDecode型のOptinalプロパティが増えると共に convertTo メソッドのSwitch文が肥大化してしまいます。

2. 型消去されたDecode用の型を用いる

struct AnyAnimal: Decodable {
    let animal: Animal?

    private enum AnimalType: String, Decodable {
        case cat = "Cat"
        case dog = "Dog"
    }

    private enum Discriminator: String, CodingKey {
        case type
    }

    init(from decoder: Decoder) throws {
        let typeContainer = try decoder.container(keyedBy: Discriminator.self)
        let animalContainer = try decoder.singleValueContainer()

        guard let type = try? typeContainer.decode(AnimalType.self, forKey: .type) else {
            self.animal = nil
            return
        }

        switch type {
        case .cat:
            self.animal = try? animalContainer.decode(Cat.self)
        case .dog:
            self.animal = try? animalContainer.decode(Dog.self)
        }
    }
}

let json = try JSONSerialization.data(withJSONObject: data)
let animals = try JSONDecoder().decode([AnyAnimal].self, from: json).compactMap { $0.animal }

AnyAnimal型はOptional型のプロパティだけ定義されている型です。Decodableに準拠していて、カスタムなinit(from decoder: Decoder)メソッドが定義されています。そのメソッド内で、最初にAnimalType型のプロパティのみをDecodeして、switch文でそれぞれの型に対応したAnimalプロトコルに準拠した型をDecodeします。

この方法はそれぞれの型の生成処理をDecoderに任せることで、Switch文をシンプルに書くことができ、1の方法と違ってAnimalに準拠する型が増えてもSwitch文がそれほど増えることがありません。また、Switch文で同じ型の違う名前のプロパティを入れ間違えるリスクもありません。


そもそも、複数の型を含む配列をDecodeしないといけないケースが稀で、他のレイヤーで解決できるならそうすべきだと思います。ただ、外部サービスのレスポンスなどで自分がコントロールできないケースもあるので、その際はせめてメンテナンスしやすい型消去されたDecode用の型を用いるのがよいかと思います。