Mọi cuốn sách thiết kế đều có câu "ưu tiên thành phần hơn kế thừa". Câu đó được lặp lại nhiều tới mức thành khẩu hiệu, và khẩu hiệu thì ít ai thực sự tin.
Nên hôm nay tôi không giải thích. Tôi cho chạy.
Một lớp đếm số phần tử đã thêm
Yêu cầu đơn giản: một tập hợp giống HashSet, nhưng nhớ xem đã có bao nhiêu lượt thêm phần tử. Cách tự nhiên nhất là kế thừa và ghi đè hai phương thức thêm:
static class TapDem<E> extends HashSet<E> {
private int soLanThem = 0;
@Override public boolean add(E e) {
soLanThem++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
soLanThem += c.size();
return super.addAll(c);
}
int soLanThem() { return soLanThem; }
}
Nhìn không có gì sai. Thêm ba phần tử thì phải đếm được ba.
Kế thừa — thêm 3 phần tử, đếm được: 6
Sáu.
Vì sao
Vì HashSet.addAll() bên trong gọi lại add() cho từng phần tử. Mà add() đã bị TapDem ghi đè, nên nó là bản của lớp con — cộng thêm 1 mỗi lần. Cộng với 3 mà addAll đã cộng sẵn, thành 6.
Điểm mấu chốt: tôi không thể biết điều đó nếu chỉ đọc tài liệu của HashSet. Tài liệu nói addAll làm gì, không nói nó làm bằng cách nào. Việc addAll gọi add là chi tiết cài đặt, và JDK hoàn toàn có quyền đổi nó ở phiên bản sau mà không báo ai.
Đây chính là vấn đề của kế thừa: lớp con phụ thuộc không chỉ vào hợp đồng của lớp cha mà cả vào cách lớp cha tự gọi chính nó. Người ta gọi hiện tượng này là "kế thừa phá vỡ đóng gói", và nó khiến lớp của bạn có thể hỏng vì một bản nâng cấp thư viện mà bạn không hề động vào dòng mã nào.
Viết lại bằng thành phần
Thay vì là một HashSet, ta giữ một Set:
static class TapDemBoc<E> {
private final Set<E> bentrong = new HashSet<>();
private int soLanThem = 0;
boolean add(E e) {
soLanThem++;
return bentrong.add(e);
}
boolean addAll(Collection<? extends E> c) {
boolean doi = false;
for (E e : c) doi |= add(e);
return doi;
}
int soLanThem() { return soLanThem; }
}
Thành phần — thêm 3 phần tử, đếm được: 3
Đúng.
Và quan trọng hơn con số: lớp này không quan tâm HashSet bên trong tự gọi chính nó thế nào. Nó chỉ dùng qua giao diện Set. Đổi sang LinkedHashSet hay TreeSet cũng không ảnh hưởng gì. Nâng cấp JDK cũng không.
Cái giá phải trả là bạn tự viết những phương thức uỷ quyền. Với một giao diện lớn thì đó là việc buồn tẻ — nhưng IDE sinh giúp trong ba giây, và đổi lại bạn kiểm soát được chính xác cái gì lọt ra ngoài.
Vậy khi nào kế thừa là đúng
Không phải bao giờ cũng tránh. Ba câu hỏi tôi tự đặt:
Đây có thật sự là quan hệ "là một" không? HinhVuong là một HinhChuNhat nghe hợp lý cho tới khi bạn thấy setChieuRong phải đổi luôn chiều cao — và mọi mã đang giả định hai chiều độc lập sẽ sai. TapDem cũng vậy: nó có một tập hợp, chứ không hẳn là một tập hợp có thêm tính năng.
Lớp cha có được thiết kế để kế thừa không? Lớp thiết kế cho kế thừa phải ghi rõ trong tài liệu: phương thức nào gọi phương thức nào, ghi đè cái gì thì phải giữ giao ước gì. Nếu tài liệu không nói, bạn đang đoán — và đoán về chi tiết cài đặt là chỗ mà TapDem đã chết.
Bạn có kiểm soát cả hai lớp không? Kế thừa từ lớp trong cùng dự án, do chính nhóm bạn bảo trì, rủi ro thấp hơn hẳn kế thừa từ thư viện bên ngoài.
Nơi kế thừa toả sáng là khi lớp cha cố tình để trống phần cho lớp con điền vào — mẫu "phương thức khuôn mẫu":
abstract class XuLyTepTin {
// Khuôn cố định, lớp con không đổi được
public final void chay(Path tep) {
var noiDung = doc(tep);
var ketQua = xuLy(noiDung); // phần để trống
ghi(ketQua);
}
protected abstract String xuLy(String noiDung);
private String doc(Path tep) { ... }
private void ghi(String kq) { ... }
}
Ở đây kế thừa hoàn toàn hợp lý: chay() là final nên khuôn không bị phá, phần mở rộng được khai tường minh bằng abstract, và lớp con không cần biết gì về bên trong.
super: gọi lên lớp cha
Hai cách dùng:
super(thamSo); // trong hàm khởi tạo, phải là câu lệnh đầu tiên
super.phuongThuc(); // gọi bản của lớp cha thay vì bản đã ghi đè
super.phuongThuc() hay dùng khi muốn mở rộng chứ không thay thế hành vi:
@Override
public String toString() {
return super.toString() + " [đã xác thực]";
}
Lưu ý: chỉ đi lên được một tầng. Không có super.super. Nếu bạn cảm thấy cần nó, cây kế thừa đang quá sâu.
Ghi đè: bốn luật phải nhớ
Chữ ký phải khớp. Tên, số lượng và kiểu tham số giống hệt. Sai một chỗ là bạn đang nạp chồng chứ không phải ghi đè — và mã vẫn biên dịch, chỉ là phương thức của bạn không bao giờ được gọi. Luôn dùng @Override, để trình biên dịch bắt lỗi này hộ:
@Override
public boolean equals(Object o) { ... } // đúng
@Override
public boolean equals(TaiKhoan o) { ... } // lỗi biên dịch nhờ @Override
Không có @Override, dòng thứ hai biên dịch trót lọt và equals của bạn không bao giờ chạy khi HashMap gọi. Đây là một trong những lỗi tốn thời gian nhất tôi từng gặp, và một chú thích năm ký tự là đủ để chặn.
Không thu hẹp quyền truy cập. public không ghi đè thành protected được. Ngược lại thì được.
Kiểu trả về được phép hẹp hơn (hiệp biến): lớp cha trả Object thì lớp con trả String được.
Ngoại lệ checked không được rộng hơn. Lớp con ném ít hơn hoặc hẹp hơn thì được, rộng hơn thì không.
Ba thứ không ghi đè được
Phương thức static — nó thuộc về lớp, không thuộc đối tượng. Khai lại ở lớp con là che chứ không phải ghi đè, và lời gọi được quyết định lúc biên dịch theo kiểu khai báo.
Trường — cũng vậy. Trường trùng tên ở lớp con che trường của lớp cha, và biến kiểu nào thì thấy trường của lớp đó. Đây là cái bẫy rất khó chịu, ta sẽ nhìn thấy nó chạy trong bài đa hình ngày mai.
Phương thức private và final. private thì lớp con không thấy; final thì cấm hẳn.
Java chỉ cho kế thừa một lớp
Không có đa kế thừa lớp. Lý do là bài toán kim cương: nếu D kế thừa cả B và C mà cả hai cùng ghi đè một phương thức của A, thì D dùng bản nào?
Java giải bằng cách cho kế thừa một lớp nhưng cài nhiều giao diện. Từ Java 8, giao diện có phương thức default nên bài toán kim cương quay lại một phần — và Java xử lý bằng cách bắt lớp cài đặt phải ghi đè tường minh khi có xung đột. Chuyện của bài về interface.
final cho lớp không sinh ra để kế thừa. Nghe cực đoan, nhưng nó biến một câu hỏi mơ hồ ("lớp này kế thừa được không?") thành câu trả lời rõ ràng, và cho bạn quyền sửa phần bên trong sau này mà không sợ phá mã của ai. Cần mở ra thì bỏ final lúc nào cũng được — chiều ngược lại thì không.
Thử lại
Chạy hai lớp ở đầu bài trên máy bạn. Rồi thử một biến thể: bỏ hẳn phần ghi đè addAll trong TapDem đi và chạy lại.
Kết quả lần này ra đúng 3 — vì addAll của HashSet gọi add đã ghi đè. Nhưng lớp của bạn giờ phụ thuộc vào việc addAll phải gọi add. Đúng hôm nay, và không ai hứa gì cho phiên bản JDK sau.
Đó mới là điều đáng sợ: cả hai cách viết bằng kế thừa đều có lúc đúng, và bạn không có cách nào biết cách nào đúng nếu không đọc mã nguồn của lớp cha.
Ngày mai: đa hình và liên kết động — cùng một lời gọi chạy ra mã khác nhau, nhìn từ bytecode, kèm cái bẫy trường không đa hình mà tôi vừa nhắc ở trên.