Allow chained member references in implicit member expressions

Introduction

Following up on the discussion thread from the other day, I propose that we extend implicit member syntax (a.k.a. "leading dot syntax") to allow for chained member references rather than just a single level.

Motivation

When the type of an expression is implied by the context, Swift allows developers to use what is formally referred to as an "implicit member expression," sometimes referred to as "leading dot syntax":

class C {
    static let zero = C(0)
    var x: Int

    init(_ x: Int) {
        self.x = x
    }
}

func f(_ c: C) {
    print(c.x)
}

f(.zero) // prints '0'

However, attempting to use this with a chain of member references fails:

extension C {
    var incremented: C {
        return C(self.x + 1)
    }
}

f(.zero.incremented) // Error: Type of expression is ambiguous without more context

On master, the new diagnostic system has improved the produced error:


f(.zero.incremented) // Error: Cannot infer contextual base in reference to member 'zero'

but the same problem persists. This error breaks the mental model that many users likely have for implicit member syntax, which boils down to a simple lexical omission of the type name in contexts where the type is clear. I.e., users expect that writing:

let one: C = .zero.incremented

is just the same as writing

let one = C.zero.incremented

This issue arises in practice with any type that offers "modifier" methods that vend updated instances according to some rule. For example, UIColor offers the withAlphaComponent(_:) modifier for constructing new colors, which cannot be used with implicit member syntax:

let milky: UIColor = .white.withAlphaComponent(0.5) // error

Proposed solution

Type inference would be improved in order to be able to handle multiple chained member accesses. The type of the resulting expression would be constrained to match the contextual type.

Detailed design

This proposal would provide the model mentioned earlier for implicit member expressions: anywhere that a contextual type T can be inferred, writing

.member1.member2.(...).memberN

Will behave exactly as if the user had written:

T.member1.member2.(...).memberN

Further, if T is the contextually inferred type but memberN has non-convertible type R, a diagnostic of the form:

Error: Cannot convert value of type 'R' to expected type 'T'

will be produced. The exact form of the diagnostic will depend on how T was contextually inferred (e.g. as an argument, as an explicit type annotation, etc.).

Source compatibility

This is a purely additive change and does not have any effect on source compatibility.

Effect on ABI stability

This change is frontend only and would not impact ABI.

Effect on API resilience

This is not an API-level change and would not impact resilience.

48 Likes

Makes sense to me, and matches my mental model of how things should work!

2 Likes

This is a nice improvement. Thank you for working on it!

6 Likes

Entirely sensible. TBH, I’m surprised the language doesn’t already do this.

1 Like

Also +1 from me, I run into this every time I need to replace a static usage with one of its properties, like the UIColor example above.

1 Like

+1 The color example comes up for me a lot and it seems like this should work exactly as you propose. Thank you for looking into this!

1 Like

+1 This is one of those things that improves the consistency and ergonomics of the language. I would be happy to see a full proposal for this.

1 Like

This would be a great improvement. With generic types, should it also be possible for members looked up along the chain to have different generic arguments? You could for instance have something like this:

struct Foo<T> { }

extension Foo where T == Int { static var a: Foo<String> }
extension Foo where T == String { var b: Foo<Double> }
extension Foo where T == Double { var c: Foo<Float> }

func anyFoo<T>(_: Foo<T>) {}
func floatFoo(_: Foo<Float>) {}

anyFoo(.a.b.c)
floatFoo(.a.b.c)
2 Likes

@Joe_Groff I'm assuming b and c were not meant to be static here, and that the first extension was meant to be where T == Float?

If I'm understanding correctly, then I would expect the following:

anyFoo(.a.b.c) // Error: Generic parameter 'T' could not be inferred.
floatFoo(.a.b.c) // OK

In general, I'm not proposing any constraints on any members of the chain besides the last. So the following would work just fine as well:

struct A {
    static var b = B()
}

struct B {
    var c = C()
}

struct C {
    var a = A()
}

func f(_: A) {}

f(.b.c.a) // OK
2 Likes

Love it. :heart: And the only question that I had was answered the example in the post above this one.

1 Like

I'll be sure to include that example when this eventually ends up as a full proposal! :slight_smile:

1 Like

Ah, I did mean for b and c to be instance properties, but I intentionally meant for the the first extension to be T == Int. Currently, we do allow for implicit member lookup to guide generic argument inference. This compiles:

struct Foo<T> {}

extension Foo where T == Int { static var x = Foo<Int>() }

func foo<T>(_: Foo<T>) { print(T.self) }

foo(.x)

and so it seems reasonable that multiple-member chains could guide generic argument inference too.

4 Likes

Is this really what‘s happening here? Isn‘t the where clause in that particular example ignored by the compiler similar to how it‘s definitely is ignored with associated type inference. I really dislike this latter part of the language. :(

I don't see how the where clause is being "ignored by the compiler" in either case. Until the argument that the where clause applies to can be deduced, the type checker has to consider potential members that may be part of the generic type depending on what arguments end up being picked.

Ah, interesting—didn't realize that! I think then I would expect the following errors:

anyFoo(.a.b.c) // Error: Cannot convert value of type 'Foo<Float>' to 'Foo<Int>'
floatFoo(.a.b.c) // Error: Static property 'a' requires that 'Float' and 'Int' be equivalent.

I agree that given the current behavior it seems like multiple-member chains should also guide the generic argument inference.

Are there examples that motivate allowing heterogenous types for everything between the implied T and memberN? All the times I've ever wanted to use this, it's been because I'm using some kind of fluent interface, so the properties are T across the board. This includes things like the examples in the proposal as well as Font in SwiftUI, and probably others.

It seems like opening implicit member expressions up all the way adds quite a bit of cognitive load for those reading the code, without a huge benefit.

2 Likes

Yeah, I know what you mean. I did think about whether the whole chain should be constrained to T, but ultimately felt like that conflicted with my mental model of what is going on with implicit member expressions (alluded to in the proposal): in cases where the type of the expression is "obvious", you can omit the type name if you're just accessing a member off that type.

Admittedly, that's somewhat complicated by the generic parameter inference since it's not "obvious" what the type of the expression is without considering the chain, so as I start looking at implementing this I'll have to think about what makes sense in those cases.

In the brief examples written here I see what you mean about the cognitive load, but I think that in most cases with well-named APIs the meaning will still be pretty clear, e.g.:

struct Font {
    var family: FontFamily { ... }
    static var systemDefault: Font
}

struct FontFamily {
    func boldFontOfSize(_ size: CGFloat) -> Font { ... }
}

// ...

label.font = .systemDefault.family.boldFontOfSize(12.0)

Now, I can't really think of a real-world situation in which I've wanted the feature to take this form, but I also couldn't really come up with a compelling reason to add what otherwise felt like an arbitrary restriction. I don't have super strong feelings either way, even though allowing heterogeneously-typed chains fits better with my expectations!

5 Likes

Does this mean that calling instance methods by implicit-member-expressions will be supported?:

struct MyStruct {
  func modify(by: Int) -> MyStruct { ... }
}

func test(base: MyStruct, arg: Int) {
  let _: MyStruct = MyStruct.modify(base)(by: arg)
  let _: MyStruct = .modify(base)(by: arg)
}

This is gross, IMO.

Another concern is for code-completion. Currently, for . completion, Xcode only shows enum cases, static members, and initializers which results T. However, after this language change, we need to show every members except for subscripts after the initial '.'. Of course we can prioritize enum cases and matching static variables in the Xcode UI. But users may confuse when they see a bunch of looks-like-unrelated members in the completion window.

Good callout. My initial reaction is yes, that form should be supported by implicit member syntax. I actually think the two versions you've written out there read about as well as one another. This feels like another example like the ones from my original post where difficulty results from the use of generic/placeholder variable and method names—the weirdness isn't really exaggerated (to me) by omitting the type name. But I like this example because it's a concrete illustration of a situation that will arise frequently, but which could be avoided if the "all types along the chain must be the same" rule were adopted.

Without that rule, though, I think carving out an exception just for instance methods is not super strongly motivated. Formally, they are members of the type, and static methods are otherwise supported by implicit member syntax. It feels like it would complicate what is otherwise a simple model.

As far as code completion goes, I think prioritizing members which produce the contextual type still makes sense, since the chain will be required to end in a value of that type, but yes, this would open up the completion options to all static members of the type.

A further thought along the lines of the question that @Joe_Groff raised is whether down-chain members can influence generic argument inference, e.g.:

struct Foo<T> {}

extension Foo where T == Int { static var a: Foo<Float> }
extension Foo where T == String { static var a: Foo<Double> }
extension Foo where T == Double { var b: Foo<String> }

func anyFoo<T>(_ foo: Foo<T>) {}

anyFoo(.a.b) // anyFoo<String>, or bail out?

My initial thought is that it would be fine to let just the first member participate in generic argument inference, and let the above fail with an "ambiguous use of 'a'" error. It seems like allowing the whole chain to participate in inference could cause a performance blowup in pathological cases, though maybe this wouldn't be an issue in practice. Interested to know what others think!