The Liskov Substitution Principle (LSP) is a fundamental concept in object-oriented design, ensuring that derived classes can be substituted for their base classes without affecting the correctness of a program. This principle, first introduced by Barbara Liskov, emphasizes the importance of maintaining behavioral consistency across inherited classes.
Definition
The original definition found in the paper by Barbara Liskov states:
Let be a property provable about objects of type . Then should be true for objects of type , where is a subtype of .
However, this can be translated into the much more digestable definition:
Subtypes must be substitutable for their base types.
While this may seem intuitive, some programming languages allow subtypes to modify the behavior of their supertypes in ways that can violate this principle, leading to unintended consequences in code. The LSP serves as a guideline to avoid such pitfalls.
Basics of Object-Oriented Design
When discussing object-oriented design, we often encounter two types of relationships:
- Inheritance: Describes an IS-A relationship.
- Example: A square is a rectangle.
- Composition: Describes a HAS-A relationship.
- Example: A house has a price property.
The LSP suggests that the IS-A relationship alone is insufficient. Instead, it should be replaced with an IS-SUBSTITUTABLE-FOR relationship, ensuring that derived types can stand in for their base types without introducing errors.
Example - The Rectangle-Square Problem
Consider the classic example involving rectangles and squares:
- A rectangle has four sides and four right angles.
- A square, by definition, has four equal sides and four right angles.
- Geometrically, a square is a rectangle.
Let’s explore this with some code:
public class Rectangle
{
public virtual int Height { get; set; }
public virtual int Width { get; set; }
}
public class AreaCalculator
{
public static int CalculateArea(Rectangle r)
{
return r.Height * r.Width;
}
}
If we implement a Square
class inheriting from Rectangle
, we might do something like this:
public class Square : Rectangle
{
private int _side;
public override int Height
{
get { return _side; }
set { _side = value; }
}
public override int Width
{
get { return _side; }
set { _side = value; }
}
}
Since a square is a rectangle, Square
inherits from Rectangle
. We enforce that both the height and width are the same by setting them to _side
.
The Problem
Consider the following code:
Rectangle myRect = new Square { Width = 4, Height = 5 };
Assert.Equal(20, AreaCalculator.CalculateArea(myRect));
// False - The actual result is 25.
Here, we expect the area to be 4 * 5 = 20
, but instead, we get 25
. This result is incorrect because the Square
class violates the expectations set by the Rectangle
class.
What Happened?
- A
Square
has an invariant: all sides must be equal. - A
Rectangle
allows its width and height to vary independently.
The design of the Square
class breaks the Rectangle
’s invariant, meaning we can no longer substitute Square
for Rectangle
without causing errors. This is a clear violation of the LSP.
Possible Solutions
To resolve this issue, we can approach it in a couple of ways:
-
Remove the
Square
class and add a flag toRectangle
:public class Rectangle { public int Height { get; set; } public int Width { get; set; } public bool IsSquare => Height == Width; }
-
Create a separate
Square
class without inheriting fromRectangle
, representing a square explicitly:public class Rectangle { public int Height { get; set; } public int Width { get; set; } } public class Square { public int Side { get; set; } }
This approach avoids violating the LSP and provides a clearer representation of both shapes.
Detecting LSP Violations in Real Life
1. Type Checking with is
or as
in Polymorphic Code
Type checking in polymorphic code often signals an LSP violation.
Example
foreach (var user in users)
{
if (user is Admin)
{
Helper.PrintAdmin(user as Admin);
break;
}
Helpers.PrintUser(user);
}
This code checks if a user
is an Admin
, which suggests that the Admin
type isn’t fully substitutable for the base User
type. If additional subtypes like RestrictedUser
are introduced, you’ll need to update all type checks, violating the Open/Closed Principle (OCP).
Solution
Ensure subtypes are fully substitutable by implementing custom functionality in each subtype:
foreach (var user in users)
{
user.Print(); // Each user type implements its own Print method.
}
Alternatively, adjust the Helper
method to work with any user type, ensuring the loop doesn’t violate the LSP.
2. Null Checks
Null checks can also indicate LSP violations, as nulls aren’t substitutable for actual instances.
foreach (var user in users)
{
if (user == null)
{
throw new NullReferenceException();
}
Helpers.PrintUser(user);
}
Instead of checking for nulls, consider using the Null Object Pattern to avoid this issue.
3. NotImplementedException
Throwing a NotImplementedException
can indicate that only some features of an interface or base class were implemented, violating LSP.
Example
public interface ILoggingService
{
void SendWarning(string message);
void SendInfo(string message);
}
public class WarningService : ILoggingService
{
public void SendWarning(string message)
{
// Send the user a warning message.
}
public void SendInfo(string message)
{
throw new NotImplementedException();
}
}
In this example, WarningService
doesn’t fully implement ILoggingService
, making it non-substitutable and thus violating the LSP.
LSP as a Subset of Polymorphism
Polymorphism describes an IS-A relationship, while the LSP extends this to an IS-SUBSTITUTABLE-FOR relationship. Classes that adhere to LSP also adhere to polymorphism. Violating LSP usually indicates a violation of polymorphism.
Fixing LSP Violations
1. Follow the “Tell, Don’t Ask” Principle
Avoid querying an object’s type or state to make decisions. Instead, encapsulate behavior within the object:
foreach (var user in users)
{
user.Print(); // Tell the user to print itself.
}
This leads to more modular, cohesive designs.
2. Address Null References
Utilize C# features such as nullable reference types, null conditional operators, and guard clauses. Alternatively, implement the Null Object Pattern to handle nulls more gracefully.
3. Fully Implement Interfaces
When implementing an interface, ensure all methods are fully implemented, adhering to the Interface Segregation Principle (ISP) to avoid LSP violations.
Key Takeaways
- Ensure subtypes are substitutable for their base types.
- Maintain the invariants of base types in subtypes.
- Detect potential LSP violations by looking for type checks, null checks, and
NotImplementedException
occurrences.