🈁

[Swift] 主要関連型(Primary Associated Types)を持つプロトコルと「ジェネリックなプロトコル」の違い

2023/06/23に公開

Swift 5.7で導入されたPrimary Associated Typesという機能によって、以下のような記法が可能になりました。

// 任意のコレクションを受け取る関数(Elementの型は指定しない)
func foo<T: Collection>(collection: T)

// 任意のIntのコレクションを受け取る関数
func foo<T: Collection<Int>>(collection: T)

var collection: any Collection= [1, 2, 3]
print(collection.first!)   // firstの型はAny?になってしまう

var intCollection: any Collection<Int> = [1, 2, 3]
print(intCollection.first!) // firstの型はInt?に定まる

このCollection<Int>のような記法は、「主要関連型(Primary Associated Types)に対して同型制約(Same-Type Requirements)をつけるための軽量記法」な記法として、SE-0346で提案され、導入されました。

https://github.com/apple/swift-evolution/blob/main/proposals/0346-light-weight-same-type-syntax.md

ところで、ときおりこの機能が「ジェネリックなプロトコル」として紹介されることがあります。「ジェネリックな」という言葉で何を指しているかにもよるのですが、主要関連型の記法の紹介としてはあまり正しくない説明です。

この記事では「ジェネリックなプロトコル」と「主要関連型を持つプロトコル」の違いを説明します。

「ジェネリック」とは

始めに述べたとおり、「ジェネリックな」という言葉の意味するところがまずは問題ですが、Swiftにおいては一言で言うと「型パラメータの利用によって複数の型で実装を共有する」ことであると考えられます。例えば、以下はジェネリックな「Swap」です。

func swap<T>(lhs: inout T, rhs: inout T) {
    (lhs, rhs) = (rhs, lhs)
}

このswapにおいては、引数の型から自動的に型パラメータTが推定され、以下のように利用できます。

var a = 0
var b = 1
swap(&a, &b)
print(a, b) // 1, 0

ジェネリックな関数は複数の型で動作します。例えば以下のような用例はすべて問題ありません。

var a = true, b = false
swap(&a, &b)

var a = "Hi", b = "Swift"
swap(&a, &b)

var a = [0, 1, 2], b = [2, 1, 0]
swap(&a, &b)

なお、ご存知の方も多いでしょうが、型パラメータには制約をつけることができます。以下の例ではTEquatableというプロトコルで制限しているため、lhs == rhsが必ず実行できます。

func equal<T: Equatable>(lhs: T, rhs: T) -> Bool {
    return lhs == rhs
}

このように、型パラメータを利用するのがジェネリクスだと考えて良いでしょう。

プロトコルの関連型と型エイリアス

次に、関連型(Associated Types)についても確認しておきます。関連型はプロトコルにおいて定義できるもので、プロトコルを実装した型においてネストして実装されるべき型を宣言します。

protocol List {
    // 関連型
    associatedtype Element
}

関連型の要件を満たすには、T.Elementが存在していれば問題ありません。したがって、以下のような実装ができます。

struct IntList: List {
    typealias Element = Int
}

struct MyList: List {
    struct Element {}
}

struct GenericList<Element>: List {
    typealias Element = Element
}

重要なのは、関連型の要件があるからといって、必ずしもそれを型パラメータで表現する必要はないと言うことです。IntListの例ではElementをエイリアスで指定していますし、MyListではElement型を内部で定義しています。どの例においても.Elementが存在するから、それで制約が満たせるわけです。
もっというと、型パラメータが関連型の要件を直接満たすことはできません。実はジェネリックな型の型パラメータは、typealiasによって明示的に外部に公開しないと、アクセスできないからです。
例えば、以下はエラーになります。

struct Foo<T> {}
print(Foo<Int>.T.self)  // error

関連型はキーワードがassociatedtypeと長く、初見では少しギョッとするのですが、その意味することはシンプルです。

主要関連型

さて、いよいよ主要関連型の話をします。主要関連型は、プロトコル内で特に重要な関連型を表現するための機能で、特殊な構文で表現されます。任意のプロトコルはゼロ個以上の主要関連型を持つことができます。「どの関連型を主要関連型にするかはどうやって決めるの?」という話はここではしませんが、基本的には頻繁に制約をつけたい型を指定すべきです。

まずは定義を見ていきましょう。例えば、Listプロトコルにおいて、要素の型を表す関連型であるElementを主要関連型とすることができます。

// ここでElementを主要関連型として扱うことが宣言されている
protocol List<Element> {
    // 関連型の存在はここで宣言する
    associatedtype Element
}

ここで気をつけてほしいのが、associatedtype Elementの宣言は絶対に必要であるということです。読む順序としては

  1. Elementという関連型を宣言する
  2. List<Element>のようにプロトコルを宣言することで、これが主要関連型である事を明示する

という順序になっています。

実際、例えば、以下の実装はコンパイルエラーです。

// error: an associated type named 'Element' must be declared in the protocol 'List' or a protocol it inherits
protocol List<Element> {
}

ここで述べられているように、<Element>は型パラメータでなく、宣言済みの関連型を主要であると宣言するだけの役割です。

このことから、以下の実装もエラーになります。

// error: expected '>' to complete primary associated type list
protocol List<Element: Equatable> {
    associatedtype Element
}

繰り返しになってしまいますが、List以降の<...>の部分は「primary associated type list」であって、型パラメータの宣言ではありません。ここで宣言される型はすでにassociatedtype Elementとして宣言されており、主要関連型のリストの中で追加の制約をかけることはできません。

なお、読む順序が逆転するのが少し変な感じがするかもしれませんが、これはプロトコルの宣言ではよくあることです。例えば以下は有効な実装です。

protocol List where Element: Equatable {
    associatedtype Element
}

この実装は、以下のように読むべきです。

  1. Elementという関連型を宣言する
  2. where Element: EquatableによってElementに制約をつける。

さて、ここまでの説明で、なぜ主要関連型の宣言がジェネリクスと違うのか、という点がわかったと思います。

ジェネリクスにおける<T>という表現は「型パラメータを新規に導入する」ための記法です。それに対して主要関連型の宣言においては「これらの関連型は、主要であるので、<T>と表記できる」という事を述べているにすぎません。

このように、同じ<T>という記法を使っていながら、その意味するところは全く異なるのです。

ジェネリックなプロトコル

では、本当に「ジェネリックなプロトコル」があるとしたら、どのように振る舞うでしょうか。実は主要関連型の導入の際、この点は大きく議論を呼びました。というのも、主要関連型の記法が、将来的に導入されうる「ジェネリックなプロトコル」の記法を「奪っている」恐れがあったからです。

「ジェネリックなプロトコル」というのは、型パラメータによって実装を共有されたプロトコルです。主要関連型の導入のプロポーザルでは、以下が「ジェネリックなプロトコル」の例として挙げられています。

protocol GenericConvertibleTo<Other> {
  static func convert(_: Self) -> Other
}

extension String : GenericConvertibleTo<Int> {
  static func convert(_: String) -> Int { 0 }
}

extension String : GenericConvertibleTo<Double> {
  static func convert(_: String) -> Double { 0 }
}

注意すべき違いは、まずOther関連型ではないということです。例えばGenericConvertibleTo<Int>というプロトコルは、実際には以下のようなプロトコルです。明らかに、ここに関連型はありません[1]

protocol ConvertibleToInt {
  static func convert(_: Self) -> Int
}

このため、Stringが同時にGenericConvertibleTo<Int>GenericConvertibleTo<Double>に準拠することができます。

もし、Otherが主要関連型だったらどうなるでしょうか。この場合、プロトコルの定義は以下のようになります。

protocol ConvertibleTo<Other> {
  associatedtype Other
  static func convert(_: Self) -> Other
}

実は、以下はエラーになる[2]ので、

extension String : ConvertibleTo<Int> {
  static func convert(_: String) -> Int { 0 }
}

代わりに、以下のように書く必要があります………が、これももちろんエラーになります。

extension String : ConvertibleTo {
  typealias Other = Int
  static func convert(_: String) -> Int { 0 }
}

// Error: Redundant conformance of 'String' to protocol 'ConvertibleTo'
extension String : ConvertibleTo {
  // Error: Invalid redeclaration of 'Other'
  typealias Other = Double
  static func convert(_: String) -> Double { 0 }
}

このように、Otherが関連型の場合、Stringが同時にConvertibleTo<Int>ConvertibleTo<Double>に準拠する、というようなことはできなくなります。この点はジェネリックなプロトコルと主要関連型の大きな違いです。

もう1つの大きな違いは、ジェネリックなプロトコルは<Other>なしではプロトコルとして使えないということです。例えば上記のGenericConvertibleToの場合、以下はエラーになります。型パラメータ<Other>が指定されていないから、GenericConvertibleToはプロトコルになっていないのです。

func take<T: GenericConvertibleTo>(value: T) {
    // Errorになる
}

これは型パラメータを指定しない状態のArrayそのものが型としては扱えないのと同じ現象です。

一方で、主要関連型を持つプロトコルは違います。ConvertibleToは以下のように使っても問題ありません。

func take<T: ConvertibleTo>(value: T) {
    // T.Otherは関連型
}

このように、主要関連型を持つプロトコルというのはあくまで「1つのプロトコル」であり、「ジェネリックなプロトコル」が存在した場合に想定されるような挙動は取ってくれません。

つまり、主要関連型はあくまで利用側の表記法を変えただけであって、プロトコルが何か新しい種類の機能を獲得したわけではないのです。

実際、Swift 5.7以降でCollectionなどは突如としてCollection<Element>になりましたが、それまでCollectionだったものが突然壊れたりすることはありませんでした。これはCollectionの要請は前から変わっていないからです。

「ジェネリックなプロトコル」は導入されるのか?

このように「ジェネリックなプロトコル」は現在のSwiftにはありませんが、Rustでは「ジェネリックなトレイト」として導入されており、採用する言語がないわけではありません。

しかし、Swiftへのこの機能の導入については否定的に評価されています。主要関連型の記法を導入したSE-0346の第一レビューではジェネリックなプロトコルの構文との競合が問題になりましたが、コアチームは結論として以下のように述べています

We do not think that "generic protocols" are the right way to model arbitrary relationships between multiple types, and so we are not concerned about taking the generic-argument syntax away from that future direction.
(私たちは「ジェネリックなプロトコル」は複数の型の間の任意の関係をモデル化するための適切な方法ではないと考えており、「ジェネリックなプロトコル」の方向性からジェネリック引数の構文を取り除くことについては懸念していません。)

こうしたことから、今後もSwiftに「ジェネリックなプロトコル」が導入されることはないと思って良いと思います。

まとめ

以上、ジェネリックなプロトコルと主要関連型を持つプロトコルの違いを説明しました。

今回の記事では違いの部分を主題としましたが、導入されることのない「ジェネリックなプロトコル」の挙動はそこまで重要ではありません。
実際にはむしろ、主要関連型の振る舞いに対してどのような期待をすれば良いか、という点が重要でしょう。主要関連型の<T>の部分を型パラメータであると考えてしまうと、例えば「Collection<T><T>は常に書かないといけない」というような勘違いを招く可能性があります。

主要関連型の記法はCollection<T>のような直感的な記法を実現する一方で、よくよく考えてみるとジェネリクスとは全然違う、という少し難しい立ち位置にある機能です。正しく理解して賢く使っていきたいですね。

脚注
  1. 厳密にいうとSelfは暗黙の関連型です。 ↩︎

  2. これは少し人工的な制約ではないかと思っています。というのも、extension String : ConvertibleTo<Int>というコードをextension String : ConvertibleTo { typealias Other = Int }のように解釈しても、特に困ることがないからです。実際、プロポーザルでは言及があるので不具合の可能性もあります。 ↩︎

Discussion