Liskov substitution principle (LSP) – Architectural Principles
The Liskov Substitution Principle (LSP) states that in a program, if we replace an instance of a superclass (supertype) with an instance of a subclass (subtype), the program should not break or behave unexpectedly.Imagine we have a base class called Bird with a function called Fly, and we add the Eagle and Penguin subclasses. Since a penguin can’t fly, replacing an instance of the Bird class with an instance of the Penguin subclass might cause problems because the program expects all birds to be able to fly.So, according to the LSP, our subclasses should behave so the program can still work correctly, even if it doesn’t know which subclass it’s using, preserving system stability.Before moving on with the LSP, let’s look at covariance and contravariance.
Covariance and contravariance
We won’t go too deep into this, so we don’t move too far away from the LSP, but since the formal definition mentions them, we must understand these at least a minimum.Covariance and contravariance represent specific polymorphic scenarios. They allow reference types to be converted into other types implicitly. They apply to generic type arguments, delegates, and array types. Chances are, you will never need to remember this, as most of it is implicit, yet, here’s an overview:
• Covariance (out) enables us to use a more derived type (a subtype) instead of the supertype. Covariance is usually applicable to method return types. For instance, if a base class method returns an instance of a class, the equivalent method of a derived class can return an instance of a subclass.
• Contravariance (in) is the reverse situation. It allows a less derived type (a supertype) to be used instead of the subtype. Contravariance is usually applicable to method argument types. If a method of a base class accepts a parameter of a particular class, the equivalent method of a derived class can accept a parameter of a superclass.
Let’s use some code to understand this more, starting with the model we are using:
public record class Weapon { }
public record class Sword : Weapon { }
public record class TwoHandedSword : Sword { }
Simple class hierarchy, we have a TwoHandedSword class that inherits from the Sword class and the Sword class that inherits from the Weapon class.
Covariance
To demo covariance, we leverage the following generic interface:
public interface ICovariant
{
T Get();
}
In C#, the out modifier, the highlighted code, explicitly specifies that the generic parameter T is covariant. Covariance applies to return types, hence the Get method that returns the generic type T.Before testing this out, we need an implementation. Here’s a barebone one:
public class SwordGetter : ICovariant
{
private static readonly Sword _instance = new();
public Sword Get() => _instance;
}
The highlighted code, which represents the T parameter, is of type Sword, a subclass of Weapon. Since covariance means you can return (output) the instance of a subtype as its supertype, using the Sword subtype allows exploring this with the Weapon supertype. Here’s the xUnit fact that demonstrates covariance:
[Fact]
public void Generic_Covariance_tests()
{
ICovariant swordGetter = new SwordGetter();
ICovariant weaponGetter = swordGetter;
Assert.Same(swordGetter, weaponGetter);
Sword sword = swordGetter.Get();
Weapon weapon = weaponGetter.Get();
var isSwordASword = Assert.IsType(sword);
var isWeaponASword = Assert.IsType(weapon);
Assert.NotNull(isSwordASword);
Assert.NotNull(isWeaponASword);
}
The highlighted line represents covariance, showing that we can implicitly convert the ICovariant subtype to the ICovariant supertype.The code after that showcases what happens with that polymorphic change. For example, the Get method of the weaponGetter object returns a Weapon type, not a Sword, even if the underlying instance is a SwordGetter object. However, that Weapon is, in fact, a Sword, as the assertions demonstrate.Next, let’s explore contravariance.