Generic Types in Rust
Generic types in Rust are exciting topics to learn, which may be intimidating for someone new to strongly typed languages. But through this article, I will explain what generic types are and why we need to use them.
Time before Generic Types
Generic types are introduced for the development community to reduce the redundant code we need to write for different types (like i32, String, f32 and much more). Let's understand this situation with an example.
struct PointI32 { // -> 1
x: i32,
y: i32,
}
struct PointF32 { // -> 2
x: f32,
y: f32,
}
In the above example, you might see two structs with the same field names but different types. At first glance, the example shown might not have any issues. But what if we want to add a struct with similar fields (and functionality) for string types?
Before knowing the concept of Generic types, my answer would be to declare a struct for String types as well like this:
struct PointString { // 3
x: String,
y: String,
}
This would work fine, but this can become cumbersome and involve lots of repetition. It gets even worse if we implement methods for these structures (which we will cover later). Again, using this formula is okay, but generic types were introduced for these situations.
How to use Generic Types
We can declare Generic Types using the angular bracket (< >) after the struct name:
struct Point<T> { // 4
x: T,
y: T,
}
For me, it was to understand why exactly T. T is simply a name I gave as a Generic Type for our Point struct. This can be anything. I can provide it like this, and it would still work.
struct Point<Type> { // 5
x: Type,
y: Type,
}
However, there are some standard conventions for generic types. It should be as short as possible and in UpperCamelCase (or simply PascalCase). Also, I have seen many examples involving 'T' for generic Types and misunderstood that it is a fixed keyword for Generic types. But now you and I both know what it means.
Let's dig some more:
In example number 4, we see that 'T' is assigned to both x and y. We can also take two generic types and give them to individual struct fields.
struct Point<T, U> { // 6
x: T,
y: U,
}
// calling function
fn main() {
let point_i32: Point<i32, f32> = Point { x: 20, y: 30.12 };
}
You can mix generic types with concrete types according to your objectives. This makes the functionality of Struct or enums more flexible for our needs.
struct Point<T> { // 7
x: T,
y: f32,
}
Implementing methods on Struct without Generic Types
Let's begin by understanding how to implement methods for a struct example (without generic types). We will add methods for our PointString struct (example 3):
impl PointString { // 8
fn get_x(&self) -> String {
self.x.clone()
}
}
// using this method
let point_string: PointString = PointString {
x: "Hello".to_string(),
y: "World".to_string(),
};
dbg!(point_string.get_x()); // prints "Hello"
Implementing methods on Struct with Generic Types
This is similar to example 8 shown previously, but the only difference is adding angular brackets, as we do while declaring the Struct.
impl Point<T, U> { // 9
fn print(&self) {
println!("{}", self.x);
}
}
Although the idea is correct, this code won't work. The reason number one is that there may come an ambiguous situation where Rust may confuse T and U with a concrete type. For example:
impl Point<f32, i32> { // 10
fn print_float_x(&self) {
println!("{}", self.x);
}
}
impl Point<i32, f32> { // 11
fn print_all(&self) {
println!("x: {}, y: {}", self.x, self.y);
}
}
So you see, in the above example, both 10 and 11 examples are valid. This may seem repetitive, but it's essential to understand that we can implement methods on Struct with Generic Types in more than one way. Therefore, Rust cannot know if it is a Generic or Concrete type in the compile time.
So, to implement methods in the case of Generic types, we would do this:
impl<T, U> Point<T, U> { // 12
fn print(&self) {
println!("{}", self.x);
}
}
So now, adding the same Generic type notation after the impl keyword tells the compiler that we are dealing with Generic types here.
Now, there is also another reason why it is an absolute must to tell the compiler that we are dealing with Generic types this way (example 12). We are going to understand that next.
* Monomorphization *
As we are introduced to the boss at the end of each level, we will learn about the "Boss" for this article. We'll be able to understand why it is crucial here.
Monomorphization is a technique used in Rust that converts all the generic types to concrete types in compile time by analysing the parts where the structs and enums are used. You will understand how cool it is once we see through some examples.
impl<T, U> Point<T, U> {
fn print(&self) {
println!("{}", self.x);
}
}
fn main() {
let point = Point { x: 20, y: 30.12 };
let point_f32 = Point { x: 21.3, y: 30.12 };
}
So, in the above example, we have created two instances of Point<T, U>. But what monomorphization does in the compile time looks like this:
// for point
impl Point<i32, f32> {
fn print_all(&self) {
println!("x: {}, y: {}", self.x, self.y);
}
}
// for point_f32
impl Point<f32, f32> {
fn print_all(&self) {
println!("x: {}, y: {}", self.x, self.y);
}
}
fn main() {
let point = Point { x: 20, y: 30.12 };
let point_f32 = Point { x: 21.3, y: 30.12 };
}
Remember, this situation was the reason why we got Generic types introduced. But in the end, it has compiled to the same repeating code (examples 1 and 2). So what is the big deal?
The first reason is that we will use less code while developing. Secondly, this is done in Compile Time. There is a massive difference since Rust is a static-typed language that will try to find all types in the compile time, reducing the overhead required in runtime. This gives Rust a swift runtime that you will experience throughout your rusty journey.
领英推荐
The End?
Of course, we have finished understanding the basics of Generic Types in this article. But it still needs to be completed because if you try to run the code provided in this article, you will get an error related to traits.
Traits deserves its own article. But as a bonus, we will learn traits just to run our examples and ensure this article is not incomplete. Let's see what the completed code and its error are:
struct Point<T, U> { // 13
x: T,
y: U,
}
impl<T, U> Point<T, U> {
fn print_x(&self) -> T {
self.x
}
}
fn main() {
let point = Point { x: 20, y: 30.12 };
dbg!(point.print_x());
}
-----------------
Error log:
-----------------
error[E0507]: cannot move out of `self.x` which is behind a shared reference
--> src/main.rs:23:9
|
23 | self.x
| ^^^^^^ move occurs because `self.x` has type `T`, which does not implement the `Copy` trait
For more information about this error, try `rustc --explain E0507`.
error: could not compile `my-project` (bin "my-project") due to previous error
Let's now understand what traits are and then jump back to this error and how to fix it.
Traits
Traits are the primitive behaviour of a particular something. For example, what is the behaviour of dogs when they see a stranger near a house? They bark (Cats don't care). So, traits have a set of behaviours that defines this very something.
Let's list down the behaviours of a good pet (Dog) with our crab ?? (Rust):
trait GoodPet {
fn protect_owner();
fn show_love();
fn mark_territory() // if you know what i mean
}
At first glance, it has similarities with a Struct and Enum. The only difference is that it has function signatures. We still need to bring the Dog into the picture. We have just declared the traits that are required for a good pet. Notice that in traits, we only provide the function signature and will only define them where the traits are implemented (in our case, Dog).
Let's define the basic properties of a dog:
struct Dog {
name: String,
breed: String,
}
Finally, we include(implement) traits for the Dog:
impl GoodPet for Dog {
fn protect_owner() {
println!("bark");
}
fn show_love() {
println!("Lick the Hoomans face");
}
fn mark_territory {
println!("I like to water things");
}
}
This is what traits are and how they can be used to add behaviours (common) for multiple structs and enums. For example, we can implement traits of a good pet in other animals, but I don't think it beats dogs (no offence). In Rust terms, we implement GoodPet for any Struct or Enums as long as you are sure to define all the trait functions. There is a way to define a default trait, but we will see it in a separate article.
Let's Fix the Code
We will fix the code and analyse the solution later. Please take a look at example number 13. So, the error says that the Generic Type 'T' doesn't implement the Copy trait. First, we will fix this and run the code:
struct Point<T, U> { // 14
x: T,
y: U,
}
impl<T: Copy, U> Point<T, U> { // fixed implementation
fn print_x(&self) -> T {
self.x
}
}
fn main() {
let point = Point { x: 20, y: 30.12 };
dbg!(point.print_x());
}
What I did may look strange. We seem to be assigning Copy (trait) to our 'T' (Generic type). But that's different from what is happening. Now, we will understand this with our Dog example.
Understanding with Cats and Dogs
trait GoodPet {
fn protect_owner();
fn show_love();
fn mark_territory();
}
struct Dog {
name: String,
}
impl GoodPet for Dog {
fn protect_owner() {
println!("bark");
}
fn show_love() {
println!("Lick the Humans face");
}
fn mark_territory() {
println!("I like to water things");
}
}
struct Cat {
name: String,
}
fn main() {
let dog = Dog {
name: "Brownie".to_string(),
};
let cat = Cat {
name: "millie".to_string(),
};
}
In the above example, we have created two separate structs for dogs and cats. Also, we have implemented the GoodPet trait only for our Dog struct. Now we have to test their behaviour with another struct Pet with generic type T. I will explain why we do that after seeing the example.
struct Pet<T> {
pet: T,
}
impl<T> Pet<T> {
pub fn congratulate(&self) {
println!("You have bought a good pet");
}
}
Here, Pet takes a generic type T, and we have implemented the congratulate method. Now, let's use this for the Dogs and Cats example. First, we will see an ordinary case where a Cat and Dog implement the congratulate method.
fn main() {
let dog = Dog {
name: "Brownie".to_string(),
};
let cat = Cat {
name: "millie".to_string(),
};
let pet_dog = Pet { pet: dog };
let pet_cat = Pet { pet: cat };
pet_cat.congratulate(); // works fine
pet_dog.congratulate(); // works fine
}
We have added the instance of Dog and cat as pets in the Pet struct. We also see that pet_cat and pet_dog have implemented the congratulate method and printed "You have bought a good pet". Now let's print the reality and tell the Pet implementation to restrict the congratulate method to the Dog (pet_dog).
impl<T: GoodPet> Pet<T> { // magic is done here
pub fn congratulate(&self) {
println!("You have bought a good pet");
}
}
fn main() {
let dog = Dog {
name: "Brownie".to_string(),
};
let cat = Cat {
name: "millie".to_string(),
};
let pet_dog = Pet { pet: dog };
let pet_cat = Pet { pet: cat };
// pet_cat.congratulate(); // will not work
pet_dog.congratulate(); // works fine
}
Do you know what changed? This is called trait bounding or, in other words, ensuring that an implementation only works if the Generic Type has implemented a particular trait.
So, in the above example, we have tried to congratulate the owner only if he selected a good pet. Here, the good Pet is our Dog (brownie). This restriction is beneficial because we would only want a pet if it has the behaviours of a good pet. You can't buy a mouse and expect it to defend its owner. So, we can reduce the pet selection a lot more if we can figure out its traits.
In Rust's terms, we cannot expect Cat (Struct) to have behaviour like protect_owner or show_love. Hey, I'm not a cat hater. It's just an example (please don't hate me). This is because we haven't implemented the GoodPet trait for our Cat struct, that's all.
Coming back to our Issue Earlier..
struct Point<T, U> {
x: T,
y: U,
}
impl<T: Copy, U> Point<T, U> { // fixed implementation
fn print_x(&self) -> T {
self.x
}
}
fn main() {
let point = Point { x: 20, y: 30.12 };
dbg!(point.print_x());
}
The error earlier (example 13) was because when we return self. We are moving the ownership of x from the self (Struct), which is not allowed in Rust. We either have to send a copy or a clone of the value. Also, you must inform the rust compiler that we are using a Generic type T, which implements a Copy trait. This will allow our code to run correctly.
You can also experiment with the Clone trait for the above example and make this program run.
The End
With this, we have come to the end of this article. I hope I was helpful and we all learned something new today.
You can take a look at my other articles on Rust ??:
So until next time. Thank you, and stay Rustic.
Senior Software Engineer | AWS, MySQL, Apache Airflow
1 年Recommend https://doc.rust-lang.org/book/ Regard.