Learning Cairo - Ownership (6)

Learning Cairo - Ownership (6)

Prerequisites

Install cairo-run.

Ownership

Ownership is an important concept in Cairo/Rust that sets it apart from other languages. The ownership system in Cairo ensures the safety and correctness of compiled code by specifying that a variable can only be used once. The ownership system prevents common operations that can lead to runtime errors, such as illegal memory address references or multiple writes to the same memory address.

Let's take a look at the ownership rules in Cairo:

  • Every value in Cairo has an owner.
  • Only one owner can exist at any given time.
  • When the owner goes out of scope, the value is dropped.

Variable Scope

Scope defines the valid range of an item within a program. For example:

let s = 'hi';

s is a short string, and its lifetime starts from the moment it is declared until the end of the current scope. The following example demonstrates the scope range of s:

{
    // s is not valid here, it’s not yet declared
    let s = 'hi'; // s is valid from this point forward

    // do stuff with s
}   // this scope is now over, and s is no longer valid

In other words, there are two important points to note:

  • When s enters the scope, it is valid.
  • It remains valid until it goes out of scope.

Scope of Array Types

Array types are relatively complex, and we can use them to illustrate ownership rules. Let's start with a basic usage of an array type:

let mut arr = ArrayTrait::<u128>::new();
arr.append(1);
arr.append(2);

Here, we define a mutable array arr and then add two elements to it.

Let's consider a simple example:

use array::ArrayTrait;
fn foo(arr: Array<u128>) {}

fn bar(arr: Array<u128>) {}

fn main() {
    let mut arr = ArrayTrait::<u128>::new();
    foo(arr);
    bar(arr);
}

We first define a mutable array arr and then pass it as an argument to the foo and bar functions. According to the programming syntax in other languages, this should work without any issues. However, when we try to compile it, we get an error:

error: Variable was previously moved. Trait has no implementation in context: core::traits::Copy::<core::array::Array::<core::integer::u128>>
 --> main.cairo:7:9
    let mut arr = ArrayTrait::<u128>::new();
        ^*****^

The error message indicates that the variable was previously moved and has no implementation of the Copy trait.

This error message reveals the essence of ownership in Cairo. In the above code, when we call foo(arr), the ownership of arr is transferred to the foo function. Then, when we call bar(arr), it fails because arr has already left the current scope. This explains the error message:

error: Variable was previously moved.

This design in Cairo may seem difficult to understand, but it actually helps us avoid many runtime errors. In the example above, both foo(arr) and bar(arr) receive the same array instance. If both functions could be called normally, and if we add a value to the array in foo and then add another value to the array in bar, it would result in writing twice to the same memory location, which is not allowed in Cairo. To prevent this situation, when foo is called, the ownership of arr is transferred to foo, and when bar is called next, since the ownership of arr has already been transferred, such issues are prevented at the compilation level.

Copy Trait

The previous error message also contains useful information:

Trait has no implementation in context: core::traits::Copy::<core::array::Array::<core::integer::u128>>

Let's take a look at what the Copy trait is.

In the example above, because the ownership of arr is transferred to foo, calling bar results in an error. However, if we have a similar business scenario where we need to pass a variable to multiple functions, how do we handle it?

Cairo provides us with a Copy trait feature. If a type implements the Copy trait, passing a variable of that type to a function does not transfer the ownership of the value to the called function; instead, it passes a copy of the value. The Copy trait can be implemented by adding the #[derive(Copy)] attribute to custom types. However, if a type itself or any of its component types does not implement the Copy trait, the type cannot be marked as Copy. It's important to note that arrays and dictionaries cannot be copied.

#[derive(Copy, Drop)]
struct Point {
    x: u128,
    y: u128,
}

fn main() {
    let p1 = Point { x: 5, y: 10 };
    foo(p1);
    foo(p1);
}

fn foo(p: Point) {
    // do something with p
}

In this example, since Point implements the Copy trait, when foo is called, the ownership of p1 is not transferred; only a copy of p1 is passed. Therefore, we can call foo multiple times. If we remove the Copy trait, the compilation will result in an error.

Drop Trait

In the previous example, after Copy, there is the Drop identifier. Let's try removing Drop and compile again. It will result in an error:

error: Variable not dropped. Trait has no implementation in context: core::traits::Drop::<main::main::Point>. Trait has no implementation in context: core::traits::Destruct::<main::main::Point>.
 --> main.cairo:8:9
    let p1 = Point { x: 5, y: 10 };

The error message indicates that the variable was not dropped.

As mentioned before, variables in Cairo have ownership. When a variable is defined, its ownership belongs to the current scope. If it is passed as a parameter to another function, its ownership is also transferred to that function. If ownership is not transferred to another function, it remains within the current scope. Cairo specifies that a value cannot outlive its scope unless it has been moved previously.

For some built-in types, Cairo automatically calls an implicit drop function for variables of those types, allowing them to outlive their scope. For custom types, we need to explicitly indicate the Drop trait by adding #[derive(Drop)].

#[derive(Drop)]
struct A {}

fn main() {
    A {}; // Now there is no error.
}

Except for dictionaries (Felt252Dict) and types that contain dictionaries, all other types can derive the Drop implementation, allowing them to be dropped when they go out of scope. For example, the following code can be compiled successfully:

#[derive(Drop)]
struct A {}

fn main() {

 A {}; // No error here
}

Destruct Trait

As mentioned before, dictionary types cannot derive the Drop implementation. However, if a dictionary type cannot use Drop, the Cairo compiler will report an error for the following scenario:

use dict::Felt252DictTrait;

struct A {
    dict: Felt252Dict<u128>
}

fn main() {
    A { dict: Felt252DictTrait::new() };
}

error: Variable not dropped. Trait has no implementation in context: core::traits::Drop::<main::main::A>. Trait has no implementation in context: core::traits::Destruct::<main::main::A>.
 --> main.cairo:8:5
    A { dict: Felt252DictTrait::new() };
    ^*********************************^

Since the type A contains a dictionary, it cannot be dropped. Fortunately, Cairo provides us with the Destruct trait, which can provide similar functionality to Drop:

use dict::Felt252DictTrait;

#[derive(Destruct)]
struct A {
    dict: Felt252Dict<u128>
}

fn main() {
    A { dict: Felt252DictTrait::new() }; // No error here
}

Using Clone for Deep Copying Array Data

Cairo provides the clone method, which helps us deep copy the data of an array. For example:

use array::ArrayTrait;
use clone::Clone;
use array::ArrayTCloneImpl;
fn main() {
    let arr1 = ArrayTrait::<u128>::new();
    let arr2 = arr1.clone();
}

First, we need to import clone::Clone and array::ArrayTCloneImpl. Additionally, you need to add the following runtime parameter:

--available-gas=2000000

Since clone implementation involves looping, it requires a specified amount of gas.

Ownership and Functions

As we saw earlier, when passing a variable's value to a function, we can either move its ownership or copy its value. For array types, passing them as arguments to functions transfers their ownership. For other types, they each have their own way of handling this, for example:

#[derive(Drop)]
struct MyStruct {}

fn main() {
    let my_struct = MyStruct{};  // my_struct comes into scope

    takes_ownership(my_struct);     // my_struct's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                 // x comes into scope

    makes_copy(x);                  // x would move into the function,
                                    // but u128 implements Copy, so it is okay to still
                                    // use x afterward

}                                   // Here, x goes out of scope and is dropped.

fn takes_ownership(some_struct: MyStruct) { // some_struct comes into scope
} // Here, some_struct goes out of scope and `drop` is called.

fn makes_copy(some_uinteger: u128) { // some_uinteger comes into scope
} // Here, some_integer goes out of scope and is dropped.

After calling takes_ownership, if my_struct is used again, an error occurs because the MyStruct type does not implement the Copy trait. However, for the variable x, since it is an integer and implements the Copy trait by default, it can still be used after calling makes_copy.

Ownership can also be returned to the calling function through return values, not just by passing it as a parameter to a called function. For example:

#[derive(Drop)]
struct A {}

fn main() {
    let a1 = gives_ownership();           // gives_ownership moves its return
                                          // value into a1

    let a2 = A {};                        // a2 comes into scope

    let a3 = takes_and_gives_back(a2);    // a2 is moved into
                                          // takes_and_gives_back, which also
                                          // moves its return value into a3

} // Here, a3 goes out of scope and is dropped. a2 was moved, so nothing
  // happens. a1 goes out of scope and is dropped.

fn gives_ownership() -> A {               // gives_ownership will move its
                                          // return value into the function
                                          // that calls it

    let some_a = A {};                    // some_a comes into scope

    some_a                                // some_a is returned and
                                          // moves ownership to the calling
                                          // function
}

// This function takes an instance some_a of A and returns it
fn takes_and_gives_back(some_a: A) -> A { // some_a comes into
                                          // scope

    some_a                               // some_a is returned and moves
                                         // ownership to the calling
                                         // function
}

In the gives_ownership function, we define a variable some_a and return it, transferring ownership back to the calling function. In the main function, we define a2, which is then passed to the takes_and_gives_back function, transferring ownership again, and finally, it is returned back. This pattern can be redundant, but Cairo provides us with references and snapshots that allow us to use the variable value in the called function without transferring ownership.

Summary

Ownership is an important concept in Cairo. Although it may seem difficult to understand at first, it actually helps us avoid many runtime issues at the compilation level and is a powerful mechanism.

Low latency and free Starknet node awaits!

For a limited time, Reddio is offering unrestricted access to its high-speed StarkNet Node, completely free of charge. This is an unparalleled opportunity to experience the fastest connection with the lowest delay. All you need to do is register an account on Reddio at https://dashboard.reddio.com/ and start exploring the limitless possibilities.

You can discover why Reddio claims the fastest connection by reading more here.