Swift & Objective-C

[Swift中級 #2] ジェネリックで重複と危険をなくす

Swiftコードで初めて山括弧 <T> に出会うと、思わず目が止まります。関数名の横に大文字のアルファベットがあり、ドキュメントを開くと func map<T>( transform: (Element) -> T) -> [T] のようなシグネチャが待っています。ジェネリックはSwift中級への登竜門です…

読了 7 分
[Swift中級 #2] ジェネリックで重複と危険をなくすのカバー画像

Swiftコードで初めて山括弧 <T> に出会うと、思わず目が止まります。関数名の横に大文字のアルファベットがあり、ドキュメントを開くと func map<T>(_ transform: (Element) -> T) -> [T] のようなシグネチャが待っています。ジェネリックはSwift中級への登竜門です。標準ライブラリのほぼすべて、Array、Dictionary、Optionalまでがジェネリックで作られているため、これを乗り越えて初めてライブラリコードが読めるようになります。

中級シリーズ第2回です。ジェネリックが解決する問題、制約(constraint)とwhere句が必要になる場面、そして性能まで整理します。

ジェネリックが解決する問題 — 重複か型安全性かという二者択一を拒む

ジェネリックのない世界で「2つの値を入れ替える関数」を作ってみると、問題がすぐに見えてきます。

Int用に作るとStringでは使えません。型ごとにコピーすれば同じコードが増えます。そこで浮かぶのが「どんな型でも受け取る箱」、SwiftでいうAnyです。しかしAnyを使った瞬間に型情報が失われます。取り出すたびにキャストが必要で、Intを入れてStringとして取り出すミスがコンパイルを通り、実行時クラッシュになります。

つまり選択肢は「重複はないが危険なAny」と「安全だが重複する型別コピー」だけでした。ジェネリックはこの二者択一を拒みます。

func swapValues<T>(_ a: inout T, _ b: inout T) {
    let temp = a
    a = b
    b = temp
}

<T>は「後から型を埋める空欄」の宣言です。呼び出した瞬間にTが具体型として確定し、コンパイラはその型で全体を検査します。swapValues(&intA, &strB)のように型が合わない呼び出しはコンパイルエラーです。コードは1つなのに型検査は型ごとに行われる――これがジェネリックの本質です。

型にも同じことが当てはまります。struct Stack<Element>と宣言すると、Stack<Int>Stack<String>は別の型になり、Intのスタックに文字列を入れるミスをコンパイラが防ぎます。Arrayはまさにこの構造で、Optionalもオプショナルの記事で見たようにenum Optional<Wrapped>というジェネリックenumでした。つまり、すでに毎日ジェネリックを使っていたのです。

制約 — 「どんな型でも」から「この能力を持つ型」へ

空欄Tの基本状態は任意の型です。しかし任意の型ということは、何もできないという意味でもあります。コンパイラはTについて何も知らないため、比較も出力も加算もできません。

func largest<T>(_ items: [T]) -> T? {
    items.max(by: <)  // コンパイルエラー — T比較可能とは保証されない
}

ここで制約が登場します。<T: Comparable>と書くと、「TにはComparableに準拠した型だけを渡せる」という条件が付き、その瞬間からTに対して<を使えるようになります。

func largest<T: Comparable>(_ items: [T]) -> T? {
    items.max()
}

制約は損失ではなく取引です。受け付ける型の幅を狭める代わりに、その型でできることが増えます。プロトコル編で扱った「能力の組み合わせ」という概念がジェネリックと交わる場所がここです。制約に使うのがプロトコルだからです。

条件が複雑になったらwhere句で書きます。位置が違うだけで意味は同じですが、型パラメータの関連型に条件を付ける場合はwhereが必須になります。

// Elementが Equatableであるコレクション同士だけを比較
func allEqual<C: Collection>(_ items: C) -> Bool where C.Element: Equatable {
    guard let first = items.first else { return true }
    return items.allSatisfy { $0 == first }
}

コレクションが保持する要素型を指すのが関連型(associated type)で、C.Elementのように表します。これは次々回で本格的に扱うテーマなので、ここではwhere句がその条件を付ける場所だと覚えておけば十分です。

実務上の感覚を1つ加えるなら、制約は必要な分だけ付けるのがよいでしょう。Comparableで十分な関数にHashableまで要求すると、使える型だけが減ります。関数本体が実際に使う能力が、制約の正確な一覧です。

制約は門を狭める代わりに、内側でできることを増やします
制約は門を狭める代わりに、内側でできることを増やします

性能の話 — コンパイラが抽象化のコストを消す

「ジェネリックは遅いのでは?」という疑問には、Swiftを代表する最適化で答えられます。特殊化(specialization)です。

原理上、ジェネリック関数はどの型が来るか分からないため、実行時に型情報を持ち運び、間接的に動作する必要があります。しかしコンパイラが呼び出し箇所を見られるなら、swapValues<Int>専用のバージョンを別に生成します。手書きでInt版を書いた場合と同じ機械語が出ます。ジェネリックという抽象化を使ったから実行時コストを払うのではなく、コンパイラが抽象化を剥がして具体的なコードにするのです。哲学編第1回で述べた、ゼロコスト抽象化の代表例です。

同じモジュール内ではこの最適化がよく働きますが、モジュール境界を越えると制限が生じます(ライブラリの配布形態によって異なります)。日常のコードで性能を心配してジェネリックを避ける理由はなく、本当のボトルネックは計測で見つけるべきです。早すぎる最適化については別記事で扱いました。

ここで自然な疑問が出ます。「プロトコル型で受け取るなら(items: [Comparable]のように)、ジェネリックと何が違うの?」良い質問で、まさに次回のテーマです。ジェネリックはコンパイル時に型が確定する静的ポリモーフィズムで、プロトコル型(existential)は実行時に型が混在する動的ポリモーフィズムです。Swiftがsomeとanyというキーワードで両者を明示させた理由まで、次回に続きます。

いつジェネリックを作るか — 実務での判断基準

ジェネリックを読むことと自分で設計することは別の次元なので、作る側の基準を整理します。

**のようなロジックが型だけ変わって2回目に現れたら、それが合図です。**最初から「いつか別の型も来る」とジェネリックで始めるのは、たいてい過剰設計です。YAGNI (You Aren’t Gonna Need It — 必要になるまで作らない)の原則どおり、具体型で始め、実際に重複が生じた瞬間に一般化するのが順序です。

**はドメイン概念より構造やアルゴリズムに向いています。**キャッシュ、ページネーションのレスポンス、スタックのように内容物に依存しない構造がジェネリックの得意分野です。APIResponse<User>Cache<ImageKey, UIImage>のようなものです。逆に注文決済など特定ドメインのロジックを無理にジェネリック化すると、シグネチャだけが難しくなります。

**シグネチャの複雑さが利用側のメリットを超えたら、引き返す合図です。**型パラメータが3、4個付き、where句が3行になるなら、呼び出す同僚が読めるか自問しましょう。Progressive Disclosure編で見た原則はここにも適用されます。複雑さは宣言側が吸収し、利用側に漏らしてはいけません。

特殊化が抽象化を剥がし、手書きコードと同じ機械語を生成します
特殊化が抽象化を剥がし、手書きコードと同じ機械語を生成します

まとめ

  • ジェネリックは、重複のない再利用性と型安全性の二者択一を拒む仕組みです。コードは1つ、型検査は型ごとに行われます。
  • <T>は型の空欄の宣言で、制約(T: Comparable、where句)は空欄を狭める代わりにできることを増やす取引です。
  • 特殊化のおかげで、ジェネリックは多くの場合、手書きの具体コードと同等の性能になります。
  • 設計基準:2つ目の重複が現れたら一般化し、構造やアルゴリズムに使い、シグネチャの複雑さがメリットを超えたら止めます。

次回はジェネリックの兄弟であり、最近のSwiftで最も混乱しやすい構文、someとanyです。some Viewの正体とexistentialのコストまで掘り下げます。

あわせて読みたい