Dyn Trait vs generics

Hi,
I'm implementinga simple web api with rust to get away from the Spring world.

Background Info why I'm asking this specifically but might not be actually relevant to the answer.

I do want to use mutiple layers within the api like:
HTTP -> ApplicationService -> BusinessService -> Persistence

In the Spring world I would create multiple such services and auto inject the dependencies at startup.

I figured I could do without such service instances in rust by just using public methods decoupled by modules:

mod Persistence {}
mod Business {
//call persistence
}
mod Application {
// call business
}

That way I would loose a way to unit test these modules alone.

Because of that I thought I would create Traits for each of these modules which could be mocked. <- that works fine with a few adjustments

Let's say I have a Trait

pub trait Persistence {
// definitions
}

and a struct that has a member of a impl of the Persistence Trait i figured two ways of doing this:

pub struct Service<Pers: Persistence + Sync + Send + OtherTraits> {
    repo: Pers
}

pub struct Service {
   repo: dyn Persistence + Sync + Send + OtherTraits
}

which one would be the better fit? I would have to put the generic on every impl I do for the struct which is quite verbose to me.

There’s also the third option of using some fully generic field

pub struct Service<Pers> {
    repo: Pers
}

and only introducing the relevant Pers: Persistence or Pers: OtherTrait bounds for impls or fns where they’re actually used/needed.

Another consideration is that trait objects (e.g. dyn SomeTrait) have a small overhead by using vtables. Also, you’ll probably need to introduce some indirection, e.g. Box<dyn Persistence + Sync + Send> for the compiler to be happy, and you can’t just add OtherTraits like that; instead you’d have to bundle multiple traits up into a new one using supertraits, if more than one trait (not counting marker traits such as Sync or Send) is needed.


I don’t know which one is the better fit. I’d personally probably use try using the generics if that works out; trait object’s main strength lies in their ability to abstract over different types to allow for things like heterogeneous collections.

Or what I’m trying to say is: There are things that can only be done using trait objects, and if you don’t require these features, i.e. if not using trait objects works out for you, then why use them and their overhead. On the other hand, there may very well be some value in simplifying code by needing fewer generic arguments and trait bounds.


Another thing that comes to mind if you’re considering using generic arguments with a single common type in mind to use for them in ordinary circumstances, i.e. in cases that don’t require mock objects for testing or some dependency-injection-style use case for other reasons: You can provide default arguments, i.e.

pub struct DefaultService<Pers = DefaultPersistence> {
    repo: Pers
}

then you can use Service without arguments in production. Or even for initial implementation of Service’s API itself, and later refactor to be fully generic.

(The naming subject to change.. an alternative is to name the structs Service, Persistence, etc.. and the traits ServiceTrait, PersistenceTraits, etc.. or whatever; I don’t know if there are any naming conventions around in Rust for such things.)

6 Likes

There are differences in implementation. If your reference point is Java, where all calls are indirected through pointers until the JIT can prove to itself that the call site is constant, then the implementation differences are very small - probably irrelevantly so. Mainly: given a specialization of Service<P: Pers> with a field of type P, references to functions that use that field will compile down to direct function calls, while given Service with a dyn Pers field, any calls to functions using that field will be indirected through a vtable whose contents depend on what is assigned to the field at that point in the program.

There are also differences in semantics. The parameterized Service<P> type creates a family of independent types, each with a different specialization on P. Those types have very little to do with one another - you can't, for example, create a vector of Service<?> the way you can in Java, but if you know what type is bound to P, you can exploit that knowledge in your program. On the other hand, the Service type with a pointer to an object with the Persistence type defines a single type. You can put heterogenous collections of services together, but you can't exploit type-specific knowledge - or at least, not through that struct.

Keep in mind that the main motivation for this kind of abstraction in Java is to permit the insertion of alternative implementations, either for testing, or in order for frameworks to insert themselves between the components of your system. (Spring, for example, exploits this for remoting and transaction management.) Those kinds of features tend to be handled differently in Rust. In a vacuum, I would tend to use the former - with the Service type parameterized over the repository type - if I needed that level of abstraction, or I might remove the generic parameter entirely and have the service type directly contain a repository struct until the need arises. However, a better answer is probably to look at the programs you're trying to write, which include both your final application and any tests you plan on writing, and decide from there where you need abstractions and where you don't.

1 Like

Thanks for your input. That made it clearer and I will stick to generics then :slight_smile:

@derspiny yeah Spring is heavy on the magic. That is exactly the reason I do not like it that much because everytime the magic does not work there is lots of debugging. Somehow my team caught these edge cases a lot lately :roll_eyes:

I have yet to get my head around how to structure the parts of my project in a rusty way. If it weren't for the testing it would be straight forward. But yeah, tests aren't cool until they save you :man_shrugging:

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.