Lớp và đối tượng
Chương này không dạy lại Java từ đầu. Bạn đã qua học phần Java cơ bản, nên ở đây ta điểm lại thật nhanh phần nền tảng rồi dành phần lớn thời lượng cho tư duy hướng đối tượng. Bài mở đầu này nói về viên gạch nền: lớp và đối tượng. Đây là bộ móng cho mọi chủ đề nâng cao về sau, từ Lambda ở chương 2 cho tới đa luồng và lập trình mạng ở các chương cuối. Mục tiêu là nhìn lại các khái niệm quen thuộc bằng con mắt của người thiết kế phần mềm, không chỉ của người viết cú pháp.
Đặt vấn đề cho cụ thể. Bạn viết phần quản lý sinh viên cho một khoa. Cách nghĩ theo thủ tục cho ra ba mảng song song: họ tên, mã số, điểm. Chương trình chạy được, nhưng chỉ một lần sắp xếp nhầm là ba mảng lệch nhau, và lỗi đó chỉ lộ ra khi bảng điểm đã in xong. Hướng đối tượng trả lời bằng một ý đơn giản: gom dữ liệu và hành vi thao tác trên dữ liệu đó vào một chỗ rồi khoá cửa lại. Còn một mảng đối tượng thì không có gì để lệch nữa.
Thử ngay: xem đối tượng trên heap chạy từng bước
Chưa cần đọc hết lý thuyết, hãy xem trước một hiện tượng. Chuyện gom dữ liệu vào một đối tượng
nghe trừu tượng cho tới khi nhìn thấy đối tượng nằm trên vùng nhớ động và các biến chỉ giữ mũi tên
trỏ tới nó. Ví dụ dưới đây gán một biến thứ hai trỏ tới cùng một đối tượng, sửa qua biến này rồi
đọc qua biến kia. Bấm ▶ Chạy để theo dõi từng dòng: đối tượng nằm trên bộ nhớ động, còn
p và q chỉ là hai tên gọi của cùng một đối tượng. Phần lý thuyết ở dưới sẽ giải thích vì sao
lại như vậy.
1public class Demo {2 public static void main(String[] args) {3 Point p = new Point(3); // object lives on the heap4 Point q = p; // second variable, same object5 q.x = 9; // change through q6 System.out.println(p.x); // p sees it too: 97 }8}910class Point {11 int x;12 Point(int x) { this.x = x; }13}
Ôn nhanh nền tảng
Mọi mã nguồn Java nằm trong lớp. Điểm khởi đầu của một ứng dụng là phương thức main có
chữ ký cố định. Tệp .java được javac dịch sang mã byte trong tệp .class, rồi máy ảo
Java nạp và chạy. Tên lớp công khai phải trùng tên tệp.
package vn.dau.cntt; // package declaration, comes first
public class ChaoMung { // public class name matches file name
public static void main(String[] args) { // program entry point
System.out.println("Xin chao Java nang cao");
}
}
Java có hai nhóm kiểu. Kiểu nguyên thủy gồm tám kiểu (byte, short, int, long,
float, double, char, boolean); biến nguyên thủy giữ trực tiếp giá trị. Kiểu tham
chiếu gồm lớp, mảng, giao tiếp; biến tham chiếu giữ địa chỉ tới đối tượng trên vùng nhớ
động. Mỗi kiểu nguyên thủy có lớp bao tương ứng như Integer, Double, Boolean.
==So sánh hai đối tượng bằng == là so sánh địa chỉ tham chiếu, không phải nội dung. Muốn so
nội dung hãy dùng equals. Sai lầm này đặc biệt hay gặp với String.
Các câu lệnh rẽ nhánh và lặp giữ nguyên như C: if/else, switch, for, while,
do-while, kèm break và continue. Phần điều khiển ít thay đổi nhất so với những gì bạn
đã quen, nên ta đi nhanh để dành sức cho phần hướng đối tượng.
Lớp và đối tượng
Lớp là khuôn mẫu mô tả trạng thái và hành vi của một loại thực thể. Đối tượng là một thể
hiện cụ thể của lớp, sinh ra bằng toán tử new và có vùng nhớ riêng cho các trường của nó.
Lớp là bản vẽ, đối tượng là ngôi nhà xây theo bản vẽ đó. Câu lệnh
SinhVien a = new SinhVien("An", 8.0); làm ba việc: new xin vùng nhớ trên heap, hàm dựng
nạp trạng thái ban đầu, rồi địa chỉ được gán cho biến a. Biến a không phải đối tượng, nó
chỉ là tay cầm trỏ tới đối tượng. Đó là lý do == chỉ hỏi hai tay cầm có trỏ vào cùng một chỗ
hay không, và cũng là lý do khi truyền đối tượng vào phương thức, thứ được sao chép là địa chỉ
nên phương thức sửa được trạng thái bên trong.
Đối tượng ở trên heap, thu gom rác lo phần dọn dẹp
Hiểu chỗ đối tượng nằm giúp giải thích gọn nhiều hành vi tưởng như khó. Java chia bộ nhớ lúc
chạy thành hai vùng đáng nhớ. Ngăn xếp (stack) là vùng riêng của mỗi luồng, giữ biến cục bộ và
tham số của từng lời gọi phương thức; khi phương thức trả về, khung của nó bị bỏ ngay. Vùng nhớ
động (heap) dùng chung cho cả máy ảo, là nơi mọi đối tượng tạo bằng new thực sự nằm. Một biến
kiểu đối tượng nằm trên ngăn xếp nhưng chỉ giữ địa chỉ trỏ tới đối tượng trên heap, đúng như tay
cầm trỏ tới ngôi nhà đã nói ở trên.
Từ đó suy ra một quy tắc hay bị hiểu sai: Java luôn truyền tham số theo giá trị, kể cả với đối tượng. Cái được sao chép khi gọi phương thức là giá trị của tham chiếu, tức bản sao của địa chỉ, chứ không phải đối tượng. Vì bản sao vẫn trỏ tới cùng đối tượng nên phương thức sửa được trạng thái bên trong và bên ngoài thấy thay đổi đó. Nhưng nếu phương thức gán lại chính tham số cho một đối tượng khác, nó chỉ đổi bản sao địa chỉ trên khung của mình, biến gốc bên ngoài không hề hấn gì.
public class ThamChieu {
static void doiPhanTu(int[] a) { a[0] = 99; } // mutates the shared array
static void ganLai(int[] a) { a = new int[]{-1}; } // reassigns the local copy only
public static void main(String[] args) {
int[] m = {1, 2, 3};
doiPhanTu(m); // m[0] becomes 99: same array object on the heap
ganLai(m); // m is unchanged: only the local reference was replaced
System.out.println(m[0]); // prints 99
}
}
Chuyện dọn dẹp heap thuộc về bộ thu gom rác (garbage collector). Khi không còn tham chiếu nào tới được một đối tượng, đối tượng ấy thành rác và sẽ bị thu hồi vào lúc máy ảo thấy tiện, lập trình viên không tự giải phóng như trong C. Điều này bỏ được cả một lớp lỗi con trỏ treo, nhưng không có nghĩa là hết lo bộ nhớ. Chừng nào còn một tham chiếu sống trỏ tới, thu gom rác vẫn coi đối tượng là cần và không đụng tới, nên nguyên tắc thực dụng là buông tham chiếu khi hết cần.
Nguyên nhân gốc không phải máy ảo quên dọn, mà là mã còn giữ một tham chiếu sống tới đối tượng
lẽ ra đã hết dùng. Thủ phạm quen thuộc là trường static trỏ tới một tập hợp cứ thêm mà không
bao giờ xoá: vì biến static sống suốt vòng đời chương trình, mọi đối tượng nó níu đều thoát
khỏi tay thu gom rác. Cách chặn là giới hạn vòng đời tham chiếu, xoá phần tử khi xong việc, hoặc
dùng cấu trúc tự buông như WeakHashMap khi phù hợp.
Trường, phương thức, hàm dựng và this
Trường là biến mô tả trạng thái của đối tượng. Phương thức là hàm mô tả hành vi. Hàm dựng
là khối khởi tạo trùng tên lớp, không có kiểu trả về, chạy đúng một lần khi đối tượng ra
đời. Khi tên tham số trùng tên trường, từ khoá this trỏ tới chính đối tượng hiện tại giúp
phân biệt. Ta có thể nạp chồng nhiều hàm dựng và gọi chéo nhau bằng this(...).
public class SinhVien {
private String hoTen; // instance field
private double diem;
public SinhVien(String hoTen, double diem) { // constructor
this.hoTen = hoTen; // this distinguishes field from parameter
this.diem = diem;
}
public SinhVien(String hoTen) { // overloaded constructor
this(hoTen, 0.0); // delegates to the other constructor
}
public String getHoTen() { return hoTen; } // accessor method
public double getDiem() { return diem; }
}
Ba chi tiết dễ bỏ qua. Nếu bạn không viết hàm dựng nào, Java tự cấp một hàm dựng rỗng, nhưng
ngay khi bạn viết một hàm dựng có tham số thì hàm dựng rỗng đó biến mất. Lời gọi this(...)
nếu có thì phải là câu lệnh đầu tiên. Và ngoài chỗ khai báo, hàm dựng là nơi duy nhất còn gán
được trường final, nên cũng là chỗ hợp lý để kiểm tra dữ liệu: thà ném
IllegalArgumentException ngay còn hơn để một điểm âm trôi vào hệ thống rồi hỏng ở báo cáo
cuối kỳ.
Đặt kiểu trả về, ví dụ public void SinhVien(...), khiến nó lặng lẽ thành một phương thức
thường: trình biên dịch không báo gì, chỉ là hàm dựng không bao giờ chạy. Quên this khiến
hoTen = hoTen; gán tham số cho chính nó, trường vẫn null. Gọi một phương thức có thể bị
ghi đè ngay trong hàm dựng cũng nguy hiểm, vì trường của lớp con chưa kịp khởi tạo.
Các mức truy cập
Java có bốn mức truy cập, kiểm soát ai được nhìn thấy thành viên. Nguyên tắc thiết kế là che
giấu: để trường ở mức private, mở hành vi qua phương thức công khai. Bảng sau tóm tắt phạm
vi nhìn thấy.
| Mức | Cùng lớp | Cùng gói | Lớp con | Mọi nơi |
|---|---|---|---|---|
private | có | không | không | không |
| mặc định (package-private) | có | có | không | không |
protected | có | có | có | không |
public | có | có | có | có |
static và final
Thành viên static thuộc về lớp chứ không thuộc về từng đối tượng: mọi thể hiện dùng chung
một bản, truy cập qua tên lớp. Trường static hợp với hằng số chung và bộ đếm; phương thức
static hợp với hàm tiện ích không cần trạng thái đối tượng. Từ khoá final đóng băng giá
trị: biến final chỉ gán một lần, phương thức final không cho ghi đè, lớp final không
cho kế thừa. Kết hợp static final cho ta hằng số toàn lớp.
public class TaiKhoan {
public static final double LAI_SUAT = 0.05; // shared constant
private static int soLuong = 0; // class-wide counter
private final String maSo; // assigned once only
public TaiKhoan(String maSo) {
this.maSo = maSo;
soLuong++; // count every new object
}
public static int demTaiKhoan() { return soLuong; } // static method
}
Trường static dùng chung cho cả lớp; trường thể hiện riêng cho mỗi đối tượng. Hãy đặt
trường ở mức private và chỉ mở ra ngoài qua phương thức, đó là tinh thần che giấu dữ liệu.
Kế thừa và giao tiếp, nhìn sơ lược
Kế thừa dùng extends: lớp con nhận lại thành viên của lớp cha và ghi đè hành vi bằng
@Override; nó trả lời câu hỏi lớp con có phải một loại của lớp cha hay không. Giao tiếp dùng
interface và implements: nó là bản hợp đồng gồm những phương thức lớp cài đặt phải cung
cấp, và trả lời câu hỏi lớp này làm được việc gì. Một lớp chỉ kế thừa một lớp cha nhưng cài
đặt được nhiều giao tiếp. Lớp abstract là lớp chưa hoàn chỉnh nên không tạo đối tượng trực
tiếp được, còn super(...) gọi hàm dựng cha và phải đứng đầu hàm dựng con.
public interface CoLuong { // a capability contract
double tinhLuong();
}
abstract class NhanSu { // abstract: cannot be instantiated
protected final String hoTen;
protected NhanSu(String hoTen) { this.hoTen = hoTen; }
public String moTa() { return "Nhan su: " + hoTen; }
}
class GiangVien extends NhanSu implements CoLuong {
private final int soTiet;
public GiangVien(String hoTen, int soTiet) {
super(hoTen); // parent constructor must run first
this.soTiet = soTiet;
}
@Override
public double tinhLuong() { return soTiet * 150_000.0; }
@Override
public String moTa() { return "Giang vien: " + hoTen; } // overrides parent
}
Giá trị thật của hai cơ chế này là đa hình. Một biến kiểu NhanSu giữ được đối tượng
GiangVien; khi gọi moTa(), máy ảo chọn phiên bản của lớp thực tế lúc chạy. Nhờ đó vòng lặp
duyệt danh sách nhân sự vẫn in đúng mô tả riêng của từng loại, và thêm loại mới về sau không
phải sửa vòng lặp đó.
equals, hashCode và toString
Mọi lớp đều ngầm kế thừa Object. Bản equals mặc định so sánh địa chỉ, tức giống hệt ==;
bản toString mặc định in tên lớp kèm một mã băm khó đọc. Với lớp dữ liệu, cả hai gần như vô
dụng nếu để nguyên. Quy tắc bắt buộc: ghi đè equals thì phải ghi đè hashCode theo, vì hai
đối tượng bằng nhau phải cho cùng mã băm. Phá quy tắc này thì HashSet hỏng theo cách rất khó
lần, bạn cất một đối tượng vào rồi hỏi lại bằng một đối tượng nội dung y hệt và tập hợp trả
lời là không có. Chọn trường nào đưa vào equals là quyết định thiết kế: ở đây hai sinh viên
là một người nếu trùng mã số, vì họ tên có thể sửa chính tả mà vẫn là người đó.
import java.util.Objects;
public class SinhVien {
private final String maSo;
private final String hoTen;
public SinhVien(String maSo, String hoTen) {
this.maSo = maSo;
this.hoTen = hoTen;
}
@Override
public boolean equals(Object o) {
if (this == o) return true; // same reference
if (!(o instanceof SinhVien other)) return false; // pattern matching, Java 16
return Objects.equals(maSo, other.maSo); // identity is the code
}
@Override
public int hashCode() { return Objects.hash(maSo); } // same field as equals
@Override
public String toString() { return "SinhVien{" + maSo + ", " + hoTen + "}"; }
}
equals hay gặp nhấtViết public boolean equals(SinhVien o) là nạp chồng chứ không phải ghi đè, vì chữ ký đúng
phải nhận Object. Mã vẫn biên dịch, nhưng HashSet lại gọi bản gốc equals(Object) và so
địa chỉ nên kết quả sai. Luôn viết @Override: nếu chữ ký sai, trình biên dịch báo ngay.
Kiểu record
Từ Java 16, record khai báo một lớp dữ liệu bất biến chỉ trong một dòng: trình biên dịch tự
sinh trường private final, hàm dựng đầy đủ, phương thức đọc trường, cùng equals, hashCode
và toString dựng trên toàn bộ thành phần đã khai.
public record Diem(String maMon, double giaTri) {
public Diem { // compact constructor: validate here
if (giaTri < 0 || giaTri > 10) {
throw new IllegalArgumentException("Diem phai nam trong [0, 10]");
}
}
public boolean dat() { return giaTri >= 5.0; } // extra behaviour is allowed
}
Có một điểm sâu hơn về equals đáng ghi lòng. Ngoài quy tắc buộc đi cùng hashCode, bản thân
equals phải giữ ba tính chất: phản xạ (một đối tượng bằng chính nó), đối xứng (nếu a bằng
b thì b bằng a), và bắc cầu (nếu a bằng b và b bằng c thì a bằng c). Phá tính
đối xứng là bẫy hay gặp nhất, thường do so sánh với cả lớp con theo kiểu nới lỏng một chiều. Kèm
theo đó, hashCode phải ổn định: cùng một đối tượng, gọi bao nhiêu lần cũng cho một giá trị,
miễn các trường tham gia so bằng chưa đổi. Đây chính là lý do vì sao chỉ nên đưa vào hashCode
những trường bất biến, để đối tượng không lạc ngăn khi làm khoá của HashMap.
Phương thức đọc trường mang đúng tên thành phần, tức d.giaTri() chứ không phải
d.getGiaTri(). Vì bất biến nên record hợp làm khoá của Map hay đối tượng truyền dữ liệu
giữa các tầng. Đổi lại nó không kế thừa được lớp khác và không thêm được trường thể hiện nào
ngoài danh sách thành phần, nên lớp cần trạng thái thay đổi vẫn phải là lớp thường.
Bài tập thực hành
Bài tập 1: lớp SinhVien với bộ đếm static
Viết lớp SinhVien có các trường private là họ tên và điểm, một hàm dựng đầy đủ, một hàm
dựng nạp chồng chỉ nhận họ tên (điểm mặc định 0), và các phương thức đọc trường. Thêm một bộ
đếm static cho biết tổng số sinh viên đã tạo.
Lời giải chi tiết
Bước 1: đặt hai trường ở mức private để che giấu dữ liệu. Bước 2: hàm dựng đầy đủ dùng
this để phân biệt tham số với trường. Bước 3: hàm dựng nạp chồng gọi this(hoTen, 0.0) để
khỏi lặp mã. Bước 4: một trường static đếm số đối tượng, tăng trong mỗi hàm dựng đầy đủ.
public class SinhVien {
private static int soLuong = 0; // class-wide counter
private final String hoTen;
private double diem;
public SinhVien(String hoTen, double diem) { // full constructor
this.hoTen = hoTen;
this.diem = diem;
soLuong++; // count every new object
}
public SinhVien(String hoTen) { // overloaded constructor
this(hoTen, 0.0); // delegates, so counting happens once
}
public String getHoTen() { return hoTen; }
public double getDiem() { return diem; }
public static int demSinhVien() { return soLuong; } // static accessor
}
Ý chính: gọi chéo hàm dựng bằng this(...) giúp logic đếm nằm một chỗ duy nhất, tránh đếm
nhầm hai lần.
Bài tập 2: lớp TaiKhoan với hằng số static final
Viết lớp TaiKhoan có một mã số final gán một lần trong hàm dựng, một lãi suất chung khai
báo static final, và một bộ đếm static cho tổng số tài khoản đã mở. Thêm phương thức
tính tiền lãi của một số dư cho trước.
Lời giải chi tiết
Bước 1: mã số để final nên chỉ gán được trong hàm dựng. Bước 2: lãi suất là hằng số chung
cho mọi tài khoản nên dùng static final. Bước 3: một trường static đếm số tài khoản,
tăng trong hàm dựng. Bước 4: phương thức tính lãi đọc hằng số static final chung.
public class TaiKhoan {
public static final double LAI_SUAT = 0.05; // shared constant for all
private static int soLuong = 0; // class-wide counter
private final String maSo; // assigned once only
public TaiKhoan(String maSo) {
this.maSo = maSo;
soLuong++; // count every new account
}
public double tinhLai(double soDu) { // uses the shared constant
return soDu * LAI_SUAT;
}
public static int demTaiKhoan() { return soLuong; }
}
Ý chính: final bảo vệ mã số khỏi bị đổi sau khi tạo, còn static final cho ta một hằng số
dùng chung mà không tốn bộ nhớ cho từng đối tượng.
Bài tập 3: equals, hashCode và record cho lớp MonHoc
Viết lớp MonHoc gồm mã môn và tên môn, coi hai môn là một nếu trùng mã môn. Bỏ hai đối tượng
cùng mã môn nhưng khác tên vào một HashSet và xác nhận tập hợp chỉ giữ một phần tử. Sau đó
viết lại MonHoc bằng record và giải thích vì sao kết quả đổi.
Lời giải chi tiết
Ghi đè equals chỉ so sánh maMon và ghi đè hashCode bằng Objects.hash(maMon), đúng theo
mẫu lớp SinhVien ở trên. Khi đó thêm new MonHoc("IT101", "Java co ban") và
new MonHoc("IT101", "Java can ban") vào HashSet thì kích thước là 1: hashCode đưa hai
đối tượng về cùng một ngăn, rồi equals xác nhận chúng trùng.
Nếu viết public record MonHoc(String maMon, String tenMon) {} thì trình biên dịch sinh
equals và hashCode trên mọi thành phần, kể cả tên môn, nên hai đối tượng trên trở thành
khác nhau và tập hợp giữ lại cả hai. Bài học: record chỉ thay được lớp viết tay khi bằng
nhau đúng nghĩa là trùng toàn bộ thành phần; khi căn cước chỉ nằm ở một phần dữ liệu, hãy giữ
lớp thường và tự ghi đè.
Câu hỏi tự kiểm
- 1Biến a và b cùng trỏ tới một đối tượng. Sửa trạng thái qua b thì đọc qua a sẽ thấy gì?
- 2Java truyền tham số cho phương thức theo kiểu nào?
- 3Khi ghi đè equals cho một lớp dữ liệu, vì sao phải ghi đè hashCode theo?
- 4Trường static của một lớp có đặc điểm gì?
Tóm tắt
Bốn ý cần mang theo. Che giấu dữ liệu là điểm khởi đầu: trường để private, hành vi mở ra qua
phương thức, và hàm dựng vừa khởi tạo trạng thái vừa là nơi chặn dữ liệu sai. Kế thừa diễn đạt
quan hệ là một loại của, giao tiếp diễn đạt khả năng làm được việc gì, và cả hai cùng mở đường
cho đa hình. Với lớp dữ liệu, equals và hashCode là một cặp không tách rời, thêm toString
để dễ gỡ lỗi. Cuối cùng, record từ Java 16 rút gọn cả cụm đó về một dòng, với điều kiện bằng
nhau đúng nghĩa là trùng toàn bộ thành phần.
OOP trong Java khởi đầu từ che giấu dữ liệu: để trường ở mức private, mở hành vi qua phương
thức công khai. Hàm dựng khởi tạo trạng thái, this phân biệt trường với tham số, còn
static và final cho ta thành viên dùng chung và giá trị bất biến của lớp.