Building structs
In modern high-level dynamic languages, objects have been the bedrock for building big applications and solving complex problems, and for good reason. Objects enable us to encapsulate data, functionality, and behavior. In Rust, we do not have objects. However, we do have structs that can hold data in fields. We can then manage the functionality of these structs and group them together with traits. This is a powerful approach, and it gives us the benefits of objects without the high coupling, as highlighted in the following figure:
Figure 1.10 – Difference between Rust structs and objects
We will start with something basic by creating a Human
struct with the following code:
#[derive(Debug)] struct Human<'a> { name: &'a str, age: i8, current_thought: &'a str }
In the preceding code, we can see that our string literal fields have the same lifetime as the struct itself. We have also applied the Debug
trait to the Human
struct, so we can print it out and see everything. We can then create the Human
struct and print the struct out using the following code:
fn main() { let developer = Human{ name: "Maxwell Flitton", age: 32, current_thought: "nothing" }; println!("{:?}", developer); println!("{}", developer.name); }
Running the preceding code would give us the following printout:
Human { name: "Maxwell Flitton", age: 32, current_thought: "nothing" } Maxwell Flitton
We can see that our fields are what we expect. However, we can change our string slice fields to strings to get rid of lifetime parameters. We may also want to add another field where we can reference another Human
struct under a friend
field. However, we may also have no friends. We can account for this by creating an enum that is either a friend or not and assigning this to a friend
field, as seen in the following code:
#[derive(Debug)] enum Friend { HUMAN(Human), NIL } #[derive(Debug)] struct Human { name: String, age: i8, current_thought: String, friend: Friend }
We can then define the Human
struct initially with no friends just to see if it works with the following code:
let developer = Human{ name: "Maxwell Flitton".to_string(), age: 32, current_thought: "nothing".to_string(), friend: Friend::NIL };
However, when we run the compiler, it does not work. I would like to think this is because the compiler cannot believe that I have no friends. But alas, it’s to do with the compiler not knowing how much memory to allocate for this declaration. This is shown through the following error code:
enum Friend { HUMAN(Human), NIL}#[derive(Debug)] ^^^^^^^^^^^ ----- recursive without indirection | recursive type has infinite size
Because of the enum, theoretically, the memory needed to store this variable could be infinite. One Human
struct could reference another Human
struct as a friend
field, which could in turn reference another Human
struct, resulting in a potentially infinite number of Human
structs being linked together through the friend
field. We can solve this problem with pointers. Instead of storing all the data of a Human
struct in the friend
field, we store a memory address that we know has a maximum value because it’s a standard integer. This memory address points to where another Human
struct is stored in the memory. As a result, the program knows exactly how much memory to allocate when it crosses a Human
struct, irrespective of whether the Human
struct has a friend
field or not. This can be achieved by using a Box
struct, which is essentially a smart pointer for our enum, with the following code:
#[derive(Debug)] enum Friend { HUMAN(Box<Human>), NIL }
So, now our enum states whether the friend exists or not, and if so, it has a memory address if we need to extract information about this friend. We can achieve this with the following code:
fn main() { let another_developer = Human{ name: "Caroline Morton".to_string(), age:30, current_thought: "I need to code!!".to_string(), friend: Friend::NIL }; let developer = Human{ name: "Maxwell Flitton".to_string(), age: 32, current_thought: "nothing".to_string(), friend: Friend::HUMAN(Box::new(another_developer)) }; match &developer.friend { Friend::HUMAN(data) => { println!("{}", data.name); }, Friend::NIL => {} } }
In the preceding code, we can see that we have created one Human
struct, and then another Human
struct with a reference to the first Human
struct as a friend
field. We then access the second Human
struct’s friend
through the friend
field. Remember, we must handle both possibilities as it could be a nil value.
While it is exciting that friends can be made, if we take a step back, we can see that there is a lot of code written for each human we create. This is not helpful if we must create a lot of humans in a program. We can reduce this by implementing some functionality for our struct. We will essentially create a constructor for the struct with extra functions, so we can add optional values if we want. We will also make the thought
field optional. So, a basic struct with a constructor populating only the most essential fields can be achieved with the following code:
#[derive(Debug)] struct Human { name: String, age: i8, current_thought: Option<String>, friend: Friend } impl Human { fn new(name: &str, age: i8) -> Human { return Human{ name: name.to_string(), age: age, current_thought: None, friend: Friend::NIL } } }
Therefore, creating a new human now only takes the following line of code:
let developer = Human::new("Maxwell Flitton", 32);
This will have the following field values:
- Name:
"
Maxwell Flitton"
- Age:
32
- Current Thought:
None
- Friend:
NIL
We can add more functions in the implement block for adding friends and a current thought with the following code:
fn with_thought(mut self, thought: &str) -> Human { self.current_thought = Some(thought.to_string()); return self } fn with_friend(mut self, friend: Box<Human>) -> Human { self.friend = Friend::HUMAN(friend); return self }
In the preceding code, we can see that we pass in a mutable version of the struct that is calling these functions. These functions can be chained because they return the struct that called them. If we want to create a developer with a thought, we can do this with the following code:
let developer = Human::new("Maxwell Flitton", 32) .with_thought("I love Rust!");
We must note that a function that does not require self
as a parameter can be called with ::
, while a function that does require self
as a parameter can be called with a simple dot (.
). If we want to create a developer with a friend, it can be done using the following code:
let developer_friend = Human::new("Caroline Morton", 30); let developer = Human::new("Maxwell Flitton", 32) .with_thought("I love Rust!") .with_friend(Box::new(developer_friend)); Println!("{:?}", developer);
Running the code will result in the following parameters for developer
:
Name: "Maxwell Flitton" Age: 32 Current Thought: Some("I love Rust!") Friend: HUMAN(Human { name: "Caroline Morton", age: 30, current_thought: None, friend: NIL })
We can see that structs combined with enums and functions that have been implemented with these structs can be powerful building blocks. We can define fields and functionality with only a small amount of code if we have defined our structs well. However, writing the same functionality for multiple structs can be time-consuming and result in a lot of repeated code. If you have worked with objects before, you may have utilized inheritance for that. Rust goes one better. It has traits, which we will explore in the next section.
Verifying with traits
We can see enums can empower structs so that they can handle multiple types. This can also be translated for any type of function or data structure. However, this can lead to a lot of repetition. Take, for instance, a User
struct. Users have a core set of values, such as a username and password. However, they could also have extra functionality based on roles. With users, we must check roles before firing certain processes. We can wrap up structs with traits by creating a simple toy program that defines users and their roles with the following steps:
- We can define our users with the following code:
struct AdminUser { username: String, password: String } struct User { username: String, password: String }
We can see in the preceding code that the User
and AdminUser
structs have the same fields. For this exercise, we merely need two different structs to demonstrate the effect traits have on them. Now that our structs are defined, we can move on to our next step, which is creating the traits.
- We will be implementing these traits in our structs. The total traits that we will have are, comprise create, edit, and delete. We will be using them to assign permissions to our users. We can create these three traits with the following code:
trait CanEdit { fn edit(&self) { println!("admin is editing"); } } trait CanCreate { fn create(&self) { println!("admin is creating"); } } trait CanDelete { fn delete(&self) { println!("admin is deleting"); } }
We can see that the functions for the traits only take in self
. We cannot make any references to the fields in the functions to self
as we do not know what structs will be implemented. However, we can override functions when we implement the trait to the struct if needed. If we are to return self
, we will need to wrap it in a Box
struct, as the compiler will not know the size of the struct being returned. We also must note that the signature of the function (input parameters and return values) must be the same as the original if we overwrite the function for a struct. Now that we have defined the traits, we can move on to the next step of implementing the traits to define roles for our user.
- With our roles, we can make our admin have every permission and our user only the edit permission. This can be achieved with the following code:
impl CanDelete for AdminUser {} impl CanCreate for AdminUser {} impl CanEdit for AdminUser {} impl CanEdit for User { fn edit(&self) { println!("A standard user {} is editing", self.username); } }
From our previous step, we can remember that all the functions already worked for the admin by printing out that the admin is doing the action. Therefore, we do not have to do anything for the implementation of the traits for the admin. We can also see that we can implement multiple traits for a single struct. This adds a lot of flexibility. In our user implementation of the CanEdit
trait, we have overwritten the edit
function so that we can have the correct statement printed out. Now that we have implemented the traits, our user
structs have permission in the code to enter scopes that require those traits. We can now build the functions for using these traits in the next step.
- We could utilize the functions in the traits by directly running them in the
main
function on the structs that have implemented them. However, if we do this, we will not see their true power in this exercise. We may also want this standard functionality throughout our program in the future when we span multiple files. The following code shows how we create functions that utilize the traits:fn create<T: CanCreate>(user: &T) -> () { user.create(); } fn edit<T: CanEdit>(user: &T) -> () { user.edit(); } fn delete<T: CanDelete>(user: &T) -> () { user.delete(); }
The preceding notation is fairly like the lifetime annotation. We use angle brackets before the input definitions to define the trait we want to accept at T
. We then state that we will accept a borrowed struct that has implemented the trait as &T
. This means that any struct that implements that specific trait can pass through the function. Because we know what the trait can do, we can then use the trait functions. However, because we do not know what struct is going to be passed through, we cannot utilize specific fields. But remember, we can overwrite a trait function to utilize struct fields when we implement the trait for the struct. This might seem rigid, but the process enforces good, isolated, decoupled coding that is safe. For instance, let’s say we remove a function from a trait or remove a trait from a struct. The compiler would refuse to compile until all the effects of this change were complete. Thus, we can see that, especially for big systems, Rust is safe, and can save time by reducing the risk of silent bugs. Now that we have defined the functions, we can use them in the main
function in the next step.
- We can test to see whether all the traits work with the following code:
fn main() { let admin = AdminUser{ username: "admin".to_string(), password: "password".to_string() }; let user = User{ username: "user".to_string(), password: "password".to_string() }; create(&admin); edit(&admin); edit(&user); delete(&admin); }
We can see that the functions that accept traits are used just like any other function.
Running the entire program will give us the following printout:
admin is creating admin is editing A standard user user is editing admin is deleting
In our output, we can see that the overriding of the edit
function for the User
struct works.
We have now learned enough about traits to be productive with web development. Traits get even more powerful, and we will be using them for some key parts of our web programming. For instance, several web frameworks have traits that execute before the request is processed by the view/API endpoint. Implementing structs with these traits automatically loads the view
function with the result of the trait
function. This can be database connections, extraction of tokens from headers, or anything else we wish to work with. There is also one last concept that we need to tackle before we move on to the next chapter, and that is macros.