Rust Generics and Traits
Generics are an indispensable mechanism in programming languages.
C++ uses "templates" to implement generics, while C does not have a generic mechanism, which makes it difficult for C to build projects with complex types.
The generic mechanism is a mechanism used by programming languages to express type abstraction, generally used for classes with determined functionality but unspecified data types, such as linked lists and maps.
Defining Generics in Functions
This is a method for selection sorting integer numbers:
Example
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));
}
Output:
max = 6
This is a simple program for getting the maximum value, which can handle data of the i32 numeric type, but cannot handle data of the f64 type. By using generics, we can make this function usable for various types. However, in reality, not all data types can be compared by size, so the next piece of code is not meant to be run, but to describe the syntax of function generics:
Example
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 earlier are generic.
Both structs and enums in Rust can implement the generic mechanism.
struct Point<T> {
x: T,
y: T
}This is a point coordinate struct, where T represents the numeric type describing the point coordinates. We can use it like this:
let p1 = Point {x: 1, y: 2};
let p2 = Point {x: 1.0, y: 2.0};No type is declared when using it; the automatic type inference mechanism is used here, but type mismatches are not allowed, as follows:
let p = Point {x: 1, y: 2.0};When x is bound to 1, T is already set to i32, so the f64 type is not allowed. If we want x and y to be represented by different data types, we can use two generic identifiers:
struct Point<T1, T2> {
x: T1,
y: T2
}In enums, generics are expressed in ways 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, so methods should also implement the generic mechanism; otherwise, generic types cannot be effectively operated on by methods.
Example
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());
}
Output:
p.x = 1
Note that the impl keyword must be followed by<T><T>, because the T after it is modeled after it.
impl Point<f64> {
fn x(&self) -> f64 {
self.x
}
}The generics of the impl block itself do not prevent its inner methods from having generic capabilities: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 merges the x of a Point<T, U> point with the y of a Point<V, W> point into a new point of type Point<T, W>.
Traits
The concept of traits is close to interfaces in Java, but the two are not exactly the same. The commonality between traits and interfaces is that they are both behavioral specifications, used to identify which classes have which methods.
Traits are represented in Rust using the trait keyword:
trait Descriptive {
fn describe(&self) -> String;
}
Descriptive specifies that implementers must havedescribe(&self) -> Stringmethod.
We use it to implement a struct:
Example
name: String,
age: u8
}
impl Descriptive for Person {
fn describe(&self) -> String {
format!("{} {}", self.name, self.age)
}
}
The format is:
impl <特性名> for <所实现的类型名>
In Rust, the same type can implement multiple traits, and each impl block can only implement one.
Default Traits
This is the difference between traits and interfaces: interfaces can only specify methods but cannot define methods, while traits can define methods as default methods. Since they are "default", objects can either redefine methods or use the default methods without redefining them:
Example
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 in the impl Descriptive for Person block, then the output will be:
[Object]
Traits as Parameters
In many cases we need to pass a function as a parameter, such as callback functions, setting button events, etc. In Java, functions must be passed as instances of classes that implement an interface; in Rust, this can be achieved by passing trait parameters:
fn output(object: impl Descriptive) {
println!("{}", object.describe());
}
Any object implementing the Descriptive trait can be passed as a parameter to this function. The function does not need to know whether the passed object has other properties or methods; it only needs to know that it must have the methods specified by the Descriptive trait. Of course, this function cannot use other properties or methods either.
Trait parameters can also be implemented with this equivalent syntax:
fn output<T: Descriptive>(object: T) {
println!("{}", object.describe());
}This is syntactic sugar with a style similar to generics. This syntactic sugar is very useful when multiple parameter types are all traits:
fn output_two<T: Descriptive>(arg1: T, arg2: T) {
println!("{}", arg1.describe());
println!("{}", arg2.describe());
}
When traits are used as type representations and multiple traits are involved, you can use++ symbol to represent, for example:
fn notify(item: impl Summary + Display) fn notify<T: Summary + Display>(item: T)
Note:It is only used when representing types; it 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 "get maximum value" example in the generics chapter can truly be implemented:
Example
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));
}
Output:
maximum of arr is 5
Tip:Since it is necessary to declare that the second parameter of the compare function must be the same as the type implementing the trait, the Self keyword (note the capitalization) represents the current type itself (not the instance).
Traits as Return Values
The format for traits as return values is as follows:
Example
Person {
name: String::from("Cali"),
age: 24
}
}
But there is one point: when a trait is used as a return value, only objects implementing the trait are accepted as return values, and all possible return types in the same function must be exactly the same. For example, if both struct A and struct B implement the trait Trait, the following function is erroneous:
Example
if bl {
return A {};
} else {
return B {};
}
}
Conditional Method Implementation
The impl functionality is very powerful; we can use it to implement methods for a type. But for generic types, sometimes we need to distinguish which methods the generic type it belongs to has already implemented in order to decide which methods it should implement next:
struct A<T> {}
impl<T: B + C> A<T> {
fn d(&self) {}
}This code declares that the A<T> type can effectively implement this impl block only if T has already implemented the B and C traits.
Other Extensions