Recently, I rewrote the TenantsModule
(formerly OrganizationalUnitsModule
, because I also did some refactoring :)).
This is what it looks like in its current state:
1pub struct TenantsModule {
2 pub pool_manager: Arc<dyn PgPoolManagerTrait>,
3 pub config: Arc<AppConfig>,
4 pub repo_factory: Box<dyn Fn() -> Box<dyn TenantsRepository + Send + Sync> + Send + Sync>,
5 pub migrator_factory: Box<dyn Fn() -> Box<dyn DatabaseMigrator + Send + Sync> + Send + Sync>,
6}
But what are these Boxes? Link to heading
To be able to illustrate this, I need to take a brief detour into the world of memories. In this topic, we distinguish two important levels of the available memory:
The stack Link to heading
This memory area is allocated for us by the operating system (more specifically, the kernel) when the program starts. The size of this area is determined by the compiler at the time of program compilation. It can only do this if we only push onto the stack things whose size we already know at compile time ( see: Sized trait). In cases where we cannot determine at compile time how much space we will need to store a particular item or when working with a construction where we cannot exclude the possibility that the size may change, we need to use heap memory.
The heap Link to heading
At runtime for dynamically changing values, which we cannot put on the stack, we can request the kernel to allocate additional space on the heap where we can work with our data. This sounds good, but there is a small cost: we have to give control back to the kernel first, which then performs the memory allocation. If the control has already been handed over, it might do something else in the meantime. Among other reasons, this is why multiple programs can appear to run simultaneously on your computer at the same time even if there is only one processor with a single core in your system, because when a program returns control to the kernel, it reallocates resources: for example, it allows another program to run..
I hope you haven’t gotten tired of the detours yet, because there’s one more: Code composition. Link to heading
Every line of code you write is another line of code that, if you want to use it long-term, needs to be maintained. Therefore, as a developer, you aim to make the procedures you write as universally applicable as possible to different data types and/or data structures, so you have to write and maintain less code. One of the tools for this is generics, and the other is traits. From a practical perspective of this topic, the latter is more interesting, so perhaps one day I will discuss generics in another post.
Traits Link to heading
A trait defines a functionality that a type can have and share with other types. I believe something like this should be written on paper if you’re filling out a test related to this, but what does it mean?
Given three vehicle types. The task is to print out their make in our program.
What does this look like without traits?
1struct Airplane {
2 pub make: String,
3 pub callsign: String,
4 // ...
5}
6struct Car {
7 pub make: String,
8 pub platenumber: String
9 // ...
10}
11
12struct Bicycle {
13 pub make: String,
14 pub saddle_type: String,
15 // ...
16}
17
18// What you need to notice here is that I have a procedure that I had to write three times, even though it does the same
19// thing each time. In this example, this isn't a big problem because it only calls a println macro within the function,
20// but imagine the same thing with 50-60 line functions and many more different types of vehicles.
21fn print_airplane_make(airplane: &Airplane) {
22 println!("Make: {}", &airplane.make);
23}
24
25fn print_auto_make(car: &Car) {
26 println!("Make: {}", &car.make);
27}
28
29fn print_bicycle_make(Bicycle: &Bicycle) {
30 println!("Make: {}", &bicycle.make)
31}
32
33fn main() {
34 let airplane = Airplane { make: String::from("Cessna"), callsign: String::from("KD123456") };
35 let auto = Car { make: String::from("Audi"), platenumber: true };
36 let bicycle = Bicycle { make: String::from("Merida"), saddle_type: String::from("ASD Royal+ Hybrid") };
37 print_airplane_make(&airplane);
38 print_auto_make(&auto);
39 print_bicycle_make(&bicycle);
40}
What does this look like with traits?
1trait HaveMake {
2 fn get_make(&self) -> &String;
3}
4struct Airplane {
5 pub make: String,
6 pub callsign: String,
7 // ...
8}
9struct Car {
10 pub make: String,
11 pub platenumber: bool
12 // ...
13}
14
15struct Bicycle {
16 pub make: String,
17 pub saddle_type: String,
18 // ...
19}
20
21impl HaveMake for Airplane {
22 fn get_make(&self) -> &String {
23 &self.make
24 }
25}
26impl HaveMake for Car {
27 fn get_make(&self) -> &String {
28 &self.make
29 }
30}
31impl HaveMake for Bicycle {
32 fn get_make(&self) -> &String {
33 &self.make
34 }
35}
36
37// It appears that I am passing a variable-sized parameter to this function, which is not possible, but this is because
38// the function is actually a well-disguised generic function
39fn print_make(vehicle: impl HaveMake) {
40 println!("Make: {}", vehicle.get_make());
41}
42
43// what it looks like when written in full form
44fn print_make_full<T: HaveMake>(vehicle: T) {
45 println!("Make: {}", vehicle.get_make());
46}
47
48// This function could also be written as:
49fn print_make_box(vehicle: Box<dyn HaveMake>) {
50 println!("Make: {}", vehicle.get_make());
51}
52// In this case, I would force the caller to ask the kernel to allocate space for them on the heap,
53// transfer the data there, and pass the pointer to this function that points to the data on the heap.
54// I can still optimize this costly operation here by using impl Trait.
55
56fn main() {
57 let airplane = Airplane { make: String::from("Cessna"), callsign: String::from("KD123456") };
58 let auto = Car { make: String::from("Audi"), platenumber: true };
59 let bicycle = Bicycle { make: String::from("Merida"), saddle_type: String::from("ASD Royal+ Hybrid") };
60 print_make(airplane);
61 print_make(auto);
62 print_make(bicycle);
63}
But then, what are these boxes after all? Link to heading
Let’s modify the previous example so that our print function can handle not just a single instance, but a vector of instances.
1
2// The function can only be called with a parameter whose size is known at compile time.
3// Here, we pass a pointer to a vector containing pointers. The size of the pointer is known at compile time.
4fn print_make(vehicles: &Vec<Box<dyn HaveMake>>) {
5 for vehicle in vehicles {
6 println!("Make: {}", vehicle.get_make());
7 }
8}
9
10fn main() {
11 let airplane = Airplane { make: String::from("Cessna"), callsign: String::from("KD123456") };
12 let auto = Car { make: String::from("Audi"), platenumber: true };
13 let bicycle = Bicycle { make: String::from("Merida"), saddle_type: String::from("ASD Royal+ Hybrid") };
14
15 // This won't work because the variable's type and size do not match.
16 // let vehicles = vec![airplane, auto, bicycle];
17
18 //The solution:
19
20 // Request space from the kernel on the heap, copy our instances there, and store the pointer to them in the
21 // appropriate variable
22 let airplane = Box::new(airplane);
23 let auto = Box: new(auto);
24 let bicycle = Box::new(bicycle);
25
26 // Request space for our vector on the heap, and store the pointer to it in the vehicles variable.
27 let vehicles: Vec<Box<dyn HaveMake>> = vec![airplane, auto, bicycle];
28
29 print_make(&vehicles);
30}
In summary, Box is one solution among many to request space on the heap, where we can work with data that changes dynamically at runtime, and the pointer pointing to them can be used in such a way that its size is already known at compile time.
When I was planning this post, I thought it would be much shorter, and I started with the hope that writing it would take orders of magnitude less time. If you find any mistakes, please email me!