Unsafe and raw pointers
The safe world
In normal Nyx code, you cannot corrupt memory. The garbage collector manages allocations, array bounds are checked, and null pointers do not exist. This safety has a small cost — the GC adds overhead, bounds checks add instructions.
Sometimes you need to escape these guardrails: writing a memory allocator, interfacing with hardware, or squeezing the last drop of performance from a hot loop. That is what unsafe is for.
The unsafe block
Unsafe operations must be wrapped in an unsafe block:
fn main() { unsafe { let ptr: *int = alloc(8) // allocate 8 bytes *ptr = 42 // write to raw memory print(*ptr) // read from raw memory: 42 free(ptr) // deallocate } }
The unsafe keyword is a contract: "I, the programmer, guarantee this code is correct. The compiler should not try to protect me here."
Raw pointers
A raw pointer is a memory address. Unlike references in safe code, raw pointers can be null, dangling, or unaligned. They give you direct access to memory.
fn main() { var x: int = 100 unsafe { let ptr: *int = &x // get address of x print(*ptr) // dereference: 100 *ptr = 200 // modify through pointer print(x) // 200 — x changed! } }
&x— the address-of operator. Returns a pointer tox.*ptr— the dereference operator. Reads or writes the value at the address.
Manual memory management
In safe Nyx, the garbage collector handles all allocations. In unsafe code, you manage memory yourself:
fn main() { unsafe { // Allocate space for 10 integers (10 * 8 bytes) let arr: *int = alloc(80) // Write values var i: int = 0 while i < 10 { *(arr + i) = i * i // pointer arithmetic i += 1 } // Read values i = 0 while i < 10 { print(*(arr + i)) i += 1 } free(arr) } }
Output:
0 1 4 9 16 25 36 49 64 81
alloc(bytes) allocates raw memory (like C's malloc). free(ptr) releases it. Forgetting to call free causes a memory leak. Calling free twice causes undefined behavior.
Pointer arithmetic
You can add to pointers to navigate through memory:
unsafe { let base: *int = alloc(32) // 4 integers * 8 bytes each *(base + 0) = 10 *(base + 1) = 20 *(base + 2) = 30 *(base + 3) = 40 // base + N moves N * sizeof(int) bytes forward print(*(base + 2)) // 30 free(base) }
Static variables
Static variables live for the entire program lifetime, outside the garbage collector:
static var request_count: int = 0 fn log_request() { request_count = request_count + 1 print("Request #" + int_to_string(request_count)) } fn main() { log_request() // Request #1 log_request() // Request #2 log_request() // Request #3 }
Static variables are useful for global state that needs to be fast and GC-free.
Volatile reads and writes
Normally, the compiler can reorder or eliminate memory operations if it thinks they are redundant. Volatile operations prevent this:
unsafe { let ptr: *int = alloc(8) volatile_write(ptr, 42) // guaranteed to write let v: int = volatile_read(ptr) // guaranteed to read free(ptr) }
Volatile is essential for:
- Memory-mapped I/O (talking to hardware)
- Signal handlers (variables modified by external events)
- Shared memory between processes
Atomic operations
When multiple threads access the same memory, you need atomic operations to prevent race conditions without a mutex:
unsafe { let counter: *int = alloc(8) atomic_store(counter, 0) // Thread-safe increment pattern: let current: int = atomic_load(counter) atomic_store(counter, current + 1) print(atomic_load(counter)) // 1 free(counter) }
Atomic operations use sequential consistency — the strongest memory ordering guarantee. Every thread sees operations in the same order.
When to use unsafe
Use unsafe only when you must:
- FFI — calling C functions that take raw pointers
- Performance-critical code — avoiding GC in hot loops
- Low-level data structures — implementing custom allocators or lock-free structures
- Hardware interaction — memory-mapped I/O, device drivers
Never use unsafe just because it feels faster. Measure first. The GC is highly optimized and adds less overhead than most people think.
Exercises
- Write an unsafe function that allocates an array of 100 integers, fills them with values 0-99, sums them, frees the memory, and returns the sum.
- Create a static counter variable and increment it from two different functions. Print the final value.
- Write a function that swaps two integers using raw pointers:
fn swap(a: int, b: int).
- Allocate a block of memory, write values using pointer arithmetic, then read them back in reverse order.
- Write a program that uses
volatile_writeandvolatile_readto simulate a hardware register that a signal handler might modify.
Summary
unsafe { }enables operations that bypass safety checks.&xgets the address of a variable.*ptrdereferences a pointer.alloc(bytes)allocates memory.free(ptr)releases it.- Pointer arithmetic:
*(ptr + offset)accesses memory at an offset. static varcreates variables that live for the program's lifetime.volatile_read/volatile_writeprevent compiler reordering.atomic_load/atomic_storeprovide thread-safe memory access.- Only use unsafe when safe alternatives are insufficient. Measure first.
Next chapter: Async and event loop →