Implementation inheritance is often justified as a way of "reusing" code, but as it turns out, we've merely introduced undesired coupling instead of the seamless reuse we might have expected.
You've touched on a whole host of performance issues here too. Not only are we making indirection after indirection when accessing virtual methods and data (my poor cache), but invariably these are all stored in heap memory, so we get the worst possible performance profile baked in.
> the actual code that gets executed depends on whether the object is one of a "derived" class, where that method might have been changed in ways that might break any amount of expected invariants.
On top of that inheritance makes the concrete type unknowable at compile time just to add to the pain.
> Implementation inheritance is often justified as a way of "reusing" code, but as it turns out, we've merely introduced undesired coupling instead of the seamless reuse we might have expected.
Yes this 100%. Inheritance actually stands in the way of code reuse. If I have a class that is kind of similar to another, but different enough to "require" being another class, oh dear they're now incompatible. So you try to kind of fit it into a subclass, but it makes more sense in another, and now you're spending mental effort on a problem created by architecture and not the actual thing you're trying to solve.
This is really clear in the game development world, where game objects often just don't fit into the inheritance format, even with multiple inheritance. This is why most complex games are built with the entity component system pattern as it focuses entirely on composition.
Perhaps the crux of the issue is that inheritance encourages design by "identity" (mental concept) rather than "attribute" (data). Thus to solve problems we must mentally classify them into some taxonomy as if they are animal species before we even write any code. This is just extra work in exchange for all the issues mentioned.
It's not supposed to, and if it does, the design is clearly broken. Now the (rhetorical) question is: does it happens often. The not so rhetorical question is: is it possible to make it happen rarely. The even more interesting question is: is it easy / more practical compared to alternative approaches, and if not then what is the point.
My opinion on SOLID is that there is precisely one hard "principle", and it is worthwhile: the LSP (it derives directly from logic, that's why). I believe that Open-close is even borderline insane, and that if there is a crazy way to make it not insane, this way is probably applied by so few people that it may well be irrelevant -- most people will try to apply it in ways that will quickly put them at risk for LSP violation, but LSP is far more important (or they will try to apply it in bad places, but that is another story). Plus programs designed with complex hierarchies are often missing the point of execution contexts, and then understanding them is absolute hell -- their original authors sometimes do not understand them themselves. (The rest of SOLID are soft attempts at fixing self-inflicted wounds, sometimes even reasonable if you insist on doing Javaesque / old-C++-esque OOO, but I digress.)
I'm not sure why anybody thought that kind of OOO was a good idea, or that the main characteristic in interesting big programs was the usage of "classes". I even find the suggestion of causation instead of mere correlation dubious; they were already quite a good number of big programs before, and what permitted the explosion of program size was more the ever growing capabilities of computers, that happened, in affordable versions, during the Java-like OOO hype. Besides the simplistic reductionism (we can start with: which classes model entities, which classes model values, which classes are controllers, etc.) which is not too much a big deal in practice, modeling with class diagrams is often missing the river in the middle of the forest for some groups of intertwined trees.
The Smalltalk-80 container hierarchy demonstrates that you can get a lot of mileage out of simple single-dispatch virtual methods with inheritance. The Smalltalk-78 system, which you can try at https://lively-web.org/users/bert/Smalltalk-78.html (although it's not working for me at the moment), got a multiwindow GUI with an IDE running usably on an Intel 8086 with 256KiB of RAM, in only about 100 classes and 2000 methods totaling 200 KiB of code. This is not what I would describe as a "big program", but it is a fairly impressive program nonetheless. Seeing that kind of thing is what led people to adopt object-oriented programming.
In principle, with a sufficiently-robust type system, the invariants expected by the base class would be encoded in the typing, and the derived class would only be able to narrow them.
(Of course, OOP languages tend to either be untyped or have insufficiently robust type systems for this; robust type systems are more frequently associated with functional languages.)
why would you make internal methods virtual though?
So, for example, Java's Map is an interface type with, for example, .get() and .put() methods; TreeMap and HashMap implement those methods differently. There are similar things in Smalltalk-80 and the C++ STL.
So clearly you think Java, Smalltalk, and C++ are "a bass-ackward parody of proper OO design." I'm interested to hear what systems you consider paragons of proper OO design.
You might find the book Program Development in Java: Abstraction, Specification, and Object-Oriented Design by Barbara Liskov and John Guttag very useful for understanding OOD/OOP.
I firmly agree.
I wrote https://www.perlmonks.org/?node_id=318257 over 15 years ago. I still agree with the fundamental criticism of "OO everywhere" that I offered there.
Though w.r.t. the speculation in your final "Disclaimer", I'd expect the algebraically inclined to prefer either the elegant combinatorical explosion of a J, or the build-it-yourself language of a metaprogrammed Lisp.
I find OOP useful for configuration and library-level APIs (e.g. for injecting dependencies and wrapping things), when there is really zero expectation that a user should have to understand the class definition entirely. But that's about it.
Where it seems to break down for me is when classes are actively used at the application implementation level for general encapsulation. E.g. data access layers. This can be done perfectly fine with modules/packages. Adding inheritance is just asking for trouble in a team setting IMHO.
Trouble comes when there is no clear peer review and style policy to avoid classes for anything other than config or distributing libraries (as separate projects). What ends up happening is a proliferation of subclasses or method overrides when developers are in a rush to ship features without understanding the whole codebase. This is a technical loan with very high interest.
It makes sense rationally in the moment, as classes have an inviting feel to the user as a kind of grab bag of related functionalities that are easily introspected (at first). Compare it to searching through docs for all the different namespaces in a package and learning what types their various functions support, it requires more thought and grasping the concepts of the library. Alternatively, a class with a broadly defined purpose starts to look like a common utility to throw things at. It's a grab bag of stuff that is more amenable to hacking with blinders on, in a way.
Next thing you know the chains of inheritance and method overrides have grown into a very hard to disentangle hydra. The accumulated overhead of maintaining it is compounded by the debugging challenge of knowing where exactly a given instance is coming from and which layers it has been through on its way to a given breakpoint.
The mental effort of building up taxonomies in particular is exactly what put me off inheritance (and fussy type systems in general).
It's too easy to jump down that rabbit hole without considering whether it's a productive activity.
Inheritance is used inappropriately (outside of frameworks) almost everywhere.
When you do build programs like this, code gets more and more rigid until it becomes a maintenance nightmare, and extremely expensive to pivot functionality. Sometimes having a complex object hierarchy literally stops you making some new feature.
A lot of mental effort is sucked up in mashing problem spaces into hierarchies, and once there the program is constrained by them.