Giao tiếp (interface)
Giao tiếp là một hợp đồng mô tả tập hành vi mà lớp cài đặt phải cung cấp, nhưng không quy định cách cài đặt. Giao tiếp định ra cái gì cần làm, còn lớp quyết định làm thế nào. Đây là công cụ trung tâm để viết mã hướng đối tượng linh hoạt trong Java và là nền tảng của đa hình.
Hãy bắt đầu bằng một tình huống thật. Ta viết phần thanh toán cho một trang bán hàng, lúc đầu chỉ có
chuyển khoản nên viết thẳng lớp ThanhToanNganHang và gọi nó từ khắp nơi trong mã xử lý đơn hàng.
Vài tháng sau kinh doanh yêu cầu thêm ví điện tử, rồi thẻ tín dụng. Vì mã xử lý đơn hàng nhắc thẳng
tên lớp cụ thể, mỗi cách trả tiền mới là một lần phải mở lại tệp cũ và chèn thêm nhánh if.
Lối thoát là định nghĩa một hợp đồng tên CongThanhToan: nhận số tiền, trả về kết quả. Mã xử lý đơn
hàng chỉ làm việc với hợp đồng đó, còn mỗi cách trả tiền là một lớp riêng cam kết thực hiện nó, nên
thêm ví điện tử chỉ là viết một lớp mới mà không sửa dòng nào trong mã cũ. Giao tiếp chính là công cụ
ngôn ngữ để viết cái hợp đồng ấy: phần dùng chỉ biết những gì hợp đồng hứa, phần làm tự do thay đổi
bên trong miễn là giữ lời hứa.
Thử ngay: xem đa hình qua giao tiếp chạy từng bước
Chưa cần đọc hết lý thuyết, hãy xem trước hiện tượng lạ nhất của giao tiếp. Ví dụ dưới đây gọi
đúng một dòng h.tinhDienTich() hai lần mà ra hai công thức khác nhau, tuỳ đối tượng đằng sau
biến h là HinhTron hay HinhChuNhat. Bấm ▶ Chạy (hoặc kéo thanh trượt) để theo dõi
từng dòng: biến giao tiếp nằm trên ngăn xếp, đối tượng thật nằm trên bộ nhớ động, và
lời gọi qua giao tiếp được phân giải về đúng lớp cài đặt. Hiện tượng ấy tên là đa hình, và
phần lý thuyết ở dưới sẽ giải thích vì sao nó chạy được.
1interface HinhPhang { // the contract2 double tinhDienTich();3}45class HinhTron implements HinhPhang {6 double r;7 HinhTron(double r) { this.r = r; }8 public double tinhDienTich() { return Math.PI * r * r; }9}1011class HinhChuNhat implements HinhPhang {12 double a, b;13 HinhChuNhat(double a, double b) { this.a = a; this.b = b; }14 public double tinhDienTich() { return a * b; }15}1617public class Demo {18 public static void main(String[] args) {19 HinhPhang h = new HinhTron(2); // declared type: interface20 double s1 = h.tinhDienTich(); // runs HinhTron version21 h = new HinhChuNhat(3, 4); // same variable, new object22 double s2 = h.tinhDienTich(); // runs HinhChuNhat version23 System.out.println(s2);24 }25}
Đọc lại ví dụ bằng lời
Điểm đáng chú ý nhất là biến h có hai kiểu cùng lúc. Kiểu khai báo là HinhPhang, cố định từ lúc
viết mã. Kiểu thực của đối tượng nó trỏ tới thì đổi trong lúc chạy: ban đầu là HinhTron, sau lệnh
gán thứ hai là HinhChuNhat. Phân đôi ấy quyết định hai câu hỏi. Gọi được phương thức nào là do
trình biên dịch quyết, và nó chỉ cho gọi những gì có trong hợp đồng HinhPhang. Chạy phần thân nào
là do máy ảo quyết lúc chạy dựa vào kiểu thực, nên cùng dòng h.tinhDienTich() mà bước ba cho ra
công thức đường tròn còn bước năm cho ra phép nhân hai cạnh.
Ý nghĩa thực tế: vòng lặp duyệt một mảng HinhPhang không chứa lệnh if nào để phân loại hình, nó
chỉ gọi hợp đồng và mỗi đối tượng tự biết phải làm gì, nên thêm lớp HinhTamGiac thì vòng lặp đó vẫn
đúng nguyên xi.
Quên public khi cài đặt. Nguyên nhân gốc: mặc định của lớp là phạm vi gói còn mặc định của giao
tiếp là công khai, nên bỏ chữ public thành ra thu hẹp phạm vi truy cập và trình biên dịch từ chối.
Tưởng biến kiểu giao tiếp gọi được mọi phương thức của lớp thật. Với HinhPhang h = new HinhTron(2);
mà HinhTron có thêm layBanKinh(), thì h.layBanKinh() không biên dịch. Nguyên nhân gốc: trình
biên dịch chỉ nhìn kiểu khai báo. Ép kiểu nhiều là dấu hiệu hợp đồng đang thiếu phương thức.
Định nghĩa trường trạng thái trong giao tiếp. Viết int dem = 0; không tạo biến đếm cho từng đối
tượng mà tạo một hằng số public static final dùng chung, nên gán lại nó không biên dịch. Nguyên
nhân gốc: giao tiếp mô tả hành vi chứ không giữ dữ liệu.
Khai báo và cài đặt
Ta khai báo giao tiếp bằng từ khoá interface, liệt kê các phương thức trừu tượng. Một lớp
dùng implements để cam kết cài đặt và phải hiện thực mọi phương thức của hợp đồng. Nhờ
giao tiếp, mã gọi chỉ phụ thuộc vào kiểu hợp đồng, không dính vào lớp cụ thể, đó là nền tảng
của đa hình.
public interface HinhPhang { // a contract
double tinhDienTich(); // abstract method, no body
double tinhChuVi();
}
class HinhTron implements HinhPhang { // promises to fulfil contract
private final double banKinh;
public HinhTron(double r) { this.banKinh = r; }
@Override public double tinhDienTich() { return Math.PI * banKinh * banKinh; }
@Override public double tinhChuVi() { return 2 * Math.PI * banKinh; }
}
Đoạn mã trên chứa vài quy ước ngầm. Hai phương thức trong giao tiếp không có từ khoá nào đứng trước,
nhưng trình biên dịch hiểu chúng là public abstract, nên lớp cài đặt bắt buộc phải khai báo
public. Chú thích @Override không bắt buộc nhưng nên viết, vì nó nhờ trình biên dịch kiểm hộ rằng
ta đang cài đúng một phương thức của hợp đồng.
Giao tiếp cũng không có hàm dựng nên không tạo đối tượng trực tiếp được: new HinhPhang() là sai, vì
hợp đồng chưa nói cách tính diện tích thì lấy gì mà chạy. Và nó không giữ trạng thái, mọi trường khai
báo trong nó ngầm là public static final.
Đa giao tiếp
Khác với lớp (chỉ kế thừa một lớp cha), một lớp Java cài được nhiều giao tiếp cùng lúc bằng
cách liệt kê sau implements, cách nhau bởi dấu phẩy. Đây là cách Java giải bài toán đa kế
thừa hành vi mà tránh được sự rối rắm của đa kế thừa trạng thái.
// one class fulfilling two contracts at once
public class HinhTron implements HinhPhang, Comparable<HinhTron> {
private final double banKinh;
public HinhTron(double r) { this.banKinh = r; }
@Override public double tinhDienTich() { return Math.PI * banKinh * banKinh; }
@Override public double tinhChuVi() { return 2 * Math.PI * banKinh; }
@Override public int compareTo(HinhTron o) { // from the second interface
return Double.compare(this.banKinh, o.banKinh);
}
}
Vì sao Java cấm kế thừa hai lớp cha nhưng cho cài bao nhiêu giao tiếp cũng được? Câu trả lời nằm ở
trạng thái. Giả sử lớp C kế thừa cả A lẫn B, mà A và B cùng kế thừa một lớp có trường ten.
Khi đó C có mấy trường ten, và hàm dựng nào khởi tạo nó? Đây là bài toán kim cương, không có câu
trả lời tự nhiên, nên Java cắt tận gốc: chỉ kế thừa trạng thái từ đúng một lớp cha. Giao tiếp thì
khác vì nó không mang trạng thái, cài mười giao tiếp chỉ là cam kết mười nhóm hành vi chứ không nhân
bản trường dữ liệu nào. Ta gọi đây là đa kế thừa hành vi.
Cách đọc đúng của đa giao tiếp là đọc theo vai trò. HinhTron ở trên đóng hai vai độc lập: một hình
phẳng, và một thứ so sánh được. Một hàm nhận vào Comparable dùng được HinhTron mà không cần biết
gì về hình học. Vì thế nên thiết kế giao tiếp nhỏ, mỗi giao tiếp một khả năng.
Từ Java 8, khi giao tiếp có phương thức default có thân, một mảnh của bài toán kim cương quay lại:
hai giao tiếp cùng có default trùng chữ ký thì lớp cài cả hai chạy cái nào? Java bắt lớp đó ghi đè
và tự chọn, gọi tường minh bằng tên giao tiếp kèm super.
public interface Bay {
default String diChuyen() { return "bay"; }
}
interface Boi {
default String diChuyen() { return "boi"; } // same signature, clashes with Bay
}
class Vit implements Bay, Boi {
// compiler forces an explicit override to break the tie
@Override public String diChuyen() {
return Bay.super.diChuyen() + " va " + Boi.super.diChuyen();
}
}
Phương thức default và static
Từ Java 8, giao tiếp có thể chứa phương thức có thân. Phương thức default cung cấp cài đặt
sẵn để lớp con dùng ngay mà không bắt buộc ghi đè; nhờ đó ta bổ sung hành vi mới cho một
giao tiếp đã phát hành mà không phá vỡ mã cũ. Phương thức static trong giao tiếp là hàm
tiện ích gắn với giao tiếp, gọi qua tên giao tiếp.
public interface HinhPhang {
double tinhDienTich();
double tinhChuVi();
default String moTa() { // default method with a body
return "Dien tich = " + tinhDienTich(); // reuses abstract method
}
static HinhPhang rong() { // static utility method
return new HinhTron(0); // a degenerate shape
}
}
Vì sao default ra đời? Trước Java 8, giao tiếp chỉ chứa phương thức trừu tượng, nên thêm một phương
thức vào giao tiếp đã phát hành là thay đổi phá vỡ: mọi lớp đang cài nó lập tức không biên dịch được
nữa. Khi nhóm phát triển Java muốn thêm stream cho Collection, làm theo kiểu trừu tượng thì hàng
triệu lớp ngoài kia sẽ hỏng. default là lối thoát: giao tiếp mang sẵn cài đặt mặc định, lớp cũ
không sửa gì vẫn chạy, lớp mới muốn tối ưu thì ghi đè. Nhưng đừng dùng nó để mô phỏng lớp trừu tượng,
vì giao tiếp không có trường thể hiện nên phương thức default chỉ đọc dữ liệu qua chính các phương
thức của hợp đồng: nó cho tái dùng mã nhưng không cho trạng thái. Còn static gom hàm tiện ích vào
cạnh hợp đồng, gọi qua tên giao tiếp và không kế thừa xuống lớp cài đặt, nên HinhPhang.rong() chạy
được còn HinhTron.rong() thì không.
Phương thức private trong giao tiếp
Java 9 bổ sung phương thức private vì một lý do thực dụng: khi giao tiếp có nhiều phương thức
default, chúng thường lặp lại vài đoạn tính giống nhau, mà trước đó không có cách nào tách phần
dùng chung ấy ra vì mọi thứ trong giao tiếp đều công khai, và phơi một hàm phụ thuần kỹ thuật ra
ngoài thì làm bẩn hợp đồng. private cho ta chỗ giấu phần dùng chung, chỉ gọi được từ bên trong.
public interface BaoCao {
String tieuDe();
double giaTri();
default String dongNgan() { return dinhDang(tieuDe(), giaTri()); }
default String dongDai() { return dinhDang("Bao cao: " + tieuDe(), giaTri()); }
// shared helper, hidden from implementers and callers
private String dinhDang(String nhan, double v) {
return nhan + " = " + String.format("%.2f", v);
}
}
Giao tiếp đánh dấu
Giao tiếp đánh dấu là loại không khai báo phương thức nào. Nó không hứa hành vi gì, chỉ dán một cái
nhãn lên kiểu, và mã khác kiểm cái nhãn ấy lúc chạy bằng instanceof. Ví dụ kinh điển là
java.io.Serializable: lớp nào cài nó thì cơ chế tuần tự hoá mới đồng ý ghi đối tượng ra luồng byte,
còn không thì ném lỗi.
import java.io.Serializable;
interface CoTheLuu {} // a marker: no methods, only a label
class TaiLieu implements CoTheLuu, Serializable {
private static final long serialVersionUID = 1L;
String noiDung = "";
}
public class Demo {
static void luu(Object o) {
if (o instanceof CoTheLuu) { // check the label at run time
System.out.println("cho phep luu");
} else {
System.out.println("khong cho phep luu");
}
}
public static void main(String[] args) {
luu(new TaiLieu()); // prints: cho phep luu
luu("chuoi thuong"); // prints: khong cho phep luu
}
}
Ngày nay chú thích thường được ưa hơn, vì nó mang theo được tham số. Ta vẫn học giao tiếp đánh dấu vì nó cho thấy một điều quan trọng: giao tiếp trước hết là một cái kiểu, còn phương thức chỉ là phần nội dung tuỳ chọn của kiểu ấy.
Giao tiếp và lớp trừu tượng
Lớp trừu tượng cũng là một kiểu chưa hoàn chỉnh, có thể chứa cả phương thức trừu tượng lẫn phương thức có thân. Câu hỏi thiết kế thường gặp là khi nào dùng giao tiếp, khi nào dùng lớp trừu tượng. Bảng sau so sánh hai công cụ.
| Tiêu chí | Giao tiếp | Lớp trừu tượng |
|---|---|---|
| Từ khoá dùng | implements | extends |
| Số lượng được dùng | cài nhiều giao tiếp | chỉ kế thừa một lớp |
| Trường thể hiện | không có trường trạng thái | có trường thể hiện đầy đủ |
| Hằng số | ngầm là public static final | mọi loại trường |
| Phương thức có thân | chỉ default và static | tự do, kèm cả trừu tượng |
| Hàm dựng | không có | có hàm dựng |
| Ý nghĩa thiết kế | một khả năng, một vai trò | một loại thực thể chung cốt lõi |
Chọn giao tiếp khi muốn mô tả một khả năng mà nhiều loại khác nhau có thể có, ví dụ so sánh được hay vẽ được. Chọn lớp trừu tượng khi nhiều lớp con chia sẻ một phần cài đặt và trạng thái chung. Ngày nay xu hướng là ưu tiên giao tiếp vì tính linh hoạt của đa giao tiếp.
Mẹo phân biệt nhanh nằm ở cách đọc câu. Lớp trừu tượng trả lời đối tượng này là cái gì, nên quan hệ
với lớp con là quan hệ là một: HinhTron là một HinhPhang. Giao tiếp trả lời đối tượng này làm
được gì, nên quan hệ là có khả năng: HinhTron có khả năng so sánh được, có khả năng tuần tự hoá
được. Một thực thể chỉ thuộc đúng một loại cốt lõi nhưng có thể có nhiều khả năng cùng lúc, và đó
chính là lý do Java cho kế thừa một lớp mà cài nhiều giao tiếp. Hai công cụ không loại trừ nhau: thư
viện chuẩn có cả giao tiếp List lẫn lớp trừu tượng AbstractList cài sẵn phần chung.
Lập trình theo giao tiếp, không theo cài đặt
Đây là nguyên tắc quan trọng nhất của cả bài. Nội dung của nó gọn: chỗ nào khai báo được bằng kiểu
hợp đồng thì đừng khai báo bằng kiểu lớp cụ thể. Kiểu cụ thể chỉ nên xuất hiện đúng một lần, ở chỗ
ta thật sự tạo đối tượng bằng new.
import java.util.ArrayList;
import java.util.List;
public class KhoHang {
// declared type is the contract, not the concrete class
private final List<String> mucHang = new ArrayList<>();
public void them(String ten) { mucHang.add(ten); }
public int soLuong() { return mucHang.size(); }
public List<String> tatCa() { return mucHang; } // exposes the contract only
}
Cái kiểu ta viết trong khai báo chính là bề mặt phụ thuộc của mã. Nếu trường mucHang khai báo là
ArrayList, mọi nơi đọc nó đều được phép gọi phương thức riêng của ArrayList và ta bị khoá vào lớp
ấy; khai báo là List thì ta giữ quyền đổi cài đặt chỉ bằng cách sửa chữ sau new. Lợi ích thứ hai
là kiểm thử: lớp phụ thuộc vào CongThanhToan thì lúc kiểm thử ta truyền vào một cài đặt giả lập,
không đụng tới ngân hàng thật. Đánh đổi là mỗi giao tiếp thêm một tầng gián tiếp, nên với đoạn mã chỉ
có đúng một cài đặt thì dựng giao tiếp là bày vẽ vô ích.
Bài tập thực hành
Bài tập 1: giao tiếp HinhPhang và mảng đa hình
Khai báo giao tiếp HinhPhang với phương thức tinhDienTich và một phương thức default
trả về mô tả ngắn. Cài đặt HinhTron và HinhChuNhat. Viết main tạo một mảng HinhPhang,
đổ vài đối tượng khác lớp vào và in mô tả từng phần tử bằng vòng lặp for-each.
Lời giải chi tiết
Bước 1: giao tiếp khai báo một phương thức trừu tượng và một phương thức default tái dùng
phương thức trừu tượng đó. Bước 2: hai lớp cài đặt cùng giao tiếp với công thức riêng. Bước
3: gom các đối tượng khác lớp vào một mảng kiểu HinhPhang[]; một vòng for-each gọi
moTa() cho từng phần tử, mỗi phần tử tự chạy đúng tinhDienTich của lớp thật. Đó là đa
hình qua giao tiếp.
interface HinhPhang {
double tinhDienTich(); // abstract method
default String moTa() { // reuses the abstract method
return "Dien tich = " + tinhDienTich();
}
}
class HinhTron implements HinhPhang {
double r;
HinhTron(double r) { this.r = r; }
public double tinhDienTich() { return Math.PI * r * r; }
}
class HinhChuNhat implements HinhPhang {
double a, b;
HinhChuNhat(double a, double b) { this.a = a; this.b = b; }
public double tinhDienTich() { return a * b; }
}
public class Demo {
public static void main(String[] args) {
HinhPhang[] ds = { new HinhTron(2), new HinhChuNhat(3, 4) };
for (HinhPhang h : ds) { // one loop, many behaviors
System.out.println(h.moTa()); // polymorphic dispatch per element
}
}
}
Ý chính: mã trong main chỉ phụ thuộc vào giao tiếp HinhPhang; thêm một loại hình mới chỉ
cần viết một lớp cài đặt mới, không phải sửa vòng lặp.
Bài tập 2: đa giao tiếp với Comparable
Cho lớp HinhTron cài đặt đồng thời HinhPhang và Comparable<HinhTron>, so sánh hai hình
tròn theo bán kính. Viết main tạo vài hình tròn rồi tìm hình có bán kính lớn nhất bằng
phương thức compareTo.
Lời giải chi tiết
Bước 1: liệt kê hai giao tiếp sau implements, cách nhau bằng dấu phẩy. Bước 2: cài đặt
tinhDienTich từ giao tiếp thứ nhất và compareTo từ giao tiếp thứ hai. Bước 3: trong
main, duyệt mảng và dùng compareTo để giữ lại hình lớn nhất.
interface HinhPhang {
double tinhDienTich();
}
class HinhTron implements HinhPhang, Comparable<HinhTron> {
double r;
HinhTron(double r) { this.r = r; }
public double tinhDienTich() { return Math.PI * r * r; }
public int compareTo(HinhTron o) { // from the second interface
return Double.compare(this.r, o.r);
}
}
public class Demo {
public static void main(String[] args) {
HinhTron[] ds = { new HinhTron(2), new HinhTron(5), new HinhTron(3) };
HinhTron lonNhat = ds[0];
for (HinhTron h : ds) { // compare by radius
if (h.compareTo(lonNhat) > 0) lonNhat = h;
}
System.out.println(lonNhat.tinhDienTich());
}
}
Ý chính: đa giao tiếp cho phép một lớp vừa là một hình phẳng vừa là một thứ so sánh được, mỗi vai trò đến từ một giao tiếp riêng.
Bài tập 3: cổng thanh toán và lập trình theo giao tiếp
Quay lại tình huống ở đầu bài. Khai báo giao tiếp CongThanhToan với phương thức thanhToan nhận
số tiền và trả về chuỗi kết quả, cùng một phương thức default tên ten. Viết hai lớp cài đặt là
ChuyenKhoan và ViDienTu. Viết lớp DonHang chỉ phụ thuộc vào giao tiếp, nhận cổng thanh toán
qua hàm dựng. Trong main, chạy cùng một đơn hàng với hai cổng khác nhau.
Lời giải chi tiết
Bước 1: giao tiếp giữ đúng một phương thức trừu tượng; ten dùng được cho mọi cài đặt nên đặt làm
default. Bước 2: hai lớp cài đặt tự lo cách trả tiền của mình. Bước 3: mấu chốt nằm ở DonHang,
trường của nó khai báo kiểu CongThanhToan và nó nhận cổng qua hàm dựng chứ không tự gọi new.
interface CongThanhToan {
String thanhToan(double soTien); // the whole contract
default String ten() { // shared by every implementation
return getClass().getSimpleName();
}
}
class ChuyenKhoan implements CongThanhToan {
@Override public String thanhToan(double soTien) {
return "Chuyen khoan " + soTien + " dong, phi 0";
}
}
class ViDienTu implements CongThanhToan {
@Override public String thanhToan(double soTien) {
return "Vi dien tu tru " + soTien + " dong, phi 1%";
}
}
class DonHang {
private final double tongTien;
private final CongThanhToan cong; // depends on the contract only
DonHang(double tongTien, CongThanhToan cong) { // gate is injected, not created here
this.tongTien = tongTien;
this.cong = cong;
}
String xacNhan() {
return cong.ten() + ": " + cong.thanhToan(tongTien);
}
}
public class Demo {
public static void main(String[] args) {
DonHang d1 = new DonHang(500000, new ChuyenKhoan());
DonHang d2 = new DonHang(500000, new ViDienTu()); // same order, other gate
System.out.println(d1.xacNhan());
System.out.println(d2.xacNhan());
}
}
Ý chính: tên lớp cụ thể chỉ xuất hiện đúng một chỗ là sau new trong main, toàn bộ phần còn lại
làm việc với hợp đồng. Đó là lập trình theo giao tiếp, và nó cho ta thay cài đặt mà không sửa mã dùng.
Câu hỏi tự kiểm
- 1Hai giao tiếp cùng khai báo một phương thức default trùng chữ ký, một lớp cài đặt cả hai. Trình biên dịch xử lý thế nào?
- 2Với phương thức static HinhPhang rong() khai báo trong giao tiếp HinhPhang, lời gọi nào hợp lệ?
- 3Giao tiếp đánh dấu như Serializable khác giao tiếp thường ở điểm nào?
- 4Vì sao Java cấm kế thừa nhiều lớp cha nhưng cho cài đặt nhiều giao tiếp?
Tóm tắt
Giao tiếp là hợp đồng: nó nói cái gì phải làm được, không nói làm thế nào. Một lớp chỉ kế thừa được một lớp cha nhưng cài được nhiều giao tiếp, vì giao tiếp mang hành vi chứ không mang trạng thái nên không vướng bài toán kim cương.
Java 8 thêm default để tiến hoá giao tiếp mà không phá mã cũ và static để gom hàm tiện ích cạnh
hợp đồng; Java 9 thêm private để giấu phần dùng chung; giao tiếp đánh dấu chỉ gắn nhãn lên kiểu để
kiểm lúc chạy. So với lớp trừu tượng: giao tiếp trả lời làm được gì, lớp trừu tượng trả lời là cái gì
và giữ được trạng thái chung. Nguyên tắc cần mang theo là lập trình theo giao tiếp chứ không theo cài
đặt.
Lập trình theo giao tiếp tách phần gọi khỏi phần cài đặt. Biến kiểu giao tiếp trỏ tới đối
tượng lớp cài đặt, gọi phương thức chạy đúng lớp thật, đó là đa hình. Đa giao tiếp, phương
thức default và static làm cho giao tiếp linh hoạt hơn lớp trừu tượng trong nhiều tình
huống thiết kế.