Variables, Types and Functions · about 40 min

Tuples and Arrays in Memory

Why this matters

Single numbers only go so far. Real data comes in groups: a point has an x and a y, a function might need to return both a result and a count, a week has seven temperatures. Rust's two built-in compound types, tuples and arrays, are the simplest way to group values. They are also the simplest place to see how compound data is laid out in memory, which explains things you will meet again and again: why sizes are sometimes larger than you expect, why indexes are checked, and what it means for a type to have a size "known at compile time".

Tuples: a fixed group of mixed types

A tuple groups a fixed number of values, which can have different types:

fn main() {
    let person: (&str, i32, f64) = ("Ada", 36, 1.65);
    println!("{:?}", person);
    println!("name: {}", person.0);
    println!("age: {}", person.1);
    println!("height: {}", person.2);
}
("Ada", 36, 1.65)
name: Ada
age: 36
height: 1.65

The type is written like the value: (&str, i32, f64). Elements are accessed by position with .0, .1, .2. A tuple's length and the type of each position are part of its type, so a (i32, i32) and a (i32, i32, i32) are different types, and person.3 is a compile error, not a run-time surprise.

Destructuring

More often you unpack a tuple into named variables with a pattern:

fn main() {
    let point = (3, -4);
    let (x, y) = point;
    println!("x = {x}, y = {y}");
    let (name, _, height) = ("Ada", 36, 1.65);
    println!("{name} is {height} m tall");
}
x = 3, y = -4
Ada is 1.65 m tall

let (x, y) = point; matches the shape on the left against the value on the right and binds each part. _ means "ignore this position". Patterns are a big theme in Rust (module 7); this is the first one you have seen.

Tuples shine as return values: a function can return (quotient, remainder) or (min, max) without defining a new type. You will do exactly that in the next lesson.

The unit type

The empty tuple () is called unit. It has exactly one value, also written (), and it means "nothing interesting". A function that returns no value really returns (); println! returns (). Since there is only one possible value, it takes zero bytes.

Arrays: a fixed number of the same type

An array holds a fixed number of values of one type, stored next to each other:

fn main() {
    let days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
    let temps: [i32; 5] = [12, 15, 9, 20, 17];
    let zeros = [0u8; 8];
    println!("{} has {} days", days[0], days.len());
    println!("{:?}", temps);
    println!("{:?}", zeros);
}
Mon has 7 days
[12, 15, 9, 20, 17]
[0, 0, 0, 0, 0, 0, 0, 0]

To change an element, the array binding must be mut:

fn main() {
    let mut scores = [0; 4];
    scores[2] = 7;
    scores[0] += 1;
    println!("{:?}", scores);
}
[1, 0, 7, 0]

Arrays of arrays give you grids: [[0; 3]; 3] is three rows of three, and grid[r][c] reads row r, column c.

Because the length is fixed, arrays suit data whose size is part of the problem: seven days, RGB colors, a 3x3 board, the 16 bytes of an ID. For lists that grow and shrink you will use Vec (module 9), which is built on the same contiguous layout.

What they look like in memory

An array is its elements laid out back to back, with no extra header. The length is not stored anywhere: it is part of the type, so the compiler knows it.

let temps: [i32; 5] = [12, 15, 9, 20, 17];

 address:  +0    +4    +8    +12   +16   (20 bytes total)
         +-----+-----+-----+-----+-----+
         | 12  | 15  |  9  | 20  | 17  |
         +-----+-----+-----+-----+-----+
 index:     0     1     2     3     4

Indexing is arithmetic: element i is at the start address plus i * 4. That is why indexing is fast (one multiplication and addition) and why indexes start at 0 (the first element is at offset zero).

Tuples are stored as their fields in sequence, but with a twist: alignment. A CPU reads a 4-byte value fastest (and on some machines only) at an address divisible by 4. So the compiler places each field at a suitably aligned offset and adds padding bytes where needed. Rust is also free to reorder tuple fields to reduce padding.

fn main() {
    use std::mem::size_of;
    println!("[i32; 5]       {} bytes", size_of::<[i32; 5]>());
    println!("(u8, u8)       {} bytes", size_of::<(u8, u8)>());
    println!("(u8, u32)      {} bytes", size_of::<(u8, u32)>());
    println!("(u8, u32, u8)  {} bytes", size_of::<(u8, u32, u8)>());
    println!("(u8, u64)      {} bytes", size_of::<(u8, u64)>());
    println!("()             {} bytes", size_of::<()>());
    println!("bool           {} bytes", size_of::<bool>());
}
[i32; 5]       20 bytes
(u8, u8)       2 bytes
(u8, u32)      8 bytes
(u8, u32, u8)  8 bytes
(u8, u64)      16 bytes
()             0 bytes
bool           1 bytes
(u8, u32, u8), as laid out by the compiler (fields reordered):

 offset:  0    1    2    3    4    5    6    7
        +-------------------+----+----+----+----+
        |   u32             | u8 | u8 | pad pad |
        +-------------------+----+----+----+----+

The tuple's total size is rounded up to a multiple of its largest alignment so that arrays of tuples keep every element aligned. You rarely need to think about padding, but it explains why size_of sometimes reports more than the sum of the parts, and it matters when Rust talks to C (module 15).

Both tuples and arrays have a size known at compile time, so they live directly in the stack frame (or inside whatever contains them) with no separate allocation. Remember this property: it comes back in module 4 as the dividing line between data that is cheap to copy and data that is not.

Bounds checking

Every array index is checked:

fn main() {
    let a = [1, 2, 3];
    let i = "3".parse::<usize>().unwrap();
    println!("{}", a[i]);
}
thread 'main' panicked at main.rs:4:20:
index out of bounds: the len is 3 but the index is 3
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Again I hid the index from the compiler by parsing it from text. With a visible constant, rustc refuses to build:

fn main() {
    let a = [1, 2, 3];
    println!("{}", a[3]);
}
error: this operation will panic at runtime
 --> main.rs:3:20
  |
3 |     println!("{}", a[3]);
  |                    ^^^^ index out of bounds: the length is 3 but the index is 3
  |
  = note: `#[deny(unconditional_panic)]` on by default

In C, a[3] would read whatever bytes follow the array in memory, silently. In Rust you get either a compile error or a clean stop with the index and the length in the message. The check costs a comparison per access, and the optimizer removes it whenever it can prove the index is in range.

Common mistakes

Off-by-one on the last element. The last index is len() - 1; a[a.len()] always panics.

Forgetting mut when assigning to an element: E0594, "cannot assign to scores[_], as scores is not declared as mutable".

Mixing types in an array:

fn main() {
    let mixed = [1, "two", 3];
}
error[E0308]: mismatched types
 --> main.rs:2:21
  |
2 |     let mixed = [1, "two", 3];
  |                     ^^^^^ expected integer, found `&str`

Use a tuple if the values have different types.

Indexing with the wrong integer type. Indexes must be usize. With let i: i32 = 1;, a[i] fails with "the type [{integer}] cannot be indexed by i32". Use a[i as usize], or better, make i a usize from the start.

Recap

Going further: print std::mem::align_of::<u64>() and align_of::<(u8, u16)>(). Try let [first, second, ..] = temps; (array destructuring with ..) and see what it binds.

Exercises

Exercise 1 · write code

Destructure and swap

Given the tuple let pair = (3, 8);, use destructuring to create two variables a and b, then print them, then print them swapped by building a new tuple. Output exactly:

a=3 b=8
swapped: (8, 3)

The second line prints a tuple with {:?}.

File: main.rs

Starting point

fn main() {
    let pair = (3, 8);
}

Example

expected output

a=3 b=8
swapped: (8, 3)
Hint 1

let (a, b) = pair; unpacks the tuple into two variables.

Reference solution
fn main() {
    let pair = (3, 8);
    let (a, b) = pair;
    println!("a={a} b={b}");
    let swapped = (b, a);
    println!("swapped: {:?}", swapped);
}

Exercise 2 · write code

Indexing an array

Given let temps = [12, 15, 9, 20, 17];, print the first element, the last element (using temps.len() to compute its index, not the number 4), the number of elements, and the sum of the first three, exactly:

first: 12
last: 17
count: 5
sum of first three: 36

File: main.rs

Starting point

fn main() {
    let temps = [12, 15, 9, 20, 17];
}

Example

expected output

first: 12
last: 17
count: 5
sum of first three: 36
Hint 1

Indexes start at 0. The last index is temps.len() - 1.

Reference solution
fn main() {
    let temps = [12, 15, 9, 20, 17];
    println!("first: {}", temps[0]);
    println!("last: {}", temps[temps.len() - 1]);
    println!("count: {}", temps.len());
    println!("sum of first three: {}", temps[0] + temps[1] + temps[2]);
}

Exercise 3 · quiz

Out of bounds

let a = [1, 2, 3]; and later the program reads a[i] where i was computed at run time and happens to be 3. What happens?

Answer
  • The program panics with "index out of bounds: the len is 3 but the index is 3"

Every index into an array is checked against its length. An out-of-range index stops the program with a panic instead of reading memory that belongs to something else. That one check eliminates the buffer over-read and over-write bugs behind a large share of security holes in C programs. If the index is a constant the compiler can see, it even refuses to compile.

Exercise 4 · quiz

Predict the sizes

On a 64-bit machine, what does this print?

fn main() {
    println!("{}", std::mem::size_of::<[u16; 10]>());
    println!("{}", std::mem::size_of::<(u8, u32)>());
    println!("{}", std::mem::size_of::<()>());
}
Answer
  • 20, 8, 0

An array is its elements back to back: 10 * 2 bytes = 20. The tuple holds 1 + 4 bytes of data, but a u32 must start at an address divisible by 4 (its alignment), so 3 bytes of padding are added and the whole tuple is 8 bytes. The unit type () has exactly one possible value, so it needs no bytes at all.

Exercise 5 · write code

A 3x3 grid

Create a 3x3 grid as an array of arrays, let mut grid = [[0; 3]; 3];. Set the center cell to 5 and the bottom-right cell to 9, then print the grid one row per line with {:?}:

[0, 0, 0]
[0, 5, 0]
[0, 0, 9]

File: main.rs

Starting point

fn main() {
    let grid = [[0; 3]; 3];
    grid[1][1] = 5;
}

Example

expected output

[0, 0, 0]
[0, 5, 0]
[0, 0, 9]
Hint 1

Changing an element changes the array, so the binding needs mut.

Hint 2

grid[row][column]: the bottom-right is row 2, column 2.

Reference solution
fn main() {
    let mut grid = [[0; 3]; 3];
    grid[1][1] = 5;
    grid[2][2] = 9;
    println!("{:?}", grid[0]);
    println!("{:?}", grid[1]);
    println!("{:?}", grid[2]);
}