[Pitch] Light-weight same-type constraint syntax

It's bad enough that Swift already promotes generic arguments to implicit associated types when their names match, which I feel might actually be the motivation behind this proposal. But the names of a type's generic arguments should not ever leak, unless one explicitly exports them as associated types.

Apart from this we already have the unfortunate situation that the same syntax can mean completely different things (and be both valid and invalid, based on the specific context), just based on whether a generic argument has been implicitly promoted to an associatedtype, or not:

protocol Foo {
    associatedtype Bar

    init(bar: Self.Bar)
    
    func bar() -> Self.Bar
}

struct DummyA<Bar>: Foo {
    var _bar: Bar
    
    init(bar: Bar) {
        self._bar = bar
    }
    
    func bar() -> Bar {
        self._bar
    }
}

struct DummyB<Bar>: Foo {
    typealias Bar = Int
    
    var _bar: Self.Bar
    
    init(bar: Self.Bar) {
        self._bar = bar
    }
    
    func bar() -> Self.Bar {
        self._bar
    }
}

This code compiles just fine.

Notice however how the Bar in DummyA and the Self.Bar in DummyB both refer to the associatedtype Bar of Foo, yet writing DummyA with the valid syntax of DummyB like this fails…

struct DummyA<Bar>: Foo {
    // This line works, for whatever reason.
    var _bar: Self.Bar
    
    // error: 'Bar' is not a member type of generic struct 'DummyA<Bar>'
    init(bar: Self.Bar) {
        self._bar = bar
    }
    
    // error: 'Bar' is not a member type of generic struct 'DummyA<Bar>'
    func bar() -> Self.Bar {
        self._bar
    }
}

… as does any attempt of writing DummyB with the valid syntax of DummyA:

// error: type 'DummyB<Bar>' does not conform to protocol 'Foo'
struct DummyB<Bar>: Foo {
    typealias Bar = Int
    
    var _bar: Bar
    
    // note: candidate has non-matching type '(bar: Bar)'
    init(bar: Bar) {
        self._bar = bar
    }
    
    // note: candidate has non-matching type '<Bar> () -> Bar'
    func bar() -> Bar {
        self._bar
    }
}

(Why these snippets don't compile doesn't matter here. What matters is that the illusion of "generic arguments and associated types are the same" simply doesn't hold water in practice.)

The code above is semantically (and syntactically) inconsistent (ironically for syntactic consistency's sake) due to exactly the same orthogonality of generic arguments vs. associated types that we're dealing with in this proposal and it will lead to very similar issues.

And to make things worse: by having unified their syntax it is now much, much more difficult for the user to understand why code that works for one doesn't work for the other or worse: why the simple addition of a typealias suddenly broke the whole type.

The implicit promotion of generic arguments to associated types is a bug, not a feature.

Let's please not use it as a justification for making yet another mess, attempting to unify the syntax around generic arguments and associated types. Two wrongs don't make a right.

8 Likes

This would also make it easier for the compiler to understand what is being implied by the syntax. With Array<Collection<Int>>, for example, it would be possible to provide a proper diagnostic and a fix-it to convert collection to a generic parameter with a proper same-type constraint and would type-check when applied.

I think this looks really great. I fall on the pragmatic side of making my life easier and this makes things much much simpler and easier to use. Kudos. I have a couple questions:

  1. I assume this to be the case but it's only possible to name ALL of the primary associated types?
protocol MyProtocol<Element, Index> {
  associatedtype Constraint
}

For example, can you use MyProtocol<Int> for the above protocol or do you always need to type both?

  1. Theres some code in the pitch that mentions returning a Collection<String>. I know it's mostly to point out that the ??? part is difficult. Or is the pitch also suggesting that you could return a Collection<String>? If so, what is that type?
extension Collection where ??? == String {
  func concatenate(with: Collection<String>) -> Collection<String> {
    ...
  }
}

Yes, the proposal actually explicitly mentions that because we don't use .<Name> = ... syntax here we have to support all-or-nothing only to positionally match associated types.

It's not suggesting that, it's purely to show progression of generalization for concatenate.

1 Like

Thanks makes sense. +1 on the pitch!

Nice pitch. Auto promote/map generic type to associatedtype significantly reducing where associatedtype==type constraint burden and will be more intuitive to find out where the type is materialized, it's much more like explicit assiciatedtype declaration.

I think the proposal makes sense, because it makes sense for a protocol to have what the proposal calls "primary associated type", but I don't think that the "Require associated type names" alternative is actually an alternative to this: the two complement each other perfectly, and I'd love to see them in the same proposal. Let me elaborate on this.

Both generic parameters and associated types suffer, in my opinion, of a potential problem of clarity at the usage site. Types like Array<Element> or Dictionary<Key, Value>, when specialized, are generally pretty clear: Array<Int> means "an array of ints". But many generic types are not like that, and even something as simple as Result could be potentially confusing. Consider for example:

// somewhere in the codebase
extension String: Error {}

// somewhere else
let someResult: Result<Int, String> = ...

after using Result for a while, one understands that the first generic parameter is the Success type, and the second is the Failure, but by just reading Result<Int, String> it's not clear which is which (in fact, many languages that have a similar type in their stdlib consider the second parameter as the success one).

I think it would be great if I could write, for example, Result<Int, .Failure = String>.

Let's consider a couple of libraries from the Pointfree people: swift-tagged and swift-composable-architecture.

swift-tagged is based on the very useful Tagged<Tag, RawValue> type, that can be used to replace a plain raw value with a new one that carries some type information, in order to write better self-documenting code. But the meaning and positioning of the generic parameters can be confusing. A usage example is the following:

struct User {
  let id: Tagged<User, Int>
}

what's the RawValue of id? it's User or Int? I think it would be clearer if one could write something like let id: Tagged<Int, .Tag = User>.

swift-composable-architecture is based on the Reducer<State, Action, Environment> type, where each generic parameter represents a specific aspect of that particular Reducer. Unfortunately, when reading something like:

let x: Reducer<Int, String, [String: Int]>

it's impossible to understand what's what. It would be great if we could specify which type parameter we're specializing with which type.

This kind of reasoning applies also to protocols, and the examples in the pitch clearly show this. If I see Collection<String> in a type constraint, I immediately understand that I'm dealing with a collection of String; but there would be no point in having the Index associated type as primary, because for example Collection<String, Int> is simply confusing. But a Collection<String, .Index == Int> as type constraint would be perfect.

The distinction between primary and non-primary, when it comes to both associated types and type parameters, is very useful in my opinion, and having the option to write Collection<String> opens the possibility of using the angle brackets syntax for more stuff, like the very nice, I think, .Index == Int declaration, without the need to add anything extra for the primary types, precisely because they're primary. I think this approach would solve the drawbacks listed in the pitch, and I don't think it goes against SE-0081 that much, because the point is rendering the simplest most common case as concise and clear as possible, and progressively introducing more constraints as needed, eventually turning to the where clause in cases where a list of type constraints is sufficiently heavy to justify a change in code style.

3 Likes

I know that currently you can make a typealias for a protocol and use it as a constraint. Which is pretty confusing to me (I would expect it to alias existential), but it actually useful.

But this leads me to an idea, which may address some of the negative feedback.

What if instead of polluting the protocol itself with declarations of primary associated types, this information is externalised into a a typealias:

typealias CollectionOf<Element> = Collection where Self.Element == Element
func foo<C: CollectionOf<Int>>(numbers: C) {}

Actually, as I mentioned before, using typealias for that purpose is confusing. IMO, this deserves a separate entity:

constraint CollectionOf<Element> = Self: Collection where Self.Element == Element
func foo<C: CollectionOf<Int>>(numbers: C) {}
1 Like

With any it would be clear and constraint wouldn't be required.

I'm very against this. As @regexident says, this syntax is important for a major missing Swift feature, generic protocols.

This feature is used extensively in Rust, where it forms the backbone of generic conversion and operator overloading, and is one of the things I miss most when moving between the languages. I understand that the implementation in Swift is difficult and the idea has been passed over before for various reasons, but I remain convinced that this is an eventual necessity for Swift.

the obvious interpretation of this syntax is as a generic protocol, so this usage of this syntax will inherently be misleading. You can't actually use it how it looks:

extension Data: Collection<UInt8> { ... }
extension Data: Collection<UInt32> { ... }

is still an error due to overlapping conformances.

Likewise, you can't make any number of overlapping conformances:

protocol RawBytes { ... }
extension Data: Collection<T> where T: RawBytes { ... }

But also, it's lying about fundamental truths of Swift syntax. For better or worse, <T> means T is an input type, under the control of the programmer at that moment. With this proposal, <T> has an alternate meaning as a constraint of some invisible associated type to be equal to T.

I agree again with @regexident that an appropriate syntax for this is <.AssociatedType == T>, which

  • more or less matches Rust's syntax for the same purpose
  • is explicit about exactly which associated types are being constrained
  • doesn't require adding the concept of a "primary" associated type to the language
  • is flexible with regard to adding many constraints of many associated types (just use commas to separate the constraints)
  • is orthogonal to existing syntax rather than confusingly overlapping
  • allows us to keep the possibility of generic protocols open for the future (we're gonna want 'em)
17 Likes

I think it’s worth recalling that generic protocols have been listed under “Unlikely” in the Generics Manifesto since approximately the Palaeozoic era, and I have yet to see anything that suggests this is going to change. As such, it seems manifestly unreasonable to require that everyone maintains a carve-out for a feature that has effectively already been rejected.

That said, I agree that the pitched syntax would be misleading (and while I have no particular interest in generic protocols, I find the argument against them in the manifesto surprisingly weak).

1 Like

Yes, the argument against them is basically "you don't often need this", which may be fair, but when you do need it you really need it. I suspect that as Swift's generics get fleshed out a bit more, the absence will be all the more notable.

4 Likes

Right—the argument isn't that the pitched syntax conflicts with a feature that may well never be added, but rather that the pitched syntax misleadingly appears as though that feature and yet doesn't work that way, and users are already confused as to what that feature is about.

To make a rough analogy, it isn't so much saying, "We shouldn't call this Full Self-Driving because what would we then call actual full self-driving?"—rather, it's: "We shouldn't call this Full Self-Driving because we have to explain in the same breath that users have to keep their hand on the steering wheel at all times to stop from driving into highway barriers." The rejoinder that, obviously, actual full self-driving doesn’t exist is a fair enough rebuttal against the first argument but bolsters the second argument.

13 Likes

In the example for extensions, it shows that the following would be valid:

extension MyProtocol<String, Int> { ... }

and

extension MyProtocol<String> { ... }

which is sugar for

extension MyProtocol where Element == String

what would the sugar look like for this:

extension MyProtocol where Index == Int

I suggest

extension MyProtocol<_, Int> { ... }

to retain the positional interface.

I would note that it also seems odd to me that

extension MyProtocol<String> { ... }

Would be allowed, as partial specification of generic is not permitted using compact syntax today

2 Likes

I like this idea. It's mentioned a couple times in the proposal that just like for generic types whole signature has to be specified or angle brackets cannot be used, so using _ for some of the parameters could make sense.

3 Likes

In the past I was with the group that favored the stricter separation of generic parameters and associated types. They are different, and that’s important for understanding how a non-generic type conforms to a protocol with an associated type (like Data being a Collection of UInt8). But you know what? People want this syntax to work. People try to do it all the time. And the compiler has to support it anyway, if only to emit the diagnostic that says you’re holding it wrong. (I don’t know if that diagnostic’s there, but if not it ought to be.)

What really brings me over to the positive side on this is the acceptance of SE-0306—another controversial topic, but one that has now been decided. That means that Collection<String> would be a valid type—an existential type—just as Set<String> is a valid type. All of these use sites would behave as people would want them to. (Extending this in the opposite direction would make Set a valid type, which I need to think about more but tentatively support. Let’s not derail this proposal though.)

For those worried about generic parameter names becoming significant, that bridge has already been crossed when you extend a generic type. All in all I think this proposal is a practical, pragmatic way to improve the language for newbies and experienced users alike. Props to Pavel and Holly!

8 Likes

I think there’s bigger picture at play here, where this lightweight syntax is just one small piece of the puzzle. It’s very important to make things simpler for the simple situations, and not make the simple cases overly complicated. The kind of syntax proposed here is something people would try to use anyway and that nicely lowers the barrier for them.

I don’t believe that simple cases should be more complicated just for the sake of some rarely used advanced case, which may not even happen at all, or might take on some very different form.

So +1 from me for this proposal.

Just want to mention that without generic protocols (which might not be likely now, but would effectively be blocked by this pitch), it's also less likely that we will ever be able to nest protocols in other types — and as types have more or less officially become our replacement for namespaces, that is imho a quite big restriction :frowning:.

To be clear, I think nesting would still be possible, but require yet another exception in the language...

2 Likes

I think it’s completely fine to have a situation, where simple cases have simple syntax and the more advanced cases have more complicated and possibly less optimized syntax. While ideally all cases of syntax would be elegant and concise, it’s not worth optimising advanced syntax if that means sacrificing simple cases by making those more verbose or more complicated.

I believe it’s possible to come up with many kinds of workarounds for the advanced cases, should they be implemented at some point, so that they are not truly ”impossible”. Rather, they might need a somewhat more involved syntax to achieve the result.

That is compelling. Note, in contrast, the text in the pitch:

...and I think this goes to and even builds upon my concern regarding this pitch: The use of <> strongly implies a whole host of behaviors. For me, what you point out is that even putting aside everything about generic protocols, there are other implications of this spelling that immediately arise which this pitch not only does not accommodate, but requires prerequisites currently missing from Swift such that it cannot accommodate them until some unspecified future time (cf. analogy to Full Self-Driving).

10 Likes