Variadic Generics

The problem with treating them as Collection or more accurately RandomAccessCollection is that the Iterator would need to have some sort of annotation of the return type of next that permits the heterogeneous type signature that may be applied. Consider a pack of Numeric things: that could be Int, Int32, Int8 and so on. The iterator would need to have some way of addressing that return type to be different per iteration call. Maybe some types might work?

It would be reasonable however to still access a pack in an indexed fashion. I have distinct need for this behavior for similar APIs to zip.

One could easily conceive of count being implemented as:

func count<T...>(_ items: T...) -> Int { 
  var count = 0
  for _ in items { count += 1 }
  return count
}

But more complex algorithms need some more distinct access: for example making a combinatoric of states:

Lets say you have some types that are operations:

protocol Operation { 
  associatedtype Output
  func run() async -> Output
}

Then you want to pass a variadic of operations into a structure and for each call invoke the output resultant of that operation that happened next out of the group. This means there are a few different states associated with that - they all could be idle, the first could be actively running in a task and the rest could be idle, the second could be actively running and the others would be idle and so on, and then the final state is terminal.

Given a non variadic situation I would write this as for two items as an enum as such:

enum State {
  case allIdle(Operation1, Operation2)
  case firstPending(Task<Operation1.Output, Never>, Operation2)
  case secondPending(Operation1, Task<Operation2.Output, Never>)
  case terminal
}

The proposal as such does not seem to address this type of usage. Indexable packs might be able to allow for this type of affordance (perhaps with a downside of some states that are unreachable).

In that same vein of "higher kinded type" variadics - if the example of zip was to store elements for asynchronous processing of a temporary; would there be a way to map types across?

Say I needed to store the iterators and some values to compose the resultant. How can I map the type of the pack of iterators to a pack of corresponding Iterator.Element?

From what I can tell this does not allow for that - which seems like a missing part that still poses me reaching for .gyb files.

1 Like

You might be right, but I thought that when using Iterator, forEach and the like you only get a view on items based on the constraint, e.g. if you have

struct SomeStruct<Ns
> where Ns: Numeric {
  let numbers: Ns
}

let v: SomeStruct<Int, Double, UInt32> = 


then iterating over v.numbers will give you instances of Numeric, but a “direct access” (with some unspecified syntax) will give you the exact type.

If that was not the case, how would I even write the body of the iteration to use the specific type of the “current item” in a more interesting way than generically? At the moment (I just woke up) I can’t imagine :thinking:

The pitch has this example:

struct X<U...> { }

struct AmbiguityWithFirstMeaning<T...> {
  struct Inner<U...> {
    typealias VariadicFn<V, R> = (V...) -> R
    typealias A = X<VariadicFn<T, U>...>
  }
}

and it resolves that A should be expanded pairwise, e.g.:

Ambiguity<String, Character>.Inner<Float, Double>.A would be equivalent to X<(String, Character) -> Float, (String, Character) -> Double> .

It seems then that A imposes an equal-arity constraint between T... and U... on Inner, which feels disconcertingly non-local. It’s also strange that such a powerful constraint can only be expressed implicitly.

How would this constraint be communicated in a generated interface, if Inner was public but A private? Could A be declared in an extension and thus retroactively constrain Inner?

2 Likes

I believe that the current limit should be stated in this proposal or at least declare that there is an implicit sensible upper limit. Idk 128? The advantage is that we can always lift this limitation but I don’t think we can add it later.

I imagine there is no way to enforce the limit at compile time so this is going to be a runtime trap? Perhaps we leave Packs as a special non-array and worry about fix length array in another push for performance switch.

I know that I'm not really contributing to the conversation, but this addition would be awesome for the Sequential API of S4TF and DL4S. There would be no more need to codegen and limit the size of result builders.

This is quite interesting, though I have a few questions about whether this could be generalized further (or designed with the expectation of potentially doing so in the future).

Rather than introducing an entirely novel “pack” kind, would it be possible to simply empower tuples to fill that role? I recognize this would be a significant increase in scope, but it seems like it’d be a lot easier to understand if you could work with them like other types[1].

Protocol conformance could be implemented (albeit inefficiently) as a MutableCollection/RandomAccessCollection of existentials, such that a variadic generic could be handled like any type conforming to MutableCollection & RandomAccessCollection where Element == Any, with constraints being duly expressed on Element. This would not be a normal implementation of those protocols, obviously, but that’s definitely more flexible than explicitly implementing a subset of Sequence.

This would not be able to replicate the proposed “magic map”, but it would work for most other things.


As for explicit arity constraints, I feel this would be best addressed by finally implementing generic value parameters. It is not difficult to think of other applications of this (CollectionOfOne, Int64, pretty much every type whose name includes a number), and that could massively increase the power of the language. Of course, a complete implementation of that would almost certainly require compile-time literal parsing, which would require compile-time evaluation, so that’s not exactly in the near future.

Failing that, we could continue using the same tricks those types use, such as constant type properties.

func printArity<T...>(of elements: T...) where T == Any {
  // Implicit `Any` would break current code, and using `Any`
  // shouldn't be encouraged anyway
  //
  // T... is an alias for a tuple type
  // Tuple types, aliased or not, conform to `MutableCollection`
  // and `RandomAccessCollection`.

  // Prints arity (in the absence of named generic parameters
  // and preexisting extensions of structural types, no chance of conflict)
  //
  // Please let this be `UInt`, there's no compatibility issue and
  // I'm personally sick of unsigned signed integers
  print(T....count)

  // Prints 2, to demonstrate lack of novel kind
  print((T, T).count)
}

printArity(42, "Testing", [1, 2, 3], (true, 5.5))
// Prints:
//  4
//  2

This could even be expressed by introducing a special Tuple protocol that the language explicitly bans novel conformances to (like StringProtocol), inheriting protocols like RandomAccessCollection[2] and (in lieu of generic value parameters) requiring type properties like count. All tuple types would implicitly conform to it, obviously, making it potentially useful for constraints or extensions.

Heterogeneous tuple types would have an Element of Any, because I'm pretty sure anything stricter would be impossible.

To resolve ambiguity, I propose following the example of other languages and using a splat operator: similar to existing pointer and inout notation[3], we could use & for this:

func printArity<T...>(of elements: T...) where T == LosslessStringConvertible {
  // T....Element == LosslessStringConvertible
  print(T....count)
}
printArity((true, 2, "three")) // 1
printArity(&(true, 2, "three")) // 3

Empowering tuples in this manner could also allow programmers to disallow variadic semantics through alternate construction, while accepting the same input.

func printArity<T: Tuple>(of elements: T) where T.Element: Equatable {
  // Also note we don't need to use existentials if we don’t want to
  print(T.count)
}
// ❌
printArity(1, 2, 3) // Only one parameter
// ❌
printArity(&(1, 2, 3)) // Not variadic
// ❌
printArity((1, "two", 3)) // These need to be homogenous this time
// ❌
printArity(1) // Not a tuple
// ✅
printArity((1, 2, 3)) // 3

  1. This is presumably why the pitch has this wrapper:

    ↩
  2. And conditionally conforming to protocols like Hashable, of course, which is currently something that you just have to know happens. It wouldn't necessarily be less magical under this proposal (and there'd still be conformance for heterogeneous tuples of Hashable types that can't even be expressed in Swift), but it would arguably be more discoverable. ↩

  3. Would an inout tuple make sense? If so, we'd probably need to choose something else. * is an obvious candidate, in light of the aforementioned other languages. ↩

1 Like

The big advantage of variadic generics as a concept is that the types aren't erased. What practical difference would there be between your tuple version of printArity and this version that I can write today?

func printArity(of elements: Any...) {
    print(elements.count)
}

The only difference I can see is the ability to splat the variadic parameter into another variadic function. That's something we need for regular variadics regardless.

Most of those advantages don't actually make sense, as others have noted. It is fundamentally impossible to have an arbitrary number of heterogeneous types, as you'd have to specify said arbitrary number of heterogeneous types in the function signature. And how would you actually do anything with that?

At best, what you end up with are existentials, which entail enough tradeoffs to warrant being explicit.

Have you ever used parameter packs in c++ before (or similar concepts in other languages)? You can do lots of very useful things with them and still parameter packs in c++ consist of an arbitrary number of heterogeneous types...

1 Like

I have not, though I've used unpacking in Python and that informed my assertion. Existentials can work, sometimes, but they come with serious issues that make them suitable only as a last resort.

As far as I know, and I just checked to confirm that includes C++’s parameter packs, all such approaches involve destroying type information at the interface boundary. In Swift, that’s what Any and other existentials do.

If I haven't completely misunderstood the proposal, there is no 'destruction' of type information taking place here. It just works like a generic function would work.

Take a normal generic function like this one:

func foo<T>(_ t: T) {
    print(T.self, t)
}

If you call this function, e.g. like this:

foo(5) // prints "Int 5"
foo("Hello") // prints "String Hello"

It behaves like you actually wrote the following:

func foo_Int(_ t: Int) {
    print(Int.self, t)
}

func foo_String(_ t: String) {
    print(String.self, t)
}

foo_Int(5) // prints "Int 5"
foo_String("Hello") // prints "String Hello"

Now what is proposed here is essentially the same thing, only for a 'collection' of an arbitrary number of heterogenous types you need some special constructs in the language that we do not currently have.

The same function as above but with variations generics could look somewhat like this:

func foo<T...>(_ ts: T...) {
    for (t: T) in ts { // every 't' in this loop potentially has a different, but known type
        print(T.self, t)
    }
}

This works exactly like the previous example worked:

foo(5) // prints "Int 5"
foo(5, "Hello") // prints "Int 5" and "String Hello"
foo(5, "Hello", true) // prints "Int 5", "String Hello" and "Bool true"
foo() // does nothing

Exactly like in the previous example we can reason about what this function does by imagining the specializations (even if the compiler maybe doesn't produce them but does something different under the hood):

foo_Int(_ t_0: Int) {
    print(Int.self, t_0)
}

foo_Int_String(_ t_0: Int, _ t_1: String) {
    print(Int.self, t_0)
    print(String.self, t_1)
}

foo_Int_String_Bool(_ t_0: Int, _ t_1: String, _ t_2: Bool) {
    print(Int.self, t_0)
    print(String.self, t_1)
    print(Bool.self, t_2)
}

foo_() {}

foo_Int(5) // prints "Int 5"
foo_Int_String(5, "Hello") // prints "Int 5" and "String Hello"
foo_Int_String_Bool(5, "Hello", true) // prints "Int 5", "String Hello" and "Bool true"
foo_() // does nothing

As you can see, the types inside the parameter packs, which consist of an arbitrary amount of heterogeneous types, do not get 'destroyed' any more than they are being 'destroyed' in a normal generic function.

1 Like

This proposal is sadly limited to a very few number of operations that can be done with a heterogenous pack, but what it does allow are iterating over the contents of a pack and converting the pack to a tuple. That's enough to replace the hand coded versions of zip and the equatable and comparable conformances of tuples with single implementatons that handles any arity instead of the current limitation of 6. It could also be used to conditionally conform tuples to codable.

With a few other features that aren't proposed here (arbitrary arity constraints, generic values, hygenic macros) we could write things like fixed sized arrays, synthesized protocol conformances, etc. in pure Swift.

While I do feel that the lack of arity constraints, same type constraints, and arbitrary access to any member of the type pack leaves this proposal as only half of a proper implementation of variadic generics, it's definitely a step in the right direction.

Edit: And C++ parameter packs definitely don't erase any information.

Funny you chose Swift.print(_:separator:terminator:) as an example. Guess what it does?

func print(_ items: Any..., separator: String = " ", terminator: String = "\n”) { [
] }

One way or another, this would only work when Any would. So it really should be spelled out.

So you are of the opinion that the type of T is being erased here?

Correct. It’s being erased at the call site of print.

He didn't ask about the call to print which takes an Any... and therefore definitely erases. He's asking about the call to foo itself.

Edit: To rephrase his question, if I called foo with an argument of 100, do you think it should it print Any 100 or would it print Int 100.

Does it matter when the erasure happens?

Changing tack for a moment, could you provide an example of something that does work for heterogeneous types? Even the pitch has broken examples:

Fine. I use a different example then:

func foo<T>(_ t: T) -> T { t }

let bar = foo(5) // bar has type 'Int'
let baz = foo("Hello") // baz has type 'String'

works as if you wrote

func foo_Int(_ t: Int) -> Int { t }
func foo_String(_ t: String) -> String { t }

let bar = foo_Int(5) // bar has type 'Int'
let baz = foo_String("Hello") // baz has type 'String'

Now the same with variadic generics:

func foo<T...>(_ ts: T...) -> (T...) { ts }

let bar = foo(5) // bar has type 'Int'
let baz = foo(5, "Hello") // baz has type '(Int, String)'
let baz2 = foo(5, "Hello", true) // baz2 has type '(Int, String, Bool)'
let baz3 = foo() // baz3 has type '()' or 'Void'

works as if you wrote

foo_Int(_ t_0: Int) -> Int { t_0 }

foo_Int_String(_ t_0: Int, _ t_1: String) -> (Int, String) { (t_0, t_1) }

foo_Int_String_Bool(_ t_0: Int, _ t_1: String, _ t_2: Bool) -> (Int, String, Bool) { (t_0, t_1, t_2) }

foo_() -> () { () }


let bar = foo_Int(5) // bar has type 'Int'
let baz = foo_Int_String(5, "Hello") // baz has type '(Int, String)'
let baz2 = foo_Int_String_Bool(5, "Hello", true) // baz2 has type '(Int, String, Bool)'
let baz3 = foo_() // baz3 has type '()' or 'Void'

Are there any types erased here?

Can you do anything that wouldn’t work if types were erased? The identity function doesn’t qualify: the implementation is tantamount to just passing a single tuple constructed at the call site.

What can you do with t in this function:

func foo<T>(_ t: T) {
    // show me some things you can do with 't' here...
}

?