Swift Digest
SE-0252 | Swift Evolution

Key Path Member Lookup

Proposal
SE-0252
Authors
Doug Gregor, Pavel Yaskevich
Review Manager
Ted Kremenek
Status
Implemented (Swift 5.1)

01 何が問題だったのか

SE-0195 で導入された @dynamicMemberLookup は、subscript(dynamicMember:) の引数として 文字列 を受け取り、メンバー名の解決をランタイムに委ねる仕組みでした。Python など、動的言語のオブジェクトを Swift から扱うブリッジには適しますが、メンバー名は型チェッカの目から見ればただの文字列なので、静的な型情報はほとんど残りません。

一方で、Swift にはプロパティへの参照を値として持ち運べる key path があり、WritableKeyPath<Root, Value> のように「どの型のどのプロパティか」という静的な型情報を保持します。この情報を活かすと、たとえば次のような「値を指す箱」としての Lens を定義できます。

struct Lens<T> {
  let getter: () -> T
  let setter: (T) -> Void

  var value: T {
    get { getter() }
    nonmutating set { setter(newValue) }
  }
}

extension Lens {
  func project<U>(_ keyPath: WritableKeyPath<T, U>) -> Lens<U> {
    return Lens<U>(
      getter: { self.value[keyPath: keyPath] },
      setter: { self.value[keyPath: keyPath] = $0 }
    )
  }
}

Lens<Rectangle> から内部の Point を指す Lens<Point> を得たいとき、利用側は project を介して key path リテラルを渡す必要があります。

struct Point { var x, y: Double }
struct Rectangle { var topLeft, bottomRight: Point }

func projections(lens: Lens<Rectangle>) {
  let topLeft = lens.project(\.topLeft)  // Lens<Point>
  let top = lens.project(\.topLeft.y)    // Lens<Double>
}

書けはするものの、project(\.topLeft) のように毎回メソッド呼び出しと \. 記法を挟まなければならず、「Lens が指す値のプロパティへ Lens 経由でアクセスする」という自然な書き味にはなっていません。文字列ベースの @dynamicMemberLookup で似た見た目(lens.topLeft)を作ることはできても、その場合は静的な型情報を失ってしまいます。

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

@dynamicMemberLookup を拡張し、subscript(dynamicMember:) の引数として KeyPath 系の型を取るオーバーロードを認めます。このオーバーロードを持つ型に対して .[...] でメンバーアクセスすると、コンパイラはそのアクセスを 対応する key path を引数にした subscript 呼び出しへ書き換え ます。文字列を介さないため、戻り値の型は key path の静的な型情報から完全に決まります。

key path 版の書き方

先ほどの Lens は、project メソッドを subscript(dynamicMember:) に置き換えるだけで、ドット記法でそのまま使えるようになります。

@dynamicMemberLookup
struct Lens<T> {
  let getter: () -> T
  let setter: (T) -> Void

  var value: T {
    get { getter() }
    nonmutating set { setter(newValue) }
  }

  subscript<U>(dynamicMember keyPath: WritableKeyPath<T, U>) -> Lens<U> {
    return Lens<U>(
      getter: { self.value[keyPath: keyPath] },
      setter: { self.value[keyPath: keyPath] = $0 }
    )
  }
}

lens: Lens<Rectangle> に対して、

  • lens.topLeftlens[dynamicMember: \.topLeft] に書き換えられ、結果は Lens<Point> になります。
  • lens.topLeft.ylens[dynamicMember: \.topLeft][dynamicMember: \.y] のように、1 階層ずつ subscript 呼び出しに展開され、結果は Lens<Double> になります。

key path を一気に \.topLeft.y にまとめるのではなく 1 ステップずつ分解されるため、中間の型(ここでは Lens<Point>)に対する別の @dynamicMemberLookup オーバーロードやメンバーが介在しても、各段階で期待通りに解決されます。

ルックアップのルール

key path ベースのメンバールックアップは、従来の文字列ベースと同じ基本的な制約を引き継ぎつつ、次のように振る舞います。

  • 同名の実メンバーが優先される: @dynamicMemberLookup を持つ型自身に同名のメンバー(プロパティやメソッド)があれば、そちらが優先されます。たとえば Lens<Rectangle> 自身の value は、Rectangle の同名メンバーより優先されます。
  • 宣言は型本体にのみ書ける: @dynamicMemberLookup は型の定義側にのみ指定でき、extension から付与することはできません。
  • subscript の形: 非可変長の単一引数で、引数ラベルが dynamicMember、型が KeyPath / WritableKeyPath / ReferenceWritableKeyPath などの key path 型である必要があります。
  • 文字列版との共存: 同じ型に文字列ベースと key path ベースの両方のオーバーロードがあり、どちらでも解決可能な場合は、より多くの型情報を持つ key path 版が優先されます。これにより、普段は静的に型の付く key path 版を使い、key path で表現できないケースでは文字列版にフォールバックする、といった設計も可能です。