A vision for variadic generics in Swift

My brain has lost a lot of the context from the single-element tuple discussion, so forgive me if I’m retreading ground.

My understanding is that in shipping Swift, there is a strict division between scalars and tuples, and that () (aka Void) is a scalar, not a tuple. It was determined that type packs require admitting single-element tuples, but does it necessarily follow that zero-element tuples must exist, or that () is an (the?) instance of a zero-element tuple?

I also don’t recall a resolution to the question of whether single-element tuples are synonymous with scalars. Void has to remain a scalar, so if () is the spelling of a zero-element tuple, what is the definition of Void? If scalars are one-element tuples, perhaps the answer is typealias Void = (_: ())? But given that “Packs are also flat lists, so packs cannot contain other packs”, how would one describe the shape of Void in a same-type conformance?

I don’t actually know what I expect in this case.

This does not match my understanding. My understanding is that Void / () is an empty tuple. I'm not sure what you mean by "scalar" here, but there is a distinction between nominal and structural types. Void and other tuple types are structural (along with function types and metatypes), whereas structs, enums, classes, and actors are nominal types. We've defined "scalar" in the variadic generics terminology as an individual type, e.g. Int, (), or a non-pack type parameter, which is contrasted with a type parameter pack which has a length.

2 Likes

Yeah, and in particular tuples (both empty and non-empty) are scalar types (but they can contain type packs, just like a function type's parameter list can contain a type pack)

Eg, if T := {Int, String} and U := {}, then (T..., U...) and (U..., T...) are both equal to (Int, String).

1 Like

OK, sorry for reusing terminology. This post from @John_McCall is what I was keying off:

I guess since John calls them “element values”, “element types” might be a good term for non-tuple types. (It may be the case that all non-tuple types are nominal types, but since it’s possible to invent new non-nominal, non-tuple types in the future, I’m resisting using that term.)

That makes (_: Void) a tuple whose single element is the empty tuple, which yields Peano arithmetic: 0 ≡ (), 1 ≡ (_: ()), etc.

The thing I’m trying to figure out is whether that means it’s possible to match the () inside (_: ()), and whether it’s therefore possible to match a type variable to the empty list within (), because that’s where the problems emerge from. It sounds like as long as (_: T) is distinct from T, this isn’t possible, because a pack can unify with the inside of (_: T), but not with T itself.

Ah, I see. I think John's terminology isn't actually useful here because the tuple vs non-tuple distinction is not actually an important one in variadic generics.

The unification operation you're thinking of is only defined for types, and packs are not types, so a tuple can only unify with another tuple and not with another pack.

However, you can unify a one-element tuple that contains a type parameter pack (T...) with an empty tuple (), which will bind T to the empty type pack {}.

Similarly, unifying (T...) with the one-element tuple type (_: Int) binds T to the type pack {Int}.

Unifying (T...) with the Int type fails, because the right hand side is not a tuple type.

By this logic, unifying (T...) with (_: ()) will bind T to the type pack {()} which contains a single element, the empty tuple type.

Anywhere I had a tuple type above, I could have used a function type instead. So for instance, unifying (T...) -> () with (Int, String) -> () will bind T to {Int, String}.

2 Likes

Would it be possible for a type to have two variadic type parameters? E.g.

struct Example<(First...), (Second...)> {}
Example<(Void, Bool, Int), (Character, String)>()

I've added a note about naming convention to the document. I'll paste it here for ease of quoting for further questions, thoughts, and other feedback. Please let me know what you think!

A note on naming convention

In code, programmers naturally use plural names for variables representing lists. However, in this design for variadic generics, pack names can only appear in repetition patterns where the type or expression represents an individual type or value that is repeated under expansion. The recommended naming convention is to use singular names for parameter packs, and plural names only in argument labels:

struct List<Element...> {
  let element: Element...
  init(elements element: Element...)
}

List(elements: 1, "hello", true)

More broadly, packs are fundamentally different from first-class types and values in Swift. Packs themselves are not types or values; they are a special kind of entity that allow you to write one piece of code that is repeated for N individual types or values. For example, consider the element stored property pack in the List type:

struct List<Element...> {
  let element: Element...
}

The way to think about the property pack let element: Element... is "a property called element with type Element, repeated N times". When List is initialized with 3 concrete arguments, e.g. List<Int, String, Bool>, the stored property pack expands into 3 respective stored properties of type Int, String, and Bool. You might conceptualize List specialization for {Int, String, Bool} like this:

struct List<Int, String, Bool> {
  let element.0: Int
  let element.1: String
  let element.2: Bool
}

The singular nature of pack names becomes more evident with more sophisticated repetition patterns. Consider the following withOptionalElements method, which returns a new list containing optional elements:

extension List {
  func withOptionalElements() -> List<Optional<Element>...> {
    return List(elements: Optional(element)...)
  }
}

In the return type, the repetition pattern Optional<Element>... means there is an optional type for each individual element in the parameter pack. When this method is called on List<Int, String, Bool>, the pattern is repeated once for each individual type in the list, replacing the reference to Element with that individual type, resulting in Optional<Int>, Optional<String>, Optional<Bool>.

The singular naming convention encourages this model of thinking about parameter packs.

2 Likes

This isn’t planned, at least initially, because with a purely positional syntax for generic arguments there’s no way to disambiguate which concrete types end up in which pack. Some possibilities are to introduce argument labels for generic parameters, or the parenthetical notation in your example, or even to disallow writing the variadic type directly and force the user to rely on inference (but that seems like the worst option).

We’ll address these in the “Future Directions” section of the variadic types pitch.

2 Likes

A workaround for this in the meantime is to use outer generic types as ad-hoc "argument labels" for type arguments:

struct Zip<First...> {
  struct With<Second...> {
    typealias Value = ((First, Second)...)
  }
}

Zip<Int, String>.With<Bool, Int>.Value // ((Int, Bool), (String, Int))
11 Likes

Wow, that’s pretty.

Awesome, this whole explanation clarified my understanding and especially in light of the fact this is an advanced feature anyway I find your justification entirely satisfying.

If I’ve understood correctly, the reality of the reasoning matches the essence of what I was getting at in my question quoted below, although with your clarifications I see that my description could not be said to be correct as written.

1 Like

Thank you for the detailed explanation! Maybe I will just have to let it sink in, but initially I just don’t have the same intuition as you, because to me your hypothetical expanded version of the List struct would read more naturally as:

struct List<Int, String, Bool> {
  let elements.0: Int
  let elements.1: String
  let elements.2: Bool
}

It does feel like a closer question when it comes to more complex pack expansions, but I don’t find it too unnatural to read, e.g.:

return List(elements: Optional(elements)...)

In my mental model, when elements is expanded into a comma-separated list of, well, elements, each element takes along its own copy of the surrounding structure in which it sits. So Optional(elements), when treated with the expansion operator, naturally becomes Optional(elements.1), Optional(elements.2), and so on.

As I mentioned, another spelling that feels natural on first thinking of it would be element*, so we would have

return List(elements: Optional(element*)...)

This has a bit of the best of both worlds, in that the word itself is singular as is each element type, while the asterisk marks it as different from a normal value name with a particular connotation of plurality. That said, I imagine such a spelling might raise other issues including source compatibility and maybe it does not stand up to serious scrutiny.

3 Likes

FWIW, in my mental model, that's what the pack expansion operator ... does. It isn't element that's plural, it's Optional(element). Under substitution, the name element is replaced with an individual element from the pack.

Another FWIW, I didn't really internalize any of this until I wrote out some code examples and experimented with both naming conventions, so I encourage you to do the same and see how that evolves your understanding! That's not to say that you'll think about the code in the same way that I do - everyone internalizes concepts differently. In any case, I think it's a helpful exercise for folks participating in this discussion :slight_smile:

5 Likes

Thank you for writing this. I found myself nodding along when reading it, as you’ve covered the (large!) space of features and capabilities that I’d hope for variadic generics, and it fits together well.

The area I’m least convinced of is the use of .element” for getting a pack from a tuple. It's an interesting operation, because it takes a single value and then expands it out into a pack.

For one thing, I think it would help if you tied this bit together explicitly with concrete packs and extensions on tuple types, because it would feel less magical if it had a signature we could write in the language:

extension <Element...> (Element...) {
  var element: Element... { /* it's okay for this implementation to be magic */ }
}

That might also make the behavior of .element on a tuple that contains an element labeled "element" clearer, because we'll need some name-lookup rule deals with extensions on labeled tuple types in the general case.

Also, I was a little surprised that this section doesn't contain an example of forwarding, because I think that's really the big win from getting a pack from a tuple:

struct CapturedArguments<Result, Parameters...> {
  var arguments: (Parameters...)

  func evaluate(with function: (Parameters...) -> Result) -> Result {
    return function(arguments.element...)
  }
}

Forwarding is mentioned earlier, but only in the "this is why packs and tuples are different" section as a reason for making them different.

Now, the moment I see forwarding, scope creep sets in and I would like to also solve the variadic forwarding problem. Can I have .element on an array, for example?

func f(_ args: Int...) { }
func g(_ args: Int...) {
  f(args.element...)  // could this make sense?
}

It's in a sense very different, because the length of the array is a run-time value, so you have a concrete type [Int] that would need to be expanded into a homogeneous parameter pack of runtime-computed length. But I think we can express that notion of a "homogeneous parameter pack of runtime-computed length" already through the generics system:

func gPrime<T...>(_ args: T...) where T == Int { 
  f(args.element...) // okay, I think? expand the homogeneous pack and capture results into array
}

Allowing an array (or any Sequence?) to be turned into a homogeneous parameter pack has a similar feel to implicit opening of existentials, because it takes a run-time-computed value (length for arrays vs. dynamic type for existentials) and turns it into something more static (# of arguments in a pack vs. generic argument of the dynamic type of the existential). For example:

func printAll<T...>(_ args: T...) { }

func printArray<Element>(_ array: [Element]) 
  printAll(array.element...) // # of parameters in the pack "T" depends on length of array!
}

Doug

6 Likes

I can imagine a spelling for this that doesn't require a magic member on tuples, since it is notionally a destructuring binding like we already support on tuples, just variadic:

let (argument...) = arguments // bind elements of tuple `arguments` to value pack `argument`

for arg in argument... { ... } // iterate over pack

function(argument...) // forward pack
4 Likes

It would be nice if there was a way to perform this operation without introducing a temporary binding just for this purpose though.

We've gone back and forth a couple of times on whether a stored property ought to be able to have a pack expansion type T..., rather than a tuple type (T...). Currently we're thinking that this should be allowed.

If stored properties are limited to scalar types, like (T...), then the temporary binding becomes more of a hassle because basically doing anything with a (T...) requires expanding it into a pack first. Perhaps by allowing T... stored properties, we eliminate most of the pain and expansion becomes a less-frequent operation.

4 Likes

It would also be nice for computed properties to be able to yield a pack of values by read or modify in-place without having to move them into a tuple first too.

3 Likes

From Destructuring operation using static shape:

Could

let (first, rest...) = (element...)

be used instead? In a failable pattern such as in a case let, there could be no prior knowledge about the underlying shape of a pack and (first, rest) would actually mean "the pack on the right has two scalar elements". I'd prefer to have the same syntax in failable and non failable assignments.


From Exploring syntax alternatives:

Postfix partial range operator is the only one unrelated to the rest. How unfeasible would be to progressively change the range operator from ... to .. in a major Swift version (allow both overloads in Swift 5, warn on Swift 6, ...)?
Would that be enough to remove the .element ceremony for tuples?


Hopefully, just a matter of placing three dots in the right position? :crossed_fingers:

struct Iterator: IteratorProtocol {
  var iterator: Seq.Iterator...

  mutating func next() -> (Seq.Element...)? {
    guard let element = iterator.next()... else { return nil }
    return (element...)
  }
}

I imagine that runtime pattern matching / pack destructuring would be done using type coercions with as, because that's the syntax we already have, and because there's probably more than the pack-ness that you want to match against. For example:

switch (element...) {
  case let (first, rest) as (First, Rest...):
    // do something with 'first' and 'rest'
  default:
    break
}

This also may be a use case for generalized opaque types for local variables to introduce new generic parameters that can be used for pattern matching.

In my opinion, whether or not value packs are declared with ... after the variable name is orthogonal to the pattern matching syntax; if we decide to use let value... for value packs, we should do it everywhere, including parameter declarations:

func tuplify<T...>(_ t...: T...) { (t...) }

Personally, I find the ... after t to be redundant, and it makes it a little more difficult to think about t: T as the element that is repeated by the ..., but of course that's just the way I think about the code.

Unfortunately it's not just about the postfix range operator .... The partial range operator intentionally uses the same operator as the regular closed range infix operator 1 ... x.count. Then there's also the closely related half-open range operator 1 ..< x.count that intentionally matches the structure of the closed-range operator, just with a different final character to denote the open/closed property of the range. While it might be feasible to change all of these operators to be spelled differently, these are such widely used operators that I don't think it would be worth the tradeoff.

If we're concerned about the existing uses of ... in Swift, I think we should instead consider using a different syntax for variadic generics. I'll admit the * alternative is growing on me. In any case, I'm going to start a dedicated thread to bikeshed the ... syntax for variadic generics so we can figure it out!

No, the operation to access tuple elements as a pack is fundamentally necessary in this design, because otherwise, there would be no way to expand a tuple element-wise in the pattern of a pack expansion. We could choose to instead provide a way to expand a tuple into a local variable pack, and in fact the original draft of this vision document included a "value expansion operation" instead of a direct operation to access tuple elements as a pack, but generalizing tuple types became super annoying because let element = tuple... was the first line of every function that accepted an abstract tuple.

The section of the document comparing tuples and packs explains this in more detail, and provides a few examples of the difference between using a tuple in the pattern of a pack expansion vs using the tuple elements as a pack.

Neat! I didn't think about this, but I agree that guard let and friends should be supported with local variable packs.

1 Like