DEV Community

Cover image for Liskov Substitution Principle Isn’t Complex. Just Give It A Try
Mohamed Mayallo
Mohamed Mayallo

Posted on • Edited on • Originally published at mayallo.com

Liskov Substitution Principle Isn’t Complex. Just Give It A Try

Introduction

As we all know, software requirements always change, and we as developers need to make sure that these changes don't break the existing code. For this reason, the SOLID principles were introduced in Object-Oriented design to ease this process.

The SOLID principles are a set of principles set by Robert C. Martin (Uncle Bob). These principles help us create more flexible, maintainable, and understandable software. These principles are:

After introducing the Open-Closed Principle in the previous article, in this article, we will discuss the third principle Liskov Substitution Principle (LSP) which is the L in the SOLID acronym.

Definition

Lets introduce the mathematical definition of the LSP and then jump into the details. The mathematical definition is introduced by Barbara Liskov in 1988:

If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.

The basic object-oriented design controls the relationship between objects using either Inheritance or Composition. Inheritance, the IS-A relationship, occurs when something IS A kind of another thing. For example, a horse IS AN animal. On the other hand, Composition, the HAS-A relationship with something else. For example, an address HAS A city related to it. The LSP brings additional constraint to the Object-Oriented Design and states that these relationships aren't sufficient and should be replaced with IS-SUBSTITUTABLE-FOR.

But what that means? Simply, it means that a supertype should be replaceable with its subtype without breaking the existing code. In other words, the supertype should behave the same as its subtype.

Having said that, how can we guarantee that replacing a supertype with a subtype has no side effects on our existing code?

LSP extends OCP

Keep in mind that the five SOLID principles are somehow related to each other. And following one principle does not ensure that you are following the others correctly.

As we will see, the LSP is extending the Open-Closed principle, and following the rules of the OCP isn't enough to ensure that your code is open for extension and closed for modifications. But your code as well has to conform to the Liskov Substitution Principle to avoid any side effects.

Below is an example that will help you better understand this point:

Follows OCP, not LSP

As you see, this example is following the OCP in a perfect way. That is, if we want to add a new employee role, all we have to do is add a new class including the new role functionality that conforms to the IEmployee contract.

That sounds good. But let me ask you a question, how would you build the Guest role? yes, the guest role can listPosts but how would you deal with the login functionality as there is no authentication for a guest employee?

I think you could respond, I can leave it blank with no functionality at all, or I can throw an unsupported exception in the login method. Yes, these solutions are intuitive if you don't keep in mind the LSP.

Again, you could ask me, since I have fulfilled the OCP perfectly and that's the most important thing to me, then what's the problem if I violate the LSP? I think this question has an implicit correct point which is some principles are more important than others. However that said, we should not ignore a principle because it is less important.

As we already knew, the LSP is all about substitution between a supertype and a subtype without impacting the existing client code. Keep in mind this point and lets jump again into your solutions:

  • leaving it blank with no functionality at all: Now, your client code expects the login function to return an authenticated user token, what will happen if you used the Guest.login that returns nothing? Logically, it will break your existing code, wont it?

  • throwing an unsupported exception: Again, your existing code doesn't handle this new exception from Guest.login. So, as a result, using the Guest.login will break the existing code.

Surprisingly, this design perfectly follows the OCP, however, it violates the LSP.

The LSP Rules

Liskov Substitution Principle Rules

Unfortunately, there is no easy way to enforce this principle in your code. However, to apply this principle correctly in your code, there are two types of rules you have to follow, the Signature rules and the Behavior rules .

Although you can enforce the Signature rules using a compiled language like Java, you can't enforce the Behavior rules , instead, you need to implement your own checks to enforce a specific behavior.

Firstly, let's introduce the Signature rules:

1- Countervariance of Method Arguments : It is a conversion from a more specific to a more general type. In other words, the overridden subtype method argument has to be the same as the supertype or wider.

class SuperType {
   getItem(item: string) {}
 }

 class SubType extends SuperType {
   getItem(item: string | number) { // Wider input type
     if (typeof item === 'string') {}
     else if (typeof item === 'number') {}
   }
 }

 const ins = new SuperType(); // Try to replace the SuperType with SubType
 const stringInput = 'string';
 ins.getItem(stringInput);
Enter fullscreen mode Exit fullscreen mode

If the client code expects a string-only argument in the SuperType, logically, if you replaced SuperType with SubType which accepts string or number arguments (wider), the client code will notice no differences.

2- Covariance of Return Types : It is a conversion from a more general type to a more specific one. In other words, the return type of the overridden subtype method has to be the same as the supertype or narrower.

class SuperType {
  getItem(): number | string {
    const anyCond = true;
    if (anyCond) {
      return 'String output';
    }
    return 5;
  }
}

class SubType extends SuperType {
  getItem(): string {
    return 'Only string output';
  }
}

const ins = new SuperType(); // Try to replace the SuperType with SubType
const res = ins.getItem();
if (typeof res === "string") {
  // Handle the string response
} else if (typeof res === "number") {
  // Handle the number response
}
Enter fullscreen mode Exit fullscreen mode

The client code has already handled a string or number response coming from the SuperType. So if you replaced the SuperType with the SubType which returns only a string response, logically, the client code wouldn’t break.

3- Exceptions : The subtype method has to throw the same exceptions of the supertype or narrower. In fact, this rule couldn't be enforced by all compiled languages. There are some languages that can enforce it like Java, and others that couldn't enforce it like TypeScript.

 class SuperType {
   getItem(itemId: number) {
     if (!itemId) throw new Error("NOT_FOUND");
     if (itemId <= 0) throw new Error("INVALID_INPUT");
   }
 }

 class SubType extends SuperType {
   getItem(itemId: number) {
     if (!itemId) throw new Error("NOT_FOUND"); // Narrower exceptions
   }
 }

 try {
   const ins = new SuperType(); // Try to replace the SuperType with SubType
   ins.getItem(-1);
 } catch (error: any) {
   if (error.message === "NOT_FOUND") {}
   else if (error.message === "INVALID_INPUT") {}
 }

Enter fullscreen mode Exit fullscreen mode

Like the previous rule, as long as, the client code that depends on the SuperType handles more exceptions, if you replaced this SuperType with the SubType which handles fewer exceptions, the client code would notice no difference.

Secondly, let's introduce the Behavior rules:

1- Class Invariants (Property Rule): The subtype methods have to preserve or strengthen the supertypes class invariants.

 class SuperType {
   // Class invariant: dayHour >= 0 && dayHour <= 23
   protected dayHour: number = 12;
 }

 class SubType extends SuperType {
   // SubType should preserve or strengthen the SuperType's invariant
   setDayHour(hour: number) {
     if (hour < 0 || hour > 12) { // Strengthen the SuperType's invariant
       throw new Error('INVALID_INPUT');
     }
     this.dayHour = hour;
   }
 }

 const ins = new SubType();
 ins.setDayHour(5); // Valid. Preserve the SuperType's invariant (5 > 0 && 5 < 23)
 ins.setDayHour(30); // Invalid. Not preserve the SuperType's invariant (30 > 23)
Enter fullscreen mode Exit fullscreen mode

The SubType has to maintain the same SuperType invariants or strengthen them. Think of it, if the SubType doesn’t maintain the same SuperType invariants, logically, it wouldn’t be substitutable for the SuperType and would break the client code that depends on a specific behavior from the SuperType.

2- History Constraint (Property Rule): The subtype methods shouldn't allow a state change that the supertype doesn't allow.

 class SuperType {
   // Class History
   // should be set once at the creation time
   // shouldn't be overridden after that
   protected itemId: number = 40;

   constructor(itemId: number) {
     this.itemId = itemId;
   }
 }

 class SubType extends SuperType {
   // Invalid method behavior
   setItem(itemId: number) {
     this.itemId = itemId;
   }
 }

 const ins = new SubType(2); // Valid to be set just at the creation time
 ins.setItem(5); // Invalid. Not preserve the SuperType class history (`itemId` value shouldn't be overridden)
Enter fullscreen mode Exit fullscreen mode

Here, if the SubType ignored any constraint imposed by the SuperType, logically, this will break any client code that relies on these constraints. Therefore, SubType couldn't be substitutable for SuperType.

3- Preconditions (Method Rule): The subtype method should preserve or weaken the preconditions of the overridden supertype method. Here, if you weaken the condition, you relax its constraints.

 class SuperType {
   protected dayHour: number = 10;

   setDayHour(hour: number) {
     if (hour < 0 || hour > 12) { // Precondition
       throw new Error('INVALID_INPUT');
     }
     this.dayHour = hour;
   }
 }

 class SubType extends SuperType {
   setDayHour(hour: number) {
     if (hour < 0 || hour > 23) { // Weakens the precondition
       throw new Error('INVALID_INPUT');
     }
     this.dayHour = hour;
   }
 }

 const ins = new SuperType(); // Try to replace the SuperType with SubType
 ins.setDayHour(10);
Enter fullscreen mode Exit fullscreen mode

In the previous example, any client code that provides the hour input imposed to the SuperType condition hour < 0 && hour > 12 will logically be imposed on a wider range hour < 0 && hour > 23 from the SubType. In other words, SubType could be substitutable for SuperType without any side effects.

4- Postconditions (Method Rule): The subtype method should preserve or strengthen the postconditions of the overridden supertype method.

 class SuperType {
   sum(a: number, b: number) {
     const sum = a + b;
     if (sum > 50) { // Postcondition
       throw new Error('TOO_BIG')
     }
     return sum;
   }
 }

 class SubType extends SuperType {
   sum(a: number, b: number) {
     const sum = (a + b) * 0.5;
     if (sum > 30) { // Strengthens the postcondition
       throw new Error('TOO_BIG');
     }
     return sum;
   }
 }

 const ins = new SuperType(); // Try to replace the SuperType with SubType
 ins.sum(20, 40);
Enter fullscreen mode Exit fullscreen mode

Like the previous example, if the client code expects the returned value from SuperType to have a maximum value of 50 will consequently be valid if you replaced it with a SubType returns a value with a maximum value of 30.

Don't be Confused like me

Liskov Substitution Principle and Inheritance Confusion

At first glance, you might think that the Liskov Substitution Principle is all about Inheritance, but this is not true. I preferred to dedicate a separate section for this point to stress it more because it was confusing me a lot while learning this principle. I thought that the LSP could be applied only if I used Inheritance.

In fact, the Liskov Substitution Principle has nothing to do with Inheritance. The LSP is just about Subtyping regardless this subtyping comes from Inheritance or Composition*.* Since the LSP has nothing to do with inheritance, whether or not you use inheritance is irrelevant to whether or not the LSP applies*.* Take a look at this solution on StackExchange.

So, don't tightly couple the two concepts, the LSP and Inheritance. Instead, just keep in mind the LSP if you are forced to use inheritance. Take a look again at the example of the LSP extends OCP section.

LSP Violation

Liskov Substitution Principle Violation

Lets introduce the most common violations of LSP and try to redesign it to follow the LSP.

Type Checking:

If you're checking the type of a variable inside a polymorphic code. Have a look at the below example:

 interface IEmployee {}
 class Employee implements IEmployee {}
 class Guest implements IEmployee {}

 const employees: IEmployee[] = [];
 for (const employee of employees) {
   if (employee instanceof Guest) { // Checking the the employee type
     doSomethingSpecificForGuest();
   } else {
     doAnotherThingForOtherTypes();
   }
 }
Enter fullscreen mode Exit fullscreen mode

As you see, this loop has two different functionalities based on the employee type. But what is the problem with this implementation?

Think again, the first problem here is that any time you work with employees, you might have to perform a check to see if this employee is a Guest type to do a specific functionality or another type to do another functionality.

The second problem, you might add new types in the future and you might have to visit everywhere this check exists to add specific behaviors to support these new types. On top of that, this obviously violates the Open-Closed Principle.

So how can we solve this problem? One solution is using the Tell, Don't Ask Principle or Encapsulation. It means, don’t ask an instance for its type and then conditionally perform a specific action, instead, encapsulate that logic in the type itself and tell it to perform an action.
Let’s apply this to the previous example:

 interface IEmployee {
   doSomething: () => void;
 }
 class Employee implements IEmployee {
   doSomething() {}
 }
 class Guest implements IEmployee {
   doSomething() {}
 }

 const employees: IEmployee[] = [];
 for (const employee of employees) {
   employee.doSomething(); // Don't ask for the type, encapsulate the behavior, then tell the instance to do an action
 }
Enter fullscreen mode Exit fullscreen mode

Null Checking:

It has the same behavior as type checking. Have a look at the below example, instead of checking on Guest type, you are checking on null value like this if (employee === null). Obviously, this violates the LSP as well.

 type User = {
   id: number;
   name: string;
 };

 interface IEmployee {
   getUser: () => User;
 }
 class Employee implements IEmployee {
   getUser(): User {
     return { id: 1, name: "test" };
   }
 }

 const employees: IEmployee[] = [new Employee(), null, null, new Employee()];
 for (const employee of employees) {
   let user = null;
   if (employee === null) { // Null checking to do specific behavior
     user = { id: -1, name: "Not exist user" };
   } else {
     user = employee.getUser();
   }
   console.log(user);
 }
Enter fullscreen mode Exit fullscreen mode

But how can solve this problem? One common solution for this problem is using the Null Object design pattern. Have a look at this redesign:

 type User = {
   id: number;
   name: string;
 };

 interface IEmployee {
   getUser: () => User;
 }
 class Employee implements IEmployee {
   getUser(): User {
     return { id: 1, name: "test" };
   }
 }
 class NullEmployee implements IEmployee {
   getUser(): User {
     return { id: -1, name: "Not exist user" };
   }
 }

 // Factory function to guarantee that the IEmployee type would be returned all the time even if it was null
 function employeeFactroy(employee: IEmployee) {
   if (employee === null) return new NullEmployee();
   return employee;
 }

 const employees: IEmployee[] = [new Employee(), null, null, new Employee()];
 for (const employee of employees) {
   const emp = employeeFactroy(employee);
   const user = emp.getUser();
   console.log(user);
 }
Enter fullscreen mode Exit fullscreen mode

Throwing Not Implemented Exception:

This is a common one because of the partial implementation of an interface or a base class. Take a look again at the example of the “LSP extends OCP” section, you have to throw a Not Implemented Exception in the method login of the Guest subtype because it can’t fully implement the IEmployee interface (supertype).

The solution for this problem is to make sure to fully implement the supertype whether it was an interface or a base class.

However, you might argue that it is sometimes difficult to fully implement the interface, like our example. That’s true, if you have such a case, you probably need to double-check the relation between the supertype and subtype, the subtype might not be qualified to be a subtype for this supertype, in other words, this is probably a violation for the Interface Segregation Principle.

Conclusion

In this article, we have just introduced the Liskov Substitution Principle. We knew that LSP adds a new constraint to the object-oriented design, it states that relationships are insufficient and you need to make sure that subtypes are substitutable for supertypes.

We also knew the rules that you need to follow to apply this principle correctly. And these rules could be categorized under Signature and Behavior rules.

After that, we introduced some common violations of this principle and solutions for them.

Before you leave

Thanks a lot for staying with me up till this point, I hope you enjoy reading this article.

If you found this article useful, check out these articles as well:

Liskov Substitution Principle

References

Top comments (0)