S.O.L.I.D. Principles in C# (Series)

S.O.L.I.D. Principles in C# (Series)

C#
S.O.L.I.D.
Programming
Software Engineering
Design Principles
2020-08-04

The Liskov Substitution Principle states that if B is a subclass of A, then anywhere you can use A, you should be able to use B without breaking things. In other words, subclasses should fully adhere to the contract established by their base classes so that they can be used interchangeably without surprises.

Imagine a MediaPlayer base class designed to play audio and video files. A naive subclass might throw an exception whenever it encounters a file type it can’t handle. This breaks LSP because code that expects a generic media player now gets unexpected exceptions from the subclass.

// Potential Violation of LSP public class MediaPlayer { public virtual void Play(string filePath) { // Play audio or video } } public class AudioPlayer : MediaPlayer { public override void Play(string filePath) { // Might throw exception if it's a video, violating user expectations throw new NotSupportedException("Cannot play videos"); } }

A better approach is to either design the base class with narrower expectations—ensuring the subclass can always fulfill them—or give the subclass a graceful fallback if the file type isn’t supported.

public abstract class MediaPlayer { public abstract void Play(string filePath); } public class UniversalPlayer : MediaPlayer { public override void Play(string filePath) { // Handles audio or video gracefully } } public class AudioOnlyPlayer : MediaPlayer { public override void Play(string filePath) { // If video, gracefully skip or notify // Avoid unexpected exceptions } }

A classic illustration of LSP involves Rectangle and Square. At first glance, it might make sense to consider Square a subclass of Rectangle. However, a square always has equal width and height, which can break the expectations set by methods that rely on a standard rectangle’s width and height being independently changeable.

public class Rectangle { public virtual int Width { get; set; } public virtual int Height { get; set; } public int GetArea() { return Width * Height; } } public class Square : Rectangle { public override int Width { get { return base.Width; } set { base.Width = value; base.Height = value; } } public override int Height { get { return base.Height; } set { base.Width = value; base.Height = value; } } }

Here, the Square class changes the behavior of Width and Height setters so that they’re always equal. This can produce unexpected results if existing code assumes it can set Width and Height independently. Moreover, methods testing a rectangle’s properties will be surprised if they can’t differentiate between the two dimensions.

Ensuring that subclasses maintain the promises of their parent class can be tricky. Use these tips to avoid common pitfalls:

  • Avoid Overly General Parents: If the base class supports behavior that not all subclasses can handle, you risk violating LSP. Try factoring out specialized behavior or using composition instead.
  • Assert or Guard, Don’t Just Fail: If your subclass can’t handle a certain input, consider alternatives like gracefully degrading or raising a validation error early, rather than failing silently or abruptly.
  • Focus on Clear Contracts: Document how methods and properties are intended to behave. Subclasses that can't maintain these specifics should likely not inherit from that class.

Sometimes, an interface-based design can sidestep the need for a deep class hierarchy, making violation of LSP less likely.

public interface IPlayable { void PlayAudio(string filePath); void PlayVideo(string filePath); } public class UniversalPlayer : IPlayable { public void PlayAudio(string filePath) { // Implement audio playing } public void PlayVideo(string filePath) { // Implement video playing } } public class AudioOnlyPlayer : IPlayable { public void PlayAudio(string filePath) { // Implement audio playing } public void PlayVideo(string filePath) { // Graceful fallback: maybe ignore or log } }

Violating LSP often causes subtle bugs. Code that works with a base class might break mysteriously when handed a subclass that changes the contract. By ensuring each subclass fulfills and respects the base class’s promises, you keep your inheritance hierarchy consistent and reliable.

Next on the list is the Interface Segregation Principle, which is all about preventing bloated interfaces.

See you in Part 4!
– Nate

Go to Part 4