Skip to content
volt
GitHub

Ownership

Volt has no garbage collector. Every value has one owner (a variable, a field, an element), and when the owner’s scope ends the value is deleted: the compiler inserts the call. Deletes run in reverse order of declaration, and a struct’s fields are deleted after the struct’s own hook, last field first. Assigning a new value deletes the one it replaces.

A type that holds a resource attaches fn delete(this: T&). It runs automatically, exactly once; calling it by hand is an error.

use std::io;
struct file {
name: str;
}
attach fn delete(this: file&) -> void {
std::println("closing {}", this.name);
}
fn main() -> void {
val a: file = { name: "a.txt" };
{
val b: file = { name: "b.txt" };
std::println("inner scope ends");
}
std::println("main ends");
}
// expect: inner scope ends
// expect: closing b.txt
// expect: main ends
// expect: closing a.txt

Types made of owned parts (a struct with a std::vec field, say) need no hook: each field is deleted when the struct is.

A value nobody keeps, like the result of make("a") below, is a temporary. It’s deleted at the end of the statement it’s in, so make("a").view() can be used anywhere in that statement, even when the result points into it (word().as_str() as an argument). In a val or var’s initializer, it lives as long as the variable instead, and so does one in a break value that the initializer’s block gives. A defer’s statement keeps its own until it’s done.

use std::io;
struct note {
text: str;
}
attach fn delete(this: note&) -> void {
std::println("delete {}", this.text);
}
attach fn view(this: note&) -> str {
return this.text;
}
fn make(t: str) -> note {
return { text: t };
}
fn main() -> void {
std::println("{}", make("a").view()); // deleted after this statement
val kept = make("b").view(); // deleted when kept goes out of scope
std::println("{} still here", kept);
}
// expect: a
// expect: delete a
// expect: b still here
// expect: delete b

Using an owned value by value (passing it, returning it, assigning it) moves it: the new place owns it, and the old one is no longer usable. move x says so explicitly, but it’s never needed to return a local or parameter: return out; hands out to the caller without copying it. The compiler tracks moves through branches and loops, and using a moved value is a compile error:

use std::io;
fn consume(v: std::vec<i32>) -> void {}
fn main() -> void {
val v: std::vec<i32> = {};
consume(v);
std::println("{}", v.len);
}
// error: 'v' was moved earlier

A variable moved inside a loop needs a new value before the loop goes around, or the next pass would find nothing there: val c = move a; a = move b; b = c; is fine in a loop, eat(move a); on its own is an error. Values that own nothing, like numbers and plain structs, are copied instead of moved.

Only a whole variable moves. An owned field, element, or value behind a reference can’t move out of its place (the error says to copy it); a container hands an element out through a method, such as a vec’s pop or remove.

To lend a value without giving it up, pass a reference:

use std::io;
fn total(v: std::vec<i32>&) -> i32 { // borrows: the caller keeps v
var sum = 0;
for (x) in v.items() {
sum += x;
}
return sum;
}
fn main() -> !void {
var v: std::vec<i32> = {};
try v.push(2);
try v.push(3);
std::println("{} {}", total(&v), v.len);
}
// expect: 5 2

A reference never owns anything: nothing is deleted through a T&.

copy x makes a second, independent value. For a type with a delete hook, copying needs a copy hook too, or both copies would delete the same resource:

use std::io;
struct token {
id: i32;
}
attach fn delete(this: token&) -> void {
std::println("revoke {}", this.id);
}
attach fn copy(this: token&) -> token {
return { id: this.id + 100 };
}
fn main() -> void {
val a: token = { id: 1 };
val b = copy a;
std::println("{} {}", a.id, b.id);
}
// expect: 1 101
// expect: revoke 101
// expect: revoke 1

A copy hook can only read this. std’s containers attach copy, so copy v on a vec copies its elements, and copy b on a box (below) is a new box holding a copy of the value.

std::mem::box<T> owns one value on the heap. T::new(value) makes one (allocation can fail, so it’s an error union); a box is used like a T&, and deleting the box deletes the value and frees the memory.

use std::io;
struct node {
value: i32;
next: std::mem::box<node>?;
}
fn sum(n: node&) -> i32 {
if (n.next) { // narrows the field: a box, used like a node&
return n.value + sum(n.next);
}
return n.value;
}
fn main() -> !void {
val tail = try node::new({ value: 3, next: null });
val head: node = { value: 1, next: try node::new({ value: 2, next: tail }) };
std::println("{}", sum(&head));
}
// expect: 6

box is an ordinary std type: @attributes([@owns("ptr")]) is what makes a struct an owning pointer, and a library can make its own.

A box<T>? is as small as the box: a live box’s pointer is never null, so null means none, and node above is 16 bytes, like the C struct with a next pointer. That holds for any @owns struct whose other fields take no space, such as a box using the default allocator. A box from an arena carries the arena’s pointer, so its optional adds a flag. (An allocator that returned null for a successful allocation would make that box read as none.)

Deletes handle memory and anything with a hook. For other cleanup (unlocking, logging, closing a C handle) use defer, which runs when the scope ends, and errdefer, which runs only when it ends by returning an error. See Errors.

Debug builds stop the program on a double free. --leak-check makes a debug build exit with code 102 if any allocation was never freed:

Terminal window
voltc run app.volt --leak-check