Would you consider this style of "inheritance" unidiomatic?

Consider finding yourself in a scenario where you want to share some data and methods with multiple structs. One solution I've seen looks something like this:

trait Common {
    fn do_thing1(&self) -> u32;
    fn do_thing2(&self);
}

struct CommonData<T> {
    a: u32,
    b: bool
    data: T
}

impl<T: Common> CommonData<T> {
    fn do_thing_with_thing(&mut self) {
        self.a = self.data.do_thing1();
    }
}

struct A;
impl Common for A { /* ... */ }
impl CommonData<A> {
    fn do_a_specific_thing(&self) { ... }
}

struct B;
impl Common for B { /* ... */ }
impl CommonData<B> {
    fn do_b_specific_thing(&self) { ... }
}

This is nice because every Common has access to methods on Common and CommonData. However, I've also seen this described as unidiomatic. What would you consider unidiomatic about it? What pitfalls does this pattern have?

One big drawback of this is that you can only have one set of "common" fields for each struct (A and B).

Is there some reason that simple embedding (no need to call it inheritance) wouldn't work instead? This is more idiomatic I believe.

struct Common {
    a: u32,
    b: bool
}

impl Common {
    fn do_thing1(&self) -> u32 { todo!() }

    fn do_thing2(&self) -> u32 { todo!() }

    fn do_thing_with_thing(&mut self) {
        self.a = self.do_thing1();
    }
}

struct A {
    common: Common,
    // more fields...
}

impl A {
    fn do_a_specific_thing(&self) { todo!() }
}

struct B {
    common: Common,
    // more fields...
}

impl B {
    fn do_b_specific_thing(&self) { todo!() }
}

playground


If callers that don't have access to the A::common and B::common need to call Common methods directly, you can add

    fn common(&self) -> &Common {
        &self.common
    }

to A and B. Or A and B can provide methods that forward, when they need more control over what callers can do.

1 Like

It is static polymorphism, not inheritance. And it's very common, I wouldn't call it unidiomatic (unlike runtime polymorphism).

1 Like

I want to clarify I don't have an actual issue I need to solve with this, I just saw it once and thought it'd be interesting to discuss.

One issue with embedding is I can't have the implementations of do_thing1 and do_thing2 use things from data.

FWIW, "embedding" is usually called "composition" in this context.

In addition to:

fn common(&self) -> &Common

The AsRef trait can do something remarkable:

Make the compiler call the method (common() or as_ref()) for you! The downside here is that it doesn't work as well when you have multiple Common fields. (That sounds familiar, lol!)

This breaks encapsulation, though. That's widely regarded to be a bad idea. Common should only do common things. If it's able to reach into its parent, then it isn't doing common things.

I had a conversation with another developer recently and saw the same kind of expectation. I'm going to say it: implementation inheritance is never what you want. Code reuse through interface inheritance (traits) is not perfect by any means, but it's capable. Macros can also help fill in some of the gaps. Ultimately, I believe that designing your code with loose coupling is (as shown time and time again) a very robust way to write and compose reusable pieces of software.

A real example that is cited often is the "I/O-Free" AKA "Sans-I/O" [1] technique for protocol implementations. The implementation is designed in a way that decouples it entirely from I/O. Which removes all concerns around threading model details, platform details, and interface details. It's trivial by design to wire up a sans-IO protocol to work over a serial port, or UDP, or microwave radio. The protocol doesn't concern itself with the transport layer, and is thus free to compose with any of them.


  1. See Writing I/O-Free (Sans-I/O) Protocol Implementations — Sans I/O 1.0.0 documentation for a general description, and sans-IO: The secret to effective Rust for network services for a Rust-specific tutorial. â†Šī¸Ž

In the code I posted, do_thing1 and do_thing2 are methods of Common, so they have access to the what you called the data field. This is the same restriction as in the code you posted.

If you also want generic methods that are implemented differently in A and B, and have access to the Common struct's fields as well as the outer struct's fields, you simply define a trait and implement it for A and B. This trait can be completely decoupled from Common.

You can use any combination of these approaches for multiple embedded/composed structs and multiple traits, without thinking about a "super class" or inheritance of any kind. Taking advantage of this flexibility is idiomatic in Rust. It can be difficult to stop thinking about inheritance per se, but you get the hang of it after a while.