Swift Digest
SE-0393 | Swift Evolution

Value and Type Parameter Packs

Proposal
SE-0393
Authors
Holly Borla, John McCall, Slava Pestov
Review Manager
Xiaodi Wu
Status
Implemented (Swift 5.9)

01 何が問題だったのか

Swiftのジェネリック関数は、型パラメータの数が固定でなければなりませんでした。そのため、引数の個数が可変で、かつ それぞれ異なる型を取りたい APIを書くには、次のような回避策しかありませんでした。

  • Any... などで型を消してしまう
  • 複数の型引数ではなく、ひとつのタプル型にまとめる
  • 引数の個数ごとに人為的な上限を設けてオーバーロードを並べる

典型的な例が、標準ライブラリのタプル比較演算子です。要素数ごとに個別のオーバーロードを用意する必要がありました。

func < (lhs: (), rhs: ()) -> Bool

func < <A, B>(lhs: (A, B), rhs: (A, B)) -> Bool
  where A: Comparable, B: Comparable

func < <A, B, C>(lhs: (A, B, C), rhs: (A, B, C)) -> Bool
  where A: Comparable, B: Comparable, C: Comparable

// 6要素のタプルまで同じパターンが続く

zip の複数系列版、タスクグループへの異種引数の流し込み、ビルダー系APIなど、ライブラリ側で「N個のジェネリック引数」を扱いたい場面は広く存在します。そのたびに手作業でオーバーロードを並べることは、ライブラリのメンテナンスコストを押し上げ、利用者にとっても「上限を超えたら急に型推論が通らなくなる」という分かりにくい体験を生んでいました。

この提案は、型パラメータと値パラメータの 個数そのもの を抽象化できる仕組みを言語に導入することで、これらのad hocな回避策を置き換えることを目的としています。これはSwiftにおけるvariadic genericsへの最初の一歩にあたり、今回のスコープはジェネリック関数(メソッド、イニシャライザ、subscriptを含む)に限られます。

02 どのように解決されるのか

型と値の両方について、「要素数が抽象的なリスト」を扱うための parameter pack を導入します。中心となる構文は次の2つです。

  • 宣言側: each を使って「パックである型パラメータ」を宣言する
  • 利用側: repeat + パターン式で、パックをリストへ展開する

型パラメータパックの宣言

ジェネリックパラメータリストで each T のように書くと、T はひとつの型ではなく「0個以上の型の並び」を表す 型パラメータパック になります。個々の要素に対する制約は通常のジェネリック制約と同じ構文で書けます。

// 'S' は各要素が 'Sequence' に適合する型パラメータパック
func zip<each S: Sequence>(...)

パック自体はファーストクラスの型や値ではありません。パックを使うには、パック展開(pack expansion) を通じて「型や値のリストが来る場所」に埋め込む必要があります。

パック展開 repeat

パック展開は repeat キーワードにパターンを続けた形をとります。パターンの中には、少なくともひとつの each T というパックへの参照が含まれていなければなりません。実行時には、置換されたパックの各要素ごとにパターンが繰り返され、その結果が周囲のリストに展開されます。

// 引数、戻り値、where 節でパック展開を使う
func zip<each S>(_ sequence: repeat each S) where repeat each S: Sequence

例えば SArray<Int>, Set<String> に置換されると、repeat Optional<each S>Optional<Array<Int>>, Optional<Set<String>> という2要素のリストに展開されます。

パック展開型(repeat P という型)を書けるのは、本質的に「型のリストを受け取る場所」に限られます。

  • 関数宣言や関数型のパラメータ位置: func foo<each T>(values: repeat each T)
  • タプル型のラベルなし要素: (repeat each T)

ラベル付きタプル要素でのパック展開は認められません。(t: repeat each T) を認めると、T := {Int, String} のときに (t: Int, String) となり、.t で最初の要素だけが取れてしまうといった直感に反する挙動になるためです。

値パラメータパック

関数パラメータの型をパック展開型にすると、その引数は 値パラメータパック になります。呼び出し側が渡した0個以上の引数が、値パックとしてまとめてバインドされます。

func tuplify<each T>(_ value: repeat each T) -> (repeat each T)

_ = tuplify()                 // T := {},              value := {}
_ = tuplify(1)                // T := {Int},           value := {1}
_ = tuplify(1, "hello", [0])  // T := {Int, String, [Int]}, value := {1, "hello", [0]}

値パックを使うには、これも repeat によるパック展開式の中に置きます。展開式はリストを受け取れる場所(呼び出しの引数、subscriptの引数、タプルリテラル、配列リテラルなど)で使えます。

func tuplify<each T>(_ t: repeat each T) -> (repeat each T) {
  return (repeat each t)
}

func forward<each U>(u: repeat each U) {
  _ = tuplify(repeat each u)                 // T := {repeat each U}
  _ = tuplify(repeat each u, 10)             // T := {repeat each U, Int}
  _ = tuplify(repeat each u, repeat each u)  // T := {repeat each U, repeat each U}
  _ = tuplify(repeat [each u])               // T := {repeat Array<each U>}
}

パターンの中で同じ添字の要素同士がまとめて使われる点に注目してください。複数のパックを同じパターン内で参照すると、自動的に「i番目の要素を組にする」ようなzip的な展開になります。

struct Pair<First, Second> {
  init(_ first: First, _ second: Second)
}

func makePairs<each First, each Second>(
  firsts first: repeat each First,
  seconds second: repeat each Second
) -> (repeat Pair<each First, each Second>) {
  return (repeat Pair(each first, each second))
}

let pairs = makePairs(firsts: 1, "hello", seconds: true, 2.0)
// pairs の型は (Pair<Int, Bool>, Pair<String, Double>)
// pairs の値は (Pair(1, true), Pair("hello", 2.0))

型マッチングと引数ラベル

呼び出しから型パラメータパックの置換を推論するために、2種類のマッチングルールが用意されています。

  • ラベルマッチング: 関数宣言の呼び出しでは、既存のvariadicパラメータと同じく「パック展開型のパラメータは最後に置くか、直後に必ずラベル付きのパラメータが来る」というルールでパックの切れ目を決めます。

    func concat<each T, each U>(t: repeat each T, u: repeat each U)
      -> (repeat each T, repeat each U)
    
    // T := {Int, Double}, U := {String, Array<Int>}
    concat(t: 1, 2.0, u: "hi", [3])
    

    次は曖昧なのでエラーになります。

    func bad<each T, each U>(t: repeat each T, repeat each U) // error
    
  • 型リストマッチング: それ以外(主にクロージャ式の文脈や、戻り値のタプル型からの推論)では、共通の先頭と末尾を剥がしたうえで、残った部分が「パック展開 vs 具体型のリスト」という組になっているときにだけマッチに成功します。

    func variadic<each T>(_: repeat each T) -> (Int, repeat each T, String) {}
    
    let fn = { x, y in variadic(x, y) as (Int, Double, Float, String) }
    // (Int, repeat each T, String) と (Int, Double, Float, String) から
    // T := {Double, Float} を推論
    

単一要素のパックが (repeat each T) というタプルを構成する場合、「1要素タプル」は作られず、自動的にスカラー型に剥がされます。例えば each T := {Int}(repeat each T) に代入すると型は Int になります。

メンバー型パラメータパック

型パラメータパックがプロトコル要件を持ち、そのプロトコルに関連型 A があれば、(each T).A も妥当なパターン型として使えます。これは各要素の関連型を取り出したパックを表します。

func variadic<each T: Sequence>(_: repeat each T) -> (repeat (each T).Element)

// T := {Array<Int>, Set<String>} のとき、戻り値の型は (Int, String)

ジェネリック要件の展開

既存の要件(適合、上位型、同型)も、repeat を付けた 要件展開 を通じてパックに対して書けます。

// 各要素が Sequence に適合する
func variadic<each S>(_: repeat each S) where repeat each S: Sequence { }

// 各要素の Element がすべて同じ型 T になる(same-element requirement)
func variadic<each S: Sequence, T>(_: repeat each S)
  where repeat (each S).Element == T { }

// 両辺が同じ長さのパックで、要素ごとに等しい(same-type-pack requirement)
func variadic<each S: Sequence, each T>(_: repeat each S)
  where repeat (each S).Element == Array<each T> { }

これらに加えて、コンパイラ内部では same-shape requirement(2つの型パラメータパックが同じ長さであること)が使われます。今回は明示的な構文は導入されませんが、関数の引数・戻り値・where 節に現れるパック展開からは自動的に推論されます。そのため、次のコードは「TU は同じ長さ」という制約が暗黙に課され、長さの違う呼び出しはエラーになります。

func zip<each T, each U>(
  firsts: repeat each T,
  seconds: repeat each U
) -> (repeat (each T, each U)) {
  return (repeat (each firsts, each seconds))
}

zip(firsts: 1, 2, seconds: "hi", "bye") // OK
zip(firsts: 1, 2, seconds: "hi")        // error: 長さが合わない

また、同型要件を使えば原理的にはパックの長さに複雑な制約(整数の一次方程式に相当)を書けてしまうため、この提案では、長さについては 抽象的なshape(パック単位の等値クラス) のみを許可し、具体的な長さや構造を混ぜる要件はコンフリクトとして診断されます。この制限は将来段階的に緩められる可能性があります。

オーバーロード解決

スカラーの型パラメータとパックの型パラメータは、同じ関数名でオーバーロードできます。パラメータパック版にスカラー版の引数列を「転送」可能な場合、スカラー版はパック版の サブタイプ として扱われ、通常どおりサブタイプが優先されます。

func overload() {}
func overload<T>(_: T) {}
func overload<each T>(_: repeat each T) {}

overload()      // 引数なしオーバーロードが呼ばれる
overload(1)     // スカラーオーバーロードが呼ばれる
overload(1, "") // パックオーバーロードが呼ばれる

この規則のおかげで、既存の固定個数オーバーロード群を将来パックオーバーロードに差し替えても、従来の呼び出しの解決結果は変わらないようになっています。

今回のスコープと今後の見通し

この提案はvariadic genericsの土台のみを扱い、対象は関数・メソッド・イニシャライザ・subscriptです。以下は将来の拡張として議論されているもので、speculativeであり、実現を約束するものではありません。

  • variadic genericsな型(structenumclass で型パラメータパックを使えるようにする)
  • ローカル変数としての値パック
  • 明示的な型パック構文(where T == {Int, String} のような書き方)
  • for-in によるパックの反復処理
  • Int キーやキーパスによるパック要素のプロジェクション
  • タプル値やArrayをパックに変換する展開演算子
  • パックの先頭要素を切り出すような分解操作
  • タプルそのものへの条件付き Equatable などの適合(tuple conformance)