Tổng quan OOP (Object-Oriented Programming)
Xem thửTình huống mở đầu
Quán Cà Phê Gió có 200 khách quen, mỗi người một thẻ tích điểm. Lúc đầu bạn lưu bằng hai mảng chạy song song: mảng names giữ tên, mảng points giữ điểm, ô số 7 của hai mảng là của cùng một người. Sáng thứ Bảy, bạn viết thêm một dòng để sắp tên theo bảng chữ cái cho dễ tìm. Dòng ấy chạy đúng, không báo lỗi. Nhưng buổi chiều, khách tên An đến đổi quà thì màn hình báo An chỉ có 5 điểm, trong khi hôm qua An có 30.
Không dòng nào đỏ, không lỗi nào hiện ra, vậy điểm của An đã đi đâu? Hãy giữ câu hỏi này, vì cách tổ chức dữ liệu trong bài này sinh ra để chặn đúng loại lỗi đó.
Dự án chạy xuyên chương: Hệ thống thẻ thành viên của quán Cà Phê Gió. Đây là bước 1 trên 8 của chương. Bài này mới chỉ có tấm thẻ đầu tiên, Card, cùng một bản xem trước của cả chương. Các bài sau thêm dần: tạo thẻ có kiểm tra (bài 23), khoá điểm lại (bài 24), thẻ Vàng (bài 25), quẹt thẻ ở quầy (bài 26) và khuôn khung (bài 27). Bài 28 chuyển sang bộ đếm hoá đơn của cùng quán.
Ôn nhanh bài trước: StringBuilder khác String thế nào khi bạn nối chuỗi nhiều lần?
Đáp án: StringBuilder sửa ngay trong cùng một đối tượng nên không tạo chuỗi mới sau mỗi lần nối, còn String bất biến nên mỗi lần nối tạo ra một chuỗi mới. Cả hai đều là đối tượng, và bài này nói đối tượng là gì.
Mục tiêu bài học
Sau bài này, bạn có thể:
- Phân biệt được class và object, và chỉ ra trong code đâu là cái khuôn, đâu là tấm thẻ cụ thể.
- Giải thích được điều gì xảy ra trong bộ nhớ khi
new Card("An")chạy hai lần: mỗi lần tạo ra một bộ field riêng. - Dự đoán được điểm của từng thẻ sau một dãy lời gọi
earn. - Chỉ ra được trong đoạn code xem trước đâu là đóng gói, kế thừa, đa hình, trừu tượng, và nói được mỗi ý giải quyết vấn đề gì bằng một câu.
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 toàn cảnh của chương trình nhỏ mà cả bài xoay quanh.
Cách đọc hình: nhìn từ trên xuống. Ô vàng ở trên là class Card, cái khuôn. Hai mũi tên liền đi xuống là lệnh new: mỗi lệnh dập ra một tấm thẻ, ở đây là thẻ của An (50 điểm) và thẻ của Bình (5 điểm). Ô trắng ở dưới là hàm main, và hai mũi tên đứt đi lên là main nhờ đúng một tấm thẻ làm việc. Mỗi ô đều có chữ, màu chỉ giúp bạn tìm nhanh.
Điểm cần thấy: khuôn chỉ có một, thẻ thì có nhiều, và mỗi thẻ giữ số điểm của riêng nó.
Ẩn dụ xuyên chương: xưởng dập thẻ của quán. Mình dùng đúng một ẩn dụ này cho cả bảy bài từ 21 đến 27, và cũng đúng một dự án: lớp Card lớn dần qua từng bài. Bảng dưới là bản đồ của cả chương, bạn sẽ quay lại nó nhiều lần:
| Bài | Ý tưởng OOP | Trong xưởng dập thẻ |
|---|---|---|
| 21 | Class và object | Class là cái khuôn dập thẻ, object là từng tấm thẻ dập ra |
| 22 | Biến giữ tham chiếu | Biến là mảnh giấy ghi ngăn tủ cất thẻ, không phải chính tấm thẻ |
| 23 | Constructor | Khâu kiểm tra thẻ ngay tại xưởng, thẻ không đạt thì không được giao ra |
| 24 | Đóng gói | Số điểm nằm trong két sau quầy, chỉ nhân viên thu ngân mở két theo luật |
| 25 | Kế thừa | Khuôn thẻ Vàng làm từ khuôn thẻ thường, lấy nguyên khuôn cha rồi thêm nét riêng |
| 26 | Đa hình | 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ó |
| 27 | Trừu tượng | Khuôn khung chưa hoàn chỉnh, không dập được thẻ từ nó, mỗi khuôn con phải khoét nốt ô trống |
Ẩn dụ có chỗ không còn đúng: khuôn thật chỉ đứng yên, còn class trong Java có thể chứa cả việc cần làm (method). Mình sẽ nói rõ chỗ đó khi tới.
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: đối tượng gom dữ liệu và hành vi lại một chỗ
Đối tượng (object) là một thứ trong chương trình vừa giữ dữ liệu của nó vừa biết làm những việc liên quan tới dữ liệu đó. Một tấm thẻ tích điểm giữ tên chủ thẻ và số điểm, và biết cộng điểm. Field là một mẩu dữ liệu của đối tượng, ví dụ points. Method là một việc đối tượng biết làm, ví dụ earn.
Cách viết cũ, kiểu thủ tục (procedural), tách rời dữ liệu và việc cần làm: dữ liệu nằm trong các mảng, việc cần làm nằm trong các hàm đứng ngoài. Đoạn dưới đặt hai cách cạnh nhau với cùng dữ liệu: Bình 5 điểm, An 30 điểm, rồi sắp xếp theo tên. Lớp Card trong cách thứ hai chính là lớp ở ví dụ chính phía sau.
String[] names = {"Binh", "An"};
int[] points = {5, 30};
Arrays.sort(names);
System.out.println(names[0] + " has " + points[0] + " points");
Card[] cards = {new Card("Binh"), new Card("An")};
cards[0].earn(5);
cards[1].earn(30);
Card first = cards[0];
cards[0] = cards[1];
cards[1] = first;
System.out.println(cards[0].describe());
Kết quả [Đã chạy, JDK 17.0.15, file BeforeAfter.java]:
An has 5 points
An has 30 points
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
Arrays.sort(names); | Sắp lại đúng một mảng, mảng points đứng yên | Đây là cái bẫy: máy không biết hai mảng là một cặp, nên names[0] thành An mà points[0] vẫn là 5 của Bình |
names[0] + ... + points[0] | Ghép ô 0 của hai mảng | Dòng in sai nhưng không báo lỗi, vì ô 0 nào cũng ghép được |
new Card("Binh") và new Card("An") | Tạo hai object, mỗi object giữ tên của mình | Từ đây tên và điểm đi cùng nhau |
cards[0].earn(5); và cards[1].earn(30); | Cộng điểm vào đúng thẻ qua ô mảng | Điểm được ghi vào thẻ, không ghi vào một mảng riêng |
ba dòng với first | Đổi chỗ hai ô mảng bằng một biến tạm | Hoán vị chuyển nguyên tấm thẻ, nên tên không thể tách khỏi điểm |
cards[0].describe() | Ô 0 giờ là thẻ của An | Vẫn là 30 điểm, vì điểm đi theo thẻ |
Hình dưới cho thấy cái giá của việc tách rời.
Cách đọc hình: bên trái là hai mảng. Sau Arrays.sort(names), ô names[0] đã thành An nhưng ô points[0] vẫn giữ số 5 vốn của Bình, nên hai ô chỉ còn khớp nhau theo số thứ tự, không khớp theo con người. Bên phải là hai object Card: tên và điểm nằm trong cùng một object nên không có cách nào lệch cặp. Mũi tên đỏ đứt là cặp bị ghép nhầm, hai ô xanh lục là cặp luôn đúng.
Đây chính là lỗi ở tình huống mở đầu, và bạn sẽ chạy nó thật ở phần Thử phá nó đi.
Khái niệm 2: class là cái khuôn, object là tấm thẻ
Class là bản mô tả một loại đối tượng: nó có những field nào và biết làm những method nào. Object (còn gọi là instance, một thể hiện của class) là một đối tượng cụ thể được dập ra từ class đó bằng từ khoá new. Một class có thể dập ra bao nhiêu object tuỳ ý.
Điều quan trọng nhất bài này: mỗi lần new chạy, Java tạo một bộ field mới cho object đó. Hai thẻ cùng khuôn nhưng không dùng chung điểm.
Cách đọc hình: ô trắng ở trên là class Card, ghi rõ đoạn code của earn() và describe() chỉ có một bản. Hai ô xanh lục bên trái là hai field của object an, hai ô vàng bên phải là hai field của object binh. Mũi tên từ khuôn xuống mỗi cặp cho thấy mỗi lệnh new tạo một cặp field mới.
Chỗ ẩn dụ không còn đúng: khuôn thật không biết làm gì, còn class Card chứa luôn cách cộng điểm. Khi bạn gọi an.earn(30), Java dùng đoạn code chung của class nhưng chạy trên bộ field của riêng an.
Concept Check 1. Chương trình tạo hai thẻ Card an = new Card("An"); và Card binh = new Card("Binh");, rồi chạy an.earn(30);. Hỏi binh.describe() cho kết quả nào? Hãy chọn trước khi đọc tiếp.
- A. Binh has 30 points, vì cả hai thẻ cùng làm từ một khuôn Card nên dùng chung điểm.
- B. Binh has null points, vì Binh chưa được cộng điểm nên field chưa có giá trị nào.
- C. Binh has 0 points, vì mỗi lệnh new tạo bộ field riêng và lời gọi chỉ chạm vào thẻ an.
- D. Lỗi biên dịch, vì earn chỉ được gọi trên cái khuôn Card chứ không gọi trên từng thẻ.
Đáp án: C. A là mô hình sai mà hình "mô hình sai và mô hình đúng" ở phần sau sẽ vẽ ra: nhầm "cùng khuôn" với "cùng dữ liệu". B nhầm kiểu dữ liệu: points là int nên tự có giá trị 0, chỉ field kiểu tham chiếu như String mới mặc định là null. D ngược với sự thật: earn được gọi trên từng thẻ, còn gọi trên khuôn mới là lỗi (bạn sẽ thấy ở phần Thử phá nó đi).
Khái niệm 3: bốn ý tưởng của OOP nhìn từ xa
OOP không chỉ có class và object. Người ta hay gom thành bốn ý tưởng. Bài này cho bạn nhận mặt cả bốn trong một chương trình xem trước, còn mỗi ý có một bài riêng phía sau để học kỹ.
Đóng gói (encapsulation) là giấu dữ liệu bên trong object và chỉ cho đụng vào qua các method có luật. Kế thừa (inheritance) là cho một class mới dùng lại những gì class cũ đã có. Đa hình (polymorphism) là cùng một lời gọi nhưng mỗi object phản ứng theo cách riêng của nó. Trừu tượng (abstraction) là dùng một thứ mà không cần biết bên trong nó làm thế nào.
Chương trình dưới đây là bản xem trước của chỗ chương này kết thúc. Bạn chưa cần tự viết, chỉ cần nhận ra từng ý xuất hiện ở dòng nào. Một chi tiết cần nói trước: ở bản xem trước này Card đã là lớp trừu tượng và thẻ thường tên BasicCard. Từ bài 22 đến bài 26 Card vẫn là lớp bình thường, đến bài 27 mới đổi.
Pillars.java
public class Pillars {
public static void main(String[] args) {
Card[] cards = { new BasicCard("An"), new GoldCard("Binh") };
for (Card card : cards) {
card.earn(10);
System.out.println(card.describe());
}
}
}
// abstract: khuôn khung, không dập thẻ trực tiếp từ nó được
abstract class Card {
private final String owner;
private int points;
Card(String owner) { this.owner = owner; }
abstract int multiplier();
void earn(int amount) { points = points + amount * multiplier(); }
String describe() { return owner + " has " + points + " points"; }
}
// extends: lấy lại toàn bộ Card, chỉ điền chỗ trống
class BasicCard extends Card {
BasicCard(String owner) { super(owner); }
int multiplier() { return 1; }
}
class GoldCard extends Card {
GoldCard(String owner) { super(owner); }
int multiplier() { return 2; }
}
Predict: hãy đoán chương trình in ra những dòng nào, và vì sao hai dòng khác nhau dù cùng gọi card.earn(10).
Kết quả mong đợi [Đã chạy, JDK 17.0.15]:
An has 10 points
Binh has 20 points
Đọc code theo từng ý tưởng:
| Ý tưởng | Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|---|
| Đóng gói | private int points; | Chỉ code trong Card đụng được vào points | Bên ngoài không gán thẳng points = 999 được, chỉ gọi earn |
| Đóng gói | void earn(int amount) | Là cửa duy nhất để điểm tăng | Luật sau này (chẳng hạn không nhận số âm) chỉ cần thêm ở một chỗ |
| Kế thừa | class GoldCard extends Card | GoldCard có sẵn owner, points, earn, describe của Card | Không phải chép lại phần chung vào mỗi loại thẻ |
| Kế thừa | super(owner); | Nhờ Card dựng phần của nó trước | Phần chung do lớp cha lo, lớp con chỉ lo phần riêng |
| Đa hình | Card[] cards = { new BasicCard("An"), new GoldCard("Binh") }; | Một mảng kiểu Card giữ hai loại thẻ | Biến kiểu cha trỏ được vào object của lớp con |
| Đa hình | card.earn(10); | Cùng một dòng, thẻ của An cộng 10 còn thẻ của Bình cộng 20 | Java chạy multiplier() của thẻ thật đang được trỏ tới |
| Trừu tượng | abstract int multiplier(); | Khai báo một chỗ trống, không có thân | Card biết cần một hệ số nhưng không biết giá trị |
| Trừu tượng | abstract class Card | Cấm viết new Card("X") | Khuôn khung không dập thẻ trực tiếp được |
| Trừu tượng | int multiplier() { return 2; } | Lớp con điền chỗ trống | Mỗi loại thẻ tự quyết hệ số của mình |
Điểm đáng nhìn: dòng card.earn(10); không có chữ if nào hỏi "thẻ này loại gì". Đó là lý do đa hình giúp thêm loại thẻ mới mà không sửa vòng lặp.
Cách đọc hình: ô vàng ở giữa là class Card, bốn mũi tên toả ra bốn ý tưởng, mỗi ô ghi tên ý và số bài sẽ dạy nó. Cả bốn ý đã xuất hiện trong Pillars.java ở trên. Ô xanh lục đậm ở góc trên bên trái là đóng gói, vì nó gắn với từ khoá private mà bạn cũng đã thấy ở ví dụ chính. Ba ô còn lại sẽ được dạy kỹ ở các bài sau, không cần nhớ chi tiết bây giờ.
Bạn chưa cần dùng bốn ý này. Chỉ cần biết rằng class và object là nền, còn bốn ý tưởng này là cách dùng nền đó để viết chương trình khó bị lệch, dễ mở rộng.
Ví dụ đầu tiên (Worked Example) và "Xem từng bước"
Đây là chương trình đầu tiên của dự án thẻ tích điểm: tạo hai thẻ, cộng điểm cho từng thẻ, rồi in kết quả.
CardDemo.java
public class CardDemo {
public static void main(String[] args) {
Card an = new Card("An");
Card binh = new Card("Binh");
an.earn(30);
binh.earn(5);
an.earn(20);
System.out.println(an.describe());
System.out.println(binh.describe());
}
}
class Card {
private String owner;
private int points;
Card(String name) {
owner = name;
}
void earn(int amount) {
// Cộng vào points của chính thẻ đang được gọi
points = points + amount;
}
String describe() {
return owner + " has " + points + " points";
}
}
Predict: hãy dừng lại, lấy giấy nháp và đoán: chương trình in ra những dòng nào? Đoán xong rồi hãy đọc tiếp.
Kết quả mong đợi [Đã chạy, JDK 17.0.15]:
An has 50 points
Binh has 5 points
Đọc code từng phần:
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
public class CardDemo và main | Java bắt đầu chạy chương trình từ method main | Class có main phải đứng đầu file thì lệnh java CardDemo.java mới biết chạy class nào |
Card an = new Card("An"); | new tạo một object Card mới rồi biến an trỏ tới nó | Biến an là cái tên để gọi lại object, còn "An" là dữ liệu riêng của thẻ này |
Card binh = new Card("Binh"); | Tạo object thứ hai, độc lập với an | Cùng khuôn nhưng bộ field mới hoàn toàn |
an.earn(30); | Chạy earn trên object an, points của an từ 0 thành 30 | Dấu chấm: bên trái là thẻ nào, bên phải là việc gì |
private String owner; và private int points; | Khai báo hai field, mỗi object có một bản riêng | private tạm hiểu là chỉ code trong class Card được đụng vào, bài 24 sẽ giải thích |
Card(String name) | Chạy ngay khi có new, gán tên chủ thẻ vào owner | Đây là constructor: tên trùng tên class và không có kiểu trả về, bài 23 sẽ giải thích |
void earn(int amount) | Cộng amount vào points của chính thẻ đang được gọi | Không cần ghi thẻ nào, vì Java biết thẻ nào vừa gọi |
String describe() | Ghép tên và điểm thành một chuỗi rồi trả về | return đưa kết quả về nơi gọi, để println in ra |
Bây giờ xem từng bước. Mỗi bước nêu dòng đang chạy và trạng thái hai thẻ:
Đ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 | Điểm của an | Điểm của binh | Ghi chú |
|---|---|---|---|
new Card("An") | 0 | chưa có thẻ | Thẻ an ra đời, owner là An |
new Card("Binh") | 0 | 0 | Thẻ binh ra đời, độc lập |
an.earn(30) | 30 | 0 | Chỉ thẻ an đổi |
binh.earn(5) | 30 | 5 | Chỉ thẻ binh đổi |
an.earn(20) | 50 | 5 | Thẻ an cộng dồn |
hai dòng println | 50 | 5 | In An has 50 points rồi Binh has 5 points |
Bạn vừa làm được: đọc một chương trình có hai object và nói đúng điểm của từng thẻ sau mỗi 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 class Card có dòng private int points;. Ai được phép đọc hoặc ghi points trực tiếp?
- A. Chỉ code nằm bên trong class Card, nên hàm main không viết được an.points.
- B. Mọi class trong chương trình, nhưng chỉ để đọc, còn việc ghi thì bị cấm hẳn.
- C. Chỉ chính object đó, nên code trong Card cũng không đọc được points của thẻ khác.
- D. Không ai cả, vì Java tự sinh sẵn các method đọc ghi để thay cho truy cập trực tiếp.
Đáp án: A. private khoá theo class: mọi code viết trong class Card đụng được, code ngoài class thì không. B sai vì private cấm cả đọc lẫn ghi từ bên ngoài. C sai ở chi tiết dễ nhầm: khoá theo class chứ không theo từng object, nên một method trong Card vẫn đọc được points của thẻ khác cùng class. D sai vì Java không tự sinh method nào; muốn cho phép bên ngoài đọc, bạn phải tự viết method, và bài 24 nói cách làm.
Concept Check 3. Vì sao gom owner và points vào một object Card lại tốt hơn dùng hai mảng song song?
- A. Truy cập object chạy nhanh hơn truy cập mảng khoảng vài chục lần.
- B. Java không cho phép khai báo hai mảng có cùng độ dài trong một chương trình.
- C. Mảng không thể lưu cùng lúc chuỗi và số nguyên trong cùng một chương trình.
- D. Dữ liệu đi cùng nhau nằm chung một chỗ, nên không thể bị lệch cặp khi sắp xếp hay xoá.
Đáp án: D. Cái được chính là tính toàn vẹn của cặp dữ liệu, như hình hai mảng cho thấy. A là chuyện tốc độ, mà bài này không nói và không đo gì về nó. B và C đều không đúng với Java: bạn khai báo bao nhiêu mảng cùng độ dài tuỳ ý, và chương trình vẫn dùng được mảng chuỗi lẫn mảng số. Hai mảng song song vẫn chạy được, chỉ là bạn phải tự canh cho chúng không lệch.
Làm cùng mình (Guided Practice)
Bạn sẽ dựng lại chương trình thẻ tích điểm qua bốn bước nhỏ, hỗ trợ giảm dần: bước 1 gần như làm sẵn, bước 4 chỉ còn đề. Gõ code vào một file CardShop.java rồi chạy bằng java CardShop.java.
Bước 1: tạo thẻ thứ hai.
public class CardShop {
public static void main(String[] args) {
Card an = new Card("An");
// TODO 1: tạo thẻ binh cho "Binh" bằng new Card, rồi in binh.describe()
System.out.println(an.describe());
}
}
class Card {
private String owner;
private int points;
// Constructor: chạy ngay khi có new
Card(String name) {
owner = name;
}
String describe() {
return owner + " has " + points + " points";
}
}
Chạy xong bạn sẽ thấy hai dòng: An has 0 points và Binh has 0 points.
Bước 2: viết method earn. Trong class Card, thêm method sau, rồi trong main thêm an.earn(30); trước các dòng in:
void earn(int amount) {
// TODO 2: cộng amount vào points
}
Chạy xong bạn sẽ thấy: An has 30 points và Binh has 0 points.
Vẽ ra giấy: vẽ hai hộp, hộp an và hộp binh, mỗi hộp có hai ô owner và points. Điền giá trị sau bước 1, rồi sau an.earn(30). Đối chiếu: hộp an đổi points từ 0 sang 30, hộp binh không đổi gì. Đây là cùng ý với hình "field thuộc về từng object" ở khái niệm 2.
Bước 3: thêm method hỏi trạng thái. Thẻ có từ 100 điểm trở lên là thẻ VIP. Thêm vào Card:
boolean isVip() {
// TODO 3: trả về true nếu points từ 100 trở lên
return false;
}
Trong main, thêm an.earn(70); rồi in an.isVip() + " " + binh.isVip(). Chạy xong bạn sẽ thấy: true false. Gợi ý: phép so sánh points >= 100 đã cho ra đúng một giá trị boolean, nên bạn có thể trả về thẳng.
Bước 4: thử phá nó đi. Bạn tự quyết cách viết. Trong main, thêm một dòng ghi thẳng points của thẻ an thành 999 từ bên ngoài class Card. Chạy xong bạn sẽ thấy lỗi biên dịch nói rằng points có private access. Gợi ý: cú pháp truy cập field là tên object, dấu chấm, tên field. Xoá dòng đó đi sau khi đã đọc lỗi. Lời giải đầy đủ nằm ở cuối bài.
Bạn vừa làm được: tự dựng một chương trình có hai object, tự viết hai method và tự thấy khoá private hoạt động. Toàn bộ mã nguồn của bài cũng nằm trong thư mục 06-oop/01-tong-quan-oop 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 khi bắt đầu với class và object. Mỗi dòng đã được chạy thật hoặc biên dịch thật [Đã chạy, JDK 17.0.15; chữ trong thông báo lỗi có thể khác ở 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 |
|---|---|---|---|
Arrays.sort(names); trên hai mảng song song names và points | Không báo lỗi, chương trình in An has 5 points | Chỉ mảng tên được sắp lại, mảng điểm đứng yên nên cặp bị ghép nhầm | Gom tên và điểm vào class Card, sắp xếp danh sách các object |
an.points = 999; trong main | points has private access in Card | points bị khoá, chỉ code trong Card được đụng | Viết một method trong Card, như earn, rồi gọi method đó |
Card.earn(10); | non-static method earn(int) cannot be referenced from a static context | earn là việc của từng thẻ, không phải việc của cái khuôn | Gọi trên một object cụ thể: an.earn(10); |
new Card() khi class chỉ có Card(String name) | constructor Card in class Card cannot be applied to given types; | Khuôn đòi một tên chủ thẻ mà bạn không đưa | Truyền đủ dữ liệu: new Card("An") |
Lỗi đầu tiên đáng sợ nhất vì nó không báo gì cả. Hình dưới gom hiểu lầm hay gặp nhất của bài về một chỗ.
Cách đọc hình: hàng trên màu đỏ nhạt là mô hình sai: nhiều người nghĩ điểm nằm trong class nên mọi thẻ dùng chung, và dự đoán binh cũng hiện 30 sau an.earn(30). Hàng dưới màu xanh lục là mô hình đúng: điểm nằm trong từng object, nên an có 30 còn binh vẫn 0. Chữ trong ô đủ để hiểu, màu chỉ giúp bạn tìm nhanh.
Bạn vừa làm được: đọc bốn thông báo hoặc hiện tượng lỗi phổ biến của class và object, 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à đoạn code dưới đây, với class Card giống ví dụ chính:
Card x = new Card("X");
Card y = new Card("Y");
Card z = new Card("Z");
x.earn(10);
y.earn(20);
x.earn(5);
z.earn(1);
Điền bảng gồm bốn cột: dòng vừa chạy, điểm của x, điểm của y, điểm của z. Kết quả mong đợi: một bảng bảy dòng, và ba dòng describe cuối cùng của ba thẻ. Tiêu chí nghiệm thu: điểm cuối cùng của ba thẻ trùng với kết quả chạy thật, và bạn nói được vì sao z.earn(1) không làm đổi x hay y. Gợi ý: mỗi lệnh new đã tạo một bộ field riêng.
Bài 2. Gọi tên các phần. Dữ liệu vào là đoạn code sau:
class Cup {
private int ml;
void fill(int amount) { ml = ml + amount; }
}
Trong phần main có hai dòng: Cup c = new Cup(); và c.fill(200);. Kết quả mong đợi: bạn điền đúng năm chỗ trống bằng các từ class, object, field, method, biến. Cụ thể: Cup là ___, ml là ___, fill là ___, thứ mà new Cup() tạo ra là ___, và c là ___. Tiêu chí nghiệm thu: cả năm chỗ đúng, kèm một câu giải thích vì sao c không phải là object. Gợi ý: biến chỉ là cái tên để gọi lại object.
Mức Vận dụng
Bài 3. Thêm việc cho thẻ. Mở rộng class Card của dự án với bốn việc: isVip() trả về true khi từ 100 điểm trở lên, redeem(int amount) trừ điểm và trả về true nếu đủ điểm, ngược lại không trừ và trả về false, và reset() đưa điểm về 0. Dữ liệu vào là hàm main sau (dùng class Card đã mở rộng):
Card an = new Card("An");
an.earn(80);
System.out.println(an.describe() + ", vip " + an.isVip());
an.earn(25);
System.out.println(an.describe() + ", vip " + an.isVip());
System.out.println(an.redeem(40) + " " + an.describe());
System.out.println(an.redeem(500) + " " + an.describe());
an.reset();
System.out.println(an.describe());
Kết quả mong đợi:
An has 80 points, vip false
An has 105 points, vip true
true An has 65 points
false An has 65 points
An has 0 points
Tiêu chí nghiệm thu: năm dòng in ra khớp từng ký tự, và hàm main không đụng thẳng vào field points. Gợi ý: redeem cần kiểm tra điểm có đủ trước khi trừ.
Mức Thử thách (tuỳ chọn)
Bài 4. Thêm số điện thoại vào hai kiểu tổ chức. Dữ liệu vào là quán có 3 khách, bạn phải lưu thêm số điện thoại cho mỗi khách. Viết hai phiên bản: một bản dùng ba mảng song song (names, points, phones), một bản dùng class Card có thêm field phone. Cả hai đều in ra tên, điểm và số điện thoại của ba khách, rồi sắp xếp theo tên. Kết quả mong đợi: hai bản in ra ba dòng giống nhau theo thứ tự tên. Tiêu chí nghiệm thu: bạn liệt kê được số chỗ phải sửa trong mỗi bản khi thêm field phone và nêu được bản nào dễ sai hơn lúc sắp xếp. Gợi ý: với bản mảng, đừng quên sắp lại cả hai mảng còn lại theo cùng thứ tự.
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). Nói bằng một câu sự khác nhau giữa class và object, và lấy ví dụ từ Card.
Câu 2 (dự đoán kết quả code, mục tiêu 2 và 3). Class Card có earn(int amount) cộng điểm và current() trả về điểm hiện tại. Đoạn sau in ra gì?
Card p = new Card("P");
Card q = new Card("Q");
p.earn(10);
q.earn(p.current());
p.earn(q.current());
System.out.println(p.current() + " " + q.current());
Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 3). Ba thẻ a, b, c đều bắt đầu từ 0 điểm. Chạy lần lượt a.earn(10), b.earn(a.current()), c.earn(b.current() + 5), a.earn(c.current()). Viết điểm của ba thẻ sau mỗi dòng.
Câu 4 (tìm lỗi, mục tiêu 2). Đoạn sau định cộng 20 điểm cho thẻ của An nhưng không biên dịch được. Chỉ ra lỗi và sửa.
Card an = new Card("An");
Card.earn(20);
Câu 5 (giải thích cho bạn chưa học, mục tiêu 4). Bạn của bạn hỏi: "Quán mình có 200 thẻ, sao không để hai danh sách tên và điểm cho đơn giản?" Hãy trả lời bằng ví dụ sắp xếp, rồi kể tên một trong bốn ý tưởng OOP, chỉ ra một dòng của Pillars.java thể hiện ý đó, và nói nó giúp ích gì cho thẻ thành viên.
Điều cần nhớ
- Class là cái khuôn, object là từng tấm thẻ dập ra từ khuôn; nhìn code là phân biệt được ngay (mục tiêu 1).
- Mỗi lệnh
newtạo một bộ field riêng, nên hai object cùng class không dùng chung dữ liệu (mục tiêu 2). an.earn(30)chỉ đổi thẻan, vì dấu chấm chỉ rõ object nào nhận việc (mục tiêu 3).- Bốn ý tưởng OOP: đóng gói giấu dữ liệu, kế thừa dùng lại, đa hình mỗi object một kiểu phản ứng, trừu tượng dùng mà không cần biết bên trong (mục tiêu 4).
Cheat sheet một trang.
| Việc cần làm | Cú pháp | Ý nghĩa | Ghi chú |
|---|---|---|---|
| Khai báo class | class Card { ... } | Mô tả một loại đối tượng | Một class, nhiều object |
| Tạo object | new Card("An") | Dập một thẻ mới từ khuôn | Mỗi lần là một bộ field riêng |
| Giữ object trong biến | Card an = new Card("An"); | Biến an dùng để gọi lại thẻ | Biến là cái tên, không phải thẻ |
| Gọi method | an.earn(30); | Nhờ đúng thẻ an làm việc | Trái dấu chấm là object |
| Khoá field | private int points; | Chỉ code trong class đụng được | Chi tiết ở bài 24 |
| Gọi nhầm lên khuôn | Card.earn(10); | Lỗi biên dịch | earn thuộc từng thẻ |
| Kế thừa | class GoldCard extends Card | Dùng lại phần chung của Card | Bài 25 |
| Trừu tượng | abstract int multiplier(); | Chỗ trống lớp con phải điền | Bài 27 |
Cách đọc hình: mỗi hàng là một dòng code bên trái và ý nghĩa của nó bên phải. Đọc theo hàng ngang: class Card là cái khuôn, new Card("An") tạo một object, an.earn(30) nhờ đúng object đó làm việc, private int points là dữ liệu có khoá.
Câu hỏi để dành cho ngày mai: khi bạn viết Card b = a; thì có thêm một tấm thẻ mới không, hay chỉ có thêm một cái tên cho tấm thẻ cũ? Hãy đoán trước khi sang bài sau.
Đà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 bộ nhớ. Mỗi lệnh new xin Java một vùng nhớ riêng cho object, nơi chứa bộ field của nó. Method thì khác: đoạn code của earn chỉ có một bản, được mọi object dùng chung, và Java biết nó đang chạy cho object nào nhờ lời gọi an.earn(...) hay binh.earn(...). Bạn kiểm chứng được điều quan sát bên ngoài: an == binh cho false vì đó là hai object khác nhau, dù cùng class [Suy luận từ mô hình, chi tiết sắp xếp vùng nhớ phụ thuộc JVM].
So với lựa chọn khác. Cách viết thủ tục không sai. Chương trình ngắn chỉ cần vài biến và vài hàm thì hai mảng song song vẫn dùng được. OOP bắt đầu có lợi khi dữ liệu có nhiều field đi cùng nhau và có luật chung, như tên và điểm của một tấm thẻ, vì khi đó gom chúng lại làm giảm số chỗ bạn phải nhớ giữ cho khớp.
Hiểu lầm nâng cao. Nhiều người nghĩ OOP nghĩa là mọi thứ phải thành class và càng nhiều class càng tốt. Không phải vậy: số class nhiều không làm chương trình hướng đối tượng hơn. Điều quan trọng là mỗi class gom đúng những dữ liệu và việc cần làm đi cùng nhau. Ngoài ra, hàm main bạn đang viết là method static, thuộc về class chứ không thuộc object nào; bài 28 nói kỹ phần này.
Giới hạn của bài. Bài này chưa nói về tham chiếu (hai biến cùng trỏ một object), về constructor nhiều dạng, về cách đọc dữ liệu private bằng getter, và về kế thừa, đa hình, trừu tượng. Các bài 22 đến 27 sẽ lần lượt trả lời.
Đọc thêm (tài liệu chính thống, Java SE 17):
- The Java Tutorials: What Is an Object?
- The Java Tutorials: What Is a Class?
- The Java Tutorials: Classes and Objects
Bài tiếp theo
Bài sau là Class và Object: bạn sẽ học cách viết class đầy đủ, vì sao một biến kiểu Card chỉ là cái tên trỏ tới thẻ chứ không phải chính tấm thẻ, và điều gì xảy ra khi hai biến cùng trỏ tới một thẻ.
Mini-dự án nối tiếp: hãy giữ lại file CardShop.java bạn vừa viết. Ở bài sau bạn sẽ dựng dần nó thành CardShop có một mảng Card[], và thấy vì sao một ô mảng chưa gán thẻ thì chưa có thẻ nào.
Dự án chạy xuyên chương: cuối bài 21, thẻ Card có owner, points, earn, describe, và bạn đã nhìn trước cả chương qua Pillars.java. Bài 22 tạm gỡ constructor và private để nhìn kỹ biến, tham chiếu và null; bài 23 lắp constructor có kiểm tra, bài 24 khoá điểm lại, bài 25 thêm thẻ Vàng.
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. Ghép các đoạn thay cho các dòng // TODO sẽ cho ra các kết quả đã nêu ở từng bước [Đã chạy, JDK 17.0.15]:
Card binh = new Card("Binh");
points = points + amount;
return points >= 100;
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
Card binh = new Card("Binh"); | Tạo thẻ thứ hai và cho biến binh giữ nó | Mỗi new tạo một object độc lập, nên binh có điểm riêng |
points = points + amount; | Lấy điểm hiện tại cộng amount rồi ghi lại vào chính field | Không cần chỉ rõ thẻ nào, vì method chạy trên thẻ đã gọi nó |
return points >= 100; | So sánh và trả thẳng kết quả true hoặc false | Phép so sánh đã là một boolean, không cần if |
Bước 4: dòng cần viết là an.points = 999;. Trình biên dịch dừng với points has private access in Card, và đó chính là mục đích của private [Đã chạy].
Lời giải Bài 1. [Đã chạy, kết quả cuối X has 15 points, Y has 20 points, Z has 1 points]
| Dòng vừa chạy | x | y | z |
|---|---|---|---|
ba lệnh new | 0 | 0 | 0 |
x.earn(10) | 10 | 0 | 0 |
y.earn(20) | 10 | 20 | 0 |
x.earn(5) | 15 | 20 | 0 |
z.earn(1) | 15 | 20 | 1 |
z.earn(1) chỉ đổi thẻ z vì dấu chấm chỉ vào z, còn x và y có bộ field riêng.
Lời giải Bài 2. Cup là class, ml là field, fill là method, new Cup() tạo ra một object, và c là biến. c không phải object, nó chỉ là cái tên giữ đường tới object [Suy luận].
Lời giải Bài 3. [Đã chạy, năm dòng khớp với kết quả mong đợi]
boolean redeem(int amount) {
// Không đủ điểm thì từ chối, điểm giữ nguyên
if (amount > points) {
return false;
}
points = points - amount;
return true;
}
isVip() giữ nguyên như ở phần Làm cùng mình, và reset() chỉ là points = 0;.
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
if (amount > points) | Chặn việc trừ quá số điểm đang có | Phải kiểm tra trước khi trừ, nếu không điểm sẽ âm |
return false; | Từ chối và rời method ngay, điểm không đổi | Trả false để nơi gọi biết việc không thành |
points = points - amount; rồi return true; | Trừ điểm rồi báo thành công | Chỉ chạy khi đã qua được phép kiểm tra |
Gợi ý lời giải Bài 4. Với bản mảng, mỗi lần thêm field mới bạn phải thêm một mảng, sửa mọi vòng lặp in và sắp xếp lại cả ba mảng cùng một thứ tự, tức ít nhất ba chỗ rải khắp chương trình. Với bản Card, bạn thêm field phone vào class, sửa constructor và describe, tức ba chỗ nằm cùng một class; phần sắp xếp chỉ cần sắp danh sách các object theo tên. Bản mảng dễ sai hơn vì chỉ cần quên một mảng là cặp bị lệch, giống tình huống mở đầu.
Đáp án Tự kiểm tra.
- Class là bản mô tả một loại, object là một cá thể cụ thể làm từ bản mô tả đó. Ví dụ
Cardlà class, còn thẻ của An là một object. Kiểm tra mục tiêu 1. - In ra
20 10[Đã chạy]. Từng dòng: p có 10, q nhận 10 từ p nên có 10, p nhận thêm 10 từ q nên có 20. Kiểm tra mục tiêu 2 và 3. - Sau
a.earn(10): a 10, b 0, c 0. Saub.earn(a.current()): a 10, b 10, c 0. Sauc.earn(b.current() + 5): a 10, b 10, c 15. Saua.earn(c.current()): a 25, b 10, c 15 [Suy luận]. Kiểm tra mục tiêu 3. earnlà việc của từng thẻ, nhưng dòng 2 gọi nó trên tên class nên báonon-static method earn(int) cannot be referenced from a static context. Sửa thànhan.earn(20);. Kiểm tra mục tiêu 2.- Với hai danh sách, chỉ cần sắp xếp một danh sách là tên và điểm bị ghép nhầm, như An nhận điểm của Bình. Với
Card, tên và điểm nằm cùng một object nên đi đâu cũng đi cùng nhau. Chọn đóng gói làm ví dụ: dòngprivate int points;khoá điểm lại, nên chỉ đổi được qua method có luật nhưearn. Hoặc chọn đa hình: dòngcard.earn(10);chạy khác nhau cho thẻ thường và thẻ Vàng mà không cầnif. Kiểm tra mục tiêu 4.