StarknetAstro

StarknetAstro

14_Cairo1.0 Variable Ownership

14_Cairo1.0 Variable Ownership#

This article uses Cairo compiler version: 2.0.0-rc0. Because Cairo is being rapidly updated, there may be slight differences in syntax between different versions, and the article content will be updated to the stable version in the future.

Scope of Variables#

The scope of a variable, also known as the owner's scope, usually refers to the range of validity or accessibility of a 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;
    } // End of braces, the scope of the variable ends, and v1 variable cannot be accessed below
    ...
}

In the above code, the variable v1 is created within the braces of the main function, so the scope of v1 is from its creation until the end of the braces. The braces used here are regular braces, and the braces of if, loop, and fn also apply.

Origin of Variable Ownership#

In programming, there are many situations where variables are passed. For example, when calling a function, a variable is passed as a parameter to the function. This creates a phenomenon where variables can move between multiple scopes (note: this is a 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 place it in a data structure container, which requires a copy operation. For an object, a deep copy is required for 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 after being passed. 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 mentioned control.

Rules of Variable Ownership in Cairo#

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

  1. Every value in Cairo has an owner.
  2. There can only be one owner at a time.
  3. When a variable leaves the owner's scope, the variable is 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 passed 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 freed.

// 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 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 that involve using a variable whose ownership has been moved.

Note: Basic types (felt252, u8, etc.) have already implemented the Copy Trait, so there won't be any move situations when using basic types. Therefore, to demonstrate the effect of move, an 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#

Struct fields can be moved, so code that seems 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 this will result in a compilation error
    u.name.print(); // The name field can still be accessed as usual

    // 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 a new value, 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. Additionally, 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 free memory when they exceed their scope. So when writing a struct that contains 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 state that it implements 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, either a copy of the variable is passed, or the variable itself is passed, and ownership is transferred along with it, causing a change in scope. Similarly, the return value of a function can also transfer ownership.

When a variable exceeds its scope, it is either 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 another article by a great author, and I pay tribute to him.

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