Allow chained member references in implicit member expressions

I believe that specific example won't be supported, as @Jumhyn's work is supporting chaining where the initial "missing type" is the same as the final type. In your example it appears you're wanting to start with a UIColor and end up with a CGColor.

4 Likes

Yeah, as @davedelong points out, that case won't be supported. If you look at it a little harder, you'll notice that such a feature isn't really feasible. As the programmer, you know that the base of .red.cgColor "should" be UIColor, but there's nothing that indicates that to the compiler. In order for such an expression type check, the compiler would have to look through all scopes for a red member, and then in all matching "overloads" to see which of them provide a cgColor property.

2 Likes

Sigh. Yeah. I misread the op. I should have known it was too good to be true :sweat_smile:.

Wishful thinking I guess. Still this is a great improvement :+1:

5 Likes

Another thing to note: this implementation, at least at the moment, does not support generic member chains which start and end with different generic parameters as discussed up-thread. I.e., the following would fail to type check:

struct Foo<T> {
    var fooString: Foo<String> { Foo<String>() }
}

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

let _: Foo<String> = .fooInt.fooString

I haven't explored this possibility too thoroughly, but to me it does complicate the mental model a bit since the types no longer have to be the "same" at the head and tail of the chain, so I'd want to see a bit more discussion about use cases where this feature would actually be helpful! @Douglas_Gregor, you were one proponent of extending the functionality in this way, do you have a motivating example you could share?

(Note that, today, the following does not type check,

struct Foo<T> {}
extension Foo where T == Int {
    static var fooString: Foo<String> { Foo<String>() }
}

let _: Foo<String> = .fooString // Error: Static property 'fooString' requires the types 'String' and 'Int' be equivalent

so the current implementation is a straightforward extension of the single-implicit-member rules)

1 Like

Thank you so much for working on this. I’m a huge fan of this features!

I’m not Doug, but I can provide a motivating example. Any generic type which supports operations such as map and pullback would greatly benefit from this functionality. Point Free’s composable architecture library has some great examples of types like this GitHub - pointfreeco/swift-composable-architecture: A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.. In fact, most of the use cases I have for chained member expressions involve operation chains like this that modify the generic types involved. They begin with a factory method which produces a value and then apply map, pullback and other such operators.

I don’t have time to write up a concrete code example, but I hope this description helps.

3 Likes

Yep, that makes total sense! I think that's an excellent example. I'll explore how feasible it would be to extend the implementation to support this.

1 Like

After some excellent discussion with @typesanitizer over on Compiler, I'm back with some questions about what people's intuitions are with regards to this potential "allow different generic arguments at head/tail of an implicit chain" rule. Breaking the requirement that the head and tail match up in terms of generic arguments leads to some potentially surprising results. To see this, let's start off by stating the rule as:

In a chain of members from an implicit base, any generic arguments of the types at the head and tail of the chain need not match.

Now, under this feature, do we want the following to successfully type-check?

let _: Result<Int, SomeError> = .success(0).mapError { SomeError($0.code + 1) }

My personal intuition is "yes" but without the "same generic arguments" requirement, there's no way to connect the Failure: Error parameter at the (implicit) base to the Failure: Error parameter at the tail (since the link from head to tail is lost at the mapError call.

This can be worked around by augmenting the rule to say

Generic arguments don't have to be the same at the head and tail of a chain, but if we don't have context within the chain to resolve the arguments at the head, we can default them to the generic arguments at the tail.

Such a rule allows the above to compile, but it fails on something like this:

struct S<T> {}

extension S where T == Int {
    static var sInt: S<Int> { S<Int>() }
}

extension S where T == String {
    static var sInt: S<Int> { S<Int>() }
}

let _: S<Int> = .sInt // Error: Ambiguous use of 'sInt'

Here, when we attempt to resolve the unbound generic argument at the head via lookup, type checking can find two results: S<Int>.sInt and S<String>.sInt. While defaulting the head's generic arguments helps us when that argument is a free variable, it doesn't help us when there's an ambiguity unless we add a further caveat to the rule:

Solutions which resolve the head's generic arguments to something other than the tail's generic arguments are worse solutions than when the head and the tail agree in terms of generic arguments.

That brings us to a further issue:

struct Adder<T: AdditiveArithmetic> {
    init(x: T, y: T) {}
    static func add<U: AdditiveArithmetic>(x: T, y: T) -> Adder<U> {
        print(x + y)
        return Adder<U>(x: .zero, y: .zero)
    }
}

let _: Adder<Int8> = .add(x: 128, y: 128) // compile error, or no?

Here, if the base is inferred as Adder<Int8>, we'll get a compile error on the last line (since 128 overflows when stored into Int8), but if we allow the base to be inferred as Int based on the integer literals guiding generic parameter inference for the add function, then the example will compile successfully.

Note that this can also be constructed the other way around, where inferring the base arguments from the contextual type compiles successfully, but inferring them from the literals causes an error (e.g., consider a contextual type of Adder<Int64> with a value for x that would overflow a 32-bit integer—on 32-bit platforms, inferring the base generic arguments from the integer literals would fail to compile).

The type system currently considers solutions involving non-default literals to be worse than solutions with more default literals, but this is "worse" along a different axis than our "non-default generic parameters" rule. This means that to maintain source compatibility, we need to further augment the rule:

Not only are same-generic-argument-at-head-and-tail preferred over other solutions, but any number of different generic arguments is worse than any numbers of non-default literals.

That is, generic argument inference from tail-to-head always takes precedence over other inference rules.

I think all of these rules are easy enough to explain, and are well-founded, but I'm curious to what extent others find them surprising/intuitive. If you have thoughts, please chime in!

(@Douglas_Gregor, you mentioned that yourself and @xedin had discussed this "allow different generic arguments" feature, so I'm interested in whether either of y'all considered what's reasonable in these more pathological cases.)

Note for other people who haven't been following the design discussion: In this part

the code example being talked about compiles successfully today, so if we want to maintain backwards compatibility, we cannot have this ambiguity and we need the "further caveat" that Jumhyn is talking about (or something equivalent).

1 Like

@Jumhyn When we talked about this it seemed to us that it would be reasonable to make it possible to infer generic arguments based on context provided by the expression where type is used and generic parameters between linked steps supposed to be disconnected unless dictated by flow of an expression e.g. (result of the latter call/reference uses the same generic argument as used in base).

Adder example you mentioned I think it would be reasonable to type-check successfully because U could be inferred from contextual type and T could be inferred from arguments passed to .add call.

But .sInt example it's not as certain for me because there is nothing which connects T in the base with return type of the var in expression, if extension was spelled as:

extension S where T == Int {
  var s: S<T> { S<T>() }
}

it would make more sense to infer T as Int and disambiguate on that, otherwise I think such expression should be ambiguous.

Thanks for your thoughts, @xedin! The issue with this model is that, as Varun notes, we're boxed in by existing source compatibility constraints for implicit member expressions, since the generic arguments at the base and result of the member access are inferred as equal today. So we have to continue supporting the .sInt example, as it compiles without issue right now.

The problem with the adder example is even more subtle, because the issue arises post-type-checking, once the compiler checks if 128 can fit into an Int8. The better way to illustrate the source-compat break is to write the example as

let _: Adder<Int64> = .add(x: 2_147_483_648, y: 2_147_483_648)
//                                 ^------2^31------^

If we're compiling for a 32 bit platform, then the above works just fine today. The base is inferred as Adder<Int64>, and so x and y are Int64 parameters, and the literals don't cause an overflow. However if we switch inferring parameters from the chain instead of from the contextual type, x and y will be inferred as Int during type checking, and we'll get a compile error when we check for overflows.

This is the reasoning that has led me to the rule of "prefer any solution where the generic arguments at the head and the tail match, but if that fails then also search for other solutions that we can infer via the chain".

1 Like

This could be reconsidered for Swift 6 and there is a good argument for that.

1 Like

This is something @ravikandhadai might be interested in for static analysis. I think we should type-check base as Adder<Int> because 128 defaults to Int and result U to be Int8 since that's constrained by context.

Gotcha. I think breaking this would feel like a regression for me. The sInt example seems perfectly intuitive when we know the contextual type (since I'm "clearly" referring to S<Int>.sInt), so I personally would prefer a world where this advanced "different generic arguments" feature doesn't break the simple cases (especially single-member cases).

It's easy enough to split that off as a separate (later) proposal, though, so maybe the best way forward for now is to just extend the syntax to cover multi-member chains and leave the specifics of the generic argument rules for a later time.

1 Like

What makes you so certain? Contextual type only applies to the result of an expression and result type of sInt is not connected to its declaration context context. That's why I was saying that if declaration was S<T> { S<T>() } it would have made a lot more sense to pick S<Int> as a base because type of context and type of sInt are connected and both are Int.

The existing user-level rule, as I understand it, for implicit member expressions is basically:

When the base of a member access is omitted, substitute the contextual type at the base.

With no special-casing around generic parameters, this makes the simple cases behave essentially like a straightforward lexical substitution. When a programmer sees an expression like:

let _: S<Int> = .sInt

Without knowing anything about the type S, or even Int, we can mentally translate that to

let _: S<Int> = S<Int>.sInt

Obviously this model gets complicated somewhat by member overloads, overloads on contextual type, optionals and possibly other thing, but in "clean" cases, the meaning of an expression like let _: S<Int> = .sInt seems straightforward to me.

Putting "clearly" in quotes was meant to imply that I realize it may not necessarily be clear to others, but that's my mental model for these types of expressions. :slight_smile:

4 Likes

I think that makes sense when we think about the types but probably not generic arguments. It seems like we should unify rules in a way which makes sense when applied both ways - from base to member and from member to base, it would also make diagnostics job much easier. Another situation which currently doesn't type-check but should - [SR-1789] Swift can't infer types in generic methods that reuse part of the struct/class' parameters · Issue #44398 · apple/swift · GitHub

1 Like

FWIW I have a prototype implementation here which enables this different-generic-argument behavior for implicit member chains. Totally breaking the (inherent) link between the generic arguments at the head and tail would simplify the implementation considerably.

I realized that a better declaration for sInt would be var sInt: Self { ... } to collect base to result properly...

Isn't Self synonymous with S<T> (or just S) within the context of S<T>?

I think so, the idea is to express that T in base and T in result is the same generic parameter.