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]
- The type is
[T; N]: element typeT, lengthN.[i32; 5]and[i32; 6]are different types. The length must be known at compile time. [0u8; 8]is the repeat form: eight copies of0u8.a[i]reads elementi; indexes start at 0 and must beusize.a.len()is the length.- Arrays print with
{:?}.
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
- A tuple
(T1, T2, ...)groups a fixed number of values of possibly different types; access with.0,.1or destructure withlet (a, b) = t;. ()is the unit type: one value, zero bytes, "no meaningful result".- An array
[T; N]holds exactlyNvalues of typeTcontiguously;[x; N]repeats a value. - Index with
a[i](ausize, starting at 0); every index is bounds-checked. - Compound values are laid out field by field with alignment padding;
size_ofshows the result. - Tuples and arrays have compile-time sizes and live inline, on the stack for local variables.
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.