Rust Generics and Traits
Generics are an indispensable mechanism in programming languages.
In C++ language, generics are implemented using "templates," while C language lacks a generics mechanism, which makes it difficult to build complex type projects.
The generics mechanism is used by programming languages to express type abstraction, typically for classes with fixed functionality but undecided data types, such as linked lists and hash tables.
Defining Generics in Functions
Here is a method for selecting the maximum value from an array of integers:
Example
fn max(array: &[i32]) -> i32 {
let mut max_index = 0;
let mut i = 1;
while i < array.len() {
if array[i] > array[max_index] {
max_index = i;
}
i += 1;
}
array[max_index]
}
fn main() {
let a = [2, 4, 6, 3, 1];
println!("max = {}", max(&a));
}
Running result:
max = 6
This is a simple program to find the maximum value, which can handle data of type i32
but not f64
. By using generics, we can make this function applicable to various types. However, not all data types can be compared, so the following code is not meant to be run but to describe the syntax of function generics:
Example
fn max<T>(array: &[T]) -> T {
let mut max_index = 0;
let mut i = 1;
while i < array.len() {
if array[i] > array[max_index] {
max_index = i;
}
i += 1;
}
array[max_index]
}
Generics in Structs and Enums
The Option
and Result
enums we learned about earlier are generic.
Structs and enums in Rust can both implement generics.
struct Point<T> {
x: T,
y: T,
}
This is a point coordinate struct where T
represents the numeric type describing the coordinates. We can use it like this:
let p1 = Point { x: 1, y: 2 };
let p2 = Point { x: 1.0, y: 2.0 };
The types are not explicitly declared here; automatic type inference is used, but type mismatches are not allowed, as shown below:
let p = Point { x: 1, y: 2.0 };
When x
is bound to 1
, T
is set to i32
, so a type f64
is not allowed. If we want x
and y
to use different data types, we can use two generic identifiers:
struct Point<T1, T2> {
x: T1,
y: T2,
}
Generic representation in enums, such as Option
and Result
:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
Both structs and enums can define methods, and these methods should also implement generics, otherwise, generic classes cannot be effectively operated on.
Example
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
fn main() {
let p = Point { x: 1, y: 2 };
println!("p.x = {}", p.x());
}
Running result:
p.x = 1
Note that <T>
must be present after the impl
keyword, as it serves as a template for the T
that follows. However, we can also add methods to one specific generic type:
impl Point<f64> {
fn x(&self) -> f64 {
self.x
}
}
impl<T, U> Point<T, U> {
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point {
x: self.x,
y: other.y,
}
}
}
The mixup
method combines the x
of a Point<T, U>
with the y
of a Point<V, W>
to create a new point of type Point<T, W>
.
Traits
Traits define shared behavior in an abstract way. A type's behavior consists of the methods we can call on that type. Different types can share the same behavior if they both implement the same trait. The trait concept is similar to interfaces in Java, but they are not identical. Both traits and interfaces serve as behavioral specifications, indicating which classes have which methods.
In Rust, traits are represented as:
trait Descriptive {
fn describe(&self) -> String;
}
Descriptive specifies that the implementer must have a describe(&self) -> String
method.
We implement it for a struct:
Example
struct Person {
name: String,
age: u8
}
impl Descriptive for Person {
fn describe(&self) -> String {
format!("{} {}", self.name, self.age)
}
}
The format is:
impl <TraitName> for <TypeName>
In Rust, a class can implement multiple traits, but each impl
block can only implement one.
Default Methods
This is where traits differ from interfaces: interfaces can only specify methods, while traits can define methods as default methods. Since they are "default," objects can either redefine the method or use the default method without redefining it:
Example
trait Descriptive {
fn describe(&self) -> String {
String::from("[Object]")
}
}
struct Person {
name: String,
age: u8
}
impl Descriptive for Person {
fn describe(&self) -> String {
format!("{} {}", self.name, self.age)
}
}
fn main() {
let cali = Person {
name: String::from("Cali"),
age: 24
};
println!("{}", cali.describe());
}
Output:
Cali 24
If we remove the content from the impl Descriptive for Person
block, the output would be:
[Object]
Trait as Parameter
In many cases, we need to pass a function as a parameter, such as callback functions or button event handlers. In Java, functions must be passed as instances of classes that implement an interface. In Rust, traits can be passed as parameters:
fn output(object: impl Descriptive) {
println!("{}", object.describe());
}
Any object that implements the Descriptive trait can be passed as an argument to this function. The function does not need to know if the object has other properties or methods, only that it has the methods specified by the Descriptive trait. Of course, this function cannot use other properties or methods.
Trait parameters can also be implemented using this equivalent syntax:
fn output<T: Descriptive>(object: T) {
println!("{}", object.describe());
}
This is a syntax sugar similar to generics, which is very useful when multiple parameters are traits:
fn output_two<T: Descriptive>(arg1: T, arg2: T) {
println!("{}", arg1.describe());
println!("{}", arg2.describe());
}
When representing types with multiple traits, the +
symbol can be used, for example:
fn notify(item: impl Summary + Display)
fn notify<T: Summary + Display>(item: T)
Note: This is only for type representation and does not mean it can be used in impl
blocks.
Complex implementation relationships can be simplified using the where
keyword, for example:
fn some_function<T: Display + Clone, U: Clone + Debug>(t: T, u: U)
Can be simplified to:
fn some_function<T, U>(t: T, u: U) -> i32
where T: Display + Clone,
U: Clone + Debug
After understanding this syntax, the "maximum value" example from the generics section can be truly implemented:
Example
trait Comparable {
fn compare(&self, object: &Self) -> i8;
}
fn max<T: Comparable>(array: &[T]) -> &T {
let mut max_index = 0;
let mut i = 1;
while i < array.len() {
if array[i].compare(&array[max_index]) > 0 {
max_index = i;
}
i += 1;
}
&array[max_index]
}
impl Comparable for f64 {
fn compare(&self, object: &f64) -> i8 {
if &self > &object { 1 }
else if &self == &object { 0 }
else { -1 }
}
}
fn main() {
let arr = [1.0, 3.0, 5.0, 4.0, 2.0];
println!("maximum of arr is {}", max(&arr));
}
Running Result:
maximum of arr is 5
Tip: Since the second parameter of the compare
function needs to be the same as the type that implements the trait, the Self
keyword represents the current type (not the instance) itself.
Trait as Return Value
The format for returning a trait is as follows:
Example
fn person() -> impl Descriptive {
Person {
name: String::from("Cali"),
age: 24
}
}
However, there is a limitation: the trait as a return value only accepts objects that implement the trait, and all possible return value types within the same function must be exactly the same. For example, if both struct A and struct B implement trait Trait, the following function is incorrect:
Example
fn some_function(bool bl) -> impl Descriptive {
if bl {
return A {};
} else {
return B {};
}
}
Conditional Method Implementation
The impl
feature is very powerful, and we can use it to implement methods for a struct. However, for generic structs, sometimes we need to differentiate based on the methods already implemented by the generic type to decide which methods to implement next:
struct A<T> {}
impl<T: B + C> A<T> {
fn d(&self) {}
}
This code declares that the A<T>
type can only implement this impl
block if T
has already implemented traits B
and C
.