Inheritance, why is bad?

As I see it, encapsulation in the broad sense is not about keeping data together with the code that operates on it. That is more specifically called data encapsulation, and it is a very restrictive and inexpressive model of privacy adopted by OO languages like C++ and Java.

Rather, encapsulation is more generally about the ability to define a set of invariants and protect them with privacy mechanisms... the purpose being that downstream code should be allowed to assume that all of the relavant functions are basically black boxes that uphold these invariants.

So the user of a library may see:

mod stack {
    /// A stack of even numbers.
    pub struct EvenStack(...);

    impl EvenStack {
        pub fn new() -> EvenStack;
    }

    /// Place a number at the end of the stack if it is even,
    /// otherwise do nothing.  Returns `true` when the item is
    /// successfully added.
    pub fn try_push(stack: &mut EvenStack, x: i64) -> bool;

    /// Remove the number at the end of the stack.
    /// The result, if present, will always be even.
    pub fn pop(stack: &mut EvenStack) -> Option<i64>;
}

and should be able to trust the documentation and have reasonable expectations about the behavior of these functions, without knowing their implementation.

Meanwhile, the author sees:

mod stack {
    pub struct EvenStack(Vec<i64>);

    impl EvenStack {
        pub fn new() -> EvenStack {
            EvenStack(vec![])
        }
    }

    pub fn try_push(stack: &mut EvenStack, x: i64) -> bool {
        if x % 2 == 0 {
            stack.0.push(x);
            true
        } else {
            false
        }
    }

    pub fn pop(stack: &mut EvenStack) -> Option<i64> {
        stack.0.pop(x)
    }
}

and ought to be able to argue that the documented invariants are properly upheld without needing to know anything about the implementation of lower-level things like Vec::push or Vec::pop or a % b, because those things in turn already have documented behavior that is encapsulated somewhere in the standard library or the compiler.

The "boundary" of encapsulation is how far we need to look in order to see all code that is capable of violating the invariants. Here, all usage of stack.0 is clearly confined to mod stack, and the implementation of these functions protect the invariants, so we may treat everything as black boxes once we are beyond that boundary.

Suppose we instead had something like:

mod stack {
    ... contents from before ...

    pub(super) fn raw_push(stack: &mut EvenStack, x: i64) {
        stack.0.push(x);
    }
}

Now that we've added this pub(super) function that is capable of violating the invariant that numbers are even, the boundary of encapsulation has grown to include whatever module contains mod stack. Perhaps you might see something like:

mod containing_module {
    mod stack {
        ...
    }

    pub fn foo(stack: &mut stack::EvenStack) {
        stack::raw_push(stack, 1);
        assert_eq!(stack::pop(stack), 1);
    }
}

In this code, we can see that stack::pop is called and it returns a value that is not even, despite it clearly saying otherwise in its (user-facing) documentation! That's because, thanks to the existence and visibility of raw_push, code inside containing_module is now inside the encapsulation boundary of that invariant, and thus cannot treat the functions inside mod stack merely as black boxes. (code outside of containing_module can still view them as black boxes, because we can easily verify that containing_module itself upholds the invariant)


You will notice that this definition of encapsulation is descriptive rather than prescriptive. I.e. it is not a principle to be followed, but rather, a question to be answered: Is invariant Y encapsulated in module X?

The actual principle to be followed here is that modules should ideally be as small as necessary, and expose as few public items as they need to, so that this question becomes easier to answer! Generally, at the very least one should be able to assume that the crate is the encapsulation boundary of all invariants documented inside that crate, and if it isn't, that should be considered a bug.

(but there are exceptional circumstances (especially involving macros) where sometimes the boundary of encapsulation must lie beyond a crate; that's why #[doc(hidden)] exists!)

7 Likes

But still your example demonstrates data encapsulation. It looks very much like what one would see in a class in C++.

Yes. That is a rather abstract, high-level view of things. By contrast to my down to Earth mechanism view above.

The problem then is that in C++ most of the invariants one needs to rely on are not specified as part of the language. This has lead many to ill-advised use of inheritance as a form of code reuse.

If ClassD is derived from ClassC in order to implement some similar but tweaked functionality by method over ride. Which in turn is derived from ClassB, which in turn is derived from ClassA. Then ClassD is now dependent on the invariance of ClassC, ClassB and ClassA. When one wants to make a change to any class up the hierarchy in the future one had better be sure that is not going to break any class lower down the chain. In that sense one has broken all encapsulation by making everything interdependent on everything else.

Of course C++ does not dictate you program like that. It just provides the mechanism to allow one to do so. One can also use that mechanism to allow for the encapsulation you are talking about as well.

I had not really thought about subtyping yet, because Rust has a strong type system and does not even allow coercion to smaller number types, so I would not expect it to just coerce a subtype to its inherited type. The way this would not violate LSP just in the sense that you could at any point substitute the type of A with B and everything still works just the same, because there are no overrides.

If we wanted something like subtyping in Rust, I think we should go with a generic approach, where the target type "slot" will only expect "at least" something of type A and can then generate code for all of A's subtypes.

In general, I think it would be really bold if Rust offered something like "safe OOP". Personally I have never used OOP much (in C++), but I can imagine that it is very useful for GUI development or Games.

Right, because there are no subtype relationships between integer types. But Rust does have subtype relationships between lifetimes, which is why you can provide a &'long T where a &'short T is expected. These are different than coercions, which actually change the type (from &mut T to &T, or from &[T; 10] to &[T], etc.)

That's not what LSP is, though. LSP is about being able to substitute an object of type B for an object of type A. It isn't about substituting one type for another.

You seem to be mixing the concept of dispatch with the concept of inheritance. They aren't in opposition to each other and having one doesn't make it impossible to implement the other. Nor does implementing subclassing mean that you automatically have to ditch knowing the size of a type. A parent type's function is perfectly capable of working with the subtype provided the layout of the subtype matches the layout of the parent type.

So yes there are logical subtyping reasons for inheritance as well as structural subtyping reasons.

An underexplored aspect of this conversation is delved into really well by Casey Muratori here: Semantic Compression. As an aside, the language of the first couple sections is an indictment of OOP, but you can skip them to get the meat of the technical arguments he has to make.

A compromise I could see an argument for for an OOP-like Rust -- though I'm not actually advocating for it -- is the ability to declare mandatory fields a trait must have. This would allow for default trait definitions, which is the essence of what people usually want when they reach for inheritance: a parent type and an easy way to create a subtype. Rust already offers default trait functions, so I believe the only thing missing is default data fields. Again, I'm not seriously advocating for this but just curious how people see it fitting into the discussion. This still would run into the same diamond problem that multiple inheritance does if you used multiple traits.

I'm not a type-theorist. I'm dynamic language riffraff. So take what I say with something of a grain of salt.

I think that's the killer reason to not adopt such an approach. Currently, Rust traits are not susceptible to that problem. The 2nd Rust koan (cited upstream) applies.

1 Like

Yeah, dynamic traits solve that with vtables, which makes the argument for structural subtyping moot. But cache locality etc etc.

I think a more interesting discussion than how Rust fails to meet expectations coming from other languages is how one can adapt to Rust or how Rust can be adapted to solve specific problems in software development which other languages are currently better-suited toward. Specific examples, time, and discussion are the only way to move forward with that.

C++ has a proven track record. Rust is fine. I'm not dogmatically for or against either. There are certain patterns which are useful and certain which impede understanding/performance/maintenance in various languages. If our goal is to improve the software we can write, we should look at whether we've actually done so.

As far as I can tell that discussion has been going on among people far smarter and more experienced of these things than we for a decade or more now. Since the first inception of Rust.

So much so that features were developed for Rust and subsequently removed on finding they are a bad idea. Or at least do not fit the principles of safety, zero cost abstraction, and performance required of a systems programming language.

Indeed it does. As decades of the "blue screen of death" and the ever increasing volume of security vulnerabilities show. Something has to be done.

I'm pretty sure that something is not introducing C++ style OOP into Rust. Or anything else that has no reason to be apart from making users of other languages happy. For no sound engineering reasons.

4 Likes

Thanks for this explanation! That makes a lot of sense. I had the wrong idea about what encapsulation meant.

Well you can't do that in C++ either since you slice the object or otherwise call the non virtual versions of functions. You need a vector of pointers to the subtype and need to use that. iow translate that to C++ and call a virtual function on Animal and see what happens.

If you translate it to use pointers, hey presto it works in Rust:

    let mut animal : Vec<Box<dyn Animal>> = vec![];
    animal.push(Box::new(Dog{}));
    animal.push(Box::new(Cat{}));

May be I have missed a point.

As far as I can tell one can put Dogs on a vector of Animals in C++. Provided your Animals are virtual and the is a lot of dynamic dispatch going on.

How is your Rust version different?

When I pop an animal how do I know what animal is in the Box?

Unless all animals are part of an enum?

Yes, that is rather my point: Subclasses are not subtypes. That is, class inheritance does not satisfy the Liskov substitution principle; I'm sure it may be considered "subtyping" under some more relaxed definition but that sounds like a surpassingly boring argument to have.

It does, but it's still not subtyping because subtraits are also not subtypes, because traits are not types, and also because they do not participate in variance. The example you give does work, but it is not an example of subtyping -- it is an example of coercion.

Then I don't really get your point.

(Assuming C++ point of reference) It does if you use pointers. It can also be done in Rust using Box<dyn T>.

Yes, I mentioned that too:

1 Like

You need to use pointers or the memory layout is all messed up. First off, if you have virtual int some_func() = 0; as an abstract method, you will notice you cannot make std::vector<Animal>. And if you have virtual int some_func() { return 0;} and then push a Dog and call v[0].some_func() then it returns 0 because it did not use the vtable lookup but instead treated it as a value of type Animal.

This might seem like a nit, but you can use Vec<Box<dyn Animal>> in Rust and it all works so I wasn't sure what the point of saying you can't do it in Rust was if you cannot do it in other languages with inheritance. (FWIW, Java also puts all objects on the heap anyway so it always 'works' in Java (List<Animal> really means something akin to List<*Animal>>)).

You ask it nicely?

Back on topic though: inheritance, like any tool, is fine and useful when used appropriately. It just so happens that many people use it inappropriately because they are smart. So smart that they see patterns and similarities everywhere and begin to overspecify the types so inheritance hierarchies become burdens that describe 'is-a' but don't add value. Dog is an Animal, but often you are better off with Animal{ species: Species::Dog, name: "Rover".to_owned(), friends: vec![]}.

So, it just so happens that inheritance is an overly general mechanism to describe type relationships that people use poorly. And the Rust approach appears to take it away but actually the useful bits are still there.

4 Likes

I think we are in agreement.

One can certainly use inheritance in C++ in such a way that it satisfies the Liskov substitution principle. One just has to code it that way. If anyone is not sure the Back To Basics presentation I linked to above spells it out.

As to whether any of that is a good idea or not I'm not in a position to say. I was never much sold on object oriented programming in the C++, Java style.

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.