[Pitch] One-Element Tuples

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

Thank you. This kind of statement is exactly what we need to help us with Swift! Is there a list of such identities anywhere?
(IIRC, such identities were key to Iverson's thinking in designing APL.)

The labeled one-element tuple could also unblock tuple-desctructuring of types with a single stored property. Besides the somewhat odd type signature for the unlabeled tuple, the proposal is a great win for Swift.

6 Likes

It would be nice to see this workaround go away.

2 Likes

Allowing for single-element labeled tuples is reasonable, but I'm pretty strongly against letting single-element unlabeled tuples become different from the element type, and I think it'll lead to a net increase in the need for overloads, maps, and other shimming overhead to deal with the difference once variadic APIs proliferate. I would much rather see us design the capabilities of variadics and tuples going forward to avoid the need to introduce that difference, and so far, I haven't seen a reason we absolutely need to.

I wouldn't look at this as "hiding one-element tuples from the user" and doing an implicit conversion. If (U...) with U... == Int expands to (Int), and (Int) is canonically equivalent to Int, then it's already the same type on the other side of the variadic substitution, and no conversion is necessary.

If "a single element tuple is the same as the element type", then also, "every type is a single-element tuple of itself", so treating (T...) as a tuple is still reasonable even when T... contains only one element. Generic values are constrained to the set of operations required by their constraints, so the main hazard I see here for the value-as-tuple to diverge in behavior from the view-as-scalar is if we introduce tuple conformances, and the conformance for (_: T) could behave differently from the conformance for T. To prevent that, we could constrain the ability for user-defined tuple conformances in such a way that the single-element case cannot be independently controlled, perhaps by only allowing you to specify a conformance for (T...): P where T...: P, and only allowing the () and (Head, Tail...) cases to be defined by the conformance.

A few people noted that the metadata increase isn't necessarily a concern, but the bigger problem I see here is the performance/code overhead of having to map between these types, since it's very unlikely code really wants to work with a G<(_: Int)>. Types that are naturally "functors" like Array have a map operation that can transform an Array<(_: T)> to Array<T>, but that operation may be expensive, and many types may not even have a map that makes sense.

14 Likes

It is great that this is finally tackled — but as I really don't like "solving" problems with underscores:
What is wrong with (: element)?
It is not mentioned as an alternative, but imo it is a better fit.

As discussed above, (_: element) is an existing production in Swift and this spelling is already permitted in multi-element tuples.

2 Likes

And as noted above, it currently means something other than a single-element tuple:

  1> typealias foo = (_: Int)
  2> let x: foo
x: foo = 0
  3> type(of: x)
$R0: foo.Type = Int

Another alternative that does not conflict with this existing spelling is (0: «type»), since the syntax for extracting the first element is myTuple.0.

1 Like

The generalized form of this equivalence rule is (T) === T. By recursive application, therefore, ((T)) === (T) === T. I don’t think it’s possible to ban this recursion and keep other useful operations like concatenation on type packs.

This leads to the question of how typealias Void = () behaves under this equivalence. By the above rule, it is canonically equivalent to the types (Void), ((Void)), etc. But there doesn’t seem to be any scalar type to which Void is canonically equivalent. Void can’t both be a type and be equivalent to “no type at all”.

By that logic, (T…) concat (Void) === (T…, ()). This seems kind of unfortunate; it would be much more convenient for doing variadic things if (T…) concat (Void) === (T…), just like how [1, 2, 3] concat [] === [1, 2, 3]. But what definition of (T…) concat (U…) === (T…, U…) admits that behavior and is also compatible with the base case of the (((Void))) recursion?

4 Likes

I don't see how this follows. [] isn't equivalent to "no array at all"—for instance, when promoted to an optional array, it isn't equivalent to nil—but appending the contents (elements) of an empty array to another array is an identity function. The same would apply here.

1 Like

The difference is that there’s no rule that [[1]] == [1]. Therefore you don’t wind up having to address the question of “how do the contents of an empty list differ from the contents of a list whose sole element is the empty list”.

2 Likes

Wait? What? (T..., ()) is certainly the expected result, to me. And, imho, the desirable result.
Remember, Void isn't equivalent to "no type", but to the "unit type". That is, a type with exactly one element.

I do think, however, that (T...) concat Never should perhaps produce (T...)

1 Like

Right—I’m also curious how we handle operations on tuples while maintaining the single-element/underlying type equivalence. I suppose we could have a rule that such operations are not permitted on any value known statically to be single-element, so that the only possible result of (T...) concat () is (T...). Or at least unconditionally prefer operating on the tuple to operating on the single-element tuple one level up?