Inheritance, why is bad?

If you create a new trait, you can implement it for any type, even if the type is not yours. So you could add methods even to String or i32, for example:

trait BuiltinExt {
    fn pretty_print(&self);
}

impl BuiltinExt for f32 {
    fn pretty_print(&self) {
        println!("{:?}", *self);
    }
}

impl BuiltinExt for String {
    fn pretty_print(&self) {
        println!("{:?}", *self);
    }
}

Um, excuse me?

1 Like

Ah, I see. But this fails if the extension requires Self to store new data.

What do you mean by that?

A trait can add methods to any type, but to add data to a type, you still need to wrap the type as a field in a new type. It works, but results in a lot of boilerplate.

Well if you need to "add fields" to a type, then it's not the same type. So what's the problem with creating a new type? And why is it "boilerplate", and how is that related to extension methods?

There is no problem with creating an entirely new type, I just think the required code is too explicit. As soon as you extend a type, and want to use its functionality through your new type, you have to basically implement the old type all over again, with all of its method signatures explicitly written down, and whenever the old type changes, all of the code has to be adjusted, which can be a lot of code.

1 Like

This has never been a real issue for me. I did do a lot of OO programming in various languages before (mostly C++, Objective-C, and Swift, but I have messed with Python and C# as well), and yes, I can see that Rust's approach to type hierarchies is entirely different. But usually when I encounter a problem that I would have modeled using inheritance in an OO language, there's almost always a better, more powerful, less convoluted, inheritance-free solution in Rust.

E.g. if you really want to expose an underlying "base" object, you can add a getter method for it and let the users of your code deal with the "conversion". Or for smart pointer and container-like types, you can implement Deref<Base> or DerefMut<Base> to recover the underlying object automatically in coercion contexts.

And in every other remaining case, it's probably a much better idea to program against a trait on the interface, and implement the trait by forwarding to the underlying object, which in turn can mostly be automated by careful reliance on default trait methods, or as a very last resort, macros.

The bottom line is, if you know the tools well that the language has to offer to you, you'll be less and less tempted to simulate OO in Rust. In fact I never missed inheritance in Rust.

During the couple of years I've been using the language, there has been one (!) case when inheritance would have been useful, but that only consisted of forwarding a single-method trait to a very simple object. It was a Spanned trait for syntax tree nodes in a parser I wrote, with the method fn span(&self) -> Spanned, which I ended up implementing for a couple AST types as fn span(&self) -> Spanned { self.parsed_span }.

Yes, technically that was boilerplate, although it probably was way less than I needed to write in the past for "nice" and "elegant" design patterns and frameworks that forced me to create subclasses for many reasons, few of which were any good at all.

3 Likes

I'm no OOP expert but according to the references I gave in my first reply here that is the only recommended way to use inheritance in C++. One want's "Dog", "Cat", "Hamster" as different types, all be it with similar interfaces but one want to be able to reference them by a common type "Animal".

The guideline is that in C++ classes can perform two roles:
A) Be inherited/derived from, providing an interface and a type used for referencing members of the family tree. Like 'Animal'.
B) Be an implementation of something, like the derived "Dog", "Cat", "Hamster"
A class should never be fulfilling both these roles.

This prevents one from creating long chains of inheritance as on attempts to make new things out of old things, tweaking the behavior all the way down the chain. Ending up with an unmaintainable mass of classes that are horribly dependent on each others internal workings.

1 Like

I mean, if you are extending a type with a crazy number of functions, maybe a macro would come in handy? Or maybe you should just use Deref and DerefMut?

impl_cvec_mut! {
    fn append(other: &mut Vec<T>) -> ();
    fn as_mut_ptr() -> *mut T;
    fn as_mut_slice() -> &mut [T];
    fn clear() -> ();
    fn insert(index: usize, element: T) -> ();
    fn pop() -> Option<T>;
    fn push(value: T) -> ();
    fn remove(index: usize) -> T;
    fn reserve(additional: usize) -> ();
    fn reserve_exact(additional: usize) -> ();
    fn shrink_to_fit() -> ();
    fn split_off(at: usize) -> Vec<T>;
    fn swap_remove(index: usize) -> T;
    fn truncate(len: usize) -> ();
}
impl_cvec_ref! {
    fn as_ptr() -> *const T;
    fn as_slice() -> &[T];
    fn capacity() -> usize;
    fn is_empty() -> bool;
    fn len() -> usize;
}

playground

Yes, this requires listing every function you want to extend, but I'm not sure I like the alternative as things get weird if the underlying type suddenly adds a method you also added.

2 Likes

Indeed...

I'm saying, similar to how the presented discussed annotations, we aren't going to be doing custom XML parsing implementations for everything, so the logic needed to operate on XML data largely will come from external libraries which aren't included in the object's class.

Isn't that at odds with the idea that only the object itself should contain the logic for operating on its contents?

Aren't libraries more similar to static methods, which the presented already said isn't OOP?

I think libraries and everything else being discussed here are unrelated things. The are orthohonal.

I mean, a library can hold all of the above, free functions, structs, classes, methods, traits, constant definitions, etc, etc, whatever ones language supports.

But my point is, libraries, whether they contain static methods or classes, are logic that is meant to operate on your own objects. Isn't that against the idea that the logic to operate on a certain object should be remain within that object?

It feels more functional to me to have a bunch of generic logic that can operate on a bunch of different types as long as those types meet certain criteria.

This is hardly achievable in practice. As an object starts being used in more and more places, the probability goes up that you'll need to reach directly into it. Either that or add more and more interface methods to it, which I guess you could say is a stronger encapsulation guarantee than arbitrary access to fields, but it also leads to huge class definitions and a lumping together of all the concerns an object has within a single file. I prefer just having a struct with maybe a few OO-style methods, but letting, for example, the physics and graphics modules of a game treat the struct differently.

As for static methods and XML, I'm not entirely sure what you mean. Could you summarize?

I think maybe we're saying the same thing? I am saying I don't understand where encapsulation is supposed to end. If an object is supposed to contain all of the logic needed to operate on its data, then it needs everything all the way down to the lowest levels of everything. Otherwise it's just including the higher level logic relevant only to that object, but then there is plenty of overlap between that logic and other object types, just like there is with lower level logic that you didn't include in the class anyway...

1 Like

I cannot fathom what you are saying there.

Who said "an object is supposed to contain all of the logic needed to operate on its data" ?

Are you suggesting that my top level program should contain all the code it ever needs to do anything? The file system, the operating system, all the maths routines in uses, etc, etc...

That is absurd. I'm sure that is not what you have in mind.

The deal with libraries/modules/packages, whatever, is that you write some useful code and I want to make use of it. Many other people want to make use of it. In many programs. Those libraries/modules/packages etc are the means of delivery.

Meanwhile, functions, structs, traits, classes and the like are the means of organizing code within your library, within my program and ultimately across the whole ecosystem. They are the means by which to think about the problem one is solving. They are about abstraction, I should be able to swap your library code out for some other given they both comply with the same interface. They are about maintenance.

1 Like

I never said it should. I am saying, from my understanding of what encapsulation tries to do, that is an extreme example of why I don't understand it. We shouldn't include all that low level logic in every object. That logic is generic across lots of things, and doesn't belong only in one class.

You're describing inheritance, sure. I was talking about subtyping. You said earlier "This would not violate the substitution principle" but what you're describing does exactly violate LSP. If you can't use a Dog where an Animal is required, Dog is by definition not a subtype of Animal.

Put another way, you can have at most two of the following at a time:

  1. Liskov substitution principle
  2. Data extensions (fields in subclasses not in the parent)
  3. Sizedness (objects not behind a pointer)

If you get rid of LSP, you can add fields to subclasses, and deal with Animals not behind a pointer, but you can't push a Dog into a Vec<Animal>.

If you get rid of fields in subclasses, you can keep LSP and you don't need the pointer because all Animals have the same data layout. This is why lifetime relationships are (or can be) subtype relationships: they don't influence data layout (or codegen at all, in fact).

If you get rid of sizedness, you can keep LSP and give subclasses fields that aren't in the parent, but the cost you pay is that every Animal has to be behind a pointer because you don't know whether or not it might be a Dog.

1 Like

You did not say "it should" but you did pose the question "If it should...". I just wonder where that idea came from.

OK then, what is this 'encapsulation' idea all about anyway?

I general encapsulation means many things and works at many levels.

I C for example we encapsulate bits of code into functions. Why do we do that? As a practical matter functions enable us to make use of the same code in many places within a program, saves us having to repeat the code, in line, in the source, reduces program size. Importantly makes lumps of code into things with names, like sin(), that are much easier to think about.

We might then encapsulate a pile of related functions and structures, into a separate file. Why do we do that? As practical mater that makes it easier to find things in our program, it allows different people to work on different parts of the code, it might reduce build time, it means we don't have to open 100 thousand lines of code in our editors, it makes change history easier to follow.

Enter C++ and other object oriented languages. Now we can encapsulate functions and the data it operates on into classes and objects. Why do we do that?

To me honest I'm not entirely sure. Never have been. Never was totally sold on the 'everything is a class/object' idea of Java for example.

But now we can but a lot of complex functionality into a box, that requires more than a single function to use it like sine() does, and give it a name. It's class. Again that hopefully makes it easier to reason about our program. By encapsulating and abstracting into a class we can substitute other implementations for the same idea. A classic example being that of a logger. A 'Logger' class can define what a logger looks like to my application. But I may well want to use different loggers here and there, log to file, log to network, etc. I should not have to change my application code to do that. The implementation details are encapsulated and hidden, my program does not need to know or care about them.

That brings us to interfaces and inheritance. In the logger case the class "Logger" defines a logger as far as my application is concerned. It can hold references to Loggers and make calls on Logger methods. However the actual logging is implemented in classes that inherit the API from Logger. There may be many of them, FileLogger, NetLogger, etc.

Hence the now commonly accepted idea that, in C++ at least, a class should do one of two things: Define an interface, a contract, between the users and the implementers of functionality. Or provide the implementation of that functionality, inheriting the API contract from elsewhere. Not both!

Hey, I could write a whole book about this. On second thoughts ... there are many out there already.

Let me tell you a little secret...

Classes in C++ are nothing to do with 'encapsulation', 'interfaces', 'API contracts', 'Liskov substitution' or any other principles.

No, classes in C++ are purely a mechanical contrivance for convenience.

Let me explain:

Consider the function. As I said above they exist so that we take those dozen or so lines of code we keep repeating all over our code base, stick them in a block, give them a name and then refer to them by name from all the places we need them. There, much easier to deal with.

What of classes then? Well, when we have a bunch of functions that all operate on some data set, a struct say, in C, we find that we need a pointer parameter in each function signature. To tell it were the data is for any particular instance of that data.

This extra parameter all over our code is tedious and ugly.

Enter the 'class'. Now we can stick a bunch of functions in a box along with the data they operate on and give it a name. Great, now we don't need to keep writing those instance pointer parameters all over the place!

What of inheritance? Again I suggest it's just a mechanical convenience. With inheritance we can clone and mutate an existing thing into a new thing. Saves bunch of typing and copying.

But here is a thought: With the convenience of 'function' we get the possibility for simple recursion. That opens up a whole new world of algorithmic possibilities, quick sort, binary tree search, all manner of divide and conquer algorithms. Fantastic stuff.

What then does Class and Inheritance bring us that is so fantastic?

I guess my powers of abstract thought are limited, principle this and principle that. Bah! Sometimes it might be useful to forget all that highfalutin talk, step back and ask "What does this mechanism actually do?", "Why do I need it?", "Is there perhaps a better way to do what I want?"

2 Likes