12. Lifecycle and ownership

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 = 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).

struct MyNumber:

    var value_ptr: UnsafePointer[Int]

    fn __init__(inout self, value: Int):
        self.value_ptr = UnsafePointer[Int].alloc(1)
        initialize_pointee_move(self.value_ptr, value)

    fn value(self) -> Int:
        return self.value_ptr[]

    fn change_value(self, value: Int):
        initialize_pointee_move(self.value_ptr, 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.

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, unless its lifetime is explicitly extended. 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.

struct MyNumber:

    var value_ptr: UnsafePointer[Int]

    fn __init__(inout self, value: Int):
        self.value_ptr = UnsafePointer[Int].alloc(1)
        initialize_pointee_move(self.value_ptr, value)

    fn value(self) -> Int:
        return self.value_ptr[]

    fn change_value(self, value: Int):
        initialize_pointee_move(self.value_ptr, value)
    fn __del__(owned self):
        self.value_ptr.free()
    var num: MyNumber = MyNumber(42)
    print("num:", num.value())

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.

struct MyNumber:

    var value_ptr: UnsafePointer[Int]

    fn __init__(inout self, value: Int):
        self.value_ptr = UnsafePointer[Int].alloc(1)
        initialize_pointee_move(self.value_ptr, value)

    fn value(self) -> Int:
        return self.value_ptr[]

    fn change_value(self, value: Int):
        initialize_pointee_move(self.value_ptr, value)
    fn __copyinit__(inout self, other: Self):
        self.value_ptr = UnsafePointer[Int].alloc(1)
        initialize_pointee_copy(self.value_ptr, 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 all the cases where a value needs to be 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.

moveinit is particularly useful where copy operations are expensive. For example, in Mojo move semantics are used for String. This ensures that string operations are as much as possible efficient, while still maintaining immutability.

    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())

The different lifecycle operations are illustrated in the following diagram.

Lifecycle