Implications of single large trait vs many traits, grouped by associated type

heya, I'm designing an API that requires users to implement approximately 12 "big" functions. Some of these functions use common types, e.g. the return type of one function may be a parameter to another.

Two questions:

  • Are there runtime performance, size, or other implications between the following designs?
  • Intuitively, which design would be more understandable / maintainable from an API consumer point of view?
Design 1: All in one
// Design 1: All in one
#[async_trait]
pub trait ItemSpec {
    type Error: std::error::Error;
    type State: Serialize + Deserialize;
    type Diff: Serialize + Deserialize;
    type Data<'d>: Data<'d>;

    async fn fn_1(Self::Data) -> Result<Self::State, Self::Error>;
    async fn fn_2(Self::Data) -> Result<Self::State, Self::Error>;
    async fn fn_3(Self::State, Self::State) -> Result<Self::Diff, Self::Error>;
    async fn fn_4(Self::State, Self::State, Self::Diff) -> Result<bool, Self::Error>;
    async fn fn_5(Self::State, Self::State, Self::Diff) -> Result<(), Self::Error>;
    // ..
    async fn fn_12(Self::State, Self::State, Self::Diff) -> Result<(), Self::Error>;
}
Design 2: Split into separate traits (wall of text)
pub trait ItemSpec {
    type Error: std::error::Error;
    type State: Serialize + Deserialize;
    type Diff: Serialize + Deserialize;
    type Data<'d>: Data<'d>;

    type SubTrait0: SubTrait0<Error = Self::Error, Output = Self::State>;
    type SubTrait1: SubTrait1<Error = Self::Error, State = Self::State, Diff = Self::Diff>;
    type SubTrait2: SubTrait2<Error = Self::Error, State = Self::State, Diff = Self::Diff>;
}

#[async_trait]
pub trait SubTrait0 {
    type Error: std::error::Error;
    type Output;
    type Data<'d>: Data<'d>;

    async fn fn_1(Self::Data) -> Result<Self::Output, Self::Error>;
    async fn fn_2(Self::Data) -> Result<Self::Output, Self::Error>;
}

#[async_trait]
pub trait SubTrait1 {
    type Error: std::error::Error;
    type State: Serialize + Deserialize;
    type Diff: Serialize + Deserialize;

    async fn fn_3(Self::State, Self::State) -> Result<Self::Diff, Self::Error>;
    async fn fn_4(Self::State, Self::State, Self::Diff) -> Result<bool, Self::Error>;
    async fn fn_5(Self::State, Self::State, Self::Diff) -> Result<(), Self::Error>;
}

#[async_trait]
pub trait SubTrait2 {
    type Error: std::error::Error;
    type State: Serialize + Deserialize;
    type Diff: Serialize + Deserialize;

    async fn fn_6(Self::State, Self::State) -> Result<Self::Diff, Self::Error>;
    async fn fn_7(Self::State, Self::State, Self::Diff) -> Result<bool, Self::Error>;
    async fn fn_8(Self::State, Self::State, Self::Diff) -> Result<(), Self::Error>;
}

I don't think the proposed solutions would have runtime differences as long as the specific function bodies remain equal.
There might be a difference when it comes to trait objects, but I'm not sure how they are represented at the end and if it makes a difference in this represantation (since the function bodies are "big" I would however assume this to be negligible).

As for the design:
I see a few pros and cons for both.
On the one hand a single trait is "simpler", either it is implemented or not.
If your library could however already be used if only some parts are implemented the second approach might be useful for some.

1 Like

This reminds me of the Go Proverb,

The bigger the interface, the weaker the abstraction.

I know Rust isn't Go, but the advice is useful regardless.

If I need to provide a dozen methods with half a dozen associated types, that's going to be a big API surface and a pain to implement/maintain regardless of whether it's done using one big trait or multiple smaller ones. You also lose a lot of the abstraction because ItemSpec expects so much behaviour from its implementors.

However... If I were to pick an option I'd go down the "all in one" route.

If everything is split into multiple smaller traits and they are grouped with super-trait and blanket implementation, you'll still need to implement all the traits in order to do anything useful. You're effectively still implementing one big trait, except it's now spread across multiple impl blocks.

The power of small traits is that they are easy to implement and compose nicely. For example, if my parsing function only needs to read bytes from some source and move the cursor back/forth, so I'll accept some R: std::io::Read + std::io::Seek argument and not also force the user to implement std::io::Write or my_crate::Opener or whatever.

1 Like