Traits

Dear Computer

Chapter 12: Polymorphism Revisited

Traits

A trait in Rust is like an interface in Java or a typeclass in Haskell. It is only an abstract type, declaring a set of function signatures and associated types that implementing types must define. As in recent versions of Java, a trait may include default definitions of its functions.

Defining a Trait

For a trait to be meaningful as a standalone abstraction, there must be multiple ways of implementing its interface. For example, one can implement a set data structure using a hashtable, a balanced search tree, or a plain array. This trait outlines the common interface of all three implementations:

Rust
trait Set {
  fn size(&self) -> usize;
  fn contains(&self, value: u32) -> bool; 
  fn add(&mut self, value: u32); 
  fn remove(&mut self, value: u32); 
}

Implementing a Trait

Suppose we are implementing a set that tracks the membership of just the numbers 0 through 999 in constant time with an array of 1000 booleans. We start by defining a struct and a constructor function:

Rust
struct ThousandSet {
  members: [bool; 1000],
  size: usize,
}

impl ThousandSet {
  fn new() -> Self {
    ThousandSet {
      members: [false; 1000],
      size: 0,
    }
  }
}

So far there is no mention of Set or its imposed functions anywhere in the struct definition or its impl block. What makes ThousandSet an implementation of the trait is this separate impl block:

Rust
impl Set for ThousandSet {
  fn size(&self) -> usize {
    self.size
  }

  fn contains(&self, value: u32) -> bool {
    self.members[value]
  }

  fn add(&mut self, value: u32) {
    if !self.contains(value) {
      self.members[value] = true;
      self.size += 1;
    }
  }

  fn remove(&mut self, value: u32) {
    if self.contains(value) {
      self.members[value] = false;
      self.size -= 1;
    }
  }
}

Note how the impl header connects the trait name to the type: impl Set for ThousandSet. This syntax is similar to Haskell's instance command. If we were writing in that language, we would have written instance Set ThousandSet where and then defined the functions.

Each different trait that a type implements will have its own independent impl block. Here two more traits are implemented for ThousandSet:

Rust
impl BlueTrait for ThousandSet {
  fn blue(&self) {
    // ...
  }
}

impl RedTrait for ThousandSet {
  fn red(&self) {
    // ...
  }
}

This scattering of a type's methods across many separate impl blocks is very different from a Java class, which is a single syntactic unit containing all the methods required by its interfaces. There are some advantages to the separation. If we need a type but not one of its traits, then we hide the unneeded interface by not importing the trait. If we reference the hidden interface, we'll get a compilation error. In Java, we get the whole type with all of its methods, always.

Conditional Implementation

Another reason for separate impl blocks for each trait is conditional implementation, which allows us to define a trait for a generic abstraction only if its type parameters meet certain type constraints. Suppose we have this Extrema trait that imposes a couple of functions for querying the smallest and biggest items from a collection:

Rust
trait Extrema<T: Ord> {
  fn smallest(&self) -> &T;
  fn biggest(&self) -> &T;
}

We decide that we would like the builtin Vec type to have these methods. But Vec can hold any kind of value, including those that can't be ordered from smallest to biggest. We don't really want to apply these functions to Vec<Color> or Vec<UserId> since there's no meaningful ordering of colors or user IDs. So, we implement the trait only for Vec whose element type implements the Ord trait:

Rust
impl<T: Ord> Extrema<T> for Vec<T> {
  fn smallest(&self) -> &T {
    self.iter().min().unwrap()
  }

  fn biggest(&self) -> &T {
    self.iter().max().unwrap()
  }
}

Since this impl block targets T: Ord, a Vec whose element type does not implement Ord will not have these functions.

If we had to implement the trait's methods in the vanilla definition of Vec, we wouldn't be able to assume anything about T.

Automatic Implementation

In Haskell, we ask the compiler to automatically implement certain typeclasses by appending a deriving clause to the data definition. The same is possible in Rust. Traits for comparing, cloning, stringifying, and hashing are eligible for automatic implementation. We request automatic implementation by preceding an enum or struct definition with the derive attribute. The struct Machine in this code is comparable and presentable with {:?} because the compiler automatically implements the relevant traits:

Rust
use std::fmt::Debug;

#[derive(Debug, Ord, PartialOrd, Eq, PartialEq)]
pub struct Machine {
  make: String,
  model: String,
  year: u32,
}

impl Machine {
  fn new(make: &str, model: &str, year: u32) -> Self {
    Machine {
      make: make.to_string(),
      model: model.to_string(),
      year
    }
  }
}

The impl block includes only a constructor function; it makes no mention of the functions imposed by these traits. Yet the functions exist, and this vector of machines can be sorted and printed:

Rust
let mut machines = vec![
  Machine::new("Dodge", "Caravan", 2018),
  Machine::new("Honda", "Civic", 2007),
  Machine::new("Dodge", "Caravan", 2012),
];
machines.sort();
println!("{:?}", machines);  // prints elements 2, 0, 1
← Type ConstraintsSubtype Polymorphism →