🕌

Embrace Swift Genericsのセッションを振り返りながら、Swift 5.7以降でのProtocolに思いを馳せる

2022/06/26に公開約7,700字

はじめに

WWDC22のセッション Embrace Swift Generics にて、Swift5.7で someキーワード・anyキーワードに対して実装される新機能についての発表がありました。

この機能拡張により、SwiftにおいてProtocolをより使いやすく・読みやすく利用できるようになると考えています。

ここでは自分自身の振り返りのために、SwiftにおけるProtocolの振る舞いや目的、ユースケースをまとめ直し、Swift 5.7で導入されるsomeキーワードとanyキーワードに触れながら日本語でまとめました。

(記事の内容は概ねWWDC22のEmbrace Swift Generics セッションと同じです)

1. ポリモーフィズムとは

SwiftではProtocolを利用して、ポリモーフィズムを実現できます。

ポリモーフィズムとは複数異なる型に一元的にアクセスできる接点を作ることです。

たとえば、人間・猫・牛・魚など、様々な動物がいます。動物は何かを「食べる」事ができる一方、下記の点で細かい違いがあります。

  • どう食べるか(咀嚼するのか飲み込むのか、血だけ飲んで本体には手を付けないのか…etc)
  • 何を食べるか(雑食なのか、ユーカリの葉っぱだけを食べるのか、プランクトンじゃないとだめなのか…etc)
  • どれぐらいの周期で食べるのか(1日3回か、1週間に一回か…etc)

このようなとき、「食べる」動作は下記条件を満たすことになり、ポリモーフィズムが実現されています。

  1. 「食べる」動作そのものはすべての動物に存在する
  2. 「食べる」動作の条件は各動物によって異なる

このようなとき、Swiftでは下記のように記述することでポリモーフィズムを実現することができます。

protocol Animal {
    associatedType: Feed
    func eat(_ food: Feed)
}

2. Swiftでポリモーフィズムを実現する

プログラム上でポリモーフィズムを実現する有名なやり方は下記3つです

  1. AdHocポリモーフィズム
  2. パラメトリックポリモーフィズム
  3. サブタイピング

この内、1と2について、具体的なやり方を交えながら振り返ります

(3については内容的に別になるので、本ページでは解説しません。興味がある場合はWikipediaを参照すると良いでしょう)

2.1 AdHocポリモーフィズムで実現する

難しく言うと、「恣意的な型の集合に一つの共通接点を提供する」方法でポリモーフィズムを実現するパターンです。

わかりやすいやつだと、皆さんも多分見たことのある、親クラスに抽象メソッドが定義されていて、各サブクラス側で抽象メソッドをoverrideしてあげる形のあれです

Swiftだと多分こういうコードになります。(Appleセッションより引用、以降注記のない限りコードはセッションよりの引用)

class Animal {
    func eat(_ food: Any) { fatalError("subclass must implement 'eat'")
}

class Chicken: Animal {
    override func eat(_ food: Any) {
        guard let foot = food as?  Carrot else { fatalError("Chicken cannot eat \(food)") }
    }
}

この段階のAdHocポリモーフィズムでは下記2つの問題点があります

  1. 親クラスの eatメソッドのoverrideを強制できない
  2. 単一のメソッドを各具象クラス側でoverrideする、というやり方でポリモーフィズムを実現しているため、メソッドの引数の型を明示的に決定できない

ジェネリクスを利用することで、後者の問題点を改善し、もう少し型安全に実装することができます。

class Animal<Food> {
    func eat(_ food: Food) { fatalError("Subclass must implement 'eat'!")}
}

class Chicken :Animal<Carrot> {
    override func eat(_ food: Grain) {
        // eat carrot
    }
}

しかし上記を持ってしても親クラスの eatメソッドのoverrideを強制できない問題については改善することができない上、下記のように追加で何かを定義したいときにはAnimalの定義が長くなってしまうことが避けられません。

class Animal<Food, Commodity> {
    func eat(_ food: Food) { fatalError("Subclass must implement 'eat'!")}
    func produce() -> Commodity { fatalError("Subclass must implement 'produce'!")}
}

class Chicken:Animal<Grain, ChickenMeat> {
    override func eat(_ food: Grain) {
        // eat carrot
    }
    
    override func produce() -> ChickenMeat {
        return ChickenMeat()
    }
}

AdHocポリモーフィズムを用いて問題を解決する場合、問題の解決そのものはできますが、注意深く実装する必要があることがわかりました。

2.2 パラメトリックポリモーフィズムを使う

Protocolを利用する場合、今まで説明したAdHocポリモーフィズムではなくパラメトリックポリモーフィズムというやり方で、ポリモーフィズムを利用することができます。

一番最初の例をProtocolで実現する場合、下記のような形になります。

protocol Animal {
    associatedtype Food: AnimalFeed
    
    func eat(_ food: Food)
    func produce() -> Commodity
}

struct Chicken: Animal {
    typealias Food = Grain

    func eat(_ food: Grain) {
        // do something eat...
    }
}

また実装していく上で追加の要件を実装したくなったときも、下記のように、associatedtype を追加するだけでOKです。

protocol Animal {
    associatedtype Food: AnimalFeed
    associatedtype Commodity: AnimalCommodity
    
    func eat(_ food: Food)
    func produce() -> Commodity
}

struct Chicken: Animal {
    typealias Food = Grain
    typealias Commodity = ChickenMeat

    func eat(_ food: Grain) {
        // do something eat...
    }
    
    func produce() -> ChickenMeat {
        return ChickenMeat()
    }
}

この実装であれば将来別のHorse型を追加したくなってもわかりやすい実装で実現することができます。

3. Protocolにより実現したポリモーフィズムは2パターンの使い方がある

Swiftという言語において、Protocolを使う場合、下記2つの使い方が可能です。

  • 型としてのProtocol
  • 制約としてのProtocol

しかしながら、 Self-Requirementsである、もしくはassociated typeを持つProtocolの場合、後者の利用の方法しかありませんでした。

(前者として利用しようとすると、コンパイルエラーになる)

3.1 some Animal

型としてProtocolが利用できない場合、例えば引数も下記のような見慣れた書き方をすることができません。

struct Farm {
    func feed(_ animal: Animal) {
// do something to eat...
    }
}

このため、 Self-Requirementsである、もしくはassociated typeを持つProtocolを引数として受け付けるメソッドを作る場合、下記のように実装する必要がありました。

struct Farm {
    func feed<A>(_ animal: A) where A: Animal {
        // do something to eat...
  }
}

この書き方はSwiftにおいてとてもよくある方法ですが、Swift5.7では下記のように書き方を変更できます

struct Fartm {
    func feed(_ animal: some Animal) {
        // do something to eat
    }
}

素晴らしいですね! where句を利用したやり方より書きやすく、ナチュラルな読み方ができます。

この some Animalのような、特定の具象型のプレースホルダーとして取り扱える型を Opaque Type(不透明型)と呼びます。

対して、このOpaqueTypeに導入される具体的な型の方は Underlying Type(基礎型)と呼びます。

また入力の引数にも返却値のどちらにもこのOpaqueTypeを利用できます。

このOpaqueTypeにはいくつかの注意点があります。

  • OpaqueTypeは通常の変数の宣言と同じですので、初期値を代入してあげる必要があります。
  • OpaqueTypeで宣言されていたとしても、変数のスコープで型は1つに固定されている必要があるので、型を変更しようとするとエラーになります
var animal: some Animal = Horse() // ←この段階で animalの型が Horse に固定される
animal = Chicken() // ← これはエラーになる
  • OpaqueType で戻り値を宣言したとしても、返却する型を状況に応じて置き換えることは許可されません。
// 下記のようなコードはコンパイルエラーになる。
func makeView(for farm: Farm) -> someView {
    if condition {
        FarmView(farm)
    } else {
        EmptyView()
    }
}

3.2 any Animal

さて、これまでで「あるプロトコルに適合した任意の型」を some Animal のような形で表現することができることがわかりました。

実際のコーディングにおいては、おそらく「複数の動物にまとめて餌を上げる」というユースケースも生まれるはずです。

その時 some Animalを利用したい・・・となるかもしれませんが、 some Animalの書き方ではベースの Underlying Typeを変更することができないという問題がありました。

つまり、犬や猫や馬や鳥などの任意の型のまとまりとして扱うことはできず、犬だけの集まり、猫だけの集まりと特定の方に絞った集まりとしてしか使うことができないのです。

この「特定のなにかの型だけのCollection」を Homogeneous Collection と呼び、「いろんな型が含まれたCollection」を Heterogeneous Collectionと呼びます。

今回のユースケースにおいては、 any Animal と書くことでその問題を解決できます。

any Animalではベースとなる Underlying Typeが1つに限定されていないため、Heterogeneous Collectionを実現できます。

この any Animalのような型はExistential Type(実存型)と呼ばれます。

3.3 any Animalsome Animalの変換

any Animalでは、複数の型の共通集合であるHeterogeneous Collectionが利用できることがわかりました。

しかしながら、 any Animalを利用することで型関係がなくなってしまっており、 associatedTypeSelf Requirementsな引数を持つ処理が利用できなくなります。

今回の例でいうと、any Animal 型だとfeedの処理が利用できません。

func feedsAll(_ animals: [any Animal]) {
    animals.forEach({ animal in
        // ここでは animalが何を食べるかがわからないので、 eatメソッドを呼び出すことができない
    })
}

このためany Animal型を some Animal型の世界に引っ張って来る必要があります。

Swiftではこの面倒な作業をコンパイラがやってくれるので、下記のように some Animal型の引数を取る関数に投げてあげるだけで、中の値を取り出すことができます。

func feed(_ animal: some Animal) {
    let crop = type(of: animal).Feed.grow()
    animal.eat(crop)
}
    
func feedsAll(_ animals: [any Animal]) {
    animals.forEach({ feed($0) })
}

feedsAllforEach文内部では any Animal型ですが、feed内部で some Animal型に変換されるので eat処理を呼び出すことができるのです。

4. 終わりに

以前からもSwift言語におけるProtocolの型情報を消失させるテクニックは type-erasureとしてよく使われていましたが書き方がめんどくさくてあまり使っていませんでした。

Swift 5.7からコンパイラレベルで someとanyが導入されることにより、SwiftにおけるProtocolの利便性が大きく向上することにワクワクしています。

5. 参考リンク

本記事のために参考にさせていただいたサイトです

Discussion

ログインするとコメントできます