Ownership · Lesson 11.4

Destructor

Source
Write a destructor, func ~T(self: &var T), and watch when the compiler runs it.
You'll need: Move, Method

A destructor is code that runs when a value's life ends. Nobody calls it. The compiler does, exactly once for every value that is still alive when its life ends — when its scope closes, when a function returns, or when = replaces it with a new value.

In real code a destructor gives back what the value was holding: a file, a network connection, a block of memory. Because the compiler calls it, it cannot be forgotten, and it cannot be called twice. Here the destructor only prints, so that you can watch exactly when it runs.

Writing a destructor

A destructor is written inside extend, named after the type with a leading ~, and borrows the dying value mutably:

struct Guest {
    name: char8[..];
}

extend Guest {
    func ~Guest(self: &var Guest) {
        PrintLine("    {} leaves", self.name);
    }
}

It takes no other parameters and returns nothing. The signature is fixed: func ~T(self: &var T), with the type's own name.

When a scope ends

The guest in Visit lives only as long as the call:

func Visit(name: char8[..]) {
    let guest = Guest { name: name };
    PrintLine("    {} visits", guest.name);
}
a short visit:
    Ada visits
    Ada leaves
    back in Main

"Ada leaves" is printed by the destructor as Visit returns, before Main carries on. The same happens at the end of every block that owns a value — including each pass of a loop body.

When a value is replaced

Replacing a value ends the old one's life first:

var seat = Guest { name: "Bob" };
seat = Guest { name: "Cy" };

Bob leaves at the =. Cy now holds the seat and will leave later. Replacing one field of a struct works the same way: the old field value is destroyed, the rest of the struct is untouched.

When a value is moved

After a move, only the new owner is destroyed:

let host = Guest { name: "Dee" };
let moved <- host;

host gave its value away, so there is nothing left in host to destroy. Dee leaves once, at the end of Main, as moved.

Newest first

Values that end together are destroyed in reverse order of creation — newest first. At the end of Main, seat (holding Cy) was created before moved (holding Dee), so Dee leaves first:

flowchart LR
    subgraph made["Created, in order"]
        direction LR
        c1["seat — Cy"] --> c2["moved — Dee"]
    end
    subgraph gone["Destroyed at the end of Main"]
        direction LR
        d1["Dee leaves"] --> d2["Cy leaves"]
    end
    made -- "reversed" --> gone

The order is not arbitrary. A value made later may depend on one made earlier — a reader that uses an open file, say — so it has to go first, while the thing it depends on is still there.

What happensDestructor runs
The owning scope endsyes, newest value first
= replaces the valueyes, on the old value, at the assignment
The value is moved with <-not here — the new owner destroys it later
The value is copiedthe copy is a second value, destroyed on its own

Why this program never copies a guest

A copy of a guest would be a second guest, and it would leave too. With let twin = host;, the program would print "Dee leaves" twice for one person. For a guest that only prints, that is odd; for a value that frees memory or closes a file, it would free or close the same thing twice. The next lesson shows how a type turns copying off.

The program

The whole lesson is one package in the Examples repository. Its comments explain every step.

Src/Main.rux
// A destructor is code that runs when a value's life ends. It is written inside `extend`, named
// after the type with a leading `~`, and borrows the dying value mutably:
//
//     func ~Guest(self: &var Guest) { ... }
//
// Nobody calls it. The compiler does, exactly once for every value that is still alive when its
// life ends: when its scope closes, when a function returns, or when `=` replaces it with a new
// value, whether it is a whole variable or one field of a struct. A value that was moved away is
// not destroyed where it was, because its new owner will destroy it later. Values that end
// together are destroyed in reverse order of creation, newest first.
//
// Here the destructor only prints, so its timing can be watched. In real code it gives back what
// the value was holding: a file, a connection, a block of memory. Notice that this program never
// copies a guest: a copy would be a second guest that leaves too. The NoCopy lesson deals with it.
import Io::PrintLine;

struct Guest {
    name: char8[..];
}

extend Guest {
    func ~Guest(self: &var Guest) {
        PrintLine("    {} leaves", self.name);
    }
}

// The guest lives only as long as this call.
func Visit(name: char8[..]) {
    let guest = Guest { name: name };
    PrintLine("    {} visits", guest.name);
}

func Main() -> int {
    PrintLine("a short visit:");
    Visit("Ada");
    PrintLine("    back in Main");

    // Replacing a value ends the old one's life first.
    PrintLine("swapping the seat:");
    var seat = Guest { name: "Bob" };
    seat = Guest { name: "Cy" };

    // After a move only the new owner is destroyed, so Dee leaves once, at the end.
    PrintLine("moving:");
    let host = Guest { name: "Dee" };
    let moved <- host;

    // `moved` was created after `seat`, so it is destroyed before it.
    PrintLine("end of Main:");
    return 0;
}

Run it

cd Examples/Ownership/Destructor
rux run
a short visit:
    Ada visits
    Ada leaves
    back in Main
swapping the seat:
    Bob leaves
moving:
end of Main:
    Dee leaves
    Cy leaves

Common mistakes

The wrong signature.
func ~Guest(self: &Guest) fails with error: destructor for type 'Guest' must have signature 'func ~Guest(self: &var Guest)'. The value is dying, and the destructor may need to change it on the way out — closing a handle, clearing a pointer — so it borrows it mutably.
The wrong name.
func ~Visitor(self: &var Guest) inside extend Guest fails with error: destructor '~Visitor' must be named '~Guest' for type 'Guest'.
Calling a destructor yourself.
seat.~Guest(); does not even parse: error: expected a field name or tuple index after '.' before '~'. Destruction is the compiler's job. To end a value early, replace it with = or move it into a function that lets it go.
Copying a value that has a destructor.
let twin = host; compiles, and both twin and host are destroyed — the destructor runs twice for what you meant as one guest. For a type that holds a resource, prohibit copying, as in Non-copyable types.

Try it yourself

  1. Create a guest inside a for loop over 0..3 and print the pass number. When does each guest leave?
  2. Add let twin = host; before the move and count how many times Dee leaves.
  3. Declare struct Table { number: int32; guest: Guest; }, make a var table, and assign a new guest to its guest field. Who leaves, and when?

Learn more

  • Move — why a moved-from value is not destroyed
  • Non-copyable types — one value, one destructor call
  • Defer — cleanup that belongs to a piece of work rather than a value
  • Methods in the Rux Reference