Pointer arithmetic and ptr::read

A raw pointer can be moved by a number of elements, which is how a buffer is walked without an index. The operation is unsafe because nothing checks the result: a pointer past the allocation reads memory that belongs to something else. The one-past-the-end position is allowed as a bound, and nothing else.

This lesson walks an array, computes the end pointer, and reads and writes through a pointer with the functions in std::ptr. The last section is the error for calling the pointer method on a slice, which is the mistake a C++ habit produces.

Walking with add

as_ptr gives the address of the first element, and add(index) moves by that many elements, scaled by the size of the type. The offset is in elements, not bytes, which is the difference from byte_add.

The address arithmetic is scaled by the element type, so an offset of one moves by four bytes for an i32 and by one for a u8. A buffer of structs walks with the same code, and the compiler multiplies the offset by the size it knows; that scaling is the difference between add and byte_add.

The program walks the three elements with an index and prints them. Each add is inside its own unsafe block, and the pointer does not leave the function.

fn main() { let values = [10, 20, 30]; let base = values.as_ptr(); for index in 0..values.len() { let value = unsafe { *base.add(index) }; println!("{value}"); } }

rustc --edition 2024 walk.rs
./walk

10
20
30

Each iteration computes the address again from the base, so the pointer itself never moves and the loop variable is the offset. The loop is the manual form of the iterator from the lesson on iterators, written to show the arithmetic. Safe code should always prefer the iterator.

One past the end

A pointer to one past the last element is valid to compute and compare, and never to dereference. It is the bound a loop uses, and the arithmetic behind slice::iter.

The program subtracts the base pointer from the end pointer and prints twelve, the size of the array in bytes. The number is the distance in bytes between the two addresses.

fn main() { let values = [10, 20, 30]; let base = values.as_ptr(); let end = unsafe { base.add(values.len()) }; println!("{}", end as usize - base as usize); }

rustc --edition 2024 past_end.rs
./past_end

12

The distance is three elements and twelve bytes, and the two numbers are the same fact in different units. The end pointer is useful as a bound and dangerous as a value: comparing two pointers into one allocation is defined, while comparing pointers into different allocations is not, and dereferencing either is never allowed. The cast to usize is how the distance is printed; the subtraction of two pointers of the same type gives the same information. The lesson on memory layout explains the element sizes.

Distance and sub

offset_from returns the distance between two pointers as an isize in elements, and sub moves a pointer backwards by a number of elements. Both keep the scaling by the element type, so the three functions agree: a pointer that is one element apart is four bytes apart for an i32.

The program measures the last element from the base, then walks the array backwards from that element. The distance is three, and the values come out in reverse order.

fn main() { let values = [10, 20, 30, 40]; let base = values.as_ptr(); let last = unsafe { base.add(values.len() - 1) }; println!("{}", unsafe { last.offset_from(base) }); for step in 0..values.len() { let value = unsafe { *base.add(values.len() - 1).sub(step) }; println!("{value}"); } }

rustc --edition 2024 distance.rs
./distance

3
40
30
20
10

offset_from is the pointer form of the length: the result is signed because a pointer can be before its base, and it is only defined when both pointers belong to the same allocation. Dividing the byte distance by size_of::<i32>() gives the same number, and the element-based call is the one that keeps the arithmetic honest.

Reading and writing through a pointer

ptr::write stores a value without dropping the old one, and ptr::read copies the value out without taking ownership of the source. They are the tools for a buffer whose contents are not yet a value.

The program uses a MaybeUninit slot, writes seven through its pointer and reads it back. The output is seven, and no allocation or drop happens on the way.

use std::mem::MaybeUninit; fn main() { let mut slot = MaybeUninit::<i32>::uninit(); let pointer = slot.as_mut_ptr(); unsafe { pointer.write(7); println!("{}", pointer.read()); } }

rustc --edition 2024 read_write.rs
./read_write

7

Reading twice would produce two owners of one value, which is why ptr::read is used together with forget or ManuallyDrop when the source must not drop. The slot is not a value until the write, which is why MaybeUninit is the container for it and why the read comes after the write in the same block. The lesson on uninitialised memory builds on this.

A method that belongs to a pointer

add is a method of the raw pointer, not of a slice or an array. Calling it on a reference is E0599, and the message says the method was not found for the reference type.

The program calls add on &values. The fix is as_ptr() on the slice or the array first, which is the conversion the previous sections use.

fn main() { let values = [10, 20, 30]; let pointer = &values; let next = pointer.add(1); println!("{next:?}"); }

rustc --edition 2024 add_err.rs

error[E0599]: no method named `add` found for reference `&[{integer}; 3]` in the current scope
--> add_err.rs:4:24
|
4 | let next = pointer.add(1);
| ^^^ method not found in `&[{integer}; 3]`

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0599`.

The compiler offers no similar method on the reference because the conversion would silently drop the validity guarantee that a reference carries. The same distinction explains why a reference cannot be cast to a pointer with a method: the conversion is explicit, and the unsafe block is where the pointer is used.

Exercises

  1. Walk an array backwards with sub and print the values.
  2. Compute the end pointer of a slice and compare it with the last element's address plus one.
  3. Write two values into a MaybeUninit buffer with ptr::write and read them back.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What unit does add(n) use?

    Answer

    Elements of the pointed-to type

    The offset is scaled by the size of the type, unlike byte_add.

  2. Which pointer position is allowed but not dereferenceable?

    Answer

    One past the last element

    The one-past-the-end pointer is the valid bound of a buffer.

  3. What does ptr::read do?

    Answer

    Copies the value out without taking ownership of the source

    The source still owns its bytes, which is why reading twice would be a double use.

  4. Where is add defined?

    Answer

    On raw pointers

    The method belongs to *const T and *mut T, so a reference needs as_ptr first.

  5. What does this program print? fn main() { let values = [10, 20, 30]; let base = values.as_ptr(); for index in 0..3 { let value = unsafe { *base.add(index) }; println!("{value}"); } }

    Answer

    10 / 20 / 30

    The pointer walks the three elements in order.

  6. What does this program print? fn main() { let values = [10, 20, 30]; let base = values.as_ptr(); let end = unsafe { base.add(3) }; println!("{}", end as usize - base as usize); }

    Answer

    12

    The three i32 elements are twelve bytes, which is the distance between the pointers.

  7. What does this program print? use std::mem::MaybeUninit; fn main() { let mut slot = MaybeUninit::<i32>::uninit(); let pointer = slot.as_mut_ptr(); unsafe { pointer.write(7); println!("{}", pointer.read()); } }

    Answer

    7

    The write stores seven and the read copies it out.

  8. What is the first line of the compiler output for this program? fn main() { let values = [10, 20, 30]; let pointer = &values; let next = pointer.add(1); println!("{next:?}"); }

    Answer

    error[E0599]: no method named add found for reference &[{integer}; 3] in the current scope

    The method exists only on raw pointers, and a reference is not one.

  9. What does as_ptr return for an array?

    Answer

    A raw pointer to the first element

    The conversion is the explicit step from a safe reference to a raw pointer.

  10. What happens when a pointer is moved past the allocation and dereferenced?

    Answer

    Undefined behaviour

    Nothing checks the move, which is why the operation is unsafe.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev

Banner image