Ideas to pass "empty" generic constraints?

I'm experimenting with some strategies to express an "empty" generic constraint. FWIW my focus is mostly on generic types for now… but I might also use this advice to think creatively about generic functions.

I have some code written on parameter packs. Here is a variadic type:

struct Repeater<each T> {
  init(_: repeat each T) {
    
  }
}

let _ = Repeater(1, 2, 3, "Repeater!")
let _ = Repeater("Repeater!")
let _ = Repeater()

What's cool about Repeater is that the semantics at the call sites are clean and easy for expressing multiple constraints, one constraint, or no constraints.

I'm looking for something similarly "clean" for "classic" generic types. Here is a generic type across one value, which might be nil:

struct S1<T> {
  init(_: T? = nil) {
    
  }
}

let _ = S1("S1")
let _ = S1<Never>()

This mostly works… except for the explicit Never that needs to be there to keep the compiler happy.

Here is another approach:

struct S2<T> {
  init(_: T) {
    
  }
  
  init() where T == Never {
    
  }
}

let _ = S2("S2")
let _ = S2()

Here we require the value passed to init to not be nil… but we define another constructor that takes no value and constraints T to Never. This enables me to call S2() without an explicit Never.

I feel like I'm conceptually searching for something in a similar spirit to the support for "empty" packs… but for classic generic types. Is there anything in the 6.0 compiler that can support this? Are there any tricks to improve the semantics of S1 or S2 to indicate that callers can construct instances "without any T"?

Is there any utility for what you're asking for outside of static members?

typealias StaticMembers = any Any

extension S<StaticMembers> {
  init() { }

  static func function() { }
}
S2()
S.function()

Have you considered using an existential type? (any P)? has the semantics you're after, where you either pass a value of some concrete type, or nil.

2 Likes

Hmm… This sounds like one possible idea. I'm not totally opposed to existential types… but after this type would be constructed there's nothing happening internally that "needs" this type to be erased. I'm actually using the type T to constrain more implementation logic after construction. This could get me the semantics at the call site I was looking for… but AFAIK I would also be paying a small performance penalty for the existential?

The idea you use in S2 is perfectly fine for me. The user of S2 should not be worried about the actual generic parameter, only the interface you provided matters.

Personally, I would use Void instead of Never as the default type parameter though.

1 Like

I doubt if the approach is S2 is useful in practice. Let's suppose the type parameter is used as the type of a stored property. That stored property will be accessed in most methods of S2 (otherwise S2 doesn't need to be a generic type). That means all these methods will need to check if T is Never and handle it separately. This will be tedious and hurt code readability a lot.

In my understanding generic type is intended to express "any type", it's not suitable to express "a type that may or may not exist". Variadic generic is different because IMO it's like a container. A container with nothing is still a container.

1 Like

The problem I run into there is this type T might have constraints:

struct S2<T> {
  init(_: T) {
    
  }
  
  init() where T == Void {
    
  }
}

struct S3<T: Equatable> {
  init(_: T) {
    
  }
  
  init() where T == Void {
  `- error: no type for 'T' can satisfy both 'T == ()' and 'T : Equatable'
    
  }
}

Constraining my default type to Never compiles correctly without an error.

Are there more benefits to using Void here that would be important to know about?

This type itself is kind of a funny type… it's an attempt to "backport" a subset of functionality from a type that is variadic generic to platforms that do not support variadic types (like macOS 13). So the semantics of the variadic generic do support an "optional" T that could be "missing-slash-empty".

Now I understand why you choose Never. That totally makes sense.

The reason why I considered Void in the first place is just this: if this T appears in other interfaces, the specialized implementation for T == Void becomes more natural, because we can just create an instance of Void, which is not possible for Never.

I took this idea from some first party libraries. For example PassthroughSubject, some of its APIs has an overload for T == Void.

1 Like