[Pitch] One-Element Tuples

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.

Just to clarify—is the double ... expansion here shorthand for a case where we have, say, expanded a pack T... into a stored property of type (T...) and then later want to expand the stored property back into a pack somewhere (giving us (T...)...)?

If so, then doesn't (T...)... just mean the same thing as T..., i.e., expand the pack directly? That seems to work in all of the {Int}, {(Int, String)}, and {Int, String} cases, unless I've misunderstood the operation we're talking about? (Maybe that's just a rephrasing of what you've already said...).

Without single-element tuples we'd also have cases like the following:

struct G<T...> {
  var ts: (T...)
}
G<(Int, String)>(ts: (0, "")).ts
G<Int, String>(ts: 0, "").ts     // both 'ts' have type '(Int, String)'

but I don't immediately know if that's problematic.

Yeah, it's the abstraction behind T... that makes the level of tuple-ness to apply the operation obvious. John's point (IIUC) is that, strictly speaking, if every type is also the single-element tuple of itself, the concrete case where you do (Int, String)... is ambiguous, because that could mean unpacking either the pair (Int, String) or the singleton tuple ((Int, String)). But we could make tuple operations like that unavailable on concrete types that are obviously not tuples (besides being their own singleton tuple). Technically, we already do this with the .0 member access. In pre-1.0 betas of Swift you used to be able to apply .0 to everything, and there were fun name lookup ambiguities on 2+-tuples because of it, until we simply made it unavailable on scalars (and spelt the whole-value "property" .self instead to make it unambiguous).

4 Likes

I think that's not quite right. Packs need to be able to contain tuples as elements, and so you can have packs that contains nothing but a single tuple as an element, and operating on that in a variadic-generic context is semantically different from operating on the underlying element that's a tuple. The result is that, if we're not going to introduce single-element tuples, we get this class of "things that look like tuples" in variadic generic contexts, like (T...), which is not necessarily a tuple (if T is a singleton pack of a non-tuple type) but is treated like one in the variadic-generic context. Crucially, tuple operations on such values/types have to be rewritten atomically with substitution, because they need to have the effect that they would have if single-element tuples existed and must not see through to the underlying type (which might be a tuple) if the pack happens to be singleton.

1 Like

Sorry, I was playing a few notational games there; bad math habits. (T...) is not a valid expression, but it is a valid tuple type. If tuple is a value of type (T...), then tuple... produces a sequence of values of the same length as the type pack T, with the ith element in the sequence being taken from the ith element of tuple and thus having the same type as the ith element of the type pack T. If you expand that in a tuple literal (i.e. within parentheses) with nothing else in the literal (i.e. (tuple...)), then you have an expression of type (T...) element-wise identical to tuple.

If E is an expression that references a pack, then E... produces a sequence of values of the same length as that pack, with the ith element in the sequence being the ith result of evaluating E substituting in the ith element of the pack for all the pack references within E. If you write that in a tuple literal and then immediately expand that tuple, like (E...)..., you'll get the same sequence as you would get from E....

The issue I'm discussing with {(Int,String)} arises if you think of substitution as something you can apply in separate phases. If I have tuple: (T...) in a variadic generic context, and I write tuple..., it is important that that produces the T-structured sequence I discussed above. Without single-element tuples, the type of tuple when T == {(Int,String)} becomes (Int, String), and applying the ... operator to that yields a sequence of two values, not a sequence of one tuple value; but that isn't right. So, for example, when we are implementing the substitution of the ... tuple-expansion operator on a tuple of type (T...), we can't unconditionally assume that the type after substitution is a tuple (because maybe T == {Int}), and we can't correct for that bad assumption by ignoring the ...if the type after substitution was a tuple (because maybeT == ({Int, Float})); we have to check specifically whether substitution eliminated the outermost level of potential tuple-ness that we had, and only if so can we ignore the ...` and use the single sequence element we've got. I think that works, but hopefully you can see why I'm worried about it at an implementation level.

2 Likes

Thanks for writing that up, John; that helps.

I think I'm imagining is that the tuple-expansion ... acts less dynamically than what you're describing, and instead performs a structural transformation at compile time.

IOW, the tuple-expansion ... would, at compile time, look at its argument and turn it into the appropriate sequence of values. If we have (T...)..., that's simple—the tuple-expansion of a pack directly expanded into a tuple is just the pack expansion itself. So we'd generate code to expand the sequence of Ts into whatever context was receiving the result of the tuple-expansion directly.

So in the T={(Int, String)} case, at the point when the tuple... expansion actually happens we'd have already type-checked it to produce T..., or (Int, String) concretely. And when T={Int} we'd similarly produce T... (i.e., Int), without having to special-case the situation where we've eliminated a level of tuple-ness.

1 Like