Why Won’t this CuboidCylinder Fit?!?!
Like Bertrand Meyer’s Open/Closed Principle, the Liskov Substitution Principle is an object-oriented software design principle that is unfortunately poorly understood within the industry. It seeks to deal with code quality issues arising for poor use of inheritance mechanisms in object-oriented languages.
This post is part of a series on software design principles. As with all design principles, this is a general guideline, not a hard and fast rule. Following principles such as this will improve code quality, legibility, testability, and maintainability.
The Liskov Substitution Principle (LSP) is the L in the SOLID design principles acronym. The principle was defined by Barbara Liskov and Jeannette Wing in a 1994 paper they co-authored:
Subtype Requirement: Let Φ (x) be a property provable about objects x of type T. Then Φ (y) should be true for objects y of type S where S is a subtype of T.
Liskov & Wing, 1994
Despite the alphabet soup and Greek letters, the premise behind this statement is rather simple: a derived type should maintain (ie: not violate) the contract(s) established by its base type. Of the three pillars of object-oriented languages – encapsulation, inheritance, and dynamic dispatch – the LSP deals specifically with dynamic dispatch, although it appears on the surface to be about inheritance. This is because the LSP pertains to members of a derived type that override a member of its base type. As such, the LSP imposes a number of method signature and behavioral restrictions on software design that will come naturally when it is adhered to correctly.
The first set of restrictions deal with method signatures in derived types:
- contravariance of method parameters in derived types
- covariance of method return values in derived types
- no new exceptions thrown by overriding methods in the derived types that aren’t themselves derived from exceptions already thrown by the base type/method
The concept of contra- and covariance warrants its own article. This deals with inheritance hierarchies and directionality pertaining to method arguments and return types. The contra- and co- prefixes can be thought of as away or toward from the base type, respectively. That is, contravariance of method parameters would allow for more-derived types (ie: away from the base type) of method parameters; covariance of return values would allow for a less-derived type (ie: toward the base type) in a return value.
Most object-oriented languages don’t allow derived types to change the signature of overridden methods. They do, however, support contravariance through the use of generics. Consider the following type definitions:
public abstract class Animal { }
public abstract class Feline : Animal { }
public abstract class BigCat : Feline { }
public abstract class DomesticCat : Feline { }
public class Lion : BigCat { }
public class Leopard : BigCat { }
public class Tabby : DomesticCat { }
public class MaineCoon : DomesticCat { }
public interface IAnimalEmplacement<in TAnimal> // IN: contravariant
{
void PlaceAnimal(TAnimal animal);
}
public interface IAnimalRetrieval<out TAnimal> // OUT: covariant
where TAnimal : Animal
{
TAnimal GetAnimal();
}
public abstract class AnimalEnclosure<TAnimal>
: IAnimalEmplacement<TAnimal>, IAnimalRetrieval<TAnimal>
where TAnimal : Animal
{
public abstract PlaceAnimal<TAnimal>();
public abstract TAnimal GetAnimal();
}
public class BigCatEnclosure : AnimalEnclosure<BigCat>
{
public void PlaceAnimal(BigCat bigCat) { /**/ }
public BigCat GetAnimal() { /**/ }
}
While BigCatEnclosure is simply a derived generic type, its implementation of PlaceAnimal is technically contravariant. However, its implementation of GetAnimal is technical a violation of the LSP since its return value – BigCat – is more derived than Animal, and thus the opposite of covariance.
C# does, however, support contra- and covariance of generic interfaces type parameters. Given the types above, the following code is valid:
var bigCatEnclosure = new BigCatEnclosure();
IAnimalEmplacement<Lion> lionEmplacement = bigCatEnclosure;
IAnimalRetrieval<Feline> felineRetrieval = bigCatEnclosure;
Lion, the generic type parameter of lionEmplacement is more derived than BigCat, so the assignment works. This is because object of type Lion are valid to pass into any method that accepts a BigCat. So assigning bigCatEnclosure to lionEmplacement is valid. Likewise, Feline is less derived than BigCat, making the assignment of bigCatEnclosure to felineRetrieval also valid. Note that this requires the interfaces to separated along lines of directionality (TAnimal as an input or output value), as well as the use of the in and out keywords in the respective interface definitions.
The third signature requirement of the LSP is really only valid in terms of “method signature” for Java-like languages that require thrown exceptions to be explicitly listed. For other languages that don’t require a proactive declaration of potential exception, this should be considered a behavioral requirement.
private BaseHandler _handler;
public void Action(Handleable handleable)
{
try
{
_handler.Handle(handleable);
}
catch (ArgumentException ae)
{
// handle argument exception
}
}
Action assumes that any implementation of BaseHandler only throws an ArgumentException. If _handler were to be assigned an instance of a type derived from BaseHandler that throws a InvalidOperationException instead, then that exception would not be caught without explicitly refactoring Action to catch it. This exception could percolate up the call stack and crash the application.
However, if that derived type were to throw an ArgumentOutOfRangeException instead, that would still be valid since ArgumentOutOfRangeException derives from ArgumentException and thus would be caught by the caller’s existing catch statement. It is imperative to ensure that overriding methods only throw the same exception types – including exceptions derived from those types – as the overridden base type method.
Aside from these 3 signature requirements, the Liskov substitution principle also applies several behavioral restrictions on methods in derived types. These behavioral restrictions are:
- preconditions cannot be strengthened in a derived type
- postcoditions cannot be weakened in a derived type
- invariants of a base type must be preserved in a derived type
While these requirements are often cited separately, they really boil down to the same concept that underpins the “exception signature” requirement. Take the following code, for example:
public abstract class Vehicle { }
public abstract class Airplane : Vehicle
{
public bool IsInAir { get; protected set; }
public virtual void TakeFlight()
{
if (IsInAir)
throw new InvalidOperationException("Already in flight.");
IsInAir = true;
}
public virtual void Cruise(Point destination)
{
if (!IsInAir)
throw new InvalidOperationException("Must be flying to cruise.");
/* move to destination through the air */
}
public virtual void Land()
{
if (!IsInAir)
throw new InvalidOperationException("Must be flying to land.");
IsInAir = false;
}
public void Disembark()
{
/* flight crew leaves the airplane */
}
}
A precondition of TakeFlight is that the instance must not yet be flying, and a postcondition is that the instance is flying afterward. Likewise, a precondition of Land is that the instance must be in flight, and a postcondition is that it is no longer flying. A precondition and invariant of Cruise is that the instance is flying: that is, IsFlying must be true before the method is called, and is still true afterward.
Now, consider a derived type:
public class SpaceShuttle : Airplane
{
public bool BoostersFired { get; private set; }
public bool ParachuteDeployed { get; private set; }
public bool IsInSpace { get; private set; }
private void ReenterAtmosphere()
=> IsInSpace = false;
private void DeployParachute()
=> ParachuteDeployed = true;
public void FirstBoosters()
=> BoostersFired = true;
public override void TakeFlight()
{
if (!BoostersFired)
throw new InvalidOperationException("Boosters must be fired prior to flight.");
base.TakeFlight();
IsInAir = false;
IsInSpace = true;
}
public override Cruise(Point destination)
{
if (!IsInSpace)
throw new InvalidOperationException("Must be in space to cruise.");
/* move to destination through space */
}
public override void Land()
{
if (!(IsInSpace || IsInAir))
throw new InvalidOperationException("Must have taken off prior to landing.");
ReenterAtmosphere();
DeployParachute();
Task.Delay(TimeSpan.FromMinutes(20), () => base.Land());
}
}
It’s unfortunately common to consider deriving types in order to inherit seemingly similar functionality. In this case, that functionality is the concept of powered flight. A space shuttle, like an airplane, can take-off, cruise, and land. However, while the space shuttle may look very much like like an airplane, it operates very differently. A space shuttle must fire booster engines prior to taking off. It must also be in space before it can cruise. And it must re-enter the atmosphere and execute a controlled descent (such as with parachutes) before landing.
As a derived type, this SpaceShuttle implementation violates the LSP. Specifically, it strengthens preconditions on both the TakeFlight and Land methods by requiring extra state not required by the base class. It also changes the invariant on Cruise from IsInAir to IsInSpace. And finally, it weakens the postconditions on TakeFlight (such that IsInAir is false immediately after) and Land (so that IsInAir is still true immediately after and is only eventually is set to false).
These changes in behaviors are a problem when considering how the instances might be used. Take this method for example:
public void FlyToLocationAndReturn(Airplane airplane, Point desintation)
{
Point currentLocation = GetCurrentLocaton();
airplane.TakeFlight();
airplane.Cruise(destination);
/* do something at destination location */
airplane.Cruise(currentLocation);
airplane.Land();
airplane.Disembark();
}
Because SpaceShuttle inherits from Airplane, a SpaceShuttle instance can be passed in to the first parameter. However, the method knows nothing about SpaceShuttle and its extra requirements to fire boosters. And if this method exists in a separate library from the one in which SpaceShuttle is defined, it might be impossible to refactor it to type-check and up-cast the airplane instance for additional flight preconditions.
As written, if a SpaceShuttle instance were to be passed into this method, it would fail. TakeFlight would throw an exception because the boosters haven’t been fired yet. If that were somehow resolved – such as by calling FireBoosters prior to calling FlyToLocationAndReturn – there would be a problem with the end of the method. Specifically, because there is no 20-minute wait after calling Land before the SpaceShuttle touches down, the flight crew would be forced off the “airplane” while it was still in the air and descending.
Note that it would be ok to weaken preconditions, strengthen postconditions, and add invariants. For example:
public class Harrier : Airplane
{
public bool EnginesHot { get; private set; }
public override TakeFlight() => IsInAir = true;
public override Cruise(Point destination)
{
base.Cruise(destination);
EnginesHot = true;
}
public override Land()
{
IsInAir = false;
if (EnginesHot)
Task.Delay(TimeSpan.FromMinutes(10), () => EnginesHot = false);
}
}
Note here that preconditions are weakened: TakeFlight doesn’t require the instance not to be in the air first, and Land doesn’t require the instance to be in the air. Similarly, postconditions are only strengthened: that is Cruise still maintains the invariant that the instance is in the air, but also results in the engines being hot; whereas Land still results in the instance no longer being in the air. Land adds an invariant: that is, if the instance had cruised somewhere before landing, the engines will still be hot (although they would eventually cool off, the state is still the same immediately before and immediately after the call).
Inheriting similar functionality in this way only to change pre-, post- and invariant conditions results in code with messy and incomprehensible state checks surrounding otherwise innocuous method calls. State must be repeatedly checked and asserted due to failed contracts. The code would be brittle, and testing would become extremely complicated due to the extra required assertions.
There is one final constraint, however: the so-called history constraint. This poorly-named constraint takes into consideration the states that are possible in any given type’s life-cycle, and requires derived types’ lifecycles to be limited to those same states.
For example, consider these types:
public abstract class Bird : Animal
{
public bool IsFlying { get; }
public void Run(Point destination)
{
if (IsFlying)
throw new InvalidOperationException("Cannot run while flying.");
/* run across the ground to destination */
}
}
public abstract class FlightlessBird : Bird
{
}
public abstract class FlightedBird : FlightlessBird
{
public override bool IsFlying { get; private set; }
public void Fly() => IsFlying = true;
public void Land() => IsFlying = false;
}
Because FlightedBird has functionality not present in FlightlessBird – ie, flight – it can be instinctive to have FlightlessBird inherit from FlightlessBird and simply add a method for that new functionality. Here, it’s possible for Bird instances to be in flight, or not in flight. FlightlessBird then limits possible states to always be not in flight, which is a state permissible by the base Bird class.
However, FlightedBird then re-enables the potential in-flight state. This creates a state paradox because all FlightedBird instances are also FlightlessBird instances, which themselves can never be in flight (that is, self-powered flight; flightless birds may simply “fly through the air” due to falling a far distance or being launched by an external force… please do not catapult chickens in an attempt to disprove this statement).
As with the SpaceShuttle example above, this will lead to unexpected runtime behaviors because consumers of the base-type objects may not have a way of transitioning instances of derived types to states that are permitted by the base type. For example, if a method that takes a FlightlessBird were to call Run on that instance, but the value at runtime is a FlightedBird that’s in the air, the result would be an InvalidOperationException. The consuming method would have no way of transitioning its FlightlessBird instance to the ground without type-checking and up-casting to a FlightedBird. This would have to be repeated for any and all types derived from FlightlessBird that re-enable flight, which would be a daunting set of checks and an even more complicated set of test.
One potential fix for this can be equally instinctive and tempting, but is just as incorrect: to simply flip the inheritance of FlightedBird and FlightlessBird. However, this would in and of itself posea different set of violations of the LSP in terms of pre-, post- and invariant conditions, as well as exception handling. Specifically, if FlightlessBird inherits Fly, it would have to override that method and throw exceptions that might not be thrown by FlightedBird. At the very least, it would be forced to modify state expectations.
The correct inheritance hierarchy is for FlightlessBird and FlightedBird to inherit from Bird separately. That is, they’re all Bird types, but this is where the similarities end. Proper object-oriented design should reflect that.
The Liskov Substitution Principle is short. However, packed into its terseness are far-reaching consequences that should be immediately self-evident in object-oriented design. Unfortunately, compilers are not sufficiently advanced to detect some of these violations for us. Software engineers must take care while designing their code to maintain robustness and adhere to contracts inherited in type hierarchies. This minimizes required state checks, and consequently helps to minimize required testing.
One common way of expressing the LSP is to summarize all of the above as follows:
if S is a subtype of T, then objects of type T may be replaced with objects of type S (i.e. an object of type T may be substituted with any object of a subtype S) without altering any of the desirable properties of the program
Wikipedia
To put it even more concisely: when creating a derived type, do not violate the contracts present on the base type.
I read this piece of writing fully concerning the difference of most up-to-date and previous technologies, it’s amazing article.
LikeLike
I have to thank you for the efforts you have put in writing this website. I am hoping to see the same high-grade blog posts by you later on as well. In fact, your creative writing abilities has encouraged me to get my own website now 😉
LikeLike
Thanks! It’s awesome to know that my work is inspiring to someone to share their own experiences!
LikeLike
Hi! I understand this is kind of off-topic however I had to ask.
Does managing a well-established blog such as yours require
a massive amount work? I’m brand new to blogging but I
do write in my diary on a daily basis. I’d like to start a blog
so I can share my experience and views online.
Please let me know if you have any suggestions or tips for new aspiring blog owners.
Appreciate it!
LikeLike
I’m relatively new to blogging myself. I just started this blog last summer, right before moving to Washington.
Blogging does take some discipline. I like to prepare and do research to make sure that there’s not a greater mind that disagrees with my take on these concepts. I also like to make sure that my code compiles and works. It does add time, but not substantially.
As long as you’re writing within your field of expertise, it’s not that much of a time commitment.
LikeLike