Swift

Swift の Type Erasure の実装パターンの紹介

More than 1 year has passed since last update.

Type erasure

swift では protocol を使うのが人気です。ある protocol を満たす複数の型があるとき、それらのどれでも代入できる互換性のある型が欲しくなることがあります。そのような型を type erasure と呼びます。互換性のある型に代入するときに、元の型が失われるからです。

この記事では、複数の type erasure の実装パターンを紹介します。

シンプルな protocol

言語機能 existential

swift では言語機能として、シンプルな protocol はそのままの型名で existential として使用できます。シンプルというのは、ここでは associated type を持っていない、かつ、 自身の定義に Self 型を使用していないことを指しています。 existential というのは存在型とも呼び、ある protocol を満たす型の値が代入できる型のことです。

以下のようにシンプルな protocol とそれを満たすクラスがあったとします。

protocol AnimalProtocol {
    func eat(food: String)
}

class Cat : AnimalProtocol {
    func eat(food: String) {}
}

以下のようにして、 protocol の型を existential としてそのまま変数の型に使用可能です。

var a: AnimalProtocol = Cat()

自動定義 existential は不便

しかし、この方法はそれ自体が自分自身の protocol を満たさないため不便です。

例えば、下記のようなジェネリック関数呼び出しができません。

func g<X: AnimalProtocol>(_ x: X) {}

g(a) 
// Cannot invoke 'g' with an argument list of type '(AnimalProtocol)'

また、下記のような型のジェネリックパラメータに使用することができません。

class AnimalHouse<X: AnimalProtocol> {}

AnimalHouse<AnimalProtocol>() 
// Using 'AnimalProtocol' as a concrete type conforming to protocol 'AnimalProtocol' is not supported

type erasure: existential 方式

このように言語機能 existential は不便なので、 type erasure を定義したいことがあります。

シンプルな protocol の type erasure は、言語機能 existential を内部で property として保持するだけで実装できます。これを existential 方式と呼ぶことにします。

以下に例を示します。

class AnyAnimal : AnimalProtocol {
    init(_ base: AnimalProtocol) {
        self.base = base
    }

    func eat(food: String) {
        base.eat(food: food)
    }

    private let base: AnimalProtocol
}

let a: AnyAnimal = AnyAnimal(Cat())

associated type を持つ protocol

protocol が associated type を持っている場合を考えます。以下に例を示します。

protocol AnimalProtocol {
    associatedtype Food
    func eat(food: Food)
}

class Cat : AnimalProtocol {
    func eat(food: String) {}
}

この場合 existential 方式は使えません。 property を定義しているところでエラーになるからです。

type erasure: クロージャ方式

この場合、それぞれのメソッドをクロージャで包む事によって type erasure を実装できます。これをクロージャ方式と呼ぶことにします。

以下に例を示します。

class AnyAnimal<Food> : AnimalProtocol {
    init<X: AnimalProtocol>(_ base: X)
        where X.Food == Food
    {
        _eat = { base.eat(food: $0) }
    }

    func eat(food: Food) {
        _eat(food)
    }

    private let _eat: (Food) -> Void
}

let a: AnyAnimal = AnyAnimal(Cat())

associated type を type erasure の型パラメータとし、 protocol のメソッドをクロージャにし、 init でメソッド呼び出しをクロージャに包んで保持します。

この方式はやり方はわかりやすいですが、 protocol のもつメソッド1つに対して、その名前を5回、引数の転送を2回ずつ書かなければならないため、面倒です。
また、クロージャ1つに付き16バイトのメモリを消費してしまい、メソッド数が多い protocol だと消費量が大きくなります。

Self を含む protocol

protocol 定義が Self を含む場合を考えます。以下に例を示します。

protocol AnimalProtocol {
    associatedtype Food
    func eat(food: Food)
    func spawn() -> Self
    func fight(_ x: Self)
}

class Cat : AnimalProtocol {
    required init() {}

    func eat(food: String) {}

    func spawn() -> Self {
        return type(of: self).init()
    }

    func fight(_ x: Cat) {
    }
}

この場合、クロージャ方式は使えません。 base.spawn や base.fight をクロージャに包もうとしても、その base の型 X は init の中でしか使えないため、 クロージャの型定義に組み込むことができないからです。

type erasure: 継承 box 方式

この場合、 protocol を保持するオブジェクトを内側にもう1つ作り、Self を型パラメータに焼いた上で継承でそれを消す事で type erasure が実装できます。これを継承 box 方式と呼ぶことにします。

段階的に例を示します。

まず、 associated type を型パラメータにするための AnyBox を作ります。

class AnyAnimalBox<Food> {
    func eat(food: Food) { fatalError("abstract") }
    func spawn() -> AnyAnimalBox<Food> { fatalError("abstract") }
    func fight(_ x: AnyAnimalBox<Food>) { fatalError("abstract") }
}

それ自体は protocol の準拠は不要です。実装は fatalError にしておきます。Self 部分は AnyBox それ自身を記述します。

次に、具体的な型を保持して、実装を転送するための、 AnyBox のサブクラスの Box を作ります。

class AnimalBox<X: AnimalProtocol> : AnyAnimalBox<X.Food> {
    init(_ base: X) {
        self.base = base
    }

    override func eat(food: X.Food) {
        base.eat(food: food)
    }

    override func spawn() -> AnyAnimalBox<X.Food> {
        return AnimalBox<X>(base.spawn())
    }

    override func fight(_ x: AnyAnimalBox<X.Food>) {
        base.fight((x as! AnimalBox<X>).base)
    }

    private let base: X
}

protocol の具体的な型を型パラメータで保持し、先程の AnyBox に associated type を当てはめつつ継承します。
このクラスの内部では具体的な型 X が使えるので、 existential 方式のように base プロパティとしてそれを保持します。
Self が絡む部分については、返り値方向であれば、 X を AnyBox の init に渡して包み直します。引数方向であれば、 AnyBox からダウンキャストしてから base プロパティにアクセスします。ここのダウンキャストについては、 AnyBox と Box が type erasure 内部でのみ使われることから安全性が保証されます。

最後に type erasure を実装します。

final class AnyAnimal<Food> : AnimalProtocol {
    init<X: AnimalProtocol>(_ base: X)
        where X.Food == Food
    {
        box = AnimalBox<X>(base)
    }

    func eat(food: Food) {
        box.eat(food: food)
    }

    func spawn() -> AnyAnimal<Food> {
        return AnyAnimal<Food>(box: box.spawn())
    }

    func fight(_ x: AnyAnimal<Food>) {
        box.fight(x.box)
    }

    private init(box: AnyAnimalBox<Food>) {
        self.box = box
    }

    private let box: AnyAnimalBox<Food>
}

let a: AnyAnimal = AnyAnimal(Cat())

associated type を型パラメータで持ち、 init で X として受け取る形はクロージャ方式と同じです。box をプロパティにもち、その型を AnyBox にしておきます。そして、 init の内部で X の型で Box を作って、 box プロパティにアップキャストして代入します。ここで X の型が box 内部に焼きこまれつつ見かけ上消去されます。

また、 AnyBox を受け取る init も用意しておきます。

あとはそれぞれのメソッドを転送します。 Self が絡む箇所については、返り値方向は AnyBox を受け取る init を使って包み直します。引数方向はプロパティの box にアクセスします。

type erasure 自身は final にしておきます。もしこの final を外そうとすると、 box を受ける init を required にして private を外す必要がありますが、そうすると AnyBox のサブクラスとして Box 以外のオブジェクトが渡される可能性が生じるので、 Box の中で使ったダウンキャストに失敗する可能性が産まれてしまいます。

この方式は3つの型を定義する必要があり、1つのメソッドにつき 5回の名前と2回の転送を書き、 fatalError で埋める必要もあるので、クロージャ方式よりもさらに面倒です。
ただしメモリ使用量は メソッド数によらず一定です。

AnySequence の例

この継承 box 方式は swift 標準ライブラリの AnySequence でも使われています。

gyb が使われているためわかりにくいですが、読んでみると面白いです。

謝辞

この記事の内容は Discordのみなさん と、特に @rintaro に教わりました。ありがとうございました。