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 T
s, 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
trait | property: |
---|---|
Eq and PartialEq | A type is comparable to others of the same type with == and != |
Ord and PartialOrd | A type is comparable to others of the same type with > and < and >= and <= |
Copy and Clone | A value of the type can be duplicated (implicitly and explicitly respectively) |
Debug | A value has a debug representation to print it (usually shows the structure of the type). This allows you to write println!("{:?}", v) |
Display | A value has pretty representation to print it. This allows you to write println!("{}", v) |
From and Into | A value of the type can be converted into (or from) a value of another type |
Hash | A value of this type can be hashed |
Iterator | A 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 , etc | A 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
impliesb == a
- transitive:
a == b
andb == c
impliesa == c
.While types that are Eq, also need to make sure that equality checks are reflexive:
a == a
. This excludes floating point numbers, sincef64::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.