Building structs
In dynamic languages, classes have been the bedrock of developing data structures with custom functionality. In terms of Rust, structs enable us to define data structures with functionality. To mimic a class, we can define a Human
struct:
struct Human { name: String, age: i8, current_thought: String } impl Human { fn new(input_name: &str, input_age: i8) -> Human { return Human { name: input_name.to_string(), age: input_age, current_thought: String::from("nothing") } } fn with_thought(mut self, thought: &str ) -> Human { self.current_thought = thought; return self } fn speak(&self) -> () { println!("Hello my name is {} and I'm {} years old.", &self.name, &self.age); } } fn main() { let developer = Human::new("Maxwell Flitton", 31); developer.speak(); println!("currently I'm thinking {}", developer.current_thought); let new_developer = Human::new("Grace", 30).with_thought( String::from("I'm Hungry")); new_developer.speak(); println!("currently I'm thinking {}", new_developer.current_thought); }
This looks very familiar. Here, we have a Human
struct that has name
and age
attributes. The impl
block is associated with the Human
struct. The new
function inside the impl
block is essentially a constructor for the Human
struct. The constructor states that current_thought
is a string that's been initialized with nothing because we want it to be an optional field.
We can define the optional current_thought
field by calling the with_thought
function directly after calling the new
function, which we can see in action when we define new_developer
. Self
is much like self
in Python, and also like this
in JavaScript as it's a reference to the Human
struct.
Now that we understand structs and their functionality, we can revisit hash maps to make them more functional. Here, we will exploit enums
to allow the hash map to accept an integer or a string:
use std::collections::HashMap; enum AllowedData { S(String), I(i8) } struct CustomMap { body: HashMap<String, AllowedData> }
Now that the hash map has been hosted as a body
attribute, we can define our own constructor, get
, insert
, and display
functions:
impl CustomMap { fn new() -> CustomMap { return CustomMap{body: HashMap::new()} } fn get(&self, key: &str) -> &AllowedData { return self.body.get(key).unwrap() } fn insert(&mut self, key: &str, value: AllowedData) -> () { self.body.insert(key.to_string(), value); } fn display(&self, key: &str) -> () { match self.get(key) { AllowedData::I(value) => println!("{}", value), AllowedData::S(value) => println!("{}", value) } } } fn main() { // defining a new hash map let mut map = CustomMap::new(); // inserting two different types of data map.insert("test", AllowedData::I(8)); map.insert("testing", AllowedData::S( "test value".to_string())); // displaying the data map.display("test"); map.display("testing"); }
Now that we can build structs and exploit enums to handle multiple data types, we can tackle more complex problems in Rust. However, as the problem's complexity increases, the chance of repeating code also increases. This is where traits come in.
Verifying with traits
As we can see, enums
can empower our 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 have to check roles before firing certain processes.
We also want to add the same functionality to a number of different user types. We can do this with traits. In this sense, we're going to use traits like a mixin. Here, we will create three traits for a user struct: a trait for editing data, another for creating data, and a final one for deleting data:
trait CanEdit { fn edit(&self) { println!("user is editing"); } } trait CanCreate { fn create(&self) { println!("user is creating"); } } trait CanDelete { fn delete(&self) { println!("user is deleting"); } }
Here, if a struct implements a trait, then it can use and overwrite the functions defined in the trait
block. Next, we can define an admin user struct that implements all three traits:
struct AdminUser { name: String, password: String, } impl CanDelete for AdminUser {} impl CanCreate for AdminUser {} impl CanEdit for AdminUser {}
Now that our user struct has implemented all three traits, we can create a function that only allows users inside that have the CanDelete
trait implemented:
fn delete<T: CanDelete>(user: T) -> () { user.delete(); }
Similar to the lifetime annotation, we use angle brackets before the input definitions to define T
as a CanDelete
trait. If we create a general user struct and we don't implement the CanDelete
trait for it, Rust will fail to compile if we try to pass the general user through the delete
function; it will complain, stating that it does not implement the CanDelete
trait.
Now, with what we know, we can develop a user struct that inherits from a base user struct and has traits that can allow us to use the user struct in different functions. Rust does not directly support inheritance. However, we can combine structs with basic composition:
struct BaseUser { name: String, password: String } struct GeneralUser { super_struct: BaseUser, team: String } impl GeneralUser { fn new(name: String, password: String, team: String) -> GeneralUser { return GeneralUser{super_struct: BaseUser{name, password}, team: team} } } impl CanEdit for GeneralUser {} impl CanCreate for GeneralUser { fn create(&self) -> () { println!("{} is creating under a {} team", self.super_struct.name, self.team); } }
Here, we defined what attributes are needed by a user in the base user struct. We then housed that under the super_struct
attribute for the general user struct. Once we did this, we performed the composition in the constructor function, which is defined as new, and then we implemented two traits for this general user. In the CanCreate
trait, we overwrote the create
function and utilized the team
attribute that was given to the general user.
As we can see, building structs that inherit from base structs is fairly straightforward. These traits enable us to slot in functionality such as mixins, and they go one step further by enabling typing of the struct in functions. Traits get even more powerful than this, and it's advised that you read more about them to enhance your ability to solve problems in Rust.
With what we know about traits, we can reduce code complexity and repetition when solving problems. However, a deeper dive into traits at this point will have diminishing returns when it comes to developing web apps. Another widely used method for structs and processes is macros.