Inheritance, why is bad?

Why iheritance is bad? Iheritance is very useful in object programming, while in Rust decided to aggregate instead of inheritance.

2 Likes
5 Likes

I don't think inheritance is bad. Like any other language feature it can be used inappropriately. It's a wonderful thing when used correctly.

Most of my experience of inheritance and OOP comes from the C++ world. When C++ arrived many took it as a great way to do code reuse. "Hey we can use inheritance to create WidgetB from WidgetA and overriding a few methods. Then we can create WidgetC by inheriting from WidgetB and WidgetD and more overrides....". They then set about using it badly to do just that as much a possible. This leads to a mess which soon becomes unmaintainable.

It's best summed up by a phrase like: 'Use OOP to model "is-a" relationships. Not for code reuse'

To get a good feel for all this watch this great presentation: Jon Kalb “Back to Basics: Object-Oriented Programming” https://www.youtube.com/watch?v=32tDTD9UJCE&t=919s

Now a days hearing a lot about moving away from OOP for performance reasons. With more data centric designs. That is rather outside my experience.

4 Likes

Note that one advantage that structs with traits have over inheritance is that if you have an object of type A for some struct or enum A, the compiler knows that you're dealing with the type A and not some subclass. This means that function calls can just be a direct call to the correct function instead of having to look up which exact subclass we're dealing with and calling the correct version of the function for that subclass.

Among other things, this is what allows iterators to be a zero-cost abstraction. The compiler knows you're dealing with an Map<Filter<std::slice::Iter, Closure1>, Closure2> so it can just inline that directly into the corresponding for loop instead of having dynamic function calls everywhere. Note that this also relies on every closure being its own type.

Basically allowing subclassing is just like making every type a Box<dyn Type> like Java does. You also lose the ability to know what the size of types is.

4 Likes

Inheritance isn't bad, it just is. What you do with it is your problem.

I started writing a really long post here but it was far too long-winded for your simple question. Maybe I'll make a blog some day. Basically, Rust provides three features -- enums, traits, and generics -- that collectively solve 95-99% of the problems properly addressed by inheritance in other languages. The remaining 1-5% can be solved with macros or are just really hard to do in Rust. Which is symmetric with another 1-5% of things that are easy to do in Rust but are really difficult to do in a language that has inheritance but lacks one of these other features.

I believe Rust already has all the major capabilities it needs¹. Adding inheritance would be redundant at this point.

Of course, some experience designing with different abstractions helps. If you have a particular design that uses inheritance, and you're stumped how to solve the same problem in Rust, I'd encourage you to post it here; there are probably ways of approaching the problem that you haven't considered.

--

¹ Wait, no, I forgot about GATs. And const generics. But after that we're pretty much good.

5 Likes

If used properly, it's fine. The problem is that it's harder to use properly than it seems.

If you want to make a subclass that doesn't have all of the behaviors and properties of the base class, you shouldn't be using inheritance:

In some OOP-heavy languages you have the inheritance hammer and make very object extend Nail, but not everything can be modeled as a class hierarchy no matter how hard you try:

8 Likes

See also the 2nd Rust koan.

5 Likes

Maybe better soltution than inheritance would be mixins known from Magik based on Smalltalk?

1 Like

Minor note: It's not just for performance reasons -- depending on what you do, it may lead to more maintainable code as well. This video perfectly summarizes what often happens to me with OOP (the model breaks down, adding weird proxy objects just to try to keep the original encapsulation more-or-less intact (but not really)), and how ECS or ECS-like thinking helped to allow me to focus on application logic (in certain types of applications) rather than fighting the language/paradigm: https://www.youtube.com/watch?v=U03XXzcThGU

Another way to view data-oriented design is that it's about understanding that one's software is not decoupled from the hardware; specifically that one should be writing programs for the hardware. As it happens, increased performance is a direct result of designing applications "for the hardware".

There are situations where OOP is the right choice, but even at university level we were taught, implicitly, that OOP should be the default way of thinking, which I think is wrong.

6 Likes

Also check this gloriously forceful discussion about Object Oriented Programming and use of inheritance: Yegor Bugayenko - What's Wrong with Object-Oriented Programming? Yegor Bugayenko - What's Wrong with Object-Oriented Programming? - YouTube

2 Likes

Awesome video! I love it.

To add to it, isn't using libraries at all at odds with the idea of encapsulation?

What about inheritance without overriding? Simply only allow subclassing a type by adding fields and/or methods? This would not violate the substitution principle and would not require dynamic dispatch, but would enable programmers to model "is-a" relationships more easily.

If you allow adding new fields, you lose the ability to know the size of a type. If you can't change its size, you don't really have subclasses and everything is the original type, so adding a new method would be more akin to adding a method to the original type than the new type.

1 Like

Besides, what's the point of subclassing if you're only adding new stuff? It acts identically to non-subclassed instances in cases where you don't know about the subclass, and when you do know about it, it's mostly the same as a pattern we already have where you make a new type and put a value of the "subclass" as a field in the new type, then passing on function calls to it.

Perhaps you would have some sort of instanceof functionality you could perform on the base type?

Why? You can use generics to achieve polymorphism, and there all types are concrete.

No, the subtype is a new type, with all of the (exact) properties of the inherited type, and more. The utility of this is that you can model commonalities between multiple types "more easily", compared to the current way, which would probably be adding a field of the common type, and manually adding all of its methods and forwarding calls to the wrapped object.
For example, say we have a type representing common properties of multiple objects in our domain:

struct A {
    a: f32
}
impl A {
    pub get_a(self) -> f32 {
        self.a
    }
}

To model the other types, which are more specific than A, we would currently wrap A as a field in those types:

struct B {
    wrapped_A: A,
    b: u32
}
impl B {
    pub fn get_a(self) -> f32 {
        self.wrapped_A.get_a()
    }
    pub fn get_b(self) -> u32 {
        self.b
    }
}

I mean, of course this works, but now there is more code to maintain, and when A changes, B also has to be changed.
I think it would be useful to "inherit" a struct and its methods, effectively doing the above automatically:

struct B : A { // B contains all data in A and provides the same methods.
    b: u32
}
impl B { // additional methods
    pub fn get_b(self) -> u32 {
        self.b
    }
}

Now B could nicely be thought of as an extension of A, although it is actually its own type.

fn for_As(a: A); // does not work with type B!
fn for_Bs(b: B); // does not work with type A!

To achieve this, we would need generic polymorphism:

trait Common {
    fn common();
}
impl Common for A {
    fn common() {}
}
fn for_Commons<C: Common>(a: C); // does work with A and B! sizeof is known.

All this is is syntactic sugar, I know that. But aren't all higher level languages just syntactic sugar for assembly?

... at the cost of losing the subtype relationship. You can't have both.

If Dog is a subtype of Animal, you should be able to do this:

let animals: Vec<Animal> = vec![];
animals.push(Dog::new());

This is not expressible with generics.

I mean, that's the pattern I was talking about. Personally I have yet to find myself wanting this kind of type extension feature, and I don't think it sounds that useful considering it's a rather large addition to the language. :woman_shrugging:

If you want to add new functionality to a type, there's also the TypeExt trait pattern.

Should you? I don't think this is obligatory for inheritance, this is dynamic polymorphism. Inheritance is only the inheritance of properties.

Of course as a programmer, you would think, "Why can't I treat Dogs just like Animals?", but of course, Dogs are not only Animals, but more, so they can't be put into the same box as any other Animal. However, if Animal was like a trait, we could refer to a Box which contains at least an Animal with a trait object: Box<dyn Animal>. If this was also allowed for types, dynamic polymorphism could also be modelled and subclassing would be useful here.

Do you have a reference to this? I am unfamiliar with that pattern.

The most famous example is the Itertools trait from the itertools crate. It provides a bunch of functions with default implementations and has a blanket impl for all iterators, so if you use the trait, you can call its methods on any iterator.

It's also seen rather often in the async ecosystem, e.g. the futures crate has several examples: SinkExt, SpawnExt, FutureExt, AsyncReadExt, AsyncSeekExt, TryFutureExt, TryStreamExt and AsyncWriteExt.

2 Likes