Understanding Rust Traits: Defining Shared Behavior Across Types
What Are Traits?
Rust's trait system enables shared behavior across different types. While often compared to interfaces in languages like Java or C#, traits serve a broader purpose in Rust's type system. The official documentation notes this similarity while acknowledging important distinctions.
This article focuses on using traits to define common behavior for generic types, exploring both basic and advanced patterns you'll encounter in Rust development.
Defining a Trait
Creating a trait is straightforward. Use the trait keyword followed by method signatures:
pub trait Renderable {
fn render(&self);
fn dimensions(&self) -> (u32, u32);
fn is_visible(&self) -> bool {
true
}
}
The pub modifier makes the trait public. You can define multiple methods within a single trait, and as shown above, provide default implementations.
Implementing Traits for Structs
Basic Implementation
To implement a trait for a type, use the impl Trait for Type syntax:
trait Describable {
fn description(&self) -> String;
fn content(&self) -> &str;
}
struct Article {
pub title: String,
pub body: String,
pub author: String,
}
impl Describable for Article {
fn description(&self) -> String {
format!("{} by {}", self.title, self.author)
}
fn content(&self) -> &str {
&self.body
}
}
fn main() {
let post = Article {
title: String::from("Rust Patterns"),
body: String::from("Exploring trait systems..."),
author: String::from("Dev Team"),
};
println!("{}", post.description());
}
Key Rules:
- You must implement all trait methods without default implementations
- The orphan rule prevents implementing external traits on external types (you can't implement a trait from crate A on a type from crate B)
- If the trait is defined in your crate, you can implement it for any type, even external ones
- Within the same crate, you cannot implement the same trait for the same type multiple times in different modules
Exceptions exist for some standard library traits like Display or Debug, which you're encouraged to implement for your types.
Default Implementations
Traits can provide default method implementations that types can override:
trait Describable {
fn description(&self) -> String;
fn content(&self) -> &str;
fn is_long(&self) -> bool {
self.content().len() > 100
}
}
struct Image {
pub caption: String,
pub url: String,
}
impl Describable for Image {
fn description(&self) -> String {
format!("Image: {}", self.caption)
}
fn content(&self) -> &str {
&self.caption
}
// Uses default is_long() implementation
}
struct BlogPost {
pub title: String,
pub content: String,
}
impl Describable for BlogPost {
fn description(&self) -> String {
format!("Post: {}", self.title)
}
fn content(&self) -> &str {
&self.content
}
// Override default implementation
fn is_long(&self) -> bool {
self.content.len() > 500
}
}
Traits as Function Parameters
Simplified Syntax
Use impl Trait for concise parameter declarations:
fn display_item(item: &impl Describable) {
println!("{}", item.description());
}
Formal Syntax and Type Enforcement
The formal syntax uses generic parameters with trait bounds:
fn display_formal<t: describable="">(item: &T) {
println!("Formal syntax: {}", item.description());
}
</t:>
The formal syntax has a unique advantage: when multiple parameters must be the same type:
trait Drawable {
fn draw(&self);
}
struct Circle { radius: f64 }
struct Rectangle { width: f64, height: f64 }
impl Drawable for Circle {
fn draw(&self) { println!("Drawing circle"); }
}
impl Drawable for Rectangle {
fn draw(&self) { println!("Drawing rectangle"); }
}
// Accepts different types
fn render_pair(a: &impl Drawable, b: &impl Drawable) {
a.draw(); b.draw();
}
// Requires both parameters to be the SAME type
fn render_pair_same_type<t: drawable="">(a: &T, b: &T) {
a.draw(); b.draw();
}
fn main() {
let circle = Circle { radius: 5.0 };
let rect = Rectangle { width: 10.0, height: 20.0 };
render_pair(&circle, &rect); // OK
// render_pair_same_type(&circle, &rect); // Error: type mismatch
render_pair_same_type(&circle, &circle); // OK
}
</t:>
Multiple Trait Bounds
Require a parameter to implement multiple traits using the + syntax or where clauses:
use std::fmt::Display;
struct Triangle { base: f64, height: f64 }
impl Drawable for Triangle {
fn draw(&self) {
println!("Triangle: {}", self);
}
}
impl Display for Triangle {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "base={}, height={}", self.base, self.height)
}
}
// Three equivalent ways to specify multiple bounds:
// 1. Inline with impl
fn show_and_draw(item: &(impl Drawable + Display)) {
item.draw();
}
// 2. Generic with +
fn show_and_draw_generic<t: display="" drawable="">(item: &T) {
item.draw();
}
// 3. Where clause
fn show_and_draw_where<t>(item: &T)
where
T: Drawable + Display,
{
item.draw();
}
</t></t:>
Returning Traits
Functions can return types that implement a trait:
fn create_drawable(shape: &str) -> impl Drawable {
match shape {
"circle" => Circle { radius: 10.0 },
_ => Rectangle { width: 5.0, height: 5.0 },
}
}
fn main() {
let drawable = create_drawable("circle");
drawable.draw(); // OK
// Error: compiler only knows this is something impl Drawable
// println!("{:?}", drawable.radius);
}
The compiler treats the return value as the trait type, not the concrete type. You can only call trait methods unless you use type-specific techniques.
Traits for Generic Functions
Traits enable powerful generic constraints:
fn find_max<t: partialord="">(items: &[T]) -> &T {
let mut max = &items[0];
for item in items {
if item > max {
max = item;
}
}
max
}
fn main() {
let numbers = vec![3, 7, 2, 9, 1];
println!("Max: {}", find_max(&numbers));
}
</t:>
The PartialOrd bound ensures the type supports comparison operations, enabling the generic function to work with any comparable type.