[Pitch] Parameter Packs

I agree. I think this ought to be spelled analogously to T.Type. The length of the typle -- I mean "type parameter pack" -- is a similar sort of metadata about the type and we have an existing way of spelling these things. E.g.

func sameLength<T..., U...>() where T.Length == U.Length {}

This would have the significant drawback (and potential code breakage) that you'd need to reserve Length as an invalid name for a protocol's associated type (as Type is today). Or perhaps you could continue to allow it generally and error at the point of conformance of a type parameter pack to a protocol containing a Length type member. My preference, though, would be the obvious and clear extension of the existing error message:
Type member must not be named 'Type', since it would conflict with the 'foo.Type' expression

2 Likes

For this reason I think length, count or shape might be a better choice since naming associated types with a lowercase letter is probably quite rare.

I think shape is better than length since these are not compile-time integers; with a pack type like {T, U..., V} the actual length that it will have at runtime is unknown. The important part is that a same-length requirement is satisfied if both replacement pack types have the same "structure".

5 Likes

Well, parameter packs are one concept that extend to both type parameter packs and value parameter packs, so we need to have a common term to explain the operations that you can do at the type level and at the value level :slightly_smiling_face:

I agree that there's probably a better spelling out there, and I am definitely open to suggestions. I also think that "length" might be the wrong concept to be thinking about, because packs will also have varying degrees of abstract structure depending on the requirements placed on them. Some level of statically known structure might also be useful for destructuring packs, which is mentioned in the future directions. I wonder if the concept in the programming model should be "pack structure" or "pack shape" instead of "pack length" (where same-structure also implies same-length).

I worry that # signals "known by the compiler", when the length is still only known at runtime. if #available is similar, and many folks confuse #available with a compile-time constant.

Tuple conformances will need parameters packs and some of the other future directions in order to express. Modeling packs as tuples was already explored and ruled out due to the fundamental ambiguities between using a tuple as a component parameter and using a tuple as the pack itself, among other disadvantages outlined in the alternatives considered.

2 Likes

Right. This is a technical constraint which arises naturally from the Swift generics model and would be quite difficult to lift, and attempts to do so would inevitably be incomplete and compromised and would probably just lead to a lot of user frustration in practice. “Same-length” constraints are really “same-shape” constraints. Shape is deeply bound up with the actual dynamic pack length, and shape equality implies length equality, and shape equality devolves to length equality when there aren’t pack expansions involved, which is always true at some ultimate concrete use site; as a result, it’s only a slight misnomer to call it length equality, and it avoids the confusion of throwing a new term at users when they typically don’t need to worry about it.

2 Likes

Actually the suffix ... operator returns a PartialRangeFrom<C>.
I think in your code each argument has type (PartialRangeFrom<T>..., U) and in order to get (PartialRangeFrom<T>, U) we need to write

func acceptAnything<T...>(_: T...) {}

func ranges<T..., U...>(values: T..., otherValues: U...) where T: Comparable, length(Ts...) == length(Us...) {
  func range<C: Comparable, V>(from comparable: C, and value: V) -> (PartialRangeFrom<C>, V) {
    return (comparable..., value)
  }
  
  acceptAnything(range(from: values, and: otherValues)...) 
}
1 Like

How would this work? Wouldn’t T need to be named either A or B to fulfill the respective requirement?

I see; still, I think a (T…).count- or (a…).count-like syntax would be more natural.

In the above code T: P says that "every type in the pack T has to conform to P". In the implementation of G, T.A is a pack that contains the associated A type of every type in the pack T. Similarly, T.B is a pack that contains the associated type B of every type in the pack T.

For example:

struct S: P {
  typealias A = Int
  typealias B = String
}

struct R<A, B>: P {}

let g = G<S, R>()
// T := {S, R}
// T.A := {Int, R.A}
// T.B := {String, R.B}
2 Likes

Is the pattern expression of a pack expansion allowed to be any expression? E.g. can I write something like this?

func getComparisonClosures<T...>(t: T...) -> (((T) -> Bool)...) where T: Equatable {
    ({ other in
        t == other
    }...)
}

let closures = getComparisonClosures(5, "Hello")
// closures: ((Int) -> Bool, (String) -> Bool)

closures.0(5) // true
closures.1("Foo") // false
2 Likes

Yes, that should work.

1 Like

This feature is looking great, and this is a very well written proposal to boot! Excited that work on variadic generics continues to move forward. Following are my impressions from a first read through of the pitch, apologies if it is somewhat disorganized.

Types in iterations

I think it would feel natural to express this as:

func allEmpty<T...>(_ arrays: [T]...) -> Bool {
  var result = true
  for array: EachT in arrays... {
    print(EachT.self)
  }
  return result
}

Of course, there's a secondary problem here about what to call the individual type here, which segues nicely into the next item...

How to name packs

This is somewhat of a tangent, but I think that it's important this document reflect how we expect users to use this feature. I lean towards pack names that explicitly refer to the aggregate such as Rest, Elements, or in the terse case even Ts and Us since this leaves open the natural name of Element or T if we want to use it in, e.g., iteration as above.

I also feel like it's better conceptually. I definitely like the spelling

func prepend<First, Rest...>(...)

to something like

func prepend<First, Next...>(...)

and IMO it reads better in expansions as well, e.g., (Int, Elements..., String). Adopting this convention might also ameliorate some of my concerns with the use of ... in general which, I'll get into next (buckle up).

On ...

I continue to not love the ... spelling as-proposed for the reasons called out in the proposal and ones I discussed in the last pitch. I really don't like that the utterance T... in function parameter position is impossible to decipher without knowing the identity of the type T. I don't consider it a good solution that users can click through to find the definition of T and figure out whether it's a variadic parameter or not. Depending on programming environment this will be at best inconvenient and at worst impossible.

Even in expression context, since value packs can be nested arbitrarily deep within a value pack expansion, users will potentially have to inspect all values referenced within the sub-expression of the ... to reason about what sort of ... they're dealing with.

I understand why ... feels like a natural choice (especially for folks very familiar with C++) but I don't really feel like the proposal makes a very compelling rebuttal of the issues with the ... spelling. While disambiguation rules may allow us to resolve the formal ambiguity, the examples presented still appear horribly ambiguous when reading the code, and I'm not confident that this is just a result of variadic generics being 'fresh' and unfamiliar. Furthermore, I'm not fully convinced by the linguistic arguments for ...:

This is, of course, only one possible linguistic meaning of .... It can also mean trailing off at the end of a sentence...

Or can be used to indicate a pause... in the middle of a sentence. It can also be used (in a quote) to indicate an omission due to irrelevance, and perhaps unintelligibility without further context.

Even accepting the linguistic meaning of ... as omitted information inferred from context, at the end of a list ... frequently indicates that the list is extended by reference to all prior items, not just the one to which ... is affixed. E.g., in "1, 2, 3, 4, 5, 6...", the ellipsis applies to the list as a whole (and not just "6"), which can be seen because the extension implied by the ellipsis in "2, 4, 6..." is entirely different.

I don't think any of these are great reasons to reject the ... absent other concerns, I'm just offering them as reasons why I think the linguistic argument for ... isn't exactly a slam dunk.

A priori, I don't necessarily consider "it's the same way C++ does it!" to be an argument for or against this spelling :slightly_smiling_face:. C++ didn't have all the same existing baggage around ... that Swift has, and given that the fundamental model for generics is already quite different between Swift and C++ I'm not too concerned that experienced C++ programmers would also have to learn a slightly different syntax for variadic generics.

To the extent that ... means something different for variadic generics than for normal variadic parameters, I'm not convinced that this is a win. Normal variadic parameters become arrays within the function, while variadic generic parameters become packs/tuples. These are different things with very different feature sets, and I think it's reasonable to spell them differently. Yes, the call sites look the same, but at the point where users would actually be writing ... the behavior is quite different.

This is mostly a very specific argument against the * spelling rather than the idea of an arbitrary different operator. Even the last bullet glosses over the fact that ... is (somewhat) unique within the world of "all operators" in that it is a widely used, standard library operator today, so the exact same ambiguity concerns are far more troublesome to me than they would be for other operators.

Yeah, I think I agree that bare keywords here are probably not a good fit here since the expand operation can be applied to arbitrary expressions, and I like having some broad alignment between the value and type syntax here (so I'm also on board with the reasoning rejecting the magic map operation).

But there's some space between "bare keyword" and "operator" that I'd be curious about, such as #pack(T) and #expand(t). I don't know if we have a great philosophy for what # means in swift broadly other than "special compiler thing" but variadic generics is certainly a special compiler thing, and AFAICT this would address the two points raised here against keyword-style approach.

All this said, I don't have great suggestions for alternatives that I really love either. I don't hate the #pack and #expand option. Operator-wise, doing something like *** could increase visibility while shedding the immediate pointer connection, but ... does have going for it that it's at least easily explainable by analogy with the linguistic ellipsis and I'm not sure there's another good punctuation option that has the same property.

One idea: since we keep using the {T, U} notation to refer to packs, what if we ran with that idea and had packs defined as {T}... and expanded as {t}...? This has the advantage of being a bit more heavyweight than ... alone, as well as being self-delimiting (since it seems like many value pack expansions will need to be parenthesized anyway). There's still a potential ambiguity here with closure expressions, but I don't think it's a formal ambiguity and it's (IMO) more immediately apparent that ... applied to a closure expression doesn't really make any sense.

Same length constraints

I share the discomfort of others with the length(T) syntax and agree that something like T.count is perhaps a bit more ~Swifty~. I wonder if we could also lean into the same-shape idea without introducing the shape terminology by providing a way to declare a sort of 'phantom' constraint that would cause the same-length inference machinery to kick in, e.g.:

func f<T..., U...>(t: T..., u: U...) where (T, U)... // implicitly, 'T' and 'U' must have the same length!

If we went the route of formalizing pack notation, we could also avoid the issue of T.count ambiguity by forcing users to refer to {T}.count or something to make it clear we're referring to an operation on the entire pack and not its constituent members... I guess using the notation I suggested before that would actually be {T}....count but that quadruple dot is awful. :sweat_smile:

Multiple variadic type params

I agree this doesn't really need to be addressed in the first proposal, but I do think it would be fine to allow this and permit disambiguation at the call site of (e.g.) an initializer:

struct S<T..., U...> {
  init(t: T..., u: U...) {}
}

S(t: 0, "", u: 1.0)

type authors who wanted to provide functionality without passing values would have to jump through some hoops, but maybe that's okay to start out with?

struct S<T..., U...> {
  init(t: T.Type..., u: U.Type...)
}
S(t: Int.self, String.self, u: Double.self)

I think this would even allow referring to the metatype, without instantiating at all:

extension S {
  static func getMetatype(t: T.Type, u: U.Type) -> S.Type { S.self }
}
let s = S.getMetatype(t: Int.self, String.self, u: Double.self)

Just to take things a bit further, I wonder if the explicit {} syntax for packs would also unblock types with multiple variadic type params. You could instantiate S above as S<{Int, String}..., {Double}...>(). And 'uncallable' functions would become callable as f({arg1, arg2}..., {arg3, arg4}...)

12 Likes

I want to quickly mention because it's not explicit in the proposal - we chose ... not because we think ... is amazing, but because we don't think any of the alternatives that we considered are better than .... We are, of course, open to other suggestions!

7 Likes

Yes, this is my problem too, for all my words against .... :slight_smile:

I think I just fall on the other side of the line of thinking that the existing problems with ... are enough to warrant choosing just about anything else, despite the fact that (I agree) it feels somewhat natural to use ... here in a vacuum.

I think we've ended up in a similar place with ... as what we have with _ today. Underscore is so convenient/natural as a "don't care"/"omit" type of punctuation that we've used it for several quite different things (a sin I am guilty of myself), but it still gets proposed for other purposes such as being a synonym for unconstrained primary associated types (e.g., some DictionaryProtocol<_, Int>). Funnily enough, * was also proposed as a way to resolve the clash between meanings of _ in that context.

1 Like

I'm kinda torn, because on one hand, I agree that naming packs in terms of the whole pack makes the most sense when reading (most of) the declaration, but on the other hand, I find patterned pack expansion to be most understandable when the packs in the pattern are named in terms of the component elements of the pack. For example, given the pack parameter <S...>(sequence: S...), the pattern sequence.count... naturally reads to me as "get the count of every sequence in this pack", whereas sequences.count... looks like you're getting the count of the pack.

Same for generic requirements: <C...> where C: Collection makes sense to me as "a pack of Cs where every C conforms to Collection, where <Cs...> where Cs: Collection makes it read like the pack conforms to Collection.

All of that said, I agree with this

When you're expanding just a single pack, naming the pack parameter in terms of the whole pack reads more naturally. Same for iteration for element in elements....

Yeah, Slava and I were just discussing removing the explicit spelling for length requirements and making them entirely inferred in this proposal. I find that pretty compelling, and you could spell that "phantom" constraint under this proposal with something like ((T, U)...): Any. I also suspect that most functions that want a same-length requirement will already have an expansion type in the signature that the requirement is inferred from, so hopefully this workaround would be relatively rare. I still think we'll want an explicit spelling eventually, but it doesn't need to be in this proposal.

I think your observations about the problems an explicit pack syntax would solve are interesting. We were already contemplating whether or not we needed a "pack literal" syntax when writing this proposal. I'll keep thinking about this, thank you!

9 Likes

I also don't think the ... syntax is ideal due to ambiguities with operators and non-generic variadics, but it's worth mentioning it's not just C++ that uses .... I've also seen this syntax in ML dialects with row polymorphism, and even more obscurely, Factor used it for row polymorphic stack effects.

I think pack T or many T or something could work as a declaration for the pack type parameter in a generic parameter list. Not sure how I feel about #expand(T) for a pack expansion type or expression, though.

2 Likes

The natural spelling would be, I suspect then, many Ts—that is, I suspect English grammar will strongly tilt the debate about whether or not to use the plural for a parameter pack name if we choose this spelling. It doesn't really scale though to, say, container types as one can't pluralize the base: many Sequence<Ts> doesn't read obviously more clearly than many Sequence<T>.

It could also, I think, nudge us towards a different operator to expand a pack, which might not be a bad thing. Suppose that's something like each. We could end up with something that reads a little AppleScript-y:

func allEmpty<many T>(_ arrays: [each T]) -> Bool {
    var result = true
    for each array in many arrays {
        result &&= array.isEmpty
    }
    return result
}
4 Likes

As much as I love the idea of many for this, we should be careful because some in English could mean “some thing called T” (current actual Swift meaning) or “more than one T, but not many of them”, and using many would make that ambiguity relevant.

3 Likes

Why should that be an error? Wouldn’t that rule out 0 element packs?

To my mind it’s the exact opposite: it’s super clear that ... is the pluralization. T… means ”many T”. Spelling the type as a plural too means it’s double-pluralized, which is extremely confusing. What is even ”many many T”? (In fact, my first thought when skimming the proposal was ”nice, I finally have a shot at understanding this, since they fixed the double-pluralization from the earlier proposal”.)

3 Likes

Finally had some time to catch up with this thread a bit.

  • Did I missed it somewhere, while I understand why packs must support zero parameters I feel a bit confused when it comes to generic type parameter packs. For example if we have struct G<T…>, is G<> now valid or still illformed, or is the variant representing a pack with zero parameters now spelled as just G, which could create other ambiguities?!

  • Since you have implemented some of these things already, have you explored how packs can be made available on enum types? I think this is one of the most common cases variadic generics are needed for.

I'll try to re-read the proposal and find more details around pack element extraction. e.g.

func foo<T…>(_ t: T…) where /* count == 3 */ {
  t.2 // is this supported or do we have to capture a pack into a tuple first? 🤔
}
  • If a function has 4 packs, can we build different same-length groups? e.g. length(A…) == length(B…), length(C…) == length(D…)

  • Is a fixed length pack already supported? e.g. func bar<T…>(_ t: T…) where length(T…) == 42

1 Like

Having such abilities to specify the length for a concrete pack should also unblock the currently ill-formed generic types with multiple generic type parameter packs. I personally would be very much in favor of such direction for that feature.