Memory and ownership
Single-owner auto-drop, parameter conventions, and the borrow checker.
NURL has no garbage collector, and ordinary values are not
reference-counted: shared ownership is opt-in, through Rc and Arc.
NURL uses single-owner auto-drop to manage heap memory. The compiler
tracks which binding owns each heap allocation and inserts the matching
free at the end of that binding's scope. A scope drops its values newest
first, as Rust and C++ do, so a value built from a view of another is
dropped before what it views. A % Drop destructor that may read such a
view counts as a use of it, and the borrow checker rejects a source that
would end first (docs/MEMORY.md §2.14).
Ownership
A : binding becomes the owner of a heap resource when its value is a
fresh allocation. This includes a slice literal, a slice-returning call, an
allocating string call, or a struct literal whose fields are fresh
allocations. At the end of that binding's scope, the compiler frees it
automatically. A function that returns a fresh allocation transfers
ownership to the caller. It does not free the allocation locally.
@ main → i {
: String s ( string_from `hi` ) // s owns this allocation
( puts ( string_data s ) )
^ 0
} // s is freed here, automaticallyA raw string in a value is the exception. In safe code, a raw string
(s) held by a struct, an option, an enum, a slice or a container is a
view that owns nothing, so a fresh string cannot be stored there. Hold a
String instead, or bind the string first and store the binding.
Copying an already-owned binding into another binding does not register a second drop. The compiler only tracks drops for allocations it directly sees created. This makes the base model conservative. It never double-frees on its own.
Parameter conventions
A function parameter is one of three conventions, written as a keyword before the type:
| Convention | Meaning |
|---|---|
(default) / in | immutable borrow by value — the callee gets a copy |
inout | exclusive mutable borrow — the callee writes through to the caller |
sink | ownership transfer — the caller may not use the value afterward |
in (default)
The runtime copies a struct-typed parameter into the callee at entry. Writes to it inside the callee do not reach the caller.
inout
@ bump inout Counter c → v { = . c n + . c n 1 }
: ~ Counter c @ Counter { 0 10 }
( bump c ) // c.n is now 1, in the callerThe argument must be a mutable (: ~) binding, and the callee's writes
land on the caller's storage. You must define an inout function before
you call it.
sink
@ give_away sink ( Vec i ) g → v {} // g is dropped when give_away returns
: ( Vec i ) xs ( vec_new [i] )
( give_away xs ) // xs is consumed; using it afterward is a compile errorA sink parameter owns what it is given: the callee drops it, keeps it
or passes it on, and the caller's binding is moved. String, Vec,
owning structs, library handles and Drop values can all be passed this
way. An owned slice, or a struct whose literal gave it one, cannot go to
a sink yet; the compiler rejects it rather than risk two owners.
What you still free by hand
In safe NURL, nothing is released by hand. The compiler automatically drops String, Vec, owning structs, collections (HashMap, Set, Deque, BTree, Box, Rc, Arc), recursive enums (Json, TomlValue), closure environments, and every standard-library handle type — including TcpConn, TcpListener, TlsStream, UdpSocket, UnixConn, UnixListener, File, PFile, Channel, Mutex, Cond, Atomic, RwLocked, ProcChild, FsWatch, and Sqlite. Every OS and container handle wraps ( Shared T ) or Arc and closes or releases automatically when its last owner goes out of scope.
Explicit _close and _free helpers (string_free, vec_free, file_close, tcp_close_conn, udp_close, chan_free, etc.) remain only as an optional early release before the end of a scope, and --lint flags any call that sits right where scope-exit drop would happen anyway ([redundant-free]).
Only raw memory inside unsafe functions is managed by hand:
- raw allocations from
nurl_allocbehind a*Tpointer inside anunsafefunction (release withnurl_free); - values explicitly surrendered inside an
unsafefunction via( mem_forget x ).
Views are values
A Slice (slice_from_vec, slice_window, slice_of_str, string_bytes, file_map) is a first-class, non-owning view (ptr + len). Views are ordinary values: you can bind them, pass them to functions, store them inside structs, Option, or Vec, and return them from functions whose first parameter owns the backing storage.
The borrow checker tracks the backing owner for every view and prevents mutating, growing, or dropping the owner while any view derived from it is still live:
: ( Vec i ) v ( vec_new [i] )
( vec_push [i] v 10 )
: ( Slice i ) view ( slice_from_vec [i] v )
// ( vec_push [i] v 20 ) // compile error: cannot mutate v while view is live
( nurl_println_int ( slice_get [i] view 0 ) )The borrow checker
A static analysis pass runs by default. It catches the mistakes the
auto-drop layer alone cannot find. These mistakes include using a value
after a move, aliasing a value that has a mutable borrow, and a closure
capture that would outlive the stack frame it points into. Diagnostics
produce hard compile errors. The checker never changes the generated
code. A program compiles to the same output whether the checker runs or
not. --no-borrowck still turns the pass off, but it is deprecated and
warns: a program compiled without the checker is not held to the
ownership rules, so nothing guarantees it is memory-safe.
What it checks, briefly:
- Use-after-move — reading a binding after you consume it (by freeing
it, sending it to a
sinkparameter, or copying it into another owning binding). - Alias / double-free — the checker treats an immutable copy of an owned binding as a move, not a second owner.
- Escaping closures — you cannot return, store, or send a closure to a
thread, a fiber or a signal handler if it captures by reference: a
: ~binding it assigns or hands to aninoutparameter, or a: ~multi-field struct. - Exclusive access at a call site — a binding you pass
inoutcannot also have an alias from another plain argument in the same call. The same holds for any container the callee may change,inoutor not: it cannot also reach the call through another argument, as the same binding, as a view or borrow of it, or as a closure that changes it. - Iterator invalidation — you cannot mutate a foreach loop's collection from inside the loop body.
- Loop-carried moves — the checker rejects consuming a binding inside a loop body and reading it again on the next iteration. This catches the same problem as a free-inside-a-loop double-free in C.
Soundness boundary and mechanized Lean 4 proof
Together, auto-drop, the borrow checker, and the unsafe / raw boundary (docs/spec.md §3.3d–e) guarantee that every program the compiler accepts outside unsafe functions is memory-safe, leak-free, and data-race-free (docs/MEMORY.md §6.2, §6.5). Every soundness hole ever closed is locked down by a regression probe in tools/fuzz/holes/ (an open one is listed in docs/LIMITATIONS.md), and the ownership, borrow, and concurrency rules have a mechanized Lean 4 soundness proof in formal/ (phases A1–A3: A2.memory_safe, A2.no_leak_end, A3.claim_full; no sorry). Raw *T pointers, pointer casts, raw struct fields (raw :), and foreign (FFI) calls are gated behind unsafe @.
Threads and shared state
Sending a value across a thread boundary follows the same ownership rules:
- The compiler checks
Send/NotSendbounds on everything captured by athread_spawnclosure and rejects non-atomic handles likeRc. Arc(stdlib/std/arc.nu) provides atomic reference counting for shared read-only payloads (arc_getreturns an immutable view).- Mutable state shared across threads lives in
Locked [T](locked_new,locked_withinstdlib/std/thread.nu), which pairs an atomic reference count with a mutex so concurrent accesses are serialized safely;locked_awaitwaits until a condition on it holds. - State many threads read at once lives in
RwLocked [T](stdlib/std/rwlock.nu): any number of read guards, which lend frozen (read-only) views, or one write guard. A single word threads count or bump is anAtomic(stdlib/std/atomic.nu), and a file many threads read and write at offsets is aPFile(stdlib/std/pfile.nu).
Reading NURL's compiler errors
NURL treats compiler diagnostics as the primary place to learn the rules. A rejected program's error names the problem and, in most cases, the fix. Read it before you search elsewhere. See Common pitfalls for the handful of surprises the diagnostics point at most often.
Next
- Formal Verification (Lean 4) — interactive visual explorer, step-by-step memory & borrow simulator, Lean Blueprint dependency graph, and full Lean 4 proof browser for
A1,A2, andA3. - Common pitfalls — the practical cases worth knowing up front.
- Concurrency — threads, fibers, and channels.
- For the exact rule-by-rule semantics, see
docs/MEMORY.mdand the mechanized Lean 4 formalization informal/README.md.
Last updated on