Interface là gì?
Xem thửTình huống mở đầu
Quán Cà Phê Gió vừa mở quầy thu ngân. Khách trả tiền mặt thì không mất phí. Khách quẹt thẻ thì quán chịu phí 2 phần trăm, tức 2 đồng trên hóa đơn 100 đồng. Tuần sau quán sẽ nhận thêm phiếu giảm giá, rồi ví điện tử. Bạn viết hàm checkout bằng một chuỗi if để hỏi từng loại thanh toán. Đến loại thứ năm, hàm dài 40 dòng, và mỗi lần thêm loại mới bạn phải mở lại hàm đã chạy ổn để sửa.
Có cách nào để hàm checkout được viết đúng một lần, và loại thanh toán mới chỉ cần thêm một file mà không động vào hàm cũ không? Hãy giữ câu hỏi này, cuối bài bạn sẽ tự làm được.
Ôn nhanh bài trước: trong lớp Animal có phương thức speak(), lớp Dog extends Animal viết lại speak(). Với Animal a = new Dog(); a.speak(); thì phần thân của lớp nào chạy?
Đáp án: phần thân của Dog. Java chọn theo đối tượng thật khi chạy. Bài này dùng đúng ý đó, nhưng với một hợp đồng thay cho lớp cha.
Mục tiêu bài học
Sau bài này, bạn có thể:
- Viết được
interface PaymentMethodvà hai lớpimplementsnó chạy đúng. - Giải thích được vì sao
method.fee(100)chạy phần thân củaCarddù biến được khai báo kiểuPaymentMethod. - Viết được hàm nhận tham số kiểu interface, rồi thêm lớp mới mà không sửa hàm đó.
- Đọc và sửa được ba lỗi biên dịch hay gặp khi làm việc với interface.
Bốn mục tiêu này được đo ở phần Tự tay làm và Tự kiểm tra.
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 bài.
Cách đọc hình: đi từ trên xuống. Ô trắng là hàm checkout, nó chỉ nói chuyện với ô xanh lục ở giữa là interface PaymentMethod. Ba ô vàng ở dưới là ba lớp cài đặt, mũi tên chỉ lên interface nghĩa là mỗi lớp đã ký hợp đồng. Hàm checkout không có mũi tên nào tới ô vàng. Màu chỉ là tín hiệu phụ, mỗi ô đều có nhãn chữ.
Ẩn dụ xuyên suốt của bài là ổ cắm điện. Ổ cắm có một hình dạng chuẩn. Máy sấy, quạt, sạc điện thoại có thể rất khác nhau, nhưng thiết bị nào có phích cắm vừa hình dạng đó thì dùng được, và ổ cắm không cần biết thiết bị là gì. Interface là hình dạng ổ cắm, lớp cài đặt là thiết bị. Dự án xuyên suốt là quầy thu ngân của quán Cà Phê Gió. Chỗ ẩn dụ không còn đúng: phích cắm thật chỉ cần vừa hình dạng, còn lớp Java phải tự viết nội dung cho từng phương thức của hợp đồng.
Khái niệm cốt lõi
Bài có bốn khái niệm, mỗi khái niệm một hình.
Khái niệm 1: interface là hợp đồng
Interface là danh sách các phương thức mà mọi lớp muốn dùng chung hợp đồng này phải có, nhưng chưa nói cách làm. Phương thức trừu tượng (abstract method) là phương thức chỉ có phần đầu, không có thân. Trong ổ cắm điện, đó là hình dạng hai chân tròn: nó nói "phải có hai chân", không nói thiết bị làm gì.
Cách đọc hình: ô xanh lục ở trên là interface với hai phương thức không có thân: name() và fee(int price). Hai ô vàng ở dưới là hai lớp, mũi tên chỉ lên nghĩa là "đã ký hợp đồng". Mỗi lớp tự viết thân cho cả hai phương thức.
Khái niệm 2: implements, lớp ký hợp đồng
implements là từ khóa lớp dùng để nói "mình tuân theo hợp đồng này". Lớp ký rồi thì phải viết đủ mọi phương thức của hợp đồng, và mỗi phương thức phải có chữ public, vì phương thức của interface mặc định là public và lớp không được thu hẹp quyền truy cập.
Nhìn lại hình ở khái niệm 1: nếu một ô vàng thiếu một phương thức, hoặc viết phương thức mà không có public, trình biên dịch từ chối cả lớp đó. Hai lỗi này nằm trong phần Thử phá nó đi.
Khái niệm 3: biến kiểu interface, đối tượng thật là lớp
Kiểu của biến (variable type) là kiểu viết bên trái dấu =, quyết định bạn được phép gọi phương thức nào. Kiểu của đối tượng (object type) là lớp viết sau new, quyết định phần thân nào chạy. Interface không tạo được đối tượng của riêng nó, nên biến kiểu interface luôn trỏ tới đối tượng của một lớp cài đặt.
Cách đọc hình: dòng trên là chuỗi bình thường. Ô trắng là biến method kiểu PaymentMethod, mũi tên "points to" chỉ sang đối tượng Card trong bộ nhớ, rồi mũi tên "fee(100)" chạy tới phần thân Card.fee. Ô đỏ ở dưới là lời gọi method.last4(): nó không nằm trong hợp đồng nên trình biên dịch từ chối ngay, dù đối tượng thật có thể có phương thức đó.
Đây là khái niệm quan trọng nhất của bài. Biến là cánh cửa hẹp, chỉ cho bạn nhìn thấy phần hợp đồng. Sau cánh cửa là đối tượng thật, và Java tìm phần thân theo đối tượng thật đó khi chương trình chạy.
Concept Check 1. Với PaymentMethod method = new Card();, câu nào đúng?
- A.
method.fee(100)chạy thân củaPaymentMethodvì biến mang kiểu đó. - B.
method.fee(100)chạy thân củaCard, vì Java chọn theo đối tượng thật. - C.
method.fee(100)bị lỗi biên dịch vìPaymentMethodkhông có thân để chạy. - D.
method.fee(100)chạy cả thân củaPaymentMethodlẫn thân củaCard.
Đáp án: B. A sai vì interface không có thân cho phương thức trừu tượng nên không có gì để chạy. C nhầm giữa lúc biên dịch và lúc chạy: lúc biên dịch chỉ cần fee có trong hợp đồng là đủ. D sai vì mỗi lần gọi chỉ một thân chạy.
Khái niệm 4: phụ thuộc vào hợp đồng để thay được cài đặt
Khi một hàm khai báo tham số kiểu interface, nó nhận được bất kỳ lớp nào ký hợp đồng, kể cả lớp chưa được viết ra. Đây là lý do mọi thứ ở tình huống mở đầu giải quyết được.
Cách đọc hình: các ô trắng và xanh lục ở trên ghi "unchanged", nghĩa là không sửa. Ba ô dưới là ba lớp, ô xanh lá phải nhất là EWallet mới, ghi "new file only". Mũi tên đều chỉ lên interface.
Cùng ý này giúp kiểm thử: nếu một lớp cần giờ hiện tại, bạn cho nó nhận interface Clock và truyền vào một đồng hồ giả có giờ cố định. Bạn sẽ làm điều đó ở bài tập thử thách.
Ví dụ đầu tiên (Worked Example) và "Xem từng bước"
Chương trình đầu tiên của quầy thu ngân: hai cách thanh toán và một hàm tính tổng tiền.
CashierDemo.java
public class CashierDemo {
public static void main(String[] args) {
// Biến kiểu hợp đồng, đối tượng thật là Cash
PaymentMethod cash = new Cash();
PaymentMethod card = new Card();
checkout(cash, 100);
checkout(card, 100);
}
// Chỉ phụ thuộc vào hợp đồng nên nhận được mọi cách thanh toán
static void checkout(PaymentMethod method, int price) {
// Java chạy fee của đối tượng thật phía sau biến method
int total = price + method.fee(price);
System.out.println(method.name() + " pays " + total);
}
}
// Hợp đồng của quầy thu ngân: chỉ khai báo phần đầu, chưa có thân
interface PaymentMethod {
String name();
int fee(int price);
}
// Ký hợp đồng thì phải viết đủ mọi phương thức và ghi public
class Cash implements PaymentMethod {
public String name() { return "Cash"; }
public int fee(int price) { return 0; }
}
class Card implements PaymentMethod {
public String name() { return "Card"; }
// Phí 2 phần trăm, chia số nguyên nên giá 100 cho ra 2
public int fee(int price) { return price * 2 / 100; }
}
Predict: hãy dừng lại và đoán trên giấy nháp: chương trình in ra những dòng nào? Số tiền của dòng thứ hai là bao nhiêu và vì sao?
Kết quả mong đợi [Đã chạy, JDK 17.0.15]:
Cash pays 100
Card pays 102
Đọc code từng phần:
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
interface PaymentMethod { String name(); int fee(int price); } | Khai báo hợp đồng gồm hai phương thức chưa có thân | Phương thức trong interface tự động là public, nên không cần viết chữ đó ở đây |
class Cash implements PaymentMethod | Ký hợp đồng, cam kết viết cả name và fee | Thiếu một phương thức là lỗi biên dịch |
public int fee(int price) ở Cash và Card | Viết thân cho phương thức của hợp đồng | Bắt buộc có public, vì cài đặt không được hẹp hơn hợp đồng |
price * 2 / 100 | Tính phí 2 phần trăm bằng số nguyên | Với giá 100 cho ra 2, vì phép chia số nguyên bỏ phần lẻ |
PaymentMethod cash = new Cash(); | Tạo đối tượng Cash, giữ trong biến kiểu hợp đồng | Biến kiểu interface cho phép sau này đổi lớp mà không sửa chỗ dùng |
static void checkout(PaymentMethod method, int price) | Nhận bất kỳ đối tượng nào ký hợp đồng | Hàm chỉ phụ thuộc vào interface nên không thay đổi khi thêm lớp |
method.fee(price) | Java tìm thân fee theo đối tượng thật | Lần đầu chạy thân của Cash, lần sau thân của Card |
Bây giờ xem từng bước máy chạy:
Đ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 đang chạy | method trỏ tới | fee(100) trả về | total | In ra |
|---|---|---|---|---|
new Cash() và new Card() | chưa có | chưa gọi | chưa có | không in gì |
checkout(cash, 100) vào hàm | Cash | chưa gọi | chưa có | không in gì |
method.fee(price) lần 1 | Cash | 0 | 100 | không in gì |
println lần 1 | Cash | 0 | 100 | Cash pays 100 |
checkout(card, 100) và method.fee(price) | Card | 2 | 102 | không in gì |
println lần 2 | Card | 2 | 102 | Card pays 102 |
Bạn vừa làm được: đọc một chương trình dùng interface và nói đúng thân phương thức nào chạy ở mỗi lần gọi.
Concept Check xen kẽ
Concept Check 2. Lớp Debit implements PaymentMethod có name() đầy đủ nhưng không viết fee(int). Điều gì xảy ra khi biên dịch?
- A. Lỗi biên dịch, vì lớp thường phải viết đủ mọi phương thức trừu tượng của hợp đồng.
- B. Chạy bình thường,
feetrả về 0 theo mặc định của Java. - C. Lỗi lúc chạy, chỉ khi ai đó gọi
fee, vì Java kiểm tra muộn. - D. Java tự sinh phương thức
feerỗng và in cảnh báo.
Đáp án: A. Hợp đồng là cam kết, nên trình biên dịch kiểm tra ngay khi biên dịch. B và D đoán rằng Java tự điền phần còn thiếu, nhưng Java không làm vậy với phương thức trừu tượng. C sai vì lỗi này không đợi đến lúc chạy. Thông báo thật có ở phần Thử phá nó đi.
Concept Check 3. Hàm static int total(PaymentMethod[] methods, int price) đang chạy ổn với Cash và Card. Bạn thêm lớp EWallet implements PaymentMethod. Cần sửa gì trong total?
- A. Thêm một nhánh
if (method instanceof EWallet)trongtotal. - B. Đổi kiểu tham số thành
Objectđể nhận mọi loại. - C. Không sửa gì, vì
totalchỉ gọi phương thức trong hợp đồng. - D. Sửa interface để liệt kê thêm
EWallet.
Đáp án: C. A là cách làm ngược với mục đích của interface: nó đưa chuỗi if quay lại. B làm mất khả năng gọi fee, vì Object không có phương thức đó. D sai vì interface không biết lớp nào cài đặt nó, quan hệ chỉ chạy từ lớp lên interface.
Concept Check 4. PaymentMethod method = new Card(); rồi gọi method.last4(), trong đó last4() là phương thức công khai chỉ có ở Card. Kết quả?
- A. Chạy được, vì đối tượng thật là
Card. - B. Ném lỗi lúc chạy vì
methodkhông cólast4. - C. Chạy được nhưng in cảnh báo.
- D. Lỗi biên dịch
cannot find symbol, vì trình biên dịch kiểm tra theo kiểu của biến.
Đáp án: D. A là hiểu lầm chính của bài, hình mô hình sai và đúng ở phần Thử phá nó đi nói về nó. B sai vì lỗi xảy ra sớm hơn, khi biên dịch, không phải khi chạy. C sai vì Java không có chế độ cảnh báo cho việc này.
Làm cùng mình (Guided Practice)
Bạn sẽ thêm phiếu giảm giá vào quầy thu ngân qua bốn bước. Hỗ trợ giảm dần: bước 1 gần như có sẵn, bước 3 chỉ còn đề. Tạo file Practice.java, chép vào đó interface PaymentMethod và hai lớp Cash, Card từ ví dụ ở trên, rồi chạy bằng java Practice.java.
Bước 1: viết lớp VoucherPay. Phiếu giảm giá trừ thẳng một số tiền cố định, nên fee trả về số âm.
class VoucherPay implements PaymentMethod {
private final int discount;
VoucherPay(int discount) {
// TODO 1: lưu discount vào trường của đối tượng
}
public String name() {
return "Voucher";
}
public int fee(int price) {
// TODO 2: trả về discount đổi dấu
}
}
Thêm main tạo new VoucherPay(10) và in "step 1: " + voucher.name() + " " + voucher.fee(100). Chạy xong bạn sẽ thấy: step 1: Voucher -10. Gợi ý: dùng this.discount = discount; và return -discount;.
Bước 2: đưa vào checkout. Chép hàm checkout từ ví dụ, đổi chuỗi in thành "step 2: " + method.name() + " pays " + total. Rồi trong main:
// TODO 3: gọi checkout với voucher và giá 100
Chạy xong bạn sẽ thấy: step 2: Voucher pays 90.
Vẽ ra giấy: kẻ bảng ba cột cho ba lần gọi checkout(cash, 100), checkout(card, 100), checkout(voucher, 100): kiểu của biến method, kiểu của đối tượng thật, giá trị fee(100). Đối chiếu: cột kiểu biến luôn là PaymentMethod, cột đối tượng lần lượt là Cash, Card, VoucherPay, cột giá trị lần lượt là 0, 2, -10.
Bước 3: tính tổng cho cả ba cách. Bạn tự quyết cách viết.
PaymentMethod[] methods = {new Cash(), new Card(), voucher};
int sum = 0;
// TODO 4: dùng vòng for-each cộng 100 + method.fee(100) của từng phần tử vào sum
System.out.println("step 3: total " + sum);
Chạy xong bạn sẽ thấy: step 3: total 292. Gợi ý: 100 cộng 102 cộng 90 bằng 292, nên bạn kiểm tra được tay.
Bước 4: phá thử. Xóa chữ public trước fee của lớp Card rồi chạy lại, đọc dòng lỗi, rồi đặt public trở lại. Thông báo lỗi nằm ở phần Thử phá nó đi để bạn đối chiếu. Lời giải đầy đủ của các bước nằm ở cuối bài.
Bạn vừa làm được: thêm một cách thanh toán mới mà không sửa hàm checkout. Toàn bộ mã nguồn của bài nằm trong thư mục 07-interface/01-interface 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 interface. Mỗi dòng đã được chạy thật bằng java File.java [Đã chạy, JDK 17.0.15; chữ thông báo có thể khác ở phiên bản Java khác]. Bạn có thể tự gõ từng trường hợp để thấy.
| Code lỗi | Thông báo lỗi | Nguyên nhân bằng lời thường | Cách sửa |
|---|---|---|---|
new PaymentMethod() | PaymentMethod is abstract; cannot be instantiated | Hợp đồng không có đối tượng riêng, chỉ có bản cam kết | new Cash() hoặc lớp cài đặt khác |
int fee(int price) không có public trong lớp Card | fee(int) in Card cannot implement fee(int) in PaymentMethod; attempting to assign weaker access privileges; was public | Phương thức của interface là public, lớp không được làm hẹp quyền | Thêm public trước kiểu trả về |
Lớp Card ký hợp đồng nhưng chỉ viết name() | Card is not abstract and does not override abstract method fee(int) in PaymentMethod | Còn một phương thức của hợp đồng chưa có thân | Viết public int fee(int price) |
method.last4() với PaymentMethod method = new Card() | cannot find symbol; symbol: method last4(); location: variable method of type PaymentMethod | Trình biên dịch chỉ nhìn kiểu của biến, mà hợp đồng không có last4 | Khai báo biến kiểu Card, hoặc đưa last4 vào hợp đồng nếu mọi lớp đều cần |
Lỗi thứ tư là hiểu lầm hay gặp nhất, nên mình gom cả hai mô hình vào một hình.
Cách đọc hình: hàng trên màu đỏ nhạt là mô hình sai: nghĩ biến là một Card thật nên mọi phương thức của Card đều gọi được. Hàng dưới màu lục là mô hình đúng: trình biên dịch chỉ biết biến mang kiểu PaymentMethod nên chỉ cho gọi name() và fee(int). Mỗi ô đều có nhãn chữ, màu chỉ là tín hiệu phụ.
Bạn vừa làm được: đọc bốn thông báo lỗi của interface 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 ở cuối bài, hãy thử trước rồi mới so.
Mức Cơ bản
Bài 1. Điền bảng trace. Dữ liệu vào là đoạn code sau, với Cash và Card như ví dụ:
PaymentMethod m = new Cash();
int a = m.fee(50);
m = new Card();
int b = m.fee(50);
Điền bảng bốn dòng với các cột: dòng vừa chạy, kiểu của biến m, kiểu của đối tượng m đang trỏ tới, giá trị tính được. Kết quả mong đợi: bạn nêu được a và b bằng bao nhiêu. Tiêu chí nghiệm thu: cột kiểu biến không đổi ở mọi dòng, và các số khớp với kết quả chạy thật. Gợi ý: 50 * 2 / 100 là phép chia số nguyên.
Bài 2. Viết EWallet. Dữ liệu vào: giá mỗi hóa đơn là 200, ba cách Cash, Card và lớp mới EWallet có name() trả về "EWallet" và fee luôn bằng 1. Viết EWallet rồi cộng tổng 200 + method.fee(200) của ba đối tượng. Kết quả mong đợi: sum 605. Tiêu chí nghiệm thu: in đúng dòng đó, và vòng lặp cộng chỉ dùng kiểu PaymentMethod. Gợi ý: Cash cho 200, Card cho 204, EWallet cho 201.
Bài 3. Đọc lỗi rồi sửa. Viết lớp Debit implements PaymentMethod nhưng cố ý để fee không có public. Chạy và chép lại dòng lỗi. Sửa. Rồi xóa luôn name() và chạy lại, chép dòng lỗi mới. Kết quả mong đợi: hai thông báo lần lượt chứa cannot implement và is not abstract and does not override. Tiêu chí nghiệm thu: bạn nói được mỗi lỗi vi phạm điều khoản nào của hợp đồng. Gợi ý: so với bảng ở phần Thử phá nó đi.
Mức Vận dụng
Bài 4. Class Cashier cho quầy thu ngân. Viết file Cashier.java có hai phương thức tĩnh: total(PaymentMethod[] methods, int price) trả về tổng price + fee của mọi cách, và cheapest(PaymentMethod[] methods, int price) trả về cách có fee nhỏ nhất. Dữ liệu vào: mảng Cash, Card, VoucherPay (giảm 10) và EWallet, giá 200. Kết quả mong đợi:
total 795
cheapest Voucher
Tiêu chí nghiệm thu: hai dòng in ra khớp từng ký tự, và hai phương thức không chứa tên lớp cụ thể nào. Thử thêm một lớp thứ năm vào mảng mà không sửa hai phương thức. Gợi ý: dùng biến best giữ cách rẻ nhất tới lúc đó, so fee từng phần tử với best.fee.
Mức Thử thách (tuỳ chọn)
Bài 5. Biên lai ổn định để kiểm thử. Lớp Receipt cần ghi giờ phát hành. Gọi thẳng System.currentTimeMillis() thì mỗi lần chạy ra số khác nhau, không viết được kiểm tra có kết quả cố định. Tạo interface Clock { long now(); }, lớp FixedClock trả về giá trị truyền vào, và Receipt nhận một Clock ở hàm khởi tạo. Dữ liệu vào: new Receipt(new FixedClock(1700000000000L)).issue(110). Kết quả mong đợi: paid 110 at 1700000000000. Tiêu chí nghiệm thu: kết quả giống nhau ở mọi lần chạy, và Receipt không nhắc tới FixedClock. Viết thêm SystemClock trả về System.currentTimeMillis() và nói chương trình thật dùng lớp nào. Gợi ý: chỉ main biết lớp FixedClock.
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êu ba điều lớp phải làm khi viết class X implements Y, nhắc tới số phương thức và chữ public.
Câu 2 (dự đoán kết quả code, mục tiêu 2). Với PaymentMethod m = new Card(); System.out.println(m.fee(300));, in ra số nào và phần thân của lớp nào chạy?
Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 2 và 3). Mảng {new Cash(), new Card(), new EWallet()}, giá 100. Viết fee của từng phần tử theo thứ tự, rồi tổng 100 + fee của cả mảng.
Câu 4 (tìm lỗi, mục tiêu 4). Đoạn PaymentMethod m = new PaymentMethod(); lỗi ở đâu, và hai cách sửa khác nhau là gì?
Câu 5 (giải thích cho bạn chưa học, mục tiêu 3). Bạn của bạn hỏi: "Vì sao dùng interface thì thêm loại thanh toán mới không phải sửa hàm checkout?" Hãy giải thích bằng ẩn dụ ổ cắm điện.
Điều cần nhớ
- Interface là hợp đồng gồm phương thức chưa có thân, lớp
implementsnó phải viết đủ và ghipublic(mục tiêu 1). - Biến kiểu interface giới hạn lời gọi trong hợp đồng, còn phần thân chạy theo đối tượng thật (mục tiêu 2).
- Hàm nhận tham số kiểu interface nhận được mọi lớp ký hợp đồng, kể cả lớp viết sau (mục tiêu 3).
- Ba lỗi hay gặp:
newtrên interface, quênpublic, quên một phương thức (mục tiêu 4).
Cheat sheet một trang.
| Việc cần làm | Cú pháp | Ghi chú |
|---|---|---|
| Khai báo hợp đồng | interface X { int f(int a); } | Không có thân, tự động public |
| Ký hợp đồng | class Y implements X | Phải viết đủ mọi phương thức |
| Viết phương thức hợp đồng | public int f(int a) { ... } | Bắt buộc có public |
| Dùng qua hợp đồng | X x = new Y(); | Chỉ gọi được phương thức của X |
| Nhận mọi lớp ký hợp đồng | void run(X x) | Không nhắc tên lớp cụ thể |
| Tạo đối tượng | new Y(), không phải new X() | Interface không có đối tượng riêng |
Cách đọc hình: mỗi hàng đọc từ trái sang phải: việc cần làm ở bên trái, điều cần nhớ ở bên phải. Hàng một khai báo hợp đồng, hàng hai ký hợp đồng, hàng ba dùng hợp đồng.
Câu hỏi để dành cho ngày mai: Java 8 cho phép interface có phương thức có thân sẵn. Nếu interface PaymentMethod có thêm một phương thức dùng chung, mọi lớp đã viết có phải sửa 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.
Cơ chế bên trong. Khi chương trình chạy, mỗi đối tượng biết mình thuộc lớp nào, nên lời gọi method.fee(price) được chuyển tới thân fee của lớp đó. Cơ chế này gọi là liên kết động (dynamic dispatch). Trình biên dịch chỉ đảm bảo rằng fee có trong hợp đồng; việc chọn thân nào xảy ra lúc chạy.
So với lựa chọn khác. Có thể dùng lớp cha thường thay cho interface, nhưng một lớp chỉ có một lớp cha, còn một lớp có thể ký nhiều hợp đồng. Mình đã chạy thử một chương trình nhỏ [Đã chạy, JDK 17.0.15] với một lớp ẩn danh (cài đặt interface ngay tại chỗ, không đặt tên), một hằng số int MAX = 1000; trong interface, và một lớp Both ký hai hợp đồng. Kết quả là Gift 1000 và Both printed true.
Trường trong interface mặc định là hằng số công khai nên gọi được qua PaymentMethod.MAX. Lớp ký hai hợp đồng viết implements PaymentMethod, Printable, ngăn bằng dấu phẩy.
Hiểu lầm nâng cao. Nhiều người nghĩ interface là lớp rỗng. Không phải: interface không có trạng thái riêng (không có trường thể hiện), và một lớp ký hợp đồng không "kế thừa" dữ liệu từ nó.
Giới hạn của bài. Bài này chưa nói về phương thức có thân sẵn trong interface (bài sau), so sánh với lớp trừu tượng (bài sau nữa), và interface hàm dùng với lambda. Mọi kết quả trên là của JDK 17.0.15.
Đọc thêm (tài liệu chính thống, Java SE 17):
- The Java Tutorials: Interfaces
- The Java Tutorials: Implementing an Interface
- The Java Tutorials: Anonymous Classes
- Java Language Specification, chương 9: Interfaces
Bài tiếp theo
Bài sau giới thiệu các tính năng hiện đại của interface: phương thức default, static và interface hàm, để thêm việc vào hợp đồng mà không làm hỏng các lớp đã viết.
Mini-dự án nối tiếp: giữ lại PaymentMethod, Cash, Card và Cashier bạn vừa viết. Ở bài sau, quầy thu ngân sẽ thêm một phương thức describe() dùng chung cho mọi cách thanh toán mà không phải sửa lớp nào.
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. Các dòng thay cho // TODO [Đã chạy, JDK 17.0.15, cho ra ba dòng step ở trên]:
// Lưu giảm giá vào trường của đối tượng
this.discount = discount;
// Giảm giá được biểu diễn bằng phí âm
return -discount;
checkout(voucher, 100);
for (PaymentMethod method : methods) {
sum += 100 + method.fee(100);
}
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
this.discount = discount; | Copy tham số vào trường của đối tượng | Có this để phân biệt trường với tham số cùng tên |
return -discount; | Trả số âm | Giảm giá được biểu diễn là phí âm, nên 100 + (-10) bằng 90 |
checkout(voucher, 100); | Truyền VoucherPay vào chỗ cần PaymentMethod | Hàm không đổi, hợp đồng bảo đảm có name và fee |
vòng for-each với sum += | Cộng từng cách vào tổng | Dùng biến kiểu PaymentMethod nên không cần biết lớp nào |
Bước 4: bỏ public ở fee của Card cho thông báo giống dòng thứ hai trong bảng lỗi.
Lời giải Bài 1. [Suy luận, khớp với kết quả chạy thật 0 1]
| Dòng vừa chạy | Kiểu của biến m | Kiểu của đối tượng | Giá trị |
|---|---|---|---|
PaymentMethod m = new Cash(); | PaymentMethod | Cash | chưa tính |
int a = m.fee(50); | PaymentMethod | Cash | a bằng 0 |
m = new Card(); | PaymentMethod | Card | chưa tính |
int b = m.fee(50); | PaymentMethod | Card | b bằng 1 |
Kiểu của biến giữ nguyên, chỉ kiểu của đối tượng đổi. Cùng dòng m.fee(50) cho hai kết quả khác nhau vì đối tượng khác nhau.
Lời giải Bài 2. [Đã chạy, in sum 605]
class EWallet implements PaymentMethod {
// Mọi phương thức của hợp đồng đều phải ghi public
public String name() {
return "EWallet";
}
public int fee(int price) {
// Phí cố định bằng 1
return 1;
}
}
Lớp này làm đúng bốn việc: ký hợp đồng, viết hai phương thức, mỗi phương thức có public. Tổng 200 cộng 204 cộng 201 bằng 605.
Lời giải Bài 3. Khi bỏ public ở fee của Debit, thông báo có chữ cannot implement và attempting to assign weaker access privileges; was public: lớp làm hẹp quyền truy cập so với hợp đồng. Khi xóa luôn name(), thông báo có is not abstract and does not override abstract method: lớp chưa viết đủ [Suy luận, đối chiếu với bảng lỗi đã chạy thật].
Lời giải Bài 4. [Đã chạy, hai dòng khớp kết quả mong đợi]
static int total(PaymentMethod[] methods, int price) {
int sum = 0;
for (PaymentMethod method : methods) {
// fee của đối tượng thật được gọi, hàm không biết lớp cụ thể
sum += price + method.fee(price);
}
return sum;
}
static PaymentMethod cheapest(PaymentMethod[] methods, int price) {
// Ứng viên rẻ nhất tính tới lúc này
PaymentMethod best = methods[0];
for (PaymentMethod method : methods) {
if (method.fee(price) < best.fee(price)) {
best = method;
}
}
return best;
}
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
PaymentMethod best = methods[0]; | Lấy phần tử đầu làm ứng viên tốt nhất | Mảng không rỗng nên luôn có phần tử đầu để so |
method.fee(price) < best.fee(price) | So hai phí, mỗi phí gọi vào thân của đối tượng thật | Cùng một biểu thức dùng cho mọi lớp |
best = method; | Đổi ứng viên khi tìm được cái rẻ hơn | Kết thúc vòng lặp, best là cái rẻ nhất |
Tổng là 200 cộng 204 cộng 190 cộng 201 bằng 795. VoucherPay có phí nhỏ nhất là -10 nên là cách rẻ nhất. Cả hai phương thức không nhắc tên lớp nào, nên thêm lớp thứ năm vào mảng không đòi sửa chúng.
Gợi ý lời giải Bài 5. [Đã chạy, in paid 110 at 1700000000000] Hai dòng then chốt trong Receipt:
// Đồng hồ do bên ngoài đưa vào, lớp không tự đọc giờ hệ thống
Receipt(Clock clock) {
this.clock = clock;
}
String issue(int amount) {
return "paid " + amount + " at " + clock.now();
}
Receipt giữ một Clock do bên ngoài đưa vào, và chỉ gọi now(). Khi chạy thật, main truyền new SystemClock() (với now() trả về System.currentTimeMillis()). Khi kiểm thử, truyền FixedClock. Lớp Receipt không đổi.
Đáp án Tự kiểm tra.
- Ký hợp đồng bằng
implements, viết thân cho mọi phương thức của hợp đồng, và ghipublictrên từng phương thức đó. Kiểm tra mục tiêu 1. - In ra 6, từ thân
feecủaCard(300 nhân 2 chia 100) vì đối tượng thật làCard[Suy luận]. Kiểm tra mục tiêu 2. feelần lượt là 0, 2, 1. Các tổng 100, 102, 101, cộng lại 303 [Suy luận]. Kiểm tra mục tiêu 2 và 3.- Interface không có đối tượng riêng nên
new PaymentMethod()bị từ chối. Sửa bằngnew Cash()(một lớp cài đặt), hoặc dùng lớp ẩn danh cài đặt interface tại chỗ. Kiểm tra mục tiêu 4. - Ổ cắm chỉ quy định hình dạng, nên thiết bị nào cắm vừa thì dùng được mà không cần sửa ổ cắm.
checkoutchỉ biết hợp đồng, loại thanh toán mới chỉ cần ký hợp đồng. Kiểm tra mục tiêu 3.