🍎

Swift プロトコル 〜型と制約〜

2024/07/07に公開

Swiftは値型中心の言語であり、値型は継承ができないため、抽象化の手段としてプロトコルが重要な役割を果たします。
また、プロトコルは使い方次第でパフォーマンスが変わるため、適切な使い分けが必要になります。

型としてのプロトコル

プロトコルは以下のように型として利用することが出来ます。

protocol Animal {}

struct Dog: Animal {}
struct Cat: Animal {}

// Animalを型として宣言
let animal: Animal = Bool.random() ? Dog() : Cat()

Existential Container

ここでメモリについて考えてみます。
例として、Dogに1バイト、Catに2バイトのプロパティを持たせます。

struct Dog: Animal {
    var value: UInt8 = 1
}

struct Cat: Animal {
    var value: UInt16 = 1
}

MemoryLayout.size(ofValue:)でサイズを確認すると、Dogは1バイト、Catは2バイトの領域を必要としています。

let dog = Dog() // 1バイト
let cat = Cat() // 2バイト

では以下の場合は何バイトの領域が必要でしょうか?

let animal: Animal = Bool.random() ? Dog() : Cat()

実際に確認してみると40バイトの領域を必要としていることが分かります。
一見2バイトあれば十分に見えますが、animalに入る型はDogやCatだけでなく、Animalに適合する任意の型を格納できなければいけません。
そこで、Existential Containerという特殊な入れ物を用いることでこれを実現しています。このサイズが40バイトなので、animalも40バイトになります。

参照型の場合

class Animal {}
class Cat: Animal {}
class Dog: Animal {}

参照型では、インスタンス自体はヒープ領域に格納され、そのアドレスがスタック領域に格納されるため、どのインスタンスもサイズは同じになります。

let dog: Dog = Dog() // 8バイト
let cat: Cat = Cat() // 8バイト
let animal: Animal = Bool.random() ? cat : dog // 8バイト

したがって、参照型では、Existential Containerを利用する必要がなく、値型で見たようなデメリットはありません。

パフォーマンス

Existential Containerにより、余分なメモリを消費するだけでなく、取り出したり、包んだりする必要があるためオーバーヘッドが発生します。

また、実行時まで型が決定されないため、動的ディスパッチになります。
実際に、SIL(Swift Intermediate Language)を見てみましょう。

protocol Animal {
    func foo()
}
struct Dog: Animal {
    func foo() {...}
}
struct Cat: Animal {
    func foo() {...}
}

let animal: Animal = Dog()
animal.foo()

上記コードからSILを出力させると

%9 = open_existential_addr immutable_access %3 : $*any Animal to $*@opened("62376CCC-3BA6-11EF-A5E1-B6009B3BB9A8", any Animal) Self 

%10 = witness_method $@opened("62376CCC-3BA6-11EF-A5E1-B6009B3BB9A8", any Animal) Self, #Animal.foo : <Self where Self : Animal> (Self) -> () -> (), %9 : $*@opened("62376CCC-3BA6-11EF-A5E1-B6009B3BB9A8", any Animal) Self : $@convention(witness_method: Animal) <τ_0_0 where τ_0_0 : Animal> (@in_guaranteed τ_0_0) -> ()

%11 = apply %10<@opened("62376CCC-3BA6-11EF-A5E1-B6009B3BB9A8", any Animal) Self>(%9) : $@convention(witness_method: Animal) <τ_0_0 where τ_0_0 : Animal> (@in_guaranteed τ_0_0) -> ()
  1. open_existential_addrexistential_containerを開く
  2. witness_methodで、Witness Tableから該当する関数への参照を取得する
  3. applyで関数を実行

このように、型としてのプロトコルでは、existential containerや動的ディスパッチによるオーバーヘッドが発生することが分かります。

制約としてのプロトコル

型としての使い方以外に、制約としての使い方もあります。

func foo<A: Animal>(_ animal: A) {}

ジェネリック関数

この例では、ジェネリック関数の型パラメータAの制約としてプロトコルが利用されています。

以下のように個別に実装することも出来ますが、非効率で現実的ではないため、それぞれの型に対してオーバーロードする代わりに一つの関数として実装できるようにしたものがジェネリック関数です。

func foo(_ animal: Dog) {}
func foo(_ animal: Cat) {}

パフォーマンス

コンパイル時に特殊化が行われた場合、ジェネリック関数としてのfooに加えて、上記のようなDogやCatなど具体的な型を当てはめたfooが生成されます。そして、fooにDogを渡す箇所では、Dog用のfooが呼び出されるようコンパイルされます。そのため、existential containerが必要なく、オーバーヘッドが発生しません。また、コンパイル時に型が決まっているため静的ディスパッチになります。
さらに、静的ディスパッチの場合、コンパイラによってインライン化される場合があります。これにより、関数の呼び出し箇所に関数の中身が埋め込まれ、呼び出しのオーバーヘッドが0になります。

このように、制約として利用する場合は、実行時のオーバーヘッドが型として利用するよりも少なくなります。

プロトコルの使い分け

パフォーマンスの面では制約としての使い方が優れていることが分かりました。しかし、「型として」「制約として」のプロトコルはまったく同じことができる訳ではありません。

型としてのプロトコルでしかできないこと

以下のように、引数の型が [Animal]の場合を考えてみます。

func foo(_ animals: [Animal]) {}

この場合、fooの引数には、DogやCatインスタンスを混在させることが可能です。

foo([Dog(), Cat()])

制約として利用する場合はどうでしょうか。

func foo<A: Animal>(_ animals: [A])

DogやCatを混在させたArrayを渡すことはできません

foo([Dog(), Cat()]) // コンパイルエラー

これは、ジェネリック関数の型パラメータAが具体的な型1つを表すものだからです。
そのため以下のような配列は引数として渡すことが出来ます。

foo([Dog(), Dog()])
foo([Cat(), Cat()])

制約としてのプロトコルでしかできないこと

Self-requirementassociatedtypeが使われているプロトコルは、型として利用することが出来ません。

protocol Animal {
    func foo(value: Self)
}

let animal: Animal // コンパイルエラー

これらを使う場合は制約として利用することになります。

まとめ

値型中心の言語であるSwiftでは、プロトコルを制約として利用する方がパフォーマンスの面で優れています。ただし、全てのケースを制約として書ける訳ではないため、適切な使い分けが必要です。

これらの「型として」「制約として」のプロトコルは、anysomeとも関連があり、それについてはこちらでまとめています。

関連URL

https://developer.apple.com/videos/play/wwdc2016/416

Discussion