DEV Community

Inheritance — Composition vs Inheritance

Composition vs inheritance: vì sao "favor composition" là lời khuyên mặc định

Inheritance (class B extends A) tạo quan hệ "is-a" cứng: B thừa hưởng toàn bộ A và bị ràng vào cấu trúc của A. Composition lắp ghép hành vi bằng cách chứa các thành phần nhỏ và ủy quyền cho chúng — quan hệ "has-a". Cả hai đều tái sử dụng code, nhưng inheritance ràng buộc chặt và cứng theo thời gian, còn composition linh hoạt và đổi được. Lời khuyên "favor composition over inheritance" không phải cấm kế thừa, mà là chọn mặc định composition vì phần lớn bài toán tái sử dụng không thực sự là quan hệ "is-a", và cây kế thừa sâu là một trong những thứ khó refactor nhất.

Cơ chế hoạt động

Inheritance đặt class con vào prototype chain của class cha: con có mọi method của cha, override được, gọi super. Composition đặt một object vào trong object khác và gọi method của nó:

// Inheritance: Duck LÀ Bird
class Bird { fly() { /* ... */ } }
class Duck extends Bird { swim() { /* ... */ } }

// Composition: Duck CÓ khả năng bay và bơi
class Duck {
  constructor(private flyer: Flyer, private swimmer: Swimmer) {}
  fly() { this.flyer.fly() }
  swim() { this.swimmer.swim() }
}
Enter fullscreen mode Exit fullscreen mode

Với composition, Duck lắp các khả năng từ ngoài vào, đổi flyer bằng implementation khác mà không sửa Duck. Với inheritance, Duck dính chặt vào Bird — đổi Bird ảnh hưởng mọi class con.

Vấn đề gặp trong production

Failure mode: rigid hierarchy / bài toán penguin. Cây kế thừa giả định một phân loại sạch, nhưng thực tế hiếm khi sạch:

class Bird { fly() { /* ... */ } }
class Penguin extends Bird {} // penguin không bay được -> kế thừa fly() là sai
Enter fullscreen mode Exit fullscreen mode

Khi một class con không khớp hành vi cha (chim cánh cụt không bay), inheritance buộc hoặc override để throw (vi phạm Liskov — con không thay được cha), hoặc đẩy method xuống một tầng trung gian, làm cây phình thêm. Mỗi exception kiểu này làm hierarchy cứng hơn và khó sửa. Composition tránh hẳn: chỉ những con bay được mới thành phần Flyer.

Failure mode: thay đổi ở cha lan xuống toàn bộ con. Vì con phụ thuộc chi tiết của cha, sửa cha (đổi signature, thêm trạng thái) có thể vỡ mọi con — fragile base class problem. Trong codebase lớn với cây sâu, không ai dám sửa class gốc vì không biết bao nhiêu con bị ảnh hưởng. Composition khoanh tác động: đổi một thành phần chỉ ảnh hưởng nơi dùng thành phần đó.

Failure mode: kế thừa để dùng lại code, không phải vì "is-a". Dùng extends chỉ vì cha tình cờ có method mình cần (ví dụ OrderService extends Utils) là lạm dụng — nó tạo quan hệ ngữ nghĩa sai và kéo theo mọi thứ khác của cha. Khi mục tiêu chỉ là tái sử dụng vài hàm, composition (hoặc đơn giản là import hàm) đúng hơn.

Cách debug và monitor

Dấu hiệu cây kế thừa cần refactor sang composition: độ sâu kế thừa lớn (3+ tầng), các override chỉ để throw "not supported", hoặc nỗi sợ sửa base class. Khi thêm một biến thể mới mà phải chèn một tầng trung gian vào giữa cây, đó là lúc inheritance không còn mô hình hóa đúng. Một cách kiểm nhanh: hỏi "đây có thật là quan hệ is-a không, con có thay được cha ở mọi chỗ không (Liskov)?" — nếu không, dùng composition. Theo dõi class có nhiều con kế thừa như điểm rủi ro thay đổi cao; ưu tiên ổn định interface của chúng.

Tradeoff

Inheritance gọn khi quan hệ "is-a" thật sự đúng và ổn định — chia sẻ code và đa hình tự nhiên, ít boilerplate. Nhưng nó ràng buộc chặt: con phụ thuộc chi tiết cha, cây sâu cứng và khó đổi, và những trường hợp ngoại lệ (penguin) phá mô hình. Composition linh hoạt hơn — lắp/đổi/thay hành vi mà không đụng class chứa, tác động thay đổi được khoanh vùng, dễ test (inject thành phần giả) — đổi lại nhiều boilerplate ủy quyền hơn và nhiều đối tượng nhỏ hơn. Quy tắc thực tế: mặc định composition; chỉ dùng inheritance khi quan hệ is-a thật sự đúng, nông, ổn định, và tuân Liskov.

Câu hỏi phỏng vấn

Khi nào dùng inheritance, khi nào composition, và vì sao lời khuyên mặc định là "favor composition"?

Inheritance tạo quan hệ "is-a" và đặt con vào prototype chain của cha — gọn khi phân loại thật sự đúng, nông, ổn định và con thay được cha ở mọi chỗ (tuân Liskov), cho chia sẻ code và đa hình tự nhiên. Composition tạo quan hệ "has-a", lắp hành vi bằng cách chứa và ủy quyền cho các thành phần nhỏ. Mặc định nên "favor composition" vì phần lớn nhu cầu tái sử dụng không thực sự là is-a: inheritance ràng buộc chặt nên cây sâu trở nên cứng và khó refactor, sửa base class có thể vỡ mọi con (fragile base class), và những ngoại lệ như "penguin không bay" buộc override-để-throw, vi phạm Liskov. Composition linh hoạt hơn — đổi/thay thành phần mà không đụng class chứa, khoanh vùng tác động, dễ test bằng inject. Điểm ăn điểm: dấu hiệu cần refactor là cây 3+ tầng, override chỉ để throw, và dùng extends chỉ để mượn vài hàm.

Hands-on

Lấy một cây kế thừa thật bắt đầu hợp lý rồi gặp ngoại lệ (ví dụ Bird → Duck, rồi thêm Penguin không bay, hoặc các loại PaymentMethod mà một loại không hỗ trợ refund) và quan sát nó buộc override-để-throw hoặc chèn tầng trung gian. Refactor sang composition: tách các khả năng (Flyer, Swimmer, hoặc Refundable) thành thành phần và lắp vào chỉ những đối tượng có khả năng đó. So sánh độ dễ khi thêm một biến thể mới giữa hai cách, và viết test inject thành phần giả để thấy composition dễ test hơn cây kế thừa.

Top comments (0)