Lecture 6: Traits

Contents

Traits

In Rust, a trait is best explained as a property of a type. Different types have different properties that relate them. In some other programming language, this is known as an "interface" which may be familiar to you.

So what properties are we talking about? Well, one we already saw in lecture 2, called "Clone". If types have the "Clone" property (or as the rust people say: implements the Clone trait), then you can clone values of that type.

Generics

Let's look at an example where these traits are actually useful. Let's say we are creating a function called get_largest which finds the largest number in a list of numbers:

#![allow(unused)]
fn main() {
fn get_largest(list: &[i32]) -> i32 {
    let mut largest = list[0];
    
    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}
}

Note that this function is specifically made to work on arrays of i32. But the code here is pretty much equivalent to an implementation for f64:

#![allow(unused)]
fn main() {
fn get_largest(list: &[f64]) -> f64 {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}
}

And yet, we can't call get_largest with floats:

#![allow(unused)]
fn main() {
fn get_largest(list: &[i32]) -> i32 {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}
get_largest(&[3.0, 4.0]);
}

(note: press the "play" button on the top right of the block above to see the error this produces)

So how can we solve this, without having to copy the code and rewrite the entire function just to have it work for different input types? We can use so-called generic type parameters (or "generics"):

#![allow(unused)]
fn main() {
fn get_largest<T>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}
}

The <T> syntax means "this function's code depends on a single type that we call T", and afterwards we say that if the input is an array of Ts, then the output is a single T. But let's think about this code. Does it work for any type T? Let's think about it. On the first line, we move a value out of the list. That only works by either .clone()ing, or having a type T that is Copy. Furthermore, on the 4th line, we compare two elements of the list. But we can't necessarily compare values of any type.

Thus, get_largest will not work for any type T. But the function's definition now says it will. Therefore, the code above won't compile. For it to compile, we would need to explicitly mark which properties a type must have to work for this function. What are those properties? One is called Copy. It's a trait that marks values that can move freely. The other is called PartialOrd, which says that two values are orderable (and thus we can use the > and other comparison operators on them).

Let's try it:

fn get_largest<T>(list: &[T]) -> T
    // read as: "where this works for T that are orderable and copyable"
    where T: PartialOrd + Copy
{
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}


fn main() {
    // these all work
    assert_eq!(get_largest(&[3.0, 4.0]), 4.0);
    assert_eq!(get_largest(&[3u32, 4u32]), 4u32);
    assert_eq!(get_largest(&[3u8, 4u8]), 4u8);
    // but actually, it also works for types you may not have expected
    // (this finds the alphabetically largest string)
    assert_eq!(get_largest(&["hello", "world"]), "world");
}

So PartialOrd and "Copy" are traits. Let's look at some more traits:

Built-in Traits

traitproperty:
Eq and PartialEqA type is comparable to others of the same type with == and !=
Ord and PartialOrdA type is comparable to others of the same type with > and < and >= and <=
Copy and CloneA value of the type can be duplicated (implicitly and explicitly respectively)
DebugA value has a debug representation to print it (usually shows the structure of the type). This allows you to write println!("{:?}", v)
DisplayA value has pretty representation to print it. This allows you to write println!("{}", v)
From and IntoA value of the type can be converted into (or from) a value of another type
HashA value of this type can be hashed
IteratorA value of this type can be iterated over. This also means a value of this type can be used in a for loop.
Add, Sub, etcA value can be added/subtracted/etc to other values of the same type. This is like operator overloading.

Note that some traits imply other traits. For example, a type can only be Copy if it's also already Clone. And a type can only be Ord of it's also already Eq, otherwise operators like >= can't work.

Partial{Ord, Eq} vs {Ord, Eq}:

You may have seen that some traits have a "Partial" version. So what's the difference? Simply said, the Partial version has fewer requirements than the non-partial version.

For example: for types that are PartialEq, an == check needs to be:

  • symmetric: a == b implies b == a
  • transitive: a == b and b == c implies a == c.

While types that are Eq, also need to make sure that equality checks are reflexive: a == a. This excludes floating point numbers, since f64::NAN != f64NAN.

A similar story applies to PartialOrd vs Ord.

Custom traits

Traits are not just something that's built into the compiler. Let's look at an example:

#![allow(unused)]
fn main() {
struct Rectangle {
    width: f64,
    height: f64,
}


struct Circle {
    radius: f64,
}
}

Both of these structs are shapes. They may have some common functionality. For example, you may want to find the area of both of them. In such a case, defining this functionality as a trait is a good choice:

// if types have the "Shape" property ...
trait Shape {
    // ... then they *must* provide an implementation for this associated function
    fn area(&self) -> f64;
}

// for example:
impl Shape for Rectangle {
    // we *have* to provide this, with the exact same signature as 
    // given in the trait
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        // the signature needs to be the same, but the 
        // internals do not.
        self.radius * self.radius * std::f64::consts::PI
    }
}

fn main() {
    let a = Rectangle {width: 20.0, height: 30.0};
    // we can now use this function on both Rectangles *and* Circles
    assert_eq!(a.area(), 600.0);
    
    print_area(Circle{radius: 10.0});
}

// and we could make a function that prints the area of both:
// the T: Shape is a shorthand to say that T needs to implement Shape.
fn print_area<T: Shape>(s: T) {
    println!("{}", s.area())
}

Generic Traits (bonus!)

This part is just an interesting aside. You don't necessarily need it to understand the assignments. But actually, structs and traits can also be generic over types. So you could define shapes to work on any number. I'm just going to give one big (and quite advanced) example:

use std::ops::Mul;

// generic over a T. Could be any type
struct Rectangle<T> {
    width: T,
    height: T,
}


struct Circle<T> {
    radius: T,
}

// shapes are also generic
trait Shape<T> {
    fn area(&self) -> T;
}

impl<T, Res> Shape<Res> for Rectangle<T>
    // To be a shape, the T in the shape needs to multipliable
    where T: Mul<T, Output=Res> + Copy
{
    fn area(&self) -> Res {
        // returns whatever the output of multiplying may be
        self.width * self.height
    }
}

// here we say the output is *always* an f64, 
// since circles won't have nice integer areas.
impl<T> Shape<f64> for Circle<T>
    // To be a shape, the T in the shape needs to multipliable
    where 
        // T needs to be multipliable
        T: Mul<T> + Copy,
        // and if we were to multiply T with itself, it's output needs
        // to be convertable into an f64, since we use a pi constant from
        // f64s
        <T as Mul<T>>::Output: Into<f64>
{
    fn area(&self) -> f64 {
        (self.radius * self.radius).into() * std::f64::consts::PI
    }
}

fn main() {
    // these both work
    let a = Rectangle {width: 20.0, height: 30.0};
    let b = Rectangle {width: 20u32, height: 30u32};
    
    
    assert_eq!(a.area(), 600.0);
    assert_eq!(b.area(), 600u32);
    
    
    // as does this:
    let c = Circle{radius: 40u32};
    // assert that there's a fractional part. We did end up with a float.
    assert_ne!(c.area().fract(), 0.0)
}

impl Trait shorthand

When we previously defined a function that takes value of any type that implement a trait, we wrote something like this:

#![allow(unused)]
fn main() {
fn print_area<T: Shape>(s: T) {
    println!("{}", s.area())
}
}

However, there's a nice shorthand that's usually easier to read:

#![allow(unused)]
fn main() {
fn print_area(s: impl Shape) {
    println!("{}", s.area())
}
}

Using a generic is equivalent to "impl Trait" shorthand (note: only if used in parameter position. In return position they're not at all equivalent to generics, even though you can use impl Trait for return values just fine).

Note however, that function 1 and 2 here are not at all equivalent. 1 and 3 are equivalent:

#![allow(unused)]
fn main() {
// a and b can be different types that both implement Shape individually.
// they can be the same, but don't need to be
fn print_area_1(a: impl Shape, b: impl Shape) {
    println!("{}", s.area())
}

// a and b must be the same concrete type, and need to implement Shape
fn print_area_2<T: Shape>(a: T, b: T) {
    println!("{}", s.area())
}

// this is the equivalent code to print_area_1
fn print_area_3<T: Shape, U: Shape>(a: T, b: U) {
    println!("{}", s.area())
}
}

Lifetimes

Lifetimes will be discussed live in the lecture. For more information, I direct you to the lab and the official rust book which will do a far better job at explaining them than I ever can.