Composition over inheritance — khi nào KHÔNG kế thừa
Nguyên tắc Effective Java Item 18: ưu tiên composition hơn inheritance. Vấn đề của inheritance, cách composition sửa, pattern delegation, và khi nào thực sự cần extends.
TL;DR: Kế thừa tạo ra mối liên kết chặt chẽ (tight coupling) giữa lớp cha và lớp con, dẫn đến nguy cơ vỡ mã nguồn diện rộng (fragile base class problem) khi lớp cha thay đổi chi tiết triển khai. Nguyên tắc vàng của thiết kế hướng đối tượng là ưu tiên cấu thành hơn kế thừa (Composition over Inheritance): đối tượng chứa tham chiếu đến đối tượng khác và ủy quyền (delegation) thay vì chiếm dụng cây kế thừa.
Ở bài trước, chúng ta đã dùng sealed class để kiểm soát chính xác tập con cho phép trong một phân cấp đóng. Nhưng trước khi quyết định niêm phong hay mở rộng một cây kế thừa, câu hỏi kiến trúc quan trọng nhất bạn phải trả lời là: Chúng ta có thực sự cần dùng tới quan hệ kế thừa ngay từ đầu hay không?
Năm bài học trước đã trang bị cho bạn đầy đủ cú pháp kế thừa trong Java. Bài học này sẽ làm rõ một quy tắc quan trọng của các kỹ sư phần mềm dày dặn kinh nghiệm: Khi nào KHÔNG được dùng kế thừa. Effective Java (Item 18) của Joshua Bloch đưa ra lời khuyên nổi tiếng: "Favor composition over inheritance" (Ưu tiên composition hơn inheritance).
1. Analogy: Đúc khuôn nguyên khối và Lắp ráp mô-đun
Hãy hình dung sự khác biệt giữa hai phương pháp chế tạo:
- Kế thừa giống như đúc khuôn nguyên khối: Bạn đúc liền khung vỏ và động cơ vào một khối kim loại duy nhất. Khi muốn nâng cấp động cơ mới, bạn buộc phải đập bỏ toàn bộ khối vỏ xe hoặc chấp nhận các rủi ro nứt gãy cơ học.
- Composition giống như lắp ráp mô-đun: Thân xe sở hữu một khoang chứa (
has-a), động cơ được gắn vào qua các bu-lông tiêu chuẩn. Bạn có thể tháo rời động cơ xăng để thay bằng động cơ điện lúc bảo dưỡng mà không ảnh hưởng tới kết cấu khung gầm.

| Tiêu chí | Kế thừa (Inheritance) | Cấu thành (Composition) |
|---|---|---|
| Mối quan hệ | Là một (Is-a) | Có một (Has-a) |
| Mức độ ràng buộc | Chặt chẽ (Compile-time coupling) | Lỏng lẻo (Loose coupling) |
| Thời điểm thay đổi | Cố định lúc biên dịch | Linh hoạt hoán đổi ngay lúc chạy (Runtime swap) |
| Tính đóng gói | Phá vỡ (Lớp con nhìn thấy cấu trúc cha) | Bảo vệ toàn vẹn (Giao tiếp qua public contract) |
Nếu bạn chỉ muốn kế thừa để "tận dụng lại vài hàm tiện ích" của lớp khác mà hai đối tượng không cùng bản chất thực tế, bạn đang đi sai đường. Hãy dùng Composition.
2. Vấn đề cốt tử của Kế thừa: Fragile Base Class
Để thấy rõ kế thừa phá vỡ tính đóng gói như thế nào, hãy phân tích ví dụ kinh điển được Joshua Bloch chỉ ra trong lớp HashSet:
Giả sử bạn muốn tạo một tập hợp có khả năng đếm tổng số lần các phần tử được thêm vào (InstrumentedHashSet):
// LOP CHA GIA DINH CUA THU VIEN
public class BaseSet<E> {
public boolean add(E e) {
// Luu phan tu e vao bo nho...
return true;
}
public boolean addAll(Collection<? extends E> c) {
for (E e : c) {
add(e); // Canh bao: Goi phuong thuc add() noi bo!
}
return true;
}
}
// LOP CON DO NGUOI DUNG VIET
public class InstrumentedHashSet<E> extends BaseSet<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() { return addCount; }
}
Khi chạy thử nghiệm:
InstrumentedHashSet<String> set = new InstrumentedHashSet<>();
set.addAll(List.of("Java", "Spring", "Docker"));
System.out.println(set.getAddCount()); // Ket qua in ra: 6 (thay vi 3!)
Tại sao con số lại thành 6 thay vì 3?
- Lớp con gọi
addAll(List.of(3 phần tử)), biếnaddCountđược cộng thêm 3. - Lớp con gọi
super.addAll(c). - Bên trong lớp cha, mã nguồn duyệt từng phần tử và gọi hàm
this.add(e). - Do cơ chế Dynamic Dispatch (Đa hình lúc chạy), phương thức
addđược gọi không phải của cha, mà làInstrumentedHashSet.add(e)của con đã override. - Biến
addCountbị cộng thêm 1 lần cho mỗi phần tử, tổng cộng thêm 3 lần nữa.
Lớp con đã bị vỡ vì phụ thuộc vào một chi tiết triển khai nội bộ của lớp cha (việc addAll tự gọi add). Nếu tác giả thư viện sau này tối ưu hoá addAll để ghi trực tiếp xuống mảng mà không gọi add, lớp con của bạn lại âm thầm đổi kết quả từ 6 về 3. Đó chính là Fragile Base Class Problem (Lỗi lớp cha giòn gãy).
3. Cách Composition giải quyết triệt để vấn đề
Thay vì kế thừa BaseSet, chúng ta áp dụng mô hình đóng gói: tạo một lớp chứa tham chiếu đến một đối tượng Set có sẵn và ủy quyền các thao tác cho nó:
public class InstrumentedSet<E> implements Set<E> {
private final Set<E> delegate;
private int addCount = 0;
public InstrumentedSet(Set<E> delegate) {
this.delegate = delegate;
}
@Override
public boolean add(E e) {
addCount++;
return delegate.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return delegate.addAll(c);
}
// Cac phuong thuc con lai uy quyen truc tiep cho delegate
@Override public int size() { return delegate.size(); }
@Override public boolean isEmpty() { return delegate.isEmpty(); }
@Override public boolean contains(Object o) { return delegate.contains(o); }
// ... tiep tuc delegate cac phuong thuc con lai cua Set
public int getAddCount() { return addCount; }
}
Kiến trúc này mang lại các lợi ích vượt trội:
- Tuyệt đối an toàn:
delegate.addAll(c)gọi logic nội bộ của chính đối tượng được bọc, không bao giờ bị redirect ngược vềInstrumentedSet.add. - Tái sử dụng cho mọi loại Set: Bạn có thể bọc
HashSet,TreeSet, hayLinkedHashSetmà không cần viết lại mã nguồn.
4. Tránh bẫy bùng nổ lớp (Class Explosion) trong hệ thống thư viện
Trong hệ thống quản lý thư viện, nghiệp vụ mượn sách cần bổ sung thêm hai tính năng:
- Ghi log kiểm toán (Audit Trail) để kiểm tra gian lận.
- Tính phí bảo hiểm hư hại (Damage Insurance) cho các tài liệu quý.
Nếu bạn lạm dụng kế thừa:
- Bạn tạo
AuditedBook extends PhysicalBook. - Bạn tạo
InsuredBook extends PhysicalBook. - Khi một cuốn sách vừa cần kiểm toán vừa cần bảo hiểm, bạn bị ép phải tạo
AuditedAndInsuredBook extends .... - Nếu có thêm loại
RareManuscript, số lượng lớp con sẽ nhân lên theo cấp số nhân ($2^N$).
Giải pháp chuẩn xác: Tách riêng các năng lực thành các thành phần độc lập và phối hợp qua Composition:
public record LoanTransaction(
LibraryItem item,
String borrowerId,
AuditLogger logger,
InsurancePolicy insurancePolicy
) {
public void executeLoan() {
logger.log("Khoi tao giao dich muon cho: " + item.getTitle());
double insuranceFee = insurancePolicy.calculateCoverageFee(item);
logger.log("Phi bao hiem ap dung: " + insuranceFee + " VND");
// Xu ly luu tru giao dich...
}
}
Khi cần thay đổi thuật toán bảo hiểm hoặc đổi hệ thống log, bạn chỉ cần truyền implementation khác vào constructor mà không cần sửa đổi bất kỳ class ấn phẩm nào.
5. Cây quyết định: Khi nào bạn thực sự được phép gõ extends?
Hãy đi theo sơ đồ quyết định dưới đây trước khi bạn gõ từ khoá extends trong Java:

Cột kết quả lặp lại đúng một đáp án cho ba nhánh rẽ ngang: Composition. Bạn chỉ nên sử dụng extends khi thỏa mãn đồng thời 3 điều kiện:
- Mối quan hệ "Is-a" tuyệt đối chuẩn theo Liskov Substitution Principle: Mọi đoạn mã chạy với lớp cha đều phải chạy đúng và không bất ngờ khi nhận lớp con.
- Lớp cha được thiết kế chủ đích cho kế thừa: Có tài liệu mô tả rõ ràng phương thức nào gọi phương thức nào nội bộ (self-use) hoặc được định nghĩa dưới dạng Skeletal Implementation (như
AbstractList). - Hai lớp nằm trong cùng một ranh giới kiểm soát: Cùng một nhóm phát triển bảo trì cả cha lẫn con.
6. Pattern thay thế: Strategy Pattern với Interface
Một trong những ứng dụng phổ biến nhất của Composition thay thế kế thừa là Strategy Pattern. Thay vì tạo ra hàng loạt lớp con để thay đổi cách sắp xếp hoặc cách tính phí:
// Anti-pattern: class VIPBookFee extends Book, StudentBookFee extends Book...
// Best-practice: Chuyen thuat toan tinh phi thanh mot thanh phan duoc truyen vao
@FunctionalInterface
public interface FeeCalculationStrategy {
double calculate(int daysOverdue, double baseRate);
}
public class LoanManager {
private final FeeCalculationStrategy feeStrategy;
public LoanManager(FeeCalculationStrategy feeStrategy) {
this.feeStrategy = feeStrategy;
}
public double computeOverdueFine(int days, double baseRate) {
return feeStrategy.calculate(days, baseRate);
}
}
// Su dung linh hoat bang Lambda
LoanManager studentLoan = new LoanManager((days, rate) -> days * rate * 0.5);
LoanManager standardLoan = new LoanManager((days, rate) -> days * rate);
7. Những cạm bẫy thường gặp khi thiết kế kế thừa
❌ Bẫy 1: Kế thừa chỉ để lấy lại mã nguồn (Code Reuse)
public class MyStack<E> extends ArrayList<E> {
public void push(E item) { add(item); }
public E pop() { return remove(size() - 1); }
}
Lớp MyStack kế thừa toàn bộ phương thức của ArrayList. Người dùng có thể gọi stack.add(0, item) chèn vào giữa danh sách, phá vỡ hoàn toàn nguyên lý LIFO (Last-In-First-Out) của cấu trúc Stack.
❌ Bẫy 2: Cho rằng Square "là một" Rectangle trong lập trình
Về mặt toán học, hình vuông là một hình chữ nhật đặc biệt. Nhưng trong lập trình hướng đối tượng, nếu Rectangle có phương thức setWidth(w) và setHeight(h) hoạt động độc lập, việc Square extends Rectangle sẽ vi phạm LSP vì thay đổi chiều rộng buộc phải thay đổi luôn cả chiều cao, gây lỗi logic ngầm ở client.
❌ Bẫy 3: Tiếc công viết mã ủy quyền (Delegation Boilerplate) Mã ủy quyền có thể hơi dài dòng lúc ban đầu, nhưng nó mang lại sự độc lập tuyệt đối giữa các module. Các IDE hiện đại đều hỗ trợ tạo mã ủy quyền tự động chỉ bằng một tổ hợp phím.
8. Deep Dive Oracle & Tài liệu tham khảo
- Effective Java (Joshua Bloch):
- Item 18: Favor composition over inheritance (Phân tích chi tiết rủi ro của việc kế thừa giữa các package).
- Item 19: Design and document for inheritance or else prohibit it (Nếu class không được thiết kế và ghi chép rõ ràng cho việc kế thừa, hãy đánh dấu nó là
final).
- Liskov Substitution Principle (Barbara Liskov): Nguyên lý nền tảng quy định tính hợp lệ của quan hệ cha-con trong lập trình hướng đối tượng.
- Design Patterns: Elements of Reusable Object-Oriented Software (GoF): Nguồn gốc của nguyên lý thiết kế "Favor object composition over class inheritance".
9. Tự kiểm tra kiến thức
- Q1Tại sao ví dụ InstrumentedHashSet kế thừa từ BaseSet lại đếm sai tổng số phần tử khi gọi addAll?
- Q2Hiện tượng Fragile Base Class có thể xảy ra ngay cả khi tác giả lớp con không làm gì sai không?
- Q3Hiện tượng bùng nổ lớp (Class Explosion) xuất hiện khi nào và Composition giải quyết nó như thế nào?
- Q4Tại sao việc thiết kế class MyStack kế thừa từ ArrayList lại bị coi là thiết kế tồi?
- Q5Joshua Bloch khuyến nghị điều gì trong Effective Java Item 19 đối với các lớp không được thiết kế chủ đích cho kế thừa?
Bài tiếp theo: Mini-challenge: Thiết kế hệ thống ấn phẩm thư viện đa phương tiện
Bài này có giúp bạn hiểu bản chất không?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Sealed class — kiểm soát tập subtype với pattern matching exhaustive
Bài tiếpMini-challenge: Thư viện đa phương tiện với sealed và record
Lộ trình Junior Java Backend Developer
Chặng 3/21 · OOP: class, kế thừa, đa hình, abstraction — bài 15/16 của chặng
Ôn phỏng vấn
Bài này trả lời được các câu phỏng vấn sau — tự trả lời thử trước khi mở đáp án.
Thảo luận & góp ý
Hỏi đáp về bài này