[Pitch] One-Element Tuples

[Pitch] One-Element Tuples

  • Proposal: SE-NNNN
  • Authors: Slava Pestov
  • Review Manager: TBD
  • Status: Implemented with -enable-experimental-variadic-generics staging flag

Introduction

This pitch lifts the artificial restriction preventing one-element tuple types from being written and constructed.

Motivation

As a generic container storing a single value, one-element tuples are not very useful on their own, so today the compiler does not allow them to be written or constructed.

Labeled one-element tuples, such as (element: Int), are diagnosed with a post-pass over the type checked syntax tree.

Unlabeled one-element tuples cannot be written at all. In type context, (T) resolves to the same type as T, allowing users to make a stylistic choice between (Int) -> (Int) and (Int) -> Int for example. In expression context, (expr) is the same as expr, since parentheses are used for grouping expressions, disambiguating infix operator precedence, and so on.

However, we expect that one-element tuples might naturally arise as follows. Once Swift supports variadic generics, it will be possible to define a function which returns a tuple type containing a variadic pack expansion:

func makeTuple<T...>(_ elements: T...) -> (T...) { return (elements...) }

This creates a conundrum: what is the result type of calling such a function with a single element, like makeTuple(1)? While the behavior most consistent with the existing language model would assign this expression the type Int, such “implicit unwrapping” behavior would introduce certain conceptual difficulties, which are further elaborated in the Alternatives considered section below.

Instead, we propose lifting the restriction on writing one-element tuple types in Swift, so that the return type of makeTuple(1) becomes the one-element tuple type with element Int.

Proposed solution

We propose the formal introduction of both labeled and unlabeled one-element tuple types in the language.

A labeled one-element tuple type already has a spelling, (label: element). This proposal would simply remove the existing diagnostic that is produced when such a type is resolved.

On the other hand, an unlabeled one-element tuple type cannot use the spelling (T), since that would be ambiguous with the existing parentheses sugar in type context, and expression grouping syntax in expression context. Instead, we propose the syntax (_: element) for an unlabeled one-element tuple type.

We do not expect one-element tuples to be a widely used idiom on their own; they will mostly exist to make the language model more consistent with the introduction of variadic generics. However, being a separable feature, they deserve their own proposal.

Detailed design

In type context, (label: T) resolves to a one-element tuple type with label label and element type T. In expression context, (label: expr) evaluates to a one-element tuple with label label and element value equal to the result of evaluating expr.

Similarly, in type context, (_: T) resolves to an unlabeled one-element tuple type. In expression context, (_: expr) evaluates to an unlabeled one-element with element value equal to the result of evaluating expr.

Swift supports various implicit conversions involving tuple types; these generalize to one-element tuples as follows:

  • Label elimination. An expression of type (label: T) implicitly converts to a value of type (_: T).
  • Label introduction. An expression of type (_: T) implicitly converts to a value of type (label: T).
  • Label permutation. Tuple types with more than one element can be permuted; for example, (a: T, b: U) implicitly converts to (b: U, a: T). There is no equivalent with one-element tuples, since a singleton set only admits the identity permutation.

Swift also supports tuple projection expressions; these generalize to one-element tuples as follows:

  • Index projection. If expr has type (label: T) or (_: T), then expr.0 unwraps the labeled or unlabeled one-element tuple; the result has type T.
  • Label projection. If expr has type (label: T), then expr.label unwraps the labeled one-element tuple; the result has type T.

However, we are not proposing introducing any new implicit conversions between one-element tuples and their corresponding element type:

  • An expression of type (label: T) does not implicitly convert to a value of type T or vice versa.
  • An expression of type (_: T) does not implicitly convert to a value of type T or vice versa.

Source compatibility

In Swift 5.6, the type (_: T) resolves to T and the expression (_: expr) evaluates to expr. This proposal breaks source compatibility with code that uses these forms. We strongly suspect there are little to no projects making use of this syntax, but we wish to call it out as a potential incompatibility.

Effect on ABI stability

This proposal is additive, and has no effect on the existing ABI of libraries. The existing Swift runtime entry point for constructing tuple type metadata supports one-element tuple types already, so there are no backward deployment concerns with code that makes use of this feature.

Effect on API resilience

This proposal is additive, and has no effect on the existing API of libraries.

Alternatives considered

Allow implicit conversion between one-element tuples and their element type

We could allow the implicit conversions that are explicitly called out as not supported in the Detailed design section above. While this is probably not too difficult to achieve in principle, we generally favor not introducing new implicit conversions by default.

A different syntax for unlabeled one-element tuples

We chose (_: elt) for unlabeled one-element tuples since it is an existing production in the Swift grammar, in both type and expression context. However, it does break source compatibility in a narrow edge case, as mentioned above in the Source compatibility section.

One alternative is to take inspiration from Python and use a trailing comma, so that (elt,) builds an unlabeled one-element tuple. The form (_: elt) has the advantage that it evokes the existing tuple syntax, whereas the trailing comma form (elt,) is potentially more confusing. The trailing comma still has the disadvantage that it is theoretically source-breaking, and even worse, it is inconsistent with existing usage of the trailing comma in Swift, which is a no-op in all other contexts where it is valid to write.

We could instead adopt a syntax which is wholly new, such as @tuple (elt) or something even more bizarre. However, this increases the surface area of the language in a new way for what will surely be a rarely-used feature.

Don’t introduce one-element tuples at all

It may be possible to achieve the goals of the variadic generics feature without introducing one-element tuples as a visible language feature, for example by always introducing index projections in the right places. However, this seems difficult to do in a logically consistent way. For example, consider the following code:

struct G<T> {}

func makeGTuple<U...>(_ elements: U...) -> G<(U...)> {
  return G<(U...)>()
}

Unlike makeTuple() shown in the Motivation section, which returns a tuple type, the makeGTuple() function returns a generic nominal type instantiated with a tuple type. Today, generic nominal types are invariant in Swift, meaning two instantiations of the same generic type with different generic arguments are completely unrelated types in the implicit conversion lattice. To effectively hide one-element tuples from the user, the type G<(_: Int)> would need to implicitly convert to G<Int> and vice versa, introducing an entirely new class of implicit conversion. When a type has multiple generic arguments, this becomes even more fraught; every possible combination of generic argument wrapped or unwrapped would need to convert back and forth to every other possible combination.

We believe that simply lifting the existing artificial restriction on one-element tuple types will result in a better language design overall.

49 Likes

I don’t follow this logic. If we’re discussing an alternative in which single element tuples truly do not exist, how can this implicit conversion exist?

They have to exist temporarily inside the implementation, to represent the result of the type substitution, even if they cannot be written explicitly. For various reasons, we want substitution to preserve the "concrete shape" of a type; deviating from this would create even more interesting problems.

From an argument soundness perspective, I think this is a necessary elaboration to include in the proposal. Right now, the Alternatives Considered starts talking about one thing but winds up arguing against something else.

3 Likes

True, but an implicit conversion would seem to fall naturally out of ordinary tuple destructuring with label introduction/elimination, would it not? Seen in that way, it’d not be a “new” implicit conversion.

Would this proposal also, so as not to have to introduce any rules solely for the 1-tuple, and so that users who happen to start with a 1-tuple can append additional elements in source code without editing the first element (or use a source code generator that doesn’t need to apply a special rule for n = 1), permit explicitly label-less tuples of higher arity with the same syntax—e.g., (a: 42, _: "foo", c: false)?

Having an implicit conversion between the 1-tuple and its only element would mitigate this (rare) source incompatibility to be essentially a non-issue, which I think is another nice point in favor of including it :slight_smile:

6 Likes

I think @John_McCall can probably explain the theoretical difficulties here better than I, if he is so inclined.

1 Like

This syntax is already supported; it is equivalent to (a: 42, "foo", c: false). I don't think it needs to be explicitly called out in the proposal.

4 Likes

Neat—I didn’t know that!

Arguably, since (as I understand it) current Swift supports (_: 42, b: 42) as a valid spelling of (42, b: 42) and also supports (_: 42) as a valid spelling of 42, from the end user’s perspective this is pretty indistinguishable from supporting unlabeled 1-tuples with implicit conversion, is it not?

2 Likes

Unfortunately, even the implicit conversion wouldn't quite solve the source break, because implicit conversions are not valid in all possible positions. For example, today G<(_: Int)> and G<Int> are the same type; with this proposal they become two distinct, incomparable types. However, I strongly suspect that (_: T) is not something that people write in practice, ever; however, I could be very wrong.

7 Likes

Currently, the compiler appears (at least cosmetically) to be creating one-element tuples when unwrapping enums:

enum Test {
	case payload(Int)
	case payload2(Int, Int)
}

func foo(test: Test) {
	switch test {
	case .payload(let val):
		print(val)
	}
}

If you use Xcode's autocomplete to ask Swift what the type of val is, you get (Int). This is symmetrical with the case .payload2(let tuple) syntax, which gets your the two elements together in a tuple.

Are there foreseen difficulties here with creating true one-element tuples?

2 Likes

By the way, I don’t think the current problems with the “no 1-tuples” analysis are at all a blocker. Variadic generics are both super important and super thorny, and I trust the intuition that has led to the specific approach being pitched.

3 Likes

Today, the type (Int) is a sugared type equivalent to Int. This won't change with the pitch.

However, if the payload has an argument label, like this:

enum Test {
  case payload(label: Int)
}

Then the pattern .payload(let val) will today assign the type Int to val. I think we should keep this behavior with this pitch for consistency. However, perhaps it should emit a warning suggesting changing the pattern to .payload(label: let val). I'll update the pitch to mention patterns next week. Thanks for bringing this up!

7 Likes

On the other hand, there’s a long-standing idea of changing function signatures so that the labels become part of the type. Maybe the right thing to do is to force the change for pattern matching too?

As a rule, generic code with T == Int dynamically should behave like it would as concrete code with T replaced with Int. (The eternal caveat to this is overloading, but otherwise, this is how it is.) This is just as true with variadic generics.

The fact that a type is a tuple is significant in the type system; tuples behave differently from non-tuples in some ways, and there are things you can with tuple values that you generally cannot do with element values and vice-versa. And that set of operations is likely to grow; for example, we will probably find it useful with variadic generics to add an “expand” operator, usable on tuples in contexts that can take a list of values, which produces the sequence of the elements of that tuple. (This could be used concretely to e.g. conveniently concatenate two tuples; in variadic generics it is important as a way to “escape the tuple trap” if you e.g. need to store all the elements of a value pack somewhere that cannot be a pack, like in a stored property.)

Because tuple-ness is significant in the type system, and generic code is type-checked without knowing the values of the generic arguments, unsubstituted generic code must treat (T…) as a tuple type; and because substitution should not by itself change behavior, substitution with a single-element pack must keep it as a tuple, which means single-element tuples must exist in the type system. Even if the tuple-ness were implicitly converted away when working directly with single-element tuples, they would still exist in secondary positions.

I do think there’s a possibility that this will be annoying for certain patterns and we’ll want to consider implicit injection and projection. Hopefully we’ll be in a position to evaluate that before there’s a significant amount of code relying on single-element tuple behavior.

17 Likes

This is pretty solid reasoning, thanks!

One could argue that this doesn’t preclude truly unifying Int and (Int) by making the former sugar for the latter, effectively inverting the current state of play:

I think it would be very difficult to come up with a workable design, not to mention justify such a massive change. For example, one question that immediately arises is how to handle ((Int, Int)).

Eliminating distinctions is attractive to a certain part of the computer-science brain, but I think it’s been clearly shown to be impractical and undesirable in this case.

3 Likes

Is there a way that we can mitigate the code-size cost of G<Int> and G<(Int)> being separate types?

For example, would it be possible for them to share metadata? Would we be able to guarantee that generic specialisations produce the same code and get de-duplicated? Or are there meaningful differences that would preclude those kinds of optimisations?

Does it make sense for G<Int> and G<(_: Int)> to share conformances if Int and (_: Int) are separate types? Any of G<(_: Int)>’s conformances need to project through the tuple’s first element accessor to get at the Int.

Yes, you were too fast for me - I already removed the part about conformance metadata.

I still think it would be nice if we can reduce that code-size cost. You might end up with something like an Array<(Int)>, and it would be nice if that didn't result in new specialisations and could use as much of Array<Int> as possible.

I suspect the solution to that problem would wind up being more general than just collapsing single-element tuples.

You're right - we do have outlining, which is supposed to look for common sequences of code in the general case. I'm asking if we can guarantee it for these pairs of types; if we could, I'd expect that would be something the Swift compiler would want to implement and test, etc.

Maybe it isn't even possible, though.