Tech Wiki

TOPICSSERIES

[Rust Zero to Production 04] Work with Rust Tuples, Arrays, and Destructuring

Rust will not read arbitrary memory when an index is out of bounds. An expression such as array[index] panics instead. That is a useful safety boundary, but it is usually the wrong control flow for an index supplied by a user or a network request. In those cases, get returns an Option and lets the caller decide what “missing” means.

This article uses a tuple for one endpoint and an array for four recent latency samples. The examples live in an isolated Rust 2024 Cargo project and pass rustfmt, Clippy, and four tests.

1. Choosing Tuples or Arrays

Tuples and arrays are fixed-length types. Their element rules differ.

  • A tuple can hold a different type at each position. (&str, u16, bool) is reasonable for moving a few related values across a small local boundary.
  • Every element of an array has the same type. In [u32; 4], u32 is the element type and 4 is the length. The length belongs to the type.

Tuple fields are also available as .0, .1, and so on. That notation loses meaning quickly as the tuple grows. The three-field tuple below stays small because its job is to demonstrate destructuring. Data that crosses several APIs usually deserves a struct with field names.

pub type EndpointSample<'a> = (&'a str, u16, bool);

pub fn describe_endpoint(sample: EndpointSample<'_>) -> String {
    let (host, port, uses_tls) = sample;
    let scheme = if uses_tls { "https" } else { "http" };
    format!("{scheme}://{host}:{port}")
}

The left side of let (host, port, uses_tls) = sample; is a pattern. Each name binds one position in the tuple. A tuple of the wrong length or incompatible field types does not compile, so the destructuring step checks the shape for you.

2. Array Lengths Are Types

These declarations create different types:

let short: [u32; 3] = [118, 121, 117];
let window: [u32; 4] = [118, 121, 117, 125];

A function parameter of [u32; 4] requires exactly four values before its body runs. That is a good fit for a small window whose size is an invariant. If the number of samples must grow or shrink at runtime, use Vec<T> instead; the next article covers it.

Rust can fill an array by repeating one value:

let initial_backoff_ms = [250_u64; 4];
assert_eq!(initial_backoff_ms, [250, 250, 250, 250]);

An ordinary repeat operand is evaluated once and then copied as many times as needed. For a length greater than one, it must have a Copy type, be a const block, or name a constant item. Integers and booleans are the least surprising place to start.

3. Array Destructuring

Square-bracket patterns destructure arrays. Patterns match a value's structure and can bind its pieces. _ ignores one item; .. covers the remaining items.

pub fn split_window(samples: [u32; 4]) -> (u32, [u32; 2], u32) {
    let [first, middle @ .., last] = samples;
    (first, middle, last)
}

first and last bind the two ends. middle @ .. binds the two values between them as [u32; 2]. Because the input type fixes the length at four, this let pattern cannot fail.

Use _ when a position does not matter:

let [first, _, _, last] = [118, 121, 117, 125];
assert_eq!((first, last), (118, 125));

A wildcard does not create a binding. A name such as _middle does create one, despite suppressing the unused-variable warning. That distinction matters for non-Copy values and will return in the ownership part of this series.

4. Failure Contracts: [] vs. get

Direct indexing is concise when the index is visibly valid, as in samples[0]. A runtime index from a request, file, or command-line argument is different. The indexing operator must produce an element, so it panics when no element exists. An index greater than or equal to the array length fails the runtime bounds check and panics.

Use slice::get when absence is an expected outcome. Array references can use slice methods, and get returns Option<&T>.

pub fn sample_at<const N: usize>(samples: &[u32; N], index: usize) -> Option<u32> {
    samples.get(index).copied()
}

N is a const generic parameter, so the function accepts u32 arrays of any length without discarding their array type. get(index) produces Option<&u32>. Since u32 is Copy, copied() turns that into Option<u32>.

The caller now has to state its missing-value policy:

let samples = [118, 121, 117, 125];

match sample_at(&samples, 2) {
    Some(milliseconds) => println!("sample[2]={milliseconds}ms"),
    None => println!("sample[2] is missing"),
}

assert_eq!(sample_at(&samples, 9), None);

if let, let else, and methods such as unwrap_or are alternatives. Whichever form you choose, the return type exposes the possibility that the index has no corresponding element.

5. Defining a Panic Boundary

A panic is not automatically a bug. Out-of-bounds indexing is an unrecoverable error. A panic can be appropriate when a test checks an internal invariant or when continuing would mean the program's assumptions are already broken.

User input is not an internal invariant. A bad request index should normally become None or, once more context is needed, a Result. If a library function panics on ordinary caller input, the caller loses the chance to choose a recovery policy.

The example confines the indexing panic to a test:

#[test]
#[should_panic(expected = "index out of bounds")]
fn indexing_panics_when_the_invariant_is_broken() {
    let samples = [118, 121, 117, 125];
    let impossible_index = samples.len();
    let _ = samples[impossible_index];
}

This is not an invitation to use panics as application branching. It verifies the contract of [] in a controlled test process. A separate test checks that sample_at returns None on the ordinary failure path.

6. Run the Array Example

The example has no third-party dependencies. Create a standalone Rust 2024 project from any working directory:

cargo new --name article-04-tuples-arrays article-04-tuples-arrays
cd article-04-tuples-arrays

Replace Cargo.toml with:

[package]
name = "article-04-tuples-arrays"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]

Create src/lib.rs with the complete library and its four tests:

pub type EndpointSample<'a> = (&'a str, u16, bool);

pub fn describe_endpoint(sample: EndpointSample<'_>) -> String {
    let (host, port, uses_tls) = sample;
    let scheme = if uses_tls { "https" } else { "http" };
    format!("{scheme}://{host}:{port}")
}

pub fn split_window(samples: [u32; 4]) -> (u32, [u32; 2], u32) {
    let [first, middle @ .., last] = samples;
    (first, middle, last)
}

pub fn sample_at<const N: usize>(samples: &[u32; N], index: usize) -> Option<u32> {
    samples.get(index).copied()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn destructures_a_tuple() {
        let endpoint = ("status.example.com", 443, true);
        assert_eq!(
            describe_endpoint(endpoint),
            "https://status.example.com:443"
        );
    }

    #[test]
    fn destructures_a_fixed_array() {
        let short: [u32; 3] = [118, 121, 117];
        let window: [u32; 4] = [118, 121, 117, 125];
        assert_eq!(short.len(), 3);
        assert_eq!(window.len(), 4);

        let initial_backoff_ms = [250_u64; 4];
        assert_eq!(initial_backoff_ms, [250, 250, 250, 250]);

        let [first, _, _, last] = window;
        assert_eq!((first, last), (118, 125));
        assert_eq!(split_window(window), (118, [121, 117], 125));
    }

    #[test]
    fn get_makes_out_of_bounds_access_explicit() {
        let samples = [118, 121, 117, 125];
        assert_eq!(sample_at(&samples, 2), Some(117));
        assert_eq!(sample_at(&samples, 9), None);
    }

    #[test]
    #[should_panic(expected = "index out of bounds")]
    fn indexing_panics_when_the_invariant_is_broken() {
        let samples = [118, 121, 117, 125];
        let impossible_index = samples.len();
        let _ = samples[impossible_index];
    }
}

Replace src/main.rs with:

use article_04_tuples_arrays::{describe_endpoint, sample_at, split_window};

fn main() {
    let endpoint = ("status.example.com", 443, true);
    println!("{}", describe_endpoint(endpoint));

    let samples = [118, 121, 117, 125];
    let (first, middle, last) = split_window(samples);
    println!("first={first}, middle={middle:?}, last={last}");

    match sample_at(&samples, 2) {
        Some(milliseconds) => println!("sample[2]={milliseconds}ms"),
        None => println!("sample[2] is missing"),
    }
}

From the new article-04-tuples-arrays directory, verify and run it:

cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

The program printed:

https://status.example.com:443
first=118, middle=[121, 117], last=125
sample[2]=117ms

The four tests cover tuple destructuring, array destructuring, successful and failed get access, and the expected indexing panic. The project uses rustc 1.98.1, Cargo 1.98.1, rustfmt 1.9.0-stable, Clippy 0.1.98, and Rust 2024 edition.

7. Safe Access Rules

Use a tuple for a few heterogeneous values with a short, local lifetime. Use an array when the program requires an exact number of same-typed values. Destructuring transfers that shape into named bindings without manual indexing.

Choose direct indexing only when validity is an internal invariant. When a missing element is part of normal input handling, use get and return an Option. The Option in the API signature tells every caller that it must handle absence rather than expect a value.

The next installment moves to data whose size changes at runtime: Vec, owned text with String, and key-based lookup with HashMap.

Full source code

The complete runnable source for this article is available in the Chapter 04 project on GitHub.

Sources


One response

  1. […] Previous articleWork with Rust Tuples, Arrays, and Destructuring […]

Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.