Liskov substitution will not save you. One of the worst cases of inheritance I've ever seen was in a hierarchy that was a perfect Liskov fit -- an even better fit than traditional examples like "a JSON parser is a parser". See https://news.ycombinator.com/item?id=42512629.
The fundamental problem with inheritance, and one not shared by any other kind of polymorphism, is that you can make both upcalls and downcalls within the same hierarchy. No one should ever use inheritance in any long-term production use case without some way of enforcing strict discipline, ensuring that calls can only go one way -- up or down, but not both. I don't know to what extent tooling to enforce this discipline exists.
> No one should ever use inheritance in any long-term production use case without some way of enforcing strict discipline, ensuring that calls can only go one way -- up or down, but not both. I don't know to what extent tooling to enforce this discipline exists.
Disagree with your first part. Inheritance used to express Subtyping is different from that used for Code-reuse and yet again different from that used for implementing Framework skeleton structure. You have to disambiguate them carefully when using it. See the article linked to by user "Fannon" here - https://news.ycombinator.com/item?id=42789466
As for tooling, you have to enforce the contract using pre/post/inv clauses following Meyer's DbC and also explicit documentation.
Thanks for that article -- I have to agree with Jacob Zimmerman in the comments to the article:
> I don’t get it. I read one part of the article, think I get it, then I read a different part and what I read there doesn’t jive with what I thought I understood. And I can’t figure out how to reconcile them.
---
> As for tooling, you have to enforce the contract using pre/post/inv clauses following Meyer's DbC and also explicit documentation.
I think we call them asserts and type-level state machines :)
I don't really believe in documentation as enough of a barrier to doing worse things. It must, at a structural level, be easier to do better things.
There is no confusion if you understand that Inheritance is just a "mechanism" to express three (and maybe more) different kinds of "policies" and a single class may implement any or all of them in which case it becomes important to disambiguate which methods/functions express which "policies". There is a abstract concept and a syntactical expression of that concept which needs to be clear in one's mind.
Again, asserts are just the "mechanism" to express pre/post/inv "policies" in code. Without having an understanding of pre/post/inv from the pov of Hoare Logic, merely using asserts will not give you much benefit. Documentation is quite important here.
Both the above can be seen in the design of the Eiffel Language where they are integrated into proper syntactical mechanisms. Once you understand the concepts here, you can apply them explicitly even if your language does not support the needed syntax (eg. Contracts). See Bertrand Meyer's OOSC2 for details - https://bertrandmeyer.com/oosc2/ Specifically "Design-by-Contract (DbC)" and "Inheritance Techniques" and "Using Inheritance well".
I agree that inheritance does too many things and has too many degrees of flexibility. I think other kinds of polymorphism like typeclasses don't have this issue, and are better due to that. Automation is highly preferable to documentation.
I think the discussion would benefit from you concretely working through an example. What change(s) are you proposing to how inheritance is done in C++ or Java, and how would they prevent spaghetti code and nested upcalls/downcalls?
I am not sure that you understood what i wrote. Inheritance's flexibility is its very strength that allows you to express different concepts elegantly. Also no amount of Automation/Tooling/etc. can substitute for documentation explaining the intent behind the code.
The main thing i would like to see in C++/Java/whatever is support for "Design-by-Contract" (DbC) similar to that given in Eiffel. There is already a proposal for C++; see this recent HN discussion - https://news.ycombinator.com/item?id=42131473 Basically this is a way to apply Hoare Logic to functions/methods directly in the implementation language itself. Now use this across the types/classes in a inheritance hierarchy and you can enforce the semantics that you want to express.
Regarding a concrete example; I have already pointed you to Bertrand Meyer's OOSC2 and three specific chapters to read; They walk you through proper examples with explanations. Additionally also see his Applying Design By Contract paper (pdf) here - https://se.inf.ethz.ch/~meyer/publications/computer/contract.... If you would like to see complete application code using a C++ OO framework i suggest creating a sample MFC app using Visual Studio C++ wizard and just looking at the generated code without adding anything of your own. It uses a Document/View architecture (a variation of MVC) where your app specific classes derive from MFC framework given classes. The framework invokes your derived methods (i.e. downcall) which can as needed call back to base's method (i.e. upcall). There is a strong coupling between the framework classes and your app specific ones by design. You can see how different usages of inheritance are implemented to give a powerful app framework; see documentation starting here - https://learn.microsoft.com/en-us/cpp/mfc/document-view-arch...
Sorry I don't think your response really gets to the point. I'm aware of various techniques like contracts, but you're speaking in generalities rather than specifics. So yes, I haven't quite understood what you meant.
This is a common frustration I have with OOP discourse, it tends to be really up-in-the-air and not grounded in concrete specifics. (The article you linked also has this issue.) Meanwhile, users suffer in ways that just don't happen with typeclass-based polymorphism, and none of this discourse is required in my world. So why should I not recommend everyone use typeclass-based polymorphism?
> I am not sure that you understood what i wrote. Inheritance's flexibility is its very strength
No, being too flexible is a weakness, not a strength. At scale, rigorous discipline enforced by tooling is required.
> Also no amount of Automation/Tooling/etc. can substitute for documentation explaining the intent behind the code.
Yes, of course documentation is required. What I'm saying is that if it can be automated, it should be, and that relying on documentation alone is foolish.
In particular, invariants like "no downcalls" or "no upcalls" should 100% be enforced by automation. Documentation is not enough at scale and under pressure.
> i suggest creating a sample MFC app using Visual Studio C++ wizard
I'd rather not?
> The framework invokes your derived methods (i.e. downcall) which can as needed call back to base's method (i.e. upcall).
This sounds really bad to me at scale and under pressure.
I pointed you to a specific book i.e. OOSC2 and three specific chapters in it (to start with) which explain the concepts well with examples you asked for. How much more specific can one get? If you already know contracts then it should be easy to translate the concepts to any language of your choice. Meyer provides a thorough rationale and is extremely detailed in his examples. Furthermore, i also pointed you to one of the largest and commercially most successful class library and application framework (i.e. MFC) where you can see classic OOD/OOP (including upcalls/downcalls) in action; and yet you say i am "speaking in generalities"! It seems you are not willing to read/study but expect a couple of paragraphs to enlighten everything, which is not going to happen.
Eg: Base.method() has {pre1} and {post1} as contracts. Derived.method() has {pre2} and {post2} as contracts. What should be the relationship between {pre2}&{pre1} and {post2}&{post1} to enforce proper subtyping?
> This is a common frustration I have with OOP discourse, it tends to be really up-in-the-air and not grounded in concrete specifics
It is not up-in-the-air when ideas and specific books by authors like Bertrand Meyer and Barbara Liskov (both researchers and practitioners) are being pointed out. Trying to simplify their concepts into a couple of paragraphs would invariably miss important nuances and lead to misinterpretations (the bane of most HN discussions based on trivial articles/blog posts). Hence it is better they are studied directly and then we can have a discussion if you would like.
> Meanwhile, users suffer in ways that just don't happen with typeclass-based polymorphism, and none of this discourse is required in my world. So why should I not recommend everyone use typeclass-based polymorphism?
Sure, there are other types of polymorphisms which can be better in certain scenarios. But that is not under discussion here; we are talking about "traditional" dynamic runtime dispatch based polymorphism which is far easier to understand and implement even in small languages like C.
> No, being too flexible is a weakness, not a strength. At scale, rigorous discipline enforced by tooling is required.
Flexibility increases your "design space" and hence never a weakness. Rigorous discipline is needed throughout development but tooling can only do so much.
> In particular, invariants like "no downcalls" or "no upcalls" should 100% be enforced by automation.
This depends on the concept you are trying to express and cannot be the same in all scenarios (except for direct ones like "interface implementation").
> I'd rather not?
Well, you did ask for a concrete example and i showed you MFC apps.
> This sounds really bad to me at scale and under pressure.
Saying something is "bad" or "spaghetti" without understanding the design concepts behind the implementation is wrong. MFC is one the largest and most successful application frameworks in the industry and has proven itself in all sorts of applications at scale; studying it teaches one lots of OOD/OOP techniques (good/bad/ugly) needed in real-life industry apps.
> Flexibility increases your "design space" and hence never a weakness.
This is just objectively false. Constraints liberate and liberties constrain.
> Rigorous discipline is needed throughout development but tooling can only do so much.
Have you used Rust? I would recommend building some kind of non-trivial command line tool with it — you will quickly see how low your expectations for tooling have been.
> Eg: Base.method() has {pre1} and {post1} as contracts. Derived.method() has {pre2} and {post2} as contracts. What should be the relationship between {pre2}&{pre1} and {post2}&{post1} to enforce proper subtyping?
As someone who understands variance etc quite well, my answer is to simply not have subtypes. You absolutely do not need inheritance subtyping to build production software. (Rust has subtyping and variance only for lifetime parameters, and that's confusing enough.)
> Sure, there are other types of polymorphisms which can be better in certain scenarios. But that is not under discussion here; we are talking about "traditional" dynamic runtime dispatch based polymorphism which is far easier to understand and implement even in small languages like C.
I use traits for runtime dispatch in Rust all the time?
Inheritance is only traditional because C++ and Java made it so. I think it's been a colossal mistake.
> This is just objectively false. Constraints liberate and liberties constrain.
You are completely wrong here. Flexibility by definition means an increase in the allowed degrees of freedom in one or more axes which in turn allows one to mix and match feature sets to express more design concepts (eg. Multi-paradigm). Your second line is a silly slogan which presumably means constraints make the job of picking one choice from a set easier due to less thought needed. It is applicable to inexperienced developers but certainly not to experienced ones who need all the flexibility that a language can give.
> As someone who understands variance etc quite well, my answer is to simply not have subtypes. You absolutely do not need inheritance subtyping to build production software. (Rust has subtyping and variance only for lifetime parameters, and that's confusing enough.)
You have not understood the example. Variance is used to constrain types but pre/post are predicates relating subsets of values from the types; this constrains the state space (cartesian product of the types) itself. Second, your statement not to use subtyping is silly. Subtype relationships arise naturally amongst concepts in any non-trivial system which you can group in a hierarchy based on commonality (towards the top) and variability (towards the bottom). Inheritance is just a direct way of expressing it.
> Inheritance is only traditional because C++ and Java made it so. I think it's been a colossal mistake.
Statements like these betray an ignorance of the subject. I have already shown that Inheritance can be used for different purposes of which Subtyping in the LSP sense is what everybody agrees on. The other uses need experience and discipline but are very powerful when done clearly. Inheritance was first introduced in Simula67 based on a idea presented by Tony Hoare in 1966. C++ popularized it and others simply copied it. See wikipedia for more details - https://en.wikipedia.org/wiki/Inheritance_(object-oriented_p...
PS: This discussion reminded me of "The Blub Paradox" by Paul Graham (https://paulgraham.com/avg.html) which i think most Rust evangelicals suffer from. Just from my cursory look at Rust i have seen nothing compelling to want me to study it in depth over my preferred language of C++. With the addition of more features into "Modern C++" to support Functional Programming it has become even more flexible and powerful albeit with a steeper learning curve.
> Your second line is a silly slogan which presumably means constraints make the job of picking one choice from a set easier due to less thought needed
That is absolutely not what it means, and it is not a silly slogan — it is a basic law of reality.
As an example, if your build system is monadic (build nodes can add new nodes dynamically) then the number of nodes in it is not known upfront. If the build system is not monadic, the number of nodes is determined at the start of the build process.
As another example, the constraints that Rust sets around & and &mut mean that the compiler can do really aggressive noalias optimizations that no one would even dream about doing in C or C++.
> It is applicable to inexperienced developers but certainly not to experienced ones who need all the flexibility that a language can give.
I'm quite an experienced developer, and I've tended to use more constrained languages over time. I love the fact that Rust constrains me by not having uncoordinated shared mutable state.
> This discussion reminded me of "The Blub Paradox" by Paul Graham (https://paulgraham.com/avg.html) which i think most Rust evangelicals suffer from
At Oxide we use Rust and would never have been able to achieve this level of rigor in C++. Hell, try writing anything like my tool https://nexte.st/ in C++ (be sure to get the signal handling exactly right). Rust tooling is at a completely different quality level from earlier-generation languages.
Again, these are all your preferences/opinions which you are stating as some sort of acknowledged truth; which is most definitely not the case. While there are many good points about Rust it is quite over-hyped with evangelical zeal which is why a lot of software engineers are turned off of it. Graydon Hoare himself has said he took the good ideas from old languages and put them together. That in itself is obviously not a bad thing (imo, the industry killed research in programming languages/OS from the mid-nineties when Java was marketed up the wazoo by Sun throwing ungodly amounts of money at it) but the "saviour complex" being pushed is a strict no-no with experienced C/C++ developers.
I don't think there really is any reasonable way to disagree with "constraints liberate, liberties constrain", sorry. Anyone who has spent any amount of time with algebraic structures in mathematics will grasp this intuitively, as will anyone who has written code in a type-safe style. It really is a basic law of nature, similar to other basic principles like Bayes' law.
I only brought in Rust because it does polymorphism in a non-OO style.
Pointing to Algebraic structures is not an argument since it is fundamental to mathematics and hence to programming in any language. What do you think a Type/ADT is? Why do you think user-defined types exist in any language? It is a given, the only difference is the strict pov taken by functional languages vs. imperative (procedural/OO) languages. There is a reason i pointed to the example of linking the derived type's contracts (i.e. properties) with the base type's because that would have made the isomorphism between two algebraic structures clearer (which you didn't seem to understand). Repeating some slogan is not knowledge. You have to go beyond the immediate notation/syntax in any language to the abstract concept it is trying to express. That is the heart of mathematics and programming.
Since you brought up algebraic structures, C++ can express it directly using template syntax and relationships between them using parametric polymorphism non-OO style. Not only that, but because in C++ genericity is unconstrained, we actually can use OO inheritance for a type hierarchy and then using it to get constrained genericity. This flexibility is quite powerful and you get full type checking.
To understand all this (and apply the concepts in any other language) see the books by Alexander Stepanov viz. From Mathematics to Generic Programming and Elements of Programming.
I've never seen a case where inheritance was superior to composition with a shared interface. Worst case with composition, it just returns the injected class's method directly. The beauty is that this really shines when you apply the liskov substitution principle.
I think Python's pattern using inheritance for mixins is probably a good candidate. But Python does have a culture of "inheritance is only for sharing code user beware if you try to use it for other things." Python's ABC classes for collections is also a good use of inheritance. Inherit from MutableMapping, implement the required methods, boom you get all the other mapping methods for free.
Pydantic / dataclass inheritance is elegant for building up different collections of fields. That being said it does use codegen / metaclass hackery to do it.
I think values should generally only be combined into a structure at the end (no half-formed structures with null data, no calls on methods that work on half-formed structures).
Destructors are more complicated, there's definitely times where you have to violate invariants that otherwise are always the case.
The fundamental problem with inheritance, and one not shared by any other kind of polymorphism, is that you can make both upcalls and downcalls within the same hierarchy. No one should ever use inheritance in any long-term production use case without some way of enforcing strict discipline, ensuring that calls can only go one way -- up or down, but not both. I don't know to what extent tooling to enforce this discipline exists.
(Also I just realized I got punked by LLM slop.)