12. Ownership and lifecycle
Before we jump into the lifecycle operations, let’s understand the concept of ownership of references.
12.1. Pass by value and pass by reference
We have two ways to pass something to a function or method. One is pass by value and the other is pass by reference.
We call something as passed by value when the actual value of a variable is passed to the function, which results in the value being copied to the callee function’s argument. In this case the callee has its own copy and the caller has another copy. If the callee function changes the value, it is not reflected in the caller. In Mojo the data types that fit within the registers of the CPU are passed by default as values and so the callee gets a copy of the value. Also, when we perform an assignment of a variable to another variable, the value of the variable is copied to the assignee.
The second way is to pass the location where the value is stored. In this case, both the caller and the callee refers to the exact same location of the value. We can say that the caller is passing a reference to the value to the callee. So if the callee changes the value, that change will be reflected immediately in the caller.
When we pass a value by reference to a function, that function can potentially change the value. However, if the caller is not expecting its value to be changed while the callee changes the value, we end up with defects. In many programming languages that support pass by reference, it is a common source of defects. So how can we indicate to the caller of a function that the function intends to only read the value or it intends to change it? Mojo provides a solution by annotating the function arguments with a set of keywords that shows the intend.
12.2. borrowed
The borrowed keyword indicates that the argument is used only to read the value and the argument’s value will not be changed. This is the default behavior of all Mojo function’s arguments, so the borrowed
keyword is not necessary to be given.
When an argument is borrowed, the Mojo compiler prevents any mutation of the argument’s value. It also does not allow the binding of the argument to be changed as it would have led to discarding (and destruction) of the original value contained in the argument. Since we are borrowing the value, the caller would not expect the value to be destroyed.
In def
functions, you may find that Mojo seemingly allows mutation of the arguments. However, behind the scenes, it is performing a copy-on-write. This means that the argument is copied transparently to the developer, and the original argument is left intact. This is done so that for the developer the def
function feels similar to how it works in Python.
fn value_borrowed(borrowed val: Int):
...
fn value(val: Int): # This is also borrowed
...
12.3. owned
The owned
keyword indicates that the function assumes the ownership of the given reference argument. This means that we are free to mutate or destruct the passed value within that function.
When an argument is owned, the function can be sure that it can mutate the argument. It is possible that Mojo passes a copy of the value to the function in such cases. When the value is copied, then the caller has own copy and the callee function has its own copy.
fn value_owned(owned val: Int):
...
12.4. inout
The inout
keyword indicates that the function will potentially mutate the value within the passed reference. The difference from owned
references is that the inout
arguments are implicitly returned by the function. That is, the function cannot return an uninitialized inout
argument. If the value within the inout
reference is destructed, then another value must be assigned to the argument before the function returns.
To move a reference, the caret ^
operator is used.
fn value_inout(inout val: Int):
...
fn value_inout_return(inout val: String):
_ = val^ # Effectively destruct the value. Now the reference is uninitialized
val = str(10) # We have to assign a value otherwise Mojo compiler would complain
Let’s now look into the lifecycle methods. We start with one that we are already familiar with: the init
method.
12.5. __init__
The init method is part of the lifecycle of a struct. The main purpose of init
is to initialize all its member variables (a.k.a fields).
from memory import UnsafePointer
struct MyNumber:
var value_ptr: UnsafePointer[Int]
fn __init__(inout self, value: Int):
self.value_ptr = UnsafePointer[Int].alloc(1)
self.value_ptr.init_pointee_move(value)
fn value(self) -> Int:
return self.value_ptr[]
fn change_value(self, value: Int):
self.value_ptr.init_pointee_move(value)
var num: MyNumber = MyNumber(42)
print("num:", num.value())
In this example, we defined a struct
and the init
method within it. The first argument of the init
method is always self
with a modifier inout
. The self
is a reference to the struct’s own instance. The inout
tells the compiler that the self
is mutable (i.e., we can change the field values held within self
). In Mojo, the function arguments are by default read-only, and we cannot change the values of the function argument. The inout
is needed for self
so that we are able to initialize the fields within the struct. Since one of the main responsibility of init
is to initialize the fields of the struct, we naturally need to mark it as inout
.
In the example, we are allocating memory from the heap to store an integer value using the static method call UnsafePointer[Int].alloc
. We store a value into the pointer location using the function initialize_pointee_move
. We retrieve the stored value from the pointer using the deference operator []
.
12.6. __del__
The delete method del
is also part of the lifecycle of a struct. If the init method is used to initialize variables or to allocate resources for that struct, the delete method is used to release the resources held for that struct. For example, if init method allocates memory from the heap, the delete method is used to free that memory. The del
method is called just before the value is going to be destroyed by the compiler. If we allocate resources in the init method and do not release or free those resources in the delete method, we end up with resource leaks such as memory leaks. So great care must be taken to symmetrically allocate and free resources using the init and delete methods.
from memory import UnsafePointer
struct MyNumber:
var value_ptr: UnsafePointer[Int]
fn __init__(inout self, value: Int):
self.value_ptr = UnsafePointer[Int].alloc(1)
self.value_ptr.init_pointee_move(value)
fn value(self) -> Int:
return self.value_ptr[]
fn change_value(self, value: Int):
self.value_ptr.init_pointee_move(value)
fn __del__(owned self):
self.value_ptr.destroy_pointee()
self.value_ptr.free()
var num: MyNumber = MyNumber(42)
print("num:", num.value())
12.6.1. Eager destruction
Unlike many other languages, Mojo has an eager destruction approach. This means that a value or object is destroyed as soon as its last use is over. This is in contrast with many system languages where the values or objects are destroyed at the end of the scope of a given block. This approach allowed Mojo to have a much simpler lifecycle management, improving overall ergonomics of the language.
The eager destruction might sometimes surprise us while debugging Mojo programs as the variable references would already have been destructed if they are no longer being referenced at locations past the debug breakpoint. A common pattern to extend the lifetime of the value is by assigning it to _
.
var a_value: MyNumber = MyNumber(1)
print("Hello")
...
print("World")
_ = a_value # Keep the life of a_value until this point
12.7. __copyinit__
Mojo invokes the copyinit
for all the cases where a value needs to be copied. For example, when a variable is assigned to another one, the copyinit
may be called for the assignee. This method is quite similar to the init
method in the sense that it initializes the struct. In contrast to init
, copyinit
gets an additional argument of the same type as the struct in which the method is declared (the type of itself is named as Self
in Mojo). In the copyinit
it is expected that you initialize your member fields with values copied from the "other" struct. copyinit
is also known as copy constructor in other languages.
Mojo compiler tries to optimize away copies as much as possible, especially where the reference is not being used later on.
from memory import UnsafePointer
struct MyNumber:
var value_ptr: UnsafePointer[Int]
fn __init__(inout self, value: Int):
self.value_ptr = UnsafePointer[Int].alloc(1)
self.value_ptr.init_pointee_move(value)
fn value(self) -> Int:
return self.value_ptr[]
fn change_value(self, value: Int):
self.value_ptr.init_pointee_move(value)
fn __copyinit__(inout self, other: Self):
self.value_ptr = UnsafePointer[Int].alloc(1)
self.value_ptr.init_pointee_copy(other.value())
var num: MyNumber = MyNumber(42)
print("num:", num.value())
var other_num: MyNumber = num # Calling __copyinit__ on other_num
print("other_num after copy:", other_num.value())
other_num.change_value(84)
print("other_num after change:", other_num.value())
print("num after copy:", num.value())
In the previous code listing, within the copyinit
, we are allocating new memory for holding the copy of the value from other
. The other
has type Self
, which means the same type as the struct defining the copyinit
- in this case MyNumber
.
12.8. __moveinit__
Mojo invokes the moveinit
for cases where a value needs to be physically moved. This method is quite similar to the init
method in the sense that it initializes the struct. In contrast to copyinit
, moveinit
has the second argument annotated with owned
. The owned
is required because the second argument’s value will be destroyed once the move operation completes. In moveinit
, we reassign the values from the other
struct to the struct which defines the moveinit
.
One thing to note is that the moveinit
may not be always called when a value is "moved" (even when using the transfer ^
operator). You can think of move as a transfer of ownership, but sometimes the compiler is not able to optimize it to a simple transfer of ownership (logical move), instead needs to copy the value to a new location and clear the old location (physical move). Physical move happens for example when a value is moving from a short-lived stack frame to a longer lived one.
Move operations are particularly useful for cases where copy operations are expensive. For example, in Mojo move semantics are used for String
. This ensures that the string operations are as much efficient as possible.
fn __moveinit__(inout self, owned other: Self):
self.value_ptr = other.value_ptr
other.value_ptr = UnsafePointer[Int]()
var num: MyNumber = MyNumber(42)
print("num:", num.value())
var other_num2: MyNumber = num^ # Moving
print("other_num2 after move:", other_num2.value())
other_num2.change_value(84)
print("other_num2 after change:", other_num2.value())
# Uncommenting below line results in compiler error as `num` is no longer initialized
#print("num after copy:", num.value())
Due to the fact that Mojo compiler decides when to physically copy or move, we cannot make assumptions on when the moveinit
and copyinit
are actually called.
The different lifecycle operations are illustrated in the following diagram.