Chuyển tới nội dung chính

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 pq 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.

Hai biến, một đối tượng: bí danh tham chiếu java
1public class Demo {
2 public static void main(String[] args) {
3 Point p = new Point(3); // object lives on the heap
4 Point q = p; // second variable, same object
5 q.x = 9; // change through q
6 System.out.println(p.x); // p sees it too: 9
7 }
8}
9
10class Point {
11 int x;
12 Point(int x) { this.x = x; }
13}
Ngăn xếp stack
main()
Bộ nhớ động heap
(trống)
Bắt đầu main(). Chưa có đối tượng nào, bộ nhớ động còn trống.
1/6

Ô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.

Đừng so sánh đối tượng bằng ==

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 breakcontinue. 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.

Bảy thuật ngữ nền của lớp và đối tượng

Đố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.

Rò rỉ bộ nhớ vẫn xảy ra dù có thu gom rác

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ỳ.

Ba lỗi kinh điển với hàm dựng

Đặ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ứcCùng lớpCùng góiLớp conMọi nơi
privatekhôngkhôngkhông
mặc định (package-private)khôngkhông
protectedkhông
public

staticfinal

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
}
Ghi nhớ

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 interfaceimplements: 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, hashCodetoString

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 + "}"; }
}
Bẫy equals hay gặp nhất

Viế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, hashCodetoString 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 bb 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, hashCoderecord 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")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 equalshashCode 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

Kiểm tra nhanh: lớp và đối tượng0/4 đúngchưa trả lời
  1. 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ì?
  2. 2Java truyền tham số cho phương thức theo kiểu nào?
  3. 3Khi ghi đè equals cho một lớp dữ liệu, vì sao phải ghi đè hashCode theo?
  4. 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, equalshashCode 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.

Ghi nhớ

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 staticfinal cho ta thành viên dùng chung và giá trị bất biến của lớp.