4 March 2023
Let’s say we want to write a function that returns the smaller of two numbers, so we can call it like this:
let a = 4;
let b = 5;
println!("{}", min(a, b)); // prints 4
That shouldn’t be too hard. Here’s my first attempt:
fn min(x: i32, y: i32) -> i32 {
if x < y { x } else { y }
}
This works for the example above, but it’ll only work with i32
numbers. What if we want
to use it with i64
numbers? We could copy the code, replace i32
with
i64
everywhere, and rename the function, but then we’ll have to do that for every type
we want to use. Ideally, we’d just replace i32
with a placeholder, something like this:
// T can be i32, i64, or any other type that we can compare with <
fn min(x: T, y: T) -> T {
if x < y { x } else { y }
}
It turns out we can do pretty much that! Here’s what it looks like with proper Rust syntax:
fn min<T>(x: T, y: T) -> T
where T: PartialOrd {
if x < y { x } else { y }
}
The <T>
after the function name tells the compiler that this function has a
type parameter called T
. In the function definition, we can then use
T
as if it was a real type. The where
clause in the next line puts a
limitation on what T
can be: T
has to be a type that implements the
PartialOrd
trait, which means we can use the <
operator on
x
and y
. This is called a trait bound.
When calling min
we can set T
explicitly:
println!("{}", min::<i32>(4, 5)); // T is i32
… but in many cases we can rely on the compiler to infer the type:
println!("{}", min(4, 5)); // T is inferred to be i32
println!("{}", min(4.0, 5.0)); // T is inferred to be f64
println!("{}", min(true, false)); // T is inferred to be bool
(If you’re wondering, that last one prints false
.)
During compilation, the compiler will generate multiple versions of this function, one for each type that’s needed in your program. For the sample code, it would generate three functions more-or-less like this:
fn min_i32(x: i32, y: i32) -> i32 {
if x < y { x } else { y }
}
fn min_i64(x: i64, y: i64) -> i64 {
if x < y { x } else { y }
}
fn min_bool(x: bool, y: bool) -> bool {
if x < y { x } else { y }
}
In Rust, min
is called a generic function, and the programming language feature
is called generics.
The more computer-sciencey term is parametric polymorphism, which is a bit of a mouthful,
but I would argue it’s actually a better name: “polymorphic” means “able to have several shapes or
forms”, so parametric polymorphism means something can have several forms depending on a parameter.
That’s exactly what’s happening with the min
function and the T
parameter.
By comparison, the name “generics” is a bit, well, generic. The Rust documentation always calls it
generics, though, so that’s also what I’ll use here.
One more thing about the syntax. In simple cases like this we can leave out the where clause and put the trait bound in the angle brackets:
fn min<T: PartialOrd>(x: T, y: T) -> T {
if x < y { x } else { y }
}
The most common use of generics are not generic functions but generic types. The standard
library has quite a few, including Option<T>
and Result<T, E>
.
Defining your own isn’t too complicated, either. Let’s say we want to represent “nullable” types
like the ones in SQL. Instead of defining types NullableBool
,
NullableInteger
, and so on, we can define a generic Nullable
type:
#[derive(Debug)]
enum Nullable<T> {
Null,
Value(T),
}
We can createa a Nullable<bool>
like this:
let a = Nullable::Value(true);
Here, the compiler can infer type T
from the argument. For Null
values
there is no argument, so we have to set the type with a type annotation or the turbofish operator:
let b: Nullable<bool> = Nullable::Null;
let c = Nullable::<bool>::Null;
To define methods on a generic type, we add a parameter to the impl
keyword:
impl<T> Nullable<T> {
fn is_null(&self) -> bool {
match self {
Nullable::Null => true,
_ => false,
}
}
}
Now is_null
is available for any Nullable<T>
type that we’d like to
use it with! We can also define a method on a concrete type like Nullable<bool>
,
though. For example, the not
operator in SQL only works for booleans, so we can
implement it like this:
impl Nullable<bool> {
fn not(&self) -> Nullable<bool> {
match self {
&Nullable::Value(a) => Nullable::Value(!a),
_ => Nullable::Null,
}
}
}
We can call those methods like any other:
println!("{:?}", a.is_null()); // prints false
println!("{:?}", b.is_null()); // prints true
println!("{:?}", a.not()); // prints Value(false)
In addition to type parameters, we can use const parameters to write code parameterized with an integer or boolean. As an example, let’s say we want a type for numbers with a limited range. We can define a struct that’s parameterized with two integers for minimum and maximum:
#[derive(Debug)]
struct Number<const MIN: i64, const MAX: i64> {
value: i64,
}
The syntax for parameters is like that for regular
constants in Rust. To define methods (or associated functions) for this type we’ll need an
impl
block that’s parameterized in the same way:
impl<const MIN: i64, const MAX: i64> Number<MIN, MAX> {
fn new(value: i64) -> Option<Number<MIN, MAX>> {
if value < MIN || value > MAX {
None
} else {
Some(Number { value })
}
}
}
Const generics are a relatively recent addition to the language; they were added in Rust 1.51, released in March 2021.
Traits can also be generic. Here’s a trait for a table (or dictionary, or map) with type parameters for keys and values:
trait Table<K: PartialEq, V> {
fn insert(&mut self, key: K, value: V);
fn lookup(&self, key: K) -> Option<V>;
}
We can write a simple implementation of the trait that just stores keys-value pairs in a vector. Here’s the type:
struct SimpleTable<K, V> {
list: Vec<(K, V)>,
}
Before we implement the trait, let’s add a new
method so we can easily construct an
empty SimpleTable:
impl<K, V> SimpleTable<K, V> {
fn new() -> SimpleTable<K, V> {
SimpleTable { list: vec![] }
}
}
Finally, we have the trait implementation. Because Table and SimpleTable are both generic, we need
three sets of type parameters, for the impl
keyword, the type, and the trait:
impl<K: PartialEq, V: Copy> Table<K, V> for SimpleTable<K, V> {
fn insert(&mut self, key: K, value: V) {
if let Some((_, ref mut v)) = self.list.iter_mut().find(|(k, _)| *k == key) {
*v = value
} else {
self.list.push((key, value))
}
}
fn lookup(&self, key: K) -> Option<V> {
self.list.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
}
}
To conclude this article I want to go back to something I mentioned at the end of my article on traits: using the impl
keyword for
function parameters. Let’s define a very simple function that takes a reference to a
Table
:
fn has_zero(table: &dyn Table<i64, i64>) -> bool {
table.lookup(0).is_some()
}
We can call it with a SimpleTable
object:
let mut table: SimpleTable<i64, i64> = SimpleTable::new();
println!("has zero? {}", has_zero(&table));
At runtime, this will build a trait object and pass it to has_zero
, which uses the
vtable and the object pointer so it can call lookup
. That works fine, but it’s a lot of
overhead for such a simple function. A generic function would be more efficient:
fn has_zero<T: Table<i64, i64>>(table: &T) -> bool {
table.lookup(0).is_some()
}
The impl
syntax is just another way to write this generic function:
fn has_zero(table: &impl Table<i64, i64>) -> bool {
table.lookup(0).is_some()
}
The Rust Reference calls this anonymous
type parameters: this last version of has_zero
is parameterized with a type, but
that type doesn’t actually have a name. The code just says it must be a type that implements
Table<i64, i64>
.