Ownership and RAII
A Dazzle struct has at most three special members, and that is the whole list:
- a constructor, named after the struct;
drop, the destructor;copy, the copy constructor.
There is no move constructor, no move assignment, no copy assignment and no default constructor to write: the compiler derives them. Nothing here names an allocator either: the compiler decides where each String and List keeps its memory (below).
struct Level { pub name: String pub enemies: List<i32>
Level(name: &str) { // the constructor self.name = String(name) }}
impl Drop for Level { // runs exactly once, when its owner's scope ends fn drop(&mut self) { println!("unload '{}', {} enemies", name, enemies.len()) }}
impl Copy for Level { // opt-in: without it, a Level is move-only fn copy(&mut self, other: &Self) { name = other.name // a String copies its text enemies = other.enemies // a List copies its elements }}
fn main() { let mut a = Level("forest") a.enemies.push(10) let b = a // a copy: runs `copy` let c = move(a) // a move: `a` is reset to its defaults println!("{} and {}", b.name, c.name)} // c, b, then a: a's drop runs on an empty Levelforest and forestunload 'forest', 1 enemiesunload 'forest', 1 enemiesunload '', 0 enemiesThe drop here only prints, to show when it runs: a String and a List free their own memory, so a struct made of them needs no drop and no copy at all, and is freed, copied and moved with no code.
own is for what Dazzle does not manage itself: a surface, a file, a window or a GPU buffer that a C library handed over. A field marked own is a pointer the struct is responsible for releasing, so its drop gives it back to the library:
struct Image { pub width: i32 pub height: i32 m_surface: own ^mut sdl::SDL_Surface // Image frees this; private, like every unmarked field
Image(width: i32, height: i32) { self.width = width self.height = height m_surface = sdl::SDL_CreateSurface(width, height, .SDL_PIXELFORMAT_RGBA8888) }}
impl Drop for Image { fn drop(&mut self) { sdl::SDL_DestroySurface(m_surface) }}The rules
Section titled “The rules”own or drop makes a struct move-only. A struct with an own field, or a drop, can only be moved (move(x)) unless it implements Copy. A plain struct of copyable fields is copyable with no code at all.
An own field with no drop is a compile error, so a resource cannot be acquired and forgotten:
error[DZ0412]: `Surface` owns a resource but never releases it ┌─ src/main.dz:3:5 │3 │ m_pixels: own ^mut u8 // memory a C library handed over │ ^^^^^^^^ this field is marked `own` │ = help: add a `drop()` that releases `m_pixels`, or remove `own` if Surface does not own this pointer (spec §6.5)A moved-from value is reset to its defaults, not left in C++’s “valid but unspecified” state. Its drop still runs, once, and finds nothing to release: that is the last line of the output above. Reading it again is an error:
error[DZ0401]: `a` was moved and cannot be used again ┌─ src/main.dz:30:26 │29 │ let c = move(a) // a move: `a` is reset to its defaults │ ------- moved here30 │ println!("{} {} {}", a.name, b.name, c.name) │ ^^^^^^ used here after the move │ = help: a moved-from value is left at its defaults, and reading it back would almost never be what was meant (spec §6.4)Every value is initialised. let level: Level is an empty level, never garbage memory, and copy always runs on a value already at its defaults, so returning early from it leaves something valid.
References, pointers and handles
Section titled “References, pointers and handles”&Tand&mut Tare references, never null. A reference lives only where references live: a local, a parameter, a result, or a view struct such as an iterator. It never sits in an ordinary field or a container, so no struct carries a lifetime parameter.^Tand^mut Tare C pointers. Reading or writing through one happens insideunsafe, so every unchecked access is marked.own ^mut Tis a pointer this struct frees.- The standard library has
Rc,WeakandBoxfor everything in between.
What the compiler checks about references is the bug C++ code actually has: a reference used after a call grew, shrank or freed the memory it points into. A type names its memory with ties, and every function says in its signature which ties it may end, with a kills clause. A call ends every reference into what it kills:
// in std's List: each signature says what a call may endpub fn push(&mut self, v: T) kills self@items // may move the arraypub fn len(&self) -> usize // no clause: kills nothing
// in your codelet n = counts.get_or_insert(word, 0) // a reference into the tablecounts.insert(other, 1) // may grow the tablen += 1 // error[DZ0413]: `insert` kills `counts@items`, and `n` points into itThe compiler checks each clause against its function’s body, both ways, and dz fix writes the clause a body needs. Aliasing stays legal: a &mut and a & to one value are fine, as long as nothing in between can move the memory.
So use after move, double free, and a reference used after a call moved or freed what it points into are all compile-time errors. With the rest of the rules, they add up to a promise: a program with no unsafe has no undefined behaviour on one thread. Safety has every rule and where the promise ends, and Dazzle compared with Rust how this differs from a borrow checker.
Why it works this way
Section titled “Why it works this way”Why three members? In C++, a type that owns a resource needs a destructor, then a copy constructor, copy assignment, move constructor and move assignment to make copies and moves correct, and getting any one of them wrong is a silent bug. Four of those five follow mechanically from the other three, so Dazzle writes them. You write only what is specific to your type: how to build it, how to release it, and, if it can be copied, how.
Why reset a moved-from value? C++ leaves a moved-from object “valid but unspecified”, so whether its destructor or a later use is safe depends on each type’s author. Resetting it to its defaults makes the answer the same for every type: its drop runs and does nothing, because it now holds nothing. It costs a few stores per move and removes a whole category of bug, and reading it again is still a compile error.
Why ties rather than lifetimes? The most common dangling reference is one kept in an object that outlives what it points at, and keeping references out of ordinary fields rules that out with no analysis. What is left is the reference that a call invalidates, and a kills clause says that in the one place a reader looks: the signature. No struct carries a lifetime parameter, and the C++ shape of code, a pointer into an array you also change, stays legal as long as nothing in between can move the array.
Why does the compiler place memory? Passing an allocator to every container makes code that cares about memory say so, but every other line pays for it, and choosing wrong, a scratch arena for something that outlives the frame, is a dangling pointer. The compiler can see what outlives what, through the same ties, so it chooses: the fastest place it can prove safe, the heap otherwise.
What it becomes in C++
Section titled “What it becomes in C++”The three members map onto C++ special members, and the rest of the rule of five is synthesised, so C++ itself enforces destruction order, temporaries, and the deleted copy of a move-only type. Abridged from dz cpp’s output for Level:
struct Level { ::dz::std_::String name{}; ::dz::std_::List__i32 enemies{};
Level(::dz::str name); // the constructor Level() = default; // every field at its default ~Level(); // drop Level(const Level& other); // copy Level& operator=(const Level& other); // synthesised: drop, reset, copy Level(Level&& other); // synthesised: steal, reset the source Level& operator=(Level&& other); // synthesised: drop, steal, reset the source};
Level::Level(Level&& other) { name = static_cast<decltype(name)&&>(other.name); enemies = static_cast<decltype(enemies)&&>(other.enemies); other.reset_to_defaults__(); // the moved-from Level is empty, not unspecified}| Dazzle | C++ |
|---|---|
Level(name) |
Level(::dz::str name) |
impl Drop’s drop |
~Level() |
impl Copy’s copy |
Level(const Level&), run on a defaults-initialised *this |
| move | Level(Level&&): memberwise steal, then reset the source to its defaults |
| copy and move assignment | destroy, reset, then copy or steal, with a self-assignment guard |
| a move-only type | its copy constructor and copy assignment = deleted |
The last row is a second line of defence: even if Dazzle’s own move checking had a hole, the C++ compiler would refuse the copy.
Where memory comes from
Section titled “Where memory comes from”Ordinary code names no allocator. List<Enemy>() and String(name) get their memory where the compiler can prove it safe: the region of the scope that owns them, released in one step when the scope ends; wherever the caller keeps a result; or else the heap. The same ties check every placement, the editor shows where each allocation went, and dz placements lists them with the reason for each:
fn update(&mut self, camera: &Camera) { let visible = self.cull(camera) // built in update's region, released when it returns for e in visible { e.draw(&mut self.batch) }}Every build places memory this way; DZ_REGIONS=0 builds the same program with every value on the heap, to compare against. dz run --track-memory records every allocation and reports leaks with the line that allocated them, plus double frees and mismatched frees. It costs nothing when it is off.
In the specification
Section titled “In the specification”§6 Value semantics has the full ownership rules and §6.7 ties, and §14.6 where the compiler places memory. The ownership example builds a growable array with own.
