Chào mừng bạn đến với phần thứ ba của loạt bài về SOLID . Sau khi đã tìm hiểu về SRP và OCP, hôm nay chúng ta sẽ khám phá một nguyên tắc có vẻ “học thuật” nhưng cực kỳ thực tế: L - Liskov Substitution Principle (LSP) .
Nguyên tắc này được đặt theo tên của Barbara Liskov, người đã phát biểu nó vào năm 1987:
“Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.”
Nói đơn giản: Nếu class B là subclass của class A, thì bất cứ đâu bạn sử dụng A, bạn có thể thay thế bằng B mà chương trình vẫn hoạt động đúng . Nếu không, bạn đang vi phạm LSP.
Tại Sao Điều Này Quan Trọng?
Khi vi phạm LSP, những lớp con sẽ “phá vỡ” kỳ vọng của lớp cha. Điều này dẫn đến các bug khó phát hiện, vì code trông có vẻ đúng (dựa trên inheritance) nhưng lại hoạt động sai. Bạn sẽ thấy những đoạn code kiểm tra kiểu ( instanceof ) xuất hiện khắp nơi - đó là dấu hiệu của thiết kế tồi.
Ví Dụ Thực Tế: Hệ Thống Quản Lý Tài Khoản Ngân Hàng
Hãy xem một ví dụ thực tế trong lĩnh vực ngân hàng về việc quản lý các loại tài khoản.
Ví dụ Xấu: Vi phạm LSP
Giả sử ngân hàng có tài khoản thông thường và tài khoản tiết kiệm có kỳ hạn. Tài khoản tiết kiệm không cho phép rút tiền trước hạn.
// Lớp cha: Tài khoản ngân hàng cơ bản
class BankAccount {
protected double balance;
protected String accountNumber;
public BankAccount(String accountNumber, double initialBalance) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
}
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
System.out.println("Nạp " + amount + " thành công. Số dư: " + balance);
}
}
public void withdraw(double amount) {
if (amount > 0 && balance >= amount) {
balance -= amount;
System.out.println("Rút " + amount + " thành công. Số dư: " + balance);
}
}
public double getBalance() {
return balance;
}
}
// VI PHẠM LSP: Lớp con thay đổi hành vi của lớp cha
class FixedDepositAccount extends BankAccount {
private LocalDate maturityDate;
public FixedDepositAccount(String accountNumber, double initialBalance, LocalDate maturityDate) {
super(accountNumber, initialBalance);
this.maturityDate = maturityDate;
}
@Override
public void withdraw(double amount) {
// VI PHẠM: Ném exception thay vì thực hiện rút tiền như lớp cha
if (LocalDate.now().isBefore(maturityDate)) {
throw new UnsupportedOperationException(
"Không thể rút tiền trước ngày đáo hạn: " + maturityDate
);
}
super.withdraw(amount);
}
}
Code sử dụng có thể bị crash bất ngờ:
// Code này sẽ crash nếu account là FixedDepositAccount chưa đáo hạn
public void processWithdrawal(BankAccount account, double amount) {
// Người viết code này kỳ vọng withdraw() luôn hoạt động
// vì BankAccount.withdraw() không ném exception
account.withdraw(amount); // BOOM! UnsupportedOperationException
}
// Lập trình viên phải thêm code kiểm tra kiểu - dấu hiệu vi phạm LSP
public void processWithdrawalSafe(BankAccount account, double amount) {
if (account instanceof FixedDepositAccount) {
// Xử lý đặc biệt... code smell!
} else {
account.withdraw(amount);
}
}
Ví dụ Tốt: Tuân Thủ LSP
Giải pháp là thiết kế lại hierarchy sao cho mỗi loại tài khoản chỉ hứa những gì nó có thể làm được.
// Interface cơ bản cho mọi tài khoản
interface Account {
void deposit(double amount);
double getBalance();
String getAccountNumber();
}
// Interface mở rộng cho tài khoản có thể rút tiền bất kỳ lúc nào
interface WithdrawableAccount extends Account {
void withdraw(double amount);
}
// Interface cho tài khoản có kỳ hạn
interface TermDepositAccount extends Account {
LocalDate getMaturityDate();
void withdrawOnMaturity(); // Chỉ rút được khi đáo hạn
}
// Tài khoản thanh toán thông thường - có thể rút tiền tự do
class CheckingAccount implements WithdrawableAccount {
private double balance;
private String accountNumber;
public CheckingAccount(String accountNumber, double initialBalance) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
}
@Override
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
}
}
@Override
public void withdraw(double amount) {
if (amount > 0 && balance >= amount) {
balance -= amount;
System.out.println("Rút " + amount + " thành công.");
}
}
@Override
public double getBalance() { return balance; }
@Override
public String getAccountNumber() { return accountNumber; }
}
// Tài khoản tiết kiệm có kỳ hạn - không có method withdraw()
class FixedDeposit implements TermDepositAccount {
private double balance;
private String accountNumber;
private LocalDate maturityDate;
public FixedDeposit(String accountNumber, double initialBalance, LocalDate maturityDate) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
this.maturityDate = maturityDate;
}
@Override
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
}
}
@Override
public LocalDate getMaturityDate() { return maturityDate; }
@Override
public void withdrawOnMaturity() {
if (!LocalDate.now().isBefore(maturityDate)) {
System.out.println("Rút toàn bộ " + balance + " khi đáo hạn.");
balance = 0;
} else {
System.out.println("Chưa đến ngày đáo hạn.");
}
}
@Override
public double getBalance() { return balance; }
@Override
public String getAccountNumber() { return accountNumber; }
}
Bây giờ code sử dụng an toàn và rõ ràng:
// Chỉ nhận tài khoản có thể rút tiền - không có bất ngờ!
public void processWithdrawal(WithdrawableAccount account, double amount) {
account.withdraw(amount); // Luôn an toàn
}
// FixedDeposit không implement WithdrawableAccount
// nên không thể truyền vào đây -> Compile error, không phải runtime error!
Dấu Hiệu Vi Phạm LSP
Hãy cảnh giác khi thấy những dấu hiệu sau:
- Lớp con ném exception cho method mà lớp cha không ném.
- Lớp con có method trả về
nullhoặc giá trị mặc định vô nghĩa. - Sử dụng
instanceofđể kiểm tra kiểu trước khi gọi method. - Lớp con “làm rỗng” (empty body) một method của lớp cha.
- Override method để làm điều ngược lại với kỳ vọng của lớp cha.
Kết Luận
Nguyên tắc Liskov Substitution giúp bạn thiết kế inheritance đúng đắn. Khi kế thừa, hãy tự hỏi: “Liệu lớp con có thể thay thế hoàn toàn lớp cha trong mọi tình huống không?”. Nếu câu trả lời là không, hãy xem xét sử dụng composition thay vì inheritance, hoặc tách interface như ví dụ trên. Đừng để inheritance trở thành “cái bẫy” trong code của bạn.