Nominal type as a generic constraint

It seems like currently there is no way to use nominal type as generic constraint explicitly. More exactly, such constraints can not be expressed for value types. Though, these constraints can be made for reference types:

func foo<T: AnyObject>(_ refInstance: T) {}
func foo<T: NSObjectProtocol>(_ refInstance: T) {}
func foo<T: NSObject>(_ refInstance: T) {}
func foo<T: UIView>(_ refInstance: T) {}

func foo<T: AnyActor>(_ refInstance: T) {}
func foo<T: Actor>(_ refInstance: T) {}
func foo<T: GlobalActor>(_ refInstance: T) {}

So it is possible to precisely specify that argument must be of a reference type and different flavors of them, and it would be impossible to pass an instance of enum or struct into such function.

But how such requirement can be expressed for enum or struct? For example, we want to write a function that can take only enum instances.
Of course, currently we can use RawRepresentable or CaseIterable protocols as a generic constraint. But this approach have limitations:

  1. not all enums conform to these protocols, and we will need to add retroactive conformances for such enums
  2. struct and classes can conform these protocols too, even if in reality it is a bit harder to do in comparison to enums.
  3. OptionSet can easily conform RawRepresentable
    Overall, we can't express a strict guarantee that function argument can only be an instance of enum or struct.
  4. trick with RawRepresentable or CaseIterable can not be done for enums with associated value

There is an asymmetry between reference and value types in using them as generic constraints.

Is it a good idea to add the ability of expressing generic constraints for enums and struct? If Yes, what is the best way to do that?

There is a bunch of protocols for reference types, but there is no such protocols for value types. There is a discussion about ValueSemantic protocol, which was finally named as Sendable. If had something like ValueSemantic, EnumValueSemantic and StructValueSemantic protocols then we can easily express that. But I don't feel that this is a right direction.

What do others think?

I think that whether something is an enumeration or a struct is very much an implementation detail. It think it’d be a nice future direction to be able to express move-only type constraints for performance reasons and value-semantic types to ensure proper function of types like SwiftUI’s state. Further than that, the type something uses should be kept an implementation detail. For example, one could wrap their enum in a struct in a package without library evolution to require users to provide the default case. Another example of how enums and structs aren’t that different are option sets, which are commonly used with structs to get a behavior similar to Set. I could even imagine that with expansions to existentials, some people may even want to pass type-erased existentials to functions. Even if unboxed, requiring that a protocol be conformed to only be structs or enums seems to defeat the whole point of having protocols and directly goes against things like var x: Int { get } instead of let or a computed var.

1 Like

Speaking of reference types. There is no AnyFunction super-type that could be used as a constraint. I want to build a helper type or function to reduce the amount of manual boilerplate I need, but it should only operate on T: @convention(c) AnyFunction which is impossible to express.

In the past I always wanted things like AnyStruct, AnyEnum and AnyValue to combine value type constraints. AnyValue here is not a constraint to express value semantics!!

The argument against it was always the same it's not useful. :unamused:

If possible it'd be great to drop Any prefix now as we have the any keyword and steer towards any Object and co.

1 Like

A couple things. First, it would be great if you could show some examples of the code you’d like to write that cares about the difference between structs or enums. What’s a motivating use case for such a feature?

Also,

Sendable is not just a name for the hypothetical Value protocol that has been discussed. Notably, actors are freely Sendable but can have shared mutable state.

2 Likes

Yes, but it is their responsibility for doing wrong things. There are plenty of other possibilities where people can use language incorrectly.

Not a protocol be conformed, I'm talking about generic constraints only.

I think it's time to admit that this request is not always the right one for these type of discussions. The problem with it is that a developer who knows the language and its limitations well won't run very often into the situation "I want to build X, oh it's not even possible", but rather instinctively proceed with "I know X isn't possible, let's think different in the first place". My recent non value type related use case for something like AnyFunction was that I want to have a statically safer constraint where every generic T is known to be of some concrete @convetion(c) AnyFunction type. Right now I cannot do this and have to write same unsafe casting boilerplate code multiple times when dealing with Imp.

1 Like

For example AnalyticsParam for mobile app analytics:

open class BaseAnalyticsEvent<Param> { // base class provided by library
	// most functionality is implemented here
}

// Client implementation:

public enum AnalyticsEventParam: Equatable {  
  case amount(_ amount: Double)
  case bannerId(_ bannerId: UInt64)
  case clientId(_ clientId: UInt64)
  case discount(_ discount: Double)
  case orderId(_ orderId: UInt64)
  case price(_ price: Double)
  case rating(_ rating: Double)
  ...
}

public final class AnalyticsEvent: BaseAnalyticsEvent<AnalyticsEventParam> {} 

As a library author, I may want AnalyticsEventParam implementations were enums due to enum switching is exhaustive.

I don't propose this like a feature. What I'm talking about is that we can make precise generic constraints for reference types, but we can not do it for value types. I want to gather people thoughts about it.

1 Like

One concrete example for AnyValue would be a dynamic check that does not always return true on Darwin platforms where literally everything can become AnyObject. The only 'workaround' I know is to go the metatype route and check if type(of: anything) is AnyObject.Type which isn't obvious for the majority of people at all.

Again, I'm not trying to be disrespectful or something, I'm just a bit tired of being required to provide concrete examples of non-possible code samples for features that do not yet even exist. That's a non-trivial thing to do. It's exactly those missing symmetrical features that limit the creativity of developers.

5 Likes

There is one particular use case where I would find it pretty useful to constrain a type to be a value type. There’s a library called FirebladeECS which implements an entity component system for swift. A major limitation is that components are represented by reference types, which makes safe concurrency quite a pain, and hurts memory efficiency because the components can’t be packed into an array (their references can but their contents is just stored on the heap wherever).

I was planning on attempting to refactor the library to require value type components instead of reference types, and it’d be nice to make the new component requirement into an actual compile time requirement instead of a runtime error thrown when an invalid component is detected using metatype stuff. Because the safety guarantees that the new API would provide would be completely broken by the use of reference types. This directly highlights the asymmetry in the language, because in both cases the ECS API breaks if components are the wrong type of type, however it is only possible to express this nicely when requiring reference types.

Parts of that might not make sense, it’s pretty late at night here.

EDIT: here’s a link to FirebladeECS if anyone wants to take a look

1 Like

Another examples:

public final class WeakRefBox<T: AnyObject> {
  weak var ref: T? // it is guaranteed to be a reference type
}

public final class ValBox<T: AnyValue> { // and this box should only be used with value types
  let ref: T
}
1 Like
Collapsing meta-discussion

I don’t think it’s very feasible to answer the question “is this feature a good idea?” without meaningful discussion about “what could you do with this feature that you can’t do (or would be difficult to do) today?”

I agree that many users probably follow the thought path where they never get to the point of asking “I know I can’t do this, but what if the language were different?” However, once we’re at the point where someone has raised the question of whether we should change the language, I think asking for motivating use cases is a highly relevant and necessary question—the second section of an evolution proposal is Motivation after all.

That’s not to say that every proposal has to revolutionize the type system or present some major new capability. But I think the motivation must at least be non-circular, that is, not just a restatement of the feature itself. To be concrete, the motivation for "should we have struct/enum constraints?" can't just be "it would enable us to prevent non struct/enum types from being passed." That's just another way of describing the feature, not motivation for the feature.

No disrespect taken! I don't mean to suggest it's trivial, nor do I think it's necessarily the responsibility of someone opening a discussion thread to provide such an example. It's fine to say "it occurred to me that I can't do this, I'm wondering if we should make it possible." I myself have opened many such threads!

That said, it should be the community's goal as a whole, to answer the question "would such a feature be useful?" The question is an invitation to creativity, not meant to stifle it. If no one is able to even hypothesize about a situation where a hypothetical feature would be useful, I think it's unlikely to make a compelling addition to the language.


To specifically talk about precise generic constraints for reference types vs. value types, I think we should think about specific capabilities of the constraints you've pointed out. Apple-platform AnyObject conversion notwithstanding (I agree that's a not-ideal pitfall), there are semantically meaningful things that you can do with AnyObject such as check for reference identity with === and hold weak references.

Actor constraints (and specifically refining the Actor protocol with another protocol) allows actors to conform without having to mark the requirement witnesses as nonisolated:

protocol P: Actor {
    var x: Int { get }
}

protocol Q {
    var y: Int { get }
}

actor A: P {
    var x: Int { 0 }
    nonisolated var y: Int { 1 } // y has to be nonisolated!
}

func f<T: P>(t: T) async -> Int {
    await t.x
}

The GlobalActor protocol provides direct access to the shared actor instance and was proposed alongside the future direction that you could one day make types generic over the global actor that they are synchronized on:

@GA
struct S<GA: GlobalActor> { ... }

Precise class constraints like T: UIView are only meaningful for classes because there are multiple types T which might satisfy the constraint, but since value types don't support inheritance, T: SomeStruct is no different than T == SomeStruct which is the same as just accepting SomeStruct directly.


What would you be doing within BaseAnalyticsEvent that would somehow rely on this exhaustivity? Using switch is always exhaustive—the only difference with enums is that you get exhaustivity checking (i.e., you don't have to use default) by naming a finite set of cases, but that's only possible when you know the concrete enum type you're switching over. What does the code look like that's relying on this 'exhaustivity' property of a hypothetical enum-constrained type? (Also worth noting that non-frozen enums in resilient libraries are non-exhaustive, and there's been discussions about extending this capability to non-resilient libraries as well).


I think the case for a general AnyValue constraint is probably stronger that more fine-grained struct or enum constraints, though it's also worth noting that it would be perfectly valid for types to satisfy both AnyObject and AnyValue constraints (e.g., any immutable class type), and it's also the case that top-level structs and enums aren't always valid AnyValues (e.g. all the pointer types, anything that wraps a class without carefully maintaining value semantics like Array does).

6 Likes

To be clear I didn't suggest wrapping enums in structs was a wrong thing to do. I think it's very valid in some cases. Enums cannot control access to specific cases, which can allow initialization of cases that should be internal. Also, enums in non-library-evolution mode have exhaustive checking and do not require a default case, which can be undesired for rapidly changing libraries. I don't know if you meant that wrapping enums in structs is the wrong thing to do for your API. So I guess it comes down to what @Jumhyn asked: why does your use case require exhaustive checking?

3 Likes

BaseAnalyticsEvent will not and can not rely on exhaustivity because it do all the work with abstract type.
But such library design force library users to work with enums and make their own code exhaustive.
@filip-sakel

For more context: there are other protocols which library users have to implement, such as AnalyticsEventSender and AnalyticsEventProxySender. It is important to keep code exhaustive in these implementations. There are four main tasks to be accurately done when new Params use appear: map param key, map param value, validate key, validate value.

switch is always exhaustive, enum or not. And if by exhaustive you mean "forces users to spell out every concrete case," that's also not achieved by requiring an enum type. The following code works for both enums and structs:

var paramKey: String {
  switch x {
  case .value1:
    return "value1"
  case .value2:
    return "value2"
  default:
    return "something else"
  }
}

What is it about BaseAnalticsEvent requiring x to be of enum type that would improve this API?

The exhaustivity on the protocol requirement side is achieved simply by defining the var paramKey: String requirement: every value of every conforming type must be able to produce a valid paramKey value.

2 Likes

Yes, thanks, that is exactly what I wanted to say.

It will not improve API, but will make using of a struct, class or OptionSet impossible and prevent wrong implementation

In this example we have a default branch. If a new variant will appear, the compiler will not handle all places in code where we should make changes. @filip-sakel

No, conforming type is not able to provide such paramKey. AnalyticsEventParam is maped to different key strings for different analytics SDK's. What is a "valid" paramKey depends on the target SDK. So we can not make a computed var key: String.

Right, but we can just as easily write this code for an enum as a struct—the compiler doesn't force enums to spell out every possible case, and even if you're diligent about never using a default case for enums (I've worked in codebases where this is a style rule), you can't allow any associated values or you've just moved the problem one level deeper.

ETA: thank you for going back and forth on this with me, I don't intend to just be poking holes here. I'm just trying to get at what the fundamental underlying property you're reaching for with the "must be an enum" constraint, and see if a) such a constraint really would satisfy the underlying property and b) whether there are other approaches for achieving this in the language already, or if there might be other ways to accomplish your goals using different future features.

1 Like

Thanks for sharing this example, we can take great benefits in such domain.
One question: are there cases, when something like immutable classes should or can be used? Something like Sendable & AnyObject

there several other situations where it can be useful. Analytics is only one of them.

No, I think that there are no other approaches at least for now because

  1. AnalyticsEventParam can only be made as an enum with associated value. It represents a strongly-Typed key-value pair. The key here is the name of enum case itself. Every key is binded to a value of one concrete type. bannerId must always be an UInt64 value, and price must always be a Double. For each analytics SDK a string representation of key will be made from AnalyticsEventParam instance. For example, string key representation for case clientId(_ clientId: UInt64) will be "clientId" for one sdk and "userIdentifier" for another.
    Can you imagine how can it be expressed without enum? (using class or struct). Using of Dictionary can not achive goals of the library. @filip-sakel

Yes, we can. But using structs we will be always forced to write a default case. Using enums we are able to write a default case, but at least we have an ability to write fully exhaustive switch, I mean spell out all cases.

So as a library author I want to design AnalyticsEvent in such way that only enum can be used as param.
Of course I have added all of this to documentation and provide examples. But I can't express these ideas at a type system level.

I agree, I’ve never found a situation that needs a distinction between structs and enums in an API. Enums mostly just act like special structs and there’s not much that an api can do with a user provided enum that uses those special properties such as exhaustive switching.

Not in this situation, because the API would pass the component as inout in some cases and as a copy in other cases so that the data can be kept without a lock needing to be held for as long as the data. Classes are also worse for memory efficiency and cache misses probably (although cache misses probably don’t matter a whole lot in this situation). Sendable classes usually use actor stuff too, which is slower than I’d like for the ECS.