[Pitch] One-Element Tuples

Yeah, I would say this is exactly the right way to go about it—the abstraction level of the context dictates at what level the concatenation happens. That aligns with how our generics generally interact with overloading; the best-matching operation for the generic environment wins.

Another way to look at this is that concatenation doesn't have to be expressed as an operation between tuples, but can be expressed in terms of unpacking. (T..., U...) more obviously is equivalent to (T...) when U... is empty (which is then equivalent to V when T... is a single {V}).

4 Likes

Tuple concatenation is isomorphic to just pairing the two types. Tuples are products, and the product of any type with an uninhabited type is an uninhabited type, so (T...) concat Never is isomorphic to Never, not to (T...).

The type with the property in your identity isn't Never, it's the unit type ().

9 Likes

It seems like you're proposing that we interpret tuple operations on non-tuples as if they were implicitly on a single element tuple. The biggest problem with that is that it really blows up if you have tuples within type packs, because it matters what level you apply the tuple operation at. Maybe if we didn't really have "tuples" but instead had some sort of statically-typed Perl list which automatically collapses its structure into whatever context it's used in? but that's not the world we're in, and I'm not sure it fits cleanly with generics in any way.

3 Likes

I know you mentioned upthread that "substitution shouldn't affect behavior", but that's also not the world we currently live in with overloading and such, and there are already many formally ambiguous situations where we have to pick the best or most specific operation from a series of candidates. If we only ever operate on the lowest level of tuple-ness statically available in the context, without looking into any that are abstracted away inside type packs, that seems to me like it would pretty much always DWYM.

I fear that, while making single-element unlabeled tuples be distinct types might tidy up some cases that arise in relatively advanced situations for library authors, the impact will fall on users of those libraries, and the complexity for those library authors will end up surfacing in other ways, since they will inevitably feel the urge to add unary overloads to their variadic APIs to try to avoid making users have to unwrap them.

8 Likes

I'm not sure they would need those overloads, wouldn't unwrapping a single element tuple be as simple as: let (x) = funcReturningSingleElementTuple()?

It's easy enough, albeit still inconvenient, to wrap or unwrap a single element tuple alone. I'm more concerned about structural substitutions, like getting an Array<(_: Int)> when you want an Array<Int> (which you can at least map away, at the cost of copying the array, or implementation heroics to share the buffer in circumstances where the type layouts match), or getting some arbitrary Foo<(_: Int)> when you need a Foo<Int>, where Foo doesn't have a map operator to do the transformation. I've already observed that structural type distinctions have led people to avoid using labeled tuples in places they would like to for similar reasons (since you have the same problem that Foo<(a: Int, b: Int)> can't convert to Foo<(Int, Int)>).

1 Like

It seems reasonable to me that tuple operations in generic contexts type-check and operate ambivalent to the underlying tuple-ness of the generic arguments, so if the "dynamic behavior equivalence under substitution" isn't a hard constraint I'm inclined to agree with Joe.

Aside from overloading, the situation with tuples seems somewhat similar to the current behavior that folks ask about every now and then:

func isNil<T>(_ val: T) -> Bool {
    val == nil
}

let x: Int? = nil
isNil(x) // false

Of course, we warn about the direct == nil comparison in the simple case, but treating tuple operations as a more 'structural' operation doesn't strike me as too problematic.

2 Likes

Okay, so, a world in which we allow tuple operations to work on non-tuples because substitution can eliminate tuple-ness is just unsound. If substituting T == {Int} into (T...)... produces Int..., and then we have a special rule to evaluate Int... to {Int}, we are going to blow up very badly if we instead substitute T == {(Int,Float)}. But it might be possible to treat (T...) as a tuple, consistently recognize during substitution that we're eliminating a level of tuple-ness, and then special-case the substitution of tuple operations on tuples that are losing their outer level of tuple-ness. So substituting T == {Int} into (T...)... would have to atomically jump directly to {Int} in basically every place where we do substitution.

I do worry that that is going to be extremely bug-prone in the implementation, but if it works, it should avoid the need for single-element tuples.

1 Like

Do we know what these “tuple operations” are? We’ve discussed concatenation, without quite formalizing it. Are there others?

3 Likes

That's a fair question.

Tuple expansion (the postfix ... applied to tuple types/values in my code snippets above) is one of them, and that naturally allows you to express concatenation as (tuple1..., tuple2...).

We also might want to allow "structural" indexing on tuples. Suppose you have a tuple like this:

let tuple = (0, args..., 0.0) // type is (Int, Args..., Double)

You can't really make .1, .2, etc. do absolute positional indexing, because the pack expansion prevents you from statically validating those indices in any meaningful way. But you could make them do indexing into the statically-known structure of the tuple, so that .0 pulls out the Int, .2 pulls out the Double, and .1 pulls out the interior elements, presumably as a tuple of type (Args...). (You would probably want to ban doing .0 into a tuple with a single component like (T...) because it would be a no-op.) But this would definitely need to be updated atomically during substitution because substitution could completely change the structure of the tuple, so e.g. tuple.2 would need to become tuple.4 if you substituted Args == {String, String, String} into that context.

Tuple destructuring with patterns poses similar problems, although I don't know that we've thought that case through very much.

6 Likes

Sorry, in this discussion, what is the meaning of the notation {Int}?

1 Like

A sequence of types consisting (in this case) of the single type Int. So if you’re in a context that allows a sequence of types, like the element list of a tuple type or the parameter list of a function type, this would contribute that one type into that position in the sequence. A generic type pack parameter is replaced by such a sequence (and the list of generic arguments corresponding to a pack parameter is therefore also a context that allows a sequence of types)

My example is a little weird because we haven’t actually talked about the expansion operator applying to tuple types. So please interpret that as applying to a value which happens to have the given tuple type.

While you are addressing a valid use case which may arise in front of developers, I don’t think that’s how developers should model this, and thus it’s not something that compiler needs to support.

If one needs to be able to refer to a pack of values from args as tuple.1 then tuple should be declared with a nested tuple literal:

let tupleA = (0, (args...), 0.0)

I find it very confusing to have tupleA.2 and tupleB.2 mean different things depending on the context. If they do mean different things, I’d expect tupleA and tupleB to be of different types. And (0, (args...), 0.0) vs (0, args..., 0.0) captures exactly this difference.

So, back to the original example, what to do when we have flattened tuple?

let tupleB = (0, args..., 0.0)

I think we should be able to refer to tupleB.0 to access the first Int. But key paths .1, .2 and so on now refer to values from args. And unless we know at compile type minimum size of args, usage of such key paths should be a compile-time error.

5 Likes

That's certainly an option. But then you simply have no way of destructuring that kind of tuple at all; and if we do provide a different way of doing that — perhaps not spelled .2 — it has the same theoretical concerns.

1 Like

Having users to structurally destructure strucutural types seems the best way to accomplish this, in my opinion.

let tupleB = (0, args..., 0.0)
let (first, others..., last) = tupleB
3 Likes

Yes, I realized that :slight_smile:

Yes, that was kinda my point, () is the unit type. And the unit element in this group. But concatenating () to (T...) could still produce (T..., ()), and IMHO, it should. (A, B) is isomorphic to (A, B, ()), they have the same number of possible values. But still, we treat them differently everywhere.

E.g. calling Combine's CombineLatest on publishers with outputs A, B and () yields a publisher with output (A, B, ()) and not (A, B). Passing a closure with only two params to .sink produces an error.

If (T...) concat () should produce (T...), I think we should treat (A, B, ()) as equivalent to (A, B) also. That probably requires a separate proposal?

Are you speaking about the same “concatenation” operation?

I had understood that (T...) concat () should produce (T...) for a “concat” operation where (T...) concat (A, B) produces (T..., A, B)—the tuple pack equivalent of append(contentsOf:).

It sounds like you’re speaking of an operation that is equivalent to appending a single element—that is, where (T...) concat_alt (A, B) produces (T..., (A, B))

3 Likes

I think we should distinguish between those two operations, and support both.

For example, given T = {A, B}, type (T..., ()) would expand into (A, B, ()), while type (T..., ()...) would expand into (A, B).

But this leads to the next question - do we want to distinguish between tuple types (A, B) and type packs {A, B}?

Off-topic away from single-element tuple into variadic generics

If we don't, then T = (A, B) and T is usable as a full-featured type - you can use T.self and T.Type, you can declare variables of type T without packing/unpacking: var x: T = args. While also being able to use it inside the spread operator: (T.self…) - a tuple of metatypes; (Optional<T>…) - a tuple type where each element type is wrapped into an Optional.

But there is some ambiguity here. If I have two tuple types U=(A, B) and V=(X, Y), then what does (Dictionary<U, V>…) mean? Is it a cross product ([A: X], [A: Y], [B:X], [B:Y]) or zip ([A: X], [B:Y]) or only first one is expanded ([A: (X, Y)], [B:(X, Y)]) or only last one is expanded ([(A, B): X], [(A, B):Y])?

If tuples and packs are different entities, then some ambiguity is solved, because spread operator cannot be applied to tuple type, including empty tuple. Rather, there should be a mechanism for converting tuple to pack of types, and then spread could be applied to the pack:

  • (T..., ()) expands into (A, B, ())
  • (T..., pack( () )...) expands into (A, B).
  • (Dictionary<U, pack(V)>...) expands into ([(A, B): X], [(A, B): Y])
  • (Dictionary<pack(U), V>...) expands into ([A: (X, Y)], [B: (X, Y)])

But there is still some ambiguity. What does (Dictionary<pack(U), pack(V)>...) mean? It is a cross-product, or a zip, or something else?

2 Likes

Slava and I are working on an updated roadmap for variadic generics, and revising the design that was pitched a few months ago. Spoiler: we're thinking that yes, we do want a distinction between tuples and parameter packs, and we're also working on the pitch for parameter packs. We're aiming to post those write-ups simultaneously within the coming weeks.

14 Likes

Well, not really. But this does shed some light on what I think is the point of confusion here.
In Swift, we're overloading void with two distinct properties. It is both the empty tuple, and also the unit type. As a tuple, certainly you're right.

Maybe consider this type:

frozen enum Unit {
  case unit
}

This is a type with just a single possible value. Using it as input or output to a function is isomorphic to having no input and void-output. This type, however, is free from the burden of being overloaded with any tuple connotations. But it is isomorphic to Void-as-unit in every sense, except the tuple interpretation.

(A, B, Unit) has the exact same number of elements as (A, B). Those two tuples are isomorphic.
Yet it is useful to distinguish them, in the same sense that it is useful to distinguish a 1-element array from its element. Or a 1-by-1 matrix from a vector.