[Swift] associatedtypeのあるprotocolにキャストする

takasek
FiNC Tech Blog
Published in
14 min readSep 28, 2018

--

それは無理です

Swiftでは、associatedtypeがあるprotocolにはキャストできません。

こんにちは。FiNCのiOSチームで開発をしているtakasekといいます。Swiftの型の話をします。

どういうこと

Swiftのprotocolは、associatedtypeがない場合に限り、存在型(そのprotocolと同じメンバを持つ具体型)として扱うことができます。(追記: 正確には「associatedtypeがない、かつ var hoge: SelfのようなSelfを使うメンバもない場合に限り」です。ご指摘感謝
しかしprotocolがassociatedtypeを持つ場合、型の構造が静的に定まりません。型情報を抽象的に扱う必要があるので、統一的に処理する場合genericなfuncを通さなくてはなりません。

protocol Animal {
associatedtype Assoc
}
func treatAsExistential(animal: Animal) {
// 👆Error:
// Protocol 'Animal' can only be used
// as a generic constraint because
// it has Self or associated type requirements

// Animal.Assoc の型が定まらないので、
// Animalは存在型としては扱えない ということ
}
func treatAsGeneric<T: Animal>(animal: T) {
// generic funcなら、Animalを扱える
}

では、genericなfuncの中で、総称化されたprotocolをさらにキャストしたくなったら、どうなるでしょうか。
詰みます。

protocol Animal {
associatedtype Assoc
}
protocol BarkableAnimal: Animal {
func bark() -> Assoc
}
func askAnimalToBark<T: Animal>(animal: T) -> T.Assoc? {
// Animal が BarkableAnimal だったらbarkさせたい
// しかし Animal は associatedtypeを持つprotocolなのでキャストできない
if let barkableAnimal = animal as? BarkableAnimal {
// 👆 Error:
// Protocol 'BarkableAnimal' can only be used
// as a generic constraint because
// it has Self or associated type requirements

// キャストできないと、 `bark()` を呼ぶこともできない

return barkableAnimal.bark()
// 👆 Error:
// Member 'bark' cannot be used
// on value of protocol type 'BarkableAnimal';
// use a generic constraint instead
} else {
return nil
}
}

どうしよう

ああ、あれだ、型消去の出番だ! と思う方もいらっしゃるかもしれません。associatedtypeをジェネリックな型パラメータに焼き付けるパターン。こういうやつです。

struct AnyBarkableAnimal<T> {
let bark: () -> T
init<A: BarkableAnimal>(animal: A) where A.Assoc == T {
bark = animal.bark
}
}

なんかいけそうな気がしてきましたね?
残念! 無理です!

func askAnimalToBark<T: Animal>(animal: T) -> T.Assoc? {
let barkableAnimal = AnyBarkableAnimal(animal: animal)
// 👆 Error:
// Argument type 'T' does not conform
// to expected type 'BarkableAnimal'

// しかし animalをBarkableAnimalだと認識させる手段がない!
...
}

func askAnimalToBark の引数を増やしてみるのはどうでしょうか。

func askAnimalToBark<T: Animal>(
animal: T,
barkIfPossible: (() -> T.Assoc)?
) -> T.Assoc? {
return barkIfPossible?()
}
struct Turtle: Animal {
typealias Assoc = Int
}
struct Cat: BarkableAnimal {
typealias Assoc = String

func bark() -> String {
return "meow!"
}
}
let turtle = Turtle()
askAnimalToBark(animal: turtle, barkIfPossible: nil)
let cat = Cat()
askAnimalToBark(animal: cat, barkIfPossible: cat.bark)

しかしこの手段も、その型が事前に BarkableAnimalだと分かっていなければ使えません。
そうじゃないんだ、最初は Animalとして扱いたいんだ。 func askAnimalToBark<T: Animal>(animal: T)の中で初めて BarkableAnimalかどうかを判断させたいんだ……!
一度 Animalとして総称化された型を barkさせる方法は、本当にないのでしょうか!?

こうすればできる

associatedtypeのせいで存在型にキャストできないのなら、associatedtypeをなくして存在型にしてやればよいのです。

無理矢理な方法にはなりますが、キャストのための存在型とgeneric funcを噛ませてやることで、要件を満たすことができます。

protocol Animal {
associatedtype Assoc
}
// 🆕 ワークアラウンドとして用いる存在型
protocol BarkableAnimalExistential {
// associatedtypeを持たない

// 実際の bark() をバイパスするためのメソッド
// BarkableAnimal.Assoc と T は同じ型だという想定
// コンパイラレベルでTを縛ることはできないがそこは我慢
func barkUsingBypass<T>() -> T
}
// 🆕 BarkableAnimal は BarkableAnimalExistential にも適合させる
protocol BarkableAnimal: Animal, BarkableAnimalExistential {
func bark() -> Assoc
}
extension BarkableAnimal {
// デフォルト実装を用意する
func barkUsingBypass<T>() -> T {
// コンパイラは T と Assoc が同じ型だとはわからない
// force castしてしまいましょう
return bark() as! T
}
}
func askAnimalToBark<T: Animal>(animal: T) -> T.Assoc? {
// BarkableAnimalExistential は 存在型として扱える = キャスト可能
if let barkableAnimal = animal as? BarkableAnimalExistential {
let result: T.Assoc = barkableAnimal.barkUsingBypass()
return result
} else {
return nil
}
}
// BarkableAnimalを利用する側は、
// BarkableAnimalExistential の存在については意識する必要がない
struct Cat: BarkableAnimal {
typealias Assoc = String

func bark() -> String {
return "meow!"
}
}
let cat = Cat()
askAnimalToBark(animal: cat) // "meow!"

(追記)改良案を考えた( omochimetaru さんが)

なるほど、これは綺麗だ。おもちさんありがとうございます!

なお、最初は以下のような設計も検討されましたが、

protocol Animal {
associatedtype Assoc

func asBarkableAnimal() -> AnyBarkableAnimal<Assoc>?
}

AnimalがBarkableかもしれないことをAnimal自身が知っているのは問題がありそう、ということで先述の最終形に至りました。もし実コードにてAnimalがBarkableかもしれないことを知っていても概念的に問題ないと判断できる場合は、おそらくBarkableAnimalがサブタイプになる設計がそもそもおかしくて、Animal自身が func mayBark() -> Assoc?のような具体的なメソッドを持つべきだというサインなのだと思います。barkするとAssocを返すという前提が意味不明なんでイメージしづらいですが……。

FiNCアプリで何故これが必要になったか

ここまでは一般化したコードで説明してきました。
ここからは、FiNCアプリの開発にあたって、どういうシチュエーションでこのテクニックが必要になったかをお話します。

FiNCアプリは、API通信のためのエンドポイントを次のようなprotocolで定義していました。

protocol APIConfiguration {
associatedtype Response

// エンドポイントの定義
var method: HTTPMethod { get }
var server: Server { get }
var path: String { get }
var parameters: [String: Any] { get }
...

// レスポンスのパース処理
func response(fromJSON json: SwiftyJSON.JSON) -> Response?
}

注目してほしいのは、レスポンスのパースを行うメソッドの定義です。

  • サーバレスポンスは SwiftyJSON.JSON 型として渡ってくる
  • パースした結果は Optional型 Response?で返す。失敗の場合nilになる

このメソッドには問題があります。
丁度、Swift4から導入された Decodableをプロジェクトに導入していこうと考えたとき、これが足枷になりました。問題点は以下のふたつです。

  • SwiftyJSONを前提にしている。 Decodableを使いたい場合、サーバレスポンスは純粋な Data型のほうが望ましい
  • JsonDecoderを使うとパースエラーの詳細な情報を得られるが、戻り値がnilだとエラー情報が失われてしまう

適切にエラー情報を受け取れる、本来あるべきインタフェースは次のようなものです。

func response(from data: Data) throws -> Response

しかし、APIConfigurationは既存コードに大量に存在しており、それらを一気に書き替えるのは現実的ではありません。かなり古い実装箇所も多く、自信を持って変更できるほどのテストが書けていません。
既存のAPIConfigurationはなるべくそのまま残して、少しずつ確実にテストしながら新方式に移行していくのがよさそうです。
では、APIConfigurationに新しいパース処理のメソッドを追加し、可能ならそちらを使うようにしましょうか。

protocol APIConfiguration {
...

// 古いパース処理
func response(fromJSON json: SwiftyJSON.JSON) -> Response?

// 新しいパース処理。使える場合こちらを使う
func response(from data: Data) throws -> Response
}

……はて。「新しいパース処理が使えるかどうか」って、どうやって判断するんでしょうか? デフォルト実装として特殊なErrorでもthrowするようにする? それは嫌だ……。
やはり、型レベルで分けるのがよさそうです。

// 改修の範囲を狭めるため、APIConfiguration自体には今はノータッチでいたい
protocol APIConfiguration {
...
// 古いパース処理
func response(fromJSON json: SwiftyJSON.JSON) -> Response?
}
// ad-hocな対応ではあるが、一旦新しいprotocolでラップする
// TODO: すべての古いパース処理が滅びたら、
// 元々の `APIConfiguration` protocolは密葬する
protocol NewAPIConfiguration: APIConfiguration {
// 新しいパース処理。使える場合こちらを使う
func response(from data: Data) throws -> Response
}
extension NewAPIConfiguration {
// インタフェースを合わせるために用意した、古い処理のデフォルト実装
// 新しいパース処理が優先されるので、絶対に呼ばれないことがわかっている
// そういうときには fatalError() でいい
func response(fromJSON json: SwiftyJSON.JSON) -> Response? {
fatalError()
}
}

このような経緯で、通信処理は APIConfigurationNewAPIConfigurationというふたつのprotocolを共通して受け入れることになりました。
( 実コード上は New〜なんてひどい名前にはしていませんよ、念のため!)

あとは、APIレスポンスの受け取り側でこのふたつのConfigurationを区別するだけです。
……しかし、両者も Responseというassociatedtypeを持つprotocolなので、キャストすることができない……さあ、どうしよう。
というわけで、今回のテクニックの出番となったのでした。

まとめ

associatedtypeを持つprotocolは便利で強力ですが、扱いづらいケースも多々あります。
FiNCではクライアントのアーキテクチャをAndroidチームと合わせるコミュニケーションを取っているのですが、Kotlinではinterfaceにジェネリクスが使えるということに驚きました。Swiftではそういう場合に型消去などのワークアラウンドが必要になってしまいます。この言語仕様については「最適化絡みなのでは」という説や、ジェネリクスに比べた記述上の利点の指摘もありますが、現実としてprotocolの難しさに直面することは、度々あります。そういった難しさにあなたが直面したとき、この記事が参考になれば幸いです。

最後に

FiNCでは、言語機能を活用して、既存コードとうまく戦いつつ未来を切り拓く仲間を募集しています。

FiNCエンジニアにご興味ある方はお気軽にお声掛けください!

--

--