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.