Bốn mức truy cập của Java thường được dạy bằng một cái bảng, học thuộc trong hai phút, rồi quên.
Tôi muốn làm khác: dựng bốn trường với bốn mức, thử truy cập từ ba vị trí khác nhau, và để chính javac nói cho ta biết chỗ nào không được. Vì có một mức trong đó hành xử khác hẳn trực giác của phần lớn người viết Java, kể cả người đã đi làm vài năm.
Bốn trường, bốn mức
package com.vidu.trong;
public class Kho {
public String congKhai = "ai cũng thấy";
protected String duocBaoVe = "cùng gói + lớp con";
String macDinh = "chỉ cùng gói";
private String riengTu = "chỉ trong lớp này";
private String bimat() { return riengTu; }
public String moTa() { return bimat(); } // trong lớp thì đụng được hết
}
Giờ thử truy cập từ hai chỗ: một lớp cùng gói, và một lớp khác gói nhưng kế thừa Kho.
javac trả lời:
src/com/vidu/ngoai/GoiKhac.java:9: error: duocBaoVe has protected access in Kho
System.out.println(k.duocBaoVe); // qua thể hiện khác
^
src/com/vidu/ngoai/GoiKhac.java:10: error: macDinh is not public in Kho; cannot be accessed from outside package
System.out.println(k.macDinh);
^
src/com/vidu/trong/CungGoi.java:9: error: riengTu has private access in Kho
System.out.println(k.riengTu);
^
3 errors
Ba lỗi đúng như dự đoán — trừ lỗi đầu tiên, thứ mà tôi sẽ dành hẳn một mục.
Bảng đầy đủ:
| Truy cập từ | public |
protected |
mặc định | private |
|---|---|---|---|---|
| Trong chính lớp đó | ✅ | ✅ | ✅ | ✅ |
| Lớp khác, cùng gói | ✅ | ✅ | ✅ | ❌ |
| Lớp con, khác gói | ✅ | ⚠️ | ❌ | ❌ |
| Lớp bất kỳ, khác gói | ✅ | ❌ | ❌ | ❌ |
Ô có dấu cảnh báo là chỗ cần nói kỹ.
protected không phải "lớp con thấy được"
Đây là hiểu lầm phổ biến nhất. Luật thật là:
Lớp con ở gói khác chỉ truy cập được thành viên protected thông qua tham chiếu có kiểu chính nó (hoặc lớp con của nó) — không phải qua một thể hiện bất kỳ của lớp cha.
Cụ thể, trong GoiKhac extends Kho nằm ở gói khác:
System.out.println(this.duocBaoVe); // được: qua chính mình
System.out.println(k.duocBaoVe); // LỖI: k có kiểu Kho, không phải GoiKhac
Dòng thứ hai chính là lỗi đầu tiên javac báo ở trên.
Vì sao lại có luật kỳ quặc này? Vì protected sinh ra để phục vụ kế thừa, không phải để mở cửa cho cả họ hàng. Lớp con được phép động vào phần bên trong mà nó thừa hưởng, chứ không được phép thò tay vào ruột của một đối tượng anh em khác. Nếu không có luật này, bất kỳ ai cũng có thể phá vỡ đóng gói chỉ bằng cách viết một lớp con rỗng ở gói của mình.
Thêm một điểm nữa: protected rộng hơn mặc định, vì nó bao gồm cả "cùng gói". Nhiều người tưởng thứ tự là public → protected → mặc định → private theo độ mở giảm dần, nhưng thực ra protected và mặc định không xếp thẳng hàng được — protected thêm lớp con, mặc định thì không.
Mức mặc định bị đánh giá thấp
Không viết từ khoá gì thì thành viên chỉ thấy được trong cùng gói. Java là một trong số ít ngôn ngữ lấy đây làm mặc định, và tôi nghĩ đó là lựa chọn hay bị bỏ phí.
Nó rất hợp khi bạn có một nhóm lớp làm việc cùng nhau trong một gói: chúng dùng chung phần bên trong, còn thế giới bên ngoài chỉ thấy đúng một lớp mặt tiền public.
Chỗ dùng nhiều nhất trong thực tế là kiểm thử. Một phương thức để mặc định thì test cùng gói gọi được, mà mã ngoài gói thì không:
// src/main/java/com/vidu/GiaoDich.java
class GiaoDich {
long tinhPhi(long soTien) { ... } // mặc định
}
// src/test/java/com/vidu/GiaoDichTest.java — cùng gói com.vidu
class GiaoDichTest {
@Test void tinhPhiDung() {
assertEquals(500, new GiaoDich().tinhPhi(100_000)); // gọi được
}
}
Đây là lý do Maven và Gradle đặt src/test/java song song với src/main/java theo cùng cây gói.
Tuy vậy tôi vẫn khuyên: đừng nới quyền truy cập chỉ vì muốn test. Nếu phải mở một phương thức private ra mới test được, thường là dấu hiệu lớp đang làm quá nhiều việc và nên tách. Còn khi đã cân nhắc và vẫn thấy hợp lý thì mức mặc định là lựa chọn ít tệ nhất — nó không lọt ra ngoài gói.
private là mặc định đúng
Quy tắc tôi theo, gọn trong một câu: bắt đầu bằng private, chỉ nới ra khi có nhu cầu cụ thể.
Lý do thực dụng hơn là lý thuyết: mở thì dễ, đóng lại thì rất khó. Một trường public để lâu sẽ có mã khác phụ thuộc vào — có khi ở dự án khác — và lúc muốn đóng lại thì đã muộn. Còn private thì mở ra lúc nào cũng được, không phá hỏng gì.
Điểm cần nhớ: private giới hạn theo lớp, không theo đối tượng. Hai thể hiện cùng lớp đụng được vào trường private của nhau:
class Tien {
private long giaTri;
boolean lonHon(Tien khac) {
return this.giaTri > khac.giaTri; // hợp lệ
}
}
Nhờ vậy mới viết được equals và compareTo mà không cần getter.
Getter và setter: đừng sinh máy móc
IDE nào cũng có nút "sinh getter/setter cho mọi trường". Nút đó tiện, và nó cũng là nguyên nhân khiến rất nhiều lớp mất hết ý nghĩa.
Xem lại lớp TaiKhoan của bài hôm trước:
class TaiKhoan {
private long soDu;
void napTien(long tien) {
if (tien <= 0) throw new IllegalArgumentException("Số tiền nạp phải dương");
soDu += tien;
}
}
Thêm một dòng public void setSoDu(long soDu) { this.soDu = soDu; } là toàn bộ công sức bảo vệ ràng buộc tan biến. Số dư lại có thể âm, lại có thể đổi mà không đi qua kiểm tra nào. Trường vẫn private nhưng đóng gói thì đã hỏng — vì đóng gói không phải là chuyện từ khoá, mà là chuyện ai được phép làm gì với dữ liệu.
Vài câu hỏi tôi tự đặt trước khi thêm một phương thức truy cập:
Getter này có ai dùng không? Sinh sẵn cho tương lai là tạo bề mặt công khai mà không ai cần.
Setter này có phá ràng buộc nào không? Nếu có, hãy thay bằng một phương thức nói lên hành động: napTien thay cho setSoDu, huyDon thay cho setTrangThai.
Getter có trả ra thứ sửa được không? Đây là chỗ rò rỉ hay bị bỏ qua nhất:
private final List<Mon> danhSachMon = new ArrayList<>();
public List<Mon> getDanhSachMon() {
return danhSachMon; // người gọi add/remove thoải mái!
}
Trả về đúng tham chiếu bên trong nghĩa là bất kỳ ai cũng sửa được danh sách, dù trường là private final. Hai cách chữa:
return List.copyOf(danhSachMon); // bản sao bất biến
return Collections.unmodifiableList(danhSachMon); // khung nhìn chỉ đọc
Cách thứ nhất tốn thêm bộ nhớ nhưng an toàn tuyệt đối. Cách thứ hai không sao chép, nhưng người gọi vẫn thấy thay đổi khi danh sách gốc đổi — có khi là điều bạn muốn, có khi không.
Với lớp thuần dữ liệu, không có ràng buộc gì để giữ, thì record sinh sẵn accessor và bỏ hẳn setter — đúng thứ cần. Ta sẽ tới đó ở bài về record.
@Data sinh getter và setter cho mọi trường chỉ bằng một dòng. Rất tiện, và cũng rất dễ biến mọi lớp thành túi dữ liệu. Nếu dùng Lombok, tôi khuyên dùng @Getter và @Value có chọn lọc, tránh @Data cho các lớp nghiệp vụ.
Lớp cũng có mức truy cập
Lớp cấp cao nhất chỉ nhận public hoặc mặc định. Không có lớp private ở cấp cao nhất — nó sẽ vô nghĩa vì chẳng ai dùng được.
Một file .java chứa được nhiều lớp, nhưng tối đa một lớp public, và tên lớp đó phải trùng tên file. Các lớp còn lại để mặc định, chỉ dùng trong gói. Cách này hợp khi có một lớp phụ nhỏ chỉ phục vụ lớp chính, không đáng tách file riêng.
Lớp lồng bên trong lớp khác thì nhận đủ bốn mức, kể cả private — chuyện của bài về lớp lồng.
Nhìn xa hơn: gói và module
Từ Java 9, public không còn là "mọi nơi" nữa. Nếu jar của bạn có module-info.java mà không xuất gói ra, thì lớp public trong đó vẫn không ai bên ngoài module dùng được.
Nói cách khác, hệ thống module thêm một tầng đóng gói ở mức cao hơn lớp và gói. Ứng dụng nghiệp vụ thông thường chưa cần tới, nhưng nếu bạn viết thư viện cho người khác dùng thì đây là công cụ tốt nhất để tách phần API công khai khỏi phần bên trong.
Thử ba mươi giây
Lấy một lớp bất kỳ trong dự án của bạn, đổi hết trường public thành private, rồi biên dịch.
Số lỗi hiện ra chính là số chỗ đang phụ thuộc vào phần bên trong của lớp đó. Con số ấy nói nhiều về mức độ đóng gói của mã bạn hơn bất kỳ bài viết nào.
Ngày mai ta vào kế thừa — khi nào nên dùng, và vì sao lời khuyên "ưu tiên thành phần hơn kế thừa" lại xuất hiện ở mọi cuốn sách thiết kế.