Hacker News new | ask | show | jobs
by HdS84 400 days ago
I don't think Inheritance is always bad - sometimes it's a useful tool. But it was definitely overused and composition, interfaces work much better for most problems.

Inheritance really shines when you want to encapsulate behaviour behind a common interface and also provide a standard implementation. I.e: I once wrote a RN app which talked to ~10 vacuum robots. All of these robots behaved mostly the same, but each was different in a unique way. E.g. 9 robots returned to station when the command "STOP" was send, one would just stop in place. Or some robots would rotate 90 degrees when a "LEFT" command was send, others only 30 degrees. We wrote a base class which exposed all needed commands and each robot had an inherited class which overwrote the parts which needed adjustment (e.g. sending left three times so it's also 90 degrees or send "MOVE TO STATION" instead of "STOP").

4 comments

> I don't think Inheritance is always bad - sometimes it's a useful tool.

I can only think of one or two instances where I've really been convinced that inheritance is the right tool. The only one that springs to mind is a View hierarchy in UI libraries. But even then, I notice React (& friends) have all moved away from this approach. Modern web development usually makes components be functions. (And yes, javascript supports many kinds of inheritance. Early versions of react even used them for components. But it proved to be a worse approach.)

I've been writing a lot of rust lately. Rust doesn't support inheritance, but it wouldn't be needed in your example. In rust, you'd implement that by having a trait with functions (+default behaviour). Then have each robot type implement the trait. Eg:

    trait Robot {
        fn stop(&mut self) { /* default behaviour */ }
    }

    struct BenderRobot;
    
    impl Robot for BenderRobot {
        // If this is missing, we default to Robot::stop above.
        fn stop(&mut self) { /* custom behaviour */ }
    }
> The only one that springs to mind is a View hierarchy in UI libraries.

I'd like to generalize that a little bit and say: graph structures in general. A view hierarchy is essentially a tree, where each node has a bunch of common bits (tree logic) and a bunch of custom bits (the actual view). There are tons of "graph structures" that fit that general pattern: for instance, if you have some sort of data pipeline DAG where data comes in on the left, goes out on the right, and in the middle has to pass through a bunch of transformations that are linked in some kind of DAG. Inheritance is great for this: you just have your nodes inherit from some kind of abstract "Node" class that handles the connection and data flow, and you can implement your complex custom behaviors however you want and makes it very easy to make new ones.

I'm very much in agreement that OOP inheritance has been horrendously overused in the 90s and 00s (especially in enterprise), but for some stuff, the model works really well. And works much better than e.g. sum types or composition or whatever for these kinds of things. Use the right tool for the right job, that's the central point. Nothing is one-size-fits-all.

turns out that using composition and polymorphism is usually simpler than inheritance in such cases.
> But even then, I notice React (& friends) have all moved away from this approach. Modern web development usually makes components be functions.

But what do those functions return? Oh look, it's DOM nodes, which are described by and implemented with inheritance.

I would agree that view hierarchies in UI libraries are one of the primary use-cases for inheritance. But it's a pretty big one.

> But what do those functions return? Oh look, it's DOM nodes, which are described by and implemented with inheritance.

Well of course. React builds on what the browser provides. And the DOM has been defined as a class hierarchy since forever. But react components don’t inherit from one another. If the react devs could reinvent the DOM, I think it would look very different than it looks today.

The problem is of course that there is no useful default behavior you can define when the trait is so isolated and generic.
It doesn't have to be "so isolated". The trait can still have required methods that don't have a default implementation. Eg:

    trait Robot {
        fn send_command(&mut self, command: Command);

        fn stop(&mut self) {
            self.send_command(Command.STOP);
        }
    }

    struct BenderRobot;
    
    impl Robot for BenderRobot {
        // Required.
        fn send_command(&mut self, command: Command) { todo!(); } 
    }
This is starting to look a lot like C++ class inheritance. Especially because traits can also inherit from one another. However, there are two important differences: First, traits don't define any fields. And second, BenderRobot is free to implement lots of other traits if it wants, too.

If you want a real world example of this, take a look at std::io::Write[1]. The write trait requires implementors to define 2 methods (write(data) and flush()). It then has default implementations of a bunch more methods, using write and flush. For example, write_all(). Implementers can use the default implementations, or override them as needed.

Docs: https://doc.rust-lang.org/std/io/trait.Write.html

Source: https://doc.rust-lang.org/src/std/io/mod.rs.html#1596-1935

> First, traits don't define any fields.

How does one handle cases where fields are useful? For example, imagine you have a functionality to go fetch a value and then cache it so that future calls to get that functionality are not required (resource heavy, etc).

    // in Java because it's easier for me
    public interface hasMetadata {
        Metadata getMetadata() {
            // this doesn't work because interfaces don't have fields
            if (this.cachedMetadata == null) {
                this.cachedMetadata = generateMetadata();
            }
            return this.cachedMetadata;
        }
        // relies on implementing class to provide
        Metadata fetchMetadata(); 

    }
Getters and setters that get specified by the implementing type.
But then you have the getters, setters, and field on every class that implements the functionality. It works, sure, it just feels off to me. This is code that will be the same everywhere, and you're pulling it out of the common class and implementing it everywhere.
Yep. Or ... don't put that in the interface at all. It looks like an implementation concern to me.
Don't mix implementation and interface inheritance.
The commenter used inheritance and thought it was fine. Probably not necessary to re-write in Rust just to be able to say that it doesn't use inheritance while being functionally the same thing.
> And yes, javascript supports many kinds of inheritance

Funny you mention it, since JavaScript has absolutely no concept of contracts, which is one of the most important side-effects of inheritance. Especially not at compile time, but even at runtime you can compose objects willy-nilly, pass them anywhere, and the only way to test if they adhere to some kind of trait is calling a method and hoping for the best.

At least that had been the case till ES6 came around, but good luck finding anyone actually using classes in JavaScript. Mainly because it adds near-zero benefits, basically just the ability to overwrite method behavior without too much trickery.

Yes. JavaScript is an incredibly dynamic language. If you don’t like that, don’t use it.
I will tell you one example with inheritance: The Linux kernel.
How does that work in a language without inheritance?

(yes, I guess it's the fake vtable of structure full of pointers)

Structure composition is a form of inheritance.
Inheritance is not the only way to share behavior across different implementations — it'a just the only way available in the traditional 1990s crop of static OOP languages like C++, Java and C#.

There are many other ways to share an implementation of a common feature:

1. Another comment already mentioned default method implementations in an interface (or a trait, since the example was in Rust). This technique is even available in Java (since Java 8), so it's as mainstream as it gets.

The main disadvantage is that you can have just one default implementation for the stop() method. With inheritance you could use hierarchies to create multiple shared implementations and choose which one your object should adopt by inheriting from it. You also cannot associate any member fields with the implementation. On the bright side, this technique still avoids all the issues with hierarchies and single and multiple inheritance.

2. Another technique is implementation delegation. This is basically just like using composition and manually forwarding all methods to the embedded implementer object, but the language has syntax sugar that does that for you. Kotlin is probably the most well-known language that supports this feature[1]. Object Pascal (at least in Delphi and Free Pascal) supports this feature as well[2].

This method is slightly more verbose than inheritance (you need to define a member and initialize it). But unlike inheritance, it doesn't requires forwarding the class's constructors, so in many cases you might even end up with less boilerplate than using inheritance (e.g. if you have multiple overloaded constructors you need to forward).

The only real disadvantage of this method is that you need to be careful with hierarchies. For instance, if you have a Storage interface (with the load() and store() methods) you can create EncryptedStorage interface that wraps another Storage implementation and delegates to it, but not before encrypting everything it sends to the storage (and decrypting the content on load() calls). You can also create a LimitedStorage wrapper than enforces size quotas, and then combine both LimitedStorage and EncryptedStorage. Unlike traditional class hierarchies (where you'd have to implement LimitedStorage, EncryptedStorage and LimitedEncryptedStorage), you've got a lot more flexibility: you don't have to reimplement every combination of storage and you can combine storages dynamically and freely. But let's assume you want to create ParanoidStorage, which stores two copies of every object, just to be safe. The easiest way to do that is to make ParanoidStorage.store() calls wrapped.store() twice. The thing you have to keep in mind, is that this doesn't work like inheritance: For instance, if you wrap your objects in the order EncryptedStorage(ParanoidStorage(LimitedStorage(mainStorage))), ParanoidStorage will call LimitedStorage.store(). This is unlike the inheritance chain EncryptedStorage <- ParanoidStorage <- LimitedStorage <- BaseStorage, where ParanoidStorage.store() will call EncryptedStorage.store(). In our case this is a good thing (we can avoid a stack overflow), but it's important to keep this difference in mind.

3. Dynamic languages almost always have at least one mechanism that you can use to automatically implement delegation. For instance, Python developers can use metaclasses or __getattr__[3] while Ruby developers can use method_missing or Forwaradable[4].

4. Some languages (most famously Ruby[5]) have the concept of mixins, which let you include code from other classes (or modules in Ruby) inside your classes without inheritance. Mixins are also supported in D (mixin templates). PHP has traits.

5. Rust supports (and actively promotes) implementing traits using procedural macros, especially derive macros[6]. This is by far the most complex but also the most powerful approach. You can use it to create a simple solution for generic delegation[7], but you can go far beyond that. Using derive macros to automatically implement traits like Debug, Eq, Ord is something you can find in every codebase, and some of the most popular crates like serde, clap and thiserror rely on heavily on derive.

[1] https://kotlinlang.org/docs/delegation.html

[2] https://www.freepascal.org/docs-html/ref/refse48.html

[3] https://erikscode.space/index.php/2020/08/01/delegate-and-de...

[4] https://blog.appsignal.com/2023/07/19/how-to-delegate-method...

[5] https://ruby-doc.com/docs/ProgrammingRuby/html/tut_modules.h...

[6] https://doc.rust-lang.org/reference/procedural-macros.html#d...

[7] https://crates.io/crates/ambassador

To my mind, the challenge is not "sharing behavior"; it is "sharing behavior in a way that capture human-understandable semantics and make code easier to reason about instead of harder."

I suspect part of the problem of inheritance is that it is a way to share behavior that some humans, especially visual thinkers who understand VMTs, find easy to reason about.

In my experience verbal thinkers struggle with inheritance, because it requires jumping between levels of abstraction and they aren't thinking in terms of semantic units. I have found that books like Refactoring can help bridge the gap, but we have to identify it as a gap to be bridged and people have to want to learn this new skill.

And then on the flip side you have people who try to use it just as a way to de-dupe code, even when it doesn't reflect a meaningful semantic unit.

> In my experience verbal thinkers struggle with inheritance, because it requires jumping between levels of abstraction and they aren't thinking in terms of semantic units.

This is too dismissive of the criticism. The problem with inheritance is it makes control flow harder to understand and it spreads your logic all over a bunch of classes. Ironically, inheritance violates encapsulation - since a base class is usually no longer self contained. Implementation details bleed into derived classes.

The problem isn’t “verbal thinkers”. I can think in OO just fine. I’ve worked in 1M+ line of code Java projects, and submitted code to chrome - which last time I checked is a 30M loc C++ project. My problem with OO is that thinking about where any given bit of code is distracts me from what the code is trying to do. That makes me less productive. And I’ve seen that same problem affect lots of very smart devs, who get distracted building a taxonomy in code instead of solving actual problems.

It’s not a skills problem. Programming is theory building. OO seduces you into thinking the best theory for your software is a bunch of classes which inherit from each other, and which reference each other in some tangled web of dependencies. With enough effort, you can make it work. But it almost always takes more effort than straightforward dataflow style programming to model the same thing.

I do not believe "it makes the control flow harder to understand" is as universal as you claim. If used badly any flow tool (including if-statements) can be be confusing. But "it can be complicated" doesn't mean we shouldn't use the tool when it is appropriate. One of the reasons I like Java Enums is because they provide much more structured guidance on what communicative inheritance looks like.

But we may also disagree on what "productive" means in the context of writing software.

The "taxonomy of code" you are dismissing is I believe what Fred Brooks describes as the "essential tasks" of programming: "fashioning of the complex conceptual structures that compose the abstract software entity".

It's not that I don't sympathize with your concern: being explicit and clear about "what the code is trying to do" is why TDD is popular among OOP programmers. But the step after "green" is "refactor", where the programmer stops focusing on what the code is trying to do and refines the taxonomy of the system that implements those tasks.

Also:

2003 Traits: Composable Units of Behaviour

https://www.cs.cmu.edu/~aldrich/courses/819/Scha03aTraits.pd...

" Traits as described in this paper are implemented in Squeak [22], an open-source dialect of Smalltalk-80."

To me (as a Java programmer) inheritance is very useful to reuse code and avoid copy paste. There many cases in which decorators or template methods are very useful and in general I find it "natural" in the sense that the concepts of abstraction and specialization can be found in plenty of real world examples (animals, plants, vehicles etc etc).

As usual there is no silver bullet, so it's just a tool and like any other tool you need to use it wisely, when it makes sense.

As a full stack developer who's current job is mostly Java on the backend - at least for the last 8 yrs: I'm not aware of anything you would lose by switching to interfaces with default implementations over inheritance... And that's the usual argument: use composition over inheritance.
But would switching to interfaces with default implementations fix any of the complaints that people have about inheritance? In my mind, they're pretty much equivalent, so it seems to me that anything you can do with inheritance that people complain about, you could also do with interfaces and complain about it in the same way.
The biggest difference are

1. A class can be composed out of multiple interfaces, making them more like mixins/traits etc vs inheritance, which is always a singular class

2. The implementation is flat and you do not have a tree of inheritance - which was what this discussion was about. This obviously comes with the caveat that you don't combine them, which would effectively make it inheritance again.

Yeah there can be a ton of derivative and convenience methods that would either have to be duplicated in all implementations or even worse duplicated at call sites.

Call them interfaces with default implementations or super classes, they are the same thing and very useful.