[Pitch] Light-weight same-type constraint syntax

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

Yes, it it's not allowed because we thought that going the route of extending opaque result types to parameter positions might be a more viable alternative, but it could be, although Collection<String> might not be very interesting because, if I remember correctly Index would have to be specified as well, but Sequence<Int> is not a problem.

I think that this syntax should be usable with existential types in time. Part of why we didn't want to include it in this proposal is because there's this "elephant in the room" about the spelling of existential types, and we should have the discussion about whether or not it's desirable, and more importantly, feasible to change the spelling. If that's the best direction for the language, I think we should change the spelling or at least introduce a new spelling (presumably in Swift 6) before we allow more code to use existential types in their existing spelling.

5 Likes

What about opaque types? With SE-0328 now in review, if it is accepted will its implementation bring the expressiveness necessary to support -> some Collection<Int>?

Stepping back, this pitch feels like part of a transition the Swift language is making, with the Generics Manifesto as its guiding light, but that the overall route is hazy. I get the sense that there’s some mathematical relationship between type parameters and associated types. I even suspect that relationship might be precisely what people mean when they say “generic protocols”.

Might it help to explore that relationship (perhaps with the added context of opaque and existential types) to produce a map of where we are, where we’re going, and how we’re getting there?

No, SE-0328 is purely to lift the restriction that opaque types can only appear as the top-level result type. It does not add the ability to express constraints on associated types. That said, we absolutely want this syntax to work with opaque types, but we feel like it belongs in a separate proposal. If the community feels differently, that's valuable feedback.

You're right - this pitch is part of an effort to make generic programming in Swift more approachable. I posted a discussion with higher-level goals and directions here. This is also a step toward the vision that was laid out in Improving the UI of Generics, but focusing on the aspects that design guidance and progressive disclosure into the generic programming experience (contrasted with fully generalizing some of the generic programming features that are rather limited today).

2 Likes

Thanks; I think that Improving the UI of Generics provides the kind of analysis I was looking for.

Though this pitch explicitly doesn’t touch the spelling of existential types, they keep coming up in this thread, as well as being mentioned in Improving the UI of Generics. I wonder if that’s because they are an example of the consequences of the same strategy this pitch employs: intentionally overloading syntax to make the language more approachable.

OK, after letting this gestate over the past few days, I think I’m ready to propose something radical:

  • Instead of creating second kind of associated type, let’s eliminate the associatedtype keyword entirely.
  • Let’s call this feature “generic protocols”.

Rationale

There are exactly two things you can do with a generic type:

  1. Instantiate a concrete type from it.
  2. Instantiate a quantified type via where.

Concretization of generic types happens all over the place in normal Swift code. The most obvious example is when instantiating a value of a generic type:

struct S<T> { }
let s = S<Int>() // constructs S<Int> from S<T>

This is the example @regexident draws on when talking about invoking generic types like functions. But this isn’t the only place where such an operation occurs. Because of inheritance, generic classes can also be concretized when subtyping:

class Parent<T> { }
class Sub: Parent<Int> { } // constructs Parent<Int> from Parent<T> and extends it

Like generic classes, protocols with associated types can also be concretized by subtyping:

protocol P {
    associatedtype T
}
struct S: P { // constructs type 'P where T == Int' and subtypes it
    typealias T = Int
}

So why does this have a different name and syntax from generic structs? We could call such a protocol a “generic protocol” and spell it the same way with no loss of expressivity:

protocol P<T> { }
struct S: P<Int> { }

And then we can discuss syntax enhancements that apply to all forms of generics:

class Parent<First, Second> { }
class Sub: Parent<.Second = Int, .First = String> { }

protocol Collection<Element, Index, Iterator, SubSequence> { }
struct Array<T>: Collection<.Element = T, .Index = Int, .Iterator = IndexingIterator<Self>, .SubSequence = ArraySlice> { }

As well as new functionality such as generalized existentials:

// Existential of generic struct
var arr: any Array = [1, 2, 3]
arr = ["one", "two", "three"]

// Existential of generic protocol
var coll: any Collection = Set(["a", "b", "c"])
coll = 1..<10

But what about “real” generic protocols?

The Generics Manifesto describes two different things that commonly go by the name “generic protocols”:

  1. Generalized existentials.
  2. Multiple conformance.

Even without multiple conformance, the language already prohibits conforming to a protocol more than once:

protocol P {
    associatedtype T
}

struct S { }
extension S: P {
    typealias T = Int
}
extension S: P { // error: redundant conformance of 'S' to protocol 'P'
    typealias T = String
}

Concretizations of generic protocols are themselves subtypes of the generic protocol, and this restriction is a specific instance of Swift’s more general prohibition against subtyping a supertype via more than one path. For example, Swift eschews the diamond inheritance problem by prohibiting multiple inheritance in classes.

The manifesto contains an example where have historically asked for the ability to conform to a
protocol more than once:

protocol ConstructibleFromValue<T> {
  init(_ value: T)
}
struct Real { ... }
extension Real : ConstructibleFromValue<Float> {
  init(_ value: Float) { ... }
}
extension Real : ConstructibleFromValue<Double> {
  init(_ value: Double) { ... }
}

To be honest, I’m not clear what the programmer expects to do with these extensions. Everything I can think of is either solved by conditional conformances (which arrived after the manifesto was written), or are compatible with associated-types-as-generics.

1 Like

Words have meanings. We can’t just call chickens ducks because they seem vaguely similar. Similarly, “generic” is not some catch-all term for any sort of polymorphism.

Array<Int> and Array<String> are two distinct types. That’s what makes Array generic over its Element type.

(Independent of whether we use <> solely to denote generics or not, which is the overall topic of this thread:) If we have a protocol Sequence that’s generic over its Element type, then Sequence<Int> and Sequence<String> are two distinct protocols.

And if they are two distinct protocols, then a single type can conform to both of them.

Because P is not generic over its associated type T: there is only ever a single protocol P.

8 Likes

The other counter to "why not just have generic protocols" is to look at the list of associated types for Collection: Element, Index, Indices, Iterator, and SubSequence. While you can represent this as a protocol with five(ish) generic parameters, it wouldn't be a good user experience. (Rust traits allow both generic parameters and associated types for this reason.)

Generic protocols are a valid if complicated feature that we may or may not add to Swift, but they won't obviate associated types. It is possible that every associated type we would make "primary" is one it would make sense to be generic over, though.

9 Likes

Words have the meanings we ascribe to them. This would be a deliberate decision to (re)define “generic types” in a way that encompasses all function types, structs, classes, and protocols with type parameters.

My argument is that Array<Int> and Array<String> are distinct types in the same way that IteratorProtocol where Element == Character is distinct from IteratorProtocol where Element == Int. In the future, with generalized existentials, any Array and any IteratorProtocol are both unconstrained existential types. Values of type any Array can be constructed from values of type Array<Int> and Array<String>, just like values of type any IteratorProtocol can be constructed from values of type IndexingIterator<Int>.

Only by particular definitions of “generic type”. This particular definition conflates two different meanings of “type”. If we want to be more precise, struct S { } defines a type S, but also defines a datatype S. Values of datatype S have type S.

Meanwhile, struct Array<Element> { } defines a type Array, but there is no Swift expression with type Array. There are only Swift expressions of type “Array<Element> for some Element”. Yet the type Array certainly exists: it is the name of a type constructor whose kind is * -> *.

Generalized existentials propose the any operator, which can be applied to types. As such, for any Array to exist, Array must in fact be a type. And in fact, it is only useful to apply any to a higher-kinded type, since any Array<String> has only one inhabitant, Array<String>.

So an alternative definition of “generic type” could be “any type which, when prefixed with any, has one or more free type variables.” That definition encompasses not only any Array but also any IteratorProtocol. Allowing for that Swift spell the existential type as IteratorProtocol, you can substitute IteratorProtocol for anywhere Array appears above.

Do you have any use cases for generic protocols handy? Every way I could think of to use the ConstructibleFromValue example could also be achieved with conditional conformances, generics or existential types.

I believe the main feature they add over associated types is the capability to conform to a protocol in multiple ways. @OneSadCookie already pointed out the most common examples I can think of in Rust: conversion/borrowing traits (like From<T>) and operator traits (like Mul<T>). Swift, for better or worse, has arbitrary operator overloading instead of tying them to protocols, but the conversion traits are useful in practice (partly for operating on wrapped values, and partly just for providing a standard method name for conversions). So someone else would have to come up with a good example outside that.

My own use of Rust has had me defining very few generic traits, but I wouldn't necessarily take that as indicating they're useless. After all, our "folk knowledge" in Swift has been that plenty of people never need to define a protocol at all; they just use the ones defined in the stdlib or the SDK or their dependencies.

On the other hand, a certain past colleague who's worked on both the Rust and Swift standard libraries has commented that generic traits were more trouble than they were worth in Rust. I can ask them if they have more to say on this but they may or may not respond. (One example I can think of, though, is that if, say, String conformed to both Collection<Character> and Collection<UnicodeScalar>, String.Iterator would become ambiguous.)

I'm not yet convinced that "we're gonna want 'em" in Swift, but it's true that if we did add them, this would be the syntax people would expect, and if we took this proposal and then added generic protocols, we'd have some protocols you could conform to in multiple ways and some you couldn't.

7 Likes

Well this is exactly why I'm worried about this proposal and why I previously said that we're trying to jump over too many hoops at once. This proposal is additive but not incremental at all.

The community and the core team already explored the next steps in depth:

  1. unlocking the where clause in typealiases
  2. improving the UX with "one" possible, but unambiguous spelling such as P<.Assoc == T>

It won't kill anyone to start with (1), as it's purely incremental add-on. (2) is just "one" option to improve upon (1). However the proposal to me reads like (2) was already too much for the users to deal with and we need to improve even further. Excuse me, with all my respect, please give us at least (1) so we can start using the actual feature in question and not elaborate on how to improve with sugar code (proposal) over other hypothetical non-existing sugar code (2).


I'm sorry to bring this up again, but I have this quote stuck in my mind from @Chris_Lattner3 for similar sugar proposals:

6 Likes

There is one more thing that came back to my mind today. I don't see how the concept of primary associated types can deal with generic associated types. Making seemingly generic type parameters (primary associated types) generic (e.g. protocol P<G<T>>) seems to be fairly strange unless this would eventually become a generalized feature in regular generics as well (e.g. struct Foo<G<T>>). As far as my knowledge in that area goes, generic associated types could be used to workaround the absence of HKT in some places. In fact, this is also a note in the document linked above:

Note: generic associatedtypes address many use cases also addressed by higher-kinded types but with lower implementation complexity.

4 Likes

Pavel and I have done our research on the Generics Manifesto, Improving the UI of Generics, and many other generics discussions that have taken place on these forums. I am not aware of any official core team decisions that were made on the "next steps" for generics in Swift. Just because possible features have been discussed before does not mean that people are unable to propose new ideas, or variations of an already-discussed idea.

What we are proposing here is obviously different than what has been explored in the past. We hear your opinion that you're strongly against this proposal because it is the most obvious syntax for generic protocols. We believe that feature is unlikely to be added to Swift -- as explicitly stated in the Generics Manifesto -- and even if it was, that is a very advanced feature and we believe it's worth using the Collection<Int> syntax to mean a same-type constraint as proposed here. We also feel that Collection<.Element == Self.Element> or similar would be really onerous because this is the most common form of constraint that would be written using this syntax. (Note also that we're not completely tied to primary associated types being declared in angle brackets at a protocol declaration. There are alternatives.)

This is not an appropriate place to request that we work on a different generics feature that you're interested in. We have clearly stated our goal of designing more progressive disclosure and guidance into the generic programming experience, and that is what we are pursuing.

3 Likes

Please don't feel offended on the pushback through my commentary as I'm just trying to express my feedback the best way I can. That out of the way, please note that it's not only the generic protocols. Generic protocols is one thing, but I already mentioned several other features that cross the line with the proposal and issues I see with the prosed syntax and it's potential inflexibility from my personal day to day user experience.

Well I'm fairly surprised right now as "same-type constraint" as per this threads title is exactly the type of generic feature I outlined with (1) and there is nothing fundamentally different from what I was asking about. The proposal wants to unlock it, but without unlocking the incremental intermediate form of it, which I find very unnatural way of evolution.

Interestingly, C# has generic interfaces with multiple conformance but does not actually allow you to fulfill requirements more than once, nor does it allow partial specialization:

using System;

interface IGeneric<T> {
	int valP { get; }
	int valF<T>();
}

partial class C : IGeneric<int> {
	int valP { get { return 1; } }
	int valF<int>() { return 1; } // error CS0081: Type parameter declaration must be an identifier not a type
}

partial class C : IGeneric<string> {
	string valP { get { return 2; } } // error CS0102: The type `C' already contains a definition for `valP'
	string valF<string>() { return 2; } // error CS0081: Type parameter declaration must be an identifier not a type
}

static class M {
	public static int ExtMethod<T>(this IGeneric<T> me) {
		return me.val;
	}
}

I think most likely that's because inference would run into ambiguities (which might not be possible to resolve at the use-site?), or pick a wrong thing in some situations without the developer noticing, just like what Jordan mentioned with Collection<Character> and Collection<UnicodeScalar> conformances at the same time.

The ambiguity could be resolved with partial specialization, which Rust permits but C# does not.