Pitching The Start of Variadic Generics

I think I really like this notation. Is the T0 and T1 implied from the T or would they be arbitrary placeholders? Would <pack T> where T == <ID, ...>, ID == Equatable be valid?

I would argue that this would be valid, however it should be probably == any/some Equatable or ID: Equatable, but that me nit picking on something else.

I would probably add the following to the pitched design:

  • the T == <…> syntax would be unfolding / unpacking the variadic pack T
  • T[n] could still be valid as <pack T> where T[5] == String would reference the type inside the pack; it does not violate or collide with the idea of static subscripts, because T in that context wouldn‘t be specific type, but rather a name of the variadic pack

Hence I think this could still be valid:

func foo<pack T[n]>(_: T…) 
  where 
  T == <A, B, B, _, Int, …>, 
  A: Equatable, 
  B == String, 
  T[5] == Bool,
  n >= 6 && n <= 10

// notice that `A`, `B`, and `C` were generic types,
// while the 5th type was already explicitly set to `Int`!
// we could allow this, but it‘s debatable.
// furthermore the 4th type used a “placeholder type“ `_`

foo(true, "foo", "bar", 1, 1, false, /* … */) 

The subscript syntax is really nice if you need to express very complex constraints where you know or need to require specific pack size. I personally wouldn‘t scrap that idea.


TLDR;

We could introduce these things in multiple phases:

  1. Introduce just pack T
  2. Introduce the ability to unpack where T == <…>
  3. Introduce access via pack index pack T[n] and sizes where n < 10

Each of the phases would require its own discussion and proposal, and this current thread should probably just focus on #1.

<pack T> where T == <_, T1, ...>, T1 == String

This seems a bit illogical though. On the left it seems that T is a placeholder for an individual type in the pack, not the pack itself, but after where it is treated as the totality. If we want that kind of control I think something like

<pack _, T1, ...> where T1 == String

would make more sense.

I read this as “placeholder type as a pack, followed by T1 which is a string, followed by dots which mean nothing to my brain“. No offense though. If anything this should maybe be written as.

<pack <_, T1, …>> where T1 == String

The unpacking syntax can extract the generic types from the pack and achieve the same as the index syntax. Both do partly overlap and this is expected.

<pack T> where T == <_, R, …>, R == String
<pack T> where T[1] == String // the same as above 

In the above example the index syntax is shorter, but we can find the opposite example as well.

<pack T> where T == <Int, Bool, String, Double, …>
<pack T> where T[0] == Int, T[1] == Bool, T[2] == String, T[3] == Double

It was just an example, I'm not proposing my syntax. I just wanted to point out the inconsistency in what T means. Feel free to put brackets around the argument to pack in my example!

This does not suffer from the inconsistency though:

<pack T> where T[1] == String

And it could be amended with things like T.count == 5. Not sure how to indicate that all individual Ts are equal though. Perhaps just

<pack Double>

I prefer variadic to pack though.

Come to think of it, why not just this?

<variadic T: Int, Bool, String, Double, …>

Again, please don‘t be offended. I‘m just pitching brainstormed syntax and do not claim it to be superior to any other options. ;) I also just provided my 2cents on how I read the syntax in your example.

: always meant sub-typing, inheritence or protocol conformance to me. That‘s why in generics UX threads we kept referring to same type constraint as any Collection<.Element == String>.

I‘ve updated my initial response with a potential fix to your syntax.

Would any T instead of T... make any sense? Such naming follows exact meaning of such generic parameter. Just asking.

Could that last example be rewritten like this?

<pack T> where T == <Int, Bool, String, Double, …>
<pack T> where T[0...3] == (Int, Bool, String, Double)
<pack T> where T[0...3] == <Int, Bool, String, Double> // or this, if the above line looks too tupleish :-)

It's still longer, but not by nearly as much.

Not sure, maybe, but to me personally it reads that T is required to be a tuple.

As I previously stated, I think every part of this should have its own proposal where we can discuss all the syntax options.

I would probably argue that a range like syntax can also allow partial specialization of the types:

// T can still be 0 or infinite
<pack T> where T[3...6] == <Int, Bool, String, Double>

Fair point. Edited

1 Like

We had a similar idea in the end ;)

1 Like

:rofl: We got caught in an editing race! I think I saw the page shift right before I submitted, so I suppose you won :grin:

2 Likes

First off, I want to say that I'm heartened by the overwhelmingly positive response this proposal has received both on this forum and others. As an avid Swift fan myself, I'm super excited to share in the community's enthusiasm for a clean solution to the ideas in this problem space.

It's been a couple of weeks since the draft text went out and I want to respond to a few of the ideas that were kicked around in the mean time. (Notably, I'm going to duck the spelling suggestions!)

Explicit Arity Constraints (@Jumhyn)

func allPresent<Haystacks..., Needles...>(haystacks: Haystacks..., needles: Elements...) -> Bool
  where forEach(Haystack in Haystacks, Needle in Needles, Haystack == Set<Needle>)

I think this is absolutely brilliant. I wish the syntax were closer to that of an actual sequence instead of involving a new kind of meta-operator, but I also recognize that type-level zip is a tricky thing to spell any way you slice it.

Stylistic Conventions for Sequences in APIs (@Jumhyn)

but in others, it's a singular noun describing the entirety of the sequence ( Tail , Init ), and in still others we're using just single letter generic names ( T , U ).

That is my roots showing. I've mixed the C++ convention of pluralizing pack parameter names where I thought emphasizing the pack was most important, but I've introduced the Haskell/Prolog-style singular names where emphasizing the deconstruction of a pack of types was important. You are absolutely correct that nailing down an eventual entry for the style guide is important here. I'll offer one more potential spelling after we get conformances up and running for your consideration:

/// extension<T> (T...): Sequence { /**/ }
func allPresent<Sets..., Elements...>(haystacks: Sets..., needles: Element...) -> Bool
 where Sets.Element == Set<Elements>

With that in mind, I lean towards the C++ end of things - plurals where possible - over the Haskell/Prolog end of things.

Type Composition and Sequences (@ensan-hcl)

These are each interesting examples. I believe that the implicit arity constraint you're after is already covered by the following pitch text:

Arity constraints also propagate along direct references to the element type T in variadic position. So, the following is equivalent:

Because there is a reference to both T and U in (T) -> U, they must have the same arity equivalence class.

Does T... allows zero-length type sequence?

Yes, it must.

However, I think then it is difficult to express some constraints. Consider creating HomogeneousTuple struct....

I'm wary this construction should be allowed at all. Consider a rough but demonstrative related idea in function space:

func foo<T, U>(_ x: T..., y: U) where T == U {}
// error: same-type requirement makes generic parameters 'T' and 'U' equivalent

However, the point you raise is important: Then, how can we constrain a type sequence to a homogeneous set of types? At the very least you can spell this to get 80% of the way there

struct HomogeneousTuple<T...> where T:  FixedWidthInteger & SignedInteger

The remaining 20% will involve relaxing the type concretization restriction. I think that's out of scope for this proposal so I have to leave you in the unsatisfying position of not actually having an answer at the moment!

Archetypes (@jayton)

Could we have some clarification on (or removal of) the word “archetype”?

Yes, the pitch text is drifting into implementation detail territory whenever you see that word... I will bear this in mind for the formal evolution proposal. I could link you to this entry in the lexicon but I honestly think that definition is pretty paltry (for one, "rigid type variables" is the kind of definition text that only a type theorist could love). An archetype is the runtime implementation of a generic value. If you want a much fuller explanation, I very much recommend this LLVM talk instead. Briefly, When you compile a function

func foo<T>(_ x: T) {}

The entrypoint could be roughly translated in C as

void foo(void *x, void *TMetadata)

Where TMetadata is the archetype value. All it is is a big table of the information you need to manipulate the value pointed to by x - so tables of functions that define you how to copy and destroy and move x, etc.

Concretized/Range Arity Constraints (@DevAndArtist et al)

<types(…2) T> // 0 - 2 
<types(2…) T> // 2 - infinity
<types(2 … 2) T> aka. <types(2) T> // 2 
<types T> // 0 - infinity

I believe these are out of scope. But I will note that I eventually want counting quantification to be expressed structurally if we were to build it:

func foo<T, U, V, Rest...>(_ ts: (T, U, V, Rest...)) // guaranteed to have at least 3 elements.

I don't believe upper bounds are useful to express in a general framework, but I could be convinced with a suitable example API.

12 Likes

I don't think it enough. In the following case, you can have nothing that indicates T... and U... have the same length.

func foo<T..., U...>(t: T..., u: U...) { 
    // just use t and u here
    // but you want t and u to be the same length for some reason
}
2 Likes

I think arty constraints should be deferred to another proposal — both forEach(...) and Haystack == Needle; preliminary variadic generics are a big enough feature on their own. If it’s deemed essential for early adopters, I think a hidden attribute would be more suitable: @_packArity(Haystack == Needle).


Overall, I really like this as a first step! I look forward to not having to add SwiftUI Groups everywhere in my prototypes.

What's the timeline looking like for type sequences? Will this pitch be made into a formal proposal soon? Or will there be a pitch #2 à la Actors to refine it some more?

One thing that might be useful as a step towards variadic generics is introducing local generic parameters.

For example, as @xAlien95 pointed out:

func debugPrint<T...>(_ items: T...) 
  where T: CustomDebugStringConvertible
{   
  for (item: T) in items { // <--- Here
    stdout.write(item.debugDescription)
  }
}

The T on the marked line is actually a new, local generic parameter which overrides the name T... in the function declaration. Actually, the T on that line refers to a different type on each iteration of the loop. Using a slightly different syntax, it might look like this:

func debugPrint<T...>(_ items: T...) 
  where T: CustomDebugStringConvertible
{   
  for<ItemType> item in items {
    stdout.write(item.debugDescription)
  }
}

It's a new concept in the language, and is important for variadic generics: each element in the type sequence has a different type, so naturally operations such as iteration or extracting a particular element need to bind new type parameters.

It's also something which would be useful right away - we could use it for unboxing existentials, finally allowing them to be used with generic algorithms. You could imagine something like this syntax being used to unbox an array of heterogeneous collections:

func areEqual(_ leftElements: [Equatable], _ rightElements: [Equatable]) {
  guard leftElements.count == rightElements.count else { 
    return false 
  }
  for<Left, _> (left, _right) in zip(leftElements, rightElements) {
    guard let right = _right as? Left, left == right else {
      return false
    }
  }
  return true
}

areEqual(["string", 42], [true, 99.4]) // false

Which is kind of a silly example, but it demonstrates a level of dynamic typing which we can't even express today. It also shows the overlap the between existentials (and collections of existentials) and variadic generics - they work differently, but ultimately they both deal in abstractions over heterogeneous lists and try to allow lists of types to be used generically.

It would be nice to keep this in mind and design language features which can work for both.

6 Likes

It's a good question - I've had to take a short medical leave of absence for the month of October, which has naturally pushed the timeline for this feature to land back. Now that I'm back, what I anticipate is that the pieces of this work will begin to land behind experimental flags in mainline during November which should give us plenty of time to iron out the bugs before a formal proposal is drawn up and submitted to the community for review.

18 Likes

An interesting application of the variadic generics would be to create protocols such as Equatable or Comparable.
Now you can do this:


infix operator ~==;

protocol SingleTypeEquatable {
    associatedtype T
    
    static func keyPaths() -> [KeyPath<Self, T>]
    
    static func ~==(lhs: Self, rhs: Self) -> Bool
}

extension Int : SingleTypeEquatable {
    typealias T = Int

    static func keyPaths() -> [KeyPath<Int, Int>] {
        return []
    }
    
    static func ~==(lhs: Self, rhs: Self) -> Bool {
        return lhs == rhs
    }
}

extension SingleTypeEquatable {
    static func ~==(lhs: Self, rhs: Self) -> Bool
    where Self.T : SingleEqutable
    {
        for keypath in Self.keyPaths() {
            if (lhs[keyPath: keypath] ~== rhs[keyPath: keypath]) == false {
                return false
            }
        }
        return true
    }
}

struct A : SingleTypeEquatable {
    var a: Int
    var b: Int
    
    static func keyPaths() -> [KeyPath<Self, Int>] {
        return [\A.a, \A.b]
    }
}

if we add a KeyPathIterable protocol that will give us allInnerKeyPaths : KeyPath<Self, T...> (you get the idea), than the method of an MultiTypeEquatable would be
static func keyPaths() -> [KeyPath<Self, T...>] with the default implementation that uses allInnerKeyPaths.

1 Like

I agree that Concretized/Range Arity Constraints is out of scope of this pitch but for future consideration, what do you think about this syntax?

func debugPrint<T..., U...>(_ items: T..., _ another: U...) 
    where T.count > 3, T.count == U.count { 
    ...