Improving the UI of generics

@anandabits one more example:

P.self is P.Type     // false
P.self is P.Protocol // true

It would be cool to reclaim .Type if we could. My team has a bunch of use cases where .Type is by far the best name for a model. We work around that by skipping nesting and just using a Type suffix. This is unfortunate because we use nesting quite a bit and this breaks the conventions we otherwise follow.

However, I wonder how this approach could support constraints on the metatype (or the "associated existential" I described). The syntax works fine:

func foo<T>() where Metatype<T>: MyProtocol

But the semantics don't really fit with Swift. This syntax implies that Metatype can only have one conformance to any given protocol. We want each metatype to be able to have an independent conformance. Semantically they seem to fit best in Swift's generics model as associated types.

My brain already fades for today, I'll think more on that tomorrow, but if I got you right then you want this constraint:

func foo<T>() where any Metatype<T>: MyProtocol

I still need to think through how any would apply to the design of Type<T> (or whatever we would call it). IIRC @beccadax told me back then that a metatype is like a struct or class, that's why we dropped the Metatype name.

I want to be able to use the metatype and the associated existential in the same ways we can use associated types, the difference being that they are available on all generic types including those without constraints.

In our proposal we would have described such constraint as:

func foo<T>() where AnyType<T>: AnyType<MyProtocol>

What this saying is: "We can use any type T where it's existential metatype is a sub-type of the existential metatype for MyProtocol."

This basically describes indirectly the relationship between T and MyProtocol through their metatypes.

If the translation here is symmetrical then it would be.

func foo<T>() where any Type<T>: any Type<MyProtocol>

Since with any existential types could potentially conform to protocols, the following would mean something different:

func foo<T>() where any Type<T>: MyProtocol

If we could do this, it will fill a huge hole in the constraint system that for example allow you to describe constraints for associated types that await a concrete protocol and not its existential.

I wonder if we should build on the any and some prefix syntax and use a prefix like meta (as a strawman):

  • any T is the associated existential
  • meta T is the metatype of T
  • any meta T is the metatype of the associated existential
  • any P is the existential for the protocol
  • meta P is the protocol metatype
  • any meta P is the metatype of the protocol existential

I realized after posting it that my approach to solving the nesting issue depended on the fact that the name of the existential type matches the name of the protocol (or the unbound name of a generic type for concrete type existentials). This is what made the P.NestedType or Array.NestedType syntax work.

With this prefix approach to existentials that would become (any P).NestedType or (any Array).NestedType which is not what we want when it comes to using types and protocols as namespaces. Maybe one option would be to use some on concrete types:

// We don't know what the type of `Element` is so any static members put in this
// extension are available using `Array.NestedType`, `Array.constant`, etc syntax.
// (as well as being available as `Array<Int>.NestedType`, etc
extension some Array {}

This isn't what I want though. I want to be able to say (in today's spelling) that T.Type: P and proceed to use T.self anywhere a P constraint is present.

Sure that should be possible if existentials will indeed conform to protocols, then I think it would be a great enhancement to also allow metatypes and their existentials to conform to protocols.

I actually like this direction as it avoids the need for a pseudo generic type. It's a little verbose, but I like the trade-off here. Since the syntax itself overlap we can say that we only need the top three forms, but the super-type relations differ as they preserve todays relations.

Lets say T is a non-protocol type and P is a protocol like in your example already.

any T : any T
meta T : any meta T
any meta T : any meta T

any P : any Any
meta P : any meta Any
any meta P : any meta P

IIRC this exactly the same relationship we had in our proposal. (I hope you understand this super-type notation.)

func type<T>(of value: T) -> any meta T

func subtype<T>(of value: T, named name: String) -> (any meta T)?
1 Like

I can't think of a good reason metatypes couldn't fundamentally be plain old generic types. The generics syntax also makes it even clearer (to my eyes) what Meta<any P> vs. any Meta<P> would mean. It'd be difficult to make something that abstracts over existential metatypes and plain old metatypes because they aren't really structurally similar; the former is more a special case of existential types than a proper metatype.

7 Likes

How would this work? static members of a type are instance members of the metatype:

struct MyType {
    static let x = 42
    static func doIt() {}
}
let myMeta: MyType.Type = MyType.self
myMeta.x
myMeta.doIt()

Are you imagining that the static members would be implemented something like a conditional extension like this:

extension Meta where Concrete == MyType {
   let x = 42 // instance stored properties aren't normally allowed in extensions...
   func doIt() {}
}

Even if this approach works wouldn't it be limiting? Now Meta can only have a single conformance to a protocol for all concrete types. My mental model is of metatypes being a lot more first class than this. Each metatype should be able to have its own set of protocol conformances, shouldn't it?

I think you did a correct observation here, even if we had Meta<T>, it would probably be a generic keyword type similar to non-generic Any as keyword type. That implies it won‘t be a citizen of stdlib directly.

My conclusion is:

  • I hope we can move away from T.Type and reclaim .Type
  • With this we could clean up metatypes drastically.
  • We would communicate their behavior more clearly.
  • We would get a new set of constraints we could express in Swift‘s type sytem.

If it’s not a normal generic type or typealias I think this syntax would be confusing. If we had type-level functions and used the same angle bracket syntax for those then it would make a little bit more sense. It still isn’t clear to me how we would be able to extend metatypes to add protocol conformances to them though. I don’t think we would be able to extend the result of a type function.

So you can argue about Collection<.Element == T>, no? As Joe said, for some any Meta<T> is visually more appealing than any mata T.

We aren’t taking about supporting Collection<.Element == T>, we are talking about requiring existentials and opaque types to be prefixed with any and some respectively so it would be any Collection<.Element == T> and some Collection<.Element == T>. The prefix keywords make it clear that the angle brackets have different meaning than they do for ordinary generic types.

I’m not trying to fixate on syntax here. I’m trying to focus on semantics. Ideally the syntax would align with the semantics so I’m asking questions where I see syntax that doesn’t seem to align with the semantics I hope to see.

2 Likes

Okay I understand. So in case you are fine with meta T then what would you think about typealias Meta<T> = meta T a small convenience? (Sure this is duplication of the same thing, but that's not my question.)

That would be a way to make the Meta<T> syntax fit into the semantic model I hope to see. If it didn’t conflict with the Any we already have you could also imagine typealias Any<T> = any T to access the associated existential of any type.

1 Like

Given the naming discusses just above the quote, the natural way to do constraints is:

func foo(a: some Collection A, b: some Collection B) -> some Collection C
  where A.Element == B.Element, B.Element == C.Element

No need for new weirder syntaxes.

5 Likes

I understand using shorthands (anonymous types) for situations where they are well suited for. But for more complex situations, named types are natural fit. And anonymous types can become unwieldy or difficult when you try to refer to them without having something exact to refer to.

I mean is it really that critical to create new, unnatural syntax for anonymous types that covers every single case of complexity that would be already possible with named types (who require nothing of this)? Is it worth making the parser/compiler that more complex? Having other people look at shorthand code and not understand it? Wouldn't it be ok to say that "anonymous types cover these simple cases as a subset and full functionality is provided with named types"?

Any kind of shorthand we come up with for some in return type position, should be mirrored (where applicable) for parameter position, as well as for any in same places. And we don't want to end up with a jungle of syntax.

1 Like

The way I would like to see the evolution of any/some is that after MVP:s like the latest SE-0244: Opaque Result Types (reopened) - #22 by Moximillian, the named types would be introduced (at some point): some Collection C, maybe any Protocol A, to handle all the complexity with constraints and others, which comes with no requirement for new syntax for setting up constraint rules etc.This gets us full functionality.

Once that is in use and people see actual code, then introduce some well-chosen shorthands for anonymous types, where they make sense. After all, at that point it's mostly just eye-candy, and we can have a little bit patience for one's favorite sugar, right?

EDIT: hmm, if named some types would be used e.g. in stdlib/Foundation, would those names be visible to users of library? If that’s a problem then maybe initial implementation of named some types could be converted to anonymous by the compiler behind the scenes so that names are not visible to outside of module... Ultimately, since there’s been discussion about figuring ways to refer to anonymous types too, I’m not sure such conversion would be worth it though. You’re not really hiding anything, just making it more difficult to use it?

We do have precedent with regular generics func foo<T: P>() of having always to name the type. And we have survived quite well without anonymous types there. While some is bit different when used as reverse generic (return type), I think naming is still right place to start. If I understand correctly, the current existential syntax func foo(item: Protocol) is effectively anonymous type equivalent and people are struggling with that concept and inability to refer to the exact named thing.

In terms of ease of use, here’s a bit exaggarated example:

Current
func foo(item: Protocol)
— Error: ”protocol Protocol does not conform to protocol Protocol”

with named any
func foo(item: any Protocol I)
— Error: ”existential I does not conform to protocol Protocol”

1 Like