Generic Definition#
I have always understood generics as parameters in the type system, because in TS, types and executing code are isolated. Generic definitions are like functions in a type, obtaining specific types by passing in parameters. At the same time, generics can constrain the relationship between parameters, return values, or struct fields.
fn get_value<T>(v: T) -> T {
v
}
enum Result<T, U> {
Ok(T),
Err(U)
}
struct List<T> {
items: Vec<T>,
current: T
}
impl<T> List<T> {
fn first(&self) -> T {
&self.items[0]
}
}
When defining methods for structs, you can also specify the implementation of specific types to achieve similar overloading operations.
impl List<i32> {
fn sum(&self) -> i32 {
let mut result = 0;
for item in &self.items {
result += item
}
result
}
}
// Specify the trait
impl<T: Display> List<T> {
fn sum(&self) -> i32 {}
}
Rust's generic code does not affect performance. It undergoes monomorphization during compilation, which means that the code using generics is compiled into code of specific types, similar to static optimization in Vue3.
Trait#
This thing is a bit confusing.
Trait is similar to interface
or abstract class
in TS. I understand it as a way to specify and limit generics, making some abstract generics more concrete and allowing the addition of behaviors to types.
trait Desc {
fn read(&self) -> &str;
fn default() -> &str {
"default impl"
}
}
impl<T> Desc for List<T> {
fn read(&self) -> &str {
"a list"
}
}
Implementing a trait is subject to coherence restrictions, where either the implementor or the trait being implemented must be in the local scope to be able to implement it. This rule prevents others from breaking the code outside of the crate.
Blank implementations can be made on top of abstractions.
// Implement ToString for all types that implement Display
impl<T: Display> ToString for T {}
After defining, constraints can be applied.
fn get_desc(target: &impl Desc) {}
// Trait bound
fn print_desc<T: Desc + Display>(target: &T) -> impl Display {}
fn print_default_desc<T>(target: &T) where T: Desc + Display {}
Lifetimes#
The existence of lifetimes is to ensure the validity of references and avoid dangling references. Due to ownership restrictions, the object being referenced cannot have a shorter lifetime than the reference itself. We cannot create a reference inside a function and then return it, as the source data will be destroyed when it goes out of scope, making the returned reference a dangling reference. Therefore, if a function returns a reference, the reference must be related to the references in the parameters. Sometimes, this relationship cannot be inferred by the code, such as when two references are passed in and a reference is returned. In such cases, we need to specify the lifetimes to help the compiler infer.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
// No need to specify the lifetime for y based on logic
fn first<'a>(x: &'a str, y: &str) -> &'a str {
x
}
struct Person<'a> {
name: &'a str
}
// Static lifetime, similar to any, must ensure its existence throughout the program
let s: &'static str = "hello"
Lifetime Elision Rules#
Rust has built-in predictable patterns to reduce repetitive code. If there are still lifetimes that cannot be inferred after these rules, we need to specify them explicitly.
- Each reference's lifetime must be the same as at least one input reference.
- If there is only one input reference, the output reference's lifetime will be the same as the input reference's lifetime.
- If there are multiple input references and one of them is
&self
or&mut self
, the output reference's lifetime will be the same as that input reference's lifetime. - If there are multiple input references and one of them is
&self
, the output reference's lifetime will be the same as the other input reference's lifetime.