Java — Từ Zero đến Senior/Kế thừa & Đa hình/Composition over inheritance — khi nào KHÔNG kế thừa
6/7
~16 phútKế thừa & Đa hình

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.

5 bài trước dạy bạn cú pháp kế thừa đầy đủ. Bài này dạy bạn khi nào không dùng. Nghe nghịch lý, nhưng đây là chương nhiều người nhảy qua rồi phải học lại sau khi trả giá vài dự án.

Inheritance (kế thừa) mạnh, nhưng dễ bị lạm dụng. Nó tạo ràng buộc cứng giữa cha con: sửa cha có thể phá con, con không thể rũ bỏ behaviour cha. Effective Java Item 18 nêu thẳng: "Favor composition over inheritance" (ưu tiên composition).

Bài này giải thích cụ thể vấn đề của inheritance, cách composition giải quyết, pattern delegation, và 2-3 tiêu chí để bạn quyết định khi nào thực sự cần extends.

1. Analogy — ghép lego vs đúc khuôn

Inheritance giống đúc khuôn: con cứng nhắc lấy hình của cha, đổi không dễ. Composition giống ghép lego: bạn lắp các mảnh nhỏ theo ý, thay mảnh này bằng mảnh khác không đụng mảnh kia.

Đời thườngJava
Khuôn đúcclass Child extends Parent
Khối legoclass Car { Engine e; Wheel[] w; }
Thay bánh xeReplace field, không đụng logic khác

💡 💡 Cách nhớ

"Has-a" (có một) thường đúng hơn "is-a" (là một). Xe động cơ, không phải xe động cơ. Khi ngờ ngợ, hỏi "is-a vs has-a" rồi chọn composition nếu has-a phù hợp.

2. Vấn đề với inheritance

2.1 Fragile base class — sửa cha phá con

// Cha: Library
public class HashSet<E> {
    private int size;
    public boolean add(E e) { size++; /* luu */; return true; }
    public boolean addAll(Collection<? extends E> c) {
        for (E e : c) add(e);   // goi add() noi bo
        return true;
    }
}

// Con: User code
public class InstrumentedHashSet<E> extends HashSet<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();   // dem add neu goi truc tiep
        return super.addAll(c);
    }
}

// Test:
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("a", "b", "c"));
System.out.println(s.addCount);   // 6, khong phai 3 !!

Bẫy: super.addAll() (ở HashSet) gọi add() — do dynamic dispatch, add() này là InstrumentedHashSet.add() (con override). Mỗi phần tử addCount tăng 2 lần: một lần trong addAll, một lần trong add được gọi ngầm.

Class con phụ thuộc vào implementation detail của cha (việc addAll gọi add nội bộ). Nếu một ngày HashSet.addAll đổi thành không gọi add → con vỡ không rõ lý do.

Đây gọi là fragile base class problem — vấn đề cổ điển của inheritance, xuất hiện trong mọi ngôn ngữ OOP.

2.2 Strong coupling — không chọn lọc được

Class con extends cha nghĩa là nhận tất cả: field, method, behaviour. Không có cách "chỉ lấy một phần". Nếu cha có method deleteAll mà con không muốn lộ, phải override để throw exception — xấu và vẫn có bytecode.

2.3 Đơn kế thừa — khóa cây

Class chỉ extends 1 cha. Nếu Employee extends Person rồi bạn muốn Employee còn là Auditable (share logic audit log), hết đường — phải composition với Auditable.

2.4 Không linh hoạt runtime

Kế thừa cố định lúc compile. Muốn object "đổi cha" tại runtime — vd một user đổi role → đổi behaviour → không thể với inheritance. Composition cho phép swap field: user.setRole(new AdminRole()).

3. Composition giải quyết thế nào

Composition = class chứa reference đến object khác, delegate hành vi qua reference đó:

// Thay vi "InstrumentedHashSet extends HashSet"
public class InstrumentedSet<E> implements Set<E> {
    private final Set<E> delegate;
    private int addCount = 0;

    public InstrumentedSet(Set<E> set) {
        this.delegate = set;
    }

    @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);
    }

    // Delegate các method còn lại
    @Override public int size() { return delegate.size(); }
    @Override public boolean isEmpty() { return delegate.isEmpty(); }
    @Override public boolean contains(Object o) { return delegate.contains(o); }
    // ... n method khac delegate tuong tu

    public int getAddCount() { return addCount; }
}

// Su dung:
InstrumentedSet<String> s = new InstrumentedSet<>(new HashSet<>());
s.addAll(List.of("a", "b", "c"));
System.out.println(s.getAddCount());   // 3 — dung

delegate.addAll(c) gọi trên HashSet gốc — HashSet.addAll gọi HashSet.add (không phải InstrumentedSet.add) → không double-count.

Ngoài ra:

  • InstrumentedSet decoupled khỏi implementation của HashSet — đổi sang TreeSet chỉ cần truyền vào constructor khác.
  • Làm việc với bất kỳ Set implementation nàoLinkedHashSet, ConcurrentSkipListSet, v.v.

4. Downside của composition — viết nhiều boilerplate

Bạn phải delegate mỗi method của interface. Set có ~15 method → viết 15 dòng 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); }
// ...

Java không có cơ chế "delegate tự động" (Kotlin có by delegate). Workaround: IDE "Generate delegate methods", hoặc Lombok @Delegate.

Nhưng boilerplate là giá phải trả để có flexibility. Thử sửa cha class thay đổi behaviour chung — composition cho bạn isolation, inheritance không có.

5. Kotlin-style delegation với record

Java không có first-class delegation nhưng pattern "constructor composition" sạch hơn với record:

public record AuditedOrder(Order delegate, Auditor auditor) {
    public void submit() {
        auditor.log("submit", delegate);
        delegate.submit();
    }

    public String id() { return delegate.id(); }
    public double total() { return delegate.total(); }
}

Record + method delegate vẫn boilerplate, nhưng đọc gọn hơn class thường.

6. Khi nào NÊN dùng inheritance?

Theo Effective Java Item 18:

Dùng khi:

  1. "Is-a" thật sự chặt chẽ — con thật sự là một loại của cha, tuân theo Liskov Substitution Principle: mọi code dùng được với cha phải dùng được với con không đổi behaviour.
  2. Cha được thiết kế cho kế thừa — có javadoc mô tả rõ method nào gọi method nào, method nào subclass nên override. Ví dụ AbstractList, Thread.
  3. Cùng package hoặc cùng tác giả — kiểm soát được cha và con cùng lúc, không bị fragile base class bất ngờ.

Không dùng khi:

  • Có quan hệ "has-a" (car has-an engine) hoặc "implements" (car implements Vehicle).
  • Cha là class không thiết kế cho kế thừa (vd ArrayList, HashMap).
  • Cha là class bạn không kiểm soát (library khác, framework).
  • Cần re-use một vài method của cha — composition tốt hơn.

7. Interface + composition — thay thế inheritance tốt nhất

Pattern phổ biến:

// 1. Dinh nghia interface capability
public interface PaymentProvider {
    void pay(double amount);
}

// 2. Nhieu implementation
public class StripeProvider implements PaymentProvider { ... }
public class PaypalProvider implements PaymentProvider { ... }
public class MomoProvider implements PaymentProvider { ... }

// 3. Client compose, khong extends
public class Checkout {
    private final PaymentProvider provider;

    public Checkout(PaymentProvider provider) {
        this.provider = provider;
    }

    public void complete(double amount) {
        provider.pay(amount);
    }
}

// 4. Dependency injection:
Checkout c1 = new Checkout(new StripeProvider());
Checkout c2 = new Checkout(new PaypalProvider());
// Runtime swap cung duoc

Ưu điểm:

  • Không khoá cây kế thừa.
  • Test dễ — mock PaymentProvider.
  • Swap implementation runtime.
  • Class Checkout không quan tâm StripeProvider viết thế nào — chỉ quan tâm hợp đồng pay.

8. Ví dụ thực tế — Strategy pattern

Thay vì một hierarchy lớn SortedList, ReverseSortedList, CustomSortedList:

public class SortedList<E> {
    private final List<E> items = new ArrayList<>();
    private final Comparator<E> comparator;

    public SortedList(Comparator<E> comparator) {
        this.comparator = comparator;
    }

    public void add(E e) {
        int i = Collections.binarySearch(items, e, comparator);
        if (i < 0) i = -(i + 1);
        items.add(i, e);
    }
}

// Su dung:
SortedList<Integer> asc = new SortedList<>(Integer::compareTo);
SortedList<Integer> desc = new SortedList<>(Comparator.reverseOrder());
SortedList<String> byLen = new SortedList<>(Comparator.comparingInt(String::length));

Strategy (Comparator) được inject qua constructor — 3 "kiểu" sort, 1 class, không inheritance.

9. Pitfall tổng hợp

Nhầm 1: class Stack extends ArrayList để "tái dùng" — breaks LSP.

Stack<Integer> s = new Stack<>();
s.push(1); s.push(2); s.push(3);
s.add(0, 99);   // ArrayList cho phep — pha invariant LIFO cua Stack

class Stack { private final List<E> data; void push(E e) { ... } }.

Nhầm 2: class User extends HashMap<String, Object> để "store dynamic fields".

user.put("name", "Alice");
user.clear();   // ai cung xoa duoc data user

class User { private final Map<String, Object> attributes; }.

Nhầm 3: Extends class không thiết kế cho kế thừa.

class MyList extends ArrayList<String> {
    @Override public boolean add(String e) {
        // override add -> logic con co the bi addAll goi ngam -> fragile
    }
}

✅ Composition với List<String> hoặc AbstractList.

Nhầm 4: Tiếc boilerplate → extends cho nhanh. ✅ IDE generate delegate methods, hoặc thiết kế API để cần ít method hơn.

Nhầm 5: "Is-a" trong ngôn ngữ thường ≠ "is-a" trong LSP.

  • "Car is-a Vehicle" ✓ nếu mọi thao tác vehicle làm được với car không phá behaviour.
  • "Square is-a Rectangle" ✗ — set width/height có hành vi khác.

10. 📚 Deep Dive Oracle

ℹ️ 📚 Deep Dive Oracle (optional)

Spec / reference chính thức:

Ghi chú: Item 18 & 19 đi đôi: nếu bạn ra quyết định dùng inheritance, bạn phải thiết kế cha một cách chủ ý — document "method này gọi method kia" để con hiểu điểm mở rộng. Hoặc final class để cấm kế thừa và buộc composition. Không có trung gian. Bloch cho rằng "class mặc định mở cho kế thừa" là thiết kế kém.

11. Tóm tắt

  • Inheritance mạnh nhưng dễ lạm dụng — tạo coupling cứng giữa cha và con.
  • Fragile base class problem: sửa cha phá con vì con phụ thuộc implementation detail.
  • Composition = class chứa reference, delegate hành vi — decoupled, flexible, swap được runtime.
  • Composition boilerplate hơn (delegate mỗi method), nhưng IDE/Lombok giúp giảm.
  • Interface + composition là thay thế tốt nhất cho nhiều kịch bản: capability-based, swap implementation.
  • Strategy pattern — inject behaviour qua constructor thay vì extends hierarchy.
  • Chỉ extends khi: (1) "is-a" chặt theo LSP, (2) cha thiết kế cho kế thừa, (3) cùng kiểm soát cha & con.
  • Rule "is-a vs has-a": "Car has-a Engine" → composition; "Circle is-a Shape" (tuân LSP) → có thể inheritance.
  • Effective Java Item 18: "Favor composition over inheritance".

12. Tự kiểm tra

Tự kiểm tra
Q1
Đoạn sau có vấn đề gì?
public class CountingList<E> extends ArrayList<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);
    }
}

CountingList<String> list = new CountingList<>();
list.addAll(List.of("a", "b", "c"));
System.out.println(list.addCount);

In 6, không phải 3 — đây là fragile base class bug cổ điển.

Luồng thực tế:

  1. list.addAll(...)CountingList.addAll: addCount += 3.
  2. Gọi super.addAll(c)ArrayList.addAll.
  3. ArrayList.addAll internal gọi this.add(e) cho từng phần tử.
  4. Dynamic dispatch → this.addCountingList.add (override).
  5. Mỗi phần tử: addCount++ thêm 3 lần nữa.

Tổng: 3 (addAll) + 3 (add ngầm) = 6.

Fix: composition, không inheritance:

class CountingList<E> implements List<E> {
    private final List<E> delegate = new ArrayList<>();
    private int addCount = 0;
    public boolean add(E e) { addCount++; return delegate.add(e); }
    public boolean addAll(Collection<? extends E> c) { addCount += c.size(); return delegate.addAll(c); }
    // delegate moi method khac cua List
}

delegate.addAll gọi delegate.add (không phải CountingList.add) → không double count.

Q2
Phân biệt "is-a" trong ngôn ngữ thường và "is-a" theo Liskov. Cho ví dụ.

Ngôn ngữ thường: "X là một loại Y" hiểu rộng, bao gồm taxonomy toán học/sinh học.

Liskov: "X is-a Y" chỉ đúng khi mọi code viết cho Y dùng được với X mà không đổi hành vi/invariant.

Ví dụ Square/Rectangle (kinh điển):

  • Ngôn ngữ thường: "Square là Rectangle đặc biệt có width=height" — đúng.
  • Liskov: sai. Code void test(Rectangle r) { r.setWidth(5); r.setHeight(10); assert r.area() == 50; } đúng với Rectangle, fail với Square (setHeight sửa cả width → area = 100). Square vi phạm invariant "setWidth/setHeight độc lập" của Rectangle.

Kết luận: khi toán học/taxonomy "is-a" nhưng behaviour khác → không dùng inheritance. Dùng interface chung Shape với area(), hoặc composition.

Rule thực hành: liệt kê mọi method của cha, hỏi "con override/không override thì caller có bị surprised không?". Surprise → không inheritance.

Q3
Viết lại đoạn sau bằng composition + interface:
class User {
    String name;
}
class AdminUser extends User {
    void deleteEverything() { ... }
    void banUser(User target) { ... }
}
class ModeratorUser extends User {
    void banUser(User target) { ... }
}

Vấn đề: AdminUserModeratorUser đều cần banUser — duplicate. Mở rộng thêm role thứ 3 có permission nhóm khác → inheritance không cover được.

Refactor với role/permission composition:

public interface Role {
    Set<Permission> permissions();
}

public enum Permission { DELETE_ALL, BAN_USER, READ, WRITE }

public class AdminRole implements Role {
    public Set<Permission> permissions() {
        return Set.of(Permission.DELETE_ALL, Permission.BAN_USER, Permission.READ, Permission.WRITE);
    }
}

public class ModeratorRole implements Role {
    public Set<Permission> permissions() {
        return Set.of(Permission.BAN_USER, Permission.READ);
    }
}

public class User {
    private final String name;
    private Role role;

    public User(String name, Role role) {
        this.name = name;
        this.role = role;
    }

    public void setRole(Role role) { this.role = role; }

    public void banUser(User target) {
        if (!role.permissions().contains(Permission.BAN_USER)) {
            throw new SecurityException("no permission");
        }
        // logic ban
    }

    public void deleteEverything() {
        if (!role.permissions().contains(Permission.DELETE_ALL)) {
            throw new SecurityException("no permission");
        }
        // logic delete
    }
}

Lợi ích:

  • Không cây kế thừa User. Thêm role mới chỉ viết class mới implement Role.
  • Role thay đổi runtime: user.setRole(new AdminRole()) — không với inheritance.
  • Check permission ở một chỗ, không scatter trong nhiều class con.
Q4
Khi nào bạn thực sự nên extends một class có sẵn?

Theo Effective Java Item 18 — 3 điều kiện đi cùng:

  1. Liskov substitutability: con thật sự là một loại của cha, mọi caller dùng được với con y hệt với cha.
  2. Cha thiết kế cho kế thừa: javadoc rõ method nào gọi method nào (self-use), chỉ ra điểm subclass có thể override an toàn. Ví dụ: AbstractList, Thread, HttpServlet.
  3. Cùng kiểm soát: bạn là tác giả cả cha & con, hoặc cha stable với hợp đồng rõ ràng. Không extends class library bất kỳ (ArrayList, HashMap) — chúng không thiết kế cho kế thừa.

Ví dụ OK: extends AbstractList để triển khai 1 list mới (chỉ cần implement get(i)size(), cha cung cấp phần còn lại). Đây là skeletal implementation pattern — abstract class thiết kế chủ đích cho extends.

Ví dụ KHÔNG OK: MyStack extends ArrayList. ArrayListadd(int index, E element) cho phép insert giữa — phá invariant LIFO của Stack. Dùng composition.

Q5
So sánh cách dùng Comparator inject qua constructor với việc viết subclass AscSortedList, DescSortedList. Ưu nhược?

Subclass cho mỗi kiểu sort:

class SortedList<E> { ... }
class AscSortedList<E> extends SortedList<E> { /* so sanh nho den lon */ }
class DescSortedList<E> extends SortedList<E> { /* nguoc lai */ }
  • Mỗi kiểu sort → 1 class mới. Thêm "sort theo độ dài string" phải viết class thứ 3.
  • Không thể đổi kiểu sort tại runtime — 1 object đã là Asc hay Desc rồi.
  • Boilerplate tăng theo số kiểu.

Inject Comparator:

class SortedList<E> {
    private final Comparator<E> comparator;
    public SortedList(Comparator<E> comparator) { this.comparator = comparator; }
}
  • 1 class, N comparator → N kiểu sort.
  • Comparator có thể built bằng Comparator.comparing(...), .reversed(), .thenComparing(...) — compose ngay runtime.
  • Có thể đổi bằng setter.
  • Test dễ — truyền mock comparator.

Đây là Strategy pattern — composition với interface thay cho inheritance. Java standard library dùng khắp: TreeSet(Comparator), PriorityQueue(Comparator), Collections.sort(list, Comparator).

Rule chung: nếu "kiểu" chỉ khác nhau ở 1 behaviour, dùng Strategy + composition. Inheritance chỉ hợp khi "kiểu" khác nhau ở nhiều behaviour đồng thời và cấu trúc cây khớp tự nhiên.


Bài tiếp theo: Mini-challenge: Thiết kế hệ thống animal zoo với sealed + record