Part II — Intermediate: traits, lifetimes, and the standard library
Part I gave you values and ownership. Part II covers abstraction: traits (Rust's interfaces), generics, closures, and iterators. It also covers the part of the borrow checker that Part I avoided, lifetimes. By the end you can read most library code on crates.io.
16. Traits and generics: shared behavior without inheritance
16.1 Defining and implementing a trait
trait Shape {
fn area(&self) -> f64; // required method
fn name(&self) -> String { "shape".into() } // default method, can be overridden
fn describe(&self) -> String { // defaults can call required methods
format!("{} with area {:.2}", self.name(), self.area())
}
}
struct Circle { r: f64 }
struct Rect { w: f64, h: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { std::f64::consts::PI * self.r * self.r }
fn name(&self) -> String { "circle".into() }
}
impl Shape for Rect {
fn area(&self) -> f64 { self.w * self.h }
}
fn main() {
println!("{}", Circle { r: 1.0 }.describe()); // circle with area 3.14
println!("{}", Rect { w: 2.0, h: 3.0 }.describe()); // shape with area 6.00
}
Unlike Go interfaces,
Rust traits are implemented explicitly with impl Trait for Type. You
can implement your trait for types you did not write, including i32 and
Vec<T>. The orphan rule restricts this: either the trait or the type
must be defined in your crate. That rule stops two crates from providing
conflicting implementations.
16.2 Generic functions and trait bounds
use std::fmt::Display;
// "T can be any type that implements PartialOrd and Copy"
fn largest<T: PartialOrd + Copy>(items: &[T]) -> Option<T> {
let mut it = items.iter().copied();
let first = it.next()?;
Some(it.fold(first, |best, x| if x > best { x } else { best }))
}
// `where` clauses read better when bounds get long.
fn report<K, V>(pairs: &[(K, V)]) -> String
where
K: Display,
V: Display + PartialOrd<i32>,
{
pairs
.iter()
.map(|(k, v)| format!("{k}={v}{}", if *v > 100 { " (!)" } else { "" }))
.collect::<Vec<_>>()
.join(", ")
}
// `impl Trait` in argument position is shorthand for a generic parameter.
fn shout(msg: impl AsRef<str>) -> String { msg.as_ref().to_uppercase() }
fn main() {
println!("{:?} {:?}", largest(&[3, 9, 2]), largest::<f64>(&[]));
println!("{}", report(&[("latency_ms", 120), ("errors", 3)]));
println!("{} {}", shout("hi"), shout(String::from("there")));
}
16.3 Monomorphization: what generics compile to
The compiler generates a separate copy of a generic function for every
concrete type it is used with. largest::<i32> and largest::<f64>
become two functions in the binary, each optimized for its type, with no
indirection at runtime.
| Rust generics | Go generics | C | |
|---|---|---|---|
| Implementation | monomorphization (one copy per type) | GC-shape stenciling + dictionaries | macros or void * |
| Runtime cost | none; can be inlined | sometimes an indirect call | macros: none; void *: casts and no type safety |
| Compile time / binary size | larger | smaller | — |
This is what "zero-cost abstraction" means: the generic code runs as fast as
code written by hand for each type. The cost moves to compile time and binary
size. When that matters, you can switch to dyn Trait (Ch 17), which uses
one copy of the code and dispatches through a vtable.
16.4 Associated types and generic traits
// A trait with an ASSOCIATED TYPE: each implementor picks exactly one `Output`.
trait Parser {
type Output;
fn parse(&self, input: &str) -> Option<Self::Output>;
}
struct PortParser;
impl Parser for PortParser {
type Output = u16;
fn parse(&self, input: &str) -> Option<u16> { input.parse().ok().filter(|p| *p != 0) }
}
struct CsvParser;
impl Parser for CsvParser {
type Output = Vec<String>;
fn parse(&self, input: &str) -> Option<Vec<String>> {
Some(input.split(',').map(|s| s.trim().to_string()).collect())
}
}
fn parse_all<P: Parser>(p: &P, inputs: &[&str]) -> Vec<Option<P::Output>> {
inputs.iter().map(|i| p.parse(i)).collect()
}
fn main() {
println!("{:?}", parse_all(&PortParser, &["80", "0", "x"])); // [Some(80), None, None]
println!("{:?}", parse_all(&CsvParser, &["a, b,c"]));
}
Use an associated type when each implementor has exactly one natural
choice, as with Iterator::Item. Use a generic parameter (trait From<T>)
when a type can implement the trait many times for different T.
16.5 Real-world example
std::io::Write is a trait. Files, sockets, Vec<u8>, Stdout,
BufWriter<W>, TLS streams, and gzip encoders all implement it. Code
written as fn write_report<W: Write>(out: &mut W) works with every one of
them and gets monomorphized for each. This is Go's
io.Writer pattern,
with static dispatch instead of an interface value.