Motivation
Hey everyone, how’s it going? This is the third post of the series where I’m sharing my experience with SOLID. In this article, we’re going to understand a bit more about the Liskov Substitution Principle (LSP) and see some examples.
This might be the hardest concept to understand, but I’ll try to explain it in a simple way to make everything clear.
Summary
This concept was created by Barbara Liskov and Jeannette Wingin 1994 in a paper called "A Behavioral Notion of Subtyping" and the definition says:
Subtypes must be replaceable for their base types without affecting the correctness of the program.
In short, you should be able to replace a base type with its subtype, since the subtype has all the characteristics of the base type, and this shouldn't break the current flow.
Example
Imagine the following situation: we have two classes. A parent class with some properties and methods, and a child class that extends it. If we try to replace the parent class with the child class somewhere in the code, everything should work fine. Here's an example in the image:
The example shows a function that saves the information. In theory, it works because the type substitution doesn’t affect how the code behaves. It respects the contract, whether we’re using inheritance or interface.
LSP says that objects of the child class should be usable in place of objects of the parent class without changing the correct behavior of the program. Let’s check that in the example:
-
Parent Class Contract:
- The parent class defines properties
xpto_01
andxpto_02
. The functionsave_parent()
depends on these methods to work correctly.
- The parent class defines properties
-
Child Class Behavior:
- The child class inherits
xpto_01
andxpto_02
and adds a new one:xpto_03
. If thesave_parent()
function doesn’t usexpto_03
, and the other two properties are implemented in the same way, then it’s all good.
- The child class inherits
-
Substitution in Function:
- If
save_parent()
only usesxpto_01
andxpto_02
, and the child class implements them correctly, the substitution works. The new methodxpto_03
doesn’t affect the contract.
- If
LSP in practice
Now let’s look at a Go example with the same idea:
In this case, we have an interface called Salvable
that defines a contract for saving data. A struct User
(like the parent class) and a struct PremiumUser
(like the child class) both implement this interface. The child struct has an extra field.
package main
import "fmt"
// Salvable interface defines the base contract
type Salvable interface {
Save() string
}
type User struct {
Name string
Age int
}
// User implements the Salvable interface
func (u *User) Save() string {
return fmt.Sprintf("Saving user: Name=%s, Age=%d", u.Name, u.Age)
}
// PremiumUser extends User behavior
type PremiumUser struct {
User
PremiumLevel int
}
func (pu *PremiumUser) Save() string {
// Keeps the contract, adding extra info
return fmt.Sprintf("Saving premium user: Name=%s, Age=%d, PremiumLevel=%d", pu.Name, pu.Age, pu.PremiumLevel)
}
// Function that uses the Salvable interface
func SaveProcess(s Salvable) {
result := s.Save()
fmt.Println(result)
}
func main() {
user := &User{Name: "Jonas", Age: 30}
premiumUser := &PremiumUser{User: User{Name: "Mary", Age: 25}, PremiumLevel: 3}
fmt.Println("Processing User:")
SaveProcess(user)
fmt.Println("Processing PremiumUser:")
SaveProcess(premiumUser)
}
Expected output:
Processing User:
Saving user: Name=Jonas, Age=30
Processing PremiumUser:
Saving premium user: Name=Mary, Idade=25, PremiumLevel=3
But what happens if we break the LSP?
package main
import "fmt"
type Salvable interface {
Save() string
}
type User struct {
Name string
Age int
}
func (u *User) Save() string {
return fmt.Sprintf("Saving user: Name=%s, Age=%d", u.Name, u.Age)
}
type PremiumUser struct {
User
PremiumLevel int
}
func (pu *PremiumUser) Save() string {
// Restriction: only save if PremiumLevel > 0
if pu.PremiumLevel <= 0 {
return "Error: Invalid PremiumLevel"
}
return fmt.Sprintf("Saving premium user: Name=%s, Age=%d, PremiumLevel=%d", pu.Name, pu.Age, pu.PremiumLevel)
}
func SaveProcess(s Salvable) {
result := s.Save()
fmt.Println(result)
}
func main() {
user := &User{Name: "Jonas", Age: 30}
premiumUser := &PremiumUser{User: User{Name: "Mary", Age: 25}, PremiumLevel: 3}
fmt.Println("Processing User:")
SaveProcess(user)
fmt.Println("Processing PremiumUser:")
SaveProcess(premiumUser)
}
Expected output:
Processing User:
Saving user: Name=Jonas, Age=30
Processing PremiumUser:
Error:: Invalid PremiumLevel
In this case, we have some problems:
- The
PremiumUser
class adds a restriction (PremiumLevel > 0
) that doesn’t exist inSalvable
or inUser
. - The
SaveProcess
function expectsSave()
to always return a success message, but now it can return an error. - This breaks the LSP because replacing
Salvable
withPremiumUser
changes the expected behavior by adding a condition that doesn’t exist in the contract.
Conclusion
In general, this principle helps us reflect on how modules implement behaviors like methods, callbacks, or interfaces. If you need to “force” an implementation just to respect a contract, or change how a method behaves in a subtype, or if replacing types breaks your system, that’s a strong sign the LSP is being violated.
That’s it for now! If you have any questions, leave them in the comments. See you in the next one!
- Elixir examples: solid_elixir_examples
- Go examples: solid-go-examples
Top comments (0)