Support Ukraine. DONATE.
A blog about software development.

Index out of bounds? Not always! - A Rusty Surprise

Serhii Potapov January 04, 2024 #rust #array #index #slice #Deref Coercion

I recently encountered a curious situation with Rust's array indexing that caught me off guard. Rust, known for its vigilant error checking, especially with out-of-bounds array access, presented an interesting case.

Rust's Protective Error Checks on Array Indices

Let’s look at a very basic example:

let arr: [i32; 2] = [1, 2];
let third = arr[2];

Rust, like a watchful guardian, throws an error:

  |
2 | let third = arr[2];
  |             ^^^^^^ index out of bounds: the length is 2 but the index is 2

It’s straightforward: an array with two elements doesn’t have a third slot. Rust, with its compile-time checks, easily spots such errors. So far so good.

A Twist in My Code

In my coding journey, I wrote something like this:

struct StepGroup {
    steps: [Step; 2],
}

struct Step {
    id: i32,
}

fn first_step_id(step_group: &StepGroup) -> i32 {
    step_group.steps[0].id
}

I assumed that Rust would alert me if the size of steps changed or if I accidentally accessed a wrong index. To test assumption, I altered first_step_id and replaced 0 with 5:

fn first_step_id(step_group: &StepGroup) -> i32 {
    step_group.steps[5].id
}

To my surprise, this code compiled without any errors.

Unraveling the Mystery

The clue to this puzzle lies in whether step_group is passed by value or reference. Switching to pass-by-value makes Rust vigilant again:

fn first_step_id(step_group: StepGroup) -> i32 {
    step_group.steps[2].id
}

And there’s the expected error:

   |
10 |     step_group.steps[2].id
   |     ^^^^^^^^^^^^^^ index out of bounds: the length is 2 but the index is 2

I suggest taking a break from reading now and, as an exercise, try to find the explanation on your own.

Then we can compare our results :)

A Closer Look

Here’s my take on what’s happening, based on my understanding of Rust.

Rust has a trait called std::ops::Index for indexing operations. When we use an index like arr[2], we're actually invoking the index function of this trait.

The Index trait is generically implemented for arrays, as seen in the documentation:

impl<T, I, const N: usize> Index<I> for [T; N]
where
    [T]: Index<I>,

This kicks in when step_group is passed by value. However, when step_group is a reference, like &StepGroup, the situation changes. The expression step_group.steps[2] apparently becomes a call to Index::index for &[Step; 2]. Now, we’re dealing with a reference, not the array itself.

But here is no implementation of Index for &[T; N]! However, we can see that there is Index implemented for [T] (a slice), and we know that a &[T; N] can be dereferenced to a slice. So Rust applies Deref coercion here. Eventually accessing &[Step; 2] turns the reference to an array into a slice, and the slice’s Index implementation is used. And a slice has no notion of a size at compile time.

Shrinking the example

Here's a minimal example that illustrates this quirky behavior. This code compiles, though it clearly accesses an index that doesn’t exist:

let arr: [i32; 2] = [1, 2];
(&arr)[5];

Conclusion

Though I have written the article, and gave the explanation to the odd behavior, I am seeking for external confirmation to see if I am right.

I also question myself, would the compiler gave a proper error message, if there was an implementation of Index trait for &[T; N] ?

Update

By now I got some feedback from the Rust community and I think this comment from Sharlinator explains the mystery:

Note that neither array nor slice indexing with [] actually goes through the Index trait at all. The operators are intrinsic and hardcoded into the compiler, just like all other operators of the builtin types. The trait impls only exist to facilitate generic use.

Thanks!

Back to top