Design principles Liskov - realestateexplorer/SystemDesign GitHub Wiki
explain liskov substitution principle and provide examples in c#
Of course! Let's break down the Liskov Substitution Principle (LSP) in a clear and practical way, using C# for all the examples.
The Liskov Substitution Principle is the "L" in the SOLID acronym. It was introduced by Barbara Liskov and states that:
Objects of a superclass shall be replaceable with objects of a subclass without affecting the correctness of the program.
In simpler terms: if you have a piece of code that works with a base class, it should also work with any of its derived classes without you having to change that code or worry about it breaking.
A subclass must honor the "contract" of its parent class. This means it shouldn't do something completely unexpected. If a Dog
class inherits from an Animal
class, you should be able to substitute a Dog
object anywhere you'd use an Animal
object and have the program still behave predictably.
A subclass honors the contract of its superclass if it follows these rules:
- Preconditions cannot be strengthened. A subclass method shouldn't require more from its inputs than the base class method did.
- Postconditions cannot be weakened. A subclass method must guarantee at least as much as the base class method's output or side effects.
- Invariants must be preserved. The internal state and rules of the object must remain consistent.
- Don't throw new exceptions. A subclass method should not throw exception types that the base class method is not expected to throw.
If you violate these, you break the substitutability, and thus, violate LSP.
This is the most famous example used to explain LSP because it seems logical at first but quickly reveals the behavioral problem.
Mathematically, a square is a rectangle. So, it's tempting to model it with inheritance.
// The base class
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area => Width * Height;
}
// The derived class - seems logical, right?
public class Square : Rectangle
{
public override int Width
{
get => base.Width;
set
{
base.Width = value;
base.Height = value; // A square's sides must be equal
}
}
public override int Height
{
get => base.Height;
set
{
base.Height = value;
base.Width = value; // A square's sides must be equal
}
}
}
Now, let's create a client (a piece of code that uses these classes) that doesn't know anything about Square
. It only knows how to work with Rectangle
.
public class AreaCalculator
{
public static void PrintArea(Rectangle r)
{
// Set the width and height, then calculate the area.
// The user of this method expects a rectangle to behave like a rectangle.
r.Width = 5;
r.Height = 10;
Console.WriteLine($"{r.GetType().Name} Area: {r.Area}");
// We expect the area to be 50 (5 * 10)
}
}
Let's see what happens when we use this AreaCalculator
.
public static void Main(string[] args)
{
Rectangle rect = new Rectangle();
Console.WriteLine("Testing with a Rectangle:");
AreaCalculator.PrintArea(rect); // Pass the base class instance
Console.WriteLine();
// Now, we substitute the base class with the derived class.
// According to LSP, this should not break our program's logic.
Rectangle square = new Square();
Console.WriteLine("Testing with a Square:");
AreaCalculator.PrintArea(square); // Pass the subclass instance
}
Output:
Testing with a Rectangle:
Rectangle Area: 50
Testing with a Square:
Square Area: 100
This is a clear violation of LSP!
The AreaCalculator.PrintArea
method has a reasonable expectation: if I set the Width
to 5 and the Height
to 10, the area should be 50. But when we passed in a Square
, setting the Height
to 10 also silently changed the Width
to 10. The program's logic was broken because the Square
subclass had a different behavior (a stronger postcondition) than its Rectangle
parent. The Square
object was not a valid substitute for a Rectangle
object.
The problem is the inheritance hierarchy. A Square
doesn't behave like a Rectangle
from a programming perspective, even if it's one mathematically. A better approach is to use a more abstract base class or an interface.
// A more abstract base that doesn't enforce separate Width/Height
public abstract class Shape
{
public abstract int Area { get; }
}
public class Rectangle : Shape
{
public int Width { get; set; }
public int Height { get; set; }
public override int Area => Width * Height;
}
public class Square : Shape
{
public int SideLength { get; set; }
public override int Area => SideLength * SideLength;
}
With this design, a client can work with any Shape
to get its Area
, but it cannot incorrectly assume it can set Width
and Height
independently on all shapes. We have removed the broken contract.
Another common way to violate LSP is by throwing unexpected exceptions in a derived class.
Imagine we have an interface for handling files.
public interface IFile
{
string Read();
void Write(string content);
}
Now we have a standard file that can be read and written to.
public class ReadWriteFile : IFile
{
private string _content;
public ReadWriteFile(string initialContent)
{
_content = initialContent;
}
public string Read()
{
return _content;
}
public void Write(string content)
{
_content = content;
Console.WriteLine("Content written successfully.");
}
}
Later, we need a ReadOnlyFile
. A developer might think to implement IFile
and just block the Write
method.
// LSP VIOLATION!
public class ReadOnlyFile : IFile
{
private readonly string _content;
public ReadOnlyFile(string content)
{
_content = content;
}
public string Read()
{
return _content;
}
public void Write(string content)
{
// This violates the contract. A user of IFile expects to be able to write.
throw new NotSupportedException("This is a read-only file and cannot be written to.");
}
}
A client method that processes a collection of IFile
objects will now crash unexpectedly.
public class FileManager
{
public void UpdateAllFiles(List<IFile> files, string newContent)
{
foreach (var file in files)
{
// This code assumes that any IFile can be written to.
file.Write(newContent);
}
}
}
// In Main
var files = new List<IFile>
{
new ReadWriteFile("Initial content 1"),
new ReadWriteFile("Initial content 2"),
new ReadOnlyFile("This file cannot be changed.") // The problem child
};
var fileManager = new FileManager();
try
{
fileManager.UpdateAllFiles(files, "UPDATED");
}
catch (Exception ex)
{
Console.WriteLine($"An error occurred: {ex.Message}");
}
Output:
Content written successfully.
Content written successfully.
An error occurred: This is a read-only file and cannot be written to.
The program crashed. The ReadOnlyFile
was not a valid substitute for IFile
because it broke the contract by throwing an exception where the user didn't expect one.
The fix is often to create more granular interfaces, which is also a nod to the Interface Segregation Principle (ISP).
public interface IReadable
{
string Read();
}
public interface IWritable
{
void Write(string content);
}
// Now we compose interfaces to build our classes
public class ReadWriteFile : IReadable, IWritable
{
// ... implementation ...
}
public class ReadOnlyFile : IReadable
{
private readonly string _content;
public ReadOnlyFile(string content) { _content = content; }
public string Read() => _content;
// No Write method, so it's impossible to call it!
}
Now, the FileManager
can be more specific about what it needs.
public class FileManager
{
// This method now clearly states its requirement: it needs files it can write to.
public void UpdateAllFiles(List<IWritable> files, string newContent)
{
foreach (var file in files)
{
file.Write(newContent);
}
}
}
This new design is robust. You can't even pass a ReadOnlyFile
to UpdateAllFiles
because it doesn't implement the IWritable
interface. The compiler enforces the Liskov Substitution Principle for you!
- Reliability: It ensures that polymorphism works as expected. You can trust that derived types will behave like their base types.
- Maintainability: You can introduce new subclasses without having to change the code that uses the base class. This supports the Open/Closed Principle.
- Clarity: It forces you to create cleaner, more logical inheritance hierarchies. If a subclass has to "undo" or drastically change a parent's behavior, inheritance is probably the wrong tool for the job.
what is GetType() in provided examples
Excellent question! That's a key detail in the examples that helps illustrate what's happening.
In C#, GetType()
is a fundamental method that is available on every single object.
GetType()
returns the exact, runtime type of the object it's called on.
This is a crucial concept, especially when dealing with inheritance and polymorphism. Let's break down the difference between the compile-time type and the runtime type.
- Compile-time type: This is the type of the variable as you declared it in your code. The C# compiler knows this type when it builds your program.
- Runtime type: This is the actual type of the object that the variable refers to when the program is running. This can be the same as the compile-time type, or it can be a derived (subclass) type.
Let's look at the specific line from the Liskov Substitution Principle example:
Console.WriteLine($"{r.GetType().Name} Area: {r.Area}");
This line is inside the PrintArea
method, which has this signature:
public static void PrintArea(Rectangle r)
The parameter r
has a compile-time type of Rectangle
. The compiler only knows that r
is, at a minimum, a Rectangle
.
However, when we call this method, the runtime type of the object we pass in can be different.
Rectangle rect = new Rectangle();
AreaCalculator.PrintArea(rect);
- Inside
PrintArea
, the variabler
is now referencing an actualRectangle
object. -
r.GetType()
is called. It looks at the actual object in memory and sees it's aRectangle
. It returns aType
object representing theRectangle
class. -
.Name
is a property on theType
object that returns the class name as a string:"Rectangle"
. - The output is:
Rectangle Area: 50
Rectangle square = new Square(); // Note: compile-time type is Rectangle
AreaCalculator.PrintArea(square);
- Inside
PrintArea
, the variabler
is now referencing an actualSquare
object. Even though the variabler
is of typeRectangle
, the object it points to is aSquare
. -
r.GetType()
is called. It looks at the actual object in memory and discovers it's aSquare
. It returns aType
object representing theSquare
class. -
.Name
is called on thatType
object and returns the string:"Square"
. - The output is:
Square Area: 100
In the provided examples, r.GetType().Name
was used for demonstration purposes. It allowed us to print the actual class name of the object being processed, making it crystal clear that in one case we were dealing with a Rectangle
and in the other, a Square
.
This visually proves the core of the problem: even though the PrintArea
method only knows about the Rectangle
class, it was behaving differently and incorrectly when a Square
object was "substituted" in its place. GetType()
helped us peek under the hood to see which type of object was causing that behavior.
Here is a very simple example to see GetType()
in action outside of the LSP context:
// The variable 'myObject' has a compile-time type of 'object', the most generic type.
object myObject;
// 1. Assign a string
myObject = "Hello, World!";
Console.WriteLine($"The variable is holding an object of type: {myObject.GetType().Name}");
// 2. Assign an integer
myObject = 123;
Console.WriteLine($"The variable is now holding an object of type: {myObject.GetType().Name}");
// 3. Assign a list
myObject = new List<int>();
Console.WriteLine($"The variable is now holding an object of type: {myObject.GetType().Name}");
Output:
The variable is holding an object of type: String
The variable is now holding an object of type: Int32
The variable is now holding an object of type: List`1
As you can see, GetType()
always reveals the true, underlying type of the object at runtime, regardless of the type of the variable holding it.