Liskov's Substitution Principle - The L in SOLID
I believe to understand the principle quite well but I can't think of a way to adhere to it even in a basic example.
"if it quacks like a duck but it needs batteries you got the wrong abstraction".
Say there is
RealDuck
and RubberDuck
. Both can quack, but the rubberduck needs batteries to quack. How can this be written without running into unexpected beahviour and with adhering to the principle?
To illustrate the problem with the naive approach:
Now wherever we use an IDuck
reference and call quack, we could run into unexpected behaviour of silence when a RubberDuck is passed and the user of that contract doesn't know that it needs batteries.
Now I thought I could address this by using composition over inheritance with a strategy design pattern but it turns out that also violates the principle:
I don't quite see how this could possible be modelled adhering to the principle, would appreciate to help me understand how to do this properly without potential bugs51 Replies
Generally speaking, if you're consuming an
IDuck
, you're relying on whoever provided you the concrete implementation to have performed whatever setup is needed on the target object. If some entity has instantiated a RubberDuck
but forgotten to place fresh batteries into it, the error is on their part, since whoever instantiated it was responsible for doing this.
As a consumer, you were not responsible for performing this setup. That batteries are even involved at all is an implementation detail that you don't need to know about.
A better implementation of RubberDuck::Quack
would also be for the implementation to throw an InvalidOperationException
if there are no batteries rather than to no-op. That way it forces the current operation to halt, and it provides a clear and actionable error message that whoever instantiated the RubberDuck
missed a step.
The exception InvalidOperationException
is the appropriate exception type to throw when an object cannot fulfill the contract. Violating the contract by failing silently is typically the worst possible behavior.my issue is that the caller wouldn't even know what went wrong he only sees silence. My IDuck.Quack() has no way of letting him know what went wrong, nor to know that IQuackBehaviour.Quack() may throw an InvalidOperationException
Another common exception type is
NotSupportedException
. The key difference is: NotSupportedException
means "I can't fulfill this contract by design." And InvalidOperationException
means "I would normally be able to fulfill this, but something about my current internal state (like a dead battery!) is preventing me from doing this."the exception could just be thrown without being caught, cuz the contract doesn't indicate there could be a NotSupportedException, only one of it's implementations does
Yes, that's the intent of unchecked exceptions like what .NET has.
If the caller does not know how to handle an exception, the caller allows the exception to percolate up to somebody who does know how to handle it.
Importantly: if an unexpected failure occurs, the caller does not continue their current operation. In your example, the caller doesn't observe silence. In fact, the caller doesn't observe anything. The caller is dead and can no longer act as an observer.
yeah but noone in the call stack would ever know there is a potential NotSupportedException, cuz the contract doesn't expose it. It would be unexpected, potentially even in prod
But that's a good thing!
mhm how is it good when the user complains that his app crashed trying to let his duck quack
If something unexpected occurs, you don't want the application continuing as if everything were good.
what I want is that the damn duck quacks 😄
Then put in fresh batteries when you create the
RubberDuck
. 🙂
FWIW, most web frameworks wrap request processing in a top-level request handler. So even if something unexpected occurs, it terminates the current request but leaves the web server open to take other requests. Potential downside: if the exception truly was unexpected, your internal app state might be corrupted, and now future requests are being operated on by a corrupted aplication.
It's not all that common for this to happen, but it does occasionally happen, especially if the app logic is touching some type of app-global state during request handling.mhm I don't think it's that easy. In another common example wew don't let Square inherit Rectangle because of LSP. We just let both implement another abstraction to avoid having a value interpreting / value changing inheritance.
On that example we can avoid potential bugs on our side and don't leave it to the caller. I feel there should be a way for the Duck example too, I just can't seem to find it.
If we left it to the caller to take care of a Square having same length and width, that is problematic
and equivalently here we got interpreting inheritance, just not of a value but of a behaviour
Then I do not understand what problem you are trying to solve.
I'm trying to avoid potential for bugs when we want to let ducks quack
By having the caller special-case everything that can possibly go wrong?
What happens if your
RealDuck
has laryngitis?the caller could mess it up and even unnoticed, there should not be room for messup
the type is responsible of working correctly, it's not the caller's responsibility
"There should be no room for messup" is not a realistic engineering principle.
If you have a contract like
IDuck
, then you should clearly delineate responsibilities: implementers of the contract must fulfill <some behavior>, and consumers of the contract may rely on <that behavior>.
It's not the responsibility of the consumer to know or care about any potential failure point within the implementer.exactly, and that failure point the caller shhouldn't need to be aware of is requiring batteries
Instead, it should be surfaced loudly when somebody is unable to fulfill the contract so that the dev can correct the issue.
Agreed!
if at least the IQuackBehaviour would expose that it may throw, that would be fine. Then at least the bug cannot be unnoticed. But it wouldn't make sense to say it can throw in the summary if only one of it's implementations may thhrow would it?
In your example, the
BatteryPoweredQuack
abstraction is unnecessary because nobody cares about it. That batteries are required is simply an detail of the RubberDuck
type, and IDuck
(and its consumers) don't need to worry about it.
You should read up on "checked exceptions" (like Java) vs. "unchecked exceptions" (like .NET). .NET very explicitly chose an unchecked exception design specifically to avoid this type of complexity seeping throughout typical .NET apps.I'm familiar with checked and unchecked exceptions
Then I still don't understand your question. In the system you propose,
IQuack::Quack
doesn't need to list any specific exception type unless there's some common enough exception type that callers should reasonably be expected to be aware of or handle.
Callers in your system aren't reasonably expected to handle "dead battery" exceptions.
If the caller is not expected to remediate the situation, then the operation still fails. The app user still ultimately observes that no quack has occurred.
So the app's availability is still impacted.mhm let me demonstrate with the Square and Rectangle example what I want.
When a Square inherits a Rectangle, then it interpretes a and b more strictly, because they need to be equal. This is a violation of LSP because we cannot use Square as a substitute for all rectangles.
Workd like a charm for a regular rectangle but runs into an issue when it is a square and length, widt aren't equal.
We don't want to give the caller this room for messup, so we simply don't let Square inherit Rectangle's width and length because value interpreting inheritance leads to bugs. We let both share another abstraction which they both extend with their length and width that have different meaning.
In the Duck example we also have interpreting inheritance which I want to address without leaving room for messup to the caller. Just that it is a behavior not a value being interpreted differently.
A rubberduck interpretes quacking diferent from a RealDuck, more strict and I'd like to have my IDuck substitutable with any type of duck without possible issues
You're making an excellent argument for why these data structures should be readonly once they're created, btw. 😉
Allowing mutability of simple data types (like Rectangle) is almost always a recipe for disaster.
But I think your analogy is still flawed. The key difference is that in your rectangle / square case, if
SetSize
fails for some reason, it's because the caller passed bad arguments to the method. The caller is responsible for the values of these arguments.
In the Quack
example, the caller doesn't have such agency. The contract says this object can quack, so, "object, I demand that you quack!"
(In effect, your rectangle / square analogy is really a covariance / contravariance argument.)I think its analogue, in one case the caller could pass bad arguments by passing a Square and unequal width and length, in the other the caller could pass a RubberDuck without batteries. We don't want to give him that potential of running into an unexpected issue
and equivalently in the rectangle example the contract says, this rectangle has length and width that we can set
You used two different definitions of "caller" in this message.
how so?
the caller could pass bad argumentsHere, "caller" means the entity which received an existing
Rectangle
object and invoked its SetSize
method. It's the consumer.
the caller could pass a RubberDuck without batteriesHere, "caller" is the entity which created the
RubberDuck
. It's the producer. It is not the entity which consumes the IDuck
or calls Quack
.in both cases it is the caller that creates the Rectangle or the Duck object and passes a concrete object to some method that expects a polymorph argument
Then we should switch to "producer" (or "creator") and "consumer" terminology to avoid ambiguity.
I don't quite understand the differnce you're making
I dont see one
My original argument at the beginning of this conversation was: the entity which produces / creates the
RubberDuck
is responsible for changing its batteries. The entity which consumes the IDuck
needn't worry about any of this.
If the producer fails to change the batteries, an exception will bubble up and be logged, and the dev will look at the error and say "oh, I forgot to put 'change batteries' code here!"
The consumer doesn't need to worry about whether things can go wrong. If they go wrong, so be it. It's not the consumer's responsibility to trap these failures or to try to remediate them.right, but in the rectangle example it is possible to not have the producer/creator run into that exception in the first place, and thats what I want for the duck too
without him needing to do it right, it simply can't be done wrong
But you have literally introduced a failure class into your example!
Then define the
RubberDuck
ctor as so: RubberDuck(Battery powerCells)
-- and in the ctor validate that the batteries have sufficient charge. This moves the potential failure point up as early as possible, potentially even to app start if you have a proper DI system.but there can be a RubberDuck with empty or even no batteries inside it
and in the ctor validate that the batteries have sufficient charge
that would make it so that a RubberDuck cannot exist without charged batteries, but it can...
That's fine. Your data types don't need to model things that are useless to your app. In the physical world a rubber duck can surely exist without batteries, but that doesn't mean your app needs to allow a
RubberDuck
to exist without a charged Battery
.my game / simulator or whatever may also allow a RubberDuck to not have batteries or to have empty batteries. Besides even if you require fully charged batteries at instantiation, they can go empty after use
I think what this discussion comes down to is that there is no way to model this properly without room for error, but then I wonder why it is among the two most common examples to illustrate LSP when it's not even possible to adhere to LSP in it 🤔
LSP is not a suicide pact.
The advice above is based on my own experience writing software.
right, but what did the author of the saying "when it quacks like a duck but it needs batteries you got the wrong abtraction" have in mind how to write that code adhering to LSP 😄 it seems impossible to me
In my experience, you can go down endless rabbit holes trying to prevent problems, but at a certain point you need to ship something. Turns out making money is a good thing for affording food. 😉
yeah fair enough. It's just that I find the SOLID pinciples soooo imporrtant and useful, but LSP specifically never really clicked to me. Well I thought it did but appaently not haha
if I even struggle to let a duck quack
I like the "Example 2" section of https://codesoapbox.dev/solid-principles-3-the-liskov-substitution-principle/, btw.
The article kind of beats around this a bit, but the caller's expectation of the
ISpaceship::Shoot
interface method is to destroy the target. (Even though the method name is "Shoot".)
The article suggests that if the implementation cannot meet that expectation -- even if it has a naive "shoot" function -- it can't fulfill the contract and thus shouldn't implement the interface.
(BTW, this is something we discuss frequently in .NET API review!)so you saying that RubberDuck shouldnt be an IDuck?
So I wonder if that's what the "you've got the wrong abstraction" part is about. Yes, a rubber duck can technically quack, but what was the end user actually interested in? Was quacking the end goal in itself, or was quacking a means to an end? And can a rubber duck still fulfill whatever that desired implicit end goal was?
but both make quack, it's not that one only makes sound of shooting while the other really shoots. It's just that there is a more strict requirement. But perhaps it's the same takeaway and those two classes really shouldn't share an abstraction
I mean, a pea gun can technically "shoot" in that it launches a projectile at a target. 🙂
But it's not going to destroy anything.
now that I think about it yeah what would be the benefit of having a ToyDuck and a RealDuck interchangable
I think that helped me understand better, thx a lot
really appreciate that deep dive into something rather trivial, thank you for your time 🙂
I think I got influenced too much by the Rect/Square example where the proper solution was still sharing an abstraction, just not one being the abstraction for the other. But in the RealDuck/RubberDuck example I think the way to go is not having those interchangable at all
rather we should have IRealDuck with different species of ducks that quack naturally, and IToyDucks with different types of toyducks that quack battery powererd and those two don't share a contract
+