C
C#•23h ago
Thalnos

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:
public interface IDuck{
public void Quack();
}
public class RealDuck : IDuck{
public void Quack() => Console.WriteLine("Quack");
}
public class RubberDuck : IDuck{
public bool HasBatteries { get; set; }
public void Quack(){
if(!HasBatteries) return; //more strict interpretation of the contract, making the inteface not replaceable with the implementation
Console.WriteLine("Quack");
}
}
public interface IDuck{
public void Quack();
}
public class RealDuck : IDuck{
public void Quack() => Console.WriteLine("Quack");
}
public class RubberDuck : IDuck{
public bool HasBatteries { get; set; }
public void Quack(){
if(!HasBatteries) return; //more strict interpretation of the contract, making the inteface not replaceable with the implementation
Console.WriteLine("Quack");
}
}
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:
public interface IQuackBehaviour{
public void Quack();
}
public class NaturalQuack : IQuackBehaviour{
public void Quack() => Console.WriteLine("Quack");
}
public class BatteryPoweredQuack : IQuackBehaviour{
public bool HasBatteries { get; set; }
public void Quack(){
if(!HasBatteries) return;
Console.WriteLine("Quack");
}
}
public interface IDuck{
public IQuackBehavior QuackBehaviour { get; set; }
public void Quack() => QuackBehaviour.Quack(); //can run into unexpected behaviour when there are no batteries for a BatteryPoweredQuack
}
public interface IQuackBehaviour{
public void Quack();
}
public class NaturalQuack : IQuackBehaviour{
public void Quack() => Console.WriteLine("Quack");
}
public class BatteryPoweredQuack : IQuackBehaviour{
public bool HasBatteries { get; set; }
public void Quack(){
if(!HasBatteries) return;
Console.WriteLine("Quack");
}
}
public interface IDuck{
public IQuackBehavior QuackBehaviour { get; set; }
public void Quack() => QuackBehaviour.Quack(); //can run into unexpected behaviour when there are no batteries for a BatteryPoweredQuack
}
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 bugs
51 Replies
GrabYourPitchforks
GrabYourPitchforks•23h ago
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.
Thalnos
ThalnosOP•23h ago
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
GrabYourPitchforks
GrabYourPitchforks•23h ago
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."
Thalnos
ThalnosOP•23h ago
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
GrabYourPitchforks
GrabYourPitchforks•23h ago
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.
Thalnos
ThalnosOP•23h ago
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
GrabYourPitchforks
GrabYourPitchforks•23h ago
But that's a good thing!
Thalnos
ThalnosOP•23h ago
mhm how is it good when the user complains that his app crashed trying to let his duck quack
GrabYourPitchforks
GrabYourPitchforks•23h ago
If something unexpected occurs, you don't want the application continuing as if everything were good.
Thalnos
ThalnosOP•23h ago
what I want is that the damn duck quacks 😄
GrabYourPitchforks
GrabYourPitchforks•23h ago
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.
Thalnos
ThalnosOP•23h ago
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
GrabYourPitchforks
GrabYourPitchforks•23h ago
Then I do not understand what problem you are trying to solve.
Thalnos
ThalnosOP•23h ago
I'm trying to avoid potential for bugs when we want to let ducks quack
GrabYourPitchforks
GrabYourPitchforks•23h ago
By having the caller special-case everything that can possibly go wrong? What happens if your RealDuck has laryngitis?
Thalnos
ThalnosOP•23h ago
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
GrabYourPitchforks
GrabYourPitchforks•23h ago
"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.
Thalnos
ThalnosOP•23h ago
exactly, and that failure point the caller shhouldn't need to be aware of is requiring batteries
GrabYourPitchforks
GrabYourPitchforks•23h ago
Instead, it should be surfaced loudly when somebody is unable to fulfill the contract so that the dev can correct the issue. Agreed!
Thalnos
ThalnosOP•23h ago
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?
GrabYourPitchforks
GrabYourPitchforks•23h ago
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.
Thalnos
ThalnosOP•23h ago
I'm familiar with checked and unchecked exceptions
GrabYourPitchforks
GrabYourPitchforks•23h ago
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.
Thalnos
ThalnosOP•23h ago
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.
public void SetSize(Rectangle rect, int length, int width){
rect.SetLength(length);
rect.SetWidth(width);
}
public void SetSize(Rectangle rect, int length, int width){
rect.SetLength(length);
rect.SetWidth(width);
}
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
GrabYourPitchforks
GrabYourPitchforks•23h ago
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.)
Thalnos
ThalnosOP•23h ago
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
GrabYourPitchforks
GrabYourPitchforks•23h ago
You used two different definitions of "caller" in this message.
Thalnos
ThalnosOP•23h ago
how so?
GrabYourPitchforks
GrabYourPitchforks•23h ago
the caller could pass bad arguments
Here, "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 batteries
Here, "caller" is the entity which created the RubberDuck. It's the producer. It is not the entity which consumes the IDuck or calls Quack.
Thalnos
ThalnosOP•23h ago
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
GrabYourPitchforks
GrabYourPitchforks•23h ago
Then we should switch to "producer" (or "creator") and "consumer" terminology to avoid ambiguity.
Thalnos
ThalnosOP•23h ago
I don't quite understand the differnce you're making I dont see one
GrabYourPitchforks
GrabYourPitchforks•23h ago
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.
Thalnos
ThalnosOP•23h ago
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
GrabYourPitchforks
GrabYourPitchforks•23h ago
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.
Thalnos
ThalnosOP•23h ago
but there can be a RubberDuck with empty or even no batteries inside it
GrabYourPitchforks
GrabYourPitchforks•22h ago
and in the ctor validate that the batteries have sufficient charge
Thalnos
ThalnosOP•22h ago
that would make it so that a RubberDuck cannot exist without charged batteries, but it can...
GrabYourPitchforks
GrabYourPitchforks•22h ago
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.
Thalnos
ThalnosOP•22h ago
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 🤔
GrabYourPitchforks
GrabYourPitchforks•22h ago
LSP is not a suicide pact. The advice above is based on my own experience writing software.
Thalnos
ThalnosOP•22h ago
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
GrabYourPitchforks
GrabYourPitchforks•22h ago
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. 😉
Thalnos
ThalnosOP•22h ago
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
GrabYourPitchforks
GrabYourPitchforks•22h ago
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!)
Thalnos
ThalnosOP•22h ago
so you saying that RubberDuck shouldnt be an IDuck?
GrabYourPitchforks
GrabYourPitchforks•22h ago
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?
Thalnos
ThalnosOP•22h ago
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
GrabYourPitchforks
GrabYourPitchforks•22h ago
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.
Thalnos
ThalnosOP•22h ago
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
Elton F.
Elton F.•6h ago
+

Did you find this page helpful?