[rust generics]
written 12/19/2024
frosted mini squares
Sandbox
The sandbox where I tested out these ideas is here.
The conversation I had with ChatGPT regarding the topic is here.
Generics Are Used Everywhere
Yesterday, I was learning about lifetimes and I kept coming across generic notations. I understood most of them but wanted to do a deep dive to make sure I do not miss anything important or obscure.
One thing is for sure, generics are used extensivly in Rust code. I see the notations all over the place and they have a tendancy to make the code feel "full" and a bit harder to navigate.
I think having a solid understanding of generics will help my brain feel more at ease when scanning Rust code.
Avoiding Code Duplication
Generics are all about avoiding code duplication. Since Rust is a statically typed language, you end up in scenarios where you might have a similar function for multiple types.
Take these two functions:
1fn add_strs(s1: String, s2: String) -> String {
2 s1 + &s2
3}
4
5fn add_nums(x: i32, y: i32) -> i32 {
6 x + y
7}
They can be converted into this generic function:
1fn add_generic<T: std::ops::Add<Output = T>>(x: T, y: T) -> T {
2 x + y
3}
I learned a few things while drafting out these above examples. Let's take note of this tidbit right here <T: std::ops::Add<Output = T>>
.
Rust Constrains
OK, this is an idea I like and think is a core theme found throughout Rust. Rust is a constraining language. It is trying to place tons of caps and limits on what you can do and how you can manage the flow of data throughout your program.
When we say <T: std::ops::Add<Output = T>>
, we are limiting the passed in types to types which implement the Add trait from the standard library.
ChatGPT made a good point on this. If we do not limit the passed in types, we may end up in a situation where the function does not return a value of type T
.
In short, these type constraints help us to be confident that the function is going to return the type of data that it says it will return.
Learning more about traits and trait bounds is a sure way to get a better grip on generics as they are related closely together in this way.
Constraints Are Contextual
Rust may require a function or method be constrained depending on how the inputs are used. For example, in the above function add_generic
, we see that we are operating on the inputs using the addition symbol +
. Because of this context, Rust now requires a constraint.
Had we never added the values together, the constraint would no longer be needed.
Rust provides a multitude of traits to constrain your generic types with depending on how they are used within your code.
This is all possible because the Rust compiler is so thorough. When we complain about Rust having a slow compiler, these are the types of checks which have the potential to slow things down.
All in the name of safety, baby.
Implementing Add on a Custom Type
So, what if we want to use a custom type with our add_generic
function? Well, we can implement the Add
trait and tell Rust how they type ought to be used when used in conjunction with addition operator.
First, our custom type:
1#[derive(Debug)]
2struct Point {
3 x: i32,
4 y: i32,
5}
Then, we implement the Add
trait:
1impl std::ops::Add for Point {
2 type Output = Point;
3
4 fn add(self, other: Point) -> Point {
5 Point {
6 x: self.x + other.x,
7 y: self.y + other.y,
8 }
9 }
10}
Finally, we can add our points:
1let p1 = Point {x: 10, y: 10};
2let p2 = Point {x: 10, y: 10};
3let p3 = add_generic(p1, p2);
4dbg!(p3);
Other Generic Contexts
As I was looking into this, I found it useful to learn about other contexts where our generic types might need to be constrained. After asking ChatGPT, here is what I came up with:
Arithmetic and Mathematical Traits
std::ops::Add
: For + operator.std::ops::Sub
: For - operator.std::ops::Mul
: For * operator.std::ops::Div
: For / operator.std::ops::Rem
: For % operator.std::ops::Neg
: For unary - operator.std::ops::Shl
/std::ops::Shr
: For bitwise left (<<) and right (>>) shifts.
Comparison Traits
std::cmp::PartialEq
: For == and !=.std::cmp::Eq
: For strict equality (used in HashMap keys, requires PartialEq).std::cmp::PartialOrd
: For <, <=, >, and >=.std::cmp::Ord
: For total ordering (requires PartialOrd and Eq).
Iteration and Collection Traits
std::iter::Iterator
: For types that can produce a sequence of values.std::iter::IntoIterator
: For types that can be converted into an iterator.std::iter::Extend
: For extending a collection with an iterator.std::iter::FromIterator
: For constructing a collection from an iterator.
Borrowing and Ownership Traits
std::borrow::Borrow
: For generic borrowing.std::borrow::ToOwned
: For creating an owned version of a borrowed value (e.g., String from &str).std::convert::AsRef
: For converting a value to a reference of another type.std::convert::AsMut
: For converting a value to a mutable reference of another type.
Default and Debug Traits
std::default::Default
: For providing a default value.std::fmt::Debug
: For formatting with {:?}.std::fmt::Display
: For formatting with {}.
Trait Object-Specific Traits
std::any::Any
: For working with types at runtime.std::marker::Send
: For types that are safe to transfer between threads.std::marker::Sync
: For types that are safe to reference from multiple threads.
Functional Programming Traits
Fn
: For closures that do not mutate state.FnMut
: For closures that mutate state.FnOnce
: For closures that consume their environment.
I/O Traits
std::io::Read
: For types that can read data.std::io::Write
: For types that can write data.std::io::Seek
: For types that can seek within a stream.std::io::BufRead
: For buffered readers.
Common Combinations
Clone + PartialEq
: For types that can be cloned and compared.Iterator + Debug
: For iterators that can also be debugged.Send + Sync
: For thread-safe types.
Conclusion
Generics in Rust are extremely important and a lack of understanding of them can result in code feeling full and archaic. Generics are tied directly to traits and are used in conjunction with them to ensure that return values are of the intended type.
All in all, when we are writing generic functions, we need to be mindful and aware that the context and way in which we use the input parameters will dictate the way in which we need to constrain them.