💬

Nagoya.swift #2で登壇してきたよ + 補足

に公開

Nagoya.swift #2 というイベントがあったのでそちらで登壇してきました。
https://japan-region-swift.connpass.com/event/376480/

当日発表した資料のURLはここ
https://speakerdeck.com/h1d3mun3/silkaradu-mijie-kuswiftnohorimohuisumutostructnoyou-wei-xing

「Nagoya.swiftは愛知県や名古屋にゆかりのあるアプリ開発関係者と交流したいというコンセプトで発足された勉強会です。」とのことで、iOSDC Japanで知り合いの人が参加するのでついでに参加してきました 🎉

当日は、SIL上のメソッド表現とStructの優位性についてお話させていただきましたが、Structがなぜ早いかについて、「スタックに入るから早い」という趣旨の説明をしたんですが、知り合いのfreddiさんから補足をいただきましたので、こちらでも修正します。
(freddiさん教えくれてありがとおお! 🙏)

https://x.com/___freddi___/status/2047989131847008420

発表の全体と間違ってたところ

今回の発表では下記話をしました。

  1. ポリモーフィズムってなんだっけ
  2. Swiftのコンパイルの流れ
  3. SIL上でProtocolとClassのポリモーフィズムがどのように表現されているのか
  4. なぜStructのほうが早いのか

このとき4番目についてざっくり下記のように発言しました。

Classはヒープに入るが、Structはスタックに入る。
スタックはヒープとは違い、ポインタを進める、戻すだけでメモリ管理がOKなので早い

この「Structはスタックに入る」というのが過度に一般化しすぎていました。
なのでこのブログで修正させてくださいmm

anysome

SwiftではProtocolに準拠した型情報を表現するときに、 anysome の2つを使うことができます。

let anyAnimal: any Animal = Human()
let someHuman: some Animal = Human()

このときの anyExistential Type(存在型)someOpaque Type(不透明型) と呼びます。

any 特有のメリットとして、一度宣言した後別の型に置き換えることが可能である という点がありますが、

var anyAnimal: any Animal = Human()
anyAnimal.eat() // もぐもぐ

// ここで別の型にする
anyAnimal = Cat()
anyAnimal.eat() // ちゅ~るちゅ~るちゃおちゅーる♪

some の場合は 型が確定するため、別の型に置き換えることができない という点があります

var someHuman: some Animal = Human()
someHuman.eat() // もぐもぐ

someHuman = Cat() // 型がsomeなので、ここでCatを入れ直すことはできない

any のデメリット

とまぁここまで見ただけだと、「some を使うシーンってなくない?」って思うじゃないですか。
気軽にプログラムを書く分においてはたしかに仰るとおりなんですが、「コンパイル時点では、どの具体型が代入されるか分からないため、中身のサイズが確定しない」という問題があります。

どういうことか

たとえばスライドで利用したコードに関連させて下記ケースで話しましょう

protocol Animal {
    func eat()
}
// 必要なメモリが16バイト
struct Cat: Animal {
    func eat() { print("ちゅ〜るちゅ〜るちゃおちゅーる♪") }
}

// 必要なメモリが24バイト
struct Human: Animal {
    func eat() { print("もぐもぐ") }
}

// 必要なメモリが32バイト
struct BigHuman: Animal {
	func eat() { print("もぐもぐもぐもぐ")}
}

このとき下記のように書くと、コンパイル時点で「その変数が取りうる型」が確定しないので、そのメモリをどこまで確保してよいかがわからなくなります。

let anyAnimal: any Animal = Cat() // ← 何メモリ確保しておけば良い?

一方some の場合はコンパイル時に変数の具象の型が確定する というとても強いメリットがあるので、コンパイル時点で必要なメモリ長がFixします

let someCat: some Animal = Cat() // ← Cat型しか入らないので絶対に16バイトでよい

これが厄介な挙動につながります。

オフセットが計算できない!

下記のようなコードが有るときに、メモリ長が型によって変わるとめっちゃ困ります。

var anyAnimal: any Animal // この変数のサイズはコンパイル時に決まる必要がある
anyAnimal = Cat() // 16バイト
anyAnimal = Human() // 24バイト

このとき animals: [any Animal] という型があった場合、要素型ごとにメモリ長が異なるため 先頭から何バイトずらせば目的の要素にたどり着くのか がわかりません。
たとえば、animals[2] みたいに添字アクセスしたいときでもどこだけずらせばいいかがわからないため、要素にアクセスすることができなくなってしまいます。

そのためanyは特殊な管理をしている。

このように any の場合は変数の確保に必要なメモリ長が可変になりうるという問題を持っているため、Existential Container という仕組みを持っています。

この仕組みは下記のような挙動をします。

必要なメモリ長が3ワード以下の場合はContainerのbufferにそのまま格納され、そうでない場合はヒープに別途確保する
(Containerは buffer 3ワード + Metadata 1ワード + Protocol Witness Table 1ワード の合計5ワードで構成されます)

64bit環境においては1ワード=8バイトになりますので、Structに必要なメモリ長が24バイトより大きい場合はヒープに格納されます。
(この「指定ワードより大きい場合はヒープに格納する」仕組みを Boxing と呼びます)

先程の例で説明すると下記のような挙動になります。

var anyAnimal: any Animal = Cat() // 16バイトなのでbufferにinline
anyAnimal = Human()               // 24バイトなのでbufferにinline
anyAnimal = BigHuman()            // 32バイトなのでheapにBoxing

このとき、Boxingされたヒープへの参照はARCによく似た参照カウンタ形式で管理されるため、純粋にスタックに格納された場合に比べて 速度の面でビハインドがあります

まとめ

anyでProtocolに準拠したStructを使うとき、

  • 3ワード以下であれば Containerのbufferにそのまま格納される
  • 3ワードより大きい場合はヒープに格納され、ARCによく似た参照カウント方式の管理が必要になる

ということですね。

おわりに

Xで補足してくれたfreddiさんありがとう!
ある程度調べたつもりだったんですが、メモリにインスタンス化した後のことまでは調査が足りてませんでした!
優しく教えてくれて感謝です 🙏

参考資料

Discussion