Skip to content

← Unsafe and Soundness step 7 of 24

Medium Primitives

Pointer arithmetic: `offset`, `add`, `sub`, `wrapping_*`

pub fn windows_max(v: Vec<i64>, w: usize) -> Vec<i64>

Sliding-window maximum. For every window of w consecutive elements, emit its largest value. [1,3,2,5,4] with w = 2 gives [3,3,5,5]. If w is zero or larger than the input, the answer is empty.

Do it by advancing raw pointers: keep a lo pointer, use the one-past-the-end pointer as the loop sentinel, and use offset_from_unsigned to ask how many elements are left. No indexing, no windows().

The starter compiles, produces wrong answers, and trips a lint. All three failures are the same bug.

The rule that surprises everyone

Undefined behaviour happens at the arithmetic, not at the dereference.

ptr.add(n) is unsafe even though it touches no memory. Its contract:

  • the result must stay inside the same allocation as ptr, or be exactly one past its end;
  • the total offset in bytes must fit in an isize;
  • the computation must not wrap the address space.

So for a 10-element buffer, p.add(10) is legal (one past the end) and p.add(11) is undefined behaviour — right there, on that line, before you have read anything. If you were planning to bounds-check at the dereference, you are checking in the wrong place.

This is why the one-past-the-end pointer is such a load-bearing idiom. It is the only out-of-range address the language blesses, which makes it the natural loop sentinel — and it is exactly why start.add(len) is fine and start.add(len + 1) is not.

::: question Why is one-past-the-end legal but two-past-the-end undefined, when neither is ever dereferenced? Because the rule exists to protect the optimiser’s model of allocations, and one-past-the-end is the minimum you need to express “the end”.

Every C-family language has this carve-out, for the same reason: a half-open range [begin, end) needs end to be nameable. Rust inherits it. Beyond that one address there is no motivation to allow anything, and forbidding it lets the compiler assume that a pointer derived from allocation A can never alias allocation B — which is what makes noalias-style reasoning usable at all.

The practical consequence: write your loop so the pointer never leaves [start, start + len], not so the dereference never does. Those are different programs, and only the first one is defined. :::

The API surface

method takes notes
add(n) usize forward, unsafe
sub(n) usize backward, unsafe
offset(n) isize either direction, unsafe
wrapping_add(n) / wrapping_offset(n) safe — computes only
byte_add(n) / byte_offset(n) steps in bytes, not elements
offset_from(other) signed distance in elements, unsafe
offset_from_unsigned(other) unsigned distance, requires self >= other (1.87)
is_aligned() / align_offset(a) safe

The wrapping_* family is safe precisely because it does nothing but arithmetic and defers every consequence to the eventual dereference. That sounds strictly better and is not: the resulting pointer is much harder for the compiler to reason about, and it interacts badly with provenance (item 17.10). Use add/offset and stay in bounds.

offset_from_unsigned is the modern spelling of “how far apart are these”. Before 1.87 you used offset_from, which returns an isize and requires you to get the argument order right; the unsigned version requires self >= origin and hands you a usize you can compare against w directly.

Three clippy lints watching this problem

ptr_offset_with_cast (on by default) catches the single most common tell of a C-trained author:

p.offset(i as isize)     // warned
p.add(i)                 // what you meant

If your index is a usize, add is the method. The cast is noise at best and a silent sign error at worst.

zst_offset is deny-by-default and catches pointer arithmetic on a zero-sized type, where every offset is a no-op and the loop never terminates.

size_of_in_element_count is deny-by-default and catches the classic transliteration of memcpy:

ptr::copy_nonoverlapping(src, dst, n * size_of::<T>())   // wrong
ptr::copy_nonoverlapping(src, dst, n)                    // right

Rust’s pointer APIs count in elements, always. C’s count in bytes. Mixing them multiplies your buffer overrun by size_of::<T>().

::: question The starter loops while lo < end and reads w elements from each lo. What exactly goes wrong, and where? It runs len iterations instead of len - w + 1, so the last w - 1 windows read past the end of the allocation.

On the second-to-last iteration lo is end - 1, and the inner loop computes lo.offset(1) ..= lo.offset(w - 1) — addresses beyond one-past-the-end. That is undefined behaviour at the offset computation, not at the read, and it is undefined even for the intermediate values you never dereference.

Observably it also just gives wrong answers, because it emits too many windows. That is lucky. The dangerous version of this bug is the one where the count is right and only the arithmetic strays — then the tests pass and nothing is wrong until it is.

The fix is to make the sentinel enforce the invariant: ask end.offset_from_unsigned(lo) how many elements remain and stop when there are fewer than w. Then lo + w <= end is true by construction on every iteration, and your safety comment can say so. :::

What this grader cannot check

An off-by-one that computes a two-past-the-end pointer without dereferencing it is undefined behaviour that produces completely correct output on every test here, forever. There is no runtime check, no sanitiser, no lint. Miri would catch it; Miri needs nightly and a cargo project and cannot run in this harness. Item 17.14 is about exactly that gap, and about how to close it locally.

Remember the grade is compile + tests + clippy -D warnings.