We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Unsafe and Soundness step 7 of 24
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.
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.