Structs, Traits and Implementations

Article · 14:00

Rust has structs and traits and no inheritance: a struct holds data, an impl block gives it behaviour, and a trait describes behaviour several types can share. With default methods, static against dynamic dispatch, and what derive writes for you.

Structs, Traits and Implementations

Rust has structs and traits, and no inheritance. A struct holds data; an impl block gives it behaviour; a trait describes behaviour several types can share.

struct Temperature {
    celsius: f64,
}

impl Temperature {
    // An associated function: no self, called as Temperature::new.
    fn new(celsius: f64) -> Result<Self, String> {
        if celsius < -273.15 {
            return Err(format!("below absolute zero: {celsius}"));
        }
        Ok(Self { celsius })
    }

    // A method: &self borrows, so the caller keeps the value.
    fn fahrenheit(&self) -> f64 {
        self.celsius * 9.0 / 5.0 + 32.0
    }

    // &mut self to change it; self alone consumes it.
    fn warm(&mut self, by: f64) {
        self.celsius += by;
    }

    fn into_celsius(self) -> f64 {
        self.celsius
    }
}

fn main() {
    let mut t = Temperature::new(21.0).expect("valid");
    println!("{:.1}C = {:.1}F", t.celsius, t.fahrenheit());

    t.warm(5.0);
    println!("{:.1}C", t.celsius);

    match Temperature::new(-300.0) {
        Ok(_) => println!("unexpected"),
        Err(e) => println!("rejected: {e}"),
    }

    println!("{:.1}", t.into_celsius());
    // println!("{}", t.celsius);      // moved by into_celsius
}

There is no constructor keyword. new is a convention, and returning Result from it is how you make an invalid value impossible to build.

Traits: shared behaviour without a base class

trait Shape {
    fn area(&self) -> f64;

    fn name(&self) -> String {
        // A default implementation, which an implementor may override.
        String::from("shape")
    }

    fn describe(&self) -> String {
        format!("{} of area {:.2}", self.name(), self.area())
    }
}

struct Rectangle {
    width: f64,
    height: f64,
}

struct Circle {
    radius: f64,
}

impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
    fn name(&self) -> String {
        String::from("rectangle")
    }
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
    // name() is left as the default.
}

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Rectangle { width: 3.0, height: 4.0 }),
        Box::new(Circle { radius: 1.0 }),
    ];

    for shape in &shapes {
        println!("{}", shape.describe());
    }

    // Static dispatch: the compiler generates a copy per concrete type.
    println!("{}", describe_static(&Rectangle { width: 1.0, height: 2.0 }));
}

// impl Trait: static dispatch, no allocation, resolved at compile time.
fn describe_static(shape: &impl Shape) -> String {
    shape.describe()
}

&impl Shape is static dispatch - one specialised copy per type, no runtime cost. Box<dyn Shape> is dynamic dispatch - one copy of the code, a vtable lookup per call, and the ability to hold different types in one collection. Reach for the first unless you need the second.

derive: the traits you get for free

#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
struct Point {
    x: i32,
    y: i32,
}

use std::collections::HashSet;

fn main() {
    let a = Point { x: 1, y: 2 };
    let b = a.clone();

    println!("{a:?}");                     // Debug
    println!("{a:#?}", );                  // pretty Debug
    println!("{}", a == b);                // PartialEq
    println!("{:?}", Point::default());    // Default: all fields zeroed

    let set: HashSet<Point> = vec![a.clone(), b].into_iter().collect();
    println!("{} unique", set.len());      // Hash + Eq

    let mut points = vec![Point { x: 2, y: 0 }, Point { x: 1, y: 9 }];
    points.sort();                          // Ord, field by field
    println!("{points:?}");
}

Deriving PartialEq and Hash together keeps the contract that a HashMap depends on - two equal values hash the same - without you writing either.

Implementing standard traits yourself

use std::fmt;
use std::ops::Add;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Money {
    pence: i64,
}

// Display is what {} uses; Debug ({:?}) is for developers.
impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.2}", self.pence as f64 / 100.0)
    }
}

// Operator overloading is a trait like any other.
impl Add for Money {
    type Output = Money;

    fn add(self, other: Money) -> Money {
        Money { pence: self.pence + other.pence }
    }
}

// From gives you Into for free, and is the idiomatic conversion.
impl From<i64> for Money {
    fn from(pence: i64) -> Self {
        Money { pence }
    }
}

fn main() {
    let a = Money::from(1999);
    let b: Money = 250.into();

    println!("{a} + {b} = {}", a + b);
    println!("{:?}", a + b);
    println!("{}", a == Money { pence: 1999 });
}

Generics with trait bounds

use std::fmt::Display;

// The bound is what lets the body call anything at all.
fn largest<T: PartialOrd + Copy>(values: &[T]) -> T {
    let mut best = values[0];
    for &value in values {
        if value > best {
            best = value;
        }
    }
    best
}

// A where clause, when the bounds get long.
fn describe_all<T>(values: &[T]) -> String
where
    T: Display,
{
    values.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(", ")
}

// Generic structs, with the impl block carrying the same parameter.
struct Pair<T> {
    left: T,
    right: T,
}

impl<T: Display + PartialOrd> Pair<T> {
    fn larger(&self) -> &T {
        if self.left > self.right { &self.left } else { &self.right }
    }
}

fn main() {
    println!("{}", largest(&[3, 9, 4]));
    println!("{}", largest(&[1.5, 0.2]));
    println!("{}", describe_all(&["pear", "apple"]));
    println!("{}", Pair { left: 10, right: 3 }.larger());
}

What to take away

  • impl blocks hold behaviour; new is a convention, not a keyword.
  • &self borrows, &mut self borrows mutably, self consumes.
  • Traits give shared behaviour with no inheritance, and may carry defaults.
  • impl Trait is static dispatch; Box<dyn Trait> is dynamic, for mixed collections.
  • #[derive(...)] writes Debug, Clone, PartialEq, Hash, Default and Ord for you.
  • Implement Display, Add and From to make your type behave like a built-in one.