Swift Digest
SE-0484 | Swift Evolution

Allow Additional Arguments to @dynamicMemberLookup Subscripts

Proposal
SE-0484
Authors
Itai Ferber
Review Manager
Xiaodi Wu
Status
Accepted

01 何が問題だったのか

SE-0195SE-0252 で導入された @dynamicMemberLookup は、x.member のようなドット記法を x[dynamicMember: "member"] のような subscript(dynamicMember:) 呼び出しに変換することで、静的に存在しないメンバへも型安全にアクセスできる仕組みです。Python の PyVal や JavaScript の JSValue のような外部世界の値をラップしたり、Swift 側で動的なデータを自然なドット記法で扱ったりする用途で広く使われています。

しかし、この subscript(dynamicMember:)「引数をちょうど 1 つだけ取らなければならない」 という厳しい制約を持っていました。引数の型は KeyPath 系か ExpressibleByStringLiteral に適合する具体型のいずれかで、しかもそれ以外の引数を一切受け付けられません。

この制約のせいで、たとえば呼び出し元の情報をデバッグ目的で受け取りたいケースが書けません。

@dynamicMemberLookup // error: @dynamicMemberLookupAttribute requires 'Value' to have a 'subscript(dynamicMember:)' method that accepts either 'ExpressibleByStringLiteral' or a key path
struct Value {
    subscript(
        dynamicMember property: String,
        function: StaticString = #function,
        file: StaticString = #fileID,
        line: UInt = #line
    ) -> Value {
        ...
    }
}

function / file / line にはすべてデフォルト値があり、呼び出し側の見た目は x.member のままで何も変わりません。それでも、現状の規則ではこの subscript は @dynamicMemberLookup の要件を満たさないとみなされ、コンパイルエラーになります。

回避策として get() / set(_:) のような明示的なメソッドに切り出すこともできますが、ドット記法のチェーンが長くなると一気に書きづらくなります。

let _ = x.member.get().inner.get().nested.get()  // x.member.inner.nested
x.member.get().inner.get().nested.set(Value(42)) // x.member.inner.nested = Value(42)

ジェネリック制約や #isolation をメンバ単位で付けたい、といった要望も同じ理由でブロックされていました。

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

@dynamicMemberLookup の要件を緩和し、dynamicMember 引数の 後ろ に追加の引数を取れるようにします。ただし追加の引数はすべて、デフォルト値を持つか可変長引数(暗黙のデフォルト値を持つとみなされます)でなければなりません。

新しい規則は次の通りです。

  1. 最初の引数のラベルは dynamicMember であること
  2. その型は非可変長で、KeyPath 系か ExpressibleByStringLiteral 適合の具体型であること
  3. それに続く引数は、すべて可変長引数かデフォルト値付きであること

これにより、最初に挙げた呼び出し元情報を受け取る subscript がそのまま @dynamicMemberLookup の要件を満たすようになります。

@dynamicMemberLookup
struct Value {
    subscript(
        dynamicMember property: String,
        function: StaticString = #function,
        file: StaticString = #fileID,
        line: UInt = #line
    ) -> Value {
        ...
    }
}

let x: Value = ...
let _ = x.member       // function/file/line は呼び出し元のものが自動で渡る
x.member = Value(42)

呼び出し側の構文は従来とまったく同じで、追加の引数はコンパイラがデフォルト値(#function / #fileID / #line など)で埋めます。これを応用すると、ジェネリック制約付きのメンバや #isolation を取り込んだメンバなど、これまで通常の関数では当たり前にできていた表現を、動的メンバ側にも持ち込めるようになります。

オーバーロードの選ばれ方

subscript(dynamicMember:) の引数はすべて呼び出し側からは「暗黙」で渡されるため、オーバーロードは主に戻り値の型によって解決されます。これに加えて、通常のメソッド呼び出しと同じく 「引数が少ないオーバーロードが優先される」 というルールがそのまま働きます。したがって、同じ型を返す 1 引数版と多引数版が両方存在する場合は、これまで通り 1 引数版が選ばれます。

@dynamicMemberLookup
struct A {
    /* (1) */ subscript(dynamicMember member: String) -> String { ... }
    /* (2) */ subscript(dynamicMember member: String, _: StaticString = #function) -> String { ... }
}

let _ = A().member          // (1) が選ばれる
let _: String = A().member  // (1) が選ばれる
let _: Any = A().member     // (1) が選ばれる

戻り値の型が異なる場合は、より具体的な型を返すオーバーロードが優先されます。次の例の [5] のケースが、本 Proposal で挙動が変わる唯一の組み合わせです。

@dynamicMemberLookup
struct C {
    /* (5) */ subscript(dynamicMember member: String) -> String { ... }
    /* (6) */ subscript(dynamicMember member: String, _: StaticString = #function) -> String? { ... }
}

let _: String = C().member   // (5)
let _: String? = C().member  // (6) が選ばれる(従来は (5) が選ばれていた)

(6) はこれまで「候補にすらならない」状態でしたが、本 Proposal 以降は通常のオーバーロード解決の対象になり、String? というより具体的な型を要求している文脈では (6) が優先されます。実際のコードでこの組み合わせに該当するケースは極めて稀と見込まれています。

採用にあたっての注意

この機能を使うには新しいバージョンの Swift コンパイラが必要です。ライブラリ側が新しいシグネチャの subscript を追加するだけで、既存の利用コードは書き換えなしでそのまま動きます。