How it works...
Using traits instead of interfaces and other object-oriented constructs has many implications for the general architecture. In fact, common architectural thinking will likely lead to more complex and verbose code that may perform worse on top of that! Let's examine popular object-oriented principles from the Gang of Four's book, Design Patterns (1994):
- Program to an interface not to an implementation: This principle requires some thinking in Rust. With the 2018 edition, functions can accept an impl MyTrait parameter, where earlier versions had to use Box<MyTrait> or o: T and later where T: MyTrait, all of which have their own issues. It's a trade-off for every project: either less complex abstractions with the concrete type or more generics and other complexity for cleaner encapsulation.
- Favor object composition over class inheritance: While this only applies to some extent (there is no inheritance in Rust), object composition is still something that seems like a good idea. Add trait type properties to your struct instead of the actual type. However, unless it's a boxed trait (that is, slower dynamic dispatch), there is no way for the compiler to know exactly the size it should reserve—a type instance could have 10 times the size of the trait from other things. Therefore, a reference is required. Unfortunately, though, that introduces explicit lifetimes—making the code a lot more verbose and complex to handle.
Rust clearly favors splitting off behavior from data, where the former goes into a trait and the latter remains with the original struct. In this recipe, KeyValueConfigService did not have to manage any data—its task was to read and write Config instances.
After creating these structs in step 2, we created the behavior traits in step 3. There, we split the tasks off into two individual traits to keep them small and manageable. Anything can implement these traits and thereby acquire the capabilities of writing or reading config files or retrieving a specific value by its key.
We kept the functions on the trait generic as well to allow for easy unit testing (we can use Vec<T> instead of faking files). Using Rust's impl trait feature, we only care about the fact that std::io::Read and std::io::Write have been implemented by whatever is passed in.
Step 4 implements the traits in an individual impl block for the structs. The ConfigReader strategy is naive: split into lines, split those lines at the first = character, and declare the left- and right-hand parts key and value respectively. The ValueGetter implementation then walks through the key-value pairs to find the requested key. We preferred Vec with String tuples here for simplicity, for example, HashMap can improve performance substantially.
The tests implemented in step 5 provide an overview of how the system works and how we seamlessly use the types by the traits they implement. Vec doubles as a read/write stream, no type-casting required. To make sure the tests actually run through, we run cargo test in step 6.
After this lesson on structuring code, let's move on to the next recipe.