The True Meaning of Inheritance · cekrem.github.io

After exploring Dependency Inversion and Interface Segregationwe will tackle perhaps the most misunderstood principle of SOLID: The Liskov Substitution Principle (LSP).
Again, kudos to Uncle Bob for reminding me about the importance of good software architecture in his classic Clean Architecture! That book was my main inspiration for this series. Without a clean architecture, we’ll all be building firmware (my paraphrased summary).
The Liskov Substitution Principle states that if S is a subtype of T, then objects of type T can be replaced by objects of type S without changing any desired properties of the program.
In simpler terms: subtypes must replace their base types. Let’s see what it really means in practice.
The Classic Rectangle-Square Problem
Link in the title
This is the canonical example of an LSP violation:
open class Rectangle {
open var width: Int = 0
open var height: Int = 0
fun area() = width * height
}
// This seems logical, but violates LSP
class Square : Rectangle() {
override var width: Int = 0
set(value) {
field = value
height = value
}
override var height: Int = 0
set(value) {
field = value
width = value
}
}
// This code will fail for Square
fun resizeRectangle(rectangle: Rectangle) {
rectangle.width = 4
rectangle.height = 5
assert(rectangle.area() == 20) // Fails for Square!
}
The problem? While a square is mathematically a rectangle, in terms of behavioral substitution, it is not. the Square
The class violates the LSP because it changes the behavior that clients have Rectangle
expect.
Instead of inheritance, we can use composition and interfaces:
interface Shape {
fun area(): Int
}
class Rectangle(
var width: Int,
var height: Int
) : Shape {
override fun area() = width * height
}
class Square(
var side: Int
) : Shape {
override fun area() = side * side
}
Real World Example: Payment Processing
Link in the title
Let’s look at a more practical example involving payment processing:
interface PaymentProcessor {
fun processPayment(amount: Money): Result
}
class CreditCardProcessor : PaymentProcessor {
override fun processPayment(amount: Money): Result {
// Process credit card payment
return Result.success(Transaction(amount))
}
}
class DebitCardProcessor : PaymentProcessor {
override fun processPayment(amount: Money): Result {
// Process debit card payment
return Result.success(Transaction(amount))
}
}
// This works with any PaymentProcessor
class CheckoutService(
private val paymentProcessor: PaymentProcessor
) {
fun checkout(cart: ShoppingCart) {
val amount = cart.total()
paymentProcessor.processPayment(amount)
.onSuccess { transaction ->
// Handle success
}
.onFailure { error ->
// Handle failure
}
}
}
1. Throwing Unexpected Exceptions
Link in the title
interface UserRepository {
fun findById(id: String): User?
}
// LSP Violation: Throws instead of returning null
class CachedUserRepository : UserRepository {
override fun findById(id: String): User? {
throw NotImplementedError("Cache not initialized")
// Should return null if not found
}
}
2. Return Null If Base Type Is Not
Link in the title
interface DataFetcher {
fun fetchData(): List
}
// LSP Violation: Returns null when base contract promises List
class RemoteDataFetcher : DataFetcher {
override fun fetchData(): List {
return if (isConnected()) {
listOf("data")
} else {
null // Violation! Should return empty list
}
}
}
- Use of Contract Tests
abstract class PaymentProcessorTest {
abstract fun createProcessor(): PaymentProcessor
@Test
fun `should process valid payment`() {
val processor = createProcessor()
val result = processor.processPayment(Money(100))
assert(result.isSuccess)
}
@Test
fun `should handle zero amount`() {
val processor = createProcessor()
val result = processor.processPayment(Money(0))
assert(result.isSuccess)
}
}
class CreditCardProcessorTest : PaymentProcessorTest() {
override fun createProcessor() = CreditCardProcessor()
}
class DebitCardProcessorTest : PaymentProcessorTest() {
override fun createProcessor() = DebitCardProcessor()
}
- Document Preconditions and Postconditions
interface AccountService {
/**
* Withdraws money from account
*
* Preconditions:
* - Amount must be positive
* - Account must exist
* - Account must have sufficient balance
*
* Postconditions:
* - Account balance is reduced by amount
* - Returns success with transaction details
* - Never throws exceptions (uses Result)
*/
fun withdraw(accountId: String, amount: Money): Result
}
- Inheritance is not always the answer – composition is preferred if the behavior is different
- Think in terms of contracts – subtypes must obey the contract of the base type
- Use of contract tests to verify LSP compliance
- Document the pre/postconditions obviously
- Return types are important – compatible with null/non-null, exceptions, etc.
Liskov Substitution Principle is more than inheritance – it’s about matching behavior and meeting expectations. If followed correctly, this leads to more reliable and maintainable code by ensuring that components are truly interchangeable.
Stay tuned for our next post in the series, where we’ll explore the Open-Closed Principle!
For tips: If you find yourself writing comments like “don’t use X in Y way” or “this override behaves differently”, you may be violating the LSP.
https://cekrem.github.io/images/featured-with-margins.png
2025-01-21 09:17:00