Introduction: The Origin of LSP

The Liskov Substitution Principle (LSP) is a fundamental concept in object-oriented programming that was first articulated by Barbara Liskov, a renowned computer scientist and Turing Award winner. The principle is rooted in her 1987 keynote talk titled “Data Abstraction and Hierarchy”, presented at a conference on object-oriented programming. Although the term “Liskov Substitution Principle” was later coined by Robert C. Martin (Uncle Bob) in his 1994 book “Designing Object-Oriented Software”, the principle itself originates from Liskov’s work on the theoretical underpinnings of type hierarchies and program correctness.

In her talk and subsequent writings, Barbara Liskov emphasized the importance of substitutability in object-oriented design. She argued that objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program. This idea laid the foundation for one of the key pillars of robust and scalable software design.


The Principle Defined

Liskov Substitution Principle (LSP) states:

If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program (e.g., correctness, task performed, etc.).

In simpler terms, LSP ensures that a derived class or subclass can stand in for its base class or superclass without breaking the functionality of the program.


Key Characteristics of LSP

  1. Behavioral Consistency:
    Subtypes must maintain the behavior expected by the client code using the base type. They may add functionality but cannot contradict or remove behavior defined by the base class.

  2. Contract Adherence:
    Subtypes must honor the contracts (preconditions, postconditions, invariants) of their base types.

  3. No Unexpected Side Effects:
    Replacing a base class with a subtype should not introduce errors or unintended behavior.


Example: Refactoring for LSP

Initial Code

Let’s consider a document storage system where we have a DocumentStore class and a subclass for encrypted document storage.

Code Before Refactor

open class DocumentStore {
    open fun save(document: String): Boolean {
        println("Saving document: $document")
        return true
    }
 
    open fun load(): String {
        return "Generic Document"
    }
}
 
class EncryptedDocumentStore : DocumentStore() {
    override fun save(document: String): Boolean {
        if (!document.startsWith("ENC:")) {
            throw IllegalArgumentException("Document must be encrypted!")
        }
        println("Saving encrypted document: $document")
        return true
    }
 
    override fun load(): String {
        return "ENC:Encrypted Document"
    }
}

Problem

The EncryptedDocumentStore violates LSP because:

  1. It introduces a new precondition for saving documents (must start with "ENC:").
  2. Client code expecting to use a DocumentStore would break if passed an EncryptedDocumentStore.

Client Code Example (Breaks with Substitution)

fun processAndSave(store: DocumentStore, document: String) {
    if (store.save(document)) {
        println("Document saved successfully.")
    }
}
 
// This works fine with DocumentStore
val store = DocumentStore()
processAndSave(store, "Sample Document")
 
// This breaks with EncryptedDocumentStore
val encryptedStore = EncryptedDocumentStore()
processAndSave(encryptedStore, "Sample Document") // Throws exception

Refactored Code

To address the LSP violation, we can:

  1. Introduce a shared interface for different types of document stores.
  2. Use composition to encapsulate behavior differences.

Code After Refactor

interface DocumentStore {
    fun save(document: String): Boolean
    fun load(): String
}
 
class PlainDocumentStore : DocumentStore {
    override fun save(document: String): Boolean {
        println("Saving document: $document")
        return true
    }
 
    override fun load(): String {
        return "Generic Document"
    }
}
 
class EncryptedDocumentStore : DocumentStore {
    override fun save(document: String): Boolean {
        println("Saving encrypted document: $document")
        return true
    }
 
    override fun load(): String {
        return "ENC:Encrypted Document"
    }
 
    fun encrypt(document: String): String {
        return "ENC:$document"
    }
}

Refactored Client Code (Supports Substitution)

fun processAndSave(store: DocumentStore, document: String) {
    val processedDocument = if (store is EncryptedDocumentStore) {
        store.encrypt(document)
    } else {
        document
    }
 
    if (store.save(processedDocument)) {
        println("Document saved successfully.")
    }
}
 
val plainStore = PlainDocumentStore()
processAndSave(plainStore, "Sample Document") // Works
 
val encryptedStore = EncryptedDocumentStore()
processAndSave(encryptedStore, "Sample Document") // Works after encrypting

Why Refactoring Solves LSP Violations

  1. Behavioral Consistency:
    Both PlainDocumentStore and EncryptedDocumentStore now respect the expected behavior of the DocumentStore interface.

  2. Encapsulation:
    The encrypt method in EncryptedDocumentStore is explicitly used by the client when needed, separating concerns.

  3. Substitutability:
    The client code works seamlessly with both types of document stores.


Why LSP Matters

  1. Improved Polymorphism:
    Following LSP ensures that polymorphism works as intended, allowing subclasses to seamlessly replace base classes.

  2. Robust Code:
    Adhering to LSP minimizes unexpected errors when extending or modifying code.

  3. Maintainability:
    Codebases that respect LSP are easier to maintain and extend, as the behavior of type hierarchies is predictable.

  4. Scalability:
    Systems designed with LSP in mind can scale more effectively by allowing safe substitutions in type hierarchies.


Conclusion

As demonstrated, applying the Liskov Substitution Principle requires careful consideration of type hierarchies and behavior. Refactoring, as outlined by Luca Minudel, is a powerful technique to ensure compliance with SOLID principles like LSP. By addressing violations through interfaces and composition, developers can create robust, maintainable, and scalable systems.