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());
}

A Summary trait that consists of the behavior provided by a summarize method

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
}
Last change: 2023-12-10, commit: 370d5b6