Learning Cairo - More About Structs (10)

Learning Cairo - More About Structs (10)

Prerequisite

Install cairo-run.

Method

Methods and functions share similarities: they both use the fn keyword and a name for declaration, can take parameters and return values, and contain code that is executed when the method or function is called. However, there are differences between methods and functions. Methods are defined in the context of a struct, and their first parameter is always self, representing an instance of the struct that calls the method. For those familiar with the Rust programming language, using methods with Cairo might be confusing because you can't directly define methods on types. Instead, you need to define a trait and implement it for the associated type of the method.

Defining Methods

Let's try to rewrite the previous Rectangle and its related area function into an area method defined on the RectangleTrait trait:

use debug::PrintTrait;

struct Rectangle {
    width: u64,
    height: u64,
}

trait RectangleTrait {
    fn area(self: @Rectangle) -> u64;
}

impl RectangleImpl of RectangleTrait {
    fn area(self: @Rectangle) -> u64 {
        (*self.width) * (*self.height)
    }
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };

    rect1.area().print();
}

To define a function within the context of Rectangle, we write a trait block containing the signatures of the methods we want to implement. Traits are not directly associated with specific types; only the self parameter of the method defines which types it can apply to. Then, we define an impl block for RectangleTrait, which defines the behavior of implementing the method. Everything within this impl block is specific to the type of the self parameter of the called method. While it's technically possible to define methods of different types in the same impl block, it's not a recommended practice as it can lead to confusion. It's advisable to keep the types consistent within the same impl block. We move the area function into the impl block and replace all occurrences of the first parameter with self. In the main function, we call the area method and pass rect1 as the argument.

We can invoke the area method on a Rectangle instance. The syntax involves using a dot after the instance, followed by the method name, parentheses, and any parameters.

Methods must have a type parameter named self as their first parameter, representing the instance of the type they will be applied to. It's important to note the use of the @ dereference operator before the Rectangle type in the function signatures. This indicates that the method takes an immutable snapshot of the Rectangle instance, and the compiler automatically creates this snapshot when passing the instance to the method. Methods can take ownership of the self instance, just as we use a snapshot of self here, or use the ref self: T syntax to work with a mutable reference to self.

The choice of using self: @Rectangle instead of the function version's @Rectangle is the same: we don't want ownership, just the ability to read data from the struct without writing to it. If we wanted to modify the instance calling the method within the method itself, we would use ref self: Rectangle as the first parameter. Having methods take ownership of the instance by using only self as the first parameter is rare; this technique is often used when the method converts self into another instance and we want to prevent the caller from using the original instance after the conversion.

When accessing members of the struct, note the use of the dereference operator * in the area method. This is necessary because the struct is passed as a snapshot, and its field values are of type @T, requiring dereferencing for manipulation.

The primary reason for using methods over functions is organization and code clarity. All operations available for instances of a type are placed within the combination of trait and impl blocks, rather than having users of the library search for functions related to Rectangle in different places. However, multiple trait and impl block combinations can be defined for the same type in different locations, which is useful for finer-grained code organization. For instance, you can implement an impl trait in one block and implement a Sub trait in another.

You can choose to give a method the same name as a field in the struct. For example, you can define a method on Rectangle and name it width:

use debug::PrintTrait;

struct Rectangle {
    width: u64,
    height: u64,
}

trait RectangleTrait {
    fn width(self: @Rectangle) -> bool;
}

impl RectangleImpl of RectangleTrait {
    fn width(self: @Rectangle) -> bool {
        (*self.width) > 0
    }
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };
    rect1.width().print();
}

Methods with More Parameters

Let's further practice using methods by implementing a second method on the Rectangle struct. This time, we want instances of Rectangle to accept another instance of Rectangle as a parameter. The method should return true if the second Rectangle can fit entirely within self, otherwise it should return false. In other words, once we define the can_hold method, we want to be able to write code like this:

use debug::PrintTrait;

struct Rectangle {
    width: u64,
    height: u64,
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };
    let rect2 = Rectangle { width: 10, height: 40 };
    let rect3 = Rectangle { width: 60, height: 45 };

    "Can rect1 hold rect2?".print();
    rect1.can_hold(@rect2).print();

    "Can rect1 hold rect3?".print();
    rect1.can_hold(@rect3).print();
}

The output would be:

[DEBUG]  Can rect1 hold rect2?          (raw: 384675147322001379018464490539350216396261044799)

[DEBUG]  true                          (raw: 1953658213)

[DEBUG]  Can rect1 hold rect3?          (raw: 384675147322001384331925548502381811111693612095)

[DEBUG]  false                         (raw: 439721161573)

We need to define a method, place it within the trait RectangleTrait and impl RectangleImpl of RectangleTrait blocks. The method is named can_hold, and it takes a snapshot of another Rectangle as a parameter. We can determine the parameter's type by looking at how the method is called: rect1.can_hold(@rect2) passes @rect2 as a snapshot to rect2, which is an instance of Rectangle. This makes sense because we only need to read from rect2 (not write to it, requiring

a mutable borrow), and we want main to retain ownership of rect2 to use it again after calling the can_hold method. The return value of can_hold will be a boolean, and the implementation will check if the width and height of self are greater than the width and height of the other Rectangle. Let's add the new can_hold method to the trait and impl blocks:

trait RectangleTrait {
    fn area(self: @Rectangle) -> u64;
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool;
}

impl RectangleImpl of RectangleTrait {
    fn area(self: @Rectangle) -> u64 {
        *self.width * *self.height
    }

    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool {
        *self.width > *other.width && *self.height > *other.height
    }
}

Accessing Functions Inside Implementations

All functions defined within trait and impl blocks can be accessed directly using the :: operator on the implementation's name. Functions within traits that aren't methods are commonly used as constructor functions to return new instances of a struct. These functions are often named new, but new is not a special or built-in name. For example, we can choose to provide an associated function named square, which takes a dimension parameter and uses it as both width and height, making it easier to create a square Rectangle without specifying the same value twice:

trait RectangleTrait {
    fn square(size: u64) -> Rectangle;
}

impl RectangleImpl of RectangleTrait {
    fn square(size: u64) -> Rectangle {
        Rectangle { width: size, height: size }
    }
}

To call this function, we use the :: syntax after the name of the implementation; for example, let square = RectangleImpl::square(10);. This function is within the namespace of its implementation; the :: syntax is used to create namespaces for functions associated with traits and modules.

Multiple impl Blocks

Each struct can have multiple trait and impl blocks:

trait RectangleCalc {
    fn area(self: @Rectangle) -> u64;
}

impl RectangleCalcImpl of RectangleCalc {
    fn area(self: @Rectangle) -> u64 {
        (*self.width) * (*self.height)
    }
}

trait RectangleCmp {
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool;
}

impl RectangleCmpImpl of RectangleCmp {
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool {
        *self.width > *other.width && *self.height > *other.height
    }
}

Summary

By creating structs, you can define many meaningful custom types that contribute to clearer code structure. In trait and impl blocks, you can define methods associated with your types, where methods are functions tied to the behavior of instances of a struct.

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.