StarknetAstro

StarknetAstro

14_Cairo1.0 Variable Ownership

14_Cairo1.0 Variable Ownership#

The Cairo compiler version used in this article: 2.0.0-rc0. Since Cairo is being updated rapidly, there may be slight differences in syntax between different versions, and the article will be updated to the stable version in the future.

Variable Scope#

The scope of a variable, also known as the scope of the variable owner, usually refers to the range of validity or accessibility of the variable. It determines the lifetime and visibility of the variable. Let's look at an example:

fn main() {
	...
    {
        // Cannot access before variable declaration
        let mut v1 = 1; // Variable declaration statement
        // After variable declaration, within the variable's scope, it can be accessed
        v1 += 1;
    } // The end of the curly braces marks the end of the variable's scope, and the variable v1 cannot be accessed below
    ...
}

In the above example, the variable v1 is created within the curly braces of the main function, so the scope of v1 is from its creation to the end of the curly braces. The curly braces used here are regular curly braces, and the curly braces for if, loop, and fn are also applicable.

Origin of Variable Ownership#

In programming, there are many situations where variables are passed. For example, when calling a function, variables are passed as parameters to the function. This is where the phenomenon of variables moving between multiple scopes occurs (note: this refers to the phenomenon, not breaking the rules of variable scope).

There are two ways to implement this phenomenon:

  1. Passing by value (copying). Pass a copy of a variable to a function or put it in a data structure container, which requires a copy operation. For an object, a deep copy is required to ensure safety, otherwise various problems may occur, and deep copying can lead to performance issues.
  2. Passing the object itself (by reference). Passing by reference means that there is no need to consider the cost of copying the object, but it is necessary to consider the problem of the object being referenced by multiple places. For example: we write a reference to an object into an array or pass it to another function. This means that everyone has control over the same object, and if one person releases the object, the others will suffer. Therefore, the reference counting rule is generally used to share an object.

The concept of variable ownership in Cairo evolved from the control of ownership mentioned above.

Rules of Variable Ownership in Cairo#

In Cairo, the concept of "ownership" is strengthened. Here are the three iron rules of variable ownership in Cairo:

  1. Every value in Cairo has an owner.
  2. Only one owner at a time.
  3. When a variable leaves the owner's scope, it will be dropped.

To understand the impact of these three rules on the code, consider the following Cairo code:

use core::debug::PrintTrait;

struct User {
    name: felt252,
    age: u8
}

// takes_ownership takes ownership of the called function's input parameter, and since it doesn't return, the variable cannot leave
fn takes_ownership(s: User) {
    s.name.print();
} // Here, the variable s goes out of scope and the drop method is called. The occupied memory is released

// give_ownership moves the ownership of the return value to the calling function
fn give_ownership(name: felt252,age: u8)->User{
    User{name,age}
}

fn main() {
    // give_ownership moves the return value to s
    let s = gives_ownership();
    
    // Ownership is transferred to the takes_ownership function, s is no longer available
    takes_ownership(s);
    
    // If you compile the code below, an error will occur because s1 is not available
    // s.name.print();
}

Moving the ownership of an object to another object is called a move. This move operation is very efficient in terms of performance and safety. The Cairo compiler will help you detect errors when using variables that have had their ownership moved.

Note: Basic types (felt252, u8, etc.) have already implemented the Copy trait, so there will be no move situation when using basic types. To demonstrate the effect of move, a struct that has not implemented the Copy trait is used as the parameter of the takes_ownership function.

Individual fields of a struct can also be moved#

The fields of a struct can be moved, so code that may seem normal in other languages will result in a compilation error in Cairo:

use core::debug::PrintTrait;

#[derive(Drop)]
struct User {
    name: felt252,
    age: u8,
    school: School
}

#[derive(Drop)]
struct School {
    name: felt252
}

fn give_ownership(name: felt252, age: u8, school_name: felt252) -> User {
    User { name: name, age: age, school: School { name: school_name } }
}

fn takes_ownership(school: School) {
    school.name.print();
}

fn use_User(user: User) {}

fn main() {
    let mut u = give_ownership('hello', 3, 'high school');
    takes_ownership(u.school); // Passing only the school field of the struct, so the school field is moved

    // u.school.name.print(); // The school field has been moved, so there is a compilation error here
    u.name.print(); // The name field can still be accessed normally

    // use_User(u); // At this point, the entire struct cannot be moved because the school field has been moved

    // If the school field of the struct is assigned again, the struct variable can be moved again
    u.school = School{
        name:'new_school'
    };
    use_User(u)
}

The fields of a struct can be moved, so accessing a moved field will result in a compilation error, but other fields of the struct can still be accessed.

In addition, if one field is moved, the entire struct cannot be moved anymore. If the moved field is reassigned, the struct can be moved again.

Copy trait#

The Copy trait represents the ability to copy a value. If a type implements the Copy trait, when it is passed as a function parameter, a copy of the value will be passed. To add the Copy trait to a type, use the following syntax:

use core::debug::PrintTrait;

#[derive(Copy,Drop)]
struct User {
    name: felt252,
    age: u8
}

fn give_ownership(name: felt252,age: u8)->User{
    User{name,age}
}

fn takes_ownership(s: User) {
    s.name.print();
}

fn main() {
    let s = give_ownership('hello',3);
    s.age.print();
    takes_ownership(s);
    s.name.print(); // Since User implements the Copy trait, the ownership of s is not transferred above, so it can still be accessed here
}

When using the Copy trait, there are some limitations to be aware of: if a type contains fields that do not implement the Copy trait, then that type cannot be given the Copy trait.

Drop trait#

The Drop trait contains methods that can be understood as a destructor, which is the counterpart of a constructor. The destructor is automatically called when an object is destroyed to clean up the resources or state occupied by the object. In contrast, the constructor is used to initialize the state of an object.

As mentioned earlier, when a variable exceeds its scope, it needs to be dropped or destructed to clean up the resources it occupies. If a type does not implement the Drop trait, the compiler will capture this and throw an error. For example:

use core::debug::PrintTrait;

// #[derive(Drop)]
struct User {
    name: felt252,
    age: u8
}

fn give_ownership(name: felt252,age: u8)->User{
    User{name,age}
}

fn main() {
    let s = give_ownership('hello',3);
    //  ^ error: Variable not dropped.
}

The above code will result in a compilation error. If the #[derive(Drop)] is uncommented, the Drop trait is added to the User type, and it can be dropped without causing a compilation error. In addition, scalar types are implemented with the Drop trait by default.

Destruct trait#

Currently, the Destruct trait is used in dictionaries. Because dictionaries cannot implement the Drop trait, but they implement the Destruct trait, it allows them to automatically call the squashed method to release memory when they exceed their scope. So when writing a struct that includes a dictionary, you need to be careful, for example:

use dict::Felt252DictTrait;
use traits::Default;

#[derive(Destruct)]
struct A{
    mapping: Felt252Dict<felt252>
}

fn main(){
    A { mapping: Default::default() };
}

The struct A needs to explicitly implement the Destruct trait, otherwise it will result in a compilation error. Also, it cannot implement the Drop trait because Felt252Dict cannot implement the Drop trait.

Summary#

When passing a variable as a function parameter, it is either passed as a copy or passed as the variable itself, and ownership is transferred accordingly, and the scope changes. Similarly, the return value of a function can also transfer ownership.

When a variable exceeds its scope, it is dropped or destructed.

The above actions correspond to the Copy, Drop, and Destruct traits, respectively.

Some of the content in this article is referenced from a great article by another expert. We pay tribute and respect to him. 🫡

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.