Traits #
Traits are Rust’s main abstraction mechanism — the equivalent of interfaces in Java or C#, but with a few important differences. Traits in Rust can be added to existing types (including primitive types), can have methods with default implementations, and can carry associated types determined by the implementor. More importantly: traits in Rust support two dispatch models — static dispatch via generics with zero overhead, and dynamic dispatch via dyn Trait which is more flexible but carries an indirection cost. Choosing between the two is an important design decision. This article covers every dimension of traits, from the basics to the advanced patterns that often appear in production Rust code.
Basic Definition and Implementation #
A trait defines a behavioral contract — a set of method signatures that any type claiming to follow the contract must implement:
// Trait definition — only signatures, no bodies (except default methods)
trait Ringkasan {
fn ringkas(&self) -> String;
fn penulis(&self) -> String;
}
struct ArtikelBerita {
judul: String,
penulis: String,
isi: String,
}
struct TwitSosialMedia {
akun: String,
konten: String,
reply: u32,
}
// Trait implementation for ArtikelBerita
impl Ringkasan for ArtikelBerita {
fn ringkas(&self) -> String {
format!("{}, oleh {}", self.judul, self.penulis)
}
fn penulis(&self) -> String {
self.penulis.clone()
}
}
// The same trait implemented for TwitSosialMedia
impl Ringkasan for TwitSosialMedia {
fn ringkas(&self) -> String {
format!("{}: {}", self.akun, self.konten)
}
fn penulis(&self) -> String {
self.akun.clone()
}
}
fn main() {
let artikel = ArtikelBerita {
judul: String::from("Rust 2024 Edition Released"),
penulis: String::from("The Rust Team"),
isi: String::from("..."),
};
let twit = TwitSosialMedia {
akun: String::from("@rustlang"),
konten: String::from("Exciting news!"),
reply: 42,
};
println!("{}", artikel.ringkas());
println!("{}", twit.ringkas());
}
Default Methods #
A trait can provide default implementations for its methods. Implementors are free to use them as-is or override them with a specific implementation:
trait Ringkasan {
fn ringkas(&self) -> String;
// Default method — uses another method in the trait
fn pratinjau(&self) -> String {
format!("Read more: {}...", &self.ringkas()[..50.min(self.ringkas().len())])
}
// Default method with its own implementation
fn label(&self) -> String {
String::from("[Content]")
}
}
struct ArtikelBerita {
judul: String,
penulis: String,
}
impl Ringkasan for ArtikelBerita {
fn ringkas(&self) -> String {
format!("{} - {}", self.judul, self.penulis)
}
// Override label — doesn't use the default
fn label(&self) -> String {
String::from("[Article]")
}
// pratinjau isn't overridden — uses the default implementation
}
struct PodcastEpisode {
judul: String,
durasi_menit: u32,
}
impl Ringkasan for PodcastEpisode {
fn ringkas(&self) -> String {
format!("{} ({} minutes)", self.judul, self.durasi_menit)
}
// Use all default methods
}
fn main() {
let artikel = ArtikelBerita {
judul: String::from("Rust Update"),
penulis: String::from("Core Team"),
};
let podcast = PodcastEpisode {
judul: String::from("Rust Episode 42"),
durasi_menit: 45,
};
println!("{}", artikel.label()); // [Article] — overridden
println!("{}", podcast.label()); // [Content] — default
println!("{}", artikel.pratinjau()); // default method using ringkas()
}
Associated Types #
An associated type is how a trait defines a type that each implementor will specify. Unlike generics, an associated type has only one value per implementation — cleaner to use as a constraint:
// With an associated type — cleaner
trait Konversi {
type Output; // type determined by the implementor
fn konversi(&self) -> Self::Output;
}
struct Celsius(f64);
struct Fahrenheit(f64);
impl Konversi for Celsius {
type Output = Fahrenheit;
fn konversi(&self) -> Fahrenheit {
Fahrenheit(self.0 * 9.0 / 5.0 + 32.0)
}
}
impl Konversi for Fahrenheit {
type Output = Celsius;
fn konversi(&self) -> Celsius {
Celsius((self.0 - 32.0) * 5.0 / 9.0)
}
}
// Associated type in the Iterator trait (example from the standard library)
// trait Iterator {
// type Item;
// fn next(&mut self) -> Option<Self::Item>;
// }
fn main() {
let titik_beku = Celsius(0.0);
let dalam_f = titik_beku.konversi();
println!("0°C = {}°F", dalam_f.0); // 32°F
let tubuh = Fahrenheit(98.6);
let dalam_c = tubuh.konversi();
println!("98.6°F = {:.1}°C", dalam_c.0); // 37.0°C
}
Trait Bounds — Generics with Constraints #
A trait bound lets a generic function accept any type as long as it implements a certain trait. The compiler generates type-specific code (monomorphization) — no runtime overhead.
Inline Syntax and the where Clause
#
use std::fmt::{Debug, Display};
// Inline syntax — easy for one or two constraints
fn cetak_info<T: Display + Debug>(nilai: &T) {
println!("Display: {}", nilai);
println!("Debug: {:?}", nilai);
}
// Where clause — easier to read for long constraints
fn proses_dan_cetak<T, U>(t: &T, u: &U) -> String
where
T: Display + Clone,
U: Debug + PartialOrd,
{
format!("T={}, U={:?}", t, u)
}
// impl Trait as a parameter — shorthand for a single trait bound
fn notifikasi(item: &impl Display) {
println!("Notification: {}", item);
}
// Equivalent to:
fn notifikasi_generik<T: Display>(item: &T) {
println!("Notification: {}", item);
}
fn main() {
cetak_info(&42);
cetak_info(&"halo");
let hasil = proses_dan_cetak(&"teks", &vec![1, 2, 3]);
println!("{}", hasil);
notifikasi(&"important message");
notifikasi(&3.14);
}
Multiple Trait Bounds #
use std::fmt::{Display, Debug};
// A function that needs the type to be comparable, displayable, and debuggable
fn terbesar_dan_cetak<T>(daftar: &[T]) -> &T
where
T: PartialOrd + Display + Debug,
{
assert!(!daftar.is_empty(), "List must not be empty");
let mut terbesar = &daftar[0];
for item in daftar {
if item > terbesar {
terbesar = item;
}
}
println!("All values: {:?}", daftar);
println!("Largest: {}", terbesar);
terbesar
}
fn main() {
let angka = vec![34, 50, 25, 100, 65];
terbesar_dan_cetak(&angka);
let huruf = vec!['y', 'm', 'a', 'q'];
terbesar_dan_cetak(&huruf);
}
impl Trait vs dyn Trait
#
This is one of the most important design decisions when working with traits. Both let code work with many types, but through different mechanisms:
flowchart TD
Q{Is the concrete type\nknown at compile time?}
Q -- Yes --> A["impl Trait / Generic\nStatic dispatch\nZero overhead\nCode inlined per type"]
Q -- No --> B["dyn Trait\nDynamic dispatch\nvtable overhead\nTypes can vary\nat runtime"]
A --> C{Need to return\nvarious different types\nfrom one function?}
C -- Yes --> B
C -- No --> AStatic Dispatch with impl Trait
#
trait Gambar {
fn gambar(&self) -> String;
fn luas(&self) -> f64;
}
struct Lingkaran { radius: f64 }
struct Persegi { sisi: f64 }
impl Gambar for Lingkaran {
fn gambar(&self) -> String { format!("○ r={}", self.radius) }
fn luas(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}
impl Gambar for Persegi {
fn gambar(&self) -> String { format!("□ s={}", self.sisi) }
fn luas(&self) -> f64 { self.sisi * self.sisi }
}
// impl Trait as a parameter — static dispatch
// The compiler makes a different version for Lingkaran and Persegi
fn cetak_gambar(bentuk: &impl Gambar) {
println!("{} — Area: {:.2}", bentuk.gambar(), bentuk.luas());
}
// impl Trait as a return — the concrete type is hidden, but it's still one type
fn buat_bentuk_default() -> impl Gambar {
Lingkaran { radius: 1.0 }
// Can only return one concrete type — can't choose between
// Lingkaran and Persegi based on a runtime condition
}
fn main() {
let l = Lingkaran { radius: 5.0 };
let p = Persegi { sisi: 4.0 };
cetak_gambar(&l); // static dispatch
cetak_gambar(&p); // static dispatch — a different version generated by the compiler
}
Dynamic Dispatch with dyn Trait
#
// dyn Trait — needed when different types appear at runtime
fn cetak_semua(bentuk_list: &[Box<dyn Gambar>]) {
for bentuk in bentuk_list {
println!("{} — Area: {:.2}", bentuk.gambar(), bentuk.luas());
}
}
fn buat_bentuk(nama: &str) -> Box<dyn Gambar> {
// Can return different types based on a runtime condition
match nama {
"lingkaran" => Box::new(Lingkaran { radius: 3.0 }),
"persegi" => Box::new(Persegi { sisi: 4.0 }),
_ => Box::new(Lingkaran { radius: 1.0 }),
}
}
fn main() {
// Heterogeneous collection — different types in one Vec
let bentuk_list: Vec<Box<dyn Gambar>> = vec![
Box::new(Lingkaran { radius: 5.0 }),
Box::new(Persegi { sisi: 3.0 }),
Box::new(Lingkaran { radius: 2.0 }),
];
cetak_semua(&bentuk_list);
// Pick a type based on runtime input
let input = "lingkaran";
let bentuk = buat_bentuk(input);
println!("Created: {}", bentuk.gambar());
}
| Aspect | impl Trait (static) | dyn Trait (dynamic) |
|---|---|---|
| Dispatch | Compile-time | Runtime via vtable |
| Performance | Zero overhead | Indirection overhead |
| Binary size | Larger (code per type) | Smaller |
| Heterogeneous collections | Not possible | Possible (Vec<Box<dyn Trait>>) |
| Returning various types | Not possible | Possible |
| Object safety | Not required | Trait must be object safe |
Supertraits #
A supertrait defines dependencies between traits — a type implementing trait A must also implement trait B:
use std::fmt;
// Display is a supertrait of Cetak
// Anyone implementing Cetak must also implement Display
trait Cetak: fmt::Display {
fn cetak(&self) {
println!(">>> {} <<<", self); // can use Display because it's guaranteed
}
}
#[derive(Debug)]
struct Produk {
nama: String,
harga: f64,
}
// Implement the supertrait first
impl fmt::Display for Produk {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} (Rp{:.0})", self.nama, self.harga)
}
}
// Now the trait with that supertrait can be implemented
impl Cetak for Produk {} // uses the default cetak() implementation
fn main() {
let p = Produk {
nama: String::from("Laptop"),
harga: 15_000_000.0,
};
println!("{}", p); // via Display
p.cetak(); // via Cetak (using Display internally)
}
The Orphan Rule — Implementation Limitations #
Rust enforces the orphan rule: you can only implement a trait for a type if the trait or the type (or both) is defined in your own crate. This prevents implementation conflicts:
// CORRECT: custom trait for a custom type
trait Ringkas { fn ringkas(&self) -> String; }
struct Artikel { judul: String }
impl Ringkas for Artikel { /* ... */ fn ringkas(&self) -> String { self.judul.clone() } }
// CORRECT: custom trait for a built-in type
impl Ringkas for Vec<String> {
fn ringkas(&self) -> String {
format!("{} items", self.len())
}
}
// CORRECT: built-in trait for a custom type
use std::fmt;
impl fmt::Display for Artikel {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Article: {}", self.judul)
}
}
// ANTI-PATTERN: built-in trait for a built-in type — not allowed!
// impl fmt::Display for Vec<String> { ... }
// error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
Blanket Implementations #
A blanket implementation implements a trait for all types satisfying a certain condition — just like the Rust standard library does extensively:
use std::fmt;
trait CetakDebug {
fn cetak_debug(&self);
}
// Blanket implementation: every type implementing Debug automatically gets cetak_debug()
impl<T: fmt::Debug> CetakDebug for T {
fn cetak_debug(&self) {
println!("[DEBUG] {:?}", self);
}
}
#[derive(Debug)]
struct Titik { x: f64, y: f64 }
fn main() {
// All these types get cetak_debug() for free
42i32.cetak_debug();
"halo".cetak_debug();
vec![1, 2, 3].cetak_debug();
Titik { x: 1.0, y: 2.0 }.cetak_debug();
}
The standard library uses a blanket implementation for ToString — every type implementing Display automatically gets the .to_string() method:
// In the standard library — this is what makes to_string() available everywhere:
// impl<T: fmt::Display> ToString for T {
// fn to_string(&self) -> String {
// format!("{}", self)
// }
// }
fn main() {
let s1 = 42.to_string(); // i32 → String
let s2 = 3.14.to_string(); // f64 → String
let s3 = true.to_string(); // bool → String
let s4 = 'z'.to_string(); // char → String
println!("{} {} {} {}", s1, s2, s3, s4);
}
Important Standard Traits Worth Knowing #
The Rust standard library has many built-in traits. Knowing the main ones helps you write more idiomatic code:
use std::fmt;
use std::ops::Add;
// Display — human-readable formatting
#[derive(Debug)]
struct Vektor2D { x: f64, y: f64 }
impl fmt::Display for Vektor2D {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
// Add — the + operator
impl Add for Vektor2D {
type Output = Vektor2D;
fn add(self, lain: Vektor2D) -> Vektor2D {
Vektor2D { x: self.x + lain.x, y: self.y + lain.y }
}
}
// From/Into — conversion between types
struct Meter(f64);
struct Sentimeter(f64);
impl From<Meter> for Sentimeter {
fn from(m: Meter) -> Self {
Sentimeter(m.0 * 100.0)
}
}
// Default — initial values
#[derive(Debug)]
struct Konfigurasi {
debug: bool,
level_log: u8,
nama_app: String,
}
impl Default for Konfigurasi {
fn default() -> Self {
Konfigurasi {
debug: false,
level_log: 2,
nama_app: String::from("App"),
}
}
}
fn main() {
let v1 = Vektor2D { x: 1.0, y: 2.0 };
let v2 = Vektor2D { x: 3.0, y: 4.0 };
println!("{} + {} = {}", v1, v2, v1 + v2); // Display + Add
let meter = Meter(1.75);
let cm: Sentimeter = meter.into(); // From automatically enables Into
println!("{} cm", cm.0); // 175
let config = Konfigurasi::default();
println!("{:?}", config);
// Partial override of defaults
let config_debug = Konfigurasi {
debug: true,
..Konfigurasi::default()
};
println!("{:?}", config_debug);
}
| Trait | Enables | How to implement |
|---|---|---|
Display | {} format, .to_string() | Manually |
Debug | {:?} format | #[derive(Debug)] or manually |
Clone | .clone() | #[derive(Clone)] or manually |
Copy | Copy semantics | #[derive(Copy, Clone)] |
PartialEq / Eq | ==, != | #[derive(PartialEq)] or manually |
PartialOrd / Ord | <, >, .sort() | #[derive(PartialOrd)] or manually |
Hash | Used in HashMap | #[derive(Hash)] or manually |
Default | Type::default() | #[derive(Default)] or manually |
From / Into | Type conversion | Implement From, Into comes free |
Add, Sub, etc. | Operator overloading | Manually via std::ops |
Iterator | for loops, adaptors | Manually — implement next() |
Summary #
- Trait = behavioral contract — defines method signatures that must be implemented. Can have default methods that don’t need overriding.
- Associated types are cleaner than generics for a single output —
type Outputdetermined by the implementor, not an extra type parameter at every call site.impl Traitfor static dispatch,dyn Traitfor dynamic dispatch — static is zero-overhead but the type must be known at compile time; dynamic is flexible for heterogeneous collections but has a vtable cost.- Supertraits define trait dependencies —
trait A: Bmeans implementors of A must also implement B. B’s methods are available inside A’s implementation.- The orphan rule prevents conflicts — you can only implement a trait if the trait or the type is yours. You can’t implement
DisplayforVec<String>from an external crate.- Blanket implementations give methods to every qualifying type — the standard library uses them for
ToString(allDisplaytypes) and many more.whereclauses for long constraints — easier to read than inline trait bounds when there are many constraints or many type parameters.- Important standard traits — implement
Displayfor user-friendly output,Fromfor conversions,Defaultfor initial values, andIteratorto make a type iterable.- Traits and generics work together — use trait bounds on generics to write reusable code without losing type safety.