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 PaymentMethod và hai lớp implements nó chạy đúng.
  • Giải thích được vì sao method.fee(100) chạy phần thân của Card dù biến được khai báo kiểu PaymentMethod.
  • 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ủa PaymentMethod vì biến mang kiểu đó.
  • B. method.fee(100) chạy thân của Card, vì Java chọn theo đối tượng thật.
  • C. method.fee(100) bị lỗi biên dịch vì PaymentMethod không có thân để chạy.
  • D. method.fee(100) chạy cả thân của PaymentMethod lẫn thân của Card.

Đá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

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]:

text
Cash pays 100
Card pays 102

Đọc code từng phần:

Dòng codeMá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ânPhương thức trong interface tự động là public, nên không cần viết chữ đó ở đây
class Cash implements PaymentMethodKý hợp đồng, cam kết viết cả name và feeThiếu một phương thức là lỗi biên dịch
public int fee(int price) ở Cash và CardViết thân cho phương thức của hợp đồngBắt buộc có public, vì cài đặt không được hẹp hơn hợp đồng
price * 2 / 100Tính phí 2 phần trăm bằng số nguyênVớ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 đồngBiế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 đồngHà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ậtLầ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ạymethod trỏ tớifee(100) trả vềtotalIn ra
new Cash() và new Card()chưa cóchưa gọichưa cókhông in gì
checkout(cash, 100) vào hàmCashchưa gọichưa cókhông in gì
method.fee(price) lần 1Cash0100không in gì
println lần 1Cash0100Cash pays 100
checkout(card, 100) và method.fee(price)Card2102không in gì
println lần 2Card2102Card 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, fee trả 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 fee rỗ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) trong total.
  • B. Đổi kiểu tham số thành Object để nhận mọi loại.
  • C. Không sửa gì, vì total chỉ 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ì method khô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.

java
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:

java
        // 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.

java
        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ỗiThông báo lỗiNguyên nhân bằng lời thườngCách sửa
new PaymentMethod()PaymentMethod is abstract; cannot be instantiatedHợp đồng không có đối tượng riêng, chỉ có bản cam kếtnew Cash() hoặc lớp cài đặt khác
int fee(int price) không có public trong lớp Cardfee(int) in Card cannot implement fee(int) in PaymentMethod; attempting to assign weaker access privileges; was publicPhương thức của interface là public, lớp không được làm hẹp quyềnThê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 PaymentMethodCòn một phương thức của hợp đồng chưa có thânViế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 PaymentMethodTrình biên dịch chỉ nhìn kiểu của biến, mà hợp đồng không có last4Khai 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ụ:

java
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:

text
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 implements nó phải viết đủ và ghi public (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: new trên interface, quên public, quên một phương thức (mục tiêu 4).

Cheat sheet một trang.

Việc cần làmCú phápGhi chú
Khai báo hợp đồnginterface X { int f(int a); }Không có thân, tự động public
Ký hợp đồngclass Y implements XPhải viết đủ mọi phương thức
Viết phương thức hợp đồngpublic int f(int a) { ... }Bắt buộc có public
Dùng qua hợp đồngX x = new Y();Chỉ gọi được phương thức của X
Nhận mọi lớp ký hợp đồngvoid run(X x)Không nhắc tên lớp cụ thể
Tạo đối tượngnew Y(), không phải new X()Interface không có đối tượng riêng
Hình: ba việc với interface và ai viết gì ở mỗi việc

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):

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]:

java
// 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 codeMá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ượngCó this để phân biệt trường với tham số cùng tên
return -discount;Trả số âmGiả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 PaymentMethodHà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ổngDù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ạyKiểu của biến mKiểu của đối tượngGiá trị
PaymentMethod m = new Cash();PaymentMethodCashchưa tính
int a = m.fee(50);PaymentMethodCasha bằng 0
m = new Card();PaymentMethodCardchưa tính
int b = m.fee(50);PaymentMethodCardb 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]

java
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]

java
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 codeMá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ấtMả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ậtCù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ơnKế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:

java
// Đồ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.

  1. Ký hợp đồng bằng implements, viết thân cho mọi phương thức của hợp đồng, và ghi public trên từng phương thức đó. Kiểm tra mục tiêu 1.
  2. In ra 6, từ thân fee của Card (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.
  3. fee lầ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.
  4. Interface không có đối tượng riêng nên new PaymentMethod() bị từ chối. Sửa bằng new 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.
  5. Ổ 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. checkout chỉ 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.