Swift 5.7で、型関係を破滅させないProtocolの利用方法
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
メソッドを持っている
この段階でもビルドができます。
Animal
の Homogeneous CollectionをForEach文で回してみよう!
2.3 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の型に定義されているFeed
のgrow()
を呼び出し、 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にコードを書いていきましょう 💪
Discussion