Kế thừa (Inheritance)
Xem thửTình huống mở đầu
Quán Cà Phê Chú Tư có hai loại thẻ: thẻ thường Card và thẻ Vàng cộng điểm gấp đôi. Lúc đầu bạn chép nguyên lớp Card sang một lớp mới tên GoldCard, rồi đổi đúng một chỗ: hệ số nhân từ 1 thành 2. Hai lớp giống nhau gần hết, từ trường owner, points đến earn, redeem và describe. Tuần này quán đổi luật: earn phải từ chối số điểm không dương. Bạn sửa ở Card, quên mất bản sao GoldCard. Hôm sau nhân viên gõ nhầm earn(-30) trên thẻ Vàng của An đang có 100 điểm. Máy không báo lỗi gì, và An còn 40 điểm, vì bản sao quên luật mới đã nhân 2 cho cả số âm.
Làm sao viết phần giống nhau đúng một lần, để sửa một chỗ là mọi loại thẻ đổi theo? Giữ câu hỏi này, vì cuối bài GoldCard của bạn chỉ còn vài dòng và không chép lại dòng nào của Card.
Dự án chạy xuyên chương: Hệ thống thẻ thành viên của quán Cà Phê Chú Tư, bước 5 trên 8. Đến bài 24, Card đã có hàm khởi tạo, điểm được khoá private và chỉ đổi qua earn có luật. Bài này thêm khuôn thẻ Vàng làm từ khuôn thẻ thường: Card có thêm hai chỗ để lớp con thay đổi là multiplier() và tier(), và GoldCard kế thừa phần còn lại.
Ôn nhanh bài trước: vì sao trường points của Card khai báo private?
Đáp án: để code bên ngoài không gán thẳng điểm, mà phải đi qua earn và redeem có luật. Bài này bạn sẽ thấy private còn ảnh hưởng tới cả lớp con.
Mục tiêu bài học
Sau bài này, bạn có thể:
- Viết được
class GoldCard extends Cardvới hàm khởi tạo gọisuper(owner)và ghi đè một phương thức. - Mô tả được điều gì xảy ra khi chạy
new GoldCard("Binh"): một object gồm phầnCarddựng trước, phầnGoldCarddựng sau. - Dự đoán được phương thức nào chạy khi biến khai báo kiểu
Cardtrỏ tới một objectGoldCard. - Quyết định được một quan hệ nên dùng
extendshay giữ một trường, bằng phép thử "là mộ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 cây lớp của hệ thống thẻ.
Cách đọc hình: đọc từ dưới lên theo mũi tên "extends". Ba ô vàng ở hàng dưới là các lớp thẻ con, mỗi ô đều có mũi tên chỉ lên Card, nghĩa là mỗi loại thẻ là một Card. Card có mũi tên chỉ lên Object, lớp mà mọi lớp Java đều đứng dưới. Ô lục là lớp cha mà ta viết, ô xám là Object có sẵn của Java. Mỗi ô đều có nhãn chữ, màu chỉ giúp bạn tìm nhanh.
Ý tưởng cả bài gói trong một câu: phần chung viết một lần ở lớp cha, mỗi lớp con chỉ viết phần khác biệt. Điều này giải đáp thẳng câu hỏi ở đầu bài.
Ẩn dụ xuyên chương: xưởng dập thẻ của quán. Ở bài 21 class là cái khuôn và object là tấm thẻ dập ra, ở bài 22 biến là mảnh giấy ghi ngăn tủ cất thẻ, ở bài 23 constructor là khâu kiểm tra tại xưởng, ở bài 24 điểm nằm trong két sau quầy. Bài này thêm: khuôn thẻ Vàng được làm từ khuôn thẻ thường, lấy nguyên khuôn cha rồi thêm nét riêng của con. Dự án vẫn là hệ thống thẻ thành viên của quán Cà Phê Chú Tư. Ẩn dụ có hai chỗ không còn đúng: một khuôn con thật có thể dựa trên nhiều khuôn, còn một lớp Java chỉ có đúng một lớp cha trực tiếp. Và khuôn con không nhìn thấy ngăn kéo khoá của khuôn cha, đó chính là các thành viên private.
Khái niệm cốt lõi
Bài này có bốn khái niệm. Mỗi khái niệm đi kèm một hình.
Khái niệm 1: extends và quan hệ "là một"
Kế thừa (inheritance) là việc một lớp lấy sẵn các thành viên của lớp khác rồi thêm hoặc thay đổi vài thứ. Viết class GoldCard extends Card nghĩa là: thẻ Vàng là một thẻ. Lớp cha (superclass) là lớp được kế thừa, lớp con (subclass) là lớp kế thừa. Ở xưởng, khuôn thẻ Vàng nhận nguyên khuôn thẻ thường làm nền.
Cách đọc hình: ô GoldCard ở dưới trái có mũi tên "extends" lên Card. Từ GoldCard đi ngang là các thứ nó có: earn() dùng của cha, và multiplier() thẻ Vàng tự viết lại. Ô màu đỏ private owner, points nối với Card bằng nét đứt "hides": chỉ code bên trong Card đọc được chúng.
Lớp con nhận mọi thành viên không private của lớp cha. Thành viên private vẫn nằm trong object, nhưng code của lớp con không viết thẳng tên nó được. Muốn biết nên dùng extends hay không, hãy dùng phép thử "là một": nói to câu "A là một B". "GoldCard là một Card" nghe đúng, nên dùng extends. "Customer là một Card" nghe sai, vì khách hàng có một thẻ, nên Customer giữ một trường kiểu Card. Bài tập 3 cho bạn luyện phép thử này.
Khái niệm 2: dựng object, cha trước con sau
Khi chạy new GoldCard("Binh"), Java tạo một object, nhưng nó gồm hai lớp: phần Card (có owner và points) và phần GoldCard. Hàm khởi tạo (constructor) không được kế thừa, mỗi lớp tự viết của mình. Dòng đầu của hàm khởi tạo GoldCard phải là super(owner): lời gọi hàm khởi tạo của lớp cha, để phần Card dựng xong trước.
Cách đọc hình: hàng trên đọc từ trái sang phải: new GoldCard("Binh") gọi hàm khởi tạo GoldCard, dòng đầu của nó là super(owner) gọi hàm khởi tạo Card. Mũi tên đi xuống "fills" cho thấy Card gán owner = Binh. Mũi tên "then" trỏ sang GoldCard part: phần thẻ Vàng được hoàn tất sau cùng.
Vì phần cha luôn dựng trước, phần con có thể tin rằng owner đã có giá trị khi thân hàm khởi tạo GoldCard bắt đầu chạy. Nếu bạn không viết super(...), Java tự thêm super() không đối số vào dòng đầu. Nếu lớp cha không có hàm khởi tạo không đối số thì dòng ngầm đó lỗi biên dịch, bạn sẽ thấy ở phần Thử phá nó đi.
Khái niệm 3: ghi đè và leo lên lớp cha để tìm phương thức
Ghi đè (override) là viết lại trong lớp con một phương thức đã có ở lớp cha, cùng tên và cùng kiểu tham số. @Override là nhãn báo cho trình biên dịch kiểm tra: "tôi định ghi đè", nếu sai chữ ký nó báo lỗi ngay. Khi gọi binh.earn(10), Java bắt đầu tìm từ lớp thật của object, nếu GoldCard không có thì leo lên Card.
Cách đọc hình: đọc theo mũi tên. binh.earn(10) hỏi GoldCard trước (ô đỏ: không tìm thấy), rồi leo lên Card.earn(). Từ đó, lời gọi multiplier() đi xuống ô GoldCard.multiplier() vì object thật là thẻ Vàng, và kết quả là 10 nhân 2, thẻ có 20 điểm.
Điểm dễ làm bạn bất ngờ: mã của earn() nằm trong Card, nhưng multiplier() được chọn theo object đang chạy chứ không theo nơi mã được viết. Muốn một lớp con vừa dùng lại hành vi của cha vừa thêm phần mình, dùng super.tênPhươngThức(): bạn sẽ làm điều đó ở phần Làm cùng mình.
Khái niệm 4: kiểu khai báo và object thật
Một biến có kiểu khai báo (kiểu viết ở bên trái, như Card same) và trỏ tới một object thật (thứ được tạo bởi new). Vì thẻ Vàng là một Card, bạn được gán Card same = binh. Hai biến cùng trỏ vào một object, không có bản sao nào được tạo.
Cách đọc hình: hai ô bên trái là hai biến, cả hai có mũi tên "points to" tới ô giữa là một object GoldCard duy nhất. Nét đứt "try" từ Card same sang ô đỏ same.badge() cho thấy: nếu Card không có badge() thì dòng đó không biên dịch được, dù object thật là thẻ Vàng có badge().
Nhớ một câu: kiểu khai báo quyết định bạn được viết gì, object thật quyết định phiên bản nào chạy.
Concept Check 1. Có Card same = new GoldCard("Binh"); rồi bạn gọi same.earn(10). Hỏi thẻ có bao nhiêu điểm? Chọn đáp án trước khi đọc tiếp.
- A. 10 điểm, vì kiểu khai báo là Card nên chạy hệ số nhân 1 của Card.
- B. Lỗi biên dịch, vì biến kiểu Card không thể trỏ tới object GoldCard.
- C. 20 điểm, nhưng chỉ vì lệnh gán đã chép hệ số nhân 2 sang biến same.
- D. 20 điểm, vì object thật là GoldCard nên multiplier() của GoldCard chạy.
Đáp án: D. A là mô hình sai phổ biến nhất, hình "mô hình sai và mô hình đúng" ở phần sau nói riêng về nó. B sai vì thẻ Vàng là một Card nên gán được. C sai vì không có gì được chép: biến chỉ giữ tham chiếu tới object, và object thật vẫn là GoldCard.
Ví dụ đầu tiên (Worked Example) và "Xem từng bước"
Đây là chương trình đầu tiên của bài. Card ở đây gộp các bài trước và rút gọn: có owner như bài 23, điểm private và earn có luật như bài 24, nhưng bỏ phần kiểm tra của hàm khởi tạo, bỏ redeem và getPoints để tập trung vào kế thừa, và thêm multiplier() cùng tier() để lớp con có chỗ thay đổi. Trong chế độ chạy file đơn, Java chạy lớp đầu tiên của file, nên lớp có main nằm đầu, hai lớp còn lại đặt phía sau.
GoldDemo.java
public class GoldDemo {
public static void main(String[] args) {
GoldCard binh = new GoldCard("Binh");
Card same = binh;
binh.earn(10);
System.out.println(binh.describe());
System.out.println(same.describe());
Card an = new Card("An");
an.earn(10);
System.out.println(an.describe());
}
}
class Card {
private final String owner;
private int points;
Card(String owner) {
this.owner = owner;
}
int multiplier() {
return 1;
}
String tier() {
return "basic";
}
boolean earn(int amount) {
if (amount <= 0) {
return false;
}
// multiplier() ở đây chạy bản của object thật, không phải của Card
points = points + amount * multiplier();
return true;
}
String describe() {
return owner + " (" + tier() + ") has " + points + " points";
}
}
class GoldCard extends Card {
GoldCard(String owner) {
// Nhờ Card dựng phần của nó trước, bắt buộc là dòng đầu
super(owner);
}
@Override
int multiplier() {
return 2;
}
@Override
String tier() {
return "gold";
}
}
Predict: hãy dừng lại và đoán ba dòng in ra là gì, đặc biệt dòng đầu và dòng thứ hai có khác nhau không. Đoán xong mới đọc tiếp.
Kết quả mong đợi [Đã chạy, JDK 17.0.15]:
Binh (gold) has 20 points
Binh (gold) has 20 points
An (basic) has 10 points
Đọc code từng phần:
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
GoldCard binh = new GoldCard("Binh"); | Tạo một object GoldCard, chạy hàm khởi tạo GoldCard rồi Card, cho binh trỏ tới nó | Biến binh khai báo kiểu GoldCard để dùng được mọi thứ của GoldCard |
Card same = binh; | Cho biến thứ hai kiểu Card trỏ vào cùng object | Không tạo object mới; thẻ Vàng là một Card nên gán được |
binh.describe() và same.describe() | Cùng chạy describe() kế thừa từ Card, và tier() bên trong chạy bản của GoldCard | Cả hai biến trỏ vào cùng object thật nên cùng in Binh (gold) has 20 points |
new Card("An") rồi an.earn(10) | Tạo một Card thật, multiplier() của Card cho 1 | Đối chứng: không có phần GoldCard nên điểm là 10 và hạng là basic |
private final String owner; và private int points; | Cất tên và điểm trong object, ngoài lớp không đọc được | Giữ dữ liệu kín; mọi truy cập đi qua phương thức |
Card(String owner) { this.owner = owner; } | Hàm khởi tạo gán tên cho phần Card | Đây là chỗ duy nhất gán owner, nên GoldCard phải nhờ nó qua super |
int multiplier() { return 1; } | Hệ số nhân mặc định của một thẻ thường | Lớp con sẽ thay bằng hệ số riêng |
points = points + amount * multiplier(); | Cộng điểm đã nhân hệ số vào points | Viết đúng một lần ở cha, mọi lớp con dùng lại |
class GoldCard extends Card | Khai báo GoldCard là con của Card | Từ khoá extends mở ra mọi việc kế thừa |
GoldCard(String owner) { super(owner); } | Chuyển tên cho hàm khởi tạo Card rồi mới tới thân GoldCard | Card chỉ có hàm khởi tạo nhận tên, nên dòng này bắt buộc |
@Override int multiplier() { return 2; } | Thay multiplier() của Card bằng bản của GoldCard | Nhãn @Override làm trình biên dịch bắt lỗi gõ sai chữ ký |
Bây giờ xem từng bước. Mỗi bước nêu dòng code đang chạy, ai đã được dựng, và phương thức 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):
| Dòng chạy | Phần Card | Phần GoldCard | Đã in | Ghi chú |
|---|---|---|---|---|
new GoldCard("Binh") bắt đầu | chưa dựng | đang chạy hàm khởi tạo | không có | Dòng đầu của GoldCard là super(owner) |
super(owner) | owner = Binh, points 0 | chờ | không có | Phần cha dựng xong trước |
| hết hàm khởi tạo GoldCard | owner = Binh, points 0 | multiplier() là 2 | không có | binh và same trỏ cùng một object |
binh.earn(10) | earn() chạy, points thành 20 | multiplier() được gọi | không có | earn() kế thừa gọi multiplier() của GoldCard |
binh.describe() | describe() chạy | tier() trả gold | Binh (gold) has 20 points | describe() kế thừa gọi tier() của GoldCard |
same.describe() | như trên | như trên | thêm Binh (gold) has 20 points | Cùng object nên cùng kết quả |
new Card("An") rồi an.earn(10) | owner = An, points 10 | không có | thêm An (basic) has 10 points | Object thật là Card, hệ số nhân 1 |
Bạn vừa làm được: đọc một chương trình có lớp cha và lớp con, nói đúng ai được dựng trước và phiên bản multiplier() nào chạy ở từng dòng.
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. Trong Card, owner là private final String owner. Điều nào đúng về một object GoldCard?
- A. GoldCard không có owner, vì thành viên private không được kế thừa nên
describe()phải lấy tên từ nơi khác. - B. Object GoldCard có chứa owner, nhưng code trong lớp GoldCard không viết thẳng
ownerđược. - C. Code trong GoldCard đọc được
ownerbằng cách thêm@Overridetrước dòng đó. - D. GoldCard đọc được
owner, nhưng chỉ khi GoldCard cùng một file với Card.
Đáp án: B. Phần Card của object vẫn giữ owner, nên describe() in được Binh. A nhầm "code lớp con không viết thẳng được" với "không có trong object". C sai vì @Override chỉ dành cho phương thức. D sai vì private nghĩa là chỉ trong chính lớp Card, dù hai lớp ở cùng file. [Đã chạy, xem lỗi owner has private access in Card ở phần Thử phá nó đi.]
Concept Check 3. Sau GoldCard binh = new GoldCard("Binh"); Card same = binh; có bao nhiêu object GoldCard trong bộ nhớ?
- A. Một, và cả hai biến cùng trỏ vào object đó.
- B. Hai, vì biến
samegiữ một bản sao của object. - C. Hai, một object GoldCard đầy đủ và một object Card chỉ có phần cha.
- D. Một, nhưng
samechỉ giữ nửa Card của object đó.
Đáp án: A. Gán biến chỉ chép tham chiếu, không chép object. B và C tưởng phép gán tạo thêm object. D nhầm kiểu khai báo (cái bạn được phép gọi) với phạm vi của object: object vẫn còn nguyên cả hai phần, same chỉ bị giới hạn không gọi được thành viên riêng của GoldCard.
Làm cùng mình (Guided Practice)
Bạn sẽ thêm thẻ sinh viên StudentCard vào hệ thống qua bốn bước nhỏ, hỗ trợ giảm dần. Sao chép GoldDemo.java thành CardsPlus.java, đổi public class GoldDemo thành public class CardsPlus, giữ nguyên Card và GoldCard, rồi thay thân main theo từng bước. Chạy bằng java CardsPlus.java.
Bước 1: dựng StudentCard. Thêm lớp này vào cuối file, và đặt vào main hai dòng dưới đây.
class StudentCard extends Card {
StudentCard(String owner) {
// TODO 1: gọi hàm khởi tạo của lớp cha với owner
}
}
StudentCard chi = new StudentCard("Chi");
System.out.println(chi.describe());
Chạy xong bạn sẽ thấy: Chi (basic) has 0 points. Gợi ý: Card chỉ có hàm khởi tạo nhận một chuỗi, nên bạn phải gọi nó bằng super.
Bước 2: cho StudentCard hạng riêng. Thêm vào trong lớp StudentCard:
// TODO 2: ghi đè tier() để trả về "student"
Chạy xong bạn sẽ thấy: Chi (student) has 0 points. Gợi ý: đừng quên @Override, và chữ ký phải đúng String tier().
Bước 3: dùng lại hành vi của cha rồi thêm phần mình. Thêm vào trong lớp StudentCard:
// TODO 3: ghi đè describe() để trả về super.describe() cộng thêm ", expires in 2027"
Chạy xong bạn sẽ thấy: Chi (student) has 0 points, expires in 2027.
Vẽ ra giấy: vẽ ba ô xếp dọc: StudentCard.describe(), Card.describe(), StudentCard.tier(). Đánh số thứ tự chúng chạy khi bạn gọi chi.describe(), rồi đối chiếu: StudentCard.describe() chạy đầu tiên, nó gọi super.describe() nên Card.describe() chạy thứ hai, và bên trong đó tier() rẽ về StudentCard.tier() chạy thứ ba. Chữ , expires in 2027 được nối sau cùng.
Bước 4: cả hệ thống trong một vòng lặp. Thêm import java.util.List; lên đầu file, rồi thay thân main bằng:
StudentCard chi = new StudentCard("Chi");
System.out.println(chi.describe());
List<Card> cards = List.of(new Card("An"), new GoldCard("Binh"), chi);
// TODO 4: dùng for-each cộng 10 điểm rồi in describe() của từng thẻ trong cards
Chạy xong bạn sẽ thấy bốn dòng: Chi (student) has 0 points, expires in 2027, rồi An (basic) has 10 points, Binh (gold) has 20 points và Chi (student) has 10 points, expires in 2027. Gợi ý: biến của vòng lặp có kiểu Card; bạn không cần biết thẻ nào là loại gì. Lời giải nằm ở cuối bài.
Bạn vừa làm được: tự viết một lớp con hoàn chỉnh với super(...), ghi đè và super.describe(), rồi cho nhiều loại thẻ dùng chung một vòng lặp. Toàn bộ mã nguồn của bài cũng nằm trong thư mục 06-oop/05-ke-thua 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 với kế thừa. Mỗi dòng đã được biên dịch thật với lớp Card của bài [Đã chạy, javac của 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 |
|---|---|---|---|
GoldCard(String owner) { } trong class GoldCard extends Card | constructor Card in class Card cannot be applied to given types; kèm required: String và found: no arguments | Java tự thêm super() không đối số, mà Card chỉ có hàm khởi tạo nhận tên | Viết super(owner); ở dòng đầu |
return owner + " gold"; trong GoldCard | owner has private access in Card | owner là private, code lớp con không viết thẳng được | Thêm phương thức đọc owner() ở Card, hoặc để Card tự dùng owner |
Card card = new GoldCard("Binh"); card.badge(); | cannot find symbol kèm symbol: method badge() và location: variable card of type Card | Biến kiểu Card chỉ được gọi những gì Card có | Khai báo GoldCard card, hoặc đưa badge() lên Card nếu mọi thẻ đều có |
@Override int multipler() trong GoldCard | method does not override or implement a method from a supertype | Gõ sai tên nên không có gì để ghi đè | Sửa thành multiplier(); nhờ @Override bạn biết ngay |
Lỗi thứ ba thường làm người mới nghĩ Java "quên" GoldCard. Hình dưới đây gom mô hình sai và mô hình đúng về điều Java thực sự làm.
Cách đọc hình: hàng trên màu đỏ nhạt là mô hình sai: nhiều người tưởng kiểu khai báo Card quyết định, nên hệ số nhân là 1 và thẻ có 10 điểm. Hàng dưới màu lục là mô hình đúng: object thật là GoldCard quyết định, nên thẻ có 20 điểm.
Mô hình đúng này giải thích lỗi thứ ba: kiểu khai báo giới hạn những gì bạn được viết, còn object thật quyết định phiên bản nào chạy.
Bạn vừa làm được: đọc bốn thông báo lỗi phổ biến của kế thừa 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 là các dòng sau, dùng Card (có thêm getPoints() trả về điểm) và GoldCard của bài:
Card a = new GoldCard("An");
Card b = new Card("Binh");
a.earn(10);
b.earn(10);
System.out.println(a.getPoints());
System.out.println(b.getPoints());
// Từ dòng này a trỏ sang object của b
a = b;
a.earn(5);
System.out.println(b.getPoints());
Điền bảng bốn cột: dòng vừa chạy, a trỏ tới object nào, b trỏ tới object nào, điểm của từng object hoặc chữ được in ra. Kết quả mong đợi: bảng chín dòng, và ba số in ra. Tiêu chí nghiệm thu: ba số in ra trùng với kết quả chạy thật, và bạn nói được vì sao dòng a = b làm thẻ Vàng của An không còn biến nào trỏ tới. Gợi ý: chỉ object thật mới quyết định multiplier().
Bài 2. Thẻ Bạch Kim. Dữ liệu vào: Card và GoldCard của bài. Viết lớp PlatinumCard extends GoldCard có multiplier() trả về gấp đôi hệ số của GoldCard và tier() trả platinum, rồi chạy PlatinumCard chi = new PlatinumCard("Chi"); chi.earn(10); System.out.println(chi.describe());. Kết quả mong đợi: Chi (platinum) has 40 points. Tiêu chí nghiệm thu: PlatinumCard không lặp lại số 2, và có hàm khởi tạo gọi super(owner). Gợi ý: super.multiplier() gọi bản của lớp cha trực tiếp.
Bài 3. Phép thử "là một". Với mỗi cặp, nói to câu "A là một B", rồi quyết định dùng extends hay một trường: GoldCard và Card, Customer và Card, StudentCard và Card, Shop và Card. Kết quả mong đợi: bảng bốn dòng, mỗi dòng có quyết định và một câu lý do. Tiêu chí nghiệm thu: bạn giải thích được vì sao hai cặp GoldCard và StudentCard cùng kiểu, còn hai cặp Customer và Shop khác. Gợi ý: nếu một đối tượng chứa hoặc giữ đối tượng khác bên trong thì đó là quan hệ "có".
Mức Vận dụng
Bài 4. Ly miễn phí cho thẻ thành viên. Thêm vào Card phương thức int freeDrinks() mặc định trả 0, và sửa describe() để câu mô tả có thêm số ly miễn phí. GoldCard ghi đè freeDrinks() trả 2. Dữ liệu vào là danh sách List.of(new Card("An"), new GoldCard("Binh"), new Card("Chi")), duyệt bằng for-each, mỗi thẻ earn(10) rồi in describe().
Kết quả mong đợi:
An (basic) has 10 points, 0 free drinks
Binh (gold) has 20 points, 2 free drinks
Chi (basic) has 10 points, 0 free drinks
Tiêu chí nghiệm thu: ba dòng khớp từng ký tự, multiplier() và tier() của GoldCard không bị sửa dòng nào, và describe() chỉ được viết ở Card. Gợi ý: đây chính là điều bạn mong muốn ở câu hỏi đầu bài, sửa một chỗ thì mọi lớp con đổi theo.
Mức Thử thách (tuỳ chọn)
Bài 5. Thứ tự dựng. Viết ba lớp: Card, GoldCard extends Card, PlatinumCard extends GoldCard. Mỗi hàm khởi tạo không tham số chỉ in tên lớp của nó và không có dòng super nào được viết tay. Dự đoán thứ tự ba dòng khi chạy new PlatinumCard(), chạy thật để kiểm tra, rồi giải thích bằng hình hai lớp của object. Kết quả mong đợi: ba dòng in ra. Tiêu chí nghiệm thu: thứ tự đoán trùng thứ tự chạy, và bạn giải thích được dòng super() ngầm do ai thêm vào. Gợi ý: hãy nhớ phần cha luôn dựng trước.
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 1). Dòng super(...) phải nằm ở đâu trong hàm khởi tạo? Điều gì xảy ra nếu lớp cha chỉ có hàm khởi tạo nhận tham số mà lớp con không viết super(...)?
Câu 2 (dự đoán kết quả code, mục tiêu 3). Dùng lớp StudentCard của bước 3 trong Làm cùng mình. Đoạn sau in ra gì?
Card c = new StudentCard("Chi");
c.earn(10);
System.out.println(c.describe());
Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 2 và 3). Đoạn sau in ra gì? Hãy vẽ hai biến và hai object sau mỗi dòng.
Card x = new GoldCard("A");
Card y = x;
x = new Card("B");
x.earn(10);
y.earn(10);
System.out.println(x.getPoints() + " " + y.getPoints());
Câu 4 (tìm lỗi, mục tiêu 4). Một bạn viết class Customer extends Card vì khách hàng nào cũng có thẻ. Hãy chỉ ra lỗi thiết kế và nói cách sửa.
Câu 5 (giải thích cho bạn chưa học, mục tiêu 2). Bạn của bạn hỏi: "Lớp GoldCard không có dòng nào lưu tên chủ thẻ, sao new GoldCard("Binh") vẫn nhớ tên là Binh?" Hãy giải thích bằng hình ảnh hai lớp của object.
Điều cần nhớ
class GoldCard extends Cardviết một lần phần chung ở cha, con chỉ viết phần khác; hàm khởi tạo con phải gọisuper(...)ở dòng đầu (mục tiêu 1).new GoldCardtạo một object gồm phần Card dựng trước và phần GoldCard dựng sau (mục tiêu 2).- Kiểu khai báo quyết định bạn được gọi gì, object thật quyết định phiên bản nào chạy (mục tiêu 3).
- Chỉ dùng
extendskhi nói trọn câu "A là một B"; quan hệ "có" thì giữ một trường (mục tiêu 4).
Cheat sheet một trang.
| Việc cần làm | Cú pháp | Ghi chú |
|---|---|---|
| Kế thừa | class GoldCard extends Card | Một lớp cha trực tiếp |
| Gọi hàm khởi tạo cha | super(owner); | Phải là dòng đầu; nếu bỏ, Java thêm super(); |
| Ghi đè | @Override int multiplier() | Cùng tên, cùng tham số |
| Gọi bản của cha | super.describe() | Dùng lại rồi thêm phần mình |
| Dùng biến kiểu cha | Card same = new GoldCard("Binh"); | Không tạo bản sao |
| Phép thử | "A là một B" | Đúng thì extends, sai thì dùng trường |
| Giấu khỏi con | private | Có trong object, code con không viết thẳng |
Cách đọc hình: mỗi hàng là một tình huống bên trái và công cụ nên chọn bên phải. Hàng trên: quan hệ "là một" thì extends. Hàng giữa: quan hệ "có một" thì dùng trường. Hàng dưới: lớp cha cần tham số khi dựng thì dòng đầu của lớp con là super(owner).
Câu hỏi để dành cho ngày mai: vòng for-each ở bước 4 gọi describe() và earn() trên biến kiểu Card mà vẫn ra đúng hạng và đúng điểm của từng thẻ. Nếu thêm một loại thẻ nữa, vòng lặp có phải sửa không, và nhờ cơ chế nào Java biết chạy multiplier() của ai?
Đà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.
Bên trong lời gọi phương thức. Với object thật là GoldCard, Java bắt đầu tìm phương thức từ lớp GoldCard rồi leo dần lên cha. Cách máy ảo tăng tốc việc tìm kiếm này là chi tiết của từng JVM, hợp đồng của Java chỉ nói kết quả: phiên bản gần object thật nhất được chọn.
Bẫy gọi phương thức ghi đè trong hàm khởi tạo. Hãy thử một Card có hàm khởi tạo in kết quả của tier(), và GoldCard có trường private String label = "gold" với tier() trả label. Chạy new GoldCard() in constructor sees: null [Đã chạy, JDK 17.0.15]. Lý do: phần Card dựng trước, lúc đó trường label của phần GoldCard chưa được gán, nhưng tier() của GoldCard đã chạy. Vì thế đừng gọi phương thức có thể bị ghi đè trong hàm khởi tạo.
So với lựa chọn khác: thành phần. Khi quan hệ là "có một", hoặc khi lớp cha không được thiết kế để bị kế thừa, hãy giữ object kia trong một trường rồi chuyển lời gọi tới nó. Ví dụ thực tế: một lớp con của HashSet ghi đè cả add lẫn addAll để đếm số phần tử thêm vào, rồi gọi addAll với ba phần tử, cho kết quả 3 elements, counter 6 [Đã chạy, JDK 17.0.15]. Lý do là cài đặt addAll của HashSet trong JDK này gọi add cho từng phần tử, nên bản ghi đè đếm hai lần. Đây là chi tiết cài đặt của lớp cha mà lớp con vô tình phụ thuộc vào, và nó có thể khác ở bản JDK khác. Giữ HashSet trong một trường thì không dính bẫy này.
Hiểu lầm nâng cao. Phương thức chọn theo object thật, nhưng trường thì không. Với Card và GoldCard cùng khai báo trường label, dòng Card card = new GoldCard(); rồi in card.label, card.tier(), ((GoldCard) card).label cho card gold gold [Đã chạy, JDK 17.0.15]. Vì vậy đừng khai báo lại trường cùng tên với lớp cha.
Giới hạn của bài. Bài này chưa nói về ghi đè so với nạp chồng chi tiết và cách Java chọn phương thức (bài 26), lớp trừu tượng (bài 27), protected, giao diện (interface) và sealed.
Đọc thêm (tài liệu chính thống, Java SE 17):
- The Java Tutorials: Inheritance
- The Java Tutorials: Overriding and Hiding Methods
- The Java Tutorials: Object as a Superclass
- Java Language Specification, mục 8.1.4: Superclasses and Subclasses
Bài tiếp theo
Bài sau là đa hình: vòng for-each ở bước 4 hoạt động nhờ cơ chế nào, thế nào là ghi đè so với nạp chồng, và vì sao @Override cứu bạn khỏi lỗi gõ nhầm.
Mini-dự án nối tiếp: ở bài sau, quầy tính tiền của quán sẽ có một lớp Checkout cộng điểm cho mọi loại thẻ bằng đúng một lời gọi, và bạn thêm loại thẻ PartnerCard mà không sửa dòng nào của Checkout. Hãy giữ lại Card, GoldCard bạn vừa viết làm điểm xuất phát.
Dự án chạy xuyên chương: cuối bài này hệ thống thẻ có Card (hệ số nhân 1, hạng basic) và GoldCard (hệ số nhân 2, hạng gold) dùng chung earn, redeem, getPoints, describe viết một lần ở Card. Bài 26 cho nhiều loại thẻ cùng đi qua một lời gọi ở quầy tính tiền.
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]:
super(owner);
@Override
String tier() {
return "student";
}
@Override
String describe() {
// Chạy describe() của Card rồi nối thêm phần của StudentCard
return super.describe() + ", expires in 2027";
}
for (Card card : cards) {
card.earn(10);
System.out.println(card.describe());
}
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
super(owner); | Chạy hàm khởi tạo Card để gán tên | Card không có hàm khởi tạo không đối số nên bắt buộc gọi tường minh |
@Override String tier() { return "student"; } | Thay hạng mặc định của Card | Chỉ ghi đè đúng phần khác biệt, earn() không cần viết lại |
return super.describe() + ", expires in 2027"; | Chạy describe() của Card rồi nối thêm chữ | Dùng lại hành vi của cha thay vì chép lại phép ghép chuỗi |
for (Card card : cards) | Lần lượt cho biến card trỏ tới từng thẻ | Kiểu khai báo là Card nên cả Card, GoldCard và StudentCard đều đưa vào được |
card.earn(10); rồi card.describe() | Chạy earn() và describe() đúng của object thật | Đây là chỗ object thật quyết định, vòng lặp không cần biết thẻ nào là loại gì |
Lời giải Bài 1. [Suy luận, khớp với kết quả chạy thật 20, 10, 15]
| Dòng vừa chạy | a trỏ tới | b trỏ tới | Điểm hoặc chữ in ra |
|---|---|---|---|
Card a = new GoldCard("An") | GoldCard An | chưa có | An 0 |
Card b = new Card("Binh") | GoldCard An | Card Binh | Binh 0 |
a.earn(10) | GoldCard An | Card Binh | An 20 |
b.earn(10) | GoldCard An | Card Binh | Binh 10 |
println(a.getPoints()) | GoldCard An | Card Binh | 20 |
println(b.getPoints()) | GoldCard An | Card Binh | 10 |
a = b | Card Binh | Card Binh | không có |
a.earn(5) | Card Binh | Card Binh | Binh 15 |
println(b.getPoints()) | Card Binh | Card Binh | 15 |
Sau a = b, biến a không còn trỏ vào thẻ Vàng của An, và không biến nào khác trỏ vào nó nên thẻ đó không còn dùng được nữa. a.earn(5) chạy multiplier() của Card (hệ số 1) vì object thật bây giờ là thẻ của Bình, nên điểm tăng đúng 5.
Lời giải Bài 2. [Đã chạy, in ra Chi (platinum) has 40 points]
class PlatinumCard extends GoldCard {
PlatinumCard(String owner) {
super(owner);
}
@Override
int multiplier() {
// super.multiplier() chạy bản của GoldCard, không lặp lại số 2
return super.multiplier() * 2;
}
@Override
String tier() {
return "platinum";
}
}
super.multiplier() chạy multiplier() của GoldCard nên PlatinumCard không lặp lại số 2: hệ số là 4, và 10 điểm cộng vào thành 40. earn() và describe() được kế thừa hai tầng từ Card, và multiplier(), tier() bên trong rẽ về PlatinumCard vì object thật là PlatinumCard.
Lời giải Bài 3. GoldCard là một Card: dùng extends. Customer là một Card: sai, khách hàng có một thẻ, nên giữ trường Card card. StudentCard là một Card: dùng extends. Shop là một Card: sai, quán có nhiều thẻ, nên giữ một danh sách List<Card>. Điểm chung của hai cặp "có": một đối tượng chứa đối tượng khác bên trong, nên chúng phải là trường chứ không phải lớp cha.
Lời giải Bài 4. [Đã chạy, ba dòng khớp với kết quả mong đợi]
// Trong Card: mặc định 0, rồi describe() nối thêm freeDrinks()
int freeDrinks() {
return 0;
}
// Trong GoldCard: chỉ thay số ly miễn phí
@Override
int freeDrinks() {
return 2;
}
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
int freeDrinks() { return 0; } trong Card | Đặt số ly miễn phí mặc định cho mọi thẻ | Phần lớn thẻ không có ly miễn phí, chỉ lớp khác biệt mới ghi đè |
describe() ghép thêm freeDrinks() vào câu | Một chỗ sửa trong Card, nên thẻ thường và thẻ Vàng đều đổi theo | Đây là phần giống nhau, chỉ viết một lần ở cha |
GoldCard ghi đè freeDrinks() | Thay 0 bằng 2 chỉ cho GoldCard | Chỉ viết phần khác biệt, multiplier() và tier() không bị sửa dòng nào |
Gợi ý lời giải Bài 5. [Đã chạy] Chạy new PlatinumCard() in ba dòng Card, GoldCard, PlatinumCard theo đúng thứ tự đó. Lý do: hàm khởi tạo PlatinumCard không có super viết tay nên Java thêm super(); vào dòng đầu, gọi hàm khởi tạo GoldCard, và GoldCard làm tương tự để gọi hàm khởi tạo Card. Card xong đầu tiên, nên in đầu tiên.
Đáp án Tự kiểm tra.
super(...)phải là dòng đầu tiên của hàm khởi tạo. Nếu bỏ, Java tự thêmsuper()không đối số; khi lớp cha không có hàm khởi tạo đó thì lỗiconstructor Card in class Card cannot be applied to given types;. Kiểm tra mục tiêu 1.- In
Chi (student) has 10 points, expires in 2027[Đã chạy trong CardsPlus].StudentCard.describe()chạy, gọisuper.describe()của Card,tier()bên trong rẽ về StudentCard, và hệ số nhân vẫn là 1 nên 10 điểm. Kiểm tra mục tiêu 3. - In
10 20[Đã chạy]. Sau dòng 2,xvàycùng trỏ thẻ Vàng A. Sau dòng 3,xtrỏ Card B cònyvẫn trỏ thẻ Vàng A. Nênxđược 10 điểm (hệ số 1) vàyđược 20 điểm (hệ số 2). Kiểm tra mục tiêu 2 và 3. - Customer không là một Card, nó có một Card. Sửa:
class Customergiữprivate final Card card. Kiểm tra mục tiêu 4. new GoldCard("Binh")tạo một object có hai lớp. Dòngsuper(owner)nhờ lớp Card dựng phần của nó và lưu tên Binh ở đó. GoldCard không cần dòng nào lưu tên, vì tên nằm sẵn trong phần Card. Kiểm tra mục tiêu 2.