DEV Community

Cover image for L - Liskov Substitution Principle (LSP)
Nozibul Islam
Nozibul Islam

Posted on • Edited on

L - Liskov Substitution Principle (LSP)

What is Liskov Substitution Principle(LSP)

The Liskov Substitution Principle (LSP) is one of the SOLID design principles used in Object-Oriented Programming (OOP). The core concept of LSP is:

"If a class T is considered, and S is a subclass of T, then wherever class T is used, class S should also be usable, without altering the behavior or functionality of the program."

The Liskov Substitution Principle (LSP) can be explained more simply as follows:

Imagine we have a Parent class, and from that, we've created a Child class. LSP states that wherever we've used the Parent class, we should be able to replace it with the Child class, and the program should still function exactly as before. This means there will be no errors or unexpected behavior in the program.

In other words, if we substitute a base class with its subclass, there should be no issues, and the program will continue to work correctly.

The key message of LSP is:
If we replace a base class with its subclass, the program’s behavior or outcome should not change unexpectedly. Simply put, a subclass should be designed in a way that it behaves just like the base class and adheres to all of its characteristics, ensuring that the code works without any problems.

Example:

Let's say we have a class called Bird, and a subclass of it called Penguin. Typically, the Bird class might have methods like fly(). However, if the Penguin class also has a fly() method, it would violate the Liskov Substitution Principle (LSP), because penguins cannot fly.

So, if Penguin is a subclass of Bird, it should not have a fly() method, as this would break LSP. In this case, replacing the Bird class with Penguin in a program would lead to unexpected behavior, since penguins don't fly, but the program might expect them to.

This shows that the subclass (Penguin) must not change the expected behavior of the base class (Bird), and the design should account for such differences to maintain LSP.

Let's now explain the Liskov Substitution Principle (LSP) in detail with examples in both Java and JavaScript.

LSP Example in Java:

// Base class: Bird
class Bird {
    public void fly() {
        System.out.println("Flying...");
    }
}

// Subclass: Sparrow
class Sparrow extends Bird {
    @Override
    public void fly() {
        System.out.println("Sparrow is flying...");
    }
}

// Subclass: Penguin
class Penguin extends Bird {
    // Penguins can't fly
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguin cannot fly");
    }
}

// Main class
public class Main {
    public static void main(String[] args) {
        Bird bird1 = new Sparrow(); // This works fine
        bird1.fly(); // Output: Sparrow is flying...

        Bird bird2 = new Penguin(); // Problem starts here
        bird2.fly(); // Exception: UnsupportedOperationException
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

In the above example, the Bird class has a fly() method, which assumes that all birds can fly. However, when we override this fly() method in the Penguin class and throw an exception, it violates LSP. This is because if we replace the Bird class with the Penguin class, the program will break and throw an exception.

Correctly Using LSP:

To follow LSP, we can modify the Bird class so that the ability to fly is not defined within the class itself but rather in a separate interface.

Java code:

// Interface: Flyable
interface Flyable {
    void fly();
}

// Base class: Bird
class Bird {
    // General bird behavior
}

// Subclass: Sparrow
class Sparrow extends Bird implements Flyable {
    @Override
    public void fly() {
        System.out.println("Sparrow is flying...");
    }
}

// Subclass: Penguin
class Penguin extends Bird {
    // Penguins can't fly, so no fly() method here.
}

// Main class
public class Main {
    public static void main(String[] args) {
        Flyable bird1 = new Sparrow(); // This works fine
        bird1.fly(); // Output: Sparrow is flying...

        Bird bird2 = new Penguin(); // No issues
        // But fly() method cannot be called
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

Here, we created an interface called Flyable that applies only to birds that can fly. Sparrow implements this interface because it can fly, but Penguin does not, as it cannot fly. Now, LSP is no longer violated, because no bird that cannot fly is treated as a Flyable bird.

LSP Example in JavaScript:

We can also apply LSP in JavaScript. Here's an example:

LSP Violation in JavaScript:

class Bird {
    fly() {
        console.log("Flying...");
    }
}

class Sparrow extends Bird {
    fly() {
        console.log("Sparrow is flying...");
    }
}

class Penguin extends Bird {
    fly() {
        throw new Error("Penguin cannot fly");
    }
}

const bird1 = new Sparrow();
bird1.fly(); // Output: Sparrow is flying...

const bird2 = new Penguin();
bird2.fly(); // Error: Penguin cannot fly
Enter fullscreen mode Exit fullscreen mode

Correctly Following LSP in JavaScript:

class Bird {
    // General bird behavior
}

class FlyableBird extends Bird {
    fly() {
        console.log("Flying...");
    }
}

class Sparrow extends FlyableBird {
    fly() {
        console.log("Sparrow is flying...");
    }
}

class Penguin extends Bird {
    // Penguins can't fly, so no fly() method here.
}

const bird1 = new Sparrow();
bird1.fly(); // Output: Sparrow is flying...

const bird2 = new Penguin();
// Penguins can't fly, so `fly()` method is not available
Enter fullscreen mode Exit fullscreen mode

Explanation:

In this corrected JavaScript example, we created a new FlyableBird class for birds that can fly. Sparrow extends this class, while Penguin only extends the Bird class without the fly() method. This ensures that no Penguin is mistakenly expected to fly, following LSP correctly.

Example (JavaScript):

Let's assume we have a base class called Vehicle, where there is a method named startEngine(). Now, we create two subclasses, Car and Bicycle. According to the Liskov Substitution Principle (LSP), we should be able to use both Car and Bicycle wherever we use the Vehicle class, and the program should work properly.

JavaScript Code:

// Base Class (Vehicle)
class Vehicle {
  startEngine() {
    console.log("Engine started");
  }
}

// Subclass (Car)
class Car extends Vehicle {
  startEngine() {
    console.log("Car engine started");
  }
}

// Subclass (Bicycle)
class Bicycle extends Vehicle {
  startEngine() {
    throw new Error("Bicycles don't have engines!");
  }
}

// Function that starts vehicle
function startVehicle(vehicle) {
  vehicle.startEngine();
}

// Example usage
const myCar = new Car();
startVehicle(myCar); // Output: Car engine started

const myBicycle = new Bicycle();
startVehicle(myBicycle); // Error: Bicycles don't have engines!
Enter fullscreen mode Exit fullscreen mode

Simple Explanation:

In this case, the Vehicle class has a startEngine() method that starts the engine of a vehicle. We created a subclass Car that correctly uses this feature. However, when we create a subclass Bicycle, a problem occurs because bicycles don't have engines. Therefore, the startEngine() method does not make sense for a bicycle, and this violates LSP. If we use Bicycle in place of Vehicle, the program will fail unexpectedly.

Solution:

To follow the Liskov Substitution Principle (LSP), we need to differentiate between vehicles that have engines and those that don’t. Since a bicycle doesn't have an engine, the Vehicle class should not have a method that assumes all vehicles have an engine. Instead, we will create separate classes for vehicles with engines and those without.

Example (JavaScript):

Let's assume we have a base class called Vehicle, where there is a method named startEngine(). Now, we create two subclasses, Car and Bicycle. According to the Liskov Substitution Principle (LSP), we should be able to use both Car and Bicycle wherever we use the Vehicle class, and the program should work properly.

JavaScript Code:

// Base Class (Vehicle)
class Vehicle {
  startEngine() {
    console.log("Engine started");
  }
}

// Subclass (Car)
class Car extends Vehicle {
  startEngine() {
    console.log("Car engine started");
  }
}

// Subclass (Bicycle)
class Bicycle extends Vehicle {
  startEngine() {
    throw new Error("Bicycles don't have engines!");
  }
}

// Function that starts vehicle
function startVehicle(vehicle) {
  vehicle.startEngine();
}

// Example usage
const myCar = new Car();
startVehicle(myCar); // Output: Car engine started

const myBicycle = new Bicycle();
startVehicle(myBicycle); // Error: Bicycles don't have engines!
Enter fullscreen mode Exit fullscreen mode

Simple Explanation:

In this case, the Vehicle class has a startEngine() method that starts the engine of a vehicle. We created a subclass Car that correctly uses this feature. However, when we create a subclass Bicycle, a problem occurs because bicycles don't have engines. Therefore, the startEngine() method does not make sense for a bicycle, and this violates LSP. If we use Bicycle in place of Vehicle, the program will fail unexpectedly.

Solution:

To follow the Liskov Substitution Principle (LSP), we need to differentiate between vehicles that have engines and those that don’t. Since a bicycle doesn't have an engine, the Vehicle class should not have a method that assumes all vehicles have an engine. Instead, we will create separate classes for vehicles with engines and those without.

Solution Code (JavaScript):

// Base class for vehicles with engines
class EngineVehicle {
  startEngine() {
    console.log("Engine started");
  }
}

// Subclass for cars (engine vehicle)
class Car extends EngineVehicle {
  startEngine() {
    console.log("Car engine started");
  }
}

// Separate class for bicycles (non-engine vehicle)
class Bicycle {
  pedal() {
    console.log("Pedaling the bicycle");
  }
}

// Function that handles engine vehicles only
function startEngineVehicle(vehicle) {
  vehicle.startEngine();
}

// Function that handles bicycles
function rideBicycle(bicycle) {
  bicycle.pedal();
}

// Example usage
const myCar = new Car();
startEngineVehicle(myCar); // Output: Car engine started

const myBicycle = new Bicycle();
rideBicycle(myBicycle); // Output: Pedaling the bicycle

Enter fullscreen mode Exit fullscreen mode

Simple Explanation:

What was the problem before?

In the previous example, we used a Vehicle class that tried to provide a startEngine() method for all types of vehicles. But the problem was that bicycles don’t have engines, so this method was irrelevant for them, leading to a violation of LSP.

Solution:

Here, we created two separate classes:

  • EngineVehicle: This class is for vehicles that have engines and includes the startEngine() method.

  • Bicycle: This is a separate class specifically for bicycles, which has a pedal() method for bicycle functionality.

Now, we use two separate functions:

  • startEngineVehicle(): This function is only for engine vehicles.

  • rideBicycle(): This function is specifically for bicycles.

This solution adheres to LSP because we are using Car where we need engine-powered vehicles, and Bicycle where we need non-engine-powered vehicles. The program's behavior is now predictable, and there are no unexpected results.

Consequences of Violating LSP:

  • Decreased code reusability.

  • Using subclasses can cause issues for the client code.

  • Refactoring becomes more complex and error-prone.

Benefits of Using LSP:

  • Increases code stability and robustness.

  • Improves code reusability.

  • Subclasses can easily replace base classes without causing issues.

What is Liskov Substitution Principle (LSP) in React ?

The Liskov Substitution Principle (LSP) is a part of the SOLID design principles, which is used in object-oriented programming. This principle is also applicable in component-based frameworks like React. According to LSP, if a class is a subclass of a parent class, that subclass should be usable in place of the parent class without altering the behavior of the application. In other words, if you replace a parent component with a child component, the application should continue to function correctly.

By applying the Liskov Substitution Principle (LSP) in React, components become more reusable, scalable, and maintainable. Below are three examples demonstrating how this principle works:

Button Component Example 1:

Let's say we have a basic Button component that renders a simple button.

JSX code:

// Parent Button Component
const Button = ({ label, onClick }) => {
  return <button onClick={onClick}>{label}</button>;
};
Enter fullscreen mode Exit fullscreen mode

Now, we create a new IconButton component that adds an icon inside the button:

JSX code:

// Child IconButton Component
const IconButton = ({ label, onClick, icon }) => {
  return (
    <button onClick={onClick}>
      <span>{icon}</span>
      {label}
    </button>
  );
};
Enter fullscreen mode Exit fullscreen mode

According to the Liskov Substitution Principle (LSP), wherever we use the Button component, we can substitute it with the IconButton component, and it will work correctly. This means the IconButton can be used in place of the Button component without breaking the functionality.

JSX code:

// Usage
<Button label="Click Me" onClick={() => console.log("Button clicked")} />
<IconButton label="Save" icon="💾" onClick={() => console.log("Icon Button clicked")} />
Enter fullscreen mode Exit fullscreen mode

This substitution allows us to extend the functionality of the Button component with additional features (like icons) while maintaining the core behavior, enhancing reusability and maintainability.

Text Component Example 2:

Let's say we are using a basic Text component that simply displays some text.

JSX code:

// Parent Text Component
const Text = ({ content }) => {
  return <p>{content}</p>;
};
Enter fullscreen mode Exit fullscreen mode

Now, we create a HighlightedText component, which is a subclass of Text, that highlights the text conditionally.

JSX code:

// Child HighlightedText Component
const HighlightedText = ({ content, highlight }) => {
  return (
    <p style={{ backgroundColor: highlight ? "yellow" : "none" }}>
      {content}
    </p>
  );
};
Enter fullscreen mode Exit fullscreen mode

According to the Liskov Substitution Principle (LSP), the HighlightedText component can be used in place of the Text component, and it will work correctly. This allows us to substitute Text with HighlightedText while maintaining the core behavior of displaying text.

JSX code:

// Usage
<Text content="This is normal text." />
<HighlightedText content="This is highlighted text." highlight={true} />
Enter fullscreen mode Exit fullscreen mode

With this substitution, HighlightedText extends the basic functionality of Text by adding a highlighting feature, while still adhering to the core functionality of displaying text. This makes it easy to reuse the components and ensures maintainability.

Form Component Example 3:

Let's say we have a basic Form component that renders a simple form.

JSX code:

// Parent Form Component
const Form = ({ onSubmit }) => {
  return (
    <form onSubmit={onSubmit}>
      <input type="text" />
      <button type="submit">Submit</button>
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now, we create a LoginForm component, which is a subclass of Form that renders specific input fields for user login.

JSX code:

// Child LoginForm Component
const LoginForm = ({ onSubmit }) => {
  return (
    <form onSubmit={onSubmit}>
      <input type="email" placeholder="Email" />
      <input type="password" placeholder="Password" />
      <button type="submit">Login</button>
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

According to the Liskov Substitution Principle (LSP), the LoginForm component can be used as a Form component. This means that wherever we expect a Form, we can safely substitute it with LoginForm.

JSX code:

// Usage
<Form onSubmit={() => console.log("Form Submitted")} />
<LoginForm onSubmit={() => console.log("Login Form Submitted")} />
Enter fullscreen mode Exit fullscreen mode

In this case, LoginForm extends the basic functionality of the Form by providing specific fields needed for logging in, while still adhering to the overall behavior of submitting a form. This makes the components reusable and maintains a consistent application behavior, which is a core advantage of applying LSP in React.

Why Liskov Substitution Principle (LSP) is Important in React?

The Liskov Substitution Principle (LSP) is crucial in frameworks like React because it makes components modular, reusable, and maintainable. It is a core aspect of React application design, enhancing the robustness and flexibility of component-driven architecture. Here’s a detailed look at how LSP can solve real-world problems in application development.

Importance of LSP in React:

  • Component Reusability:
    LSP ensures that we can reuse different components without rewriting them. The behavior of the parent component is maintained in the child component, allowing us to extend the parent’s behavior without creating new components from scratch.

  • Code Maintenance:
    Adhering to LSP keeps the code clean. If we need to change a component in the future, we can create a new subclass to implement changes without modifying the existing code.

  • Scalability:
    In large applications, components need to have repeatable behavior to avoid complexity during development. LSP allows for seamless interactions among multiple components.

Real-World Problems and Solutions Using LSP:

Example 1: Different Button Types

Suppose we are building an e-commerce site that requires various types of buttons (e.g., Add to Cart, Buy Now, View Details). Creating separate components for each button would lead to redundancy. Instead, we can apply LSP by creating a base Button component and deriving subclass components from it.

JSX code:

// Parent Button Component
const Button = ({ label, onClick }) => {
  return <button onClick={onClick}>{label}</button>;
};

// Child AddToCartButton Component
const AddToCartButton = ({ onClick }) => {
  return <Button label="Add to Cart" onClick={onClick} />;
};

// Child BuyNowButton Component
const BuyNowButton = ({ onClick }) => {
  return <Button label="Buy Now" onClick={onClick} />;
};

// Child ViewDetailsButton Component
const ViewDetailsButton = ({ onClick }) => {
  return <Button label="View Details" onClick={onClick} />;
};

// Usage in different parts of the application
<AddToCartButton onClick={() => console.log("Item added to cart")} />
<BuyNowButton onClick={() => console.log("Buying now")} />
<ViewDetailsButton onClick={() => console.log("Viewing details")} />

Enter fullscreen mode Exit fullscreen mode

Problem Solved:

Example 2: Different User Roles in a Dashboard

Let’s consider a scenario where we have a dashboard that serves two types of users: regular users and admins. We want to display a standard dashboard for users, while admins will have access to additional features. By applying the Liskov Substitution Principle (LSP), we can create a base Dashboard component and extend it to create an admin-specific dashboard.

JSX code:

// Parent Dashboard Component
const Dashboard = ({ name }) => {
  return <h1>Welcome to the Dashboard, {name}</h1>;
};

// Child AdminDashboard Component
const AdminDashboard = ({ name }) => {
  return (
    <div>
      <Dashboard name={name} />
      <button>Admin Settings</button>
    </div>
  );
};

// Usage
<Dashboard name="John Doe" />        // For normal users
<AdminDashboard name="Jane Doe" />   // For admins
Enter fullscreen mode Exit fullscreen mode

Problem Solved:

In this example, the Dashboard and AdminDashboard components work seamlessly without any conflicts. The AdminDashboard maintains the behavior of the base Dashboard component, ensuring that it displays the standard welcome message for the user. The only difference is that the AdminDashboard adds extra admin-specific features, such as the "Admin Settings" button. This structure not only adheres to LSP but also enhances scalability by allowing the admin dashboard to build upon the existing functionality without modifying the base component.

Example 3: Payment Method System

Suppose we have a payment system that supports various payment methods (e.g., Credit Card, PayPal, bKash, Bank Transfer). We want to ensure that adding a new payment method can be integrated without changing the existing code. By using the Liskov Substitution Principle (LSP), we can handle this easily.

JSX code:

// Parent Payment Component
const Payment = ({ amount }) => {
  return <p>Payment Amount: {amount}</p>;
};

// Child CreditCardPayment Component
const CreditCardPayment = ({ amount, cardNumber }) => {
  return (
    <div>
      <Payment amount={amount} />
      <p>Paying with Credit Card: {cardNumber}</p>
    </div>
  );
};

// Child PayPalPayment Component
const PayPalPayment = ({ amount, email }) => {
  return (
    <div>
      <Payment amount={amount} />
      <p>Paying with PayPal: {email}</p>
    </div>
  );
};

// Usage
<CreditCardPayment amount={500} cardNumber="1234-5678-9876-5432" />
<PayPalPayment amount={300} email="user@example.com" />
Enter fullscreen mode Exit fullscreen mode

Problem Solved:

The Payment component can easily adapt to multiple payment methods. According to LSP, there will be no issues when adding new payment methods. By using LSP, we can define the component's behavior once and easily extend it in various places.

Limitations of the Liskov Substitution Principle(LSP)

LSP (Liskov Substitution Principle) has some limitations, which include:

  • Strict Compliance: Writing code that adheres to LSP can sometimes introduce additional complexity when creating subclasses or derived classes, as it requires maintaining all the attributes of the parent class.

  • Lack of Flexibility: Following LSP can reduce flexibility in some cases, as the subclass must be completely compatible with the parent class, which can create challenges when adding new features.

  • Additional Design Constraints: Designing according to LSP requires adhering to specific constraints. Every method or function of the parent class must be usable in the subclass, which can complicate the design.

  • Increased Resource Costs: Following LSP often necessitates code refactoring, which can take more development time and resources.

These are some limitations of the Liskov Substitution Principle that pose challenges in advanced system design.

In Summary:

In React, the Liskov Substitution Principle (LSP) is a crucial design principle because it helps manage component behavior and structure predictably and consistently. By following LSP:

  • Increased Component Reusability: Components can be reused without modification.

  • Reduced Redundant Code: Common behaviors are defined once, avoiding code duplication.

  • Enhanced Scalability: Components can be easily extended as the application grows.

  • Clean and Maintainable Code: The codebase remains organized, making it easier to maintain.

  • No Changes Needed to Old Components for New Features: New features can be added without altering existing components.

Using this principle facilitates easier management of the codebase and simplifies handling larger projects in the future.

Conclusion:

The Liskov Substitution Principle (LSP) is a vital part of the SOLID design principles that aids in maintaining the durability and stability of programs. Adhering to LSP properly means maintaining a cohesive relationship among our classes, allowing client code to use subclasses and base classes interchangeably without noticing any differences, enabling the program to run without errors.

🔗 Connect with me on LinkedIn:

Let’s dive deeper into the world of software engineering together! I regularly share insights on JavaScript, TypeScript, Node.js, React, Next.js, data structures, algorithms, web development, and much more. Whether you're looking to enhance your skills or collaborate on exciting topics, I’d love to connect and grow with you.

Follow me: Nozibul Islam

Top comments (0)