Tuples and records have become quite popular in modern languages, with first-class syntax for both. But why are they kept as separate features? Can they be unified as just one template for product types?
What is a Tuple?
In mathematics, a tuple is a sequence of objects. This means there is an order to the objects and, unlike sets, objects can be repeated at different positions.
The term comes from a generalisation of how we refer to groups of items.
- Single
- Double
- Triple
- Quadruple
- Quintuple
- Sextuple
- Septuple
…and so on.
Because they are ordered, they were popularised by functional programming as a way to have product types with anonymous, positional fields.
newtype User = User (String, Int, Bool)
printUser :: User -> String
printUser (User (name, age, isVip)) =
"name: " ++ name ++ ", age: " ++ show age ++ ", isVip: " ++ show isVip
main :: IO ()
main = do
let user = User ("Kojo", 20, True)
putStrLn $ printUser user
The same can be done in Rust:
pub struct User(&'static str, u32, bool);
pub fn print_user(user: &User) -> String {
let User(name, age, is_vip) = user;
return "name: ".to_string() + name
+ ", age: " + &age.to_string()
+ ", is_vip: " + &is_vip.to_string();
}
pub fn main() {
let user = User("Kojo", 20, true);
println![ "{}", print_user(&user) ];
}
But these examples are problematic. The meaning of the fields under User are only made apparent through the pattern matching happening in the printUser and print_user functions. It is not evident from the definition itself what each field is supposed to represent. This is where records come in.
What is a Record?
A record is a product type with labelled fields, and solves our issue:
data User = User {
userName :: String,
userAge :: Int,
userIsVip :: Bool
}
printUser :: User -> String
printUser User { userName, userAge, userIsVip } =
"name: " ++ userName
++ ", age: " ++ show userAge
++ ", isVip: " ++ show userIsVip
main :: IO ()
main = do
let user = User { userName = "Kojo", userAge = 20, userIsVip = True }
putStrLn $ printUser user
pub struct User {
name: &'static str,
age: u32,
is_vip: bool,
}
pub fn print_user(user: &User) -> String {
let User { name, age, is_vip } = user;
return "name: ".to_string() + name
+ ", age: " + &age.to_string()
+ ", is_vip: " + &is_vip.to_string();
}
pub fn main() {
let user = User { name: "Kojo", age: 20, is_vip: true };
println![ "{}", print_user(&user) ];
}
The User types are no longer just lists of unlabelled types.
But what if we wanted to mix both unlabelled and labelled fields?
Tuples are Records
The tuple (x, y, z) can be thought of as equivalent to the record with implicit indexing { 0: x, 1: y, 2: z }. From this, it is not hard to then imagine a mixing of positional and labelled fields:
let user = User { "Kojo", age: 20, is_vip: true };
For whatever reasons, this is simply not possible in Rust. In fact, it is one of the big reasons I dislike writing Rust code. Instead, the closest you can get is this:
pub struct Age(u32);
pub enum IsVip { Yes, No }
pub struct User(&'static str, Age, IsVip);
impl Age {
pub fn to_string(&self) -> String {
let Age(age) = self;
return age.to_string();
}
}
impl IsVip {
pub fn to_string(&self) -> String {
match self {
IsVip::Yes => "yes".to_string(),
IsVip::No => "no".to_string(),
}
}
}
pub fn print_user(user: &User) -> String {
let User(name, age, is_vip) = user;
return "name: ".to_string() + name
+ ", age: " + &age.to_string()
+ ", is_vip: " + &is_vip.to_string();
}
pub fn main() {
let user = User("Kojo", Age(20), IsVip::Yes);
println![ "{}", print_user(&user) ];
}
There are those in the Rust community who delude themselves that this is reasonable, but it most certainly is not. It would be much nicer if the language simply supported mixing of labelled and unlabelled fields.
For instance, Python supports:
class User:
name: str
age: int
isVip: bool
def __init__(self, name: str, /, *, age: int, isVip: bool):
self.name = name
self.age = age
self.isVip = isVip
def __repr__(self):
return f"name: {self.name}, age: {self.age}, isVip: {self.isVip}"
user = User("Kojo", age = 20, isVip = True)
print(user)
The / and * in the function parameter list ensure that name cannot be labelled in the function call and both age and isVip must be explicitly labelled.
I cannot speak as to why Rust does not support something like this, but it sure is a damn shame, and one of many reasons I am looking to Mojo as my systems-level programming language of choice (coming from C++).
Merge Tuples and Records
Although Python supports positional and labelled arguments through its functions, I’m yet to discover a language that supports both for actual type/struct definitions. What I really want is something akin to this:
type User = (
String,
age: Int,
isVip: Bool,
);
let user = User("Kojo", age = 20, isVip = True);
You can think of the above code as a more implicit version of this syntax:
type User = (
0: String,
age: Int,
isVip: Bool,
);
let user = User(0 = "Kojo", age = 20, isVip = True);
Indeed, perhaps it is an idea to support integers as indices, both implicitly and explicitly. That way, the following would be possible:
type Vec2 = (Int, Int);
let size = Vec2(1 = 720, 0 = 1080);
But I don’t think that’s necessarily a good idea. In these cases, it makes sense to just label:
type Vec2 = (x: Int, y: Int);
let size = Vec2(y = 720, x = 1080);
In Conclusion…
There is a great benefit to unifying tuples and records like function parameters do in some languages. Not only is it more flexible and avoids needing whacky, tedious workarounds that will have users reaching for an LLM, but it also just generally reduces the language’s complexity - instead of two similar features, you intuitively have one powerful feature.
There’s also an additional benefit that doesn’t seem to be on many people’s radars: it would allow us to unify function parameters and type definitions!
But that’s a discussion for a future article…
