Dependency inversion in Rust
November 14, 2022
Given a struct MyStruct living within module my_struct, and a trait Feature living in the module my_feature, it is required that MyStruct holds different implementations of the trait Feature without depending on these implementations.
Our goal will be to tell MyStruct that it may accept any struct that implements Feature via new() method as parameter.
In other languages —mostly with structural typing— the footprint of this new() method would look like this:
language-rust
fn new(feature: Feature) -> MyStruct;
This won't work in Rust, as it can't infere the size of Feature at compile time.
But this may be achieved via two different strategies:
- Using generics
- Using trait objects
Dependency inversion with generics
It is possible to let the compiler pass the type of Feature as a generic.
A first step would be to define Feature trait within feature module:
language-rust
mod feature {
pub trait Feature {
fn action(&self) -> String;
}
}
Within my_struct module would live MyStruct. It would receive any generic of type Feature, which can be seen in impl <T: Feature>....
MyStruct will then receive T as generic; now it will be possible to type feature parameter as T.
language-rust
mod my_struct {
use super::feature::Feature;
pub struct MyStruct<T> {
feature: T,
}
impl<T: Feature> MyStruct<T> {
pub fn new(feature: T) -> MyStruct<T> {
MyStruct { feature }
}
pub fn action(&self) -> String {
self.feature.action()
}
}
}
The different implementations of Feature would live in different modules:
language-rust
mod feature_implementation01 {
use super::feature::Feature;
pub struct FeatureImplementation01;
impl Feature for FeatureImplementation01 {
fn action(&self) -> String {
String::from("Feature 01 - with generics")
}
}
}
language-rust
mod feature_implementation02 {
use super::feature::Feature;
pub struct FeatureImplementation02;
impl Feature for FeatureImplementation02 {
fn action(&self) -> String {
String::from("Feature 02 - with generics")
}
}
}
Finally the client code would consume this logic:
language-rust
pub fn main() {
use feature_implementation01::FeatureImplementation01;
use feature_implementation02::FeatureImplementation02;
use my_struct::MyStruct;
let feature_implementation_01_instance = FeatureImplementation01;
// If required, it is possible to pass the type of
// the implementation into the generic with turbofish syntax
let my_struct =
MyStruct::<FeatureImplementation01>::new(
feature_implementation_01_instance
);
let result = my_struct.action();
println!("{:#?}", result);
// If required, it is possible to pass the type of
// the implementation into the generic with turbofish syntax
let feature_implementation_02_instance = FeatureImplementation02;
let my_struct =
MyStruct::<FeatureImplementation01>::new(
feature_implementation_02_instance
);
let result = my_struct.action();
println!("{:#?}", result);
}
It is important to note that when MyStruct::new is called, it is receiving the type of the struct implementing Feature with the turbofish syntax:
language-rust
let my_struct = MyStruct::<FeatureImplementation01>::new(
feature_implementation_01_instance
);
Rust compiler may be able to infere it. This is the case with this code: if we remove ::<FeatureImplementation01>, the compiler will be able to find out which type is passed; but it is not always the case, and sometimes the developer may be required to pass it manually.
With Smart pointers
Rust provides several smart pointers for dinamically sized structures. The obvious choice here is Box.
The required changes to achieve this will hapen only on my_struct module and within the client code, as seen below.
It is required to remove the generics on MyStruct; the issue then will be that feature is typed as the trait Feature: but the size of structs implementing this trait may vary.
This can be solved by wrapping Feature within Box<dyn Feature>, telling the compiler to send this struct into the heap, which is dinamically sized. To be explicit about traits being dinamically dispatched Rust forces us to use the dyn keyword.
language-rust
mod my_struct {
use super::feature::Feature;
pub struct MyStruct {
feature: Box<dyn Feature>,
}
impl MyStruct {
pub fn new(feature: Box<dyn Feature>) -> MyStruct {
MyStruct { feature }
}
pub fn action(&self) -> String {
self.feature.action()
}
}
}
The client code will change as well, as it requires to wrap the feature instance within a Box instance:
language-rust
fn main() {
use feature_implementation01::FeatureImplementation01;
use feature_implementation02::FeatureImplementation02;
use my_struct::MyStruct;
let feature_implementation_01_instance = FeatureImplementation01;
let my_struct = MyStruct::new(Box::new(feature_implementation_01_instance));
let result = my_struct.action();
println!("{:#?}", result);
let feature_implementation_02_instance = FeatureImplementation02;
let my_struct = MyStruct::new(Box::new(feature_implementation_02_instance));
let result = my_struct.action();
println!("{:#?}", result);
}
Now, lets say MyStruct makes use of MyService, which at the same time requires the feature instance hold within its struct.
language-rust
mod my_service {
use super::feature::Feature;
pub struct MyService {
feature: Box<dyn Feature>,
}
impl MyService {
pub fn new(feature: Box<dyn Feature>) -> MyService {
MyService { feature }
}
}
}
language-rust
mod my_struct {
use super::feature::Feature;
use super::my_service::MyService;
pub struct MyStruct {
feature: Box<dyn Feature>,
}
impl MyStruct {
pub fn new(feature: Box<dyn Feature>) -> MyStruct {
MyStruct { feature }
}
pub fn action(self) -> String {
let my_service = MyService::new(self.feature);
println!("{}", my_service.action());
self.feature.action().to_owned()
}
}
}
Now the compiler returns an error, as MyService is borrowing self.feature:
language-none
error[E0382]: borrow of moved value: `self.feature`
--> src/with_box.rs:25:7
|
21 | let my_service = MyService::new(self.feature);
| ------------ value moved here
...
25 | self.feature.action().to_owned()
| ^^^^^^^^^^^^^^^^^^^^^ value borrowed here after move
When passing self.feature to MyService it is consumed, so it wont be available after this line. A solution would be to clone() it; but the compiler will return a new error:
language-none
error[E0599]: the method `clone` exists for struct
`Box<(dyn with_box::feature::Feature + 'static)>`,
but its trait bounds were not satisfied
--> src/with_box.rs:21:52
|
2 | pub trait Feature {
| -----------------
| |
| doesn't satisfy `dyn with_box::feature::Feature: Clone`
| doesn't satisfy `dyn with_box::feature::Feature: Sized`
...
21 | let my_service = MyService::new(self.feature.clone());
| ^^^^^ method
cannot be called on
`Box<(dyn with_box::feature::Feature + 'static)>`
due to unsatisfied trait bounds
The trait Box doesnt implement Clone. It would be possible to implement Clone for Box<dyn Feature>, but there is a simpler solution: use the Rc pointer instead of Box.
language-rust
mod my_struct {
use super::feature::Feature;
use super::my_service::MyService;
use std::rc::Rc;
pub struct MyStruct {
feature: Rc<dyn Feature>,
}
impl MyStruct {
pub fn new(feature: Rc<dyn Feature>) -> MyStruct {
MyStruct { feature }
}
pub fn action(self) -> String {
let my_service = MyService::new(self.feature.clone());
println!("{}", my_service.action());
self.feature.action().to_owned()
}
}
}
Rc implements Clone, so we can call it over self.feature.
A last case should be taken into account: the code may be asynchronous; but Rc is not thread safe.
The following examples will make use of Tokio as async runtime as well as async_trait to be able to create traits with asynchronous methods.
For example, if we require MyStruct to implement a trait MyStructTrait, the compiler will warn that both MyStructTrait and Feature should implement Send + Sync :
language-rust
mod my_struct {
use super::feature::Feature;
use async_trait::async_trait;
use std::rc::Rc;
pub struct MyStruct {
feature: Rc<dyn Feature>,
}
#[async_trait]
pub trait MyStructTrait: Send {
fn new(feature: Rc<dyn Feature>) -> Self;
async fn action(&self) -> Result<String, String>;
}
#[async_trait]
impl MyStructTrait for MyStruct {
fn new(feature: Rc<dyn Feature>) -> Self {
MyStruct { feature }
}
async fn action(&self) -> Result<String, String> {
self.feature.action().await
}
}
}
language-none
error: future cannot be sent between threads safely
--> src/with_arc.rs:31:54
|
31 | async fn action(&self) -> Result<String, String> {
| ______________________________________________________^
32 | | self.feature.action().await
33 | | }
| |_____^ future created by async block is not `Send`
It is possible to tell the compiler to extend both Feature and MyStructTrait with Send + Sync; there will be another obstacule though: x cannot be sent between threads safely.
language-none
error[E0277]: `Rc<(dyn with_arc::feature::Feature + 'static)>`
cannot be sent between threads safely
--> src/with_arc.rs:26:8
|
26 | impl MyStructTrait for MyStruct {
| ^^^^^^^^^^^^^ `Rc<(dyn with_arc::feature::Feature +
'static)>
` cannot be sent between threads safely
The pointer Rc is useful on sync code, but is not thread safe: luckily for this Rust provides Arc, which, as per docs: «... provides shared ownership of a value of type T, allocated in the heap. […] Unlike Rc<T>, Arc<T> uses atomic operations for its reference counting. This means that it is thread-safe.»
Using Arc has more memory consumption than Rc, but for this case is negligible. And as implements Clone it will be possible to perform self.feature.clone() in order to pass it to Service.
language-rust
mod my_struct {
use super::feature::Feature;
use async_trait::async_trait;
use std::sync::Arc;
pub struct MyStruct {
feature: Arc<dyn Feature>,
}
#[async_trait]
pub trait MyStructTrait: Send + Sync {
fn new(feature: Arc<dyn Feature>) -> Self;
async fn action(&self) -> Result<String, String>;
}
#[async_trait]
impl MyStructTrait for MyStruct {
fn new(feature: Arc<dyn Feature>) -> Self {
MyStruct { feature }
}
async fn action(&self) -> Result<String, String> {
self.feature.action().await
}
}
}
Conclusion
The convenience of generics over trait objects will depend on the context. Generics will have better performance, as its size is known at compile time and will use static dispatch; trait objects, on the other hand, will be send into the heap and will use dynamic dispatch, wich will have some memory overhead. But the flexibility it provides may worth it for many cases.
In short: each pointer —Box, Rc or Arc— will allow different actions:
- Box: synchronous code where the trait object instance will be consumed once.
- Rc: synchronous code where the trait object instance will have to be cloned in order to feed different factories.
- Arc: asynchronous code that requires thread safety.
With these four options —generics, Box, Rc and Arc— it is possible to perform dependency inversion and apply the patterns that may be required in our codebase.
The full source code for the four examples can be found at git.antoniodiaz.me/antoniodcorrea/rust-dependency-inversion .