Pitching The Start of Variadic Generics

Regarding the Arity Constraints, but maybe a different direction from some of the existing discussions:

I wonder if some lightweight algebra/subscript-like syntax might be useful, like:

  • Every element in T... must be a Set of the corresponding element in U.... This also implies that both have the same number of elements:
func allPresent<T..., U...>(haystacks: T..., needles: U...) -> Bool
  where
    T[i] == Set<U[i]>
  • Or, for the "homogenous tuple" example above, every T must be the same type:
struct HomogeneousTuple<T...> where T[i] == T[j] { … }
// or maybe:
struct HomogeneousTuple<T...> where T[i] == T[i+1] { … }
  • Specific arity: T... must contain at least 2 types:
func process<T...>(_ values: T...) where T[1]: Any { … }

In these examples:

  • 1 is a concrete index
  • i represents "any given index"
  • j represents "the index after i, if there is one"
  • i+1 also represents "the index after i , if there is one", but allows for more complex relationships.

For the call site to be valid, the expression must be true for all indices in the type sequence(s).

Edit: Fixed the mistake that DevAndArtist noted below. Thanks!

Your T and U are flipped in your first example description.

Should be:

Every element in `T...` must be a `Set` of the corresponding element in `U`.

While I also like the idea of an index, I still think we need support for Ranges. In your current example it feels like the where clause would become a bit weird if the index isn't introduced earlier:

<T...> where T[i], (2 ... 10).contains(i), T[5] == String

// slightly better
<T*{2,10}> where T[5] == String
3 Likes

Instead of _countTypeSequence(xs), what about

func measure<T...>(_ xs: T...) -> Int {
  xs.#count // "#" already vaguely means "compiler wizardry" to me
}

or, a bit better IMHO, since it's T... itself that dictates the number of elements, not any particular instance of T...

func measure<T...>(_ xs: T...) -> Int {
  (T...).count
}

WRT to the spelling of _mapTypeSequence, what about just using map, but needing to provide a name for the particular type of whichever element you're working with in <>?

func cut<T..., U...>(_ xs: T..., transform:(T...) -> U...) -> U... {
  xs.map<T> { //
    // Not sure if types should need the `.self` suffix here. I vote
    // no, but I don't like that rule in regular Swift anyway.
    switch T {
    case Int: return $0 * 2
    case String: return "Hello, " + $0
    case View: return $0.padding(20)
    case Equatable: return { other: T in $0 == other }
    case CustomStringConvertible: return $0.description
    // ...
    case _: return "\(T.self)"
    }
  }
}
side note

(Wow, that's a lot of "return"s in there! Sure would be nice if the compiler could infer them in instances where a switch is the only statement in a function or closure which is supposed to return something, and the case in question only has a single expression...)

Setting aside the desire to keep this spelled different from the regular map (which this technically does, since map normally has two generic parameters, not one), the <> part also provides a way to specify which type you're referring to if you've nested multiple maps with the same type sequence:

func convolutedExample <T..., U...> (xs: T..., ys: T...) -> U... {
  xs.map<X> { x in
    ys.map<Y> { y in
      if X == Y { return "\(X.self) == \(Y.self), how boring..." }
      // do whatever less boring stuff you want here
    }
  }
}

Anyway, moving on...

What about #?

We could (though perhaps not "should") use those and — we could use * or + to denote “zero or one (respectively) or more of this type”, and postfix to denote “zero or more types”:

struct Foo <Type1: T…, Type2: (U, V…), Type3: (W…)+, Type4: (X+, Y…)*> {
  var t1: Type1 // a heterogeneous sequence of types whose length is >= 0
  var t2: Type2 // a heterogeneous sequence of types whose length is > 0
  var t3: Type3 // a homogeneous sequence whose length is > 0 of heterogeneous type sequences whose lengths are >= 0
  var t4: Type4 // a homogeneous sequence whose length is >= 0 of heterogeneous type sequences, each of which is a homogeneous type sequence whose length is > 0 followed by a heterogeneous type sequence whose length is >= 0
}

I must admit, it is a bit cryptic if you haven't been exposed to regular expressions, but it doesn't seem too hard to explain either (says the guy who first learned about * and + in this context many years ago). I mean, at some point I think we have to accept that code written about complex subjects might have complex syntax -- we just need to avoid making the syntax unnecessarily complex. That said, while this wouldn't bother me personally, I'm not the only person who uses Swift and I wouldn't be even a little bit surprised if someone comes up with a better way.

Type sequence mapping seems unclear. I feel mapping one type sequence to another type sequence is an important use case.


If I want to map T… to the generic constraint of another type, for example, mapping parameter types (A, B, C, …, Z) to return type (Result<A>, Result<B>, Result<C>, …, Result<Z>).

Something like func foo<T…>(bar: T…) -> Result<T…> seems like the return type is Result<(A, B, C, …, Z)>.

Something like func foo<T…>(bar: T…) -> (Result<T>)… to me, does not clearly indicate that T is a variadic type in the return type.


Type sequence mapping could possibly be defined like so:

func foo<T…, U…>(bar: T…) -> U… where U == Result<T>

Note, the where clause does not use the syntax, and instead acts more like a for each U, given some T. I think this is the most clear unless we add some new syntax for type sequence mapping.


Another example of mapping one variadic generic constraint to another variadic generic constraint:

func mapResults<T…, U…, V…>(from requests: T…) -> U… where T == Request<V>, U == Result<V>


Edit: this seems similar to arity constraints, but maybe the proposal should more clearly define how it looks to use a type sequence as a return type.

2 Likes

Your post made me wonder about supporting ranges within the subscript, like:

where T[2...10]: ExpressibleByIntegerLiteral

I'm not sure if that would be useful, but I find it interesting :grinning_face_with_smiling_eyes:


Another thought: My explorations didn't include any enforcement of an upper bound. One way to express an upper bound might be to declare that index to be Never, like…

  • Specific arity: T... must contain at least 2, and no more than 10 types:
func process<T...>(_ values: T...)
  where
    T[1]: Any,
    T[i+10] == Never

That's not especially graceful. And it doesn't strictly prevent the type sequence T... from exceeding 10 members. But it would prevent calling process() with more than 10 values.

<T...> where T[i], (2 ... 10).contains(i), T[5] == String

Ranges are good, but here I think it should say that i is in the range 0..<n, where n is in (2...10).

This seems to be similar to the idea I suggested in my reply above too: Pitching The Start of Variadic Generics - #18 by AliSoftware

TL;DR: My suggested syntax would be:

func debugPrint<T[]>(_ values: T[])
  where T: CustomDebugStringConvertible
{
  print(values.count)
  for v in values { /* v is Any */ }
}
func needAtLeastOne<T[1...]>(_ values: T[]) {
  ...
}
func debugPrintEquivalentSyntax1<T[0...]>(_ values: T[])
  where T: CustomDebugStringConvertible
{ ... }
func debugPrintEquivalentSyntax2<T[...]>(_ values: T[]) // '...' here is the UnboundedRange operator
  where T: CustomDebugStringConvertible
{ ... }

And

public struct ZipSequence<Sequences[]>: Sequence
  where Sequences: Sequence 
{
   ...
   public struct Iterator: IteratorProtocol {
      public typealias Element = Sequences[].Element // maps the 'Sequences' type sequence into a type sequence of their respective 'Element'
   }
}

What I like about this syntax is:

  • It plays well with potential future direction of specifying arity and arity ranges
  • The square brackets makes it more natural (at least to me) to see and read T[0], T[1], T[n] at being different types (and not necessarily all the same like T... can imply due to its ressemblance with the existing variadic parameters feature)
1 Like

Is there really a value in adding ranges? It seems to make more sense defining explicitly typed parameters with trailing variadic generics. I can't think of any example where indexing variadic types is more clear than providing explicit types.

If we want a single string, do something like this: func foo<T...>(a: String, b: T...). This could even cover more complex cases like: func foo<U..., V...>(a: U...) where U == (String, V...).

I'm not quite able to follow your point, but when I was talking about ranges I was referring to the dimension. Personally I only really care about cases with a fixed dimension, which would be something like func f<variadic T[5]>(T) { } but you could imagine wanting something to be defined for a dimension of 3, 4 or 5 for example: func f<variadic T[3...5]>(T) { }.

Sorry, I think I replied to the wrong comment. I think I meant to reply to @DevAndArtist, or whoever started the discussion about defining certain type conformances at certain indices. I was assuming T[5] == String Is trying to bind the element at index 5 to a string, or T[3…5] == SomeProtocol To define conformance for a certain sub range.

I was wondering what advantages this has over creating explicitly typed parameters, or multiple variadic generic groups.

1 Like

I agree with you. If specific index or indices like T[5] (6th type parameter) have special meaning and it should be String, it means you should split them into different parameters, like <T0, T1, T2, T3, T4, Rest...>. Constraints like where 2 <= n have similar problem. It would mean, certain two type parameters have special meaning. Therefore, perhaps you should use perameter-split strategy like <First, Second, Rest...>, <First, Middle..., Last> or other corresponding signature for your intention.

Nevertheless, I think some type of constraints cannot be expressed without subscript-like syntax. (using @Jumhyn's forEach like syntax).

// declare fixed-length version of chain
static func buildBlock<A, B, C, D>(_ f: (A) -> B, _ g: (B) -> C, _ h: (C) -> D) -> A -> D {
    let f0 = { (value: A) in value }        // f0: (A) -> A
    let f1 = { (value: A) in f(f0(value)) } // f1: (A) -> B
    let f2 = { (value: A) in g(f1(value)) } // f2: (A) -> C
    let f3 = { (value: A) in h(f2(value)) } // f3: (A) -> D
    return f3
}

// variadic version
static func buildBlock<type(n) Input, types(n) Output>(
//                     ~~~~~^~~~~~     ~~~~~^~~~~~   
//              specify exact number of type parameters
    _ closures: ((Input) -> Output)...
) -> (Input[0]) -> Output[n-1] where 1 <= n, forEach(i, i+1 in 0 ..< n) Input[i+1] == Output[i]
//                                                   ~~~~~~~~^~~~~~~~~
//                                             ensure i and i+1 in valid range 

I tried but couldn't write this function without <= and subscript access. When I add FirstInput and LastOutput to remove <= and(Input[0]) -> Output[n-1], I couldn't write the signature of closures. I think subscript like range access would also have such usage in some rare conditions.

By the way, it also means that further discussion is required about how to treat variadic values. The pitch describes map , but reduce like operation would be also required. Chain would not be able to be expressed with only map and for .

static func buildBlock<type(n) Input, types(n) Output>(
    _ closures: ((Input) -> Output)...
) -> (Input[0]) -> Output[n-1] where 1 <= n,  forEach(i, i+1 in 0 ..< n) Input[i+1] == Output[i] {
    // how to chain closures here...?
}

(EDIT) I was replying to the wrong address. This is not a reply to @nonsensery, I'm sorry :bowing_man:
This is a reply to @maustinstar.

1 Like

I never thought that variadic parameters deserved the shorthand ... syntax and variadic generics are likely to be even less used than that. Perhaps consider an attribute in front of the type parameter (e.g. @variadic), which might also be a useful place to specify the additional constraints being discussed here, like arity.

2 Likes

As for the potential name of a keyword. An alternative name that was floating around in past thread was pack.

We could also explore the possibility to unfold a pack through a where clause.

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

Building on the keyword idea, in the future we might improve the generics UX and potentially be able to express things like:

func foo(_: pack T) 

// similar to
func bar(_: some T)
3 Likes

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.