Swift & Objective-C

[Swift中級 #4] Swiftの関連型(associatedtype)を極める

関連型(associatedtype)は、プロトコルに用意された型の空欄です。悪名高いgeneric constraintエラーの理由と、SE-0309・SE-0346で制約が緩和された経緯、primary associated typesの使い方を整理します。

読了 8 分
[Swift中級 #4] Swiftの関連型(associatedtype)を極めるのカバー画像

Swiftのプロトコルを使っていると、いつか必ず壁にぶつかります。Equatableを変数の型として使おうとしたときや、Collectionをプロパティに格納しようとしたときに出る、関連型に関するエラーです。かつて悪名高かった「Protocol can only be used as a generic constraint because it has Self or associated type requirements」がその代表例です。この壁の正体が関連型(associatedtype)です。

中級シリーズ第4回です。関連型とは何か、なぜそのエラーが出たのか、そしてprimary associated typesへと続く言語の進化を整理します。ジェネリック編とsome・any編を読んでいれば、準備はできています。

関連型とは — プロトコルに用意された型の空欄

ジェネリック編では、<T>を「あとから型を埋める空欄」と説明しました。関連型は、その空欄がプロトコルに用意されたものです。

コンテナプロトコルを作るとします。スタックでもキューでも「要素を入れて取り出す」という共通の機能を抽象化したいのですが、問題は要素の型です。IntStackの要素はInt、StringQueueの要素はStringなので、プロトコルの段階では要素型を決められません。そこで空欄として宣言します。

protocol Container {
    associatedtype Item
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}

準拠する型が空欄を埋めます。IntStackはItemをIntに、StringQueueはStringにします。多くの場合、typealias Item = Intと明示する必要もありません。appendのパラメータ型からコンパイラが推論するためです。

実は新しい概念ではありません。標準ライブラリの骨格は、すべて関連型で作られています。CollectionのElementとIndex、IteratorProtocolのElement、そしてジェネリック編のwhere句で登場したC.Elementは、まさにこの空欄を指す構文でした。Equatableにも関連型のいとこであるSelf要件(static func == (lhs: Self, rhs: Self) -> Bool)があります。つまり関連型はプロトコル世界の第一級市民で、避けて通れない概念です。

ジェネリックの<T>との違いを一言で言うと、ジェネリックパラメータは使う側が埋める空欄で、関連型は準拠する側が埋める空欄です。Stack<Int>は使う人がIntを選びますが、ContainerのItemはIntStack型自身が決めます。

エラーの理由 — 箱の規格を決められない

これで、あの悪名高いエラーを分解できます。なぜ関連型を持つプロトコルは型として使えなかったのでしょうか。

some・any編では、プロトコル型の変数を存在型、つまり箱だと説明しました。var c: Containerと書くのは、「Containerに準拠する何かが入った箱」を作るという意味です。しかし箱から要素を取り出す瞬間に問題が起きます。c[0]の型は何でしょうか。Itemですが、Itemが何になるかは箱の中身によって異なります。IntStackならInt、StringQueueならStringです。コンパイラは箱だけを見て答えられません。

型を確定できない式は静的型付け言語では許されないため、以前のSwiftは入口で拒否しました。エラーの本当の意味は「このプロトコルは制約としてだけ使ってください」です。ジェネリック<C: Container>なら呼び出し時にCが確定し、C.Itemも同時に確定するので問題ありません。メッセージは不親切でしたが、「箱ではなくジェネリックを使え」という正確な処方箋だったのです。

型の分からない箱を検査できないロボットと、ジェネリック制約のスタンプを描いたイラスト
箱から取り出したItemの型が分からないため、入口を塞いでいたのです。

言語の進化 — 少しずつ開かれたロックの歴史

この不便さは長く悪名を馳せ、Swiftはいくつかの提案によってロックを少しずつ外してきました。

**Swift 5.7、Swift Evolution提案SE-0309です。**関連型を持つプロトコルもanyで使えるようになりました。var c: any Containerがコンパイルできます。ただし、箱から取り出したItemは「型が分からないもの」として扱われ、できることは限られます。扉は開きましたが、中でできることはまだ少ない状態です。

**同じ頃、SE-0346でprimary associated typesが導入されました。**本当のゲームチェンジャーはこれです。プロトコル宣言で代表的な関連型を山かっこで公開できるようになりました。

protocol Container<Item> {
    associatedtype Item
    // ...
}

var numbers: any Container<Int>   // Itemな Intコンテナの箱
func process(_ c: some Container<Int>)  // ジェネリックでも簡潔に

any Container<Int>は「ItemがIntに確定した箱」です。取り出した要素がIntだとコンパイラに分かるため、箱の実用性が飛躍的に上がります。標準ライブラリもこの構文で整備されました。any Collection<String>some Sequence<Int>のように書けるようになったのもこの時期です。

この進化の方向性を押さえておくとよいでしょう。「関連型プロトコルは型として使えない」という絶対的な規則が、「使えるが、確定していない関連型の分だけ能力が制限される」という精密な規則に変わったのです。禁止からコストの明示へ。some・any編で見た思想の延長です。

実践パターン — 関連型と付き合う方法

理論を実務の場面に移してみましょう。

**設計時:関連型は「準拠する型ごとに異なる型が1つ決まる」場所で使います。**Repositoryプロトコルがよい例です。associatedtype Entityを置くと、UserRepositoryはUser、OrderRepositoryはOrderで埋めます。準拠する型に関係なく使う側が型を選ぶ場所なら、プロトコルの関連型ではなくジェネリック関数・型が適切です。

利用時:基本はジェネリック制約、保存や混在が必要ならprimary associated typeを指定したanyです。func sync<R: Repository>(_ repo: R) where R.Entity == Userのように制約として使うのが第一選択で、var repos: [any Repository<User>]のように格納するのが第二選択です。

**行き詰まったとき:最後の手段は型消去のAnyXパターンです。**primary associated typeでも解けない複雑な制約があるなら、標準ライブラリのAnySequenceやCombineのAnyPublisherのように具体型で包み、関連型を隠す手動の型消去(type erasure)が残されています。ただしSE-0346以降、直接作る機会は大幅に減りました。新しいAnyXラッパーを書く前に、primary associated typeで解決できるかを確認するのが現在の順序です。

もう一つ、Self要件についてです。==のような演算は「同じ型同士」でのみ意味を持つため、Selfで宣言されます。そのため、any Equatableの箱2つは直接比較できません。箱の中の型が異なる可能性があるからです。このような場合は箱を諦め、ジェネリックで解決するのが定石です。

SE-0309とSE-0346によってロックが順に開いていくゲートのタイムラインイラスト
SE-0309とSE-0346がロックを順に開きました

まとめ

  • 関連型はプロトコルに用意された型の空欄で、準拠する型が埋めます(多くは推論されます)。CollectionのElementのように、標準ライブラリの骨格も関連型で作られています。
  • 古いエラーの正体:存在型の箱から取り出した値の関連型を確定できず、入口を塞いでいました。ジェネリック制約なら、もともと問題ありませんでした。
  • SE-0309でanyが使えるようになり、SE-0346のprimary associated types(any Container<Int>)で箱の実用性が高まりました。
  • 実務での順序:第一にジェネリック制約、第二にprimary associated typeを指定したany、手動の型消去は最後の手段です。

次回は少し手を動かしやすいテーマです。map、filter、reduce、compactMapとflatMapの違いを実務目線で整理し、lazyシーケンスによるパフォーマンスの話までつなげます。


参考資料

あわせて読みたい