Following up from @dynamicMemberLookup subscript with additional (defaulted) arguments?, I've put together a brief proposal to allow additional arguments to dynamic member lookup subscripts (so long as they have default values or are variadic). Eager for your feedback!
The implementation can be found here: [Sema] Support additional args in @dynamicMemberLookup subscripts by itaiferber · Pull Request #81148 · swiftlang/swift · GitHub
Introduction
SE-0195 and SE-0252 introduced and refined @dynamicMemberLookup
to provide type-safe "dot"-syntax access to arbitrary members of a type by reflecting the existence of certain subscript(dynamicMember:)
methods on that type, turning
let _ = x.member
x.member = 42
ƒ(&x.member)
into
let _ = x[dynamicMember: <member>]
x[dynamicMember: <member>] = 42
ƒ(&x[dynamicMember: <member>])
when x.member
doesn't otherwise exist statically. Currently, in order to be eligible to satisfy @dynamicMemberLookup
requirements, a subscript must:
- Take exactly one argument with an explicit
dynamicMember
argument label, - Whose type is non-variadic and is either
- A
{{Reference}Writable}KeyPath
, or - A concrete type conforming to
ExpressibleByStringLiteral
- A
This proposal intends to relax the "exactly one" requirement above to allow eligible subscripts to take additional arguments after dynamicMember
as long as they have a default value (or are variadic, and thus have an implicit default value).
Motivation
Dynamic member lookup is often used to provide expressive and succinct API in wrapping some underlying data, be it a type-erased foreign language object (e.g., a Python PyVal
or a JavaScript JSValue
) or a native Swift type. This (and callAsFunction()
) allow a generalized API interface such as
struct Value {
subscript(_ property: String) -> Value {
get { ... }
set { ... }
}
func invoke(_ method: String, _ args: Any...) -> Value {
...
}
}
let x: Value = ...
let _ = x["member"]
x["member"] = Value(42)
x.invoke("someMethod", 1, 2, 3)
to be expressed much more naturally:
@dynamicMemberLookup
struct Value {
struct Method {
func callAsFunction(_ args: Any...) -> Value { ... }
}
subscript(dynamicMember property: String) -> Value {
get { ... }
set { ... }
}
subscript(dynamicMember method: String) -> Method { ... }
}
let x: Value = ...
let _ = x.member
x.member = Value(42)
x.someMethod(1, 2, 3)
However, as wrappers for underlying data, sometimes interfaces like this need to be able to "thread through" additional information. For example, it might be helpful to provide information about call sites for debugging purposes:
struct Value {
subscript(
_ property: String,
function: StaticString = #function,
file: StaticString = #fileID,
line: UInt = #line
) -> Value {
...
}
func invokeMethod(
_ method: String,
function: StaticString = #function,
file: StaticString = #fileID,
line: UInt = #line,
_ args: Any...
) -> Value {
...
}
}
When additional arguments like this have default values, they don't affect the appearance of call sites at all:
let x: Value = ...
let _ = x["member"]
x["member"] = Value(42)
x.invoke("someMethod", 1, 2, 3)
However, these are not valid for use with dynamic member lookup subscripts, since the additional arguments prevent subscripts from being eligible for dynamic member lookup:
@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 {
...
}
subscript(
dynamicMember method: String,
function: StaticString = #function,
file: StaticString = #fileID,
line: UInt = #line
) -> Method {
...
}
}
Proposed solution
We can amend the rules for such subscripts to make them eligible. With this proposal, in order to be eligible to satisfy @dynamicMemberLookup
requirements, a subscript must:
- Take an initial argument with an explicit
dynamicMember
argument label, - Whose parameter type is non-variadic and is either:
- A
{{Reference}Writable}KeyPath
, or - A concrete type conforming to
ExpressibleByStringLiteral
,
- A
- And whose following arguments (if any) are all either variadic or have a default value
Detailed design
Since compiler support for dynamic member lookup is already robust, implementing this requires primarily:
- Type-checking of
@dynamicMemberLookup
-annotated declarations to also considersubscript(dynamicMember:...)
methods following the above rules as valid, and - Syntactic transformation of
T.<member>
toT[dynamicMember:...]
in the constraint system to fill in default arguments expressions for any following arguments
Source compatibility
This is largely an additive change with minimal impact to source compatibility. Types which do not opt in to @dynamicMemberLookup
are unaffected, as are types which do opt in and only offer subscript(dynamicMember:)
methods which take a single argument.
However, types which opt in to @dynamicMemberLookup
and currently offer an overload of subscript(dynamicMember:...)
—which today is not eligible for consideration for dynamic member lookup—may now select this overload when they wouldn't have before.
Overload resolution
Dynamic member lookups go through regular overload resolution, with an additional disambiguation rule that prefers keypath-based subscript overloads over string-based ones. Since the dynamicMember
argument to dynamic member subscripts is implicit, overloads of subscript(dynamicMember:)
are primarily selected based on their return type (and typically for keypath-based subscripts, how that return type is used in forming the type of a keypath parameter).
With this proposal, all arguments to subscript(dynamicMember:...)
are still implicit, so overloads are still primarily selected based on return type, with the additional disambiguation rule that prefers overloads with fewer arguments over overloads with more arguments. (This rule applies "for free" since it already applies to method calls, which dynamic member lookups are transformed into.)
This means that if a type today offers a valid subscript(dynamicMember:) -> T
and a (currently-unconsidered) subscript(dynamicMember:...) -> U
,
- If
T == U
then the former will still be the preferred overload in all circumstances - If
T
andU
are compatible (and equally-specific) at a callsite then the former will still be the preferred overload - If
T
andU
are incompatible, or if one is more specific than the other, then the more specific type will be preferred
For example:
@dynamicMemberLookup
struct A {
/* (1) */ subscript(dynamicMember member: String) -> String { ... }
/* (2) */ subscript(dynamicMember member: String, _: StaticString = #function) -> String { ... }
}
@dynamicMemberLookup
struct B {
/* (3) */ subscript(dynamicMember member: String) -> String { ... }
/* (4) */ subscript(dynamicMember member: String, _: StaticString = #function) -> Int { ... }
}
@dynamicMemberLookup
struct C {
/* (5) */ subscript(dynamicMember member: String) -> String { ... }
/* (6) */ subscript(dynamicMember member: String, _: StaticString = #function) -> String? { ... }
}
// T == U
let _ = A().member // (1) preferred over (2); no ambiguity
let _: String = A().member // (1) preferred over (2); no ambiguity
// T and U are compatible
let _: Any = A().member // (1) preferred over (2); no ambiguity
let _: Any = B().member // (3) preferred over (4); no ambiguity
let _: Any = C().member // (5) preferred over (6); no ambiguity
// T and U are incompatible/differently-specific
let _: String = B().member // (3)
let _: Int = B().member // (4);️ would not previously compile
let _: String = C().member // (5); no ambiguity
let _: String? = C().member // (6) preferred over (5); ⚠️ previously (5) ⚠️
This last case is the only source of behavior change: (6) was previously not considered a valid candidate, but has a return type more specific than (5**, and is now picked at a callsite.
In practice, it is expected that this situation is exceedingly rare.
ABI compatibility
This feature is implemented entirely in the compiler as a syntactic transformation and has no impact on the ABI.
Implications on adoption
The changes in this proposal require the adoption of a new version of the Swift compiler.
Alternatives considered
The main alternative to this proposal is to not implement it. This is possible to work around using explicit methods such as get()
and set(_:)
:
@dynamicMemberLookup
struct Value {
struct Property {
func get(
function: StaticString = #function,
file: StaticString = #file,
line: UInt = #line
) -> Value {
...
}
func set(
_ value: Value,
function: StaticString = #function,
file: StaticString = #file,
line: UInt = #line
) {
...
}
}
subscript(dynamicMember member: String) -> Property { ... }
}
let x: Value = ...
let _ = x.member.get() // x.member
x.member.set(Value(42)) // x.member = Value(42)
However, this feels non-idiomatic, and for long chains of getters and setters, can become cumbersome:
let x: Value = ...
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)
Source compatibility
It is possible to avoid the risk of the behavior change noted above by adjusting the constraint system to always prefer subscript(dynamicMember:) -> T
overloads over subscript(dynamicMember:...) -> U
overloads (if T
and U
are compatible), even if U
is more specific than T
. However,
- This would be a departure from the normal method overload resolution behavior that Swift developers are familiar with, and
- If
T
were a supertype ofU
, it would be impossible to ever call the more specific overload except by direct subscript access