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:
- Memory safety: No automatic bounds checking
- Pointer validity: Ensure pointers point to valid memory
- Type correctness: C type mappings must be accurate
- 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
unsafethat move a string's NUL terminator must callstr.set_length()(orstr.sync_length()); the runtime's O(1) length is authoritative, andunsafecode 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 (
unsafedoes not bless a cross-owner method call or field write;reefc --owner-harnessstill aborts). See Passive objects — owner-check harness.
Unsafe only relaxes:
- Array and string bounds checking (suppressed inside
unsafeblocks and unsafe modules) - Pointer operation restrictions
- Some FFI-related checks
Previous: 080_TESTING.md Next: 105_FFI.md