In Java, there are two broad use cases for type abstraction:
Compile-time polymorphism: A class or function works over different data types while preserving type safety. For example,
ArrayList<User>,HashMap<String, Double>,Optional<User>. The compiler ensures that there is no type mismatch and prevents a variable of typeArrayList<User>from being assigned anArrayList<String>.Runtime polymorphism: The code uses the advertised capabilities of an interface but does not determine the concrete implementation. For example, a method accepts
List<User>but the caller can passArrayList<User>orLinkedList<User>.
List<User> uses both ideas. User is compile-time generic type information.
List is an interface, so the concrete implementation can still vary.
Compile-Time Polymorphism: Generics
Compile-time polymorphism in Rust is implemented using generics.
The basic syntax is:
fn first<T>(items: Vec<T>) -> Option<T> {
items.into_iter().next()
}
T is a type parameter. The caller decides the concrete type:
let user = first(Vec::<User>::new());
let number = first(vec![1, 2, 3]);
The function is written once, but the compiler type-checks it for the concrete types used by the caller.
Most useful generic functions need the type to support some behavior.
For example, a max function needs to compare two values. But T can be any
type, and not every type can be compared.
Rust solves this by adding a condition to the generic type. This condition is called a trait bound.
fn max<T: Ord>(left: T, right: T) -> T {
if left >= right {
left
} else {
right
}
}
T: Ord is the trait bound. Read it as:
Tcan be any type- but it must implement the
Ordtrait - therefore the function is allowed to compare
leftandright
There is another syntax for the same idea:
fn print_debug(value: impl std::fmt::Debug) {
println!("{:?}", value);
}
This is shorthand for:
fn print_debug<T: std::fmt::Debug>(value: T) {
println!("{:?}", value);
}
impl Trait is used when the bound is simple.
T: Trait is used when the same type parameter has to be mentioned more than once:
fn same_type<T: Ord>(left: T, right: T) -> T {
if left >= right {
left
} else {
right
}
}
In the above example, left and right must have the same concrete type.
For longer bounds, Rust also supports a where clause:
fn process<T>(value: T)
where
T: std::fmt::Debug + Clone,
{
println!("{:?}", value.clone());
}
This is still compile-time polymorphism. The where clause only makes the
signature easier to read.
Runtime Polymorphism: Trait Objects
Runtime polymorphism in Rust is implemented using trait objects.
First, define a trait:
trait QueueStore {
fn enqueue(&self, payload: Vec<u8>);
}
Then use dyn Trait when the concrete implementation is not fixed in the
function signature:
fn publish(store: &dyn QueueStore, payload: Vec<u8>) {
store.enqueue(payload);
}
&dyn QueueStore means:
- the function receives a reference to some type that implements
QueueStore - the concrete type is not named in the function
- method calls go through runtime dispatch
This is closer to Java interface usage:
void publish(QueueStore store, byte[] payload) {
store.enqueue(payload);
}
This is runtime polymorphism because the concrete implementation can be chosen while the program is running.
The previous sections described the API-level choice:
T: Traitmeans compile-time polymorphism.&dyn Traitmeans runtime polymorphism.
The Mechanism: Static Dispatch Versus Dynamic Dispatch
Rust also has names for the call mechanisms:
- Static dispatch: the compiler knows the concrete type and generates or directly calls code for it.
- Dynamic dispatch: the concrete type is hidden behind
dyn Trait, and method calls are resolved through a runtime table.
Consider a generic function:
fn publish<T: QueueStore>(store: T, payload: Vec<u8>) {
store.enqueue(payload);
}
The compiler knows the concrete T used by each caller.
Rust can generate code for that concrete type.
Now consider a function that uses dyn Trait:
fn publish(store: &dyn QueueStore, payload: Vec<u8>) {
store.enqueue(payload);
}
Here the function only knows that store implements QueueStore.
The concrete implementation is hidden behind the trait object.
The terminology is:
| Concept | Rust term |
|---|---|
| Compiler generates concrete versions | Monomorphization |
T: Trait or argument-position impl Trait | Static dispatch |
dyn Trait | Dynamic dispatch / trait object |
| Java removes generic type parameters at runtime | Type erasure |
Rust dynamic dispatch is not usually called type erasure.
There is a loose conceptual similarity because dyn Trait hides the concrete type.
But in Rust, the standard terms are dynamic dispatch and trait object.
Why this matters in practice:
fn publish<T: QueueStore>(store: T)does not behave like Java interface dispatch.- Rust can optimize generic code using the concrete type.
- Generic Rust APIs can be both abstract and fast.
- Use
dyn Traitonly when runtime flexibility is required.
Reading Generic Function Signatures
This syntax means both arguments have the same concrete type:
fn larger<T: Ord>(left: T, right: T) -> T {
if left >= right {
left
} else {
right
}
}
The function can be called with two integers or two strings:
let x = larger(10, 20);
let y = larger("a", "b");
But it cannot be called with one integer and one string.
Both arguments are T, so both arguments must have the same concrete type.
impl Trait in argument position behaves differently when used more than once:
fn print_pair(left: impl std::fmt::Debug, right: impl std::fmt::Debug) {
println!("{:?} {:?}", left, right);
}
This allows different concrete types:
print_pair(10, "hello");
That is because the above function is shorthand for:
fn print_pair<L: std::fmt::Debug, R: std::fmt::Debug>(left: L, right: R) {
println!("{:?} {:?}", left, right);
}
Notes:
| Requirement | Rust syntax |
|---|---|
| Both values must have the same concrete type | fn f<T: Trait>(a: T, b: T) |
| Each value can have a different concrete type | fn f(a: impl Trait, b: impl Trait) |
Trait Bounds Are Promises to the Compiler
A generic type T can be any type.
That means the compiler cannot assume that T supports printing, comparison,
cloning, hashing, or any other behavior.
This does not compile:
fn print_value<T>(value: T) {
println!("{:?}", value);
}
The function says it accepts any T, but the body tries to debug-print T.
Not every type can be debug-printed.
Rust needs the function signature to say that T supports debug printing:
fn print_value<T: std::fmt::Debug>(value: T) {
println!("{:?}", value);
}
The T: std::fmt::Debug part is a trait bound.
Read it as:
T can be any type, as long as it implements Debug.
The same rule applies to comparisons:
fn larger<T>(left: T, right: T) -> T {
if left >= right {
left
} else {
right
}
}
This does not compile because not every T can be ordered.
The function needs an Ord trait bound:
fn larger<T: Ord>(left: T, right: T) -> T {
if left >= right {
left
} else {
right
}
}
As a Java developer, this is similar to needing T extends Comparable<T> before calling compareTo.
static <T extends Comparable<T>> T larger(T left, T right) {
return left.compareTo(right) >= 0 ? left : right;
}
Notes:
Tmeans the function accepts any typeT: Debugmeans the function accepts any type that can be debug printedT: Ordmeans the function accepts any type that can be ordered
The bound is a promise to the compiler. Once the promise is in the function signature, the compiler allows the function body to use that behavior.
Summary
The main idea is that Rust separates two things that are often blended together in Java code:
- Capabilities are described using traits.
- Dispatch strategy is chosen by the syntax.
The dispatch model is:
| Use case | Rust syntax | Dispatch |
|---|---|---|
| Generic code over types | T: Trait | Compile time |
| Simple generic argument | impl Trait | Compile time |
| Runtime interface-style dispatch | &dyn Trait | Runtime |
For a Java developer, the important shift is:
T: Traitis not Java-style interface dispatch. It is static dispatch.impl Traitin function arguments is shorthand for the same compile-time model.&dyn Traitis the Rust syntax closest to Java interface dispatch.- Use generics by default when the concrete type is known to the compiler.
- Use
dyn Traitwhen the concrete implementation must stay open at runtime.