Swift Digest
SE-0244 | Swift Evolution

Opaque Result Types

Proposal
SE-0244
Authors
Doug Gregor, Joe Groff
Review Manager
Ben Cohen
Status
Implemented (Swift 5.1)

01 何が問題だったのか

ジェネリクスを組み合わせてライブラリを作る場合、組み合わせた結果の型は非常に長く複雑になりがちです。たとえば、図形を表すプロトコルとプリミティブ、およびそれらを合成する構造体を考えます。

protocol Shape {
    func draw(to: Surface)
    func collides<Other: Shape>(with: Other) -> Bool
}

struct Rectangle: Shape { /* ... */ }
struct Circle: Shape { /* ... */ }

struct Union<A: Shape, B: Shape>: Shape { var a: A, b: B }
struct Transformed<S: Shape>: Shape { var shape: S; var transform: Matrix3x3 }

こうしたライブラリを使って「何らかの Shape を返す」ことを表現したいときに、associatedtype を使うと次のように長大な型をわざわざ書かされることになります。

protocol GameObject {
    associatedtype Shape: Shapes.Shape
    var shape: Shape { get }
}

struct EightPointedStar: GameObject {
    var shape: Union<Rectangle, Transformed<Rectangle>> {
        return Union(Rectangle(), Transformed(Rectangle(), by: .fortyFiveDegrees))
    }
}

この書き方には次のような問題があります。

  • 長くて読みづらい。利用者にとって重要なのは「Shape に適合した何か」が返るという事実だけで、具体的な合成の中身はノイズになります。
  • 実装の詳細が型として公開されてしまう。利用者側が具体型に依存するコードを書けてしまい、将来「別の実装(たとえば汎用の NPointedStar)に差し替える」ことがしづらくなります。

「具体的な型を隠したい」だけであれば existential(プロトコル型そのもの)を使う手もあります。しかし existential にすると動的ディスパッチや実行時オーバーヘッドが発生するほか、型システムによる恩恵(ジェネリック特殊化による最適化や、associatedtype のフル API の活用など)が失われます。手動で type erasure のラッパーを書くこともできますが、型ごとにボイラープレートが増えます。

要するに、「実装側が一つの具体型を返しているのに、その具体型を利用者には隠して『プロトコルに適合した何か』として扱わせたい」という中間的なニーズを、Swift の型システムでは直接表現できませんでした。

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

戻り値の型として some Protocol と書ける opaque result type(オペイクな戻り値型)が導入されます。戻り値は「Protocol に適合した何らかの単一の具体型」として扱われ、その具体型の正体は呼び出し側には隠されます。

struct EightPointedStar: GameObject {
    var shape: some Shape {
        return Union(Rectangle(), Transformed(Rectangle(), by: .fortyFiveDegrees))
    }
}

some の後ろには、クラス・プロトコル・AnyAnyObject、またはそれらを & で合成したものを書けます。

「逆向きのジェネリクス」としての意味

通常のジェネリック関数では、型パラメータは 呼び出し側 が決めます。

func generic<T: Shape>() -> T { ... }
let x: Rectangle = generic() // T は呼び出し側が Rectangle と選ぶ

opaque result type はこれと双対で、型パラメータを 実装側 が決め、呼び出し側からは抽象化された型として見える、という形になります。概念的には戻り値の位置にジェネリック署名を置いたものだと考えると分かりやすく、some Shape はそのよくある形に対する簡潔な糖衣構文です。

型の同一性

opaque result type は existential とは異なり、呼び出すたびに同じ具体型が返ることが型システム上保証されます。そのため、戻り値どうしを比較したり、コレクションに集めたりできます。

func foo<T: Equatable>(x: T, y: T) -> some Equatable {
    let condition = x == y
    return condition ? 1738 : 679
}

let x = foo(x: "apples", y: "bananas")
let y = foo(x: "apples", y: "some fruit nobody's ever heard of")
print(x == y) // OK: 同じ opaque 型どうし

associated type もそのまま保たれるので、Collection のようなプロトコルの API もフルに使えます。

func makeMeACollection<T>(with: T) -> some RangeReplaceableCollection & MutableCollection { ... }

var c = makeMeACollection(with: 17)
c.append(c.first!)             // RangeReplaceableCollection として使える
c[c.startIndex] = c.first!     // MutableCollection として使える
print(c.reversed())            // Collection / Sequence の操作も使える

var cc = [c]
cc.append(c)                   // 要素型が一致するのでコレクションに入れられる

ただし、戻り値の型は関数のジェネリック引数に依存できるため、ジェネリック引数が違えば別の opaque 型として扱われます。

var d = makeMeACollection(with: "seventeen")
c = d // error: Int 版と String 版で型が違う

また、opaque 型は静的には「中身の具体型」と同一視されません。必要なら動的キャストで中身を調べられます。

func foo() -> some BinaryInteger { return 219 }
var x = foo()
let i = 912
x = i // error: Int が foo() の戻り型と同じだとは分からない

if let x = foo() as? Int {
    print("It's an Int, \(x)")
}

実装側のルール

opaque result type を返す関数は、すべての return 文で同じ具体型の値 を返す必要があります。具体型は some に指定した制約をすべて満たさなければなりません。

protocol P {}
extension Int: P {}
extension String: P {}

func f1() -> some P {
    return "opaque"
}

func f2(i: Int) -> some P {
    if i > 10 { return i }
    return 0                     // OK: どちらも Int
}

func f2(flip: Bool) -> some P {
    if flip { return 17 }
    return "a string"            // error: Int と String で不一致
}

func f3() -> some P {
    return 3.1419                // error: Double は P に適合していない
}

func f4() -> some P {
    let p: P = "hello"
    return p                     // error: existential の P 自身は P に適合しない
}

func f5() -> some P {
    return f1()                  // OK: f1() の opaque 戻り値も P に適合
}

func f6<T: P & Initializable>(_: T.Type) -> some P {
    return T()                   // OK: 戻り型は T に依存してよい
}

再帰呼び出しは許され、戻り値は「自分自身と同じ opaque 型」として扱われます。ただし、少なくとも一つの return で具体型を確定させる必要があります。また、自分の opaque 型でパラメータ化された型(たとえば Wrapper<f(...)>)を返すと無限再帰になるため許されません。

非終了関数でも return 文が必要です。fatalError だけで済ませることはできず、明示的に値を返す必要があります。

func f9() -> some P {
    fatalError("not implemented") // error: return 文が無い
}

extension Never: P {}
func f9b() -> some P {
    return fatalError("not implemented") // OK: Never を戻り型として束縛
}

プロパティとサブスクリプト

opaque result type は、関数だけでなく、プロパティやサブスクリプトの型、let/var の型にも使えます。

let strings: some Collection = ["hello", "world"]

struct GameObject {
    var shape: some Shape { /* ... */ }
}

computed property では return 文から、stored property では初期化式から具体型が決まります。setter を持つプロパティやサブスクリプトの newValue の型も getter の戻り型で決まるため、実装内部では型安全に値をやり取りできます。

public struct Vendor {
    private var storage: [Impl] = [/* ... */]
    public subscript(index: Int) -> some P {
        get { storage[index] }
        set { storage[index] = newValue }
    }
}

var vendor = Vendor()
vendor[0] = vendor[2] // OK: 要素の入れ替えができる

associated type として推論される

opaque result type を「名前で呼ぶ」直接の手段はありませんが、プロトコル要件の実装として使うと、その opaque 型が associated type として推論されます。

protocol GameObject {
    associatedtype ObjectShape: Shape
    var shape: ObjectShape { get }
}

struct Player: GameObject {
    var shape: some Shape { /* ... */ }
    // ObjectShape は Player.shape の opaque 戻り型として推論される
}

let pos: Player.ObjectShape
pos = Player().shape // OK

この推論はジェネリックでない要件に対してのみ行われます。ジェネリックな関数要件の場合、戻り型がジェネリック引数ごとに変わり得るため、単一の associated type には決まりません。

使える場所の制限

現時点では、opaque result type は次のように使える場所が限定されています。

  • 関数・プロパティ・サブスクリプトの 戻り型全体 として使えますが、(some P)? のように部分として埋め込むことはできません。
  • プロトコルの要件としては使えません。同じことは associated type で表現します。
  • クラスでは final でないメソッドの戻り型には使えません。final なら使えます。
protocol Q {
    func f() -> some P // error: プロトコル要件には使えない
}

class C {
    func f() -> some P { /* ... */ }        // error: non-final には使えない
    final func g() -> some P { /* ... */ }  // OK
}

これらの制限は将来緩和される可能性があります。

ABI と resilience

opaque result type は制約(some の後ろに書いた型)の部分が API / ABI として公開され、実際の具体型は非公開 となります。したがって、ライブラリ側はバージョン間で制約を保ったまま内部の具体型を差し替えられ、利用者のソース・バイナリ互換性を壊しません。

ただし @inlinable な宣言に opaque result type を使う場合は、インライン展開のために具体型が public(または @usableFromInline)である必要があり、いったん公開された具体型は後から変更できなくなる点に注意が必要です。

今後の展望

本提案は、ジェネリクスまわりの UI を改善していく一連の設計の最初の一歩として位置付けられています。将来的な方向性としては、たとえば次のようなアイデアが挙げられています(いずれも speculative な展望であり、導入を約束するものではありません)。

  • some を戻り値位置だけでなく、ジェネリック引数の糖衣構文や戻り型の構造的な位置にも使えるようにする
  • より一般化された「逆向きのジェネリクス」構文を導入する
  • existential を明示的に書くための anysome の双対として導入する