`shared` members and metatypes for generic types

Limitations of Generic Types

Generic types introduce a namespace that is underutilized. All members of the type are bound to the generic context even if they make no use of the generic arguments. This limitation is unfortunate in a number of practical scenarios.

Stored properties

struct Foo<T> {
    // error: Static stored properties not supported in generic types
    static let message = "hello"
}

The intent here is to create a constant within the namespace of Foo. The error can be worked around by using a computed property instead:

struct Foo<T> {
    static var message: String { return "hello" }
}

Static member access

Unfortunately message still cannot be used as desired:

// error: Generic parameter 'T' could not be inferred
let x = Foo.message

An arbitrary generic argument must be specified in order to use the constant. The requirement to specify a generic argument is also unfortunate in the case of factory methods:

extension Foo {
    static func makeFooOfInt() -> Foo<Int> { return Foo<Int>() }
}

// elsewhere:
// error: Generic parameter 'T' could not be inferred
let fooOfInt = Foo.makeFooOfInt()

Strange factories

The signature of the factory method does not depend on T. Requiring the generic argument to be specified can lead to strange and misleading (but valid) code such as:

extension Foo {
     static func makeDefault() -> Foo<Int>
}
// elsewhere:
let foo = Foo<String>.makeDefault()

What type does foo have here? Many programmers would read this and assume the type is Foo<String> but it is actually Foo<Int>.

Nested types

In some cases it would also be nice to use the "namespace" of a generic type to declare nested types or type aliases that do not depend on the generic arguments of the generic type.

Introducing shared members

This pitch introduces shared members of generic types.

struct Foo<T> {
    shared let message = "hello"
    shared func makeDefault() -> Foo<Int> { ... }
    shared struct Bar {}
}
let message = Foo.message
let foo = Foo.makeDefault()
let bar = Foo.Bar()

Metatypes

Generic types would have a shared metatype Foo.self: Foo.Type. This shared metatype would provide storage for properties. In order to make the shared metatype available in a generic context (even when it is not known whether T is generic or not), all types would have a Shared associated type: Foo.Type == Foo.Shared == Foo<Int>.Shared == Foo<String>.Shared. The instance of the shared metatype would be available via the shared property of metatypes: Foo.self == Foo.self.shared == Foo<Int>.self.shared == Foo<String>.self.shared.

Note: for metatypes of non-generic types: T.Type == T.Shared and T.self == T.self.shared.

Protocol conformance

It would also be possible to conform the shared type to protocols using a shared extension:

protocol P {
    static func doSomething()
}
shared extension Foo: MyProtocol {
    func doSomething()
}

Conformance would only be possible for protocols that do not have instance or initializer requirements because it would not be possible to fulfill all requirements of such a protocol. One edge case does exist here however: protocols which supply default implementations of all instance requirements.

The above example also demonstrates that shared is implicit for all members of a shared extension. This syntactic sugar could be mirrored with a similarly behaving static extension where all members are implicitly static.

Higher-Kinded Types

There is one significant additional use case. A shared, guaranteed-to-be-unique type is exactly what is necessary in order to produce a (nearly) bulletproof and convenient-as-possible encoding of higher-kinded types in Swift.

Note: the features described here should not be considered or accepted with motivation focused on a workaround for higher-kinded types. They should be added directly to the core language someday. This section exists primarily to point out an interesting application and not to motivate the features described here directly.

Bikeshedding

shared was name I liked best as a working name after giving it a few minutes of thought. Other possibilities are common and unbound. I'm sure members of the community would love to come up with a long list of possibilities and help choose the best one. :slight_smile:

19 Likes

The name shared is probably a complete non-starter because Foo.self.shared is equivalent to Foo.shared, and shared is an extremely common name for class properties or methods, with 60+ examples in the Apple frameworks alone.

The feature sounds extremely useful, though, and I would support it under a different name.

5 Likes

Ahh, you’re right! I have zero attachment to the name. Any ideas? I’m not in love with common or unbound but those are the best alternatives I’ve come up with so far.

nongeneric?

2 Likes

Why does this even need a label like ‘shared’ at all? I’d love to have this feature. I’ve run into places I could have used it several times now. I just don’t see a reason for having to annotate it specifically when it could just work out of the box.

7 Likes

Agreed, it would be nice not to introduce yet another keyword.

It’s tempting to just suggest that the static member can be invoked using the unqualified containing type if and only if its signature does not use the type parameter, e.g.:

extension Foo {
    static func makeFoo() -> Foo<T> { return Foo<T>() }
    static func makeFooOfInt() -> Foo<Int> { return Foo<Int>() }
}

Foo.makeFoo() // error
Foo.makeFooOfInt() // allowed

Foo<Int>.makeFoo() // allowed
Foo<Int>.makeFooOfInt() // error

There's one hitch. This would prevent a method which only internally uses T in its implementation without mentioning it in its signature:

extension Foo {
    static func makeFooAndPrintIt() { print(Foo<T>()) } // using T not allowed bc it is not in signature
}

I'm not able to come up with a reasonable use case for that. But perhaps one could distinguish it something like this:

extension Foo {
    static func makeFooAndPrintIt<T>() { print(Foo<T>()) } // allowed
}

(I haven’t thought any of that through carefully, but I have no doubt others will spot my numerous mistakes!)

4 Likes

Remember that, inside Foo, a bare Foo means Foo<T>. So there isn’t a way to write the generic Foo type from within Foo itself.

…right.

Maybe relax the “if and only if” part?

extension Foo {
    static func makeFoo() -> Foo<T> { return Foo<T>() }
    static func makeFooOfInt() -> Foo<Int> { return Foo<Int>() }
}

Foo.makeFoo() // error
Foo.makeFooOfInt() // allowed

Foo<Int>.makeFoo() // allowed
Foo<Int>.makeFooOfInt() // allowed, might emit a “type param unused” warning
1 Like

It seems development experience will be complicated without a keyword. While it is possible for compiler to identify “shared” functions, it will be hard for developer to understand what is shared and what is not and why it is not shared while you think it should. Currently if function annotated with ‘shared’ compiler will know developer intent and will inform when implementation is incorrect and there are references to generic type. Also without having a keyword it will be hard for other developers to understand an intent of an author.

1 Like

This design is based on introducing the shared metatype to which the shared members are attached. It facilitates powerful interaction with generics that would not otherwise be possible. This includes access to the shared type T.Shared of an unconstrained T as well as the ability to conform the shared type to protocols and use constraints such as T.Shared: MyProtocol.

Further, one of the guiding principles for Swift is that of clarity. shared members have much different semantics than static members. Implicitly making all members that do not mention generic arguments shared would be a bit too much magic for Swift. I don't think it would fit well with the design of the rest of the language. The design I am proposing is no less verbose than using static and is substantially more clear than introducing special case rules for static would be.

This is a pretty huge hitch! It would be extremely problematic to allow a change in the implementation to impact the availability of a member. I believe this violates the principle that the signature defines the contract. That principle is important for both clarity (as discussed above) but also in the implementation of the compiler which needs to be able to extract information about a member is extracted from the signature without parsing the entire body.

There isn't today but there is also no use or meaning for Foo with no semantically bound arguments (allowing for the sugar you mentioned when inside Foo). Foo.Shared would consistently refer to the same entity everywhere in the design I have pitched.

The question of how to resolve the conflict of the design with the sugar when inside Foo is a good one. I suspect most of the time that sugar is currently used in a position where an uninhabited Foo.Shared would be unlikely to be intended. However, should the design I have pitched be accepted I think people would expect to be able to use Foo.message and Foo.makeFooOfnt() from within the implementation of Foo.

One possible answer to that is to make shared members available on both the shared type and the bound types. I haven't spent any time thinking about how good an idea this is, but it's something we could consider.

1 Like

I agree, Foo.Shared (with the Shared part being a strawman syntax) solves this issue. I was replying to the suggestion that we might get away without a keyword.

I think we should probably do this. Essentially, I think Foo.Shared should be a supertype of all Foo.Types.

(As for the bikeshed color, I'd probably go with @unbound, Foo.unbound, and Foo.UnboundType. It's a jargony name, but it belongs to an esoteric feature. I don't like nongeneric because, while the symbols it decorates are nongeneric, the unbound metatype actually is the generic one, so Foo.nongeneric would be how you get the generic metatype instance.

An alternative might be to write the generic type as Foo<_>.Type or Foo<>.Type, but I'm not sure that's a good idea.)

4 Likes

Isn’t all of the above a subset of generalized existentials, but spelled differently? I certainly imagine that with generalized existential support, all existential types would have a metatype. I imagine those metatypes would have all the behaviors you two describe in the quotes above.

I’m having trouble shaking the gut feeling that “shared” only seems like a new, separate beast now because Swift generics are currently so limited, but that it ought to fall out naturally in a more robust generics system.

In that hypothetical future world, this proposal looks more like “allow stored static properties on existential types.”

Availability? I don't follow.

If an function’s implementation changes so that it needs an additional parameter, then in any other context that additional parameter would change the message signature. It’s the same here.

In the example I gave, makeFooAndPrintIt() essentially needs a type parameter:

extension Foo {
    static func makeFooAndPrintIt() { print(Foo<T>()) } // allowed
}

Whether that parameter is explicitly stated:

extension Foo {
    static func makeFooAndPrintIt() { print(Foo<T>()) } // allowed
}

…or implicitly added by a change from shared to static, either way you’re changing the signature, but both altered methods are still just as available as they were before.

1 Like

Just out of curiosity, why Foo<_>.method() is not a good idea? Seems like more consistent with a defined syntax in compare with an introduction of new functions / variables just to access a function.

3 Likes

I don’t think it is worth adding if the compiler can’t deal with it without an annotation; there are plenty of alternatives, e.g. put them in a seperate enum.

PS These days, every single proposal seems to want an annotation that spreads virally.

PPS The annotation will spread virally because a shared can only reference other shared.

People are going to hate this, but what about using the word 'generic'? As in, it works generically across all specializations.

1 Like

Maybe we don't need a keyword. Use Foo<_> at the call site, and use a constraint to blank out the generic type and tell the compiler you're not using it in the implementation:

static func makeDefault() -> Foo<Int> where T: _ {...}

As a bonus, if you have more than one generic type you can now declare a function is only using one of them by blanking out only the unused one:

class Dictionary<Key, Value> {
    static func generateRandomKey() -> Int where Key == Int, Value: _ {...}
}

Dictionary<Int, _>.generateRandomKey()

And this leaves much room for supporting variables with only partially known generic types and non-static methods that work with them in the future.

2 Likes

I understand the issue and agree that it should be fixed somehow but I must ask if this is a good idea in the long run, because we could do a lot more with metatypes if we‘d refactored those, but that is currently out of scope for Swift 5 and/or until reflections revamp. If we‘d add more things to the current inflexible metatypes then I‘m afraid we won‘t be able to revamp them properly in the future.

1 Like

Not at all. Generalized existentials are primarily about values of an unknown type that conform to a protocol. It would be possible to introduce existentials for generic types with unknown arguments but that would be an independent feature. It would need to be motivated by use cases for values of a known generic type with unknown generic arguments. I'm not sure what those use cases would look like.

Has there been any discussion of this kind of existential in the past on SE? There is no mention of it in the manifesto.

Availability meaning whether the member is available on Foo or requires a generic argument Foo<Int>. You have two identical examples above so I'm not sure what difference you are trying to communicate. My point is that nothing in the signature static func makeFooAndPrintIt() indicates that a generic argument is required to use the function if we implicitly make members available on the shared type. Using a declaration modifier for members that do cannot use generic arguments makes the distinction immediately clear.

The situation here is much different than generalized existentials where the conformance always has access to all associated type bindings. They are only erased in the existential type visible to callers. This difference means that members can be made available to users of the existential based on the visible signature alone with no need to consider the implementation.

This faces the same problem I discuss with Paul above. There is no way to tell from the signature of a member whether the implementation makes use of the generic argument or not unless the signature includes a modifier that disallows use of generic arguments in the implementation.

What is the objection to annotation? It is not additive to a declaration at all. It would be used instead of static.

Of course there are workarounds. They are all suboptimal, and as demonstrated in the Foo<String>.makeDefault() whose result has a type of Foo<Int>, in some cases the current options can be actively harmful.

This approach is more powerful but also substantially more clunky for common use cases such as constants and factory methods. I am also somewhat concerned that the syntax T: _ could be confusing to readers not familiar with this feature. It leans on the syntax of defining constraints on T which are typically used to increase access to members of T, but in this case actually means the implementation shall not mention T at all.

I am interested in further discussion of this option, but am not convinced the added complexity is warranted by the motivating use cases. Do you have any real world examples that would help justify it?

What motivating use cases of this pitch would be addressed by refactored metatypes (and how)? It seems pretty orthogonal to that but maybe I'm missing something.

How would the shared metatype make future refactoring so much more complicated as to make doing so prohibitive? This seems to me like a relatively minor incremental addition to the current metatype structure. I would be surprised if refactoring metatypes was possible without it, but not with it. But again, maybe I'm missing something.