1. Các hạn chế của kế thừa trong Java
Chỉ có kế thừa đơn cho lớp. Trong Java, một lớp chỉ có thể kế thừa từ một lớp khác duy nhất. Điều này được gọi là kế thừa đơn. Ví dụ, như thế này — được phép:
class Animal { }
class Dog extends Animal { }
Nhưng như thế này — không được:
class Animal { }
class Robot { }
// LỖI! Java không hỗ trợ đa kế thừa cho lớp
class RoboDog extends Animal, Robot { }
Nếu cố gắng khai báo lớp như vậy, trình biên dịch sẽ báo: "class RoboDog cannot extend multiple classes". Tại sao vậy? Bởi vì đa kế thừa dẫn đến mơ hồ: nếu cả hai lớp cha đều có phương thức với cùng chữ ký, dùng cái nào? Đây là “vấn đề hình thoi” (diamond problem) nổi tiếng.
Interface trong Java có thể được triển khai bao nhiêu cũng được, nhưng chúng ta chưa học tới. Sẽ bàn về chúng sau.
Constructor không được kế thừa. Ngay cả khi bạn có lớp cha với constructor tiện lợi, ở lớp con constructor đó sẽ không tự xuất hiện. Cần gọi rõ ràng constructor của lớp cha thông qua super(...) trong constructor của lớp con.
Các thành viên private không được kế thừa. Tất cả các trường và phương thức private của lớp cha không thể truy cập trong lớp con. Chúng tồn tại “bên trong” đối tượng, nhưng không thể truy cập trực tiếp.
2. Vấn đề của hệ phân cấp mong manh
Ràng buộc chặt giữa các lớp. Khi bạn tạo một hệ phân cấp lớp, các lớp con trở nên gắn chặt với lớp cha. Nếu bạn thay đổi lớp cơ sở, điều đó có thể ảnh hưởng (thậm chí làm hỏng) tất cả các lớp con của nó. Hãy tưởng tượng bạn có lớp Animal, từ đó kế thừa ra Dog, Cat, Bird và cả chục lớp khác. Nếu bạn thay đổi cấu trúc Animal (ví dụ thêm tham số bắt buộc mới vào constructor), bạn sẽ phải đi qua tất cả lớp con và cập nhật mã của chúng. Điều này đặc biệt đau đớn trong các dự án lớn.
Vấn đề kế thừa “dễ vỡ”. Đôi khi lớp con có thể vô tình thay đổi hành vi mà lớp cơ sở trông đợi. Ví dụ, lớp cha gọi phương thức của nó bên trong một phương thức khác, còn lớp con ghi đè phương thức đó và đổi logic. Kết quả là lớp cha bắt đầu hoạt động không như mong đợi.
class Animal {
void makeSound() {
System.out.println("Some sound");
}
void sleep() {
System.out.println("Animal is going to sleep...");
makeSound(); // Lớp cha gọi phương thức của chính nó
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Woof!");
}
}
public class Main {
public static void main(String[] args) {
Animal a = new Dog();
a.sleep();
}
}
Chương trình sẽ in ra gì?
Animal is going to sleep...
Woof!
Lớp cha kỳ vọng rằng makeSound() là hiện thực riêng của nó, nhưng trên thực tế phiên bản của lớp con sẽ được gọi! Điều này có thể dẫn đến bug bất ngờ nếu lớp con ghi đè phương thức với logic khác.
3. Vấn đề lớp cơ sở mong manh (fragile base class problem)
Đây là vấn đề có thật trong các dự án lớn. Nếu bạn thay đổi lớp cơ sở (ví dụ thêm trường, đổi hiện thực phương thức), bạn có nguy cơ làm hỏng hành vi của tất cả lớp con. Đôi khi điều này không lộ ra ngay, và việc tìm lỗi có thể tốn hàng giờ hoặc thậm chí nhiều ngày.
Minh họa: giả sử bạn có lớp Shape với phương thức draw(). Bạn quyết định thêm vào Shape phương thức mới drawShadow(), phương thức này gọi draw(). Nhưng một trong các lớp con (Circle) ghi đè draw(), và giờ khi gọi drawShadow() trên Circle, hành vi có thể trở nên bất ngờ.
4. Ràng buộc chặt và khó khăn khi refactor
Khi các lớp liên kết qua kế thừa, việc thay đổi một lớp có thể ảnh hưởng tới cả một chuỗi phụ thuộc. Điều này làm cho mã kém linh hoạt, gây khó khăn khi refactor và mở rộng. Đôi khi phải viết lại cả hệ phân cấp để thêm tính năng mới.
Ví dụ thực tế
class Vehicle { /* ... */ }
class Car extends Vehicle { /* ... */ }
class Bicycle extends Vehicle { /* ... */ }
class Bus extends Vehicle { /* ... */ }
Đột nhiên có yêu cầu: “Hãy thêm xe điện scooter!”. Nhưng xe điện scooter vừa là phương tiện, vừa là gadget. Làm sao đây? Nếu bạn bắt đầu mở rộng hệ phân cấp để nhét tất cả thực thể mới vào, nó sẽ nhanh chóng mất kiểm soát.
5. Vấn đề tái sử dụng mã không có liên hệ logic
Rất thường xuyên, lập trình viên (kể cả người mới lẫn có kinh nghiệm) dùng kế thừa để tái sử dụng mã, ngay cả khi giữa các lớp không có quan hệ “là một” (is-a). Điều này dẫn tới kiến trúc không đúng đắn.
Ví dụ về kế thừa sai
class DatabaseUtils {
void connect() { /* ... */ }
void disconnect() { /* ... */ }
}
class User extends DatabaseUtils { // Người dùng không "là" tiện ích cơ sở dữ liệu!
String name;
}
Hợp lý hơn là dùng composition: để DatabaseUtils là một lớp riêng và gọi các phương thức của nó ở nơi cần, thay vì kế thừa từ nó.
6. Các lựa chọn thay thế cho kế thừa
Composition (has-a)
Nếu một đối tượng “chứa” đối tượng khác, hãy dùng composition. Ví dụ, lớp Car có thể có trường Engine:
class Engine { /* ... */ }
class Car {
private Engine engine;
// ...
}
Ủy quyền
Thay vì mở rộng một lớp, hãy ủy quyền việc thực hiện nhiệm vụ cho đối tượng khác. Điều này giữ được tính linh hoạt và giảm sự phụ thuộc chặt giữa các thành phần.
Interface
Trong Java, một lớp có thể triển khai bao nhiêu interface cũng được. Điều này cho phép kết hợp hành vi linh hoạt mà không cần hệ phân cấp chặt chẽ. Chúng ta sẽ quay lại với interface sau.
Khi nào nên dùng kế thừa?
Chỉ sử dụng kế thừa khi giữa các lớp có quan hệ “is-a” rõ ràng (is-a):
- Mèo là động vật (Cat extends Animal)
- Hình tròn là một hình học (Circle extends Shape)
- Quản trị viên là người dùng (Admin extends User)
Đừng sử dụng kế thừa chỉ để tái sử dụng mã — đã có composition và ủy quyền cho mục đích đó.
7. Một vài ví dụ thực hành
Ví dụ: hệ phân cấp quá rườm rà
class Animal { }
class Mammal extends Animal { }
class Cat extends Mammal { }
class PersianCat extends Cat { }
class SuperPersianCat extends PersianCat { }
Nếu hệ phân cấp của bạn đi sâu quá ba cấp — hãy cân nhắc: có nên dừng lại không? Hệ phân cấp quá sâu làm cho việc hiểu và bảo trì mã trở nên khó khăn.
Ví dụ: hệ phân cấp phẳng
class Animal { }
class Cat extends Animal { }
class Dog extends Animal { }
class Bird extends Animal { }
class Fish extends Animal { }
class Spider extends Animal { }
class Platypus extends Animal { }
class Dragon extends Animal { }
Nếu bạn có hàng chục lớp con, mỗi lớp chỉ khác nhau ở một phương thức, có lẽ nên dùng interface hoặc composition.
8. Những lỗi thường gặp khi sử dụng kế thừa
Lỗi số 1: Kế thừa khi không có quan hệ “is-a”.
Nếu lớp con thực sự không phải là một biến thể của lớp cha, kiến trúc sẽ trở nên không tự nhiên và nhanh chóng vượt ngoài tầm kiểm soát. Ví dụ, lớp User không nên kế thừa từ DatabaseUtils, dù điều đó có vẻ “tiện”.
Lỗi số 2: Ghi đè phương thức nhưng thay đổi hợp đồng (contract).
Nếu bạn ghi đè một phương thức và thay đổi logic của nó đến mức không còn phù hợp với kỳ vọng của lớp cha, điều đó sẽ dẫn tới lỗi bất ngờ. Ví dụ, nếu lớp cơ sở kỳ vọng phương thức draw() là để vẽ hình, nhưng ở lớp con nó bỗng nhiên thực hiện các tác dụng phụ nguy hiểm — đó là thảm họa.
Lỗi số 3: Hệ phân cấp quá sâu hoặc quá phẳng.
Hệ quá sâu làm khó hiểu mã; quá phẳng — dẫn tới trùng lặp.
Lỗi số 4: Cố lách các giới hạn của ngôn ngữ.
Cố gắng hiện thực đa kế thừa bằng “đòn gió” (copy-paste, siêu lớp “tiện ích”) sẽ dẫn tới hỗn loạn.
Lỗi số 5: Mù quáng dùng kế thừa để tái sử dụng mã.
Thường dẫn đến các mối quan hệ bất ngờ giữa các lớp, làm phức tạp việc kiểm thử và bảo trì. Hãy dùng composition và ủy quyền.
GO TO FULL VERSION