Inversión de dependencias en Rust
14 de noviembre de 2022
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;
}
}
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()
}
}
}
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")
}
}
}
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")
}
}
}
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);
}
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
);
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 solucionar 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()
}
}
}
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);
}
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 }
}
}
}
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()
}
}
}
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
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()
}
}
}
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
}
}
}
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`
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
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
}
}
}
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 .