In this fourth part of my thoughts, we continue with the letter "L", following the order proposed by the SOLID acronym. I'll be working with TypeScript in the examples.
In this article
Liskov Substitution Principle
Abstract example
Technical example (Front-End)
Technical example (Back-End)
Personal example
Functional example
Applicabilities
Final thoughts
Liskov Substitution Principle
The Liskov Substitution Principle is named after Barbara Liskov, who originally described it. This principle proposes that a class can be replaced by its subclasses without affecting the behavior of the system.
Initially, let's set aside the reason why you would need to replace a class with a derived subclass and focus on applicabilities. Consider the following scenario:
- I have a class called
Class
; - I have a subclass called
Subclass
that extendsClass
; - I have an object
const obj = new Class()
; - If I want to switch to
const obj = new Subclass()
, it should still work;
That's the practical summary of the principle. Now, let's move on to some examples.
Abstract example
Imagine that, extending from our Book class, we identify books that are incomplete. However, the superclass expects there to be a quantity of books sold - functionality that was "lost" in the subclass.
🔴 Incorrect Implementation
Imagine que, estendendo da nossa classe de livro, identificamos livros que não estão completos. Contudo, a classe superiora espera que exista uma quantidade de livros vendidos - funcionalidade que foi "perdida" na subclasse.
// We have our class for the book entity.
class Book {
private sales: number;
constructor(private title: string, private author: string) {}
// We can set how many sales the book has.
public setSales(sales: number) {
this.sales = sales;
}
// And we can check how many sales the book has.
public checkSales() {
console.log(
`The book "${this.title}" by "${this.author}" has sold ${this.sales} copies!`
);
}
}
class IncompleteBook extends Book {
// VIOLATION OF THE PRINCIPLE: As an incomplete book has no sales, an error is returned here.
// In other words, it wouldn't be possible to replace a Book instance with an IncompleteBook without
// causing some unexpected side effects.
public checkSales() {
// Let's not dwell on the fact that we're literally causing an error here.
// It's an abstract example.
throw new Error(
"This book has not yet been completed, therefore, it has no sales!"
);
}
}
const book = new Book("The Time Machine", "H. G. Wells");
book.setSales(50000000);
book.checkSales();
const incompleteBook = new IncompleteBook("The Time Machine", "H. G. Wells");
incompleteBook.setSales(50000000);
incompleteBook.checkSales(); // Here, it will throw an error. Literally, of course, because of what we did.
🟢 Correct Implementation
class Book {
private sales: number;
constructor(private title: string, private author: string) {}
public setSales(sales: number) {
this.sales = sales;
}
public checkSales() {
console.log(
`O livro "${this.title}" de "${this.author}" vendeu ${this.sales} exemplares!`
);
}
}
// Draft would be a more adequate name, since it can transform to a Book later.
class Draft extends Book {
// We've created a new property here to control the state.
private isComplete: boolean = false;
public setComplete(isComplete: boolean) {
this.isComplete = isComplete;
}
// It will only return an error if explicitly set.
public checkSales(): void {
if (this.isComplete) super.checkSales();
}
}
// Now, both cases work normally.
const book = new Book("The Time Machine", "H. G. Wells");
book.setSales(50000000);
book.checkSales();
const incompleteBook = new IncompleteBook("The Time Machine", "H. G. Wells");
incompleteBook.setComplete(true);
incompleteBook.setSales(50000000);
incompleteBook.checkSales();
Technical example (Front-End)
For Front-End abstraction, let's imagine a Button entity that was extended to the subclass DisabledButton. If I substitute one for the other, what unexpected behaviors might I encounter?
🔴 Incorrect Implementation
// Class for our button entity.
class Button {
// We start with the button text.
constructor(public label: string, public event: () => void) {}
// We have the generic logic for the press event.
public onPress() {
console.log("The button was pressed! Executing event...");
this.event();
}
}
// However, a disabled button will not be able to execute the method correctly.
class DisabledButton extends Button {
public onPress() {
console.log("The button is disabled and cannot be pressed.");
}
}
// When we instantiate the button as Button, we get the expected behavior.
const myButton = new Button("Click here", () => console.log("Clicked!"));
myButton.onPress();
// OUTPUT:
// Clicked!
// VIOLATION OF THE PRINCIPLE: If we substitute it with DisabledButton, the behavior changes.
// If this was not expected, the principle was violated. That is, to be characterized as a violation,
// it does not necessarily need to return an error, but it needs to exhibit an unexpected abnormal behavior.
const myButton2 = new DisabledButton("Click here", () => console.log("Clicked!"));
myButton2.onPress();
// OUTPUT:
// The button is disabled and cannot be pressed.
🟢 Correct Implementation
Here, personally, I would say that the ideal would be not to use a subclass called DisabledButton, but to transform its implementation into a isDisabled property within Button. Some questions may arise:
- Doesn't this violate the Single Responsibility Principle? In my point of view, no. Being enabled or disabled is still part of the context of a button.
- How do I know when I should or shouldn't discard the subclass? Of course, be careful with uncontrolled refactoring. Analyze carefully and assess the risks.
Technical example (Back-End)
🔴 Incorrect Implementation
// Here we have an ABSTRACT class for a database.
abstract class DatabaseConnection {
abstract connect(): void;
abstract query(query: string): void;
abstract disconnect(): void;
}
// Then we can specify the CONCRETE implementation for each type of database below.
class MySQLConnection extends DatabaseConnection {
connect(): void {
console.log("Connecting to MySQL.");
// Specific implementation for MySQL.
}
query(query: string): void {
console.log(`Executing MySQL query: ${query}`);
// Specific implementation for MySQL.
}
disconnect(): void {
console.log("Disconnecting from MySQL.");
// Specific implementation for MySQL.
}
}
class MongoDBConnection extends DatabaseConnection {
connect(): void {
console.log("Connecting to MongoDB.");
// Specific implementation for MongoDB.
}
// VIOLATION OF THE PRINCIPLE: No implementation was actually developed for this type of database.
query(query: string): void {
// No implementation was made.
}
disconnect(): void {
console.log("Disconnecting from MongoDB.");
// Specific implementation for MongoDB.
}
}
// This will work.
const mysqlConnection = new MySQLConnection();
mysqlConnection.connect();
mysqlConnection.query('SELECT * FROM MY_TABLE');
mysqlConnection.disconnect();
// If we try to switch for MongoDB, it will fail.
const mongodbConnection = new MongoDBConnection();
mongodbConnection.connect();
mongodbConnection.query('SELECT * FROM MY_TABLE'); // Error: Cannot read property 'query' of undefined
mongodbConnection.disconnect(); // This will not even run.
🟢 Correct Implementation
The solution is simple: the subclass must obey the limitations imposed by the superclass. If the class is demanding the implementation of the query
method, then it must be implemented.
Personal example
In Donkey Kong 64, you can have multiple characters, each with their own attacks, special abilities, and even collectibles. However, what if I need a special attack, but one of the characters doesn't have it?
It's a very similar situation to the Back-End example, and it's all about honoring the contract from the class you're extending from.
🔴 Incorrect Implementation
// Imagine we have a Kong class for each character.
abstract class Kong {
abstract attack(): void;
abstract specialAbility(): void;
}
class DonkeyKong extends Kong {
public attack() {
console.log('Donkey Kong is attacking!')
}
public specialAbility() {
console.log('Donkey Kong is using a special ability!')
}
}
class DiddyKong extends Kong {
public attack() {
console.log('Diddy Kong is attacking!')
}
// VIOLATION OF THE PRINCIPLE: This method was not implemented for Diddy Kong.
// Now, imagine we switch characters in the middle of the game. The game
// would have crashed because this method was not implemented.
public specialAbility() {
// No implementation was actually done.
}
}
🟢 Correct Implementation
Bom, novamente, a solução é visualmente simples: Diddy Kong deve, obrigatoriamente, implementar os métodos propostos por Kong.
Functional example
Let's keep our functional examples talking about file reading. Let's suppose we have an interface, a contract, for a FileReader for our application. Therefore, I can create multiple fileReaders, each for a distinct situation, as long as they fulfill our contract.
However, what if the concrete implementation of these interfaces is not properly done?
🔴 Incorrect Implementation
// Here we have an interface that allows us to create
// how many custom File Readers we want.
type FileReaderFunction = (file: string) => { result: string };
// This one properly works.
const myFileReader: FileReaderFunction = (file) => {
const fileContent = fileSystem.read(file);
return { result: "Here's the content from the file: " + fileContent };
};
// VIOLATION OF THE PRINCIPLE: If you're going to implement an
// interface, you better honor what it proposes. Here,
// the file reader is not properly implemented.
const anotherFileReader: FileReaderFunction = (file) => {
throw new Error("This was not implemented!");
};
🟢 Correct Implementation
You guessed it: anotherFileReader should adhere to the Liskov Substitution Principle by actually honoring and respecting the contract of FileReaderFunction.
Applicabilities
The Liskov Substitution Principle is very interesting because it proposes a consistent hierarchy among the members of the same system and, in my opinion, suggests more reflective work than actual code refactoring. As Martin himself comments, issues with violating this principle are often identified too late, and the solution ends up being an if-else.
However, its applicability can be very well observed during the design phase. A developer responsible for implementing a new functionality by extending an existing class will start to consider how substitutable it would be, or if it would really be worth applying this extension.
Final thoughts
Of all the principles I've discussed so far, I believe this one is among the most contextual. In my humble opinion, constantly "banging your head" to make this principle work isn't always the right solution - sometimes a simple unification of contexts makes much more sense.
In this article, I addressed the example of the Book entity, and whether it is complete or not could very well be part of the same entity; but for didactic purposes, I chose to refactor considering the principle in question. Regardless of the path taken, the best benefit from this principle is, undoubtedly, the critical look at the extensions made.
Top comments (0)