[Pitch] One-Element Tuples

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!

6 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.

Unless the generic type is prespecialized (iirc), there wouldn't be any code size hit to metadata at all because there's a single generic metadata structure generated for generic types. Specialized metadata for things like G<Int> and G<Bool> are all instantiated at runtime using this single generic metadata structure.

3 Likes

if someone is looking for a project, they could create a swift-syntax-based tool that searches for this pattern across the packages indexed in swiftpackageindex.com, and then we would know for sure.

in the absence of this knowledge, i would prefer if (_: T) became something that generated a compiler warning in say, swift 5.8, and only change the semantics of this spelling in 5.9.

1 Like

While I agree with comments that there is a certain conceptual elegance to the "no single-element tuples" rule, I am also of the opinion that maintaining this model is not really worth other ergonomic sacrifices (especially once we imbue tuples with additional functionality, as John notes). This pitch makes sense to me.

I also like the (_: T) for unlabeled single-element tuples spelling compared to alternatives that have been proposed in the past like (T,).

Are there situations where switching (_: T) to mean "single-element tuple" instead of "just T" would actually change the semantics of Swift code today? I.e., can we contrive code which would change its behavior once the meaning of (_: T) changes in the manner pitched, rather than a) continue to work the same or b) fail to compile?

3 Likes

Anything involving dynamic casts comes to mind. Here’s a contrived example:

protocol P {}
extension Int: P {}

let x: Any = (_: 3)
let y: P? = x as? P

The above continues to compile, but now y is nil instead of .some(3 as P).

7 Likes

It’s always the dynamic casts, isn’t it. :slightly_smiling_face:

Haven't read the full details but yes please, ship it!

Just a brainstorming idea. Instead of (_: Int) can't we do something similar like with keywords?

`(Int)` != (Int)

This sounds good to me. Especially if it unblocks work on variadic generics.

enums are a good place to focus—they're confusing in regards to this pitch and working through that will be helpful.

For example, they seem to do this:

…because these seem to be equivalent.

enum Enum<T> {
  case unlabeled(T)
  case labeledWithUnderscore(_: T)
  case tupleWithUnderscore((_: T))
}

You can create them, but it can be painful to do so because we're not given access to their types.

3 Likes