[Pitch] Primary Associated Types in the Standard Library

A bit off-topic:

Reading this particular example makes we wonder if the whole feature could have been designed differently where there would be no change to the actual protocol required while we‘d introduce a primary alias of some sort. Strawman syntax:

// or just a typealias in general
primaryalias CollectionOf<Element> = Collection where .Element == Element

CollectionOf<Int>
IdentifiableAs<Int>
SIMDOf<Float>
StrideableWith<Int>

:thinking:

Unfortunately the ship has sailed for that.

4 Likes

That feels like it would be even more confusing, as we'd lose the familiar protocol names.

If the language allowed labels for type parameters, generics might have ended up looking something like this:

Array(of: Int)
Dictionary(key: Int, value: String)
Range(of: String.Index)

This would've helped here:

Collection<of: Int>
Identifiable<by: Int>
Strideable<with: Int>

Of course, that ship has sailed, returned, sailed again, capsized, and is now resting somewhere at the bottom of the ocean. :smiling_imp:

19 Likes

I still have hopes for labels on generic type parameters though. We don‘t have them now, but maybe one day we will. I‘d love to see something like this:

struct Tuple</* generic labels on the generic pack */>: ExpressibleByTupleLiteral
enum OneOf</* generic labels on the generic pack */>

let value_1: Int | String = .1(”swift”)         // OneOf<Int, String>
let value_2: (a: Int | b: Int) = .a(42)         // OneOf<a: Int, b: Int>
let value_3: (x: Int, y: Int) = (x: 0, y: 1)    // Tuple<x: Int, y: Int>
let value_4: (label: String) = (label: ”swift”) // Tuple<label: String>
3 Likes

I agree it should be Elements. Elements is kind of what I refer to as Base in this post on the review of primary associated types:

I am not actually sure Elements will actually prove useful to anyone as a primary associated type, but it probably won't do any harm.

1 Like

I was going to make this kind of list, but then I thought of Optional Int and, well…there's no preposition that goes there. We named the Optional enum with an adjective instead of a noun, and it is supposed to be read that way, so much so that we have the Int? spelling for it. So Identifiable Int is a natural reading even if we'd prefer people to say Identifiable by Int. (That doesn't mean we can't do Identifiable<Int>, though, just that there is that potential for confusion.)

3 Likes

Good idea -- I updated the original post accordingly. I also listed the rest of the associated types for each protocol. (Check out the list on StringProtocol. :nerd_face::face_with_raised_eyebrow:)

Note that Unicode.Encoding and Unicode.Parser are considered public protocols that currently happen to be typealiases to underscored protocol definitions, for very annoying technical reasons. (Which we should eventually get around to resolving...)

No -- in fact, given that Encoder/Decoder sadly forces these into type-erased boxes, there is very little reason for anyone to write generic functions over them. Therefore, there is no reason for these to have a primary associated type. :disappointed:

I updated the table accordingly.

4 Likes

Corrections for the "Others" column:

  • SetAlgebra inherits ArrayLiteralElement.

  • OptionSet inherits ArrayLiteralElement and RawValue.

  • FloatingPoint inherits Stride.

  • BinaryFloatingPoint inherits Stride.

1 Like

Clock does not have a primary associated type imho. Clocks are used upon their behavior not associated types.

2 Likes

I wholeheartedly agree. Even if we had generic-parameter labels, which I think are useful for things like default generic parameters, I don’t think they’d be the right fit here.

In fact, tooling support will also be very important. A simple feature that wouldn’t depend on documenting associated types would be to offer an auto completion list similar to that of function overloads when the user types out brackets after a protocol. Then, when the user selects an option, a placeholder with the associated type’s name at will be inserted. This way, intuition will naturally start to develop with users naturally describing their constrained protocols with prepositions. This way of spelling a protocol with primary associated types could also be reflected in the docs. For example, the Identifiable overview is currently:

A class of types whose instances hold the value of an entity with stable identity.

But it could become:

A type whose instances hold an entity, that can be identified by that entity’s stable identity.

So I think we can avoid introducing Identifiable<By: Int> which would just add verbosity in the long run.

Since it is mostly of-topic, I suggest asking this question in a new thread on Evolution/Discussion rather than here.

Excited to see this!

Personally, I'd rather we not add any primary associated types to LazySequenceProtocol and LazyCollectionProtocol. They aren't likely to be used often, and it feels odd to have them be based on Elements rather than Element like the other sequence/collection protocols. We could always add them later if compelling use cases show up in sufficient quantity.

The distributed actors runtime proposal added a few protocols that could benefit from this feature. @ktoso should weigh in here as well, but my take is:

  • DistributedActor<ActorSystem>
  • DistributedActorSystem<SerializationRequirement>: this protocol has several associated types, but I anticipate any DistributedActorSystem<Codable> to be a Very Useful Thing.
  • DistributedTargetInvocationEncoder<SerializationRequirement>
  • DistributedTargetInvocationDecoder<SerializationRequirement>
  • DistributedTargetInvocationResultHandler<SerializationRequirement>

Definitely the right call. We should bring this in when we've dealt with the Error type, rethrowing conformances, and all of the other exciting throwing-ness of these protocols.

Doug

5 Likes

Thanks for the ping @Douglas_Gregor! I had skimmed this proposal and work but somehow didn't connect the dots all the way.

Yes, those look exactly right! :100:

The two "visible by users" protocols especially will benefit a ton from this, because right now half of the methods on the cluster have to be annotated with actor system requirements like this:

    public func termination<Watchee>(
        of watchee: Watchee,
        ...
    ) where Watchee: DistributedActor, Watchee.ActorSystem == ClusterSystem {

which is very annoying and also makes it tricky to store them. With primary associated types this'll be:

  public func termination<Watchee>(
        of watchee: Watchee,
        ...
    ) where Watchee: DistributedActor<ClusterSystem> {

or maybe just any DistributedActor<ClusterSystem> in some places...

The DistributedActorSystem indeed has many associated types, but the only ones which matter to outside users are:

  • SerializationRequirement - the type arguments are checked for
  • ActorID -- equal to the DistributedActor.ID that it assigns

The ID isn't really all that interesting when passing around a system as any DistributedActorSystem I think, and we can always use where clauses if so. But the any DistributedActorSystem<Codable> seems to be the most useful...

Summary:

  • the list Doug provided seems right and this'll be very useful
    • DistributedActor<ActorSystem> (implies DistributedActor.ID as well)
    • system implementation internal protocols:
    • DistributedTargetInvocationEncoder<SerializationRequirement>
    • DistributedTargetInvocationDecoder<SerializationRequirement>
    • DistributedTargetInvocationResultHandler<SerializationRequirement>
  • Pending question:
    • I have to make sure about DistributedActorSystem if we need the ActorID as primary as well or not...
    • it might simplify mocking out actor systems I think...? Will look into this.
3 Likes

Why not just?

public func termination(of watchee: some DistributedActor<ClusterSystem>)

Is there some benefit of the (traditional) expanded generics syntax that is desirable to use in core libraries, or was it just habit to reach for it?

1 Like

Out of habit here, not for some specific reason, not yet used to the new some/any syntaxes available -- I'll give it all a proper look and I think we'll include the distributed type amendments in this proposal as Karoy intends to propose it for review soon :slight_smile:

4 Likes

Interacting with a clock seems to require knowledge of its Instant.Duration, right? Otherwise you have no way of referring to an advanced instant, or how to tell it to sleep to a certain instant.

So it sounds appropriate for Clock's primary associated type to be this associated type of its Instant. Similar to how Collection<Element> shares its primary associated type with its Iterator.

Having Clock<Duration> would allow you to tell an arbitrary clock to sleep so long as you know its primary associated type, which is particularly useful for writing testable, time-based code, where your application logic can be injected with some Clock<Duration>: it can take a ContinuousClock when run in release, and a theoretical TestClock that can be manually advanced in your tests.

In Combine Schedulers we needed to parameterize a type-erased scheduler (AnyScheduler) and TestScheduler over the SchedulerTimeType in order to inject testable schedulers into application code. It would seem that without a primary associated type on Clock, anyone that would want to do something similar would need to still resort to bespoke wrapper types, like AnyClock.

4 Likes

I spoke at length about this with @lorentey. I retract the objection that it isn't capable of being a primary type. Instead I have a feeling that will only be needed/wanted in very specific scenarios; more often than not it will be folks using a concrete Clock type. There isn't any reason to not do it, only very boutique utility for doing it.

We already have a test clock system setup: swift-async-algorithms/Clock.swift at main · apple/swift-async-algorithms · GitHub. That seems to work pretty darned well for that specific use case; it just means that algorithms should just take generalized clocks if they plan on supporting any clock.

1 Like

I took a look at this, but it only seems appropriate for testing small algorithms/operators, and not larger systems that interact with time-based code. As soon as you are operating with a larger system over time (like testing an observable object in a SwiftUI app) it will need to hold onto a clock that is erased in some way.

I understand that testing can often be an afterthought, but by not providing a primary associated type here, anyone that wants to write testable, time-based code for a larger feature will be forced to write a concrete AnyClock wrapper.

7 Likes

+1 for Clock<Duration>. :slightly_smiling_face:

By the way, the Point Free episode on controlling time in tests is simply amazing @stephencelis. Crème de la crème!

3 Likes

FWIW, this does work:

enum Foo {
  typealias Bar = _Foo_Bar
}

protocol _Foo_Bar<T> {
  associatedtype T
}

struct G<X : Foo.Bar<Int>> {}
3 Likes

Clock<Duration> would be a fine choice; unfortunately, protocol Clock does not have an associated type called Duration. It only has Instant.

It may not be too late to introduce one. A similar precedent is Sequence, which has an Element associated type even though in theory it could just be a typealias for Iterator.Element.

3 Likes