5.8 Array Type: Fixed-Length Homogeneous Collections
Right, arrays. Let’s talk about the workhorse, the trusty (if sometimes rigid) container that you’ll use more often than you’ll check your email. An array is the simplest way to say, “I need exactly N things of exactly the same type, and I want them right next to each other in memory.” It’s a fixed-length, homogeneous collection. Let’s unpack that jargon.
Fixed-length means you declare its size upfront and that’s it. No take-backsies. The array’s length is part of its type signature. A [i32; 5] is a completely different and distinct type from a [i32; 6]. This is Rust being its usual brutally honest self with the compiler: it needs to know exactly how much stack space to allocate for your array, and it can’t do that if the size might change.
Homogeneous is the easy part: every single element in the array must be of the same type. You can’t mix integers and strings in there. If you try, the compiler will give you a look of profound disappointment.
Declaring and Initializing Arrays
You’ve got a few ways to bring one of these into the world. The most explicit way is to specify the type and the size in square brackets.
// I want an array of 5 integers, initialized to zero.
let explicit: [i32; 5] = [0, 0, 0, 0, 0];
That’s a bit verbose. Let’s use a shortcut. If you want every element to have the same initial value, you can use the [value; length] syntax. This is massively more convenient and the compiler will literally generate the same code.
// Same result, far less typing. This is the way.
let convenient = [0; 5];
println!("{:?}", convenient); // Prints [0, 0, 0, 0, 0]
Of course, you can also just list the values out if they’re different. The compiler is smart enough to count.
let dream_team = ["Alice", "Bob", "Charlie"];
// The type is inferred as [&str; 3]
Accessing Elements: The Bounds of Sanity
You access elements using indexing with the square bracket notation, starting at 0. This is standard issue for most languages.
let first = dream_team[0]; // "Alice"
let second = dream_team[1]; // "Bob"
Now, here’s where we get to one of Rust’s core philosophies: safety, by default, always. What happens if you try to access dream_team[99]? In many languages, you’d get undefined behavior, a crash, or security vulnerability. In Rust, this is a panic. At runtime, it will stop the program dead in its tracks with a clear error message.
// let oops = dream_team[99]; // This will panic at runtime.
This is a good thing. It’s a controlled crash. It’s far better than silently reading garbage memory. But wait, it gets better. If you try this with a constant index and the compiler can prove it’s out of bounds at compile time, it’s a hard error. It won’t even let you compile the code.
// let nope = dream_team[5]; // Compiler Error: index out of bounds
The compiler is your brilliant, hyper-vigilant friend who checks your math before you even finish writing it.
Iteration: Looping the Right Way
You can use a standard for loop to iterate over an array. This is safe because it uses iteration under the hood, which can’t go out of bounds.
for member in &dream_team {
println!("Team member: {}", member);
}
Note the & here. We’re iterating over references to the elements. This is efficient and prevents moving or copying the data out of the array. If you need mutable access, use for member in &mut my_array.
You can also use the iter() or iter_mut() methods directly, which is often more idiomatic when you’re chaining iterator adapters.
dream_team.iter().for_each(|m| println!("Member: {}", m));
The Array Slice Symbiosis
An array by itself is a bit… standalone. Its real power comes when you use it with slices (&[T]). Want to pass your array to a function? Don’t pass the array itself. Pass a slice of it. This is how you get the flexibility of working with any size of array (or Vec) within a single function.
fn process_data(data: &[i32]) {
// This function can handle a slice of ANY length
for num in data {
println!("{}", num);
}
}
process_data(&convenient); // Pass a slice of the whole array
process_data(&convenient[1..4]); // Or just pass a slice of a part of it
This is the golden pattern. Your functions should almost always take a slice, not an array with a fixed size, unless you have a very specific reason to need exactly 4 i32s and nothing else.
The Curious Case of Non-Copy Types
Here’s a subtle pitfall. Our examples used i32 and &str, which are Copy types. What if your array holds a type that isn’t Copy, like String?
let strings: [String; 2] = [String::from("foo"), String::from("bar")];
let first_string = strings[0]; // Error! Cannot move out of index
You can’t index to get ownership of an element because that would “move” the value out of the array, leaving it partially initialized and unusable—a big no-no. The array must remain whole. To get a String out, you have a few options: use a reference (&strings[0]), swap the element with a new value, or, most commonly, use methods like iter() to get references and then clone the data if you truly need ownership.
When to Use Arrays (And When Not To)
Use an array when you know the exact number of elements at compile time. A point in 3D space? [f64; 3]. An RGBA color? [u8; 4]. The days of the week? [&str; 7]. This is what they’re made for.
The moment you think “Hmm, I might need to add or remove items later,” you’ve outgrown the array. That’s when you graduate to the Vec<T>, our dynamically-sized friend. The array is your precise, pre-packed toolkit. The Vec is your entire, expandable workshop. Both are essential, but knowing which one to reach for is half the battle.