Traits: Defining Shared Behavior

A trait defines the 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: Traits are similar to a feature often called interfaces in other languages, although with some differences.

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 multiple structs that hold various kinds and amounts of text: a NewsArticle struct that holds a news story filed in a particular location and a SocialPost that can have, at most, 280 characters along with metadata that indicates whether it was a new post, a repost, or a reply to another post.

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 SocialPost instance. To do this, we need a summary from each type, and we will request that summary by calling a summarize method on an instance. Here is the definition of a public Summary trait that expresses this behavior:

public trait Summary {
    fn summarize(): String
}

Here, we declare a trait using the trait keyword and then the trait's name, which is Summary in this case. We also declare the trait as public so that crates depending on this crate can make use of this trait too, as we will see in a few examples. 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(): String.

After the method signature, instead of providing an implementation within curly brackets, we use a semicolon. Each 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.

Rust Equivalent

The Oxide trait definition above translates to this Rust code:

#![allow(unused)]
fn main() {
pub trait Summary {
    fn summarize(&self) -> String;
}
}

Note that in Oxide, trait methods use the same receiver modifiers as methods in extension blocks: fn implies &self, mutating fn implies &mut self, consuming fn implies self, and static fn has no receiver.

Implementing a Trait on a Type

Now that we have defined the desired signatures of the Summary trait's methods, we can implement it on the types in our media aggregator. In Oxide, we use extension Type: Trait syntax to implement a trait on a type:

public struct NewsArticle {
    public headline: String,
    public location: String,
    public author: String,
    public content: String,
}

extension NewsArticle: Summary {
    fn summarize(): String {
        "\(self.headline), by \(self.author) (\(self.location))"
    }
}

public struct SocialPost {
    public username: String,
    public content: String,
    public reply: Bool,
    public repost: Bool,
}

extension SocialPost: Summary {
    fn summarize(): String {
        "\(self.username): \(self.content)"
    }
}

Implementing a trait on a type is similar to implementing regular methods. The difference is that after extension, we put the type name, then a colon, then the trait name we want to implement. Within the extension block, we put the method signatures that the trait definition has defined 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 SocialPost, users of the crate can call the trait methods on instances of NewsArticle and SocialPost 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 is an example of how a binary crate could use our aggregator library crate:

import aggregator.{ Summary, SocialPost }

fn main() {
    let post = SocialPost {
        username: "horse_ebooks".toString(),
        content: "of course, as you probably already know, people".toString(),
        reply: false,
        repost: false,
    }

    println!("1 new post: \(post.summarize())")
}

This code prints 1 new post: horse_ebooks: of course, as you probably already know, people.

Rust Equivalent

The Oxide trait implementation above translates to this Rust code:

#![allow(unused)]
fn main() {
impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
}

Note that in Rust, the syntax is impl Trait for Type, while in Oxide it's extension Type: Trait. The Oxide syntax reads naturally as "extend Type with Trait capability."

Coherence and the Orphan Rule

One restriction to note is that we can implement a trait on a type only if either the trait or the type, or both, are local to our crate. For example, we can implement standard library traits like Display on a custom type like SocialPost as part of our aggregator crate functionality because the type SocialPost is local to our aggregator crate. We can also implement Summary on Vec<T> in our aggregator crate because the trait Summary is local to our aggregator crate.

But we can't implement external traits on external types. For example, we can't implement the Display trait on Vec<T> within our aggregator crate, because Display and Vec<T> are both defined in the standard library and aren't local to our aggregator crate. This restriction is part of a property called coherence, and more specifically the orphan rule, so named because the parent type is not present. This rule ensures that other people's code can't break your code and vice versa. Without the rule, two crates could implement the same trait for the same type, and the compiler wouldn't know which implementation to use.

Default Implementations

Sometimes it's useful to have default behavior for some or all of the methods in a trait instead of requiring implementations for all methods on every type. Then, as we implement the trait on a particular type, we can keep or override each method's default behavior.

Here we specify a default string for the summarize method of the Summary trait instead of only defining the method signature:

public trait Summary {
    fn summarize(): String {
        "(Read more...)".toString()
    }
}

To use a default implementation to summarize instances of NewsArticle, we specify an empty extension block with extension NewsArticle: Summary {}.

Even though we are no longer defining the summarize method on NewsArticle directly, we have provided a default implementation and specified that NewsArticle implements the Summary trait. As a result, we can still call the summarize method on an instance of NewsArticle, like this:

let article = NewsArticle {
    headline: "Penguins win the Stanley Cup Championship!".toString(),
    location: "Pittsburgh, PA, USA".toString(),
    author: "Iceburgh".toString(),
    content: "The Pittsburgh Penguins once again are the best \
              hockey team in the NHL.".toString(),
}

println!("New article available! \(article.summarize())")

This code prints New article available! (Read more...).

Creating a default implementation doesn't require us to change anything about the implementation of Summary on SocialPost. The reason is that the syntax for overriding a default implementation is the same as the syntax for implementing a trait method that doesn't have a default implementation.

Default implementations can call other methods in the same trait, even if those other methods don't have a default implementation. In this way, a trait can provide a lot of useful functionality and only require implementors to specify a small part of it. For example, we could define the Summary trait to have a summarizeAuthor method whose implementation is required, and then define a summarize method that has a default implementation that calls the summarizeAuthor method:

public trait Summary {
    fn summarizeAuthor(): String

    fn summarize(): String {
        "(Read more from \(self.summarizeAuthor())...)".toString()
    }
}

To use this version of Summary, we only need to define summarizeAuthor when we implement the trait on a type:

extension SocialPost: Summary {
    fn summarizeAuthor(): String {
        "@\(self.username)".toString()
    }
}

After we define summarizeAuthor, we can call summarize on instances of the SocialPost struct, and the default implementation of summarize will call the definition of summarizeAuthor that we have provided:

let post = SocialPost {
    username: "horse_ebooks".toString(),
    content: "of course, as you probably already know, people".toString(),
    reply: false,
    repost: false,
}

println!("1 new post: \(post.summarize())")

This code prints 1 new post: (Read more from @horseEbooks...).

Note that it isn't possible to call the default implementation from an overriding implementation of that same method.

Traits as Parameters

Now that you know how to define and implement traits, we can explore how to use traits to define functions that accept many different types. We will use the Summary trait we implemented on the NewsArticle and SocialPost types to define a notify function that calls the summarize method on its item parameter, which is of some type that implements the Summary trait. In Oxide, we express this using a trait bound on a generic type:

public fn notify<T: Summary>(item: &T) {
    println!("Breaking news! \(item.summarize())")
}

This parameter accepts any type that implements the specified trait. In the body of notify, we can call any methods on item that come from the Summary trait, such as summarize. We can call notify and pass in any instance of NewsArticle or SocialPost. Code that calls the function with any other type, such as a String or an Int, won't compile because those types don't implement Summary.

Trait Bound Syntax

Trait bounds can also express more complex cases. For example, we can have two parameters that each implement Summary but are allowed to be different types:

public fn notify<T: Summary, U: Summary>(item1: &T, item2: &U) {
    // ...
}

If we want to force both parameters to have the same type, we use a single generic parameter:

public fn notify<T: Summary>(item1: &T, item2: &T) {
    // ...
}

The generic type T specified as the type of the item1 and item2 parameters constrains the function such that the concrete type of the value passed as an argument for item1 and item2 must be the same.

Specifying Multiple Trait Bounds with the + Syntax

We can also specify more than one trait bound. Say we wanted notify to use display formatting as well as summarize on item: We specify in the notify definition that item must implement both Display and Summary. We do so using the + syntax on the trait bound:

public fn notify<T: Summary + Display>(item: &T) {
    // ...
}

With the two trait bounds specified, the body of notify can call summarize and use {} to format item.

Clearer Trait Bounds with where Clauses

Using too many trait bounds has its downsides. Each generic has its own trait bounds, so functions with multiple generic type parameters can contain lots of trait bound information between the function's name and its parameter list, making the function signature hard to read. For this reason, Oxide (like Rust) has alternate syntax for specifying trait bounds inside a where clause after the function signature. So, instead of writing this:

fn someFunction<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U): Int {
    // ...
}

we can use a where clause, like this:

fn someFunction<T, U>(t: &T, u: &U): Int
where
    T: Display + Clone,
    U: Clone + Debug,
{
    // ...
}

This function's signature is less cluttered: The function name, parameter list, and return type are close together, similar to a function without lots of trait bounds.

Returning Types That Implement Traits

In Oxide, you can hide a concrete return type with impl Trait when the function always returns a single concrete type:

fn returnsSummarizable(): impl Summary {
    SocialPost {
        username: "horse_ebooks".toString(),
        content: "of course, as you probably already know, people".toString(),
        reply: false,
        repost: false,
    }
}

When you need to return different concrete types, use a trait object. A trait object is a value like Box<dyn Summary> that can hold any type implementing the trait.

fn returnsSummarizable(switch: Bool): Box<dyn Summary> {
    if switch {
        Box.new(NewsArticle {
            headline: "Penguins win the Stanley Cup Championship!".toString(),
            location: "Pittsburgh, PA, USA".toString(),
            author: "Iceburgh".toString(),
            content: "The Pittsburgh Penguins once again are the best \
                      hockey team in the NHL.".toString(),
        })
    } else {
        Box.new(SocialPost {
            username: "horse_ebooks".toString(),
            content: "of course, as you probably already know, people".toString(),
            reply: false,
            repost: false,
        })
    }
}

Because the return type is a trait object, the caller does not need to know the concrete type. This is especially useful for closures and iterators, which often have compiler-generated types that are difficult to name.

We will cover trait objects in detail in Chapter 18.

Using Trait Bounds to Conditionally Implement Methods

By using a trait bound with an extension block that uses generic type parameters, we can implement methods conditionally for types that implement the specified traits. For example, the type Pair<T> always implements the new function to return a new instance of Pair<T>. But Pair<T> only implements the cmpDisplay method if its inner type T implements the PartialOrd trait that enables comparison and the Display trait that enables printing:

struct Pair<T> {
    x: T,
    y: T,
}

extension Pair<T> {
    static fn new(x: T, y: T): Self {
        Self { x, y }
    }
}

extension Pair<T>
where
    T: Display + PartialOrd,
{
    fn cmpDisplay() {
        if self.x >= self.y {
            println!("The largest member is x = \(self.x)")
        } else {
            println!("The largest member is y = \(self.y)")
        }
    }
}

We can also conditionally implement a trait for any type that implements another trait. Implementations of a trait on any type that satisfies the trait bounds are called blanket implementations and are used extensively in the standard library. For example, the standard library implements the ToString trait on any type that implements the Display trait. The extension block in the standard library looks similar to this code:

extension<T: Display> T: ToString {
    // --snip--
}

Because the standard library has this blanket implementation, we can call the toString method defined by the ToString trait on any type that implements the Display trait. For example, we can turn integers into their corresponding String values like this because integers implement Display:

let s = 3.toString()

Blanket implementations appear in the documentation for the trait in the "Implementors" section.

Traits and trait bounds let us write code that uses generic type parameters to reduce duplication but also specify to the compiler that we want the generic type to have particular behavior. The compiler can then use the trait bound information to check that all the concrete types used with our code provide the correct behavior. In dynamically typed languages, we would get an error at runtime if we called a method on a type that didn't define the method. But Oxide moves these errors to compile time so that we are forced to fix the problems before our code is even able to run. Additionally, we don't have to write code that checks for behavior at runtime because we have already checked at compile time. Doing so improves performance without having to give up the flexibility of generics.