Swift & Objective-C

[Swiftの哲学 #3] なぜSwiftはすべてstructなのか? 値型優先主義まとめ

Swift標準ライブラリを見ると、Int、Double、BoolだけでなくString、Array、Dictionary、Setまで、すべてstructです。他言語ならクラスである型も、Swiftでは値型です…

読了 8 分
[Swiftの哲学 #3] なぜSwiftはすべてstructなのか? 値型優先主義まとめのカバー画像

Swift標準ライブラリには、Int、Double、Boolはもちろん、String、Array、Dictionary、Setまで、すべてstructという特徴があります。他言語でクラスの型もSwiftでは値型です。JavaのStringはクラスで、Pythonではすべてがオブジェクト参照なのとは対照的です。

偶然ではありません。Swiftは設計段階で「基本の選択肢は値型」と定め、WWDC 2015の「Protocol-Oriented Programming」と「Building Better Apps with Value Types」で公式方針として示しました。本稿では、なぜ参照ではなく値をデフォルトにしたのか、その選択が言語全体に与えた影響を整理します。

Swiftの哲学シリーズ第3回です。クラスと構造体の文法上の違いは別記事で扱ったため、ここでは「なぜ違いを設けたのか」という設計意図に集中します。

参照が基本だった世界の慢性的な問題

値型優先主義を理解するには、まず参照型が基本の世界にある問題を見る必要があります。

参照型の本質は共有です。変数をコピーしてもオブジェクトは1つのままで、2つの変数が同じオブジェクトを指します。意図した共有は機能ですが、意図しない共有はバグの温床です。典型的なパターンは次のとおりです。

// 参照型(クラス)だと仮定
let settings = defaultSettings
settings.fontSize = 20   // デフォルト設定を変更するつもりはなかったのに
// defaultSettings.fontSizeも 20なった

コピーしたつもりが、共有していたのです。このバグの厄介な点は症状と原因が遠く離れていることです。値が壊れた場所と壊したコードが複数のファイルを隔てているため、デバッグは「このオブジェクトを参照する箇所をすべて追跡する」作業になります。

Objective-C開発者はこの問題をよく知っており、慣習で防いできました。NSStringのプロパティをcopyで宣言し、NSArrayを可変版(NSMutableArray)と不変版に分け、defensive copyを習慣的に入れていました。どれも「参照が基本であることによる問題」を開発者の規律で補うパッチでした。

Swiftチームの見方はこうでした。毎回慣習で防ぐ必要がある問題なら、言語のデフォルトが間違っているのではないか。

値型が守るもの — ローカル推論

値型の本質は、コピーすると本当に別物になることです。

var a = [1, 2, 3]
var b = a
b.append(4)
// aは依然として [1, 2, 3]

これは単なる便利さではなく、ローカル推論を保証します。配列を関数に渡すとき、値型なら「この関数が配列を密かに変更するかもしれない」と心配する必要がありません。自分の変数の状態は、自分のコードブロックだけ読めば把握できます。参照型の世界では、誰がオブジェクトを保持し、いつ変更するかをプログラム全体で考える必要がありましたが、値型はその範囲を目の前の関数1つに縮めます。

これは第1回で扱ったSafeの哲学に正確につながります。Optionalが「nilの確認忘れ」をコンパイル時に検出するように、値型は「意図しない共有」というバグクラスを型レベルで除去します。letで宣言したstructが本当に不変になる点も利点です。クラスインスタンスのletは参照が変わらないだけで、内容は変えられます。

この性質は時間とともに価値を増しました。マルチスレッド環境のデータ競合は、複数のスレッドが同じメモリを共有すると発生します。値型はそもそも共有しないため、競合の前提が消えます。Swift Concurrencyがスレッド境界を越えられるSendableの代表として値型を挙げるのは自然な帰結です。2014年の設計判断が、2021年の並行処理モデルで実を結びました。

参照は1つの風船を一緒に持ち、値はそれぞれ自分の風船を持つ
参照は1つの風船を一緒に持ち、値はそれぞれ自分の風船を持つ

「コピーは高コストでは?」— Copy-on-Writeという答え

値型優先主義への最初の反論は、いつも性能です。10万要素の配列を関数に渡すたびに丸ごとコピーしたら、現実的ではないのではないかという疑問です。

Swiftの答えがCopy-on-Write(CoW)です。Array、Dictionary、Set、Stringなどの標準コレクションは代入時に内部ストレージを共有し、どちらかが変更された瞬間だけ実際にコピーします。意味論上は変更が互いに見えない完全な値でありながら、読むだけならコピー不要で参照並みのコストです。

var a = hugeArray   // コピーなし、ストレージを共有
let x = a[0]        // まだコピーなし
a.append(1)         // ここで初めてコピーが発生

注意すべきなのは、CoWが言語機能ではなくライブラリ実装技法だという点です。標準コレクションには組み込まれていますが、自作structに自動で付くわけではありません。大きなデータを持つカスタム値型が必要なら、isKnownUniquelyReferencedで自分で実装します。この実装の詳細はCoWの別記事で整理しています。

逆方向の性能上の利点もあります。小さなstructはヒープ割り当てや参照カウントなしでスタックに置けるため、クラスより安価です。CGPointがstructである理由です。したがって「値型は遅い」という直感は、Swiftではたいてい逆に働きます。

継承なしで生きる方法 — プロトコルと合成

値型優先主義には代償があります。structは継承できません。参照なしで部分的なポリモーフィズムを実装するのが難しいためです。ではコード再利用とポリモーフィズムはどう実現するのでしょうか。

Swiftの答えがプロトコル指向プログラミング(POP)です。共通インターフェースをプロトコルで宣言し、共通実装をプロトコルextensionに入れ、型が複数のプロトコルに準拠して能力を組み合わせます。継承が1つの親からすべてを受け継ぐ垂直構造なら、プロトコル準拠は必要な能力を選んで組み込む水平構造です。

struct Player: Codable, Equatable, Comparable {
    let name: String
    let score: Int

    static func < (lhs: Self, rhs: Self) -> Bool {
        lhs.score < rhs.score
    }
}

このstructは何も継承していませんが、JSON変換、同値比較、ソート能力を備えています。CodableとEquatableなら、コンパイラが実装を自動合成してくれます。値型とプロトコルの組み合わせが、継承の実用的な用途の大半を置き換えるのです。

だから値型優先主義とプロトコル指向は一組です。WWDC 2015で2つのセッションが並んで発表されたのは偶然ではありません。「クラス継承ではなくstructとプロトコル」がSwiftの基本構成であり、標準ライブラリ全体がこの方式で作られています。POP自体は別記事で詳しく扱っています。

Copy-on-Writeは読むとき共有し、書く瞬間だけコピーする
Copy-on-Writeは読むとき共有し、書く瞬間だけコピーする

では、クラスはいつ使うのか

値型優先主義は「クラスを使うな」という意味ではありません。正確には「デフォルトはstruct、参照が必要な根拠があるときはクラス」です。根拠はおおむね3つあります。

**アイデンティティ(identity)が意味を持つとき。**データベース接続、画面のビュー、ファイルハンドルのように「値が同じでも別の存在」であるものは参照が自然です。設定が同じでも、2つの接続は同じ接続ではありません。

**共有そのものが目的のとき。**複数の画面が同じ状態を見る共有モデルや、アプリ全体で1つだけ存在すべきマネージャーでは、参照の共有性が機能になります。

**ライフタイム管理が必要なとき。**deinitでリソースを解放する必要がある場合や、UIKitなどのObjective-Cフレームワークと連携する場合はクラスを使います。

Apple公式ドキュメントのガイドも同じ方向です。基本はstructとenumを使い、条件に当てはまるときクラスを選びます。実際、SwiftUI時代のアプリコードは、ビューと状態データをstructにし、少数の参照モデルを@Observableクラスにする構成へ収束しています。値が基本で参照が例外という哲学が、UIフレームワークのレベルまで貫かれています。

まとめ

  • Swift標準ライブラリのほぼすべてがstructなのは設計方針です。基本の選択肢は値型です。
  • 目的は、参照が基本の言語で意図しない共有から生じる遠隔バグを、型レベルで除去することです。
  • 値型はローカル推論を守り、その性質はSwift ConcurrencyのSendableで再び生かされています。
  • 性能への反論には、標準コレクションのCopy-on-Writeと小さなstructのスタック割り当てが答えです。
  • 継承の空白はプロトコル指向プログラミングが埋め、両者は一組として設計されています。
  • クラスは、アイデンティティ・共有・ライフタイム管理が必要なときに選ぶ道具として位置づけ直されました。

ここまでSwiftの哲学シリーズでは、安全性(第1回)、学習曲線(第2回)、値型(第3回)を見てきました。次回は、これらの哲学が実際に言語へ反映されるSwift Evolutionのプロセスを扱います。文法1つがSE-XXXX番号を得て言語に入るまでの道のりです。

あわせて読みたい