Mở đầu chặng hướng đối tượng, và tôi muốn tránh cách dạy quen thuộc: "lớp là khuôn, đối tượng là sản phẩm đúc ra từ khuôn, ví dụ lớp Cho có phương thức sua()".
Cách ví von đó không sai, nhưng nó khiến người học nghĩ hướng đối tượng là chuyện phân loại sự vật. Rồi tới lúc viết mã thật thì họ tạo ra những lớp chỉ có trường và getter/setter, còn toàn bộ logic nằm ở một lớp Service khổng lồ bên ngoài — thứ mà giới thiết kế gọi là "mô hình thiếu máu".
Nên hôm nay ta dựng một lớp có việc thật để làm, và mục tiêu là: làm sao để không ai dùng sai được nó.
Một tài khoản ngân hàng
class TaiKhoan {
private static int demSoTaiKhoan = 0; // dùng chung cho cả lớp
private final String chuTaiKhoan; // riêng từng đối tượng, không đổi
private long soDu;
TaiKhoan(String chuTaiKhoan, long soDuBanDau) {
this.chuTaiKhoan = chuTaiKhoan;
this.soDu = soDuBanDau;
demSoTaiKhoan++;
}
void napTien(long tien) {
if (tien <= 0) throw new IllegalArgumentException("Số tiền nạp phải dương");
soDu += tien;
}
boolean rutTien(long tien) {
if (tien <= 0 || tien > soDu) return false;
soDu -= tien;
return true;
}
long soDu() { return soDu; }
static int demSoTaiKhoan() { return demSoTaiKhoan; }
}
Chạy thử:
a = TaiKhoan[Minh, soDu=120000]
b = TaiKhoan[Lan, soDu=50000]
rút 500.000 từ a: false
a sau khi rút : TaiKhoan[Minh, soDu=120000]
tổng số tài khoản: 2
Ba mươi dòng đó chứa gần hết những khái niệm của bài hôm nay. Ta bóc từng lớp.
Trạng thái và hành vi
Trạng thái là dữ liệu đối tượng mang theo: chuTaiKhoan, soDu.
Hành vi là những gì nó làm được: napTien, rutTien.
Điểm mấu chốt — và cũng là chỗ phân biệt lớp tử tế với lớp chỉ để chứa dữ liệu — nằm ở chỗ hành vi đứng cùng chỗ với dữ liệu mà nó thao tác. Số dư và luật rút tiền nằm trong cùng một lớp.
So sánh với cách viết mà tôi vẫn gặp trong mã thật:
// Lớp chỉ có dữ liệu, ai cũng sửa được
class TaiKhoan {
public long soDu;
}
// Logic nằm chỗ khác
class TaiKhoanService {
void rutTien(TaiKhoan tk, long tien) {
if (tien <= tk.soDu) tk.soDu -= tien;
}
}
Cách này chạy được, nhưng có một lỗ hổng: tk.soDu = -999 là hợp lệ với trình biên dịch. Bất cứ ai trong toàn bộ dự án cũng có thể đặt số dư âm mà không đi qua một dòng kiểm tra nào. Khi phát hiện dữ liệu sai, bạn không có chỗ nào để đặt breakpoint — vì không có một cửa duy nhất.
Với cách viết đầu, số dư chỉ đổi được qua napTien và rutTien. Muốn biết vì sao nó sai thì chỉ có hai chỗ để đọc.
Đây gọi là bất biến của đối tượng: một mệnh đề luôn đúng suốt vòng đời của nó. Ở đây là "số dư không bao giờ âm". Nhiệm vụ của lớp là không cho phép tồn tại một đối tượng vi phạm bất biến của chính nó.
chặn: Số tiền nạp phải dương
private không phải để giấu, mà để giữ
Người mới thường hiểu private là "che giấu thông tin cho bí mật". Cách hiểu hữu ích hơn: private là cách bạn thu hẹp số chỗ có thể làm hỏng dữ liệu.
Bốn mức truy cập, từ mở tới đóng:
| Từ khoá | Ai thấy được |
|---|---|
public |
Mọi nơi |
protected |
Cùng gói + lớp con ở gói khác |
| (không ghi gì) | Chỉ cùng gói |
private |
Chỉ trong chính lớp đó |
Quy tắc tôi theo: bắt đầu bằng private, chỉ mở ra khi có nhu cầu thật. Mở thì dễ, đóng lại thì khó — vì lúc đó đã có mã khác phụ thuộc vào.
Còn getter và setter thì đừng sinh máy móc. getSoDu() hợp lý, nhưng setSoDu(long) thì phá hỏng toàn bộ công sức ở trên — nó mở lại đúng cánh cửa mà private vừa đóng. Hãy hỏi: đối tượng này cho phép làm gì? thay vì nó có những trường nào?
static: thuộc về lớp, không thuộc về đối tượng
private static int demSoTaiKhoan = 0;
tổng số tài khoản: 2
demSoTaiKhoan chỉ có một bản duy nhất, dùng chung cho mọi đối tượng. Còn soDu thì mỗi đối tượng một bản riêng.
Cách nhớ đơn giản: nếu câu trả lời phụ thuộc vào đối tượng nào, đó là thành viên thể hiện. Nếu nó đúng cho cả lớp, đó là thành viên tĩnh.
Phương thức tĩnh không có this, nên không đụng được vào trường thể hiện — đó là lý do public static void main không gọi thẳng phương thức thường được, phải tạo đối tượng trước.
Cảnh báo về static: trường tĩnh có thể thay đổi là trạng thái toàn cục, và trạng thái toàn cục là nguồn của rất nhiều rắc rối — nhiều luồng cùng ghi, test này ảnh hưởng test kia, không có cách nào tạo hai cấu hình khác nhau trong cùng tiến trình. Dùng static cho hằng số (static final) và hàm tiện ích thuần tuý thì tốt; dùng cho trạng thái thay đổi được thì hãy dừng lại suy nghĩ.
final: nói rõ cái gì không đổi
private final String chuTaiKhoan;
Trường final phải được gán đúng một lần, trong lúc khởi tạo, và sau đó không đổi được nữa.
Tôi đặt final cho mọi trường không có lý do phải đổi, vì ba lợi ích: người đọc biết ngay cái gì cố định, trình biên dịch bắt lỗi hộ nếu ai đó lỡ gán lại, và đối tượng bất biến thì an toàn luồng miễn phí.
Lưu ý: final chỉ khoá tham chiếu, không khoá nội dung. private final List<String> ds vẫn cho phép ds.add(...) — nó chỉ chặn ds = danhSachKhac.
this
this là tham chiếu tới chính đối tượng đang chạy phương thức. Nó cần thiết khi tên tham số trùng tên trường:
TaiKhoan(String chuTaiKhoan, long soDuBanDau) {
this.chuTaiKhoan = chuTaiKhoan; // trái là trường, phải là tham số
}
Không có this. thì chuTaiKhoan = chuTaiKhoan là gán tham số cho chính nó — mã biên dịch trót lọt, trường vẫn null, và không có cảnh báo nào. IDE thường bắt được, nhưng đây vẫn là lỗi kinh điển của người mới.
Đối tượng và tham chiếu, một lần nữa
Bài về tham số hôm trước đã nói tới chuyện này, nhưng nó quan trọng đủ để nhắc lại bằng đối tượng:
TaiKhoan c = a;
c.napTien(1_000);
a = TaiKhoan[Minh, soDu=121000]
a == c: true
c = a không tạo tài khoản mới. Nó chỉ tạo thêm một cái tên trỏ vào cùng đối tượng. Nạp tiền qua c thì a cũng thấy.
Muốn có một bản sao thật thì phải tự tạo — bằng hàm khởi tạo sao chép, hoặc một phương thức copy(). Java không sao chép đối tượng hộ bạn bao giờ.
Và khi biến chưa trỏ vào đâu:
NPE: Cannot invoke "DoiTuong$TaiKhoan.napTien(long)" because "<local4>" is null
Thông báo của Java 21 nói rõ phương thức nào và biến nào — dễ chịu hơn hẳn dòng NullPointerException trơ trọi ngày xưa.
toString: khoản đầu tư nhỏ, lời nhất
@Override
public String toString() {
return "TaiKhoan[" + chuTaiKhoan + ", soDu=" + soDu + "]";
}
Không có nó, in đối tượng ra sẽ được TaiKhoan@778d1062 — đúng cái mà mảng đã cho ta thấy hôm trước. Có nó, mọi câu lệnh log và mọi lần dừng ở breakpoint đều rõ ràng.
Tôi coi đây là phương thức đáng viết nhất trong mọi lớp mang dữ liệu. Ba dòng, và nó tiết kiệm cho bạn hàng giờ gỡ lỗi. Với lớp thuần dữ liệu thì record sinh sẵn toString — chuyện của phần sau.
Một điều nên cẩn thận: đừng đưa mật khẩu, token hay số thẻ vào toString. Log sẽ ghi lại tất cả, và log thường được gom về nơi có nhiều người đọc hơn bạn nghĩ.
Đặt tên
Tên tốt là tài liệu rẻ nhất. Quy ước Java:
Lớp viết hoa chữ đầu mỗi từ: TaiKhoan, DonHangService. Danh từ.
Phương thức viết thường chữ đầu: napTien, tinhTongTien. Động từ hoặc cụm động từ.
Hằng số viết hoa hết: SO_DU_TOI_THIEU.
Và một điều không nằm trong quy ước nhưng quan trọng hơn: tên phải nói lên ý định, không phải cách cài đặt. danhSachTaiKhoanHashMap là tên tồi — hôm nào đổi sang TreeMap thì tên thành nói dối. taiKhoanTheoMa nói đúng thứ cần biết.
Bài tập nhỏ
Viết lớp GioHang với các luật sau, và cố gắng làm sao để không thể vi phạm chúng từ bên ngoài:
Số lượng mỗi mặt hàng luôn lớn hơn 0.
Tổng tiền luôn bằng tổng của giá nhân số lượng — không có cách nào đặt tổng tiền trực tiếp.
Giỏ hàng đã thanh toán thì không thêm bớt được nữa.
Luật thứ ba là khó nhất, và cách giải quyết nó sẽ dẫn bạn tới một câu hỏi thiết kế thú vị: nên dùng một cờ boolean daThanhToan, hay nên để thanhToan() trả về một đối tượng DonHang bất biến khác hẳn? Tôi nghiêng về cách thứ hai, và ta sẽ quay lại lý do khi nói về record.
Ngày mai: hàm khởi tạo — thứ tự chạy khi tạo đối tượng, và cái bẫy gọi phương thức bị ghi đè từ trong hàm khởi tạo.