Liskov Substitution: The real meaning of inheritance
91 comments
·January 21, 2025sunshowers
rramadass
> 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.
sunshowers
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.
rramadass
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".
Also relevant is my other comment here - https://news.ycombinator.com/item?id=42788947
Salgat
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.
Spivak
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.
izietto
Same for Ruby, and you don't even need to inherit. You include the Enumerable module, implement next, and your instance is suddenly iterable
cma
Can constructors and destructors still go counter to this idea if needed?
sunshowers
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.
bts
And functional programmers would argue that contravariance is the real meaning of Liskov’s substitution principle: https://apocalisp.wordpress.com/2010/10/06/liskov-substituti...
gsf_emergency
>So LSP just says “predicates are contravariant”
Maybe just leave out the "just" for a pleasant journey?
Since the interesting part of Barbara's uh, guideline, as "almost" pointed out by your link, is "almost" the opposite of "almost" trivial..
Don't mind me, I'm imbecilic :)
(See your link's comments, at least those imbeciles "almost" get it)
zbyforgotp
A practical rephrasing of LSP: subclasses should be subtypes.
adelmotsjr
From the article:
Again, kudos to Uncle Bob for reminding me about the importance of good software architecture in his classic Clean Architecture! That book is my primary inspiration for this series. Without clean architecture, we’ll all be building firmware (my paraphrased summary).
What does clean architecture have to do with building firmware or not? Plenty of programmers make a living building firmware. Just because they don't need/can't/want to apply clean architecture in their code, doesn't mean they are inferior to those who do.
Furthermore, after a snippet which I suppose it is in Kotlin, there is this:
While mathematically a square is a rectangle, in terms of behavior substitutability, it isn’t. The Square class violates LSP because it changes the behavior that clients of Rectangle expect. Instead of inheritance, we can use composition and interfaces
The Liskov principle is about one of the three types of polymorphism (so far): subtyping polymorphism. Which is about inheritance. Composition is _not_ subtyping. And interfaces (be it Java's or Kotlin's) are another type of polymorphism: ad-hoc. Even Wikipedia[1] has the correct definition:
Ad hoc polymorphism: defines a common interface for an arbitrary set of individually specified types.
Therefore, the examples of interfaces aren't compliant with LSP as well.
I understand the good intentions behind the article, but it left much to be desired. A proper research to at least fix the glaring errors should have been made beforehand.
[1]: https://en.wikipedia.org/wiki/Polymorphism_%28computer_scien...
Kerrick
I’m in the middle of reading Clean Architecture right now. The square/rectangle example is directly from the book.
The firmware statement is an argument made (differently) in the book that software is called soft because it’s easy to change. Firmware is harder to change because of its tight coupling and dependencies (to the hardware). Software that is hard to change due to tight coupling and dependencies could almost be considered firmware—like brand new code without tests can almost be considered legacy.
moi2388
You shouldn’t believe what you read, especially from the book clean code. The principles are somewhat okay, the examples are terrible
Fannon
Here to recommend this article, really helped me to understand inheritance better. Liskov Substitution is just one aspect / type of it and may conflict with others.
https://www.sicpers.info/2018/03/why-inheritance-never-made-...
rramadass
Very Good; Gives the "Correct" overview of different usages (i.e. policy) of Inheritance (i.e. mechanism).
Quote: Inheritance was never a problem: trying to use the same tree for three different concepts was the problem.
rramadass
Like most articles on "Inheritance" this is clueless about providing any "real meaning/understanding". People always take the soundbites (eg. Uncle Bob SOLID) provided as a mnemonic as being the end-all, don't fully understand the nuances and then usually arrive at a wrong conclusion.
LSP (https://en.wikipedia.org/wiki/Liskov_substitution_principle) has to do with behavioural subtyping guaranteeing semantic interoperability between types in a hierarchy. It involves not just the syntax of function signatures but their semantic meaning involving Variance/Invariance/Covariance/Contravariance and their guarantees using an extension to Hoare Logic i.e. Preconditions/Postconditions/Invariants (derived from Meyer's DbC). Thus without enforcing the latter (which is generally done via documentation since there is no syntax for expressing pre/post/inv directly in most languages) the former is incomplete and thus the complete contract is easily missed/forgotten leading to the mistaken belief "Inheritance is bad". The LSP wikipedia page links to all the concepts, the original papers and more for further clarification.
See also Bertrand Meyer's Using Inheritance Well from his book Object Oriented Software Construction, second edition book - https://archive.eiffel.com/doc/manuals/technology/oosc/inher...
Finally see Barbara Liskov's own book (with John Guttag) Program Development in Java: Abstraction, Specification, and Object-Oriented Design for a "correct approach" to OOP. Note that Java is just used as a example language while the principles are language independent.
zwieback
If I remember correctly, Liskov didn't talk about inheritance but subtyping in a more general way. Java, C++ and other, especially statically typed, compiled languages often use inheritance to model subtyping but Liskov/Wing weren't making any statements about inheritance specifically.
cratermoon
>subtyping in a more general way
This is correct. I read her paper closely. One example I give is how SICP provides two implementations for complex numbers[1], the rectangular for and polar form.
(make-rectangular (real-part z) (imag-part z))
and (make-polar (magnitude z) (angle z))
then on page 138 provides this interface that both satisfy (define (real-part obj) (operate 'real-part obj))
(define (imag-part obj) (operate 'imag-part obj))
(define (magnitude obj) (operate 'magnitude obj))
(define (angle obj) (operate 'angle obj))
1 https://mitp-content-server.mit.edu/books/content/sectbyfn/b...rramadass
> use inheritance to model subtyping but Liskov/Wing weren't making any statements about inheritance specifically.
Right. Inheritance is just one mechanism to realize Subtyping. When done with proper contract guarantees (i.e. pre/post/inv clauses) it is a very powerful way to express semantic relationships through code reuse.
rucamzu
I don't feel the rectangle/square example is valid, given that both alternatives follow different designs - there's no Shape base class in the inheritance example. Moreover, I don't think that switching from a base (abstract) class to an interface is enough on itself to call it composition.
The two issues the article mentions have imho less to do with the LSP itself, and more with the limitations that different programming languages have when it comes to define contracts through interfaces (not the same thing), like the lack of exception specs or non-nullability enforcement.
Nezghul
> class Square : Rectangle() { ...
What if instead of Rectangle class we would have ReadonlyRectangle and Rectangle? Square could then inherit from ReadonlyRectangle, so code expecting only to read some properties and not write them could accept Square objects as ReadonlyRectangle. Alternatively if we really want to have only Square and Rectangle classes, there could be some language feature that whenever you want to cast Square to Rectangle it must be "const Rectangle" (const as in C++), so again we would be allowed to only use the "safe" subset of object methods.
cryptonector
I think what you mean is that if a Square that is also a Rectangle can't be made to be non-square, then inheritance works. Which, fair enough, but I think there's still other good reasons that inheritance is a bad approach. Interfaces (and traits) are still way better.
Viliam1234
What is "ReadonlyRectangle"? Is it just an interface that only exposes read-only methods; or is it an explicit promise that the rectangle is immutable?
Perhaps we could go with even more classes. "Rectangle" and "Square" for the read-only methods, without any implications about mutability. "MutableRectangle" and "MutableSquare" for mutable implementations; "ImmutableRectangle" and "ImmutableSquare" for immutable implementations.
- "Rectangle" has methods "getWidth" and "getHeight".
- "Square" has a method "getSide".
- "ImmutableRectangle" implements "Rectangle".
- "ImmutableSquare" implements "Rectangle" and "ImmutableRectangle" and "Square".
- "MutableRectangle" implements "Rectangle"; has extra methods "setWidth" and "setHeight".
- "MutableSquare" implements "Rectangle" and "Square"; has an extra method "setSide".
...or you could just give up, and declare two classes "Square" and "Rectangle" (mutable) that either have nothing in common, or they just both extend some "Shape" class that can paint them and calculate the area.
dang
I thought there were more but these are the only two interesting prior threads I could find. Others?
A better explanation of the Liskov Substitution Principle - https://news.ycombinator.com/item?id=38182278 - Nov 2023 (1 comment)
The Liskov Substitution Principle (2019) - https://news.ycombinator.com/item?id=23245125 - May 2020 (93 comments)
mont_tag
Better to think of LSP as more of a gray scale than all or nothing. The more the APIs match, the more substitutability you gain.
Switching to composition has its advantages but you do lose all substitutability and often need to write forwarding methods that have to be kept in sync as the code evolves over time.
sirwhinesalot
A problem that only exists in OOP codebases. Just don't do it and avoid the issue entirely.
whattheheckheck
What do you work on that avoids oop code bases?
sunshowers
I've written Rust full time for the last 8 years, being part of teams that have shipped several large, transformative, and basically correct projects. No OOP in sight. It's wonderful!
sirwhinesalot
At work we sadly have to implement very OOP-y standards with all the bullshit that entails. 11 levels of inheritance with overrides all over the place sure isn't fun to deal with.
But for things I do myself I use objects and interfaces strictly as a tool to solve specific problems, not as the overall program structure.
Most of the time you just need to turn some bits into other bits with a function, no need to overcomplicate things.
The question of if a square is a rectangle or a rectangle is a square is the sort of thing that comes from OOP brain-rot. They're just data, and their "isa" relationship is likely not even relevant to the problem you're actually trying to solve, like displaying them onscreen.
A "square" could be a function that makes a rectangle out of a single float. A "rectangle" could be a function that produces a polygon. The concepts need not be modeled as types or objects at all.
It depends on the actual use case.
taylodl
That's an interesting perspective on inheritance.
The problem I see inheritance solving is not having to distribute subtype-specific logic throughout your code base - functions can interact with objects in a generic manner and you get to keep subtype-specific code all in one spot. That's a win.
Inheritance isn't the only means for achieving this capability, though. You can also use interfaces and protocols. I prefer to use interfaces. If my class needs to implement an interface than that's explicit: it implements the interface. I can use inheritance if the class really IS-A variant of another class and it can use that base class in fulfilling its obligation in implementing that interface. That's an implementation detail. But the fact it has responsibility for implementing that interface is made explicit.
foobarkey
SOLID and clean code are not some universal bible that is followed everywhere, I spend a considerable amount of effort reasoning juniors and mid levels out of some of the bad habits they get from following these principles blindly.
For example the only reason DI became so popular is that you could not mock static in Java at the time. In FB codebase DI was also used in PHP until they found a way to mock static, after which the DI framework was deprecated and codemods started coming in removing DI. There is literally nothing wrong in using a factory method or constructing what you need on demand. These days static can also be mocked in Java and if you really think about it you see Spring Boot adds a lot of accidental complexity (but sure its convenient and well tested so its ok to use), concepts like beans and beanfactories are not essential for solving any business problem
Which brings me to S in SOLID, which I think is probably top 2 worst principles in software engineering (the no 1 spot goes to DRY). Somehow it came from some early 2000-s TDD crowd and the test pyramid, it makes sense if you embrace TDD, mocking, test pyramid and unit tests as a good thing. In reality that style of software is really hard to understand, every problem is split into 1000 small pieces invoking each other usually in some undefined ways, no flow can be understood without understanding and building a mental model of the entire 1000 object spaghetti. The tests themselves mostly just end up setting a bunch of mocks and then pretty much coupling the impl and the test on method call level, any change to the impl will cause the tests to break for only the reason that the new method call was not mocked. After going through all this ceremony the tests are not even guaranteeing the thing will work during runtime since the db, kafka or http was mocked out and all the filters, listeners, db validations were skipped. In these days so called integration tests with docker compose are a lot better (use actual db or kafka, wiremock the http level), that way your have a reasonble chance to catch things like did this mysql jdbc driver upgrade broke anything
I have to mention DRY also, the amount of sins caused in name of DRY by juniors is crazy, similar looking lines get moved into a common function/method/util all the time and coupling is introduced between 2 previously independant parts of the system. As the code involves and morphs into something different the original function starts getting more args to behave differently in one case and differently in another case, if it had been left as separate files each could evolve separately. I dont really know how to explain this better than coupling should not be introduced to save few lines of typing or boilerplate, in fact any abstraction or indirection should only be introduced when its really needed, the default mode should be copy/paste and no coupling (the person adding a cross cutting PR will likely not be a jr and has enough experience to know how and when to use grep).
Anyhow I have enough experience to know people are usually too convinced that all this solid, clean code stuff is peak software so I wont expect to change anyones thinking with 1 HN post, it usually takes me 2 years or so to train a person out of this and back to just putting the damn json in db without ceremony. Also need to make sure LLM-s have some good data that is based on experience and not dogmas to learn from :)
As for L, no strong beef with L it’s OK
Fuhrmanator
> sins caused in name of DRY by juniors
A discussion of clones that can be OK and more: <https://cormack.uwaterloo.ca/~migod/papers/2008/emse08-Clone...>
unscaled
The rationale for Dependency Injection was never _just_ about "making testing static methods" easier. In fact, Dependency injection was never about static methods at all. No DI advocate — not even the radical Uncle Bob — will tell you to stop using Math.round() or Math.sqrt(), even though they are static methods.
The main driver for dependency injection was always to avoid strong coupling of unrelated classes. Strong coupling can be introduced by cases like Class A always instantiating a class B which is a particular subtype of class S (i.e. giving up the Liskov substitution principle), Class A initializing class B with particular parameters that cannot be extended or overridden, Class A calling a static method or a singleton method which modifies or reads a global value.
Strong coupling makes you lose on flexibility, reusability and code readability. If you need to modify how either class A or class B behave later, you may now need to painstakingly scan all the places BOTH classes are used (and all the places other classes touching them are used) and modify the way they are constructed. If you want to enable OrderProcessor to accept bank transfers, but it was built to always call "new CreditCardProcessor()" internally inside its constructor, you will now have to find every place CreditCardProcessor is constructed and modify it. The worst offenders I've seen are pure logic classes that have no business having side side effects, but still end up opening multiple files, or doing a bunch of HTTP requests that you cannot avoid, because their authors just thought: "Cool, I can mock all this stuff with PowerMock while testing!"
The other issue I mentioned is code readability. This is especially an issue with singletons or static methods that mutate global state. You basically get the dreaded action-at-a-distance[1]. You might initially write a class that is using a singleton UserSessionManager object to keep track of the current user session. The class only operates on simple single-threaded scenarios, but at one point some other developer decides to use your class in a multi-threaded context. And Boom. Since the singleton UserSessionManager wasn't a part of the interface of your class, the developer wasn't aware that it's being used and that the class is not ready for multi-threaded contexts[2]. But if you've used DI, the dependencies of the classes would have been explicit.
That's the true gist of DI really. DI is not about one heavyweight framework or another (in most cases you could do it quite easily without any framework). It's also not a pure OOP technique (it is common used in functional languages too, e.g. with Reader Monad). Dependency injection is really just about making your dependencies explicit and configurable rather than implicit and fixed.
As a tangent, mocking static methods was possible for a rather long time. PowerMock (which allows mocking statics with EasyMock and Mockito) was available at least since 2008, and JMockit is even earlier, available at least in 2006[3]. So mocking static methods in Java has been possible for a very long time, probably before even 5% of the Java programmers have even started using mock objects.
But it's not always ideal. Unfortunately, tools like PowerMock or JMockit static /final mocking are working by messing with the JVM internals. These libraries often broke down when a new version of Java was released and you had to wait until the compatibility issue was fixed. These libraries also relied on tricks like custom classloaders, Java Instrumentation Agents and bytecode manipulation. These low-level tricks don't play way with many other things. For instance, if you are using a framework which needs its own custom class loader, or when you're using another tool which needs bytecode manipulation. I was personally bitten by this when I wanted to implement mutation testing[4] in Java, and I couldn't get it to work with static mocking. Since I believe mutation testing carries more value than the convenience of being able to mock statics for testing, it was an easy choice to dump Powermock.
[1] https://en.wikipedia.org/wiki/Action_at_a_distance_(computer...
[2] https://testing.googleblog.com/2008/08/by-miko-hevery-so-you...
[3] http://butunclebob.com/ArticleS.MichaelFeathers.ItsTimeToDep...
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.
(Also I just realized I got punked by LLM slop.)