[Discussion] Easing the learning curve for introducing generic parameters

I love the motivation here. I'm sure you'll come up with something that will help people. :sparkling_heart:

There's an opportunity to ease a lot of the friction that stems from having both associated types and generic placeholders. And that's applicable to everyone, not just the inexperienced.


For example, I don't believe this proposed syntax can work…

func maxValue(in collection: some Collection<Int>) -> Int

… because, which one of Collection's various associated types is that?

And look at how these functions have to be written so differently, based on level of genericism:

func count<Element>(array: Array<Element>) -> Int {
  array.count
}

func count<Collection: Swift.Collection>(collection: Collection) -> Int {
  collection.count
}

Also, while you can parameterize an associated type, or a function argument…

protocol Protocol {
  associatedtype IntCollection: Collection
  where IntCollection.Element == Int
}

func ƒ<IntCollection: Collection>(_: IntCollection)
where IntCollection.Element == Int { }

…you can't parameterize a typealias.


I propose a combination of

  1. support of labeling of generic placeholders and associated types
  2. associatedtype-esque disregarding of irrelevant generic placeholders
func maxValue(in collection: Collection<Element: Int>) -> Int
func count(array: Array) -> Int {
  array.count
}

func count(collection: Collection) -> Int {
  collection.count
}
typealias IntCollection = Collection<Element: Int>

I don't think the some keyword would be helpful to the programmer for this. It might be useful for decorating what would otherwise be existential parameters, for performance reasons?

1 Like

If you're using something like AsyncSequence with opaque types, here's how I think it would interact with API resilience:

  • Given an existing protocol AsyncSequence with an existing associated type Element, the protocol can opt Element into being a "primary" associated type without breaking ABI, allowing the some AsyncSequence<String> spelling.
  • Changing the requirements on the opaque type is not a resilient change. For example, you cannot change some AsyncSequence<String> to some AsyncSequence<Int>.
  • Given an opaque type some AsyncSequence<String>, changing the underlying return type is a resilient change.

I think it depends on what AsyncSequence<String> means. We could decide that this is an opaque type, or that you have to qualify it with some or any to distinguish between opaque and existential. One of the goals here is to push opaque types further, and those don't need an existential container since the underlying type can't change dynamically. We could also do what @Karl suggests and actually make the distinction not matter in all cases where the underlying type can't change dynamically, such as immutable function parameters.

Like you mention, part of the motivation for improving the ergonomics of generics is so that programmers can more easily maintain the wins of static type safety and its performance benefits :slightly_smiling_face:

5 Likes

So I want to make it clear, if you can pull this off with some slick syntax like some AsyncSequence<Int> etc - this is going to be perhaps one of the biggest improvements for the quality of APIs that folks that are bound by the constraints of ABI stability.

Insert Flip J Fry take my money meme here...

4 Likes

I should add for the point about treating existential function parameters like anonymous generics - the optimiser already does this in some cases, so we'd just be lifting this in to the language model. Depending on how we decide which functions qualify, this could be a source-compatible change.

It would help a lot of generic code. Simple example, using the clock protocol from the recent pitch thread:

func measure<ClockType>(
  _ body: () -> Void, using clock: ClockType
) -> Duration where ClockType: Clock {
  let start = clock.now()
  body()
  return start.duration(to: clock.now())
}

Currently, this requires a named generic type for the clock; you won't be able to access its Instant type or the now() method which returns an instance of it otherwise. By promoting existential function parameters to generics at the language level, we can lose the angle brackets and make these functions a lot simpler:

func measure(_ body: () -> Void, using clock: Clock) -> Duration {
  let start = clock.now()
  body()
  return start.duration(to: clock.now())
}

Allowing generic code to use this syntax, and opening up the full power of generics to code which happens to be written "the wrong way", and having it be source-compatible, just seems like a really great opportunity. Even if we don't do anything to inouts.

You could imagine this composing well with further shorthands, such as inline constraints. We could also consider more radical ideas to reduce angle bracket blindness, like allowing protocols which are only used once to stay anonymous and be referred to by their protocol name (e.g. for this function's return type, it's obvious which collection it's talking about):

func average(items: Collection where .Element: Numeric) -> Collection.Element? {
  ...
}

And I think this would be a considerable improvement over how you'd express the same thing today:

func average<CollectionType>(
  items: CollectionType
) -> CollectionType.Element?
 where CollectionType: Collection, CollectionType.Element: Numeric {
  ...
}

The any MyProtocol syntax may not be possible, due to the global any(_:) function:

Any & MyProtocol is already permitted, so could this be a backwards-compatible syntax for existential types? The compiler might generate a warning and fix-it in Swift 5.x, followed by an error in Swift 6 or later?

However, MemoryLayout<Any & MyProtocol>.size doesn't compile in Swift 5.5 — a workaround is to use a typealias of the protocol composition.

I'm not understanding, could you motivate? You can declare a global some(_:) function and it wouldn't collide with the some contextual keyword: the former can be used at the value level, the latter at the type level.

3 Likes

There is one use you seem to have missed: generic functions can’t be used as values, while functions over existentials can. (As I understand it this is a design decision, not a fundamental impossibility.)

any Protocol syntax for existentials, please. It’s a breaking change, but worth it to make Swift much more easier to understand.

2 Likes

Right, but it doesn't matter to the language model whether the existential spelling is literally transformed in to a generic. If we can't/won't support fully generic closures directly, an existential closure could immediately open all of its existential arguments and thunk over to a generic. Really, it's just extending the same automatic type -> value level boxing we already have, but in the other direction.

I think we need to get back to the idea of the syntax trying to express what you mean in the most natural way, and the compiler figuring out how to make it fast. It really shouldn't matter whether your closure/function value/function is written as:

let someFunc: (Collection) -> Void
// or
let someFunc: <C: Collection>(C) -> Void // hypothetical generic closure

Value-level abstraction and type-level abstraction are semantically interchangeable, except across rare points where a value-abstraction's (existential's) type changes. There's an analogy with how it's difficult to reason across suspension points from an await, and easier to reason about the synchronous bits between them. If you will never observe a change in type, there's no reason for your code to care about it, so everything should be possible in those contexts, IMO:

// Returns an existential - maybe a Range<Int>, or Array<Bool>, String, etc.
func giveMeACollection(size: Int) -> /* any */ Collection

// 'result' doesn't need to be bound by the limitations of existentials here.
// Its type will not change.
let result = giveMeACollection(size: 2000) 

result.startIndex   // Error. Doesn't need to be.
print(result.first) // Error. Doesn't need to be.

Parametric Polymorphism is hard very.. Sometimes the compiler it's self is misleading with some error messages. I solely support a source code break if possible.

The high curve of "associated types" on the Swift Language cannot be overemphasized for this same reason I wrote an article for me to understand how it works and how I can use it. Here is the article on Swift Associated Type Design Patterns
The collection aspect of it is what really gets me angry that I have to do a type-erasure else you cannot unify the collection into a single kind of T. Meaning anything that conforms to the protocol and provides a T should be able to stay in the collection. I don't think I am asking too much.
I would love to have a possibility to NOT implement the the Type-Erasure aspect if it. The compiler should be more intelligent and just understand me. But apparently the compiler is NOT intelligent enough to understand my intents and keeps requiring lots of information.

No matter the quantity of sugar, sooner or later they’ll get to some bitterness, for example having to write a type erasure.
At that point they’ll discover they maybe have been using generics and protocol oriented programming wrongly all along.

I've thought about this a lot over the last few weeks, and I think the transition might be feasible. I wrote up my thoughts in a pitch that we can discuss over here: [Pitch] Introduce existential `any`

6 Likes

If you're talking about encouraging the adoption of generics, in my experience compiler performance is also an issue.

A few times, I tried to adopt the "right way" and use more generics and protocols instead of a less ideal class-based inheritance design... but I hit a significant headwind with compile times.

I'm usually doing something with SwiftUI Views, so maybe there is something especially problematic with the mix of generics and result builders (like view bodies). I think that's where I've seen the more pathological stuff, like 20+ seconds type checking a ≈20 line function, that didn't look that complicated to the human eye.