Inversión de dependencias en Rust

Dado un struct MyStruct, contenido en el módulo my_struct, y un trait Feature contenido en el módulo my_feature, se requiere que MyStruct pueda recibir diferentes implementaciones del trait Feature sin depender directamente de ellas.

Nuestro objetivo será poder indicarle al struct MyStruct que puede recibir mediante el método new() cualquier struct que implemente Feature.
En otros lenguajes —mayormente aquellos con tipado estructural—, la huella de new() tendría esta forma:

language-rust

⁠fn new(feature: Feature) -> MyStruct;

Esto no funcionará en Rust, pues el compilador no podrá inferir el tamaño del tipo Feature en tiempo de compilación.

Sin embargo, esto puede ser logrado mediante dos estrategia diferentes:

  • Usando genéricos
  • Usando trait objects

Inversión de dependencias con genéricos

Es posible pasar el tipo concreto de la implementación de Feature como un genérico.

Un primer paso sería definir el trait Feature en el módulo feature:

⁠language-rust

mod feature {
⁠ pub trait Feature {
⁠ fn action(&self) -> String;
⁠ }
⁠}
Módulo Feature 

Dentro del módulo my_struct se encontraría el struct MyStruct, que recibiría como genérico un tipo que implementa Feature, lo que podemos ver en impl <T: Feature>....

MyStruct recibirá entonces T como genérico, con lo que será posible tipar el parámetro feature como 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()
⁠ }
⁠ }
⁠}
Módulo MyStruct 

Las diferentes implementaciones de Feature se encontrarían en módulos separados:

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")
⁠ }
⁠ }
⁠}
Primera implementación de Feature

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")
⁠ }
⁠ }
⁠}
Segunda implementación de Feature

Finalmente, el código cliente consumiría esta lógica:


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);
⁠}
Ejecútame en el playground de Rust 

Es importante notar que cuandoMyStruct::new es llamado, está recibiendo el tipo del struct implementando Feature mediante la sintaxis Turbofish.

language-rust

let my_struct =
⁠ MyStruct::<FeatureImplementation01>::new(
⁠ feature_implementation_01_instance
⁠ );
Instanciando MyStruct e injectando una instancia implementando Feature

El compilador podría ser capaz de inferir este tipo en numerosas ocasiones: ésta es una de ellas, si se elimina el código compilará sin problema. Sin embargo no siempre es éste el caso, y el desarrollador estará forzado a marcarlo manualmente en numerosos casos.

Con trait objects

Rust expone numerosos smart pointers para estructuras con tamaño dinámico: la elección más obvia aquí sería Box.
Los cambios requeridos para conseguir compilar tendrán lugar únicamente en el módulo my_struct, así como en el código cliente.

En primer lugar necesitamos eliminar los genéricos de MyStruct; el problema entonces será que feature tiene el tipo del trait Feature, cuyo tamaño es desconocido en tiempo de compilación, puesto que los structs implementando este trait podrían tener tamaños diversos.

Se puede solu⁠cionar este problema envolviendo Feature dentro de un Box<dyn Feature>⁠, diciéndole así al compilador que envié este struct al heap, siendo así redimensionado dinámicamente.

Para ser más explícito con ello Rust fuerza al desarrollador a marcar con el término dyn todos aquellos objetos que usen dispatch dinámico.

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()
⁠ }
⁠ }
⁠}
Tipando feature como Box<dyn Feature>

El código cliente cambiará igualmente, pues requiere que envolvamos la instancia implementando Feature dentro de una instancia de Box:

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);
⁠}
Ejecútamente en el Playground de Rust 

De esta forma el código compila y se ejecuta sin problemas.

Otro caso que se puede encontrar es que MyStruct haga uso de un servicio MyService, que a su vez requerirá contener una instancia de Feature dentro de sí.

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 }
⁠ }
⁠ }
⁠}
MyService, que requiere una instancia de 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()
⁠ }
⁠ }
⁠}
MyStruct haciendo uso —y dependiendo de— MyService

Ahora el compilador devuelve error, puesto que MyService está siendo transferido a self.feature:

language-rust

⁠⁠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
Error when consuming self.feature

Cuando pasamos self.feature a MyService aquel es consumido, y no estará disponible tras esa acción. Una solución podría ser clonarlo con clone(), pero el compilador devolverá un nuevo error:

language-rust

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

El trait Box no implementa Clone. Podría hacerse una implementación ad hoc de Clone para Box<dyn Feature>, pero hay una solución más sencilla para este caso: usar el puntero Rc en lugar de 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()
⁠ }
⁠ }
⁠}
Run in Rust playground 

Como quiera que Rc implementa Clone, puede ser llamado sobre self.feature para obtener una copia del mismo y que sea consumida por otro objeto.

Un último caso que se puede encontrar es el del código asíncrono. En esta situación Rc no será de utilidad, pues no es seguro para subprocesos ni multi-threading.

⁠Los siguientes ejemplos harán uso de Tokio para el runtime asíncrono, así como de async_trait para poder crear traits con métodos también asíncronos.

Por ejemplo, si MyStruct implementase un trait MyStructTrait, el compilador advertirá de que MyStructTrait y Feature deberían implementar 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
⁠ }
⁠ }
⁠}
Código asíncrono usando Rc
language-rust

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`
Error en código asíncrono con Rc

Es posible decirle al compilador que extienda Feature y MyStructTrait con Send + Sync; pero encontraremos otro obstáculo: 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
Error cuando implementamos Send + Sync

El puntero Rc es útil en código síncrono, pero no es seguro para subprocesos: para este caso Rust expone Arc, que, según la documentación: «... 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.»

Usando Arc consumiremos algo más de memoria que con Rc, pero para este caso es una cantidad que fácilmente puede ser ignorada. Por otra parte tiene la ventaja de que implementa Clone, con lo que es posible realizar self.feature.clone() para pasarlo a 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
⁠ }
⁠ }
⁠}
Using Arc, run in Rust Playground 

Conclusión

La conveniencia de los genéricos sobre los trait objects dependerá del contexto. Los genéricos tendrán mejor performance por necesidad, puesto que su tamaño es conocido en tiempo de compilación; los trait objects, por otra parte, serán enviados al heap y usarán dispatch dinámico, lo cual tendrá un mayor consimo de memoria; pero la flexibilidad que ofrece quizás sea beneficiosa en comparación.

Para resumir, cada uno de los punteros: Box, Rc o Arc, nos ofrecerá diferentes acciones:

  • Box: código sincrónico donde la instancia será consumida una sola vez.
  • Rc: código sincrónico donde la instancia debe ser clonada para pasarla a distintas factorías.
  • Arc: código asíncrono que requiere seguridad de subprocesos.

Con estas cuatro opciones —genéricos, Box, Rc y Arc— es posible obtener una inversión de dependencias correcta y aplicar los patrones que pueda requerir nuestro código.

El código completo de los cuatro ejemplos puede encontrarse en  git.antoniodiaz.me/antoniodcorrea/rust-dependency-inversion .