With composition over inheritance in Rust, how does one implement shared state to go with shared behaviour?

With classic (Java, C#, and C++ to some extent) OOP, one can model shared behavior and data between classes by making those classes inherit from some common (absctract) base class with the appropriate methods and data. By making the methods suitably granular, the base class can then make small tweaks to the shared behavior without causing code duplication.

In Rust, it is possible to implement shared behavior via traits with default method implementations, but this prevents any shared data that goes without that shared behavior in any reasonable way that I can think of. Additionally, this is problematic if one wants multiple default implementations of a single trait.

As such, I'm asking, in Rust, how can one have a trait with multiple default implementations and state that goes with each implementation? In classic OOP, I'd have one abstract base class for the trait, then abstract subclasses for each default implementation (with their own member variables), and then various concrete and final implementation classes. The default implementations could also be non-abstract and also non-final to act as concrete implementations in their own right.

5 Likes

It's been a long time since I thought about heavily inheritance-based OOP; can you find a concrete example (possibly constructed) that demonstrates the pattern you're trying to replicate?

Usually, if you have an implementation and want to be able to swap out some details of its behavior, you'll make that a generic struct that holds an object that defines the swappable behaviors. A good example of this is the Iterator trait: most of its methods return adaptors structs that contain the source iterator and alter its behavior.

1 Like

Here's an example (from a Minecraft clone) of where one may want inheritance. Let me know if anything isn't clear from this UML diagram.

This seems like it should give a workablesolution.... but it just feels a lot messier than a nice OOP system (like in the UML diagram). It's also a bit problematic for many-layer hierarchies, such as BlockType extends InventoryType extends Registrable, where each one of those adds partially-overridden implementation.

1 Like

Well, as you asked.

WTF is that?

Is that how the system was designed before it was coded?

Or is it a rendering of what exists after the code was hacked into place?

7 Likes

UML diagrams are essentially a (relatively) standard way to lay out all of the relationships (is-a, via a normal arrow; has-a, via a diamond arrow; etc.) between classes in an OOP system. They can also encode and visualize things like public methods. I've found it to be helpful to plan out my heavily OOP code in advance using them, since they're easier to iterate one than real code, and provide a nice visualization. (I'm slightly over-simplifying, but there's enough detail for the purposes of understanding that diagram, hopefully. More detail is here.)

EDIT: To address your edit, it can be both used before code is created to aid in planning clean code (most books on writing good code recommend some sort of planning stage, and, after trying it, I strongly agree), and can be automatically produced from existing code.

2 Likes

I know what UML diagrams are about.

My difficulty with such diagrams is:

  1. I cannot fathom how program control flow goes on in the diagram.

  2. I cannot fathom how data flows through the system.

  3. I have no idea how one could chop that up for parallel execution.

Basically, if I was hired to implement code to such a design spec. I would have to quit immediately.

You avoided the question. Is that diagram part of a spec to write code to, or is it the mess that results when documenting what has been coded?

5 Likes

You avoided the question.

I didn't mean to, sorry. It's part of a spec to code to. I made it for myself, however, and I have other information in my head with control flow, data flow, parallelization, etc. It wasn't originally meant to be complete ­— just enough to overcome the fact that my memory isn't perfect.

As for the parts that are unclear:

  • Program control flow is in the engine class, which has a main game loop. It then calls into the objects that it has (marked in the diagram) as needed, which then call into theirs, etc. polymorphically. Note the actor model, to allow different parts of the code to send and receive messages.
  • Concurrency happens essentially via an actor model. Other currency is implemented as implementation details of each class — they provide a non-concurrent interface (except for actor classes)
  • The main data model is that everything owns the data that is relevant to itself, and then other code simply references that as-needed via getters and setters. The exception to this is the actor model, which allows data to be moved from one object to another in some cases.

This is a hard one to answer because the design is so interconnected it’s hard to pick out a small piece to talk about. The general shape is certainly compatible with Rust, so the question is then how to implement the individual pieces ergonomically. Some thoughts on how I might go about this design in Rust:

  • Message can be an enum with a variant for each message type: the routing logic can be implemented for the enum as a whole; the receivers can get the specific payload types they care about out and ignore the others.

  • MessageReceiver and Tickable are probably traits with only one or two methods and not much in the way of shared behavior: every implementor will want to to its own thing

  • TickableBlock and similar product interfaces aren’t used very much; usually, you’ll just require objects that implement both traits: T: Tickable + Block. They’re required sometimes to generate trait objects, in which case you usually provide a blanket implementation, which implements the combined trait for all applicable objects:

trait TickableBlock: Tickable + Block {}
impl<T: Tickable + Block> TickableBlock for T {}
  • Generic trait implementations are also useful for dispatching to contained objects; you can make an ItemStack tickable if the items it contains are tickable like this:
impl<T> Tickable for ItemStack<T> where T:Tickable { ... }

Is there a particular pattern in there that you’re having trouble translating to Rust?

4 Likes

In general, this issue doesn't really come up in Rust because the whole idiomatic programming style isn't really about shared state or other classic OOP design principles.

As for a potential, quite easy solution if you really really need this, you can include a method in a trait that returns a reference to the inner type (what would be the "base class" in OOP), and call through it in other methods that need it.

If you are feeling particularly lazy, you can also write a #[derive(…)] macro for this purpose.

struct Base {
    …fields…
}

struct Derived {
    base: Base,
    other_field: u32,
}

trait Superable { // that's a superbly bad name
    fn super(&self) -> &Base;
    fn super_mut(&mut self) -> &mut Base;

    fn some_method(&self) -> u32 {
        // now you can use methods of Base from here
        self.super().other_method() + 1
    }
}

impl Superable for Derived {
    fn super(&self) -> &Base {
        &self.base
    }

    fn super_mut(&mut self) -> &mut Base {
        &mut self.base
    }
} 

10 Likes

"Composition over inheritance" does not mean "replace inheritance with composition". Composition alone is less powerful than inheritance, because inheritance is composition plus shared behavior plus some other stuff. If all you had in Rust was everything that's in Java, but no inheritance, Rust would be a less capable language.

The point of composition over inheritance (in my interpretation) is that because composition is less powerful, you can use it in more places, and combine it with other tools to make more flexible abstractions that are better suited to any given problem. The number of problems that actually call for exactly inheritance is... well, surprisingly small. So...

In general, you should not expect to take a class-based design with pervasive use of inheritance and just replace all the inheritance with something else. It may work, but it will not be as coherent as if you start from your system requirements and sketch up a new design without using inheritance. You may even find that the use of inheritance was gratuitous to begin with and the design is cleaner, less coupled and faster without it.

13 Likes

With my tendency towards picking nits I would be happier if that were "less expressive language" or some such. As far as I know all languages we might discuss here are "Turing Complete" and hence equally capable.

It would be great if you could site even one such problem. Not that I disagree or anything. It's just that I have been pondering that question ever since the arrival of C++, Java, C# and the whole OOP tsunami a couple of decades ago.

P.S. That is "less expressive" in the same way that Rust does not have 'goto'. Unless demonstrated otherwise I consider "class" and all that OOP mechanics redundant and the lack of it a good thing. Like the lack of goto.

6 Likes

I think I have just had a revelation!

I have possibly realized why C++, Java, C# and that style of OOP tsunami going on for decades has upset my mind.

Let's think about that word "inheritance". As used in C++ and other languages. It is a total misnomer, the abstraction is wrong.

Consider this:

I am led to believe that I am what I am as the result of inheriting characteristics from my parents through the mechanism of DNA and all that. In turn they were what they were by such inheritance from my grandparents, which in turn ... going back millions (billions) of years inherited from some simple single celled thing or whatever, lost in the dawn of life.

So far so good. It all sounds plausible to me and there is evidence around to suggest the validity of the inheritance idea.

Now it occurs to me there is something odd about this. You see my parents are not alive anymore. In fact nothing I inherited from since the dawn of time exists anymore. But here I am. Still functioning as people might expect. My being does not depend on my ancestors existence.

Not only the instances of my inheritance, my parents, grandparents....no longer exist. But also the blue prints, the "classes", of their DNA is no more. Except in as much as it exists in me.

Where am I going with all this?

Well, imagine how well a C++ program would function if you killed all the ancestors of a class? Delete all that code and data from the base class up. Not very well!

My conclusion: "inheritance" in C++ and such is a misnomer. Those children at the end of the chain can never live independently, free of their ancestors like I do.

Rather what we have there is an ever growing chain of dependency. Which seems like an odd thing to want, software architect gurus are always imploring us to reduce dependencies between our components.

Sorry for the long ramble there. In part two I might start on what I think about that "class" idea. Which apparently is also inspired by some biological notion of taxonomy ....

13 Likes

To take the analogy a bit further, you're composed of different bits of code from your parents. For example, the functionality from two pieces of code called OCA2 and HERC2 determines the implementation of the melanocytes in your irises, and contributes to whether your eyes appear blue or brown.

1 Like

Wikipedia is quite good on the benefits of composition over inheritance:

"As such, I'm asking, in Rust, how can one have a trait with multiple default implementations and state that goes with each implementation? "

This would run into all the problems of inheritance I think. I find the whole subject a little confusing, but inheritance generally turns out to be not what you really want, rather interfaces ( or traits ) and generics are the way to deal with commonality in a sound way.

Perhaps a concrete example of where you think inheritance would be useful would help, then it should be possible to explain how you would approach the example without inheritance.

2 Likes

Perhaps Rust Koan #2 will provide some insight into why Rust prefers a more flexible alternative than inheritance.

8 Likes

So there is the thing for me. What I want is components that I can mix and match to build a system. Call it 'compose' if you like.

Those components had better be small, self contained items that can be tested in isolation before they are composed into anything.

I like the wikipedia statement there:

...accelerator pedal and a steering wheel share very few common traits, yet both are vital components in a car

Basically they are UI elements that read from whatever input device and produce an output, a 'signal', indicating the drivers desire for a change in direction or engine power.

They are components of a car but totally independent of any car. They have properties suited to drivers not cars, like wheel diameter, pedal stiffness, etc.

The question then is, how does one 'compose' that steering wheel or accelerator pedal into a car? How does that signal get out? How are they bolted into place?

Enter traits... with traits you can connect the output to whatever input, you can add the mounting brackets and such.

4 Likes

Having done OO for many years, the lack of inheritance in Rust was one of the first design decisions I questioned.
Having seen OO done wrong (by others) for so many years, I am giving a chance to Rust to see if it actually got it right when it comes to the Palette of concepts we can apply to build efficient yet composable and maintanable software, while modeling the concepts of our domain in a natural way.

It seems so far the discussion went out of track because not everyone is on the same page. First we need to put forward that each "OO" programming language brings OO in a slight different way. I remember really long ago reading a book with a Platypus on the cover, and examples on many languages which helped me organize the concepts in my head without being tied to a specific programming language (I guess it was the first edition of this one - https://www.amazon.com/Introduction-Object-Oriented-Programming-3rd/dp/0201760312).
That said, I can see Rust does bring many building blocks of OO, but present them different then languages mentioned by OP. Traits are a powerful concept, though basically we need to apply it on a finer granularity than classes on tradition OOP. And then there is the stricter separation of data (struct) and behavior (trait) . I see encapsulation even more strict than other languages (and that is generally good).

When favoring composition over inheritance, that is typically just adding the same struct member in all the points you need, instead of having the data coming from a common superclass. Eventually you need to repeat some delegation methods. That can sound more verbose, but given the other complications of inheritance it can be a reasonable design and it is generally better than forcing unrelated concepts into a not so smooth hierarchy. So if you want to make shared data, do not think about traits. Think about a smaller struct holding this shared data. (in other languages you can also apply this - smaller classes instead of a heavy hierarchy).

Hmmm . What about each default implementation is separate trait?

We may need to differentiate where we want inheritance from just having polymorphic behavior. Polymorphic behavior can also be achieved on compile-time and in fact, many C++ programmers favor reusing code with templates, reaching compile-time polymorphism instead of run-time polymorphism. Aside from "composition over inheritance", that choice in C++ is to avoid the cost of virtual function calls.
As Rust has a comprehensible generics system, generics could be used to achieve polymorphism and reusing code. If also need to restrict the polymorphism, trait bounds are there.
While objects and inheritance is about the nouns and their relationship, my 2 cents on traits is that they fit best for the adjectives of your domain. So when you name things like "iTickable" or "Registrable", those would be traits for me. A "TickableInventoriedBlock" probably has a better name you can use and instead implement the "Tickable" and "Inventoried" traits.
In other places where hierarchy is used like Message, the variety of names lead me to think that those are loosely related types, that are just lumped together to able to pass a "Message" to a "MessageReceiver". If that is the situation I would prefer a Rust enum instead of a hiearchy (as some other poster already mentioned). That would make the treatment on "MessageReceiver" with pattern matching easier than the OO version.
So in summary, I think that with combination of traits, enums and generics we definitely cover many of the reasons we do hierarchies on other languages. Rust seems to break down the OO concepts into smaller blocks and force you to use smaller blocks. That sounds to me a good thing and lets see if I never hit the wall with that (still learning Rust here)

12 Likes

If you want to replicate Java & Co. in Rust, you can use Rc<RefCell<Box<dyn MyTrait>>>.

If you have a type Aaa and you want type Bbb to inherit from Aaa, then you need a trait Ccc, store Aaa in Bbb, e.g. via struct Bbb(Aaa);. Then you impl Ccc for Bbb, impl Ccc for Aaa and use something like delegateÂą to make your life easier. If you need to check for more than a single trait, this gets much more complicated. I hope you don't.

TBH, I don't recommend going this far, because then you might just be better off using OOP languages, that offer inheritance. If composition is too simple/annoying, delegate is usually enough to help with the annoyances coupled with composition.

Âą There is another crate, that simply delegates all methods implicitly, but I never bookmarked it, so I can't link it. Might just be called "inherit" or "inheritance".

1 Like

Do you mean ambassador?

2 Likes

matklad recently wrote a really good article which touches on almost exactly this topic.

His suggestion was to just drop the "abstraction" and use the type directly. If you have some shared logic and state, use that struct directly.

For the vast majority of cases you will be binding to the shared type directly, anyway, so putting a layer of indirection between your code and the shared data/behaviour won't actually give you anything.

It's an alternative way of writing code but considering Rust isn't your standard OOP language, maybe standard OOP approaches are better suited to the language?

3 Likes