💯

Swift 5.7で、型関係を破滅させないProtocolの利用方法

2022/07/02に公開約4,300字

1. はじめに

別の記事でも記載しましたが、Swift5.7以降では someキーワード、 anyキーワードに新機能が実装されます。

これらを適切に利用することで、Protocolを制約として取り扱いやすくなり、Structを中心にプログラミングをしていくことができます。

その一方で、someキーワードや anyキーワードを利用して抽象化しすぎたProtocolに関連するコードを取り扱う場合、型の関係が崩壊しコンパイルできなくなってしまう場合があります。

この記事では、コンパイルできなくなってしまう具体例をSwiftコードで例示しながらその解決策を提示します。

2. Protocolを利用して抽象化した結果、意図せず抽象化すぎたために型関係が崩壊し、コンパイルできなくなってしまう例

2.1 Protocol類の定義

例えば下記のようなSwiftコードがあったとしましょう

protocol Crop {
    associatedtype FeedType: AnimalFeed
    func harvest() -> FeedType
}

protocol AnimalFeed {
    associatedtype CropType: Crop
    static func grow() -> CropType 
}

protocol Animal {
    associatedtype Feed: AnimalFeed

    func eat(_ food: Feed)
}

各Protocolは下記状態です

  • Crop は草などの収穫物を表し、収穫物から作られる餌のタイプをFeedType として保持しています。
  • AnimalFeed は牧草などの動物の餌を定義し、自分の餌を作れる収穫物をCropType として保持しています。
  • Animal は動物を表し、自分が食べられる餌のタイプを Feed として保持しています。

この段階ではコンパイルが可能です。

2.2 各Protocolに対応した、Structを定義してみる。

では、それぞれに具象であるStructを定義してみましょう。

何でもいいのですが、なんとなく牛 🐮 を利用して定義してみます。

するとこうなります。

struct Grass: Crop {
    typealias FeedType = Hay
    
    func harvest() -> Hay {
        return Hay()
    }
}

struct Hay: AnimalFeed {
    typealias CropType = Grass

    static func grow() -> Grass {
        return Grass()
    }
}

struct Cow: Animal {
    typealias Food = Hay

    func eat(_ food: Hay) {
        print("無事に草が食べられた")
    }
}

各Structは下記状態です。

  • Grass(牧草)は、収穫後に得られる型Hay(干し草)を表現していて、収穫するための harvest メソッドを持っている
  • Hay(干し草)は自分自身を収穫できるCropTypeであるGrass(牧草)を表現していて、牧草を育てるための grow メソッドを持っている
  • Cow(牛) は自分が食べられるFeedTypeである Hay(干し草) を表現していて、餌を食べるための eat メソッドを持っている

この段階でもビルドができます。

2.3 Animal の Homogeneous CollectionをForEach文で回してみよう!

Animal型の Homogeneous Collection を ForEachで回すユースケースを考えてみましょう。

そういうときは、下記のように実装できます。

func feedAll(to animals: [some Animal]) {
    animals.forEach({ animal in
        let crop = type(of: animal).Feed.grow()
        let harvest = crop.harvest()
        animal.eat(harvest)
    })
}

このコードに登場している要素はすべて今までの記事で説明してきたものですし、極めてこう動きそうなコードに見えます。

ただこのコード、実はビルドできません

3. なぜビルドできないのか

問題の feedAll関数を1行ずつ見てみましょう。

forEach文の最初でまず、animalの型に定義されているFeedgrow()を呼び出し、 cropとして保持します。

cropには Crop Protocolに適合した型が入っているため、Crop Protocolで宣言している harvest() を呼び出し、 harvestとして保持します。

さて、harvestには、FeedType型が入っています。

しかしこの FeedType型、実は当初の animalの型に定義されている FeedTypeと一致している保証がないんです。

Crop Protocolと、AnimalFeed Protocolは下記のような宣言になっています。

protocol Crop {
    associatedtype FeedType: AnimalFeed
    func harvest() -> FeedType
}

protocol AnimalFeed {
    associatedtype CropType: Crop
    static func grow() -> CropType 
}

そのため、下記のような実装だったとしても一応Protocolに適合したことになります。(Protocolの意図は無視していますが)

struct Flower: Crop {
    typealias FeedType = Pollen

    func harvest() -> Pollen {
        return Pollen()
    }
}

struct Pollen: AnimalFeed {
    typealias CropType = Cedar

    static func grow() -> Cedar {
        return Cedar()
    }
}

struct Cedar: Crop {
    typealias FeedType = Sawdust
    
    func harvest() -> Sawdust {
        return Sawdust()
    }
}

このため、 Animal Protocolに定義されているFeed型のgrowから処理を始めたのに、得られる Feedの型が当初予期した型ではない可能性があるため、このコードはコンパイルできないのです

このような状況を過剰な抽象化と私は呼んでいます。

4. ビルドできるようにする

前述したようにfeedAllのコードがビルドできないのは、型同士の関係性がコンパイル時に決定しないことでした。

ということは、どうにかして、型同士の関係性をコンパイル時に決定できるようにしてあげれば、上記のコードはビルドできるわけです。

Swift 5.7には where句があり、下記のようにProtocolの宣言を改修することでビルドできるようになります。

protocol Crop {
    associatedtype FeedType: AnimalFeed
    func harvest() -> FeedType
}

protocol AnimalFeed {
    associatedtype CropType: Crop where CropType.FeedType == self
    static func grow() -> CropType 
}

この変更により、AnimalFeed ProtocolはCropType.FeedTypeが自分自身と同じな CropTypeを保持できるようになりました。

その結果AnimalFeedのCropTypeからharvestできる返却値は、AnimalFeedと一致することが保証されるので、下記のビルドできなかったコードは無事ビルドできるようになる、というわけです。

func feedAll(to animals: [some Animal]) {
    animals.forEach({ animal in
        let crop = type(of: animal).Feed.grow()
        let harvest = crop.harvest()
        animal.eat(harvest)
    })
}

5. 終わりに

Protocolによる過剰な抽象化は、Protocolを利用し始めたときによく起こると思われます。

Swift言語による回避策をうまく利用して、Protocolを最大限に活用し、Swiftyにコードを書いていきましょう 💪

6. 参考リンク

Discussion

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