Unsafe Blocks

Part of: Reef Language Reference Last reviewed on version: 0.8.0 Status: Implemented


Overview

Reef provides unsafe blocks for low-level operations that bypass normal safety checks. These are intended for FFI integration, performance-critical code, and systems programming scenarios.


Syntax

unsafe
    // Low-level operations here
end unsafe

Use Cases

1. Working with Raw Pointers

extern "C" fn malloc(size: int): pointer
extern "C" proc free(ptr: pointer)

proc allocate_buffer()
    unsafe
        let buffer: pointer = malloc(1024)
        // Use buffer...
        free(buffer)
    end unsafe
end allocate_buffer

Note: malloc's real C signature takes size_t, but Reef has no literal syntax for size_t values (no sz suffix, and as size_t is rejected — casts require a heap-allocated source type). Stdlib FFI bindings (sys.platform.libc.malloc) declare the parameter as int for this reason; follow that convention for your own extern "C" declarations.

2. Null Pointer Operations

proc use_null_pointer()
    unsafe
        let ptr: pointer = nil
        // Check or manipulate null pointers
    end unsafe
end use_null_pointer

3. FFI with C Libraries

extern "C" fn dangerous_c_function(ptr: pointer): int

fn call_c_code(some_pointer: pointer): int
    unsafe
        let result = dangerous_c_function(some_pointer)
        return result
    end unsafe
end call_c_code

The nil Literal

nil may only be written inside an unsafe context — the annotation on the binding makes no difference:

proc example()
    unsafe
        let p: pointer = nil    // Null pointer constant
    end unsafe
end example

Outside unsafe, the same line is rejected with nil can only be used inside an unsafe block.

Note: nil represents a null pointer value and should be used carefully.


Raw Address Casts (unsafe only)

Integer ↔ pointer/array casts are permitted, but only inside unsafe blocks. This is the idiom bare-metal and MMIO (memory-mapped I/O) code relies on: a raw numeric address, cast to a pointer or to an array type so it can be read and written like memory.

let addr: uint64 = 4096u64
let p = addr as pointer     // Type Error outside unsafe:
                             // "integer to pointer cast requires unsafe"

Outside unsafe, casting an integer to pointer or to an array type is rejected. Inside unsafe, both directions are legal:

proc main()
    let regs = [11, 22, 33]
    unsafe
        // array as pointer (legal in safe code) then pointer as uint64
        // (unsafe) then back to pointer
        //
        // Use `uint64` for the address, not `int`: Reef's `int` is 32-bit
        // by design (see 015_BASICS.md), so round-tripping a 64-bit host
        // address through `int` truncates it and produces an invalid
        // pointer. `uint64` (or a real bare-metal target's native word
        // size) is the safe intermediate type.
        let addr = regs as pointer as uint64
        let reg_ptr = addr as pointer

        // integer -> [T]: reinterpret the same memory as an array
        let view = addr as [int]
        print_int(view[0])
        println("")

        // MMIO-style volatile access through the integer-derived pointer.
        // volatile_read8/16/32/64 and volatile_store8/16/32/64 are built-in
        // functions that compile to a `volatile`-qualified C dereference —
        // required for MMIO registers, where the compiler must not reorder
        // or elide the access.
        volatile_store32(reg_ptr, 4242u32)
        print_int(volatile_read32(reg_ptr) as int)
        println("")
    end unsafe
end main

This example uses the address of a heap array so it is safe to compile and run on the hosted target (verified: prints 11 then 4242). On a real bare-metal target, addr would instead be a literal hardware register address supplied by the board's memory map (e.g. a UART data register); see 135_REEF_OS.md and 115_SYSTEMS_PROGRAMMING.md for that context, and examples/test_unsafe_ptr_cast.reef for the compiler's own regression test of this cast pair.


Safety Considerations

Inside unsafe blocks, you are responsible for:

  1. Memory safety: No automatic bounds checking
  2. Pointer validity: Ensure pointers point to valid memory
  3. Type correctness: C type mappings must be accurate
  4. Lifetime management: Manual resource cleanup required

The compiler does NOT verify:

  • Pointer arithmetic correctness
  • Memory allocation/deallocation pairing
  • Type punning safety
  • Race conditions with raw pointers

Raw addresses and the garbage collector

Raw addresses interact with the collector in a way worth understanding before you store one.

The GC marks precisely: it walks each thread's shadow-frame chain to find live pointers, and it stops the world to do so. It has no way to distinguish a deliberately-constructed raw address from a corrupted heap pointer once that address is sitting in a slot the collector scans.

The practical rule: keep raw addresses out of GC-tracked slots. An address produced by an integer cast, held in a local typed as a GC-tracked array ([T]) across a point where collection can happen, may land inside heap bounds at an offset that is not a valid block start. The collector then reports a corrupt block — or aborts outright under REEF_GC_STRICT=1.

proc main()
    let live = [1, 2, 3]
    unsafe
        // Fine: the raw view is used and discarded within the unsafe block,
        // and does not outlive it in a GC-scanned slot.
        let addr = live as pointer as uint64
        let view = addr as [int]
        print_int(view[0])
        println("")
    end unsafe
end main

This is inherent to raw-address semantics rather than a defect. If you need to hold a device address or similar across calls, keep it as an integer (uint64) and re-cast at each use, rather than storing it in an array- or pointer-typed field the collector will scan.

Related: allocation may trigger a collection, so "a point where collection can happen" includes any allocating operation — string interpolation and array construction among them.


Strings: the str_len invariant is unsafe code's responsibility

As of BUG-147 (v0.8.2), a Reef string's logical length lives in its heap-block header (str_len), not in a re-derived strlen scan — this is what makes bounds-checked indexing and str.length() O(1). The runtime maintains this field automatically everywhere except inside unsafe:

Writes through unsafe that move a string's NUL terminator must call str.set_length() (or str.sync_length()); the runtime's O(1) length is authoritative, and unsafe code owns the invariant.

Concretely: if unsafe code writes a new '\0' into a string buffer at a different position than its current logical length — building a string by hand-indexed writes, or handing a buffer to a C function that fills it — str.length() and safe-context bounds checks will report the old length until the code calls core.str.set_length(s, n) (when the new length is already known) or core.str.sync_length(s) (a one-time strlen resync for buffers filled by external C). Both panic cleanly if s is a string literal — literals are read-only static data, not something unsafe code is meant to mutate the length of. Neither call is bounds-checked against unsafe writes that came before it; that is the same trust boundary unsafe already draws for pointer and array operations.

Buffers allocated for index-fill (reef_alloc_string_buffer(n), the allocator behind most stdlib "build by index" helpers) are a deliberate exception: they report their full logical length (n - 1) from allocation, before any byte is written, precisely so that filling them by index works in safe context with no unsafe block at all — the region is zeroed at allocation, so every not-yet-written index reads '\0'. This closed the BUG-146 family of "buffer-filling code needs unsafe just to satisfy the bounds checker" defects. unsafe is only required when moving the NUL to a position other than the one the constructor already established, or when handing the buffer to non-Reef code that fills it independently.

string_offset(s, k) — pointer arithmetic that returns an interior pointer with no heap-block header behind it — is unsafe-only at typecheck for the same reason: indexing or length-checking its result would read a garbage header. Calling it outside unsafe is a compile error.


Best Practices

1. Minimize Unsafe Code

// GOOD: Small unsafe section
fn get_value(): int
    let result: int = 0
    unsafe
        result = read_hardware_register()
    end unsafe
    return result
end get_value

// AVOID: Large unsafe blocks
unsafe
    // Hundreds of lines of code...
end unsafe

2. Document Why Unsafe is Needed

// Unsafe needed because: accessing memory-mapped I/O
unsafe
    write_to_port(0x3F8, byte_value)
end unsafe

3. Wrap Unsafe in Safe Abstractions

// Safe wrapper around unsafe FFI
fn safe_strlen(s: string): int
    unsafe
        return strlen(s)  // C function
    end unsafe
end safe_strlen

Common Patterns

FFI Wrapper Pattern

extern "C" fn c_library_init(): int
extern "C" proc c_library_cleanup()

fn initialize(): bool
    unsafe
        let status = c_library_init()
        return status == 0
    end unsafe
end initialize

proc cleanup()
    unsafe
        c_library_cleanup()
    end unsafe
end cleanup

Resource Handle Pattern

type FileHandle = struct
    handle: pointer
end FileHandle

extern "C" fn fopen(path: string, mode: string): pointer
extern "C" proc fclose(fp: pointer)

fn open_file(path: string): FileHandle
    let fh = new FileHandle()
    unsafe
        fh.handle = fopen(path, "r")
    end unsafe
    return fh
end open_file

proc close_file(fh: FileHandle)
    unsafe
        fclose(fh.handle)
    end unsafe
end close_file

Relationship to Active Objects

Active Objects provide safe concurrency, but sometimes you need to interface with external C code that is not thread-safe:

extern "C" fn allocate_external_resource(): pointer
extern "C" proc manipulate_resource(res: pointer)
extern "C" proc free_external_resource(res: pointer)

active object UnsafeWrapper
    data: pointer

    init()
        unsafe
            self.data = allocate_external_resource()
        end unsafe
    end init

    finalize()
        unsafe
            free_external_resource(self.data)
        end unsafe
    end finalize

    exclusive proc operate()
        unsafe
            // Thread-safe due to exclusive modifier
            // but internal operations are unsafe
            manipulate_resource(self.data)
        end unsafe
    end operate
end UnsafeWrapper

Unsafe Modules

An entire module can be declared unsafe, which suppresses bounds checking and enables unsafe type coercions for all code within the module:

unsafe module mymodule

proc fast_copy(dst: [byte], src: [byte], len: int)
    // No bounds checks on array access — entire module is unsafe
    for i in 0 to len
        dst[i] = src[i]
    end for
end fast_copy

end module

This is equivalent to wrapping the entire module body in an unsafe block, but more convenient for performance-critical modules.

Note: All functions and procedures within an unsafe module inherit the unsafe context. You do not need individual unsafe blocks inside the module.


What Unsafe Does NOT Allow

Unsafe blocks and unsafe modules do not bypass:

  • Type checking (types are still verified)
  • Module visibility (private items stay private)
  • Syntax rules
  • Exhaustiveness checking
  • Owner-context discipline on passive objects (unsafe does not bless a cross-owner method call or field write; reefc --owner-harness still aborts). See Passive objects — owner-check harness.

Unsafe only relaxes:

  • Array and string bounds checking (suppressed inside unsafe blocks and unsafe modules)
  • Pointer operation restrictions
  • Some FFI-related checks

Previous: 080_TESTING.md Next: 105_FFI.md