Method Syntax
Methods are similar to functions: we declare them with the fn
keyword and a
name, they can have parameters and a return value, and they contain some code
that’s run when the method is called from somewhere else.
Unlike functions, methods are defined within the context of a type, either with their first parameter self
which represents the instance of the type the method is being called on (also called instance methods),
or by using this type for their parameters and/or return value (also called class methods in Object-Oriented programming).
Defining Methods
Let’s start by changing the area
function that has a Rectangle
instance as a parameter,
to an area
method for the Rectangle
type.
To do that, we first define the RectangleTrait
trait with an area
method taking a Rectangle
as self
parameter.
#![allow(unused)] fn main() { trait RectangleTrait { fn area(self: @Rectangle) -> u64; } }
Then, we implement this trait in RectangleImpl
with the impl
keyword. In the body of the area
method, we can access to the calling instance with the self
parameter.
#![allow(unused)] fn main() { impl RectangleImpl of RectangleTrait { fn area(self: @Rectangle) -> u64 { (*self.width) * (*self.height) } } }
Finally, we call this area
method on the Rectangle
instance rect1
using the <instance_name>.<method_name>
syntax. The instance rect1
will be passed to the area
method as the self
parameter.
fn main() { let rect1 = Rectangle { width: 30, height: 50, }; println!("Area is {}", rect1.area()); }
Note that:
-
there is no direct link between a type and a trait. Only the type of the
self
parameter of a method defines the type from which this method can be called. That means, it is technically possible to define methods on multiple types in a same trait (mixingRectangle
andCircle
methods, for example). But this is not a recommended practice as it can lead to confusion. -
It is possible to use a same name for a struct attribute and a method associated to this struct. For example, we can define a
width
method for theRectangle
type, and Cairo will know thatmy_rect.width
refers to thewidth
attribute whilemy_rect.width()
refers to thewidth
method. This is also not a recommended practice.
The generate_trait
Attribute
If you are familiar with Rust, you may find Cairo's approach confusing because methods cannot be defined directly on types. Instead, you must define a trait and an implementation of this trait associated with the type for which the method is intended. However, defining a trait and then implementing it to define methods on a specific type is verbose, and unnecessary: the trait itself will not be reused.
So, to avoid defining useless traits, Cairo provides the #[generate_trait]
attribute to add above a trait implementation, which tells to the compiler to generate the corresponding trait definition for you, and let's you focus on the implementation only. Both approaches are equivalent, but it's considered a best practice to not explicitly define traits in this case.
The previous example can also be written as follows:
#[derive(Copy, Drop)] struct Rectangle { width: u64, height: u64, } #[generate_trait] impl RectangleImpl of RectangleTrait { fn area(self: @Rectangle) -> u64 { (*self.width) * (*self.height) } } fn main() { let rect1 = Rectangle { width: 30, height: 50, }; println!("Area is {}", rect1.area()); }
Let's use this #[generate_trait]
in the following chapters to make our code cleaner.
Snapshots and References
As the area
method does not modify the calling instance, self
is declared as a snapshot of a Rectangle
instance with the @
snapshot operator. But, of course, we can also define some methods receiving a mutable reference of this instance, to be able to modify it.
Let's write a new method scale
which resizes a rectangle of a factor
given as parameter:
#[generate_trait] impl RectangleImpl of RectangleTrait { fn area(self: @Rectangle) -> u64 { (*self.width) * (*self.height) } fn scale(ref self: Rectangle, factor: u64) { self.width *= factor; self.height *= factor; } } fn main() { let mut rect2 = Rectangle { width: 10, height: 20 }; rect2.scale(2); println!("The new size is (width: {}, height: {})", rect2.width, rect2.height); }
It is also possible to define a method which takes ownership of the instance by using just self
as the first parameter but it is rare. This technique is usually used when the method transforms self
into something else and you want to prevent the caller from using the original instance after the transformation.
Look at the Understanding Ownership chapter for more details about these important notions.
Methods with Several Parameters
Let’s practice using methods by implementing another method on the Rectangle
struct. This time we want to write the method can_hold
which accepts another instance of Rectangle
and returns true
if this rectangle can fit completely within self; otherwise, it should return false.
#[generate_trait] impl RectangleImpl of RectangleTrait { fn area(self: @Rectangle) -> u64 { *self.width * *self.height } fn scale(ref self: Rectangle, factor: u64) { self.width *= factor; self.height *= factor; } fn can_hold(self: @Rectangle, other: @Rectangle) -> bool { *self.width > *other.width && *self.height > *other.height } } fn main() { let rect1 = Rectangle { width: 30, height: 50, }; let rect2 = Rectangle { width: 10, height: 40, }; let rect3 = Rectangle { width: 60, height: 45, }; println!("Can rect1 hold rect2? {}", rect1.can_hold(@rect2)); println!("Can rect1 hold rect3? {}", rect1.can_hold(@rect3)); }
Here, we expect that rect1
can hold rect2
but not rect3
.
Methods Without self
Parameter
In Cairo, we can also define a method which doesn't act on a specific instance (so, without any self
parameter) but which still manipulates the related type. This is what we call class methods in Object-Oriented programming. As these methods are not called from an instance, we don't use them with the <instance_name>.<method_name>
syntax but with the <Trait_or_Impl_name>::<method_name>
syntax as you will see in the next example.
These methods are often use to build new instances but they may have a lot of different utilities.
Let's create the method new
which creates a Rectangle
from a width
and a height
, and a method compare
which compares two Rectangle
instances, and returns true
if both rectangle have the same area and false
otherwise.
#[generate_trait] impl RectangleImpl of RectangleTrait { fn area(self: @Rectangle) -> u64 { (*self.width) * (*self.height) } fn new(width: u64, height: u64) -> Rectangle { Rectangle { width, height } } fn compare(r1: @Rectangle, r2: @Rectangle) -> bool { let r1_area = r1.area(); let r2_area = r2.area(); return r1_area == r2_area; } } fn main() { let rect1 = RectangleTrait::new(30, 50); let rect2 = RectangleTrait::new(10, 40); println!("Are rect1 and rect2 equals ? {}", RectangleTrait::compare(@rect1, @rect2)); }
Note that the compare
function could also be written as an instance method with self
as the first rectangle. In this case, instead of using the method with Rectangle::compare(rect1, rect2)
, it is called with rect1.compare(rect2)
.
Multiple Traits and impl
Blocks
Each struct is allowed to have multiple trait
and impl
blocks. For example,
the following code is equivalent to the code shown in the Methods with several parameters section, which has each method in its own trait
and impl
blocks.
#![allow(unused)] fn main() { #[generate_trait] impl RectangleCalcImpl of RectangleCalc { fn area(self: @Rectangle) -> u64 { (*self.width) * (*self.height) } } #[generate_trait] impl RectangleCmpImpl of RectangleCmp { fn can_hold(self: @Rectangle, other: @Rectangle) -> bool { *self.width > *other.width && *self.height > *other.height } } }
There’s no strong reason to separate these methods into multiple trait
and impl
blocks here, but this is valid syntax.
Summary
Structs let you create custom types that are meaningful for your domain. By
using structs, you can keep associated pieces of data connected to each other
and name each piece to make your code clear. In trait
and impl
blocks, you
can define methods, which are functions associated to a type and let you specify
the behavior that instances of your type have.
But structs aren’t the only way you can create custom types: let’s turn to Cairo’s enum feature to add another tool to your toolbox.