💭

[Swift] 引数の位置のsomeは本当にジェネリック引数なのか?

16 min read

背景

Swift5.1でOpaque Result Typeという種類の型が追加されました。これは「制約を満たすなんらかの特定の型」を関数が返せるようにする仕組みです。その動作は「リバースジェネリクス」という概念で説明されます。

Swiftのジェネリクスの方向性についての議論がなされたForumのスレッド「ジェネリクスのUIの改善(Improving the UI of generics)」では、以下の構文が提案されています。

// この2つは同じ動作
func concatenate(a: some Collection, b: some Collection) -> some Collection
func concatenate<T: Collection, U: Collection>(a: T, b: U) -> some Collection

また、Opaque Result Typeの導入のプロポーザルであるSE-0244でもこの点が言及されています。

This some Protocol sugar can be generalized to generic arguments and structural positions in return types in the future

端的にいうと、ここではsomeというキーワードが次のように働きます。

  • 引数の位置ではジェネリックな型
  • 戻り値の位置ではOpaque Result Type

この構文は「ジェネリクスの表記の軽量化」という目標を達成します。既存の構文は「威圧感が強い(intimidating)」うえに、慣習的に1文字の名前が利用されるため理解もしづらい傾向にあります。新しい構文は制約を流暢に表現するため威圧感が弱く、無理に名前をつけていないため(匿名型であるため)理解にも支障がありません。

また、someの導入時点から意図されてきたanyというキーワードとの対応も同時に目指されています。any PとはプロトコルPを用いたExistential Type、すなわち現在Pと表記される型の新しい表記で、Pに準拠する任意(any)の型の値を代入できることを示唆する表記です。any Pへの移行は正式な提案には至っていませんが、過去複数のプロポーザルで言及されるなど、半ば既定路線となっています。このany Pは引数位置でも用いることが出来るので、some Pも引数位置で用いられると対照的で綺麗です。

この記事では、引数の位置で使われるsomeが本当にジェネリック引数であるのかどうか検討します。

根拠付け

戻り値の位置ではOpaque Result Typeを示すキーワードであるsomeが引数の位置ではジェネリックな型を表すのは何故でしょうか。非常に直感的に言えば、Opaque Result Typeとジェネリック引数の振る舞いがかなり類似しているからです。

let collection: some Collection = "Hello"
print(collection.hasPrefix("H"))    // valueの型がStringなのは分からないのでエラー
print(collection.prefix(3))         // Collectionに準拠した型はprefixを実装しているので呼び出せる

func hoge<T: Collection>(_ collection: T) {
    print(collection.hasPrefix("H"))    // valueの型がStringなのは分からないのでエラー
    print(collection.prefix(3))         // Collectionに準拠した型はprefixを実装しているので呼び出せる
}

このため、ジェネリクスを用いたコードをsomeを使って以下のように書くこともできそうです。

func hoge(_ collection: some Collection) {
    print(collection.hasPrefix("H")) 
    print(collection.prefix(3))
}

ここではsomeが引数の位置にあります。この引数の位置に置かれたsome Pを指してOpaque Argument Typeと呼ぶ場合があります。

「Opaque」という言葉は、このような状態を形容しています。つまり、ある型が実際に何なのかが不透明である、ということです。そのように考えると、冒頭で紹介したジェネリクスの役割をも担うsomeをもっと単純に表現することが出来ます。つまり「Opaque」であることを標識するものがsomeだということです[1]

Rustとの関連

私はRustがほとんど分からないので誤りを含む可能性があります。

これはRustのimplの挙動に倣った提案であると考えられます。

Swiftにおけるプロトコルにあたる機能がRustのトレイトです。しかしSwiftと異なり、トレイトをそのまま型として扱うことはできません。Box<dyn Trait>として提供されるのがSwiftのProtocolにあたり、impl Traitによって提供されるのがSwiftのOpaque Result Typeとほぼ同様の働きを持つ機能です[2]。つまり、impl TraitTraitを実装した何らかの決まった型を表していて、その実際の型は分からず、コンパイル時には静的に解決されます。

Rustでは引数の位置にimpl Traitが現れた場合どうなるのでしょうか。ピッチ段階にあったOpaque Result Typeのスレッドでこれについての言及があります。

  • We're not even going to mention the use of opaque in argument position, because it's a distraction for the purposes of this proposal; see Rust RFC 1951.

ここで言及されているRFC 1951という文章で、ちょうど上記のような内容が主張されています。つまり、引数の位置のimpl Traitは、ジェネリクスと同様に振る舞う、ということです[3]

なお、ちょっとややこしいのですが、Rustではimpl TraitがExistential Typeと呼ばれ、SwiftではProtocolがExistential Typeと呼ばれています。この記事で以降Existential Typeという場合はSwiftの意味です。

問題

発想

さて、現在は許されませんが、そのうち次のような表現が可能になるでしょう。型の構成要素として用いられるsomeです。

let someArray: [some Numeric] = [0, 1, 2] // someArray: [Int]
let someClosure: (some Numeric) -> () = { (value: Int) in print(value) } // 実際は (Int) -> ()

どちらも特に不思議なことはありません。someArrayElementがOpaque Result Typeであるような配列で、someClosureNumericに準拠した何らかの型の値を引数に取り、その値をprintするクロージャです。

脳内でsomeClosureを実行してみましょう。きっと引数の型がOpaqueなので、こんな感じで動くはずです。

let number: Int = 42
someClosure(number)  // 引数の型がIntとはわからないのでエラー
someClosure(.zero)   // (some Numeric).zeroは存在するのでエラーにならない

とはいえやはり実際に動作しているところを見たいものです。実例を持ってきましょう。

[some Numeric]と書けないからといって、そういう型を作ることが出来ないわけではありません。

let value: some Numeric = 42
let someArray = [value]   // someArray: [some Numeric]

同じことを(some Numeric) -> ()でもやってみましょう。Numericだと上手くいかないので、今回はBinaryIntegerというプロトコルでやってみます。このプロトコルはisMultiple(of: Self)というメソッドの実装を要求します。

let value: some BinaryInteger = 42
let someClosure = value.isMultiple // someClosure: (some BinaryInteger) -> Bool

このクロージャは、脳内で実行したのと全く同じように、引数がOpaqueになっているように動きます。

let number: Int = 42
someClosure(number)  // 引数の型がIntとはわからないのでエラー
someClosure(.zero)   // (some BinaryInteger).zeroは存在するのでエラーにならない

困惑

以上で、someを引数の位置に含むクロージャが構成できることを確認し、その挙動は「引数の型をOpaqueにする」と形容できることがわかりました。

ところが、関数ではどうでしょうか。最初に確認したとおり、関数ではsomeを引数の位置におくとジェネリックな型を表すのでした。つまり、こういうことが起きます。

// 引数の型がOpaqueなクロージャ
let someClosure: (some Numeric) -> () = { (value: Int) in print(value) }
// 引数の型がジェネリックな関数
func someFunction(_ value: some Numeric) { print(value) }

let number: Int = 42
someClosure(number)  // エラー
someFunction(number) // 問題なく動作

関数でもクロージャでも、表記が同一であれば型も同じだというのが自然な推測です。しかしここでは、クロージャであるか、関数であるかによって、引数の型が真逆に変わってしまうのです。

うまく理屈をつければこの動作を正当化することはできるでしょう[4]。ただ、無理に理屈をつけないと説明できない言語仕様は妥当なのでしょうか。

別案

(some Numeric) -> ()が表すのは、実はジェネリックなクロージャだったかもしれません。こうすることで関数の表現と一致します。

let someClosure: (some Numeric) -> () = { (value: some Numeric) in print(value) } 
someClosure(42 as Int)       // 42
someClosure(42.0 as Double)  // 42.0

残念ながら、こうしてもやはり不自然な事態が起こります。

以下は全て有効な宣言です。動作に若干の違いはありますが、全て同じように「Opaque Result Type」として動作することが期待されます。

let x: some HogeProtocol = Hoge()
var x: some HogeProtocol { return Hoge() }
func x() -> some HogeProtocol { return Hoge() }

もちろん以下も同様です。

// これはジェネリックなクロージャ
let x: (some Numeric) -> () = /* ... */ 
// なのでこれも
var x: (some Numeric) -> () { /* ... */ }
// これも、ジェネリックなクロージャを返すべき
func x() -> (some Numeric) -> () { /* ... */ }

しかし、3つ目はよく考えると奇妙です。下のようにsome Numericの位置をずらす事を考えると、1つ目はジェネリックな関数、2つ目はジェネリックなクロージャ(Rank2型として)を返す関数、3つ目はOpaque Result Typeを含む関数型になります。

func x(some Numeric) -> () -> () { /* ... */ }
func x() -> (some Numeric) -> () { /* ... */ }
func x() -> () -> (some Numeric) { /* ... */ }

複雑に考えれば、この振る舞いを理解することは出来ます[5]。ただ、それではそもそもの目標であった「軽量で読みやすい構文」から程遠いものになってしまいます。

まとめ

以上をまとめると、引数の位置のsomeをジェネリック引数と考えた場合、次のような問題が生じることになります。

  • (some P) -> ()型のクロージャをジェネリックでないクロージャと考えた場合
  • 関数宣言と動作が一致せず、非直感的な振る舞いをすることになる
  • (some P) -> ()型のクロージャをジェネリックなクロージャと考えた場合
  • 振る舞いが非常に複雑になり、理解に支障を来す

どちらにしても、some Pに関する直感的な理解を諦め、複雑な理由付けや場合分けを用いて納得しなければなりません。コードを書く際にも読む際にも重くのしかかる負担となり、「ジェネリクスのUIの改善」となるどころか、UIが悪化してしまうことになります。

解決策

リバースジェネリクス

リバースジェネリクスという概念がOpaque Result Typeを理解するために提案されています。Opaque Result Typeはリバースジェネリックな戻り値です。

https://forums.swift.org/t/reverse-generics-and-opaque-result-types/21608

リバースジェネリクスを「実装者が型を決めるジェネリクス」と表現することがありますが、より簡潔には「外側へのジェネリクス」と表現することが出来ます。通常のジェネリクスが、外部によって決定される型を内部で利用するという意味で「内側へのジェネリクス」であるのに対し、リバースジェネリクスは内部で決定される型を外部で利用するという意味です。こう表現することで「リバース=裏返し」という言葉がよりうまく当てはまります。

こう考えると、以下のようなコードが多少わかりやすくなります。「実装者」と考えると少し混乱しそうです。

// ここから上の行は外側
let value: some Numeric = 42 // この行が内側
// ここから下の行は外側
print(value)

someの役割

「内側で見るジェネリックな型」と「外側で見るリバースジェネリックな型」は同一物に見えます。どちらも実際の型は抽象化されているからです。別の言い方にすると「内側で見るジェネリックな型と外側で見るリバースジェネリックな型は共にOpaqueだ」と言えます。「Opaque」と「リバースジェネリック」が同じ意味ではないことに注意してください。ジェネリックな引数も内側から見てOpaqueですが、決してリバースジェネリックではありません。

someをジェネリクスに使おう、という提案は「内側で見るジェネリックな型」と「外側で見るリバースジェネリックな型」が同一物に見えることに注目し、これらをsomeで統一しようとするものです。

ところが既に確認したとおり、このようなsomeの利用には無理があります。UIの改善とは程遠い複雑性を孕み、解釈もかなり難しくなります。そこでsomeをジェネリクスに使うのを諦め、次のようにすることで無矛盾な統一が得られます。Opaqueか否かに関係なく、リバースジェネリクスにあたるものをsomeで表すのです。

ジェネリクス リバースジェネリクス
内側から見る Opaque some(Visible)
外側から見る Visible some(Opaque)

この場合どうなるのでしょうか。some Pを引数に取る関数を考えるとき、この引数はジェネリックではなく、リバースジェネリックな型です。some Numericは外部に向かって抽象化された型なので、内部では決まった型として扱えます。この発想を即席の構文で表現すると、下のようになります。

// 引数の型がリバースジェネリックな関数
func someFunction(_ value: Int as some Numeric) { 
    print(value) 
}

そして、この関数の挙動はクロージャと全く同じになるはずです。つまり以下が成り立ちます。非常に直感的ではないでしょうか。

// この2つは同じ動作
let someClosure: (some Numeric) -> () = { (value: Int) in print(value) }
func someFunction(_ value: Int as some Numeric) { print(value) }

このとき、someの役割はリバースジェネリクスの標識であり、外部への抽象化の宣言です。そして、Opaqueであることの標識ではありません

ジェネリクスのショートハンド

しかしこれでは冒頭で挙げたsome Pをジェネリクスに導入することのモチベーションであった「ジェネリクスの軽量化」や「anyとの対応」といった目標が達成できなくなってしまいます。そこで発想を転換することでこれまで以上に綺麗な対応関係を持った構文体系を得ることが出来ます。ジェネリクスの標識にanyを用いるのです。

ジェネリクス リバースジェネリクス
内側から見る any some
外側から見る any some

つまり、こうです。

// この2つは同じ動作
func concatenate(a: any Collection, b: any Collection) -> some Collection
func concatenate<T: Collection, U: Collection>(a: T, b: U) -> some Collection

この構文の最大の利点は、ジェネリクスとリバースジェネリクスの表記に直接対応していることです。特に、今度は戻り値のジェネリクスにもショートハンドができたことになります。

// ジェネリックな型パラメータは常にanyになる
// リバースジェネリックな型パラメータは常にsomeになる
func x<A: P, B: P, ^C: P = Q , ^D: P = Q>(a: A, c: C) -> (b: B, d: D) { /* ... */ }
func x(a: any P, c: Q as some P) -> (b: any P, d: Q as some P) { /* ... */ }

こうすることで「軽量化」「直感的な構文」「anyとの対応」「矛盾の解決」といった私たちの欲しかったものが一挙に手に入ります。

結論

  • 引数の位置のsomeはジェネリック引数ではありません。
  • someはOpaqueではなくリバースジェネリクスの標識に用いた方がいいでしょう。
  • anyはExistential Typeではなくジェネリクスの標識に用いた方がいいでしょう。

someをOpaqueの標識と考えてジェネリック引数に用いれば、大きな混乱を招くことになるでしょう。そこでsomeをリバースジェネリクスの標識と考えることで理解しづらい挙動を取り除くことが出来ます。さらにanyをジェネリクスの標識として導入することによって綺麗な対応関係、軽量な構文、さらには混乱のない体系という欲しかったものを得られるのです。

補足

これまでの言及

実際のところ、リバースジェネリックな引数という発想は以前にも出てはいます。

例えばChris Lattner氏によるOpaque Type Aliasという提案[6]ではリバースジェネリックな引数が書ける可能性が提示されていました。

https://gist.github.com/lattner/d285d42ad21ea2672df9532e6d230e59

You can take opaque types as arguments as well, because the compiler knows the identity of types on the caller side:

public func extractField(a : OpaqueReturn1) -> Int {
 // I'm defined in the same module as OpaqueReturn1, so I know it is a string.  
 return a.count
}

これに関連して、Joe Groff氏は以下のように述べています。(時系列は上の提案より前です)。

Opaque typealiases are completely independent of "opaque argument types". There is perhaps an alternative factoring of these features, where we have opaque typealiases, and then the "some" sugar is introduced uniformly for arguments (to introduce anonymous generic arguments) and returns (to introduce an anonymous opaque return typealias).

面白いことに、長い議論の中でリバースジェネリックな引数についての言及はこれくらいです。需要のなさを物語っているとも思います。

RFC 1951での言及

記事を書いている最中に気付いて大変驚いたのですが、Opaque Result Typeのピッチやプロポーザルで言及されていたRFC 1951では、私の主張と同様に、someがリバースジェネリクス、anyがジェネリクスに充てられています。

https://github.com/rust-lang/rfcs/blob/master/text/1951-expand-impl-trait.md#universals-any-versus-existentials-some

In any case, one longstanding proposal for impl Trait is to split it into two distinct features: some Trait and any Trait. Then you'd have:

// These two are equivalent
fn foo<T: MyTrait>(t: T)
fn foo(t: any MyTrait)

// These two are equivalent
fn foo() -> impl Iterator
fn foo() -> some Iterator

// These two are equivalent
fn foo<T: Default>() -> T
fn foo() -> any Default

impl Traitを引数位置に用いてジェネリクスとして使おうと主張するRFC 1951の議論では、someanyが一貫してリバースジェネリクスとジェネリクスの意味で用いられています。Swiftはジェネリクスのanyとリバースジェネリクスのsomeの両方の特徴を持った概念として説明されていたimpl Traitsome Protocolとして導入し、それをジェネリック引数として使おうとしています。なぜ?

ちなみに、その後のセクションでは以下のようにも書かれています。

it's possible to make sense of some Trait and any Trait in arbitrary positions in a function signature. But experience with the language strongly suggests that some Trait semantics is virtually never wanted in argument position, and any Trait semantics is rarely used in return position.

「リバースジェネリックな引数」「ジェネリックな戻り値」のショートハンドはほとんど需要がない、というこの指摘は実際その通りだと思います。だからこそRustはsomeanyではなくimplを導入するだけで済ませたのです。しかしimpl Protocolでもopaque Protocolでもなくsome Protocolを導入したSwiftでは、もはやこの道しかないのではないでしょうか。

this RFC also proposes to disallow use of impl Trait within Fn trait sugar or higher-ranked bounds, i.e. to disallow examples like the following:

fn foo(f: impl Fn(impl SomeTrait) -> impl OtherTrait)
fn bar() -> (impl Fn(impl SomeTrait) -> impl OtherTrait)

While we will eventually want to allow such uses, it's likely that we'll want to introduce nested universal quantifications (i.e., higher-ranked bounds) in at least some cases; we don't yet have the ability to do so. We can revisit this question later on, once higher-ranked bounds have gained full expressiveness.

面白いことに、Fn(クロージャを示すトレイト)内部でのimpl Traitの利用は禁止され、これを高ランクの型に当てるかもしれないということになっています。Swiftで言えばany ClosureProtocol<.Argument == any P, .Result == Int>のような書き方ができるという話なので、確かに高ランクの型を意味してもおかしくないかもしれません。

Swiftでもクロージャ内でのsomeの利用を禁止できるかもしれません。そうすることで上で私が問題として挙げた不自然な振る舞いは考えずに済むでしょう。ただ、今度はクロージャ内でのsomeの利用が禁止されていること自体が理解しづらい例外になってしまいます。

Protocolのためのany

Existential Typeにつけるためのキーワードがなくなってしまうのが気になるかもしれません。これについてはexistなどのキーワードがつけばいいのではないでしょうか[7]

不安点として型消去に用いられるAnyHogehogeやトップ型のAnyが挙げられます。someとの対応を目指すのであれば多少被ってしまったとしてもanyをジェネリクスに使った方が良さそうだと思います。

定数に用いるany

someが定数の型にも使えることから、anyも定数の型で使えた方が良さそうです。これに当たる概念は既にGenerics Manifestoで言及されています。

Generic constants
let constants could be allowed to have generic parameters, such that they produce differently-typed values depending on how they are used. For example, this is particularly useful for named literal values, e.g.,

let π<T : ExpressibleByFloatLiteral>: T = >3.141592653589793238462643383279502884197169399

anyはちょうどこの宣言と同じ意味になるはずです。つまり以下のように動くでしょう。

// この2つは同じ動作
let π<T : ExpressibleByFloatLiteral>: T = 3.141592653589793238462643383279502884197169399
let π: any ExpressibleByFloatLiteral> = 3.141592653589793238462643383279502884197169399

これが実現されれば、someanyの構文上の対称関係がより明確になるはずです。


この投稿はQiitaとのクロスポストです。

脚注
  1. 厳密には「値の主な利用者にとってOpaque」とsomeの役割を説明することができます。引数を主に利用するのは関数の内部、戻り値を主に利用するのは関数の外部だからです。 ↩︎

  2. そもそも歴史的にはRustのimpl Traitを参考に導入されたのがSwiftのOpaque Result Typeです。 ↩︎

  3. ただし、導入には反発も強かったようです。Add RFC undo-universal-impl-trait. by phaazon · Pull Request #2444 · rust-lang/rfcs ↩︎

  4. 例えば「戻り値の位置のsomeはOpaque Result Typeで捉え、純粋に引数の位置にあるsomeのみジェネリクスで捉える」と考えれば辻褄が合います。ただ、かなり複雑です。 ↩︎

  5. 例えば「純粋に戻り値の位置のsomeのみOpaque Result Typeで捉え、引数位置にあるsomeその場でジェネリクスと捉える」と考えれば良さそうです。ただ、高ランク型の実装が必要になりますし、直感的とはとても言えない考え方です。 ↩︎

  6. Opaque Result Typeの議論の中で、Alternativeとして提案されたものです。 ↩︎

  7. 具体的なキーワードの提案ではありません。 ↩︎

Discussion

ログインするとコメントできます