Traits in Cairo
A trait defines a set of methods that can be implemented by a type. These methods can be called on instances of the type when this trait is implemented. A trait combined with a generic type defines functionality a particular type has and can share with other types. We can use traits to define shared behavior in an abstract way. We can use trait bounds to specify that a generic type can be any type that has certain behavior.
Note: Note: Traits are similar to a feature often called interfaces in other languages, although with some differences.
While traits can be written to not accept generic types, they are most useful when used with generic types. We already covered generics in the previous chapter, and we will use them in this chapter to demonstrate how traits can be used to define shared behavior for generic types.
Defining a Trait
A type’s behavior consists of the methods we can call on that type. Different types share the same behavior if we can call the same methods on all of those types. Trait definitions are a way to group method signatures together to define a set of behaviors necessary to accomplish some purpose.
For example, let’s say we have a struct NewsArticle
that holds a news story in a particular location. We can define a trait Summary
that describes the behavior of something that can summarize the NewsArticle
type.
#[derive(Drop, Clone)]
struct NewsArticle {
headline: ByteArray,
location: ByteArray,
author: ByteArray,
content: ByteArray,
}
trait Summary {
fn summarize(self: @NewsArticle) -> ByteArray;
}
impl NewsArticleSummary of Summary {
fn summarize(self: @NewsArticle) -> ByteArray {
format!("{:?} by {:?} ({:?})", self.headline, self.author, self.location)
}
}
Here, we declare a trait using the trait keyword and then the trait’s name, which is Summary
in this case.
Inside the curly brackets, we declare the method signatures that describe the behaviors of the types that implement this trait, which in this case is fn summarize(self: @NewsArticle) -> ByteArray
. After the method signature, instead of providing an implementation within curly brackets, we use a semicolon.
Note: the
ByteArray
type is the type used to represent Strings in Cairo.
As the trait is not generic, the self
parameter is not generic either and is of type @NewsArticle
. This means that the summarize
method can only be called on instances of NewsArticle
.
Now, consider that we want to make a media aggregator library crate named aggregator
that can display summaries of data that might be stored in a NewsArticle
or Tweet
instance. To do this, we need a summary from each type, and we’ll request that summary by calling a summarize method on an instance. By defining the Summary
trait on generic type T
, we can implement the summarize
method on any type we want to be able to summarize.
use debug::PrintTrait;
mod aggregator {
trait Summary<T> {
fn summarize(self: @T) -> ByteArray;
}
#[derive(Drop, Clone)]
struct NewsArticle {
headline: ByteArray,
location: ByteArray,
author: ByteArray,
content: ByteArray,
}
impl NewsArticleSummary of Summary<NewsArticle> {
fn summarize(self: @NewsArticle) -> ByteArray {
format!(
"{} by {} ({})", self.headline.clone(), self.author.clone(), self.location.clone()
)
}
}
#[derive(Drop, Clone)]
struct Tweet {
username: ByteArray,
content: ByteArray,
reply: bool,
retweet: bool,
}
impl TweetSummary of Summary<Tweet> {
fn summarize(self: @Tweet) -> ByteArray {
format!("{}: {}", self.username.clone(), self.content.clone())
}
}
}
use aggregator::{Summary, NewsArticle, Tweet};
fn main() {
let news = NewsArticle {
headline: "Cairo has become the most popular language for developers",
location: "Worldwide",
author: "Cairo Digger",
content: "Cairo is a new programming language for zero-knowledge proofs",
};
let tweet = Tweet {
username: "EliBenSasson",
content: "Crypto is full of short-term maximizing projects. \n @Starknet and @StarkWareLtd are about long-term vision maximization.",
reply: false,
retweet: false
}; // Tweet instantiation
println!("New article available! {}", news.summarize());
println!("1 new tweet: {}", tweet.summarize());
}
Each generic type implementing this trait must provide its own custom behavior for the body of the method. The compiler will enforce that any type that has the Summary trait will have the method summarize defined with this signature exactly.
A trait can have multiple methods in its body: the method signatures are listed one per line and each line ends in a semicolon.
Implementing a Trait on a type
Now that we’ve defined the desired signatures of the Summary
trait’s methods,
we can implement it on the types in our media aggregator. The next code snippet shows
an implementation of the Summary
trait on the NewsArticle
struct that uses
the headline, the author, and the location to create the return value of
summarize
. For the Tweet
struct, we define summarize
as the username
followed by the entire text of the tweet, assuming that tweet content is
already limited to 280 characters.
use debug::PrintTrait;
mod aggregator {
trait Summary<T> {
fn summarize(self: @T) -> ByteArray;
}
#[derive(Drop, Clone)]
struct NewsArticle {
headline: ByteArray,
location: ByteArray,
author: ByteArray,
content: ByteArray,
}
impl NewsArticleSummary of Summary<NewsArticle> {
fn summarize(self: @NewsArticle) -> ByteArray {
format!(
"{} by {} ({})", self.headline.clone(), self.author.clone(), self.location.clone()
)
}
}
#[derive(Drop, Clone)]
struct Tweet {
username: ByteArray,
content: ByteArray,
reply: bool,
retweet: bool,
}
impl TweetSummary of Summary<Tweet> {
fn summarize(self: @Tweet) -> ByteArray {
format!("{}: {}", self.username.clone(), self.content.clone())
}
}
}
use aggregator::{Summary, NewsArticle, Tweet};
fn main() {
let news = NewsArticle {
headline: "Cairo has become the most popular language for developers",
location: "Worldwide",
author: "Cairo Digger",
content: "Cairo is a new programming language for zero-knowledge proofs",
};
let tweet = Tweet {
username: "EliBenSasson",
content: "Crypto is full of short-term maximizing projects. \n @Starknet and @StarkWareLtd are about long-term vision maximization.",
reply: false,
retweet: false
}; // Tweet instantiation
println!("New article available! {}", news.summarize());
println!("1 new tweet: {}", tweet.summarize());
}
Implementing a trait on a type is similar to implementing regular methods. The
difference is that after impl
, we put a name for the implementation,
then use the of
keyword, and then specify the name of the trait we are writing the implementation for.
If the implementation is for a generic type, we place the generic type name in the angle brackets after the trait name.
Within the impl
block, we put the method signatures
that the trait definition has defined. Instead of adding a semicolon after each
signature, we use curly brackets and fill in the method body with the specific
behavior that we want the methods of the trait to have for the particular type.
Now that the library has implemented the Summary
trait on NewsArticle
and
Tweet
, users of the crate can call the trait methods on instances of
NewsArticle
and Tweet
in the same way we call regular methods. The only
difference is that the user must bring the trait into scope as well as the
types. Here’s an example of how a crate could use our aggregator
crate:
use debug::PrintTrait; mod aggregator { trait Summary<T> { fn summarize(self: @T) -> ByteArray; } #[derive(Drop, Clone)] struct NewsArticle { headline: ByteArray, location: ByteArray, author: ByteArray, content: ByteArray, } impl NewsArticleSummary of Summary<NewsArticle> { fn summarize(self: @NewsArticle) -> ByteArray { format!( "{} by {} ({})", self.headline.clone(), self.author.clone(), self.location.clone() ) } } #[derive(Drop, Clone)] struct Tweet { username: ByteArray, content: ByteArray, reply: bool, retweet: bool, } impl TweetSummary of Summary<Tweet> { fn summarize(self: @Tweet) -> ByteArray { format!("{}: {}", self.username.clone(), self.content.clone()) } } } use aggregator::{Summary, NewsArticle, Tweet}; fn main() { let news = NewsArticle { headline: "Cairo has become the most popular language for developers", location: "Worldwide", author: "Cairo Digger", content: "Cairo is a new programming language for zero-knowledge proofs", }; let tweet = Tweet { username: "EliBenSasson", content: "Crypto is full of short-term maximizing projects. \n @Starknet and @StarkWareLtd are about long-term vision maximization.", reply: false, retweet: false }; // Tweet instantiation println!("New article available! {}", news.summarize()); println!("1 new tweet: {}", tweet.summarize()); }
This code prints the following:
New article available! Cairo has become the most popular language for developers by Cairo Digger (Worldwide)
1 new tweet: EliBenSasson: Crypto is full of short-term maximizing projects.
@Starknet and @StarkWareLtd are about long-term vision maximization.
Other crates that depend on the aggregator
crate can also bring the Summary
trait into scope to implement Summary
on their own types.
Implementing a trait, without writing its declaration.
You can write implementations directly without defining the corresponding trait. This is made possible by using the #[generate_trait]
attribute within the implementation, which will make the compiler generate the trait corresponding to the implementation automatically. Remember to add Trait
as a suffix to your trait name, as the compiler will create the trait by adding a Trait
suffix to the implementation name.
struct Rectangle {
height: u64,
width: u64,
}
#[generate_trait]
impl RectangleGeometry of RectangleGeometryTrait {
fn boundary(self: Rectangle) -> u64 {
2 * (self.height + self.width)
}
fn area(self: Rectangle) -> u64 {
self.height * self.width
}
}
In the aforementioned code, there is no need to manually define the trait. The compiler will automatically handle its definition, dynamically generating and updating it as new functions are introduced.
Managing and using external trait implementations
To use traits methods, you need to make sure the correct traits/implementation(s) are imported. In the code above we imported PrintTrait
from debug
with use debug::PrintTrait;
to use the print()
methods on supported types.
In some cases you might need to import not only the trait but also the implementation if they are declared in separate modules.
If CircleGeometry
was in a separate module/file circle
then to use boundary
on circ: Circle
, we'd need to import CircleGeometry
in addition to ShapeGeometry
.
If the code was organized into modules like this, where the implementation of a trait was defined in a different module than the trait itself, explicitly importing the relevant implementation is required.
use debug::PrintTrait;
// struct Circle { ... } and struct Rectangle { ... }
mod geometry {
use super::Rectangle;
trait ShapeGeometry<T> {
// ...
}
impl RectangleGeometry of ShapeGeometry<Rectangle> {
// ...
}
}
// Could be in a different file
mod circle {
use super::geometry::ShapeGeometry;
use super::Circle;
impl CircleGeometry of ShapeGeometry<Circle> {
// ...
}
}
fn main() {
let rect = Rectangle { height: 5, width: 7 };
let circ = Circle { radius: 5 };
// Fails with this error
// Method `area` not found on... Did you import the correct trait and impl?
rect.area().print();
circ.area().print();
}
To make it work, in addition to,
#![allow(unused)] fn main() { use geometry::ShapeGeometry; }
you will need to import CircleGeometry
explicitly. Note that you do not need to import RectangleGeometry
, as it is defined in the same module as the imported trait, and thus is automatically resolved.
#![allow(unused)] fn main() { use circle::CircleGeometry }