Swift and labeled types
I just want to give a few precisions about Swift and its treatment of named and positional things.
In Swift, functions always have positional arguments only. If you write this:
func divide(a: Int, b: Int) -> Int { a / b }
The compiler will enforce that a
appears before b
at all call sites.
divide(b: 2, a: 6) // error: argument 'a' must precede argument 'b'
The reason is that Swift lets you add labels on positional parameters. Those labels don’t even have to match the name of the parameters:
func divide(dividend a: Int, divisor b: Int) { a / b }
print(divide(dividend: 6, divisor: 2))
Why is this important? Because we can also understand the “names” of a tuple the same way. They are just labels for positional elements. All tuples in Swift, named or otherwise, can be accessed via integer indices.
func divide(_ a: Int, by b: Int) -> (quotient: Int, remainder: Int) {
(quotient: a / b, remainder: a % b)
}
let x = divide(6, by: 2)
print(x.0) // 3
print(x.remainder) // 0
So one way to think about named tuples is to see them as just tuples with some information that lets the compiler relate a name to a position. This interpretation gives us more lenience to define subtyping and/or conversion.
About subtyping and conversions
I have to admit that I’m not particularly moved by the beauty of the theory and/or implementation of named <: unnamed
or unnamed <: named
. What matters to me is how useful the relation will be in my programs, and part of that includes intuition. It does not take mental gymnastic to understand that throw E
returns Nothing
, regardless of what that particular interpretation of throws
buys for the calculus or the implementation. The same can’t be said about the arguments that have been presented in favor of unnamed <: named
.
The subtyping relationship in Swift between types with labels is murky so I don’t think it is necessarily something to emulate. My (likely unpopular here) personal opinion is that not having a subtyping relationship at all might not be a bad bet. I’d also add that it’s always easier to relax constraints than tightening the screws after the fact. So perhaps it would be best to start without subtyping and identify where exactly that choice causes unbearable pain.
That being said, one thing that gets annoying without implicit conversion is the boilerplate necessary to create new tuple instances. Let me rewriting my Swift example in Scala to illustrate:
def divide(a: Int, b: Int) -> (quotient: Int, remainder: Int) = {
(quotient = a / b, remainder = a % b)
}
I claim that it would be mighty convenient if we didn’t have to repeat the labels/names of the tuple in the return value. Similarly, if we have a method def f(x: (a: Int, b: Int))
we probably want to be able to call f((1, 2))
. But I also claim that the conversion isn’t that important in other use cases. For example, I don’t think it is the end of the world if the compiler complains when I write this:
def foo((Int, Int)) -> Int = ???
foo(divide(6, 2))
After all, there is a very real possibility that I misused the result of divide. Having to pause and say “here’s how you get from (quotient: Int, remainder: Int)
to (Int, Int)
” might actually be beneficial for the understandability of this code.
I think one way to define very simple conversions between named and unnamed tuples is to restrict them to tuple literal expressions. If locally the compiler is able to infer the named that we left out, then all is well. Otherwise, (a: T, b: U)
is neither super type nor subtype of (T, U)
and the user must take the appropriate step to convert their types. We can always bikeshed syntax for that.
About the motivations for named tuples
There are many reasons why I’m not riding the subtyping train, but for tuples specifically, one is that I don’t think tuples should be a substitute for named types. In fact, I claim that the opening example shows a bad use case for named tuples:
type Person = (name: string, age: Int)
val amy: Person = (name = "Amy", age = 33)
What is the argument for not having defined a case class here? For essentially the same number of keystrokes, we get a type that also supports pattern matching and for which subtyping is clearly defined and unambiguous. So if we want to do fancy things with implicit conversions on assignment or at function boundaries, we already have the right tools for the job.
The fact that a case class has a heavier bytecode footprint isn’t a very compelling argument to me either. It’s good to know if I have to optimize my code one day but otherwise I’ll always lean on the side of using fewer features.
What I think is far more compelling is to have
a convenient lightweight way to return multiple results from a function
This use case doesn’t deserve a sophisticate subtyping relationship, only a simple way to create instances, match on them, and select their members. The simple conversion scheme that I described above is sufficient for that.
FWIW, I’ll add that in my experience with Swift, a lot of code starts with a tuple (labeled or not) and ends with a named struct because eventually one wants to properly document a type and their properties. So most uses of tuples in Swift are at function boundaries in things like Dictionary.init(uniqueKeysWithValues: Sequence<(key: Key, value: Value)>)
.
I’m not at all familiar with database oriented applications so I won’t comment on it. I’ll only say that I strongly suspect database people have thought of ways to deal with records sharing names and that is where we should look for answers if we haven’t yet.
Other possibly terrible ideas
If we adopt the view that “names” are merely labels for positional things, then there are a few restrictions we can lift.
For example:
It is illegal to mix named and unnamed elements in a tuple
Why? That is perfectly fine in Swift:
let x: (a: Int, String) = (1, "hello")
print(x.1)
We can just get tuples that happen to not have labels for some specific elements. Anyway, we can still access those elements using their position, as shown in the example.
or to use the same same name for two different elements.
Why?
That’s a little more experimental (at least we can’t do it in Swift) but we could simply say that if multiple elements have the same label, then the compiler reports an ambiguity if we try to use it. Again, all elements can be unambiguously accessed by their position anyway.
I think this approach also solves the problem of concatenating two tuples with overlapping names. We just get one whose unambiguous elements can be accessed by name and the other must be accessed by position. The label information is still useful because if we later split the combined tuple we might be able to unambiguously name its parts.
Inventing syntax and APIs because I don’t know how to express these operations in Scala:
val x = (a = 1, b = 2)
val y = (c = 3, b = 4)
// z has type (a: Int, b: Int, c: Int, b: Int)
val z = x ++ y
// compile-time error
print(z.b)
// OK
print(z(1))
// w has type (a: Int, b: Int, c: Int)
val (w, _) = z.splitAt(3)
// OK
print(z.b)