Update
Part of the issue here is that you misunderstand the life<0
as a precondition. RubberDuck explained it better than I could:
@EzoelaVacca I believe you may be misunderstanding the meaning of “precondition”. A precondition is not the internal check you’ve shown in your example. A precondition is what must be true prior to calling a method in order to avoid exceptions/undefined behavior. In other words, a precondition is the state of the system prior to calling a method. It’s that state of the system that can not be made stricter.
Update You dismiss the explanation without actually understanding it:
I do understand that, I included the check so that it is visible what precondition is expected. It does not change the fact that the precondition is stronger in the derived class.
Your continuation proves that you do not understand what RubberDuck meant. The check is not a precondition.
Your example does not have a different precondition between Boss and Enemy. The calling code should not have any expectation of the life value, nor whether it passes the <0 check before calling the method.
The entire point of the method is so that the method can be called without needing to pre-check. The calling code is able to call the method without needing to care about pre-checking if the enemy is dead.
Because if that wasn't the case, then your method should've been a Dispose()
without a built-in check.
This answer is partly based on your comment to the other answer:
No, the provable property is that when you call the method CheckHealth with life<=0, the function Destroy() is called. This is not true for the child. It is clearly violating the requirement of not strengthened pre-conditions.
You're getting caught up on the actual check (life<=0
), not the intention of the method.
First of all, the method is wrongly named. It does not "check health", it tells the enemy to "dispose yourself when you're dead".
Checking the health is indirectly part of it, as it is part of what defines "death" for an enemy. However, as your code shows, the definition for a boss death is different: they need to both have their health dropped to zero and their power source destroyed.
Both for the enemy and the boss, the "dispose yourself when you're dead" contract is the same. The contract merely stipulated a conditional dispose. It does not stipulate the exact condition (what is death?), nor what a dispose actually entails (e.g. a boss may have more resources to dispose of when it dies - which does not violate LSP in any way)
To make this clear, I could rewrite your example to:
class Enemy
{
public sealed void DisposeIfDead()
{
if (this.IsDead)
Dispose();
}
protected virtual bool IsDead => Life <= 0;
}
class Boss: Enemy
{
protected override bool IsDead => Life <= 0 && PowerSourceDestroyed;
}
This proves the point that the DisposeIfDead()
does not behave differently. It simply relies on a different definition of "death". Omitting other differences, a Boss is different from an Enemy specifically because things about them are different, including death conditions.
The LSP focuses on derived classes who behave unexpected from the point of view of a third party.
Let's say I have a method that triggers enemy behaviors. Note that I changed your code example slightly to make the example clearer.
public void HandleEnemyBehavior(Enemy enemy)
{
bool isDead = enemy.DisposeIfDead();
if(!isDead)
{
enemy.MakeMove();
enemy.AttackRandomTarget();
}
}
Let's look back at what you said:
No, the provable property is that when you call the method CheckHealth with life<=0, the function Destroy() is called. This is not true for the child.
Notice how the third party (HandleEnemyBehavior()
) does not care how death is defined. It only care that the method gets called, and in this case it cares about knowing if a death occurred.
HandleEnemyBehavior()
does not have any expectation as to what defines "death" for an enemy. Therefore, Boss
does not break the Enemy
contract. As far as the HandleEnemyBehavior()
method is concerned, every enemy that can be passed to it (Boss
/Enemy
) behaves the same way. It does not violate LSP.
I'll give an example of a third enemy type that does violate LSP:
public class Ghost : Enemy
{
protected override bool IsDead => throw new Exception("Ghosts can't die!");
}
The problem is a bit simplistic (exceptions are clearly not a good idea) but it's a clear example.
This violates LSP, because HandleEnemyBehavior()
is now unable to handle any enemy (Boss
/Enemy
/Ghost
) in the exact same way. The only way to get the method to work is:
public void HandleEnemyBehavior(Enemy enemy)
{
bool isDead = false;
if( !(enemy is Ghost) )
isDead = enemy.DisposeIfDead();
if(!isDead)
{
enemy.MakeMove();
enemy.AttackRandomTarget();
}
}
And this is the issue that violates LSP. HandleEnemyBehavior()
needs to handle ghosts differently from bosses/enemies. Implicitly, HandleEnemyBehavior()
needs to be aware of the immortality of the ghost, which violates the idea of encapsulating the definition of death inside the Enemy
class (and its subtypes).
Somewhat obviously, the correct implementation of Ghost
would be protected override bool IsDead => false;
, which does not violate LSP. The exact definitions of death aren't really the focus of the answer, it's more a matter of observing the external behavior of a class and its subtypes.
Your expectation of the violation of the LSP suggests that you're thinking of a scenario like this:
public void KillEnemy(Enemy enemy)
{
enemy.Life = 0;
enemy.DisposeIfDead();
//The enemy is now dead.
}
This would work for an Enemy
but not for a Boss
(if their power source is still active).
However, you've now violated SRP and encapsulation. The KillEnemy()
method has an expectation of the definition of death. That defeats the purpose of encapsulating the definition of death inside the Enemy
class.
The issue is in the intention of DisposeIfDead()
. It implies that the caller (KillEnemy()
) tasks the object (Enemy
or a subtype) with deciding if it's dead or not.
SRP comes into play here. The method implies that the object defines death criteria. Therefore, if KillEnemy()
were to also contain death criteria, you've written the same thing twice. Both the object and the KillEnemy()
method each have their own definition of death, which may or may not be the same. That is a clear violation of SRP.
No, the provable property is that when you call the method CheckHealth with life<=0, the function Destroy() is called.
This is the internal logic of the enemy class. Using encapsulation, outside agents are unaware of the inside logic of the enemy class.
LSP is violated when the external handling of the class needs to differentiate between subtypes of the class.
LSP ensures that when a third party handles a type of Enemy
, that it does not matter if the real object is an Enemy
or any of its subtypes. The third party does not need to care about the subtype of the object.
In response to one of your comments:
An example from a book: Base class requires an INT to be < 100. The child requires it to be < 50. LSP is violated.
That is an overly simplistic example. The behavior encountered when you pass 75 to the child class very much influences whether LSP was violated.
If it throws an immediate validation exception, you are correct that it violates LSP.
However, if it merely behaves differently (e.g. returns a different value) but does not misbehave (exception, nonsensical information, data corruption), then LSP is not violated.
"Stricter" is a really bad metric to test for violation. Because while you can argue that the boss has "stricter criteria for death", I could argue that the boss has "simpler criteria for staying alive".
The only difference is in the name of the variables and methods, there is no technical distinction between the two.