Just because you see OO languages starting to favor composition over inheritance does not mean inheritance has no place, and indeed, interfaces as a form of composition have existed in many bog-standard OO languages for decades.
Your example dosn't compute, at least in most languages, because derived objects would not have the same shape as one another, only the shape of the base class. I.e. functions expecting a User object would of course accept either an Employee or a Student (both subclasses of a User), but functions expecting a Student object or an Employee object would not accept the other object type just because they share a base class. Indeed, that's the whole point. And as another poster mentioned, you are introducing a burden by now having no way to determine whether a User is an Employee or a Student without having to pass additional information.
Listen, I'll be the first to admit that the oo paradigm went overboard with inheritance and object classification to the n-th degree by inventing ridiculous object hierarchies etc, but inheritance (even multiple inheritance) has a place- not just when reasoning about and organizing code, but for programmer ergonomics. And with the trend for composition to disallow data members (like traits in Rust), it can seriously limit the expressiveness of code.
Sometimes inheritance is better, and if used properly, there's nothing wrong with that. The alternative is that you wind up implementing the same 5-10 interfaces repeatedly for every different object you create.
It should never be all or nothing. Inheritance has its place. Composition has its place.
And if you squint just right they're two sides of the same coin. "Is A" vs "Can Do" or "Has".
> The alternative is that you wind up implementing the same 5-10 interfaces repeatedly for every different object you create.
if both Student and Employee need to implement those interfaces, it's probably User that should have and implement them, not Student and Employee (and if they truly do need to have an implement them, they can simply delegate to their internal User = "no override" or provide a unique implementation = "override") (let alone that unless I'm misremembering in Kotlin interfaces can have default implementations)
Inheritance has no place in production codebases—unless there is strict discipline, enforced by tooling, ensuring calls only go in one direction. This Liskov stuff has zero bearing.
You are conflating two completely separate things.
The Liskov principle basically defines when you want inheritance versus when you don't. If your classes don't respect the Liskov principle, then they must not use inheritance.
The problems from your story relate to the implementation of some classes that really needed inheritance. The spaghetti you allude to was not caused by inheritance itself, if was caused by people creating spaghetti. The fact that child classes were calling parent class methods and then the parent class would call child class methods and so on is symptomatic of code that does too much, and of people taking code reuse to the extreme.
I've seen the same things happen with procedural code - a bunch of functions calling each other with all sorts of control parameters, and people adding just one more bool and one more if, because an existing function already does most of what I want, it just needs to do it slightly different at the seventh step. And after a few rounds of this, you end up with a function that can technically do 32 different things to the same data, depending on the combination of flags you pass in. And everyone deeply suspects that only 7 of those things are really used, but they're still afraid to break any of the other 25 possible cases with a change.
People piggybacking on existing code and changing it just a little bit is the root of most spaghetti. Whether that happens through flags or unprincipled inheritance or a mass of callbacks, it will always happen if enough people are trying to do this and enough time passes.
I don't believe in free will. People (including me) are going to do the easiest thing possible given the constraints. I don't think that's bad in any way -- it's best to embrace it. In this view, the tools should optimize for right thing to do aligning with the easy thing.
So if you have two options, one better than the other, then the better way to do things should be easier than the worse way. With inheritance as traditionally implemented, the worse way is easier than the better way.
And this can be fixed! You just need to make sure all the calls go one way (making the worse outcome harder), and then very carefully consider and work through the consequences of that. Maybe this fix ends up being unworkable due to the downstream consequences, but has anyone tried?
> I've seen the same things happen with procedural code - a bunch of functions calling each other with all sorts of control parameters, and people adding just one more bool and one more if, because an existing function already does most of what I want, it just needs to do it slightly different at the seventh step. And after a few rounds of this, you end up with a function that can technically do 32 different things to the same data, depending on the combination of flags you pass in. And everyone deeply suspects that only 7 of those things are really used, but they're still afraid to break any of the other 25 possible cases with a change.
Completely agree, that's a really bad way to do polymorphism too. A great way to do this kind of closed-universe polymorphism is through full-fledged sum types.
> People piggybacking on existing code and changing it just a little bit is the root of most spaghetti. Whether that happens through flags or unprincipled inheritance or a mass of callbacks, it will always happen if enough people are trying to do this and enough time passes.
You're absolutely right. Where I go further is that I think it's possible to avoid this, via the carrot of making the better thing be easy to do, and rigorous discipline against doing worse things enforced by automation. I think Rust, for all its many imperfections, does a really good job at this.
edit: or, if not avoid it, at least stem the decline. This aligns perfectly with not believing in free will -- if it's all genetic and environmental luck all the way down, then the easiest point of leverage is to change the environment such that people are lucky more often than before.
Respectfully, I think you're throwing the baby out with the bathwater.
I read your story, and I can certainly empathize.
But just because someone has made a tangled web using inheritance doesn't mean inheritance itself is to blame. Show me someone doing something stupid with inheritance and I can demonstrate the same stupidity with composition.
I mean, base classes should not be operating on derived class objects outside of the base class interface, like ever. That's just poorly architected code no matter which way you slice it. But just like Dijkstra railing against goto, there is a time and a place for (measured, intelligent & appropriate) use.
Even the Linux kernel uses subclassing extensively. Sometimes Struct B really is a Struct A, just with some extra bits. You shouldn't have to duplicate code or nest structures to attain those ergonomics.
Anything can lead to a rube-goldberg mess if not handled with some common sense.
I believe the problem is structural to all traditional class-based inheritance models.
The sort of situation I described is almost impossible with trait/typeclass-based polymorphism. You have to go very far out of the idiom with a weird mix of required and provided methods to achieve it, and I have never seen anyone do this in practice. The idiomatic way is for whatever consumes the trait to pass in a context type. There is a clear separation between the call-forward and the callback interfaces. In Rust, & and &mut mean that there's an upper limit to how tangled the interface can get.
I'm fine with inheritance if there's rigorous enforcement of one-directional calls in place (which makes it roughly equivalent to trait-based composition). The Liskov stuff is a terrible distraction from this far more serious issue. Does anyone do this?
> You shouldn't have to duplicate code or nest structures to attain those ergonomics.
What's wrong with nesting structures? I like nesting structures.
Your example dosn't compute, at least in most languages, because derived objects would not have the same shape as one another, only the shape of the base class. I.e. functions expecting a User object would of course accept either an Employee or a Student (both subclasses of a User), but functions expecting a Student object or an Employee object would not accept the other object type just because they share a base class. Indeed, that's the whole point. And as another poster mentioned, you are introducing a burden by now having no way to determine whether a User is an Employee or a Student without having to pass additional information.
Listen, I'll be the first to admit that the oo paradigm went overboard with inheritance and object classification to the n-th degree by inventing ridiculous object hierarchies etc, but inheritance (even multiple inheritance) has a place- not just when reasoning about and organizing code, but for programmer ergonomics. And with the trend for composition to disallow data members (like traits in Rust), it can seriously limit the expressiveness of code.
Sometimes inheritance is better, and if used properly, there's nothing wrong with that. The alternative is that you wind up implementing the same 5-10 interfaces repeatedly for every different object you create.
It should never be all or nothing. Inheritance has its place. Composition has its place.
And if you squint just right they're two sides of the same coin. "Is A" vs "Can Do" or "Has".