Initializers and Finalizers
Note: A method taking self &new is an initializer only if it is named init or joins the overload name init; under any other name it is refused. So a clone method written against the new permission, as the Clone method section shows, is refused too. A method named clone may be declared and called as an ordinary method, but no copy calls it, and it does not make a type copyable.
Initializers and finalizers are specialized methods a type may optionally define. Their use bookends the life of a type's objects. Initializers help create new objects. Finalizers help destroy them.
Many types don't need initializer or finalizer methods. For those that do, here's how to define them.
Initializers#
Every new value of a struct is constructed by one of its initializers, written new, the type, and the initializer's arguments in parentheses:
imm pt = new Point(1., 2., 3.);
Every struct has an implicit initializer, which takes the values of its fields, in the order they are declared or by name, a field left out taking its default value (see structs). A struct may also declare initializer methods of its own, which can use any sort of logic for initializing a new value, even of private fields. They are useful when the implicit initializer would be:
- Insufficient, because initialization needs to perform some logic that is more than simply assigning value(s).
- Impossible, because parts of the type are private and can only be set by code in the module that declares it.
- Inconvenient, because listing every field would be unnecessarily verbose in comparison to using an initializer.
Here is a simple initializer:
fn init(self &new) {
*self = new Self(x: 1., y: 1., z: 1.);
}
An initializer is a method, but it has a few differences from typical methods:
- Its name is init, so you need not
specify the initializer's name when using it to create a new value.
A type may declare more than one initializer as an overload set: each has
its own name and joins the overload name init
(fn polar overload init(self &new, r f32, theta f32)).
A construction's arguments select the initializer that takes them.
Where a declared initializer and the implicit one both take values listed in order,
the declared one is used; if none takes them, or several declared ones do,
the construction is an error.
Only the implicit initializer takes values by name, so naming the fields always reaches it:
struct Seeded { pub seed i32; pub fn init(self &new, n i32) { *self = new Seeded(seed: n * 10 + 1); } } imm a = new Seeded(5); // the declared initializer: seed is 51 imm b = new Seeded(seed: 5); // the implicit initializer: seed is 5 - self is a borrowed reference using the new permission. Here's why: An initializer is called when memory has been set aside for the new value, but it does not yet hold a valid value. The borrowed reference effectively points to this location. It uses the new permission to signal that the reference does not yet point to a valid value. This prevents the initializer from accessing any of the fields until after they have been initialized. The permission is written only on an initializer's self.
- Initializers don't return a value. After the initializer has finished running, the construction that invoked it holds the value it wrote through self. Thus, having the initializer return a value would be superfluous and confusing.
- The initializer's logic uses another initializer to fill self.
Essentially, an initializer's logic performs these steps in order:
- Figure out what the values should be for every field (if needed). The fields of self are inaccessible at this point, as self is not pointing to a valid value. This means it is also impossible to call any other methods on self.
- Initialize the entire value of self at one time, *self = new Self(...), with an initializer other than this one: typically the implicit one, reached by naming the fields (as the above examples do). Values listed in order that this initializer itself takes would select it again, and filling self that way is an error. It must be done on every path before the initializer returns.
- Perform any additional work on the value that the logic requires, if needed. At this point, the initializer may access the value's fields or call any other method on the value.
- self does not escape. It is used only through itself: *self, a field of it, or a method called on it. It may not be passed to a function, stored, copied or returned.
- An initializer runs only in a construction. It is never called by name on a value, which it would overwrite.
The following code invokes the initializer defined above:
imm pt = new Point(); // x,y,z of the new Point value are 1.0
A construction fills its value in place, where the value goes. Declaring a variable with a construction hands the initializer the variable's own memory, and allocating one hands it the memory the region obtained; a value constructed where it has no place yet, as an argument to a function, or a field of another construction, is built in a temporary and copied there.
A value is constructed once. new Point(p), where p is already a Point, is an error: a finished value needs no construction. An allocation takes one as its value, moving it in.
An enum's variant is constructed with parentheses, Some(x), and an empty one by its name alone, None (Enums).
Constructing in a region#
An allocation constructs its value the same way, in the region. new Rc[mut, Node](1) runs Node's initializer on the memory the region allocated, after the region's own initializer has filled its bookkeeping. One given a finished Node instead, new Rc[mut, Node](n), moves it in and runs no initializer:
import stdio use *;
import libc;
struct Counted is RegionRef {
owners usize;
fn alloc(size usize) *u8 { libc.malloc(size); }
fn init(self &new) {
print <- "region init\n";
*self = new Counted(1usize);
}
fn aliasRef(self &uni Counted) { owners = owners + 1usize; }
fn dealiasRef(self &uni Counted) bool {
owners = owners - 1usize;
owners == 0usize;
}
fn free(self &uni Counted) {
print <- "free\n";
libc.free(self as *u8);
}
}
struct Node {
pub n i32;
pub fn init(self &new, k i32) {
print <- "node init\n";
*self = new Node(n: k * 10);
}
fn final(self &uni) { print <- "node final "; print <- n; print <- "\n"; }
}
fn main() i32 {
{
imm a = new Counted[mut, Node](1); // Node's initializer runs, on the region's memory
print <- a.n;
print <- "\n";
}
{
imm done = new Node(5);
imm b = new Counted[mut, Node](done); // a finished Node moves in: no initializer runs
print <- "moved in\n";
}
0i32;
}
The first block prints region init, node init, 10, node final 10 and free. The second prints node init (for new Node(5)), region init, moved in, node final 50 and free. The allocator is described in custom allocators.
Clone method#
As discussed earlier, it is not always obvious how to create a faithful copy of some types' values. When this difficulty exists (e.g., a value that contains references to other values), a type may define a clone method whose logic is capable of creating a faithful copy.
A clone method is essentially an initializer whose second parameter is a value or another borrowed reference to the value we want to copy:
fn clone(self &new, from &) {
*self = new Self(x: from.x, y: from.y, z: from.z);
}
Whenever copies are automatically made (e.g., assignment or function calls), the clone method will be used, if specified, to make these copies.
Finalizer#
When a object is destroyed at the end of its life, its finalizer logic, if defined, is automatically invoked. Only types that acquire a dependent relationship with other system object(s) need to define a finalizer. The finalizer releases, gives back, or safely breaks all such dependencies (e.g., closing file/network handles, de-subscribing to services, etc.) before the value's memory is reclaimed.
Here's a finalizer:
fn final(self &uni) {
file.close;
}
Key to a finalizer definition:
- The method's name must be final. Only one may be defined.
- self is a borrowed reference using the uni permission. No other parameters should be specified.
- Finalizers don't return a value.
- The logic may access the value and call methods. However, a finalizer's logic should run quickly without blocking, while still breaking all dependencies. After the finalizer finishes, the object will cease to exist.
A value dies the same way wherever it lives — a local variable at the end of its scope, a parameter at the end of its function, a field inside the value holding it, a temporary at the end of its statement: its finalizer runs first, then the finalizers of its fields, in the order they are declared, then the owning references its fields hold are released, in the order they are declared, and so on down, however deep. A tuple's elements die in order, as fields do, and so do an array's, first element first.
A temporary is a value an expression produces that nothing takes: it is not bound to a variable, stored in a field or element, passed by value, returned or moved. A call's result whose field is read, an owning reference read through, an owner lent to a call as a borrowed reference, a value thrown away by a statement: each is a temporary, and dies at the end of the statement that produced it, the newest first, as locals die at the end of their block. A temporary produced by the condition of an if or a while dies once the condition is decided, before the branch runs, so each pass of a loop finalizes its own; and one produced by the right operand of and or or, which runs on some paths only, dies at the end of that operand. A value moved out of a temporary owner belongs to its new holder, and the owner's memory is freed without it, as above.
imm n = open(name).length; // the file open returns is closed here, once its length is read
take(open(name)); // moved: take closes it
if open(name).length > 0 { // closed here, before the branch runs
...
}
A temporary a variable's initializer borrows lives longer, to the end of the block the variable is declared in, as Rust extends it: so the borrow is good for as long as the variable can be used. That is a temporary the initializer borrows directly (&make()), a part of one (&make().x), what a temporary owner owns (&*makeOwner()), or a temporary owner the initializer lends where a borrowed reference is declared (imm r &R = makeOwner()), whether the borrow is the whole initializer or an element of a tuple, an array or a variant literal it builds ((&make(), 1), Some[&R](&make())), or the value of a block, of an if's arm or of a match's arm in one of those places (if c { &make(); } else { &other(); }), as Rust 2024 extends them. Such a temporary dies at the end of its block, with the block's locals, the latest first; one an arm makes, only where that arm ran. A temporary passed to a function, or a method's receiver, is not extended, even in an initializer: it dies at the end of its statement, and a variable still holding a borrow of it may not be used after that. A borrow of a temporary anywhere else (show(&make())) points at it until then.
imm r = &make(); // the temporary lives to the end of the block imm s &R = makeOwner(); // so does the owner, and what it owns imm t = pick(makeOwner()); // the owner dies here: t may not be used after this statement
A temporary that a raw pointer made from it may outlive is not finalized at all. Nothing follows a raw pointer, so the compiler leaves the value as it is rather than finalize it and leave the pointer dangling. An ordinary borrow of the same temporary does not keep it:
fn mk(n i32) R {..}
fn id(r &R) &R {..} // answers the borrow it is handed
imm p = id(&mk(2)) as *R; // p may outlive the temporary: R's final never runs for it
imm q = id(&mk(3)); // an ordinary borrow: R's final runs at the end of this statement
A value dies on the path that still holds it, and only there. Where a variable's value was moved out, or given, on only some of the paths that reach the end of its scope — in one branch of an if or a match, in a loop that may not run, before an early return or break — the compiler keeps a hidden flag beside the variable, set where its value arrives and cleared where it leaves, and the end of its scope finalizes it only when the flag says the value is still there. A variable whose every path agrees has no flag, and costs nothing for it.
imm f = File.open(name);
if verbose {
log.keep(f); // f moves to the log on this path
}
// f is closed here only if verbose was false
A value assigned over dies as it is replaced: the new value is evaluated first, then the old one is finalized, then the new one is stored. This is so for a variable, a field or an element of one, and a value reached through a reference (*r = v). Where the variable holds nothing to replace — it was never given a value, or its value moved out — nothing is finalized; where that depends on the path, its flag decides.
A value allocated in a region is finalized when the region lets it go: when its single owner expires, or, for a counted region, its last owner. It dies as above, and only then is its memory freed, so a finalizer may still use everything its value owns. A value moved out of a region's object belongs to its new holder, which finalizes it; the object's memory is freed without finalizing what was moved out. Only the whole value moves: a field of it may not be moved out (move semantics), so no object dies partly moved out.
A value typed as an enum is finalized as the variant it holds. An enum may define its own final, which runs for every variant, and a variant may define one too: the variant's finalizer runs first, then the enum's, then the finalizers of the variant's fields (the enum's common fields among them), then the owning references those fields hold are released — the more specific type before the more general, as with inheritance in other languages. An enum none of whose variants has anything to finalize, such as Option[i32], costs nothing when its value dies.
A global variable lives until the program shuts down. Whatever it holds is then finalized by its module's finalizer, after the module's own final: a value whose type finalizes, a tuple or an array of them, or an owning reference, which is released (Dynamic initialization of global variables). A module has an initializer and a finalizer of its own too, described there.
struct R {
pub n i32;
fn final(self &uni) { print <- "final "; print <- n; print <- "\n"; }
}
mut one R;
mut pair (R, R);
mut many Array[R, 2];
mut owned So[R];
fn @initpure init() {
one = new R(1);
pair = (new R(2), new R(3));
many = [new R(4), new R(5)];
owned = new So[R](6);
}
After main returns, this prints final 1 through final 6, one to a line.