Matteo Di Pirro I am an enthusiastic young software engineer who specialized in the theory of programming languages and type safety. I enjoy learning and experimenting with new technologies and languages, looking for effective ways to employ them.

Practical use cases for Rust tuples

5 min read 1583 105

Practical use cases for Rust tuples

Tuples are collections of any number of values of different types. They are sometimes useful when functions have to return multiple results.

In this article, we’re going to dive into tuples in Rust and, in particular, into the differences with respect to Rust structs.

ToC:

An overview of Rust tuples

Rust’s documentation defines tuples as a “finite heterogeneous sequence” of values:

  • “Finite” means that they have a fixed length — that is, the number of values they can store is fixed. In computer science, the length of a tuple is also called “arity”; tuples with different arities form different types
  • “Heterogeneous” means that the elements of the tuple may have a different type
  • “Sequence” means that we access the elements in the tuple using their index. In other words, the elements of a tuple have no name

We can create a tuple simply by writing a comma-separated list of values inside parentheses, optionally specifying the types:

fn main() {
    let immutable: (String, u8) = (String::from("LogRocket"), 120);
    println!("{} {}", immutable.0, immutable.1);
}

In the example above, we created a simple tuple called immutable, where the first value is a String and the second an unsigned integer with 8 bits. Then, we printed the elements of the tuple by accessing them by index, starting from 0. When run, the example prints “LogRocket 120”.

Tuples are, by default, immutable. For example, if we tried to assign a new value to one of the elements of the tuple above, we’d get an error:

fn main() {
    let immutable: (String, u8) = (String::from("LogRocket"), 120);
    immutable.1 = 142; // cannot assign
}

To define the tuple as mutable, we have to specify mut at declaration time:

fn main() {
    let mut mutable = (String::from("LogRocket"), 120);
    mutable.1 = 142;
    println!("{} {}", mutable.0, mutable.1);
}

In either case, we can destruct a tuple to initialize other variables:

fn main() {
    let mut mutable = (String::from("LogRocket"), 120);
    let (name, value) = mutable;
    println!("{name}");
}

The program above will just print “LogRocket”.

Lastly, we can assign an alias to a tuple type using type:

type Test = (String, u8);

fn main() {
    let immutable: Test = (String::from("LogRocket"), 120);
    println!("{} {}", immutable.0, immutable.1);
}

This makes the code more readable. It is important to note, however, that what we defined with type is just an alias. Test above and (String, u8) are structurally equivalent.

Structs and tuple structs

In this section, we’re going to introduce two alternatives to tuples, namely structs and tuples structs. If tuples are anonymous types with anonymous fields, structs are named types with named fields. Tuple structs are in the middle of the spectrum, as they provide us with named types with anonymous fields.

Structs

structs are similar to tuples in that they can handle multiple values of different types. The main difference is that the elements of a struct all have a name. Hence, instead of accessing them by index, we access them by name:

struct Person {
name: String,
age: u8
}

fn main() {
let p = Person {
name: String::from("Jhon Doe"),
age: 21
};

println!("Name: {}, age: {}", p.name, p.age);
}

Generally speaking, the ability to name the elements of a struct makes the latter more flexible. Hence, for the sake of readability, it is often preferable to use a struct rather than a tuple. Nonetheless, this is not always true, as we’ll see in the next section.

Tuple structs

Tuple structs are the same as structs, with the only difference being that we don’t have to name their fields:

struct Wrapper(u8);

fn main() {
    let w = Wrapper{0: 10};
    println!("{}", w.0);
}

Since the fields don’t have a name, we must access them by index. In the example above, we define a struct to wrap a u8. Then, to read and write it, we have to refer to it using its index, 0.

Comparing tuples, structs, and tuple structs

This section compares tuples, structs, and tuple structs. First, we’ll look at a few key low-level differences. Then, we’ll see practical use cases for each of them.

Implementation differences

Since tuple structs are just structs with no name for the fields, in this section, we’ll mainly compare tuples with tuple structs. What we’ll see for tuple structs is true for structs as well. As a matter of fact, tuple structs desugar into regular structs.



First, the elements of a tuple struct are private by default, and cannot be accessed outside the module they’re defined in. Additionally, tuple structs define a type. Hence, two tuple structs with fields of the same type are two different types.

On the other hand, two tuples whose elements are of the same type define a single type. In other words, tuples are structurally equivalent, whereas tuple structs are not.

Secondly, we can add attributes to tuple structs, such as #[must_use] (to issue a warning when a value is not used) or #[derive(...)] (to automatically implement traits in the struct). Tuples, on the other hand, cannot have attributes but do implement a few traits by default.

Thirdly, tuple structs implement the Fn* family by default, allowing them to be invoked like functions or passed in as parameters to higher-order functions:

struct Name(String);

fn main() {
    let strings = [String::from("Log"), String::from("Rocket")];
    
    let names = strings.map(Name);
}

Furthermore, tuple structs support the struct update syntax, simplifying the way we can create a new instance of a struct where most of the values are equal to another instance of the same struct:

struct Person(String, u8);

fn main() {
    let john = Person {
            0: String::from("John"),
            1: 32
    };
    let another_john = Person {
            1: 25,
            ..john
    };
    
    println!("name: {}, age: {}", another_john.0, another_john.1);
}

In the example above, we first created an instance of Person, john, and then used it to create a new one, another_john, editing only a subset of the fields defined by Person.

Lastly, we can define methods on tuple structs, whereas we cannot on tuples:

struct Person(String, u8);

impl Person {
    fn is_adult(&self) -> bool {
            return &self.1 >= &18;
    }
}

fn main() {
    let p = Person {
            0: String::from("John"),
            1: 20
    };
    
    println!("{}", p.is_adult());
}

Use cases

As a rule of thumb, we should use tuple structs as soon as the name of the type carries semantic information. Then, we should move to structs when there’s more than one field.

Nonetheless, in some cases, it is just more readable not to have names. For example, the as_chunks method of slice is defined as follows:

pub fn as_chunks<const N: usize>(&self) -> (&[[T; N]], &[T])

It basically inputs a constant N, defining the size of the chunk, and returns a tuple where the first element is an array of chunks of size N, whereas the second element is the remainder of the array (that is, the last values that were not enough to compose a new chunk).

In this case, the types themselves make it clear what each element of the result represents. Hence, having names would likely be overkilling. A tuple is a good solution in this case.


More great articles from LogRocket:


Nonetheless, we normally work with complex data types, where having names helps us read the code. For example, consider a program that manipulates book information, in particular the title, the author, and the price. If we were to model it with a tuple, we might come up with:

type Book = (String, String, f32)

Our definition of Book, however, contains two String fields. How can we know which of those represents the title and which the author? In this case, a struct is a much better choice:

struct Book {
    title: String,
    author: String,
    price: f32
}

Still, sometimes we may find ourselves working with structs with only one element. We may want to do so, for instance, to use types to further enhance the clarity of our code. Consider a function to build a URL out of three components:

  • subdomain
  • domain name
  • top-level domain

At first, we might come up with a function with the following signature:

fn make_url(
    subdomain: String,
    domain_name: String,
    top_level_domain: String
) -> String {
    todo!();
}

fn main() {
    make_url(
        String::from("www"),
        String::from("mydomain"),
        String::from("com")
    );
}

We still have lots of Strings in a single structure here, though, and it’s very easy to confuse the ordering of the parameters. Furthermore, Rust doesn’t support named parameters, so we have to remember the correct ordering.

Using tuple structs, we can write a more self-explanatory signature:

struct Subdomain(String);
struct DomainName(String);
struct TopLevelDomain(String);
struct URL(String);

fn make_url(
    subdomain: Subdomain,
    domain_name: DomainName,
    top_level_domain: TopLevelDomain
) -> URL {
    todo!();
}

fn main() {
    make_url(
            Subdomain(String::from("www")),
            DomainName(String::from("mydomain")),
            TopLevelDomain(String::from("com"))
    );
}

In the example above, we leveraged tuple structs to wrap Strings. In this case, it’d make no sense to name the field of each struct. In fact, it’d make the code more complex to read.

Conclusion

In this article we dove into tuples in Rust. First, we briefly saw what they are and how to use them. Secondly, we briefly introduced possible alternatives, namely structs and tuple structs. Lastly, we compared all those alternatives by looking at low-level implementation details as well as practical use cases for each of them.

In the end, they can be used to fulfill more all less the same use cases. What really matters in the choice is, as usual, how they impact the readability and maintainability of the code.

LogRocket: Full visibility into web frontends for Rust apps

Debugging Rust applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.

Modernize how you debug your Rust apps — .

Matteo Di Pirro I am an enthusiastic young software engineer who specialized in the theory of programming languages and type safety. I enjoy learning and experimenting with new technologies and languages, looking for effective ways to employ them.

Leave a Reply