Allow Additional Arguments to @dynamicMemberLookup Subscripts
01 何が問題だったのか
SE-0195 と SE-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 引数の 後ろ に追加の引数を取れるようにします。ただし追加の引数はすべて、デフォルト値を持つか可変長引数(暗黙のデフォルト値を持つとみなされます)でなければなりません。
新しい規則は次の通りです。
- 最初の引数のラベルは
dynamicMemberであること - その型は非可変長で、
KeyPath系かExpressibleByStringLiteral適合の具体型であること - それに続く引数は、すべて可変長引数かデフォルト値付きであること
これにより、最初に挙げた呼び出し元情報を受け取る 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 を追加するだけで、既存の利用コードは書き換えなしでそのまま動きます。