1.1. Pointer family - Ptr / ConstPtr / ManyPtr / CPtr
The rok::bare::ptr module defines five kinds of raw pointer. The design follows Zig's arity-encoded model: the number of pointees (arity) and mutability are encoded in the type itself, ruling out silent coercion entirely.
1.1.1. The 5 pointer kinds
The table below shows how the five kinds divide.
| Type | Meaning | C equivalent | Nullable |
|---|---|---|---|
Ptr<T> | mutable, exactly 1 T | T* | ✗ (use Ptr<T> | none) |
ConstPtr<T> | const, exactly 1 T | const T* | ✗ |
ManyPtr<T> | mutable, N contiguous Ts (length unknown) | T* (array) | ✗ |
Slice<T> | mutable, N contiguous Ts (length included) | (T*, len) | ✗ (separate niche) |
CPtr<T> | C-compatible nullable + 1 | T* (nullable) | ✓ (automatic) |
Ptr, ConstPtr, ManyPtr and CPtr are #[repr(transparent)]: across extern "C" they are C pointers.
Of these, Slice<T> is a safe view defined in 2.9 collections; this module only defines the path for extracting its underlying ptr.
Send / Sync
The raw pointers Ptr<T> and CPtr<T> are !Send and !Sync (2.13.9). To pass a type containing a pointer between threads, its author must vouch for it with unsafe impl Send.
Automatic niche
Ptr<T>, ConstPtr<T> and ManyPtr<T> can use the null pointer value as a niche, so adding none requires no extra tag. This is an optimization, not a guarantee: an implementation may represent Ptr<T> | none as an ordinary tagged sum type.
sizeof::<Ptr<i32>>() //→ 8 (64-bit target)
sizeof::<Ptr<i32> | none>() //→ 8 (implementation using the niche - not guaranteed)
CPtr<T>, on the other hand, is itself nullable, so it needs no separate sentinel; its none representation is simply CPtr::null().
The niche mechanism is covered in 2.2 non-zero.
1.1.2. Ptr<T> - strict provenance API
The public API of the most basic type, Ptr<T>, is:
struct Ptr<T> { /* opaque - (address, provenance) pair */ }
impl<T> Ptr<T> {
// construction
fn dangling() -> Ptr<T>; // valid alignment, invalid address (unique)
fn null() -> Ptr<T> | none; // always none
// provenance-preserving (strict provenance, safety first)
fn addr(self) -> usize; // address only (provenance lost)
fn with_addr(self, addr: usize) -> Ptr<T>;
// provenance kept, address changed
fn map_addr(self, f: fn(usize) -> usize) -> Ptr<T>;
// arithmetic (provenance preserving)
fn offset(self, count: isize) -> Ptr<T>; // count * sizeof(T) byte offset
fn add(self, count: usize) -> Ptr<T>;
fn sub(self, count: usize) -> Ptr<T>;
fn byte_offset(self, count: isize) -> Ptr<T>; // offset in bytes
fn distance(self, origin: Ptr<T>) -> isize; // self.addr() - origin.addr()
fn is_aligned(self) -> bool;
fn align_offset(self, align: usize) -> usize;
// dereference (UB if invalid)
unsafe fn read(self) -> T;
unsafe fn write(self, value: T);
unsafe fn read_volatile(self) -> T;
unsafe fn write_volatile(self, value: T);
}
// module functions of rok::bare::ptr
fn addr_of<T>(place: T) -> Ptr<T>;
fn many_addr_of<T>(place: T) -> ManyPtr<T>;
fn expose_provenance<T>(p: Ptr<T>) -> usize;
unsafe fn from_exposed_provenance<T>(addr: usize) -> Ptr<T>;
addr_of / many_addr_of take a place expression: a local variable or a static (an extern "C" static included), or a field or element of one. A pointer to a local is valid while that variable is in scope; a pointer to a static for the whole run (statics live in data sections; a lazily initialized static is initialized before its address is taken; GC references held by statics are roots). Writing through a pointer to a non-mut static is undefined behavior. A variable whose address is taken stays a GC root for that scope, and roots pin, so the referent does not move. In a coroutine body the pointer is valid only until the next suspension point. Using such a pointer after a suspension point is a compile error.
Strict provenance - separated escape hatch
Conversion usize → Ptr<T> is rejected by default, because dereferencing a ptr whose provenance was lost is UB. The normal path and the escape hatch are therefore clearly separated.
// recommended - provenance preserved
let p: Ptr<i32> = ...;
let q = p.with_addr(p.addr() + 4); // 4 bytes further - provenance unchanged
// discouraged - fresh provenance (only for FFI / hardware registers)
use rok::bare::ptr::{expose_provenance, from_exposed_provenance};
let exposed: usize = expose_provenance(p); // "exposes" p's provenance
let r: Ptr<i32> = unsafe { from_exposed_provenance(exposed) };
// _assumes_ the new ptr has one of
// the exposed provenances
The authoritative starting point of this design is Ralf Jung's "Pointers Are Complicated", and it is isomorphic to rok::core::ptr::with_exposed_provenance of Rust 1.84, stabilized in December 2024.
Example - pointer arithmetic
The short example below shows arithmetic in use.
let arr: [i32; 5] = [10, 20, 30, 40, 50];
let p: Ptr<i32> = rok::bare::ptr::addr_of(arr[0]);
let p3 = p.add(3); // ptr to arr[3]
unsafe {
let v = p3.read(); //→ 40
}
// byte arithmetic (struct field offset)
struct Header { magic: u32; size: u64; }
let h: Ptr<Header> = ...;
let size_ptr: Ptr<u64> = h.byte_offset(4).cast::<u64>();
// location of Header.size
// (alignment check needed - see 1.2 cast)
1.1.3. ConstPtr<T> - read-only
The read-only variant lacks the write family.
struct ConstPtr<T> { /* opaque */ }
impl<T> ConstPtr<T> {
fn dangling() -> ConstPtr<T>;
fn addr(self) -> usize;
fn with_addr(self, addr: usize) -> ConstPtr<T>;
fn offset(self, count: isize) -> ConstPtr<T>;
fn add(self, count: usize) -> ConstPtr<T>;
fn sub(self, count: usize) -> ConstPtr<T>;
unsafe fn read(self) -> T;
unsafe fn read_volatile(self) -> T;
// no write methods - read-only
}
The safety of conversion to and from Ptr<T> depends on direction. Adding const is free; removing it must go through the named cast rok::bare::const_cast (a safe call; writing through the result is unsafe).
let m: Ptr<i32> = ...;
let c: ConstPtr<i32> = m.as_const(); // Ptr → ConstPtr (safe)
let c: ConstPtr<i32> = ...;
let m: Ptr<i32> = rok::bare::const_cast(c); // ConstPtr → Ptr (safe call; writes through m are unsafe - 1.2 cast)
Removing const therefore happens only through an explicit path; silent conversion is not accepted.
1.1.4. ManyPtr<T> - N contiguous, length unknown
Next is the type that points to several elements without length information.
struct ManyPtr<T> { /* opaque */ }
impl<T> ManyPtr<T> {
fn addr(self) -> usize;
fn with_addr(self, addr: usize) -> ManyPtr<T>;
fn offset(self, count: isize) -> ManyPtr<T>;
fn add(self, count: usize) -> ManyPtr<T>;
unsafe fn read(self, idx: usize) -> T;
unsafe fn write(self, idx: usize, value: T);
// no length information - the caller guarantees the index
unsafe fn slice(self, len: usize) -> Slice<T>; // attaches a length to make a Slice
}
It is used to accept C's T* (array); since there is no length information, avoiding OOB is the caller's responsibility. slice over memory outside the GC heap must not hold elements with GC references; that is undefined behavior.
extern "C" {
fn read_buffer(out: ManyPtr<u8>, len: usize) -> usize;
}
let buf: [u8; 1024] = [0; 1024];
let read_count = unsafe {
read_buffer(rok::bare::ptr::many_addr_of(buf[0]), 1024)
};
1.1.5. CPtr<T> - C-compatible nullable
The nullable variant tied directly to the C ABI is:
struct CPtr<T> { /* opaque - nullable */ }
impl<T> CPtr<T> {
fn null() -> CPtr<T>;
fn from_ptr(p: Ptr<T>) -> CPtr<T>; // non-null
fn from_const(p: ConstPtr<T>) -> CPtr<T>;
fn is_null(self) -> bool;
fn as_ptr(self) -> Ptr<T> | none; // null → none
fn as_const(self) -> ConstPtr<T> | none;
fn addr(self) -> usize; // null = 0
}
It fits at boundaries where C functions exchange nullable T*. Typical use:
extern "C" {
fn malloc(size: usize) -> CPtr<u8>; // null on failure
fn free(ptr: CPtr<u8>);
}
let buf: CPtr<u8> = unsafe { malloc(1024) };
match buf.as_ptr() {
p: Ptr<u8> => use(p),
none => panic("malloc failed"),
}
unsafe { free(buf) };
Ptr<T> | none is ABI-compatible thanks to the automatic niche, but CPtr alone is not enough where an explicit sentinel such as -1 is needed. In that case the standard approach is to model it separately with #[niche(-1)] on a newtype.
1.1.6. Conversion matrix
The ways between these five types are summarized in a single matrix.
| from \ to | Ptr<T> | ConstPtr<T> | ManyPtr<T> | CPtr<T> | Slice<T> | usize |
|---|---|---|---|---|---|---|
Ptr<T> | - | .as_const() safe | .as_many() safe | CPtr::from_ptr() | rok::bare::ptr::slice(p, len) | .addr() provenance loss |
ConstPtr<T> | rok::bare::const_cast | - | .as_many() | CPtr::from_const() | rok::bare::ptr::slice | .addr() |
ManyPtr<T> | .as_one() safe | .as_const() | - | CPtr::from_ptr(...) | .slice(len) unsafe | .addr() |
CPtr<T> | .as_ptr()? | .as_const()? | manual | - | manual | .addr() |
Slice<T> | (raw extraction from a safe view) | .as_ptr() (ConstPtr) | .as_many_ptr() | manual | - | .as_ptr().addr() |
usize | from_exposed_provenance ⚠ | from_exposed_provenance ⚠ | manual | CPtr::from_addr | manual | - |
⚠ marks an escape hatch that creates fresh provenance.
1.1.7. Comparison with other languages
Comparing with other languages that have tackled the same problem makes rok's position clear.
| Aspect | C | Rust | Zig | rok |
|---|---|---|---|---|
| Single ptr | T* | *const T / *mut T | *T / *const T | Ptr<T> / ConstPtr<T> |
| Many ptr | T* (ambiguous) | *const T (ambiguous) | [*]T | ManyPtr<T> |
| length-aware | none | &[T] | []T | Slice<T> |
| nullable | (every ptr) | Option<*const T> | ?*T | CPtr<T> or Ptr<T> | none |
| arity separation | none | partial (slice separate) | ✓ (*T / [*]T separate) | ✓ (Zig model) |
| strict provenance | none | ✓ (1.84+) | none | ✓ (Rust model) |
as cast rejected | allowed | allowed (warning only) | explicit @ptrCast | fully rejected |
As the table shows, rok's three weightiest decisions are: first, adopting Zig-style arity separation to divide the five kinds clearly; second, adopting the Rust 2024 model of strict provenance; and third, enforcing explicit casts so that as cannot be used on pointers at all. Together these three decisions avoid most of the pointer pitfalls of both C and Rust.