Đa hình (Polymorphism)
Xem thửTình huống mở đầu
Quán Cà Phê Gió có hai loại thẻ thành viên: thẻ thường và thẻ Vàng. Máy tính tiền nhận ra loại thẻ bằng một chuỗi if (card instanceof GoldCard) rồi else, và chuỗi này được chép ở 5 chỗ: quẹt thẻ, in hoá đơn, báo cáo cuối ngày, ứng dụng tra điểm và màn đổi quà. Hôm nay quán ra thẻ Đối tác, nhân ba điểm. Bạn sửa 4 trong 5 chỗ và bỏ sót chỗ quẹt thẻ. Kết quả: khách thẻ Đối tác trả hoá đơn 50000 đồng chỉ được cộng 5 điểm thay vì 15, và máy không báo một lỗi nào.
Có cách nào thêm loại thẻ mới mà không phải mở lại những chỗ đã chạy ổn không? Giữ câu hỏi này, vì cuối bài chính bạn sẽ thêm thẻ Đối tác mà không sửa dòng nào cũ.
Dự án chạy xuyên chương: Hệ thống thẻ thành viên của quán Cà Phê Gió, bước 6 trên 8. Đến bài 25, họ thẻ đã có Card và GoldCard: thẻ Vàng được làm từ khuôn thẻ thường và chỉ khác ở multiplier() và tier(). Bài này đưa họ thẻ ra quầy tính tiền: máy Checkout quẹt loại thẻ nào cũng được, và bạn tự thêm loại thứ ba là PartnerCard mà không sửa dòng nào của máy.
Ôn nhanh bài trước: trong constructor của GoldCard, dòng super(owner) phải nằm ở đâu và để làm gì?
Đáp án: nó phải là dòng đầu tiên, để lớp cha Card dựng phần của nó trước khi lớp con dựng phần của mình.
Mục tiêu bài học
Sau bài này, bạn có thể:
- Dùng một lời gọi trên kiểu
Cardđể chạy các hành vi khác nhau của nhiều loại thẻ, và thêm loại thẻ mới mà không sửa nơi gọi. - Giải thích được vì sao ghi đè được chọn lúc chạy theo thẻ thật (kể cả khi lời gọi nằm trong code kế thừa), còn nạp chồng được chọn lúc biên dịch theo kiểu khai báo.
- Viết đúng chữ ký của phương thức ghi đè với
@Override, và đọc được lỗi khi nhãn này báo sai. - Sửa được một lớp có
equalsnhận kiểu cụ thể thànhequals(Object)đểcontainsvàHashSettìm thấy phần tử.
Bài này đo được bằng năm câu trong phần Tự kiểm tra, mỗi câu ghi rõ nó kiểm tra mục tiêu nào.
Hãy xem video dưới đây để có cái nhìn đầu tiên, sau đó mình đi chậm hơn từng bước ở các phần sau.
Bức tranh lớn (Big Picture)
Trước khi vào chi tiết, hãy nhìn một dòng code của máy tính tiền và những đoạn code mà nó có thể chạm tới.
Cách đọc hình: ô trắng bên trái là dòng gọi duy nhất trong Checkout. Mũi tên "calls" đi tới ô Card.earn, đoạn code chỉ viết một lần. Từ đó ba mũi tên "may run" đi tới ba phiên bản của multiplier(): của Card (ô xám, trả 1), của GoldCard (ô vàng, trả 2) và của PartnerCard (ô lục, nét đứt vì là bài tập của bạn, trả 3). Ô ngoài cùng bên phải là số điểm được cộng. Mỗi ô đều có nhãn chữ, màu chỉ giúp bạn tìm nhanh.
Điều đáng chú ý là bên trái chỉ có một đường đi. Việc rẽ vào phiên bản nào không nằm ở dòng gọi, mà nằm ở tấm thẻ mà biến card đang trỏ tới. Đó là đa hình (polymorphism), nghĩa là "nhiều hình": một lời gọi, nhiều hành vi.
Ẩn dụ xuyên chương: xưởng dập thẻ của quán. Bài 21 nói class là khuôn dập thẻ, bài 22 nói biến là mảnh giấy ghi ngăn tủ cất thẻ, bài 23 nói constructor là khâu kiểm tra tại xưởng, bài 24 nói điểm nằm trong két sau quầy, bài 25 nói khuôn thẻ Vàng làm từ khuôn thẻ thường. Bài này đặt thẻ vào máy quẹt ở quầy: cùng một động tác quẹt thẻ, mỗi loại thẻ tự cộng điểm theo kiểu của nó. Ẩn dụ có một chỗ không còn đúng: máy quẹt thật có đầu đọc phân biệt loại thẻ bằng một công tắc, còn trong Java không có công tắc nào, chính tấm thẻ tự quyết.
Khái niệm cốt lõi
Bài này có ba khái niệm. Mỗi khái niệm đi kèm một hình.
Khái niệm 1: một kiểu cha, nhiều loại thẻ con
Bài trước bạn biết Card card = new GoldCard("Binh") hợp lệ vì GoldCard là một Card. Bài này dùng đúng điều đó: một biến kiểu Card được phép trỏ tới thẻ thường, GoldCard hay bất kỳ lớp con nào khác. Đa hình là khả năng gọi cùng một phương thức trên biến kiểu cha, và mỗi thẻ thật chạy phiên bản của riêng nó.
Cách đọc hình: ô lục bên trái là biến Card card (kiểu khai báo là cha). Ba mũi tên "may point to" tới ba thẻ: một Card thường, một GoldCard và một PartnerCard. Câu ghi chú dưới hình nói chiều ngược lại không đúng: một biến GoldCard không nhận được thẻ thường.
Nhờ đó vòng lặp for (Card card : cards) chỉ cần biết Card. Thêm loại thẻ thứ ba là thêm một lớp con, vòng lặp đứng yên. Đây chính là lời đáp cho câu hỏi ở đầu bài.
Có một chi tiết đáng chú ý ở Card.earn: nó chỉ được viết một lần, không lớp con nào ghi đè nó, vậy mà kết quả vẫn khác nhau giữa các loại thẻ. Lý do là bên trong earn có lời gọi multiplier(), và lời gọi đó cũng được chọn theo thẻ thật. Đa hình không chỉ xảy ra ở nơi bạn gõ lời gọi, mà cả bên trong code được kế thừa.
Khái niệm 2: ghi đè và nạp chồng, hai kiểu chọn khác nhau
Ghi đè (override) là lớp con viết lại phương thức của cha với đúng tên và đúng danh sách tham số. Nạp chồng (overload) là viết nhiều phương thức cùng tên nhưng khác danh sách tham số. Hai thứ nghe giống nhau nhưng Java chọn theo hai cách khác nhau.
Cách đọc hình: hàng trên nói về ghi đè: card.multiplier() với biến Card card = new GoldCard("Binh"), nhãn "run" dẫn tới ô lục: lúc chạy, thẻ thật là GoldCard nên multiplier của nó chạy. Hàng dưới nói về nạp chồng: desk(x) với Card x = new GoldCard("Binh"), nhãn "compile" dẫn tới ô xanh lá nhạt: trình biên dịch chỉ nhìn kiểu khai báo Card, nên chọn desk(Card) dù bên trong là một GoldCard.
Nhớ ngắn gọn: ghi đè hỏi "thẻ thật là gì" lúc chạy, nạp chồng hỏi "tôi đã viết kiểu gì" lúc biên dịch.
Concept Check 1. Có hai phương thức static String desk(Card card) { return "general desk"; } và static String desk(GoldCard card) { return "gold desk"; }. Chạy Card x = new GoldCard("Binh"); System.out.println(desk(x)); in ra gì? Hãy chọn trước khi đọc tiếp.
- A.
gold desk, vì bên trong biếnxthật ra là một GoldCard. - B. Lỗi biên dịch vì hai phương thức
deskgây mơ hồ. - C.
general desk, vì trình biên dịch chọn theo kiểu khai báoCard. - D.
general desk, nhưng chỉ vìdesk(Card)đứng trước trong file.
Đáp án: C. Nạp chồng được chốt lúc biên dịch theo kiểu khai báo của đối số. A áp dụng cách nghĩ của ghi đè (thẻ thật) cho nạp chồng, đây là hiểu lầm hay gặp nhất. B sai vì hai phương thức khác kiểu tham số nên hợp lệ. D sai vì thứ tự trong file không có tác dụng. [Đã chạy trong Overload, JDK 17.0.15: bốn dòng general desk, gold desk, general desk rồi Binh (gold), cho desk(x), desk(y) với GoldCard y, desk((Card) y) và x.describe(). Dòng cuối cho thấy cũng thẻ đó, describe() vẫn chọn theo thẻ thật.]
Khái niệm 3: ghi đè đúng chữ ký, và bẫy equals
Ghi đè chỉ xảy ra khi chữ ký giống hệt. Chỉ cần sai một kiểu tham số, bạn đã tạo ra một phương thức mới thay vì thay thế phương thức cũ, và không ai báo lỗi. @Override là nhãn bắt trình biên dịch kiểm tra: nếu không có gì để ghi đè, nó báo lỗi ngay. Ví dụ kinh điển là equals: thư viện như List.contains luôn gọi equals(Object).
Quán muốn coi hai thẻ cùng chủ là một thẻ, và viết sai chữ ký của equals:
Members.java, dòng 4 đến 10
public static void main(String[] args) {
Card a = new Card("An");
Card b = new Card("An");
System.out.println(a.equals(b));
// contains gọi equals(Object), nên không thấy bản nhận Card
System.out.println(List.of(a).contains(b));
}
Members.java, dòng 20 đến 23
// Đây là nạp chồng, không phải ghi đè equals(Object)
public boolean equals(Card other) {
return owner.equals(other.owner);
}
Kết quả khi chạy [Đã chạy, JDK 17.0.15]:
true
false
a.equals(b) cho true nhưng List.of(a).contains(b) lại false. Đọc code từng phần:
| Dòng code | Máy làm gì | Vì sao quan trọng |
|---|---|---|
public boolean equals(Card other) | Tạo một phương thức mới có tham số kiểu Card | Đây là nạp chồng, không phải ghi đè equals(Object) của Object |
a.equals(b) với a, b kiểu Card | Trình biên dịch chọn bản nhận Card vì kiểu khai báo khớp | Nên cho true, và bạn yên tâm sai chỗ |
List.of(a).contains(b) | Code thư viện gọi equals với đối số kiểu Object | Thư viện không biết Card, nên chỉ thấy equals(Object) của Object |
Object.equals mặc định | So sánh địa chỉ hai đối tượng | a và b là hai đối tượng khác nhau nên cho false |
Cách đọc hình: hàng trên đọc từ trái sang phải: list.contains(b) gọi o.equals(element) trong code thư viện, nơi o mang kiểu Object, và tìm thấy Object.equals(Object) (ô đỏ: so sánh địa chỉ nên false). Mũi tên nét đứt "misses" xuống ô xám BrokenCard.equals(BrokenCard) cho thấy phương thức nhận kiểu cụ thể không bao giờ được tới. Ô lục bên trái nói rằng chỉ phương thức có đúng chữ ký equals(Object) mới thay được bản gốc.
Cách sửa là viết đúng chữ ký, có @Override, và viết thêm hashCode cho khớp:
@Override
public boolean equals(Object o) {
// Kiểm tra kiểu trước, rồi so sánh nội dung
return o instanceof Card other && owner.equals(other.owner);
}
@Override
public int hashCode() {
// Cùng dữ liệu với equals để HashSet tìm đúng ngăn
return owner.hashCode();
}
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
@Override | Nhờ trình biên dịch kiểm tra chữ ký | Nếu gõ sai kiểu tham số, lỗi hiện ngay thay vì im lặng |
public boolean equals(Object o) | Thay equals của Object | Chữ ký phải giống hệt, kể cả mức truy cập public |
o instanceof Card other | Kiểm tra kiểu thật của o, nếu đúng thì đặt tên other | Đối số có thể là bất kỳ đối tượng nào, nên phải kiểm tra trước khi so sánh |
owner.equals(other.owner) | So sánh nội dung hai chuỗi tên chủ thẻ | So sánh nội dung chứ không so địa chỉ |
hashCode() dùng owner.hashCode() | Cho mã băm dựa trên cùng dữ liệu với equals | Hai thẻ bằng nhau phải có cùng mã băm, nếu không HashSet tìm sai chỗ |
Chạy lại với bản sửa, ba dòng đều true [Đã chạy trong MembersFixed, JDK 17.0.15]: a.equals(b), List.of(a).contains(b) và Set.of(a).contains(b).
Ví dụ đầu tiên (Worked Example) và "Xem từng bước"
Đây là chương trình đầu tiên của máy quẹt thẻ: hai thẻ cùng quẹt một hoá đơn 50000 đồng. Lớp có main nằm đầu file, vì chế độ chạy file đơn chạy lớp đầu tiên. Mình chỉ dán những đoạn cần xem; Card và GoldCard là hai lớp của bài 25, rút gọn chỉ còn các phần liên quan, và file Swipe.java đầy đủ chạy được ở thư mục lab.
Phần 1, hàm main và máy Checkout:
Swipe.java, dòng 3 đến 19
public class Swipe {
public static void main(String[] args) {
// Danh sách kiểu cha chứa được mọi loại thẻ
List<Card> cards = List.of(new Card("An"), new GoldCard("Binh"));
for (Card card : cards) {
Checkout.pay(card, 50000);
System.out.println(card.describe());
}
}
}
class Checkout {
static void pay(Card card, int bill) {
// Cứ 10000 đồng được 1 điểm; Checkout không cần biết đây là thẻ loại nào
card.earn(bill / 10000);
}
}
Phần 2, những phương thức của Card mà bài này cần:
Swipe.java, dòng 29 đến 48
int multiplier() {
return 1;
}
String tier() {
return "basic";
}
boolean earn(int amount) {
if (amount <= 0) {
return false;
}
// multiplier() chạy theo thẻ thật, dù earn chỉ viết một lần ở Card
points = points + amount * multiplier();
return true;
}
String describe() {
return owner + " (" + tier() + ") has " + points + " points";
}
Phần 3, hai phương thức mà GoldCard ghi đè:
Swipe.java, dòng 56 đến 64
@Override
int multiplier() {
return 2;
}
@Override
String tier() {
return "gold";
}
Predict: hãy dừng lại, lấy giấy nháp và đoán hai dòng in ra. Mỗi thẻ có bao nhiêu điểm, và Card.earn chỉ viết một lần thì vì sao hai kết quả lại khác nhau? Đoán xong rồi hãy đọc tiếp.
Kết quả mong đợi [Đã chạy, JDK 17.0.15]:
An (basic) has 5 points
Binh (gold) has 10 points
Đọc code từng phần:
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
List<Card> cards = List.of(new Card("An"), new GoldCard("Binh")); | Tạo hai thẻ và cất vào một danh sách kiểu Card | Kiểu của danh sách là cha nên chứa được mọi lớp con |
for (Card card : cards) | Cho card lần lượt trỏ tới từng thẻ | Vòng lặp chỉ cần biết kiểu cha |
Checkout.pay(card, 50000); | Gọi máy quẹt với thẻ và hoá đơn | Đây là dòng duy nhất trong main đụng tới điểm, và nó không biết loại thẻ |
card.earn(bill / 10000); | Đổi hoá đơn thành 5 điểm gốc rồi gọi earn | Cứ 10000 đồng được một điểm; phép chia số nguyên bỏ phần dư |
points = points + amount * multiplier(); | Cộng điểm gốc nhân hệ số của thẻ | multiplier() chạy theo thẻ thật, nên một dòng cho ra hai kết quả |
int multiplier() { return 1; } trong Card | Bản mặc định: thẻ thường nhân 1 | Lớp cha cho hành vi chung, con thay khi cần |
@Override int multiplier() { return 2; } trong GoldCard | Thay bản mặc định, thẻ Vàng nhân 2 | @Override nhờ trình biên dịch kiểm tra chữ ký |
tier() được describe() gọi | In basic hoặc gold theo thẻ thật | Cùng cơ chế chọn theo thẻ thật với multiplier() |
Bây giờ xem từng bước. Mỗi bước nêu lần lặp nào, thẻ thật là gì, và đoạn code nào được chọn:
Đang tải Animation…
Bảng trace (bản chữ tương đương của animation, [Suy luận] bằng cách đi từng dòng và khớp với kết quả chạy thật):
| Lần lặp | card trỏ tới | multiplier() đã chạy | Phép tính | Điểm sau | In ra |
|---|---|---|---|---|---|
| Trước vòng lặp | chưa có | không có | 50000 chia 10000 là 5 điểm gốc | 0 | không có |
| 1 | Card("An") | Card.multiplier trả 1 | 5 nhân 1 | 5 | An (basic) has 5 points |
| 2 | GoldCard("Binh") | GoldCard.multiplier trả 2 | 5 nhân 2 | 10 | Binh (gold) has 10 points |
Bạn vừa làm được: đọc một vòng lặp đa hình và nói đúng mỗi lần lặp chạy multiplier của lớp nào, với bao nhiêu điểm.
Concept Check xen kẽ
Chọn đáp án của bạn trước, rồi đọc phần đáp án ngay bên dưới mỗi câu.
Concept Check 2. Bạn viết class PartnerCard extends Card với phương thức int multipler() { return 3; } (gõ thiếu chữ i, và không có @Override). Với Card card = new PartnerCard("Chi"); card.earn(5);, Chi được bao nhiêu điểm?
- A. 15, vì
PartnerCardghi đè phương thức nhân điểm củaCard. - B. 5, vì
multipler()chỉ là phương thức mới,multiplier()củaCardvẫn chạy. - C. Lỗi biên dịch, vì tên
multiplerkhông tồn tại trong lớp cha. - D. 15, vì Java tự đoán ý bạn khi tên hai phương thức gần giống nhau.
Đáp án: B. Tên multipler khác multiplier nên đây là một phương thức hoàn toàn mới, và earn vẫn gọi multiplier() của Card, trả 1. A và D tưởng "gần giống thì ghi đè". C sai vì code hợp lệ, đó mới là chỗ nguy hiểm. Thêm @Override sẽ đổi lỗi im lặng này thành lỗi biên dịch. [Đã chạy trong Selfcheck, JDK 17.0.15: in Z (basic) has 5 points cho một lớp con gõ sai tên như vậy.]
Concept Check 3. Card chỉ có equals(Card other) (không có hashCode). Bạn bỏ hai thẻ của An vào HashSet, mỗi thẻ là một đối tượng new Card("An") khác nhau. Có mấy phần tử trong tập hợp?
- A. 2, vì
HashSetkhông gọi đượcequals(Card)nên coi hai thẻ là khác nhau. - B. 1, vì hai thẻ có cùng tên chủ thẻ nên
equalscoi là một. - C. 1, vì
HashSetluôn so sánh nội dung các trường của đối tượng. - D. Lỗi, vì
HashSetyêu cầu phải cóhashCodemới được dùng.
Đáp án: A. HashSet dùng hashCode rồi equals(Object); cả hai đều là của Object (so theo địa chỉ) nên hai thẻ là hai phần tử. B và C tin rằng Java tự so nội dung. D sai vì hashCode mặc định vẫn có sẵn, chỉ là không khớp với ý bạn. [Suy luận từ hợp đồng của HashSet, khớp với kết quả false của contains ở trên.]
Làm cùng mình (Guided Practice)
Bạn sẽ thêm loại thẻ thứ ba vào máy quẹt mà không sửa Checkout, qua ba bước nhỏ. Sao chép Swipe.java thành SwipePlus.java, đổi public class Swipe thành public class SwipePlus, rồi thêm vào Card phương thức int getPoints() { return points; }. Chạy bằng java SwipePlus.java.
Bước 1: viết PartnerCard. Thêm lớp này vào cuối file, và thêm new PartnerCard("Chi") vào cuối danh sách trong main.
class PartnerCard extends Card {
// TODO 1: viết constructor nhận owner và chuyển cho lớp cha bằng super
// TODO 2: ghi đè multiplier để trả 3
// TODO 3: ghi đè tier để trả "partner"
}
Chạy xong bạn sẽ thấy ba dòng: An (basic) has 5 points, Binh (gold) has 10 points và Chi (partner) has 15 points. Chú ý rằng bạn không sửa Checkout hay Card.earn dòng nào. Gợi ý: nhớ @Override, và constructor của PartnerCard cần super(owner) ở dòng đầu vì Card không có constructor không đối số.
Vẽ ra giấy: vẽ ba ô thẻ Card, GoldCard, PartnerCard, rồi ghi dưới mỗi ô giá trị multiplier() của nó. Vẽ một mũi tên từ lời gọi card.earn(5) tới từng ô. Đối chiếu: ba mũi tên xuất phát từ cùng một dòng code, và chỉ khác nhau ở ô mà chúng chạm tới. Hình đầu bài là cùng ý.
Bước 2: cộng tổng điểm của quán. Thêm vào lớp có main một phương thức static, rồi trong main in "total: " + total(cards):
static int total(List<Card> cards) {
int sum = 0;
// TODO 4: cộng getPoints của từng thẻ vào sum
return sum;
}
Chạy xong bạn sẽ thấy dòng cuối: total: 30. Gợi ý: vòng for (Card card : cards) giống hệt vòng trong main, chỉ có getPoints() mới đọc được điểm vì field đã private.
Bước 3: thử phá nó đi. Bạn tự quyết cách viết. Trong PartnerCard, sửa tên multiplier thành multipler nhưng giữ nguyên @Override, chạy và đọc lỗi. Rồi xoá @Override của phương thức đó và chạy lại. Chạy xong bạn sẽ thấy lần đầu là lỗi biên dịch method does not override or implement a method from a supertype, lần hai là chương trình chạy không lỗi nhưng Chi chỉ có 5 điểm và total: 20 [Đã chạy, JDK 17.0.15]. Trả tên về multiplier sau khi xem. Lời giải đầy đủ nằm ở cuối bài.
Bạn vừa làm được: thêm một lớp con mới, thấy nó chạy qua đúng dòng card.earn cũ, rồi thấy @Override bắt lỗi gõ sai tên. Toàn bộ mã nguồn của bài cũng nằm trong thư mục 06-oop/06-da-hinh của kho GitHub, nhưng bạn không cần mở nó để học.
Thử phá nó đi (Lỗi thường gặp)
Bốn lỗi dưới đây là những lỗi người mới gặp nhiều nhất khi ghi đè. Mỗi dòng đã được biên dịch hoặc chạy thật [Đã chạy, JDK 17.0.15; thông báo có thể khác chữ ở phiên bản Java khác].
| Code lỗi | Thông báo lỗi | Nguyên nhân bằng lời thường | Cách sửa |
|---|---|---|---|
@Override public boolean equals(Card other) | method does not override or implement a method from a supertype | Có @Override nhưng không có phương thức cha nào nhận Card | Đổi tham số thành Object o |
public boolean equals(Card other) không có nhãn | Không báo lỗi; contains cho false | Đây là phương thức mới, thư viện không gọi nó | Luôn viết @Override, và dùng equals(Object) |
int multipler() { return 3; } trong PartnerCard | Không báo lỗi; thẻ Đối tác chỉ được 5 điểm thay vì 15 | Tên gõ sai nên đây là phương thức mới, Card.multiplier() vẫn chạy | Viết @Override int multiplier() |
int multiplier() trong lớp con khi cha khai báo public int multiplier() | multiplier() in GoldOpenCard cannot override multiplier() in OpenCard kèm attempting to assign weaker access privileges; was public | Ghi đè không được thu hẹp mức truy cập | Thêm public vào phương thức ở lớp con |
Ba lỗi đầu có chung một gốc, và hình dưới đây gom mô hình sai và mô hình đúng lại.
Cách đọc hình: hàng trên màu đỏ nhạt là mô hình sai: nhiều người tưởng tên gần giống là thay thế, nên đoán Chi được 15 điểm. Hàng dưới màu lục là mô hình đúng: multipler() là phương thức thứ hai nằm cạnh multiplier(), nên Chi chỉ được 5 điểm và không có gì bị thay thế.
Quy tắc nhớ: thay thế cần chữ ký giống hệt, và @Override biến việc kiểm tra đó thành trách nhiệm của trình biên dịch.
Bạn vừa làm được: đọc bốn tình huống lỗi của ghi đè và nói được nguyên nhân bằng lời thường.
Tự tay làm (Bài tập)
Làm lần lượt từng mức. Mỗi bài có đề, dữ liệu vào, kết quả mong đợi, tiêu chí nghiệm thu và gợi ý. Lời giải nằm riêng ở cuối bài, hãy thử trước rồi mới so.
Mức Cơ bản
Bài 1. Vẽ trạng thái. Dữ liệu vào: hoá đơn 30000 quẹt cho danh sách List.of(new Card("An"), new GoldCard("Binh"), new PartnerCard("Chi")) của bài. Điền bảng năm cột: lần lặp, card trỏ tới thẻ nào, multiplier() của lớp nào chạy, điểm gốc nhân hệ số, điểm sau. Kết quả mong đợi: bảng ba dòng và ba dòng describe. Tiêu chí nghiệm thu: điểm cuối trùng với chạy thật, và bạn nói được vì sao Checkout không biết thẻ nào là thẻ nào. Gợi ý: 30000 chia 10000 là 3 điểm gốc.
Bài 2. Sửa equals. Dữ liệu vào: file Members.java ở Khái niệm 3. Sửa Card để cả List.of(a).contains(b) và Set.of(a).contains(b) đều true. Kết quả mong đợi: ba dòng true. Tiêu chí nghiệm thu: có @Override trên cả hai phương thức, và bạn giải thích được vì sao phải có hashCode. Gợi ý: dùng instanceof trong equals.
Bài 3. Lúc nào chọn? Với mỗi tình huống, nói phương thức được chọn lúc biên dịch hay lúc chạy, và chọn theo cái gì. Tình huống a: card.multiplier() với Card card = new GoldCard("Binh"). Tình huống b: desk(x) với Card x = new GoldCard("Binh") và hai phương thức desk(Card), desk(GoldCard). Tình huống c: a.equals(b) với a, b kiểu Card và lớp chỉ có equals(Card). Kết quả mong đợi: bảng ba dòng. Tiêu chí nghiệm thu: mỗi dòng có một lý do ngắn. Gợi ý: ghi đè là chọn theo thẻ thật, nạp chồng là chọn theo kiểu khai báo.
Mức Vận dụng
Bài 4. Thẻ thưởng cho quán. Viết lớp BonusCard extends Card ghi đè earn: nếu điểm gốc từ 1 trở lên thì cộng thêm 5 điểm thưởng vào điểm gốc rồi để Card.earn làm tiếp, còn điểm gốc bằng 0 thì không thưởng. Tier là "bonus". Dữ liệu vào: Card a = new BonusCard("An");, quẹt hoá đơn 50000 rồi quẹt tiếp hoá đơn 5000, mỗi lần in a.describe(). Kết quả mong đợi:
An (bonus) has 10 points
An (bonus) has 10 points
Tiêu chí nghiệm thu: không sửa Checkout, không sửa Card. Gợi ý: lời gọi super.earn(...) chạy lại bản của lớp cha.
Mức Thử thách (tuỳ chọn)
Bài 5. Quên hashCode. Giữ equals(Object) đúng nhưng xoá hashCode của Card, rồi bỏ một thẻ của An vào HashSet và hỏi contains(new Card("An")). Kết quả mong đợi: thường là false [Có thể khác nhau, vì mã băm mặc định của mỗi đối tượng thường khác nhau]. Tiêu chí nghiệm thu: bạn giải thích được kết quả bằng hình hai bước của HashSet: tìm ngăn theo mã băm rồi mới gọi equals. Gợi ý: hai đối tượng khác nhau thường có mã băm mặc định khác nhau.
Tự kiểm tra (Retrieval Practice)
Trả lời bằng trí nhớ, không xem lại bài. Mỗi câu ghi rõ nó kiểm tra mục tiêu nào trong bốn mục tiêu ở đầu bài.
Câu 1 (nhớ lại, mục tiêu 2). Phương thức ghi đè được chọn lúc nào và theo cái gì? Phương thức nạp chồng được chọn lúc nào và theo cái gì?
Câu 2 (dự đoán kết quả code, mục tiêu 1). Dùng Card và GoldCard của bài. Đoạn sau in ra gì?
// Biến kiểu cha, thẻ thật là GoldCard
Card c = new GoldCard("X");
c.earn(3);
System.out.println(c.describe());
Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 1). Ba thẻ An (thường), Binh (Vàng), Chi (Đối tác) đều bắt đầu từ 0 điểm. Chạy lần lượt earn(2) rồi earn(3) trên cả ba thẻ. Viết điểm của ba thẻ sau mỗi lần.
Câu 4 (tìm lỗi, mục tiêu 3 và 4). Đoạn sau cho contains trả false dù tên chủ thẻ trùng. Hãy chỉ ra lỗi và sửa:
// Tham số là Card chứ không phải Object
public boolean equals(Card other) {
return owner.equals(other.owner);
}
Câu 5 (giải thích cho bạn chưa học, mục tiêu 1). Bạn của bạn hỏi: "Quán thêm loại thẻ mới thì máy quẹt thẻ có phải sửa không? Nhờ đâu mà không?" Hãy giải thích bằng hình ảnh cái máy quẹt ở quầy.
Điều cần nhớ
- Một biến kiểu cha gọi phương thức thì thẻ thật chạy phiên bản của nó, nên thêm lớp con không buộc sửa nơi gọi (mục tiêu 1).
- Ghi đè chọn lúc chạy theo thẻ thật, kể cả bên trong code kế thừa; nạp chồng chọn lúc biên dịch theo kiểu khai báo (mục tiêu 2).
- Ghi đè cần chữ ký giống hệt, và luôn viết
@Overrideđể trình biên dịch kiểm tra giúp (mục tiêu 3). - Muốn
containsvàHashSettìm thấy, viếtequals(Object)kèmhashCodecùng dữ liệu (mục tiêu 4).
Cheat sheet một trang.
| Việc cần làm | Cú pháp | Ghi chú |
|---|---|---|
| Biến kiểu cha | Card card = new GoldCard("Binh"); | Gọi card.describe() chạy bản của thẻ thật |
| Ghi đè | @Override int multiplier() | Cùng tên, cùng tham số, mức truy cập không thu hẹp |
| Nạp chồng | desk(Card) và desk(GoldCard) | Chọn lúc biên dịch theo kiểu khai báo |
| Gọi bản của cha | super.earn(amount) | Dùng lại rồi thêm phần mình |
| equals đúng | @Override public boolean equals(Object o) | Dùng instanceof để kiểm tra kiểu |
| hashCode đi cùng | @Override public int hashCode() | Cùng dữ liệu với equals |
| Lỗi hay gặp | method does not override... | Dấu hiệu @Override bắt được lỗi chữ ký |
Cách đọc hình: mỗi hàng là một nhu cầu bên trái và công cụ nên viết bên phải. Hàng trên: muốn cùng lời gọi mà khác hành vi thì dùng kiểu cha cộng @Override. Hàng giữa: cùng tên khác đầu vào thì nạp chồng, chọn theo kiểu khai báo. Hàng dưới: muốn thư viện tìm thấy thì equals(Object) cộng hashCode.
Câu hỏi để dành cho ngày mai: lớp Card vẫn có thể bị new Card("An") tạo ra một thẻ không thuộc loại nào, và ai quên ghi đè multiplier() trong lớp con mới sẽ lặng lẽ nhận hệ số 1. Có cách nào cấm tạo ra thẻ chung chung đó mà vẫn bắt mọi loại thẻ phải tự viết phần của mình không?
Đào sâu
Phần này dành cho bạn tò mò. Bỏ qua cũng không thiếu gì ở trên.
Thành viên nào không đa hình. Không phải mọi thứ đều chọn theo thẻ thật. Thử với Base có phương thức static who(), phương thức private secret() và reveal() gọi secret(), còn Derived extends Base khai báo lại static who() và secret(). Chạy cho thấy Base.who Derived.who (mỗi tên lớp gọi who của chính nó), và b.reveal() với Base b = new Derived() in Base.secret, nghĩa là secret() của Derived không thay được secret() private của Base [Đã chạy trong Hidden, JDK 17.0.15]. Phương thức static chỉ bị che, phương thức private không tham gia ghi đè; còn trường cũng chọn theo kiểu khai báo như bài trước đã đo.
Kiểu trả về hiệp biến. Khi ghi đè, kiểu trả về được phép cụ thể hơn: nếu Base.create() trả Base thì Derived.create() có thể trả Derived, và người gọi trên Derived không cần ép kiểu. Điều ngược lại, đổi sang kiểu không liên quan (ví dụ int thay long), bị từ chối lúc biên dịch.
Hiểu lầm nâng cao. Nhiều người nghĩ quên hashCode thì list.contains cũng hỏng. Không đúng: List.contains chỉ gọi equals, nên với equals(Object) đúng nó vẫn tìm thấy. Chỉ các cấu trúc dùng mã băm như HashSet và HashMap mới hỏng, như thử ở Bài 5.
Giới hạn của bài. Bài này chưa nói về lớp trừu tượng và giao diện (bài kế tiếp và chương sau), Comparable và Comparator, hay mẫu thiết kế Strategy chi tiết dù cách bài này làm gần với nó. Bài cũng không bàn các điều kiện đầy đủ của hợp đồng equals như tính đối xứng và bắc cầu.
Đọc thêm (tài liệu chính thống, Java SE 17):
- The Java Tutorials: Polymorphism
- Javadoc: java.lang.Override
- Javadoc: java.lang.Object, hợp đồng của equals và hashCode
- Java Language Specification, mục 8.4.8: Inheritance, Overriding, and Hiding
Bài tiếp theo
Bài sau là trừu tượng: làm sao biến Card thành một lớp không ai tạo ra trực tiếp được, mà vẫn bắt mọi loại thẻ phải tự viết multiplier() và tier() của mình bằng lớp và phương thức abstract.
Mini-dự án nối tiếp: hãy giữ lại Card, GoldCard, PartnerCard và Checkout bạn vừa chạy trong SwipePlus.java. Ở bài sau bạn sẽ đổi Card thành lớp trừu tượng và đổi tên thẻ thường thành BasicCard.
Dự án chạy xuyên chương: đến cuối bài này, họ thẻ có Card, GoldCard, PartnerCard và máy Checkout.pay quẹt được mọi loại thẻ. Bài 27 sẽ biến Card thành khuôn khung chưa hoàn chỉnh: không dập được thẻ trực tiếp từ nó, mỗi loại thẻ phải điền nốt phần của mình.
Lời giải và đáp án
Hãy thử xong rồi mới đọc phần này.
Lời giải Làm cùng mình. Thay các dòng // TODO bằng những dòng dưới đây, ghép lại chạy ra đúng các dòng đã nêu [Đã chạy, JDK 17.0.15, trong SwipePlus]:
class PartnerCard extends Card {
PartnerCard(String owner) {
super(owner);
}
@Override
int multiplier() {
return 3;
}
@Override
String tier() {
return "partner";
}
}
for (Card card : cards) {
sum = sum + card.getPoints();
}
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
super(owner); | Chuyển tên chủ thẻ cho constructor của Card | Card không có constructor không đối số nên bắt buộc gọi tường minh, và phải ở dòng đầu |
@Override int multiplier() { return 3; } | Thay bản mặc định của Card | @Override bắt lỗi nếu gõ sai chữ ký |
@Override String tier() { return "partner"; } | Cho tên loại riêng để describe() in ra | describe() gọi tier() nên tự lấy tên mới |
vòng for trong total | Lần lượt cộng điểm của từng thẻ | Cùng cấu trúc với main, chỉ khác là cộng thay vì in |
card.getPoints() | Đọc điểm qua method | Field points đã private, bên ngoài không đọc trực tiếp được |
Bước 3: tên đúng là multiplier; gõ multipler kèm @Override cho lỗi method does not override or implement a method from a supertype, bỏ @Override thì chạy ra Chi (partner) has 5 points và total: 20 [Đã chạy].
Lời giải Bài 1. [Đã chạy trong Selfcheck, điểm cuối 3, 6, 9]
| Lần lặp | card trỏ tới | multiplier của lớp | Tính | Điểm sau |
|---|---|---|---|---|
| 1 | Card("An") | Card, trả 1 | 3 nhân 1 | 3 |
| 2 | GoldCard("Binh") | GoldCard, trả 2 | 3 nhân 2 | 6 |
| 3 | PartnerCard("Chi") | PartnerCard, trả 3 | 3 nhân 3 | 9 |
Checkout.pay chỉ có một tham số kiểu Card và gọi card.earn, không có dòng nào nhìn loại thẻ, nên nó không biết thẻ nào là thẻ nào.
Lời giải Bài 2. [Đã chạy trong MembersFixed, ba dòng true] Thay phương thức sai bằng đoạn ở phần Khái niệm 3: @Override public boolean equals(Object o) với instanceof Card other, cộng @Override public int hashCode() trả owner.hashCode(). Phải có hashCode vì Set.of dùng mã băm để chọn ngăn trước, và hai thẻ bằng nhau phải rơi vào cùng ngăn.
Lời giải Bài 3. [Suy luận]
| Tình huống | Chọn lúc nào | Chọn theo gì |
|---|---|---|
| a | Lúc chạy | Thẻ thật là GoldCard |
| b | Lúc biên dịch | Kiểu khai báo Card của x |
| c | Lúc biên dịch | Kiểu khai báo Card của a và b khớp equals(Card) |
Lời giải Bài 4. [Đã chạy trong Bonus, in An (bonus) has 10 points hai lần]
class BonusCard extends Card {
BonusCard(String owner) {
super(owner);
}
@Override
boolean earn(int amount) {
// Không có điểm gốc thì không thưởng, rồi cộng 5 điểm thưởng mỗi lần
if (amount <= 0) {
return false;
}
return super.earn(amount + 5);
}
@Override
String tier() {
return "bonus";
}
}
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
@Override boolean earn(int amount) | Thay bản earn của Card | Chữ ký phải giống hệt, kể cả kiểu trả về boolean |
if (amount <= 0) { return false; } | Chặn trường hợp không có điểm gốc | Không có phần này, hoá đơn 5000 đồng vẫn được 5 điểm thưởng |
super.earn(amount + 5) | Chạy bản earn của Card với điểm gốc đã cộng thưởng | Dùng lại luật cộng điểm của cha thay vì chép lại |
Hoá đơn 50000 cho điểm gốc 5, cộng thưởng thành 10, multiplier() là 1 nên có 10 điểm. Hoá đơn 5000 cho điểm gốc 0 nên bị chặn, điểm vẫn 10. Checkout và Card không bị sửa dòng nào: đó là lợi ích của đa hình.
Gợi ý lời giải Bài 5. [Đã chạy trong NoHash, contains cho false] HashSet tìm phần tử bằng hai bước: dùng mã băm chọn ngăn, rồi chỉ gọi equals với những phần tử trong ngăn đó. Hai thẻ của An khác đối tượng nên mã băm mặc định thường khác, chúng rơi vào hai ngăn khác nhau và equals không được gọi. Kết quả có thể khác ở lần chạy khác, nên đừng dựa vào nó.
Đáp án Tự kiểm tra.
- Ghi đè được chọn lúc chạy, theo thẻ thật. Nạp chồng được chọn lúc biên dịch, theo kiểu khai báo của đối số. Kiểm tra mục tiêu 2.
X (gold) has 6 points[Đã chạy trong Selfcheck].earn(3)nhân 3 vớimultiplier()của thẻ thật là 2, vàdescribe()ingoldvìtier()cũng chọn theo thẻ thật, dù biến khai báo kiểuCard. Kiểm tra mục tiêu 1.- Sau
earn(2): An 2, Binh 4, Chi 6. Sauearn(3): An 5, Binh 10, Chi 15 [Đã chạy trong Selfcheck: in5,10,15]. Kiểm tra mục tiêu 1. - Lỗi:
equals(Card other)là nạp chồng, không phải ghi đè, nên thư viện không gọi nó. Sửa thành@Override public boolean equals(Object o)vớiinstanceof Card other, cộnghashCode. Kiểm tra mục tiêu 3 và 4. - Cái máy quẹt vẫn chỉ có một khe. Mỗi loại thẻ tự biết cách cộng điểm của mình, nên thêm loại thẻ mới chỉ là in thêm một loại thẻ. Máy chỉ quẹt, không cần biết thẻ nào. Kiểm tra mục tiêu 1.