Skip to content

Memory and Safety

Ownership and moving ​

Every value has exactly one owner, and when the owner goes out of scope the value is destroyed. Not when a count happens to reach zero later. At the closing brace.

echo
struct Wormhole
{
    int32 $id;
    ptr<uint8> $stream;

    destructor()
    {
        echo $this->id;
        mem::free($this->stream);
    }
}

{
    Wormhole $open = Wormhole(7, mem::alloc<uint8>(64));
    echo "connected";
}

echo "disengaged";

That prints connected, then 7, then disengaged. The destructor ran at the brace, and nothing you write can make it run twice or not at all.

If your values don't own anything, none of this is your problem. Handing ownership somewhere else is what the rest of the rules are for. The examples below drop the buffer and keep the destructor, because a destructor is all it takes: a struct that has one owns something as far as the compiler is concerned, and every rule on this page follows from that.

Destruction order ​

echo
struct Wormhole
{
    int32 $id;

    destructor()
    {
        echo $this->id;
    }
}

Wormhole $first = Wormhole(1);
Wormhole $second = Wormhole(2);
Wormhole $third = Wormhole(3);

echo 0;

0, then 3, 2, 1. Reverse order is the only order that works: a value declared later may hold a reference to one declared earlier, so it has to be gone before its target is. A struct's own properties follow the same rule, last property first.

Leaving early ​

A return from inside a nested block leaves every scope it passes through, innermost first:

echo
struct Wormhole { int32 $id; destructor() { echo $this->id; } }

function dial(bool $abort) : int32
{
    Wormhole $outbound = Wormhole(1);

    if ($abort) {
        Wormhole $retry = Wormhole(2);
        return 10;
    }

    return 20;
}

echo dial(true);
echo dial(false);

2, 1, 10, then 1, 20. break and continue do the same for the scopes they leave, stopping at the loop.

mv ​

A move is written mv, and it transfers the obligation to destroy along with the value:

echo
struct Wormhole { int32 $id; destructor() { echo $this->id; } }

Wormhole $open = Wormhole(7);
Wormhole $handed = mv $open;

echo "one wormhole, one teardown";

One destructor line, not two. $handed owns it now and $open owes nothing, so the scope end only tears down what is still owned.

Use after move ​

This is the guarantee the whole design is for:

echo
struct Wormhole { int32 $id; destructor() { echo $this->id; } }

Wormhole $open = Wormhole(7);
Wormhole $handed = mv $open;

echo $open->id;     // error: '$open' has been moved out of

Nothing was compiled. There is no use-after-move at runtime because there is no use-after-move at all.

You can put a new value in the variable, though. A moved-from variable is empty, not poisoned:

echo
struct Wormhole { int32 $id; destructor() { echo $this->id; } }

Wormhole $slot = Wormhole(1);
Wormhole $kept = mv $slot;
$slot = Wormhole(2);

echo 0;

0, then 1, then 2. Two wormholes were built and two were destroyed, in the reverse order of the variables that ended up holding them.

One path ​

The move is tracked per path, so moving inside an if with no matching move on the other side is refused rather than guessed at:

echo
struct Wormhole { int32 $id; destructor() { echo $this->id; } }

function close(mv Wormhole $w) : void {}

Wormhole $open = Wormhole(7);

if (true) {
    close(mv $open);
}
// error: '$open' owns a resource and is moved out of on only one branch

The right side of ||, &&, ?? and ?-> is the same thing in a smaller coat: it only runs some of the time. So $ready || close(mv $open) gets the same refusal, pointing at the operator rather than an if.

A loop gets the same treatment with its own sentence, which is the same reason read backwards: the second iteration would move something that left on the first. '$open' is moved out of inside a loop, so the next iteration would move a value that is no longer there. Declare the value inside the loop, or move it after.

mv at the call ​

echo
struct Wormhole { int32 $id; destructor() { echo $this->id; } }

function close(mv Wormhole $w) : void
{
    echo "closing";
}

Wormhole $open = Wormhole(7);
close(mv $open);

echo "after";

closing, 7, after. Note where the 7 lands: the wormhole is destroyed at the end of close, because that's where its owner now goes out of scope.

Leave the mv off at the call and you are told to write it:

echo
struct Wormhole { int32 $id; destructor() { echo $this->id; } }

function close(mv Wormhole $w) : void {}

Wormhole $open = Wormhole(7);
close($open);   // error: '$w' takes ownership of this argument - write 'mv' in front of it

I like this rule more than any other one in the language. A signature can't quietly eat something you thought you still had, and you never have to read a function's declaration to find out whether your variable survived the call. Every place a value stops being yours is a word in your own source.

A temporary is exempt, because there is no variable left holding the wreckage:

echo
struct Wormhole { int32 $id; destructor() { echo $this->id; } }

function open(int32 $id) : Wormhole
{
    return Wormhole($id);
}

function close(mv Wormhole $w) : void
{
    echo "closing";
}

close(open(7));
echo "after";

And writing mv on a temporary is refused, since it was already on its way:

echo
function open(int32 $id) : int32
{
    return $id;
}

$w = mv open(7);    // error: 'mv' needs an expression with storage to move out of

Returning ​

A local handed back by return is moved, not copied. No destructor at the end of the callee, no duplicate:

echo
struct Wormhole { int32 $id; destructor() { echo $this->id; } }

function dial(int32 $id) : Wormhole
{
    Wormhole $w = Wormhole($id);
    return $w;
}

Wormhole $active = dial(7);
echo "connected";

connected, then a single 7. The same applies to $this at the end of a constructor, which is why a constructor can build something owning without it being destroyed on the way out.

And it applies through a call in the same statement. return wrap($w) is return $w with something in between, so $w is handed to wrap rather than copied for it. The return was going to end its life either way:

echo
struct Cargo { int32 $mass; destructor() { echo $this->mass; } }

function stow(Cargo $c) : int32
{
    echo "stowed";
    return $c->mass;
}

function launch() : int32
{
    Cargo $hold = Cargo(3);
    return stow($hold);
}

echo launch();

stowed, then 3 as the cargo is destroyed inside stow, then 3 again as the result. One Cargo, one destructor. Cargo never had to say what a copy of it would mean, because none is taken.

Write the same call one line earlier and it is refused, which is the rule showing you its edge:

echo
function unloaded() : int32
{
    Cargo $hold = Cargo(4);
    int32 $mass = stow($hold);      // error: 'Cargo' owns a resource and cannot be copied
                                    // implicitly at this argument
    return $mass;
}

Here $hold is still yours on the next line, so the call needs a value of its own and Cargo has not said how to make one. mv $hold is the answer if you meant to hand it over. In the return above you didn't have to write it, because there was nothing left for it to be.

Temporary chains ​

A method that returns $this is handing you the value you already have. When that value is a temporary that dies at the semicolon, binding it is a move:

echo
struct Probe
{
    array<int64> $xs = array<int64>();

    constructor() {}

    constructor(const Probe& $other)
    {
        echo 'copy';
        $this->xs = $other->xs;
    }

    function add(int64 $x) : Probe&
    {
        $this->xs[] = $x;
        return $this;
    }
}

$p = Probe()->add(1)->add(2);
echo $p->xs->count();     // 2

Probe $live = Probe();
Probe $q = $live->add(9);
echo $q->xs->count();     // 1

No copy on the first binding. The constructor ran, add mutated that one value, and $p took it. The same chain on a live local still copies, because the local is still yours on the next line: the copy constructor prints copy, then the echo prints 1.

unwrap() is the same shape. Bind a temporary result by value and the payload moves:

echo
int32 $n = result<int32, string>::ok(41)->unwrap();
echo $n;    // 41

guard over a call does the same. Keep the result in a local and the binding is a copy, because that local is still there after the statement.

A named borrow of a temporary is still refused. Probe& $r = Probe()->add(1) would dangle at the semicolon. Bind the value. And if the method hands back one field of a struct that still owns something else, that is a copy: moving the field would skip the rest of the destructor.

Not a hint ​

mv does not exist to make a call cheaper, and reaching for it because a value looks expensive is the wrong instinct.

What mv moves is the obligation to destroy. It says nothing about how the bytes travel, it does not promise the value stays at the same address, and it does not turn a by-value parameter into a free one.

If you want a call to avoid work, the answer is a borrow. If you want the callee to keep the value, the answer is mv. Those are two different questions, and answering the second to solve the first is how you end up with a function that owns things it never needed.

Borrowing ​

A borrow gives a function access to your value and takes nothing:

echo
struct Wormhole { int32 $id; destructor() { echo $this->id; } }

function report(const Wormhole& $w) : int32
{
    return $w->id;
}

Wormhole $open = Wormhole(4);
echo report($open);
echo report($open);

echo 0;

4, 4, 0, then 4. Called twice, destroyed once, and the call site says nothing at all: the compiler takes the address for you.

const T& is a read-only borrow and T& is one you can write through. Both are non-null, both cost the same as a pointer, and neither owns anything. Pointers and references is the chapter for what you can do with them.

Temporaries ​

Hand a freshly built value straight to a borrow parameter and it stays alive for exactly as long as the call:

echo
struct Wormhole
{
    int32 $id;

    constructor(int32 $id)
    {
        $this->id = $id;
        echo 1;
    }

    destructor()
    {
        echo 3;
    }
}

function report(Wormhole& $w) : int32
{
    echo 2;
    return $w->id;
}

report(Wormhole(5));
echo 4;

1, 2, 3, 4. Built, borrowed, destroyed, and only then does the next statement run. Not destroyed before the callee saw it, and not left lying around until the end of the scope.

Moving a field ​

Sometimes you want one field, not the whole struct. A by-value local (or a by-value parameter) can hand a field over. The rest stays. Scope-end drops skip the hole:

echo
struct Wormhole
{
    int32 $id;

    constructor(int32 $id) { $this->id = $id; }
    destructor() { echo $this->id; }
}

struct Gate
{
    Wormhole $active;
    int32 $chevrons;
}

Gate $sgc = Gate(Wormhole(1), 7);
Wormhole $stolen = mv $sgc->active;
echo $sgc->chevrons;            // 7
echo $stolen->id;               // 1

1 prints twice: once from the echo, once when $stolen is destroyed. Gate has no destructor of its own, so the compiler can skip $active and leave $chevrons alone.

That is the line. A type that declares destructor() cannot be left with a hole, because the destructor would run over storage that is no longer there:

echo
struct Wormhole
{
    int32 $id;
    constructor(int32 $id) { $this->id = $id; }
    destructor() { echo $this->id; }
}

struct Gate
{
    Wormhole $active;
    destructor() { }
}

Gate $sgc = Gate(Wormhole(1));
Wormhole $stolen = mv $sgc->active;
// error: 'mv' cannot move a field out of 'Gate', which declares a destructor

return $h->item of a destructor-free holder is the same transfer as mv $h->item. So is return tag_of($h->item) when that is the last mention of the field.

A getter on $this still copies. $this is a borrow, and the caller still holds the rest of the value. Write return mv $this->item to consume that field at the call:

echo
struct Item
{
    int32 $n;
    constructor(int32 $n) { $this->n = $n; }
    destructor() { echo $this->n; }
}

struct Holder
{
    Item $item;
    int32 $tag;

    function take_item() : Item
    {
        return mv $this->item;
    }
}

Holder $h = Holder(Item(1), 7);
Item $x = $h->take_item();
echo $h->tag;                   // 7

$h->item is unset after that call. $h->tag is fine. Filling the hole is $h->item = mv $x.

To eat the whole receiver, write function take(mv $this) and call $h->take(). $h is then unset.

An element is not a field. An array index is runtime. A foreach &$s looks like a variable; it is a borrow of a slot. mv $s is refused the same way mv $items[0] is:

echo
struct Wormhole { int32 $id; destructor() { echo $this->id; } }

array<Wormhole> $open = [Wormhole(1), Wormhole(2)];
array<Wormhole> $handed = [];

foreach ($open as &$w) {
    $handed[] = mv $w;
}
// error: 'mv' can only move a whole variable

Copy it, or drain the array with take_from. I reach for the drain: the source is emptied, the dest grows, and nested owners move with the element.

One element into an array is still $handed[] = mv $gate, and that still needs a whole variable. Writing a piece of a value is a different question, and that one works.

Field writes ​

Assigning over a field that owns something destroys what was there and stores the new value, exactly as assigning over a whole variable does:

echo
struct Client
{
    string $baseUrl;

    constructor() { $this->baseUrl = ''; }

    function base(const string& $url) : void
    {
        $this->baseUrl = $url;      // the old string is destroyed first
    }
}

Client $api = Client();

$api->base('https://example.com');
$api->base('https://echoc.dev');    // and again - nothing leaks

You don't have to write a clear()/append() pair to reach the same state, and an owning field doesn't need a setter method where a scalar one would be a plain field.

An element is the same statement. The compiler binds the element's address once, so the old value is destroyed through that address and $at is not evaluated twice:

echo
array<string> $rows = array<string>();
$rows->push('first');

usize $at = 0;
$rows[$at] = 'replaced';

echo $rows[0];      // replaced

That's every container whose bracket hands back a T&: array, slice, and a type of yours that declares only operator []. A map is different: it declares operator []=, so the write is one call and there is no assignment left to tear anything down.

A raw pointer element is still refused. $p:$[0] = $s is storage nothing is accounting for, so there is no old value there to destroy. mem::take ends what is in the slot, mem::init seats the new one.

Classes ​

Everything above is about values. A class is not a value: it is a reference-counted object on the heap, and assigning one hands out another name for it rather than another copy of it.

echo
class Stargate
{
    int32 $lockedChevrons;
}

Stargate $sgc = Stargate(0);
echo mem::refs($sgc);      // 1

Stargate $alias = $sgc;
echo mem::refs($sgc);      // 2

So there is nothing to move. A class handle is copied freely, the destructor runs when the last holder goes away rather than at a brace you can point at, and mv has no work to do. The rules on this page start mattering again the moment a struct owns something.

A unique heap T with no header is box<T>. Copying it is refused. Dropping it destroys T and frees the allocation. rc<T> is the shared twin.

Cycles ​

Reference counting has one failure mode and Echo does not pretend otherwise. Two objects holding each other never reach zero, and there is no cycle collector coming.

echo
class Wormhole
{
    int32 $id;
    weak<Stargate> $origin;

    destructor()
    {
        echo $this->id;
    }
}

class Stargate
{
    int32 $lockedChevrons;
    Wormhole? $active;

    destructor()
    {
        echo 100;
    }
}

function dial() : void
{
    Stargate $sgc = Stargate(7, null);
    Wormhole $outbound = Wormhole(1, null);

    $sgc->active = $outbound;       // strong, forward
    $outbound->origin = &$sgc;      // weak, backward
}

dial();
echo "done";

Both destructors run. Pick the direction that owns, make the other one weak<T>, and the counts reach zero in order. Had both edges been strong, neither destructor would run and nothing would tell you.

A weak reference doesn't keep its object alive, so reading one is a question rather than an access. Ask it with strong(), which gives you a T?:

echo
class Stargate { int32 $lockedChevrons; }

function watch() : weak<Stargate>
{
    Stargate $sgc = Stargate(7);
    weak<Stargate> $w = &$sgc;
    return $w;
}

weak<Stargate> $dangling = watch();

Stargate $stillThere = guard strong($dangling) else {
    echo "the gate is gone";
    die("nothing to report");
}

echo $stillThere->lockedChevrons;

The gate really is gone by then, so that program prints the gate is gone and stops. Reading through a dead weak reference is not something you can do by accident: there is no spelling for it.

Next ​

  • Copying for what happens when you do not move, and what a copy costs.
  • Pointers and references for T&, ptr<T> and the address forms.
  • Classes for reference counting, weak<T> and instanceof in full.

Echo is a work in progress. Nothing here is a promise of stability.