[Pitch] Allow Additional Arguments to @dynamicMemberLookup Subscripts

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:

  1. Take exactly one argument with an explicit dynamicMember argument label,
  2. Whose type is non-variadic and is either
    • A {{Reference}Writable}KeyPath, or
    • A concrete type conforming to ExpressibleByStringLiteral

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:

  1. Take an initial argument with an explicit dynamicMember argument label,
  2. Whose parameter type is non-variadic and is either:
    • A {{Reference}Writable}KeyPath, or
    • A concrete type conforming to ExpressibleByStringLiteral,
  3. 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:

  1. Type-checking of @dynamicMemberLookup-annotated declarations to also consider subscript(dynamicMember:...) methods following the above rules as valid, and
  2. Syntactic transformation of T.<member> to T[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,

  1. If T == U then the former will still be the preferred overload in all circumstances
  2. If T and U are compatible (and equally-specific) at a callsite then the former will still be the preferred overload
  3. If T and U 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,

  1. This would be a departure from the normal method overload resolution behavior that Swift developers are familiar with, and
  2. If T were a supertype of U, it would be impossible to ever call the more specific overload except by direct subscript access
14 Likes

What about parameter packs, or does that already work?

Great question! At the moment, I'm actually having trouble compiling functions which invoke default arguments for parameter packs — code like

func ƒ1<each T>(_ args: (repeat (each T).Type) = (repeat (each T).self)) -> (repeat (each T).Type) {
    args
}

let _: (Int.Type) = ƒ1()

or

protocol DefaultValue {
    static var defaultValue: Self { get }
}

func ƒ2<each T: DefaultValue>(_ args: (repeat each T) = (repeat (each T).defaultValue)) -> (repeat each T) {
    args
}

extension String: DefaultValue {
    static var defaultValue: String { "" }
}

extension Int: DefaultValue {
    static var defaultValue: Int { 0 }
}

let _: (String, Int) = ƒ2()

reliably crashes for me both on macOS and Linux with Swift 6.1, and on Linux on main.

Swift 6.1 crash trace
Stack dump without symbol names (ensure you have llvm-symbolizer in your PATH or set the environment var `LLVM_SYMBOLIZER_PATH` to point to it):
0  swift-frontend           0x000000010671ec28 llvm::sys::PrintStackTrace(llvm::raw_ostream&, int) + 56
1  swift-frontend           0x000000010671ca60 llvm::sys::RunSignalHandlers() + 112
2  swift-frontend           0x000000010671f264 SignalHandler(int) + 360
3  libsystem_platform.dylib 0x000000018daaf624 _sigtramp + 56
4  swift-frontend           0x0000000100fc4098 swift::Lowering::ResultPlanBuilder::buildForPackExpansion(std::__1::optional<llvm::ArrayRef<swift::Lowering::Initialization*>>, swift::Lowering::AbstractionPattern, swift::ArrayRefView<swift::TupleTypeElt, swift::CanType, swift::getCanTupleEltType(swift::TupleTypeElt const&), false>) + 864
5  swift-frontend           0x0000000100fc4098 swift::Lowering::ResultPlanBuilder::buildForPackExpansion(std::__1::optional<llvm::ArrayRef<swift::Lowering::Initialization*>>, swift::Lowering::AbstractionPattern, swift::ArrayRefView<swift::TupleTypeElt, swift::CanType, swift::getCanTupleEltType(swift::TupleTypeElt const&), false>) + 864
6  swift-frontend           0x0000000100fc9168 void llvm::function_ref<void (swift::Lowering::TupleElementGenerator&)>::callback_fn<(anonymous namespace)::TupleRValueResultPlan::TupleRValueResultPlan(swift::Lowering::ResultPlanBuilder&, swift::Lowering::AbstractionPattern, swift::CanType)::'lambda'(swift::Lowering::TupleElementGenerator&)>(long, swift::Lowering::TupleElementGenerator&) + 172
7  swift-frontend           0x0000000100dd4da8 swift::Lowering::AbstractionPattern::forEachTupleElement(swift::CanType, llvm::function_ref<void (swift::Lowering::TupleElementGenerator&)>) const + 184
8  swift-frontend           0x0000000100fc3610 swift::Lowering::ResultPlanBuilder::buildForTuple(swift::Lowering::Initialization*, swift::Lowering::AbstractionPattern, swift::CanType) + 1664
9  swift-frontend           0x0000000100fc26a0 swift::Lowering::ResultPlanBuilder::buildTopLevelResult(swift::Lowering::Initialization*, swift::SILLocation) + 652
10 swift-frontend           0x0000000101062bbc swift::Lowering::SILGenFunction::emitApplyOfDefaultArgGenerator(swift::SILLocation, swift::ConcreteDeclRef, unsigned int, swift::CanType, bool, swift::Lowering::SGFContext) + 908
11 swift-frontend           0x0000000101001a84 (anonymous namespace)::DelayedArgument::emit(swift::Lowering::SILGenFunction&, llvm::SmallVectorImpl<swift::Lowering::ManagedValue>&, unsigned long&) + 296
12 swift-frontend           0x0000000100feb234 emitDelayedArguments(swift::Lowering::SILGenFunction&, llvm::MutableArrayRef<(anonymous namespace)::DelayedArgument>, llvm::MutableArrayRef<llvm::SmallVector<swift::Lowering::ManagedValue, 4u>>) + 552
13 swift-frontend           0x0000000101007e7c (anonymous namespace)::CallEmission::emitArgumentsForNormalApply(swift::Lowering::AbstractionPattern, swift::CanTypeWrapper<swift::SILFunctionType>, swift::ForeignInfo const&, llvm::SmallVectorImpl<swift::Lowering::ManagedValue>&, std::__1::optional<swift::SILLocation>&) + 1128
14 swift-frontend           0x0000000100fef8dc (anonymous namespace)::CallEmission::apply(swift::Lowering::SGFContext) + 1072
15 swift-frontend           0x0000000100fee2b8 swift::Lowering::SILGenFunction::emitApplyExpr(swift::ApplyExpr*, swift::Lowering::SGFContext) + 3204
16 swift-frontend           0x0000000101060390 swift::Lowering::SILGenFunction::emitExprInto(swift::Expr*, swift::Lowering::Initialization*, std::__1::optional<swift::SILLocation>) + 132
17 swift-frontend           0x000000010104a584 swift::Lowering::SILGenFunction::emitPatternBinding(swift::PatternBindingDecl*, unsigned int, bool) + 1788
18 swift-frontend           0x00000001010515b4 swift::ASTVisitor<swift::Lowering::SILGenFunction, void, void, void, void, void, void>::visit(swift::Decl*) + 120
19 swift-frontend           0x000000010112a38c swift::ASTVisitor<swift::Lowering::SILGenTopLevel, void, void, void, void, void, void>::visit(swift::Decl*) + 536
20 swift-frontend           0x00000001011275b4 swift::Lowering::SILGenModule::emitEntryPoint(swift::SourceFile*, swift::SILFunction*) + 1168
21 swift-frontend           0x0000000101129f74 swift::Lowering::SILGenModule::emitEntryPoint(swift::SourceFile*) + 208
22 swift-frontend           0x0000000100fdd998 swift::ASTLoweringRequest::evaluate(swift::Evaluator&, swift::ASTLoweringDescriptor) const + 1380
23 swift-frontend           0x0000000101116cc4 swift::SimpleRequest<swift::ASTLoweringRequest, std::__1::unique_ptr<swift::SILModule, std::__1::default_delete<swift::SILModule>> (swift::ASTLoweringDescriptor), (swift::RequestFlags)17>::evaluateRequest(swift::ASTLoweringRequest const&, swift::Evaluator&) + 208
24 swift-frontend           0x0000000100fe25e0 swift::ASTLoweringRequest::OutputType swift::Evaluator::getResultUncached<swift::ASTLoweringRequest, swift::ASTLoweringRequest::OutputType swift::evaluateOrFatal<swift::ASTLoweringRequest>(swift::Evaluator&, swift::ASTLoweringRequest)::'lambda'()>(swift::ASTLoweringRequest const&, swift::ASTLoweringRequest::OutputType swift::evaluateOrFatal<swift::ASTLoweringRequest>(swift::Evaluator&, swift::ASTLoweringRequest)::'lambda'()) + 728
25 swift-frontend           0x0000000100570064 swift::performCompileStepsPostSema(swift::CompilerInstance&, int&, swift::FrontendObserver*) + 968
26 swift-frontend           0x0000000100573654 performCompile(swift::CompilerInstance&, int&, swift::FrontendObserver*) + 1764
27 swift-frontend           0x0000000100571fd8 swift::performFrontend(llvm::ArrayRef<char const*>, char const*, void*, swift::FrontendObserver*) + 3716
28 swift-frontend           0x00000001004f60bc swift::mainEntry(int, char const**) + 5428
29 dyld                     0x000000018d6d6b4c start + 6000

The code reliably compiles given explicit arguments:

let _: (Int.Type) = ƒ1(Int.self)

or

let _: (String, Int) = ƒ2(("", 0))

but given that testing this feature relies on expanding default args, it's hard to test. I'd like to say "yes" given that the crash trace I get in trying this with dynamic member lookup is identical to one I get with regular functions :sweat_smile:

If you happen to have an example in mind that compiles on main, I'll happily give it a shot!


As an implementation note: the above implementation currently leans heavily on DefaultArgumentExpr infrastructure to expand out default arguments, so I'd like to say that anything that can be represented in a DefaultArgumentExpr should "just work" :crossed_fingers:

2 Likes

Ah, I realize now you were likely asking about direct usage of parameter packs, as in

func ƒ3<each T>(_ args: repeat each T) -> (repeat each T) {
    (repeat each args)
}

I can confirm that at the moment, a subscript of this form is currently not recognized as valid:

@dynamicMemberLookup
struct S {
    subscript<each T>(dynamicMember member: String, _ args: repeat each T) -> (repeat each T) {
//  `- error: '@dynamicMemberLookup' requires 'S' to have a 'subscript(dynamicMember:)' method that accepts either 'ExpressibleByStringLiteral' or a key path
        (repeat each args)
    }
}

This is an oversight on my part that I'll address in the implementation.

1 Like

If I understand correctly, this proposal has a weird side effect, which is that proxied properties have more information than regular ones. A regular property can't access #function, #fileID, or #line, right? But proxied ones could:

struct Value {
    // No access to #function, #fileID, or #line.
    var member: Stuff { get { ... } set { ... } }
}

@dynamicMemberLookup
struct Proxy {
    var value: Value

    // Enjoy #function, #fileID, and #line.
    subscript<T>(
        dynamicMember keyPath: KeyPath<Value, T>,
        function: StaticString = #function,
        file: StaticString = #fileID,
        line: UInt = #line
    ) -> T {
        value[keyPath: keyPath]
    }
}

This is not a critic of the proposal, but If I'm not wrong this is a strange imbalance.

3 Likes

I think it also means the dynamicMember subscript is able to constrain itself with generics and a where clause, which regular properties are not able to do.

2 Likes

Dynamic member lookup also gives you the ability to present an API that looks like it has type-based overloads of properties, which isn't permitted in regular Swift. For example, this is invalid:

struct X {
  var a: Int
  var a: String  // invalid redeclaration of 'a'
}

But this is fine:

@dynamicMemberLookup
struct X {
    subscript<V>(dynamicMember keyPath: KeyPath<IntWrapper, V>) -> V { ... }
    subscript<V>(dynamicMember keyPath: KeyPath<StringWrapper, V>) -> V { ... }
}
struct IntWrapper { var i: Int }
struct StringWrapper { var i: String }

var x = X()
x.i = 5        // cool
x.i = "hello"  // legit
2 Likes

This restriction is sensible but also somewhat artificial in that it's always been possible to do this:

struct X {
    var a: Int = 42
}

protocol P {
    var a: String { get }
}

extension P {
    var a: String { "Hello, World!" }
}

extension X: P { }

(It's an exercise for the reader how to make these both writable too.)

2 Likes

Indeed! Though I would classify it as less of a side effect and more of the raison d'être for the proposal :smile:

Dynamic member lookups already offer some more flexibility than regular properties by virtue of being methods (as @allevato mentions), so I see this as an expansion of some tooling already in our toolbox. e.g., if you need something more powerful than a property, you can lean in to dynamic member lookups if desired.

For example, it should be possible (*untested!) to leverage this to expose #function or #fileID to a "regular" property with a macro that would expand something like

struct S {
    @WithArgs(function: StaticString = #function, file: StaticString = #fileID)
    var property: Int {
        print(function, file)
        return 42
    }
}

S().property

into

struct S {
    private struct _Inner_property {
        var property: Int
    }

    subscript(dynamicMember property: KeyPath<_Inner_property, Int>, function: StaticString = #function, file: StaticString = #fileID) -> Int {
        print(function, file)
        return 42
    }
}

S().property
// => S()[dynamicMember: \_Inner_property.property, function: #function, file: #fileID]

FWIW, this is already possible today:

protocol DefaultValue {
    static var defaultValue: Self { get }
}

protocol HasProperty {
    associatedtype Property
    var property: Property? { get }
}

@dynamicMemberLookup
struct Defaulter<T: HasProperty> {
    let inner: T
    init(_ inner: T) {
        self.inner = inner
    }
    
    subscript(dynamicMember member: KeyPath<T, T.Property?>) -> T.Property where T.Property: DefaultValue {
        inner.property ?? .defaultValue
    }
}

struct S: HasProperty {
    var property: String? = nil
}

extension String: DefaultValue {
    static var defaultValue: String { "Hello, default!" }
}

let p: String = Defaulter(S()).property
print(p) // => Hello, default!
2 Likes

I'd like to see an isolation declared as isolation: (any Actor)? = #isolation be allowed here as well. I've been needing this for a few years to patch some data-race holes.

1 Like

I love this and would be happy to use it. While you're adding this... can you add the same thing to operator functions, so we can capture the same things for custom operators? :pleading_face::folded_hands:

4 Likes

If the goal is to make a property access via dynamic subscript behave like a direct call to the subscript, forming an argument list with default arguments, variadics and packs and so on, it might be possible to refactor the implementation to reuse all of that logic in both the solver and CSApply.

That is, if the solver generates and solves the same constraints as it does for an ordinary subscript call, then when you go to apply the solution you should be able construct the subscript call expression using the right locator.

This approach is general enough that you might even be able to get overloaded subscripts to work this way, but I’d recommend not going down that route.

3 Likes

Yeah, the more I dig into properly supporting parameter packs (and isolated parameters), the more this seems like the way to go. I tried to avoid making sweeping changes, but I don't think it makes sense to attempt to replicate the logic piecemeal in CSApply; I have more learning to do about the internals here, so I might ping you for some pointers (but I'll try to figure out as much as possible first).

3 Likes

Keep in mind you still need a structural check on the subscript, to ensure it declares something that will match the constraints you generate in the solver. Otherwise if the user does something wrong the only indication will be they can’t make it call the subscript at all.

2 Likes

Huge kudos to you @itaiferber for working on this, you're my hero. (It's incredible I was running into issues with this gap the past few days and next thing I know...)

(While we're taking advantage of your goodwill, it'd be lovely if static dynamicMemberLookup not working with implicit member syntax were fixed...)

(I'll do what I can to look into `@dynamicMemberLookup` static subscripts do not work with unprefixed dot syntax · Issue #60574 · swiftlang/swift · GitHub and `@dynamicMemberLookup` properties cannot be accessed within the context of the annotated type without `self` · Issue #60589 · swiftlang/swift · GitHub, but just to set expectations, please don't hold your breath — both because that'll have to wait for me to resolve implementation issues here, and also because I don't yet have the full knowledge to assess the scope of the work involved.)

4 Likes