Open5

swiftのany/some

Shun UematsuShun Uematsu

Existensial Containerとany

protocolとは

Swiftのprotocolの主な役割はジェネリクスの型パラメーターに制約を設けるものです。
ただ副次的に型としても扱える便利機能も備えている。

型としてprotocolを扱う

protocolを型として扱うと以下のような感じになります。

protocol Animal {
  func foo() -> Int
}

struct Dog: Animal {
  func foo() -> Int { 1 }
}

struct Cat: Animal {
  func foo() -> Int { 2 }
}

let animal: Animal = Cat()
print(animal.foo())

ここで注目して欲しいのはこの一文です。

let animal: Animal = Cat()

前述したようにprotocolの主な役割はジェネリクスに制約を設けることです。
それにも関わらず、ジェネリクスの制約として扱うほうが書きづらいのは微妙です。
そこでSwift 5.6からanyキーワードをつける事ができるようになりました。そしてSwift6からanyをつけていないと警告が出るようになるみたいです。

- let animal: Animal = Cat()
+ let animal: any Animal = Cat()

ではなぜprotocolを型として扱うことがメインではなくサブなのでしょうか。
これにはExistensial Containerというものが関わってきます。

例えば以下のようにDog型に1byte、Cat型に4bytesのstored propertyを持たせてみます。
そしてそれらをそれぞれインスタンス化すると、1 byteと4 bytesのインスタンスが生成されます。
ではそれらのインスタンスをAnimal型で抽象化された定数に格納した際、その定数は何byteになるのでしょうか。

struct Dog: Animal {
  var a: UInt8 = 0 // 1 byte
  func foo() -> Int { 1 }
}

struct Cat: Animal {
  var b: UInt32 = 0 // 4 bytes
  func foo() -> Int { 2 }
}

let dog: Dog = .init() // 1 byte
let cat: Cat = .init() // 4 bytes
let animal: any Animal = Bool.random() ? dog : cat // ? byte
print(animal.foo())

MemoryLayoutを使って確認してみます。

print(MemoryLayout.size(ofValue: dog)) // 1 byte
print(MemoryLayout.size(ofValue: cat)) // 4 bytes
print(MemoryLayout.size(ofValue: animal)) // 40 bytes

dogとcatは想定どおりになりましたが、animalに関しては40 bytesになっています。 
animal定数には、dogかcatしか入る可能性がないから大きい方の4 bytesにしておけば良いのではないかと思うかもしれません。

ではAnimal型に適合した以下のようなCow型が追加されたとします。

struct Cow: Animal {
  var c1: Double = 0 // 8 bytes
  var c2: Double = 0 // 8 bytes
  var c3: Double = 0 // 8 bytes
  var c4: Double = 0 // 8 bytes
  var c5: Double = 0 // 8 bytes
  var c6: Double = 0 // 8 bytes
  var c7: Double = 0 // 8 bytes
  var c8: Double = 0 // 8 bytes
  var c9: Double = 0 // 8 bytes
  var c10: Double = 0 // 8 bytes
  func foo() -> Int { 0 }
}

Cow型は合計80 bytesのstored propertyを持っており、当然それをインスタンス化すると80 bytesになります。

let cow: Cow = .init() // 80 bytes
print(MemoryLayout.size(ofValue: cow)) // 80 bytes

ではAnimal型で定義された定数にCowインスタンスを格納すると、その定数は何bytesになるでしょうか。
答えは40 bytesです。

let animal: any Animal = cow // ? byte
print(MemoryLayout.size(ofValue: animal)) // 40 bytes

Animal型で定義された定数には、Animal型に適合したインスタンスは何でも入れられないといけません。
しかし今回の様な80 bytesだったり、さらに大きいインスタンスを格納しようとした時にそれに合わせたメモリ領域を確保しようとすると上限がなくなってしまいます。
そこでどんなサイズの物が来ても入れられるように特殊な入れ物に入れています。
その入れ物のことをExistential Containerと言います。

Existential Containerが先程でた40 bytesと大きさになっています。

Existential Containerの24bytes分は値を格納するために使われます。

  • catやdogなど24 bytes以下のものは直接値が格納される
  • cowのような24 bytesを超えるようなものは、そのインスタンスをヒープ領域に格納し、そのアドレスが格納される

残りの16bytesは型情報などのメタデータやprotocol fitness tableが入っています。

Exitential Containerにはオーバーヘッドを生んでしまうという問題があります。

  • 1 byteしか必要としないdogをExistential Containerで包んでしまうと、40 bytesとなり無駄にメモリを消費してしまう。
  • Existential Containerで包んだり、それを剥がしたりするのでパフォーマンスが悪くなる。
  • animal.foo()としたときに、animalはcatかdogかcowかコンパイル時には分からず、実行時に解決しないと行けないので、パフォーマンスが悪くなる。

ただ以下のようにAnimal型のような抽象化されたものを使ってコーディングすることは多々あると思います。
しかし関数を引数に渡すときにexistential containerに包まれてしまうので、オーバーヘッドが発生してしまいます。

func use(animal: Animal) {
  print(animal.foo()) // dynamic dispatch
}
use(animal: dog)
use(animal: cat)

上記のような抽象化はポリモーフィズムの一種である、Subtyping(サブタイピング)と呼ばれる抽象化の方法を使っています。これをParametric polymorphism(パラメーター多相)という別の抽象化方法をジェネリック関数を用いて書いてみます。

func use<A: Animal>(animal: A) {
  print(animal.foo())
}
use(animal: dog)
use(animal: cat)

上記の様に書くことで、
任意の型がAnimalプロトコルに準拠していないといけない、という制約を持ったジェネリック関数を定義することができます。

そして上記関数はコンパイル時、**特殊化(Specialization)**という最適化が行われることがあり、これにより先程のジェネリック関数は以下のコード書いたときと同じバイナリが出力されます。

func use(animal: Dog) {
  print(animal.foo())
}

func use(animal: Cat) {
  print(animal.foo())
}

上記のanimal引数の型は、DogやCatとなっているのでExistential Containerに包む必要はありません。
そのためProtocolを型として扱った書き方より、型パラメーターの制約として扱ったときのほうがオーバーヘッドが少なくなります

これが最初に記述した、

Swiftのprotocolの主な役割はジェネリクスの型パラメーターに制約を設けるものです。
ただ副次的に型としても扱える便利機能も備えている。

の理由です。

Shun UematsuShun Uematsu

Opaque Parameter Declarations

Protocolの主な役割は、型パラメーターの制約を設けるものでした。
にもかかわらずSwift5.5までは、Protocolを型として扱うほうが書きやすく、型パラメーターの制約として扱うほうが書きにくい状態でした。

そこでこの現状を改善するために、
Swift5.6からはanyキーワード、Swift5.7からはsomeキーワードを使うことができるようになりました。

型として扱う場合、書きづらくなった
- func use(animal: Animal) {}
+ func use(animal: any Animal) {}
型パラメーターの制約として扱う場合、書きやすくなった
- func use<A: Animal>(animal: A) {}
+ func use(animal: some Animal) {}
Shun UematsuShun Uematsu

参照型中心の言語と値型中心の言語の違い

前述したように、Swiftでサブタイピングを実現しようとすると、Existential Containerに包む必要がありオーバーヘッドが発生すると説明しました。
しかしkotlinなどの参照型中心の言語では、サブタイピングでも問題ありません。

それは参照型と値型の特徴の違いにあります。
参照型は変数に代入する際、値そのものを格納しているのではなくメモリ上の別の領域(ヒープ領域のどこか)に保存され、その領域を表すアドレスが格納されています。
そのため参照型中心の言語ではExistential Containerに包む必要は無いので、値型中心の言語余地もオーバーヘッドも少ないのです。

Shun UematsuShun Uematsu

Opaque Result Type

ここまでは引数の型を抽象化する話をしてきました。では戻り値を抽象化するにはどうすればよいでしょうか。

サブタイピングを用いた場合は以下のように書くことができます。

func makeAnimal() -> Animal { Dog() }
let animal = makeAnimal()
print(animal.foo())

上記をパラメーター多相を用いて書く場合は、どのように書けば良いでしょうか。
例えば以下のようにジェネリクスを使って書いてみます。