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

Lớp ẩn danh

Lớp ẩn danh là một lớp không có tên, được khai báo và tạo thể hiện ngay tại chỗ cần dùng. Nó thường dùng để cài đặt nhanh một giao tiếp hoặc ghi đè một lớp khi ta chỉ cần đúng một thể hiện duy nhất.

Hãy hình dung một tình huống thật. Ta đang viết một nút bấm và cần gắn cho nó một hành động khi người dùng nhấn. Hành động đó chỉ dùng đúng một lần, ở đúng chỗ này, không nơi nào khác gọi lại. Nếu bắt buộc tách ra một lớp riêng đặt trong một tệp riêng, mã nguồn sẽ phình lên vì những lớp bé xíu chỉ sống một chỗ. Lớp ẩn danh sinh ra để giải quyết đúng nỗi phiền này: nó cho ta viết thân lớp ngay tại nơi cần, gắn liền với ngữ cảnh, và không phải nghĩ ra một cái tên cho thứ chỉ dùng một lần.

Lớp ẩn danh là một trong bốn kiểu lớp lồng của Java. Trước khi đi sâu vào nó, ta điểm qua cả bốn kiểu để thấy lớp ẩn danh nằm ở đâu và khác các kiểu kia thế nào. Hiểu được điều này giúp ta chọn đúng công cụ chứ không lạm dụng một kiểu cho mọi việc.

Thử ngay: xem lớp ẩn danh 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 lạ. Ví dụ dưới đây tạo một lớp ẩn danh bên trong phương thức thể hiện của lớp Box, cất nó lại, sửa dữ liệu của Box rồi mới cho nó chạy. Nó in ra giá trị mới nhất chứ không phải giá trị lúc nó được tạo. Bấm ▶ Chạy để theo dõi từng dòng: đối tượng lớp ẩn danh nằm trên bộ nhớ động và mang một trường ẩn trỏ về đối tượng Box bao. Cái trường ẩn ấy là lý do của toàn bộ hiện tượng, và mục "Tham chiếu ngầm tới đối tượng bao" ở dưới sẽ giải thích nó cặn kẽ.

Lớp ẩn danh giữ tham chiếu tới đối tượng bao java
1public class Demo {
2 public static void main(String[] args) {
3 Box b = new Box(7); // enclosing object on the heap
4 Runnable r = b.makeTask(); // anonymous class captures b
5 b.value = 42; // change the captured object later
6 r.run(); // prints 42, not 7
7 }
8}
9
10class Box {
11 int value;
12 Box(int value) { this.value = value; }
13 Runnable makeTask() {
14 return new Runnable() { // anonymous class
15 public void run() {
16 System.out.println(value); // reads the enclosing Box field
17 }
18 };
19 }
20}
Ngăn xếp stack
main()
b→ #1
Bộ nhớ động heap
#1Box
value7
new Box(7) tạo một đối tượng Box (#1) với value = 7 trên heap. Biến b giữ tham chiếu tới nó.
1/6

Khai báo tại chỗ

Khi cần một đối tượng cài đặt giao tiếp chỉ để dùng một lần, viết hẳn một lớp riêng là thừa. Lớp ẩn danh cho phép vừa khai báo vừa khởi tạo trong một biểu thức. Cú pháp là new kèm tên giao tiếp hoặc lớp, rồi mở thân lớp ngay sau cặp ngoặc.

interface XuLy { // secondary type: not public, one file
void chay(); // single abstract method
}

public class Demo {
public static void main(String[] args) {
XuLy viec = new XuLy() { // anonymous class, no separate file
@Override public void chay() {
System.out.println("Dang chay mot tac vu");
}
};
viec.chay();
}
}

Giao tiếp XuLy chỉ có đúng một phương thức trừu tượng. Loại giao tiếp như vậy gọi là giao tiếp hàm, và toàn bộ khối lớp ẩn danh ở trên về sau viết gọn lại thành một biểu thức Lambda. Vì lẽ đó, lớp ẩn danh chính là bậc thang dẫn vào chương 2.

Bốn loại lớp lồng trong Java
1Lớp ẩn danhkhông có tên
Khai báo và tạo thể hiện ngay trong một biểu thức, hợp khi chỉ cần đúng một thể hiện.
2Lớp cục bộtrong một phương thức
Có tên nhưng chỉ nhìn thấy được trong khối chứa nó.
3Lớp nội bộthành viên không static
Gắn với một thể hiện của lớp bao, đọc được cả trường thể hiện của lớp đó.
4Lớp lồng tĩnhthành viên static
Không gắn với thể hiện lớp bao, dùng như một lớp độc lập đặt bên trong.
Bốn loại này ngang hàng nhau, đều là lớp lồng trong Java; thứ tự trên dưới ở đây chỉ để liệt kê, không phải bậc cha con hay thứ cấp kế thừa.
Biến bao quanh phải bất biến

Lớp ẩn danh truy cập được các biến cục bộ bao quanh nó, nhưng các biến ấy phải là final hoặc thực chất không đổi, nghĩa là không được gán lại sau khi khởi tạo.

Bốn loại lớp lồng và khi nào dùng

Lớp lồng tĩnh khai báo với từ khóa static bên trong một lớp khác. Nó không giữ tham chiếu tới thể hiện lớp bao, nên hành xử gần như một lớp độc lập, chỉ khác chỗ nó được gói bên trong để gom nhóm về mặt tên gọi. Ta chọn nó khi một lớp phụ chỉ có ý nghĩa trong phạm vi lớp bao, ví dụ lớp Node bên trong lớp DanhSachLienKet. Vì không giữ tham chiếu ẩn tới lớp bao, nó tiết kiệm bộ nhớ và tránh rò rỉ ngoài ý muốn.

Lớp nội bộ, tức lớp thành viên không static, thì ngược lại: mỗi thể hiện của nó luôn gắn với một thể hiện của lớp bao và đọc thẳng được trường thể hiện của lớp đó. Ta chọn nó khi lớp con cần dùng chung trạng thái với đối tượng bao, ví dụ một trình lặp cần đọc dữ liệu bên trong tập hợp mà nó thuộc về. Cái giá phải trả là thể hiện lớp nội bộ luôn níu giữ đối tượng bao, nên nếu vòng đời hai bên lệch nhau thì dễ giữ bộ nhớ lâu hơn cần thiết.

Lớp cục bộ khai báo ngay bên trong một phương thức và chỉ nhìn thấy được trong khối chứa nó. Nó có tên, nên khác lớp ẩn danh ở chỗ ta tạo được nhiều thể hiện của nó và định nghĩa thêm hàm khởi tạo. Ta chọn nó khi cần một lớp trợ giúp cục bộ, dùng vài lần trong đúng một phương thức, và không muốn nó lộ ra ngoài.

Lớp ẩn danh là loại thứ tư, đứng ngang hàng ba loại trên chứ không phải một biến thể của lớp cục bộ. Nó không có tên, gộp khai báo với khởi tạo vào một biểu thức, và chỉ đẻ ra đúng một thể hiện. Ta chọn nó khi cần cài đặt nhanh một giao tiếp hay ghi đè một lớp cho đúng một lần dùng, thường là một hàm gọi lại truyền vào phương thức khác. Đặt cạnh lớp cục bộ, khác biệt là lớp cục bộ có tên nên tạo được nhiều thể hiện và có hàm khởi tạo riêng, còn lớp ẩn danh thì không.

Một cách sắp xếp lại bốn loại cho dễ nhớ là hỏi hai câu. Câu thứ nhất: lớp này khai báo ở đâu, ở cấp thành viên của lớp bao hay ở bên trong thân một phương thức? Hai loại đầu là thành viên, hai loại sau nằm trong phương thức. Câu thứ hai: lớp này có gắn với một thể hiện của lớp bao hay không? Lớp lồng tĩnh thì không, ba loại còn lại thì có, miễn là chúng được tạo ra trong ngữ cảnh có thể hiện. Hai câu hỏi ấy chia bốn loại thành một bảng gọn gàng, và quan trọng hơn, chúng chỉ thẳng vào cái tính chất gây hệ luỵ nhiều nhất, đó là tham chiếu ngầm tới đối tượng bao. Phần ngay dưới đây nói về nó.

Tham chiếu ngầm tới đối tượng bao

Đây là kiến thức mà rất nhiều người học xong bốn loại lớp lồng rồi vẫn không nắm, và nó là nguồn gốc của một lớp lỗi rất khó tìm.

Khi ta viết một lớp nội bộ, tức lớp thành viên không static, trình biên dịch âm thầm thêm vào lớp ấy một trường ẩn trỏ về đối tượng bao, và âm thầm thêm một tham số vào hàm khởi tạo để truyền đối tượng bao vào. Đó chính là cơ chế cho phép thân lớp nội bộ đọc thẳng trường thể hiện của lớp bao mà không phải viết gì thêm: bên dưới, mọi truy cập ấy đều được dịch thành lời gọi qua cái trường ẩn kia. Lớp cục bộ và lớp ẩn danh, khi được tạo ra trong một ngữ cảnh có thể hiện, cũng mang theo đúng tham chiếu ngầm ấy. Chỉ có lớp lồng tĩnh là không, vì nó không gắn với thể hiện nào cả.

Tham chiếu ngầm này tiện, nhưng nó có hai hệ luỵ mà ta phải biết.

Hệ luỵ thứ nhất là chuyện this. Trong thân một lớp nội bộ hay lớp ẩn danh, this trỏ về chính thể hiện của lớp lồng, không trỏ về đối tượng bao. Muốn trỏ về đối tượng bao phải viết TenLopBao.this. Nếu lớp lồng có một trường trùng tên với trường của lớp bao, trường của lớp lồng sẽ che mất trường kia, và đây là chỗ sinh ra những lỗi đọc nhầm biến rất khó phát hiện bằng mắt.

public class NguoiDung {
private String ten = "lop bao";

class The { // inner class, not static
private String ten = "lop noi bo";

void in() {
System.out.println(ten); // lop noi bo
System.out.println(this.ten); // lop noi bo
System.out.println(NguoiDung.this.ten); // lop bao
}
}

public static void main(String[] args) {
NguoiDung nd = new NguoiDung();
The t = nd.new The(); // note the syntax: outer.new Inner()
t.in();
}
}

Chú ý cả cú pháp khởi tạo nd.new The(). Nó trông lạ mắt nhưng rất logic: vì mọi thể hiện lớp nội bộ đều phải gắn với một đối tượng bao, ta buộc phải nói rõ đối tượng bao là ai. Nếu thử viết new The() từ một ngữ cảnh static như main, trình biên dịch sẽ báo lỗi vì không có đối tượng bao nào để gắn vào. Chính thông báo lỗi ấy là bằng chứng cho thấy tham chiếu ngầm có thật.

Hệ luỵ thứ hai nghiêm trọng hơn, đó là nguy cơ rò rỉ bộ nhớ. Bộ thu gom rác của Java chỉ dọn những đối tượng không còn ai tham chiếu tới. Một thể hiện lớp nội bộ luôn níu giữ đối tượng bao của nó, nên chừng nào thể hiện lớp nội bộ còn sống thì đối tượng bao cũng không thể bị dọn, dù chương trình không còn dùng đối tượng bao ấy nữa. Chuyện chỉ thành vấn đề khi vòng đời hai bên lệch nhau: ta tạo một đối tượng bao nặng, lấy từ nó ra một đối tượng lớp nội bộ nhỏ xíu, rồi cất đối tượng nhỏ ấy vào một chỗ sống lâu, chẳng hạn một danh sách bộ lắng nghe ở cấp toàn cục hay một bộ nhớ đệm tĩnh. Từ lúc đó, cái đối tượng bao nặng bị neo lại trong bộ nhớ vô thời hạn qua sợi dây mà ta không hề nhìn thấy trong mã nguồn.

import java.util.ArrayList;
import java.util.List;

public class RoRi {
// A long-lived registry: anything put in here stays alive for good
private static final List<Runnable> SO_TAY = new ArrayList<>();

static class Nang { // static: no hidden outer reference
private final byte[] duLieu = new byte[10_000_000]; // heavy payload

Runnable taoAnDanh() {
return new Runnable() { // anonymous class inside an instance
@Override public void run() {
System.out.println("chay"); // does not even touch duLieu
}
};
}
}

public static void main(String[] args) {
Nang n = new Nang();
SO_TAY.add(n.taoAnDanh()); // the anonymous object silently pins n alive
n = null; // we drop our reference, but n cannot be collected
System.out.println(SO_TAY.size());
}
}

Điểm cay đắng của ví dụ trên là lớp ẩn danh không hề đụng tới trường duLieu. Nó chỉ in ra một chuỗi. Vậy mà mười megabyte kia vẫn không được dọn, chỉ vì tham chiếu ngầm luôn có mặt, bất kể ta có dùng tới đối tượng bao hay không. Đọc mã bằng mắt thường thì không thấy sợi dây ấy ở đâu cả, và đó chính là lý do loại rò rỉ này khó truy.

Cách chữa đơn giản đến bất ngờ: nếu lớp lồng không cần trạng thái của đối tượng bao, hãy khai báo nó static. Một lớp lồng tĩnh không mang tham chiếu ngầm, nên không neo giữ ai cả. Đây là lý do nhiều tài liệu khuyên một câu ngắn gọn: mặc định hãy để lớp lồng là static, và chỉ bỏ static đi khi thật sự cần đọc trạng thái của đối tượng bao. Với lớp ẩn danh, cách tương đương là đừng cho nó sống lâu hơn đối tượng bao, hoặc thay nó bằng một Lambda không bắt biến nào, hoặc rút phần dữ liệu cần dùng ra một biến cục bộ rồi chỉ bắt biến ấy.

Lambda cũng bắt được đối tượng bao

Đừng tưởng đổi lớp ẩn danh sang Lambda là hết rò rỉ. Một Lambda bắt đối tượng bao nếu thân nó tham chiếu tới this hoặc tới một trường thể hiện, kể cả khi ta viết tên trường trần không kèm this. Khác biệt là Lambda chỉ bắt khi thật sự cần, còn lớp ẩn danh thì bắt vô điều kiện. Muốn chắc chắn không neo giữ gì, hãy viết Lambda chỉ đọc biến cục bộ và tham số.

Lớp ẩn danh làm bộ lắng nghe

Kiểu dùng phổ biến nhất của lớp ẩn danh là làm hàm gọi lại, hay còn gọi là bộ lắng nghe. Một phương thức nhận vào một đối tượng cài đặt giao tiếp, rồi tự gọi phương thức của đối tượng đó khi có sự kiện. Ta truyền một lớp ẩn danh vào đúng chỗ tham số.

interface SuKien { // secondary type: not public, one file
void xuLy(String thongDiep); // called back when an event happens
}

public class NutBam {
private SuKien listener;

public void datListener(SuKien s) { this.listener = s; }
public void nhan() { // simulate a click
if (listener != null) listener.xuLy("da nhan nut");
}

public static void main(String[] args) {
NutBam nut = new NutBam();
nut.datListener(new SuKien() { // anonymous listener
@Override public void xuLy(String thongDiep) {
System.out.println("Phan hoi: " + thongDiep);
}
});
nut.nhan(); // prints "Phan hoi: da nhan nut"
}
}

Điểm hay ở đây là hành động phản hồi nằm ngay cạnh nút bấm, đọc mã tới đâu hiểu tới đó, không phải nhảy sang một tệp khác. Nếu sau này cần một nút khác với phản hồi khác, ta chỉ việc truyền một lớp ẩn danh khác, thân lớp cũ không bị đụng tới.

Lớp ẩn danh làm bộ so sánh

Một kiểu dùng kinh điển khác là truyền một bộ so sánh cho phương thức sắp xếp. Giao tiếp Comparator có một phương thức compare quyết định thứ tự hai phần tử. Ta thường cần một thứ tự riêng cho đúng một lần sắp, nên viết hẳn một lớp Comparator là thừa.

import java.util.Arrays;
import java.util.Comparator;

public class SapXep {
public static void main(String[] args) {
String[] ten = {"Binh", "An", "Cuong"};
Arrays.sort(ten, new Comparator<String>() { // anonymous comparator
@Override public int compare(String a, String b) {
return b.length() - a.length(); // longer names first
}
});
System.out.println(Arrays.toString(ten)); // [Cuong, Binh, An]
}
}

Bộ so sánh trên xếp chuỗi dài đứng trước. Vì thứ tự này chỉ dùng đúng ở đây, lớp ẩn danh là lựa chọn gọn nhất. Khi cần một thứ tự khác cho lần sắp khác, ta truyền một lớp ẩn danh mới.

Lớp ẩn danh so với Lambda

Cả hai ví dụ trên đều cài giao tiếp hàm, tức giao tiếp chỉ có một phương thức trừu tượng. Với loại giao tiếp này, biểu thức Lambda viết gọn hơn hẳn vì bỏ được phần khung lặp lại.

// anonymous class form
Arrays.sort(ten, new Comparator<String>() {
@Override public int compare(String a, String b) {
return b.length() - a.length();
}
});

// lambda form, same behaviour
Arrays.sort(ten, (a, b) -> b.length() - a.length());

Tuy vậy lớp ẩn danh không hề lỗi thời. Lambda chỉ dùng được cho giao tiếp hàm một phương thức; khi cần cài một giao tiếp có nhiều phương thức, hoặc cần ghi đè một lớp và giữ trạng thái riêng, hoặc cần dùng this trỏ về chính thể hiện lớp ẩn danh, ta vẫn phải viết lớp ẩn danh. Một khác biệt tinh tế nữa: bên trong lớp ẩn danh, từ khóa this trỏ về thể hiện lớp ẩn danh, còn trong Lambda thì this trỏ về thể hiện lớp bao ngoài.

Ba khác biệt thật sự, ngoài chuyện dài ngắn

Nếu chỉ nhìn vào số dòng thì Lambda thắng tuyệt đối, và người học dễ kết luận sai rằng Lambda là "cách viết tắt" của lớp ẩn danh. Hai thứ này khác nhau ở bản chất chứ không chỉ ở hình thức, và có ba khác biệt đáng nhớ.

Khác biệt thứ nhất nằm ở chỗ Lambda không sinh ra một lớp mới. Mỗi lớp ẩn danh ta viết đều được trình biên dịch dịch thành một tệp lớp riêng, mang cái tên xấu xí kiểu NutBam$1.class. Viết mười lớp ẩn danh là có thêm mười tệp lớp phải nạp lúc chạy. Lambda thì không: trình biên dịch để lại một chỉ dẫn đặc biệt, và máy ảo dựng cài đặt cho nó ngay lúc chạy, lần đầu tiên chỗ ấy được dùng tới. Hệ quả thực tế là chương trình dùng nhiều Lambda khởi động nhẹ hơn và tốn ít bộ nhớ siêu dữ liệu hơn so với chương trình dùng nhiều lớp ẩn danh làm cùng một việc.

Khác biệt thứ hai là this, và đây là chỗ hay làm người ta lúng túng nhất. Lớp ẩn danh là một lớp thật, nên nó có phạm vi riêng, và this bên trong nó trỏ về chính nó. Lambda thì không tạo phạm vi mới, nó nằm hẳn trong phạm vi của phương thức bao quanh, nên this bên trong Lambda vẫn là this của lớp bao. Điều này có mặt tốt: trong Lambda, muốn gọi một phương thức của lớp bao thì cứ gọi thẳng, không cần cú pháp TenLopBao.this.

public class SoSanhThis {
private String ten = "lop bao";

public void chay() {
Runnable anDanh = new Runnable() {
@Override public void run() {
// this refers to the anonymous object, so we must qualify to reach outer
System.out.println(SoSanhThis.this.ten); // lop bao
System.out.println(this.getClass().getSimpleName()); // an empty name
}
};

Runnable lam = () -> {
// this is the enclosing SoSanhThis instance: no qualification needed
System.out.println(this.ten); // lop bao
System.out.println(this.getClass().getSimpleName()); // SoSanhThis
};

anDanh.run();
lam.run();
}

public static void main(String[] args) { new SoSanhThis().chay(); }
}

Khác biệt thứ ba là phạm vi tên. Vì lớp ẩn danh mở ra một phạm vi mới, nó được phép khai báo một biến trùng tên với biến cục bộ bên ngoài, và biến bên trong sẽ che biến bên ngoài. Lambda không mở phạm vi mới, nên khai báo một tham số hay biến trùng tên với biến cục bộ bao quanh là lỗi biên dịch ngay. Nghe thì có vẻ Lambda khắt khe hơn, nhưng thực chất đây là một ưu điểm: nó chặn đứng loại lỗi che biến vốn rất khó nhìn ra bằng mắt.

Tổng kết cách chọn cho gọn. Cứ mặc định dùng Lambda khi mục tiêu là một giao tiếp hàm, tức giao tiếp chỉ có một phương thức trừu tượng, vì Lambda ngắn hơn, nhẹ hơn và ít bẫy hơn. Chỉ quay lại lớp ẩn danh trong ba tình huống: giao tiếp có nhiều hơn một phương thức trừu tượng; ta cần kế thừa và ghi đè một lớp chứ không phải cài một giao tiếp; hoặc ta cần đối tượng ấy giữ trạng thái riêng và cần this trỏ về chính nó.

Truy cập biến bao quanh

Lớp ẩn danh bắt được cả trường của lớp bao lẫn biến cục bộ của phương thức chứa nó. Với biến cục bộ, Java bắt buộc biến phải là final hoặc thực chất không đổi, nghĩa là gán một lần rồi không đổi nữa. Lý do là lớp ẩn danh có thể sống lâu hơn phương thức đã tạo ra nó, nên nó giữ một bản sao giá trị biến; nếu cho phép biến gốc đổi thì bản sao sẽ lệch, gây rối loạn khó lần.

public class BienBao {
private String tienTo = "Log: "; // instance field, always accessible

public Runnable tao() {
int lan = 3; // effectively final local variable
return new Runnable() {
@Override public void run() {
System.out.println(tienTo + "chay lan thu " + lan);
}
};
}
}

Ở đây tienTo là trường thể hiện nên lớp ẩn danh đọc trực tiếp và không đòi hỏi gì thêm. Còn lan là biến cục bộ, ta không gán lại nó sau khi khởi tạo nên nó thực chất không đổi và được phép dùng. Nếu thêm một dòng lan = 4; ở dưới, trình biên dịch lập tức báo lỗi.

Vì sao Java bắt buộc như vậy

Quy tắc "gần như bất biến" nghe tuỳ tiện cho tới khi ta hiểu biến cục bộ sống ở đâu. Biến cục bộ nằm trên ngăn xếp của lời gọi phương thức, và ngăn xếp ấy bị xoá sạch ngay khi phương thức trả về. Trong khi đó, đối tượng lớp ẩn danh nằm trên bộ nhớ động và hoàn toàn có thể sống lâu hơn phương thức đã sinh ra nó, đúng như trong ví dụ trên: phương thức tao() trả về đối tượng Runnable rồi kết thúc, nhưng đối tượng ấy còn được cất đi và chạy về sau. Nếu lớp ẩn danh giữ một con trỏ tới ô nhớ của biến lan trên ngăn xếp, thì tới lúc nó chạy, ô nhớ ấy đã bị thu hồi và ghi đè bởi lời gọi khác.

Java giải quyết bằng cách sao chép giá trị. Lúc tạo đối tượng lớp ẩn danh, trình biên dịch âm thầm sao giá trị của mọi biến cục bộ được bắt vào các trường ẩn của đối tượng ấy. Từ đó về sau, lớp ẩn danh làm việc với bản sao của mình chứ không đọc ô nhớ gốc nữa. Kiểu bắt biến này gọi là bắt theo giá trị.

Tới đây thì quy tắc trở nên hiển nhiên. Nếu cho phép gán lại biến gốc, ta lập tức có hai nguồn sự thật cho cùng một cái tên: bản gốc trên ngăn xếp và bản sao trong đối tượng. Sửa bản này thì bản kia không hay biết, và người đọc mã sẽ không đoán nổi giá trị nào sẽ được in ra. Java đóng cửa vấn đề bằng cách cấm luôn: đã bắt thì biến phải là final hoặc thực chất không đổi, tức là gán một lần rồi thôi. Khi hai bản sao không bao giờ đổi thì chúng không bao giờ lệch nhau, và câu hỏi "giá trị nào mới đúng" không còn tồn tại.

Chú ý một chỗ tinh vi: quy tắc này chỉ áp cho biến cục bộ, không áp cho trường thể hiện. Trường thể hiện nằm trên bộ nhớ động cùng với đối tượng bao, và lớp ẩn danh truy cập nó qua tham chiếu ngầm chứ không sao chép, nên nó đọc luôn giá trị mới nhất. Điều này giải thích một mẹo mà ta hay thấy trong mã thực tế: khi cần một biến đếm thay đổi được bên trong lớp ẩn danh, người ta không dùng biến cục bộ int mà bọc nó vào một đối tượng, ví dụ một mảng một phần tử hoặc một AtomicInteger. Bản thân biến tham chiếu vẫn thực chất không đổi nên hợp lệ, còn cái bị sửa là nội dung của đối tượng nó trỏ tới.

import java.util.concurrent.atomic.AtomicInteger;

public class DemLan {
public static void main(String[] args) {
AtomicInteger dem = new AtomicInteger(); // the reference never changes

Runnable r = new Runnable() {
@Override public void run() {
dem.incrementAndGet(); // we mutate the object, not the variable
}
};

r.run();
r.run();
System.out.println(dem.get()); // 2
}
}

Mẹo này hợp lệ nhưng nên dùng dè dặt. Nó lách được luật chứ không xoá được lý do đẻ ra luật, và một biến đếm bị nhiều đoạn mã cùng sửa vẫn khó lần như thường, nhất là khi có nhiều luồng. Khi nào thấy mình phải viết như vậy, hãy dừng lại một nhịp và tự hỏi liệu có cách nào diễn đạt cùng ý định mà không cần trạng thái sửa được hay không, ví dụ trả về giá trị thay vì tích luỹ vào một biến bên ngoài.

Lỗi thường gặp

Gán lại biến cục bộ sau khi lớp ẩn danh đã tham chiếu tới nó sẽ khiến biên dịch hỏng với thông báo biến phải là final hoặc thực chất không đổi. Một lỗi khác là quên từ khóa new trước tên giao tiếp, khiến trình biên dịch không hiểu đây là lớp ẩn danh. Ngoài ra, đừng nhầm this trong lớp ẩn danh với this của lớp bao; muốn trỏ về lớp bao phải viết TenLopBao.this.

Bài tập thực hành

Bài tập 1: lớp ẩn danh cài đặt XuLy

Dùng lớp ẩn danh tạo một thể hiện của giao tiếp XuLy (một phương thức chay) in ra một thông báo, rồi gọi nó. Sau đó nêu nhận xét: vì sao khối lớp ẩn danh này có thể viết gọn thành Lambda ở chương 2.

Lời giải chi tiết

Bước 1: khai báo giao tiếp XuLy chỉ một phương thức. Bước 2: dùng new XuLy() { ... } để vừa khai báo vừa tạo thể hiện trong một biểu thức, không cần tệp lớp riêng. Bước 3: gọi phương thức chay() trên thể hiện vừa tạo.

interface XuLy { // secondary type: not public, one file
void chay(); // single abstract method
}

public class Demo {
public static void main(String[] args) {
XuLy viec = new XuLy() { // anonymous class, soon a lambda
@Override public void chay() {
System.out.println("Dang chay mot tac vu");
}
};
viec.chay();
}
}

Nhận xét: vì XuLy chỉ có một phương thức trừu tượng nên nó là giao tiếp hàm. Cả khối lớp ẩn danh trên về sau viết gọn thành XuLy viec = () -> System.out.println("Dang chay mot tac vu");, đó chính là biểu thức Lambda của chương 2.

Bài tập 2: lớp ẩn danh đọc biến bao quanh

Viết main khai báo một biến cục bộ String chứa tên một công việc, rồi tạo một lớp ẩn danh cài đặt XuLy in ra tên công việc đó. Lưu ý biến cục bộ phải thực chất không đổi.

Lời giải chi tiết

Bước 1: khai báo biến cục bộ và không gán lại nó về sau, nhờ vậy nó thực chất không đổi. Bước 2: lớp ẩn danh đọc biến bao quanh ngay trong thân chay(). Bước 3: gọi chay() để in.

interface XuLy { // secondary type: not public, one file
void chay();
}

public class Demo {
public static void main(String[] args) {
String tenViec = "Sao luu du lieu"; // effectively final, never reassigned
XuLy viec = new XuLy() {
@Override public void chay() {
System.out.println("Dang chay: " + tenViec); // reads outer variable
}
};
viec.chay();
}
}

Ý chính: lớp ẩn danh bắt được biến cục bộ bao quanh, nhưng chỉ khi biến ấy là final hoặc thực chất không đổi; nếu gán lại tenViec sau đó, trình biên dịch sẽ báo lỗi.

Bài tập 3: đổi lớp ẩn danh sang Lambda và chỉ ra chỗ không đổi được

Cho một mảng tên. Hãy sắp xếp nó theo độ dài tăng dần, tên nào cùng độ dài thì xếp theo thứ tự bảng chữ cái, bằng một Comparator viết dưới dạng lớp ẩn danh. Sau đó viết lại bằng Lambda. Cuối cùng trả lời: nếu bên trong bộ so sánh ta cần in ra tên lớp của chính bộ so sánh ấy qua this.getClass(), thì hai cách viết cho kết quả khác nhau ra sao, và vì sao?

Lời giải chi tiết

Bước 1, viết bằng lớp ẩn danh. Phép so sánh gồm hai tầng: so độ dài trước, nếu bằng nhau mới so nội dung chuỗi. Chú ý dùng Integer.compare chứ không dùng phép trừ, vì phép trừ có thể tràn số khi hai giá trị quá xa nhau.

import java.util.Arrays;
import java.util.Comparator;

public class SapTheoDoDai {
public static void main(String[] args) {
String[] ten = {"Cuong", "An", "Binh", "Ha"};

Arrays.sort(ten, new Comparator<String>() { // anonymous class form
@Override public int compare(String a, String b) {
int d = Integer.compare(a.length(), b.length()); // never a - b
return d != 0 ? d : a.compareTo(b); // tie break
}
});
System.out.println(Arrays.toString(ten)); // [An, Ha, Binh, Cuong]
}
}

Bước 2, viết lại bằng Lambda. Vì Comparator là giao tiếp hàm nên Lambda thay thế được trọn vẹn, và thư viện còn cho ta cách viết khai báo hơn nữa qua comparingIntthenComparing.

import java.util.Arrays;
import java.util.Comparator;

public class SapTheoDoDaiLam {
public static void main(String[] args) {
String[] ten = {"Cuong", "An", "Binh", "Ha"};

Arrays.sort(ten, (a, b) -> { // plain lambda form
int d = Integer.compare(a.length(), b.length());
return d != 0 ? d : a.compareTo(b);
});

// Same order, expressed declaratively
Arrays.sort(ten, Comparator.comparingInt(String::length)
.thenComparing(Comparator.naturalOrder()));

System.out.println(Arrays.toString(ten)); // [An, Ha, Binh, Cuong]
}
}

Bước 3, câu hỏi về this. Hai cách viết cho kết quả khác hẳn nhau. Trong lớp ẩn danh, this trỏ về chính thể hiện lớp ẩn danh, nên this.getClass() cho ra lớp do trình biên dịch sinh ra, tên kiểu SapTheoDoDai$1. Trong Lambda, this trỏ về thể hiện của lớp bao ngoài, vì Lambda không tạo phạm vi mới và cũng không sinh ra một lớp mới, nên this.getClass() cho ra chính lớp bao.

Còn một điểm phải nói rõ: đoạn Lambda ở trên nằm trong main, mà mainstatic nên không hề có this nào cả. Viết this trong một Lambda đặt ở ngữ cảnh static là lỗi biên dịch, trong khi lớp ẩn danh vẫn có this của riêng nó và biên dịch bình thường. Đây chính là minh chứng rõ nhất cho luận điểm ở phần so sánh: lớp ẩn danh là một lớp thật với danh tính riêng, còn Lambda chỉ là một mẩu hành vi sống nhờ trong phạm vi bao quanh nó.

Câu hỏi tự kiểm

Kiểm tra nhanh: lớp ẩn danh0/4 đúngchưa trả lời
  1. 1Bên trong một lớp ẩn danh, từ khóa this trỏ tới đâu?
  2. 2Vì sao biến cục bộ bị lớp ẩn danh bắt phải là final hoặc thực chất không đổi?
  3. 3Khác biệt nào sau đây ĐÚNG giữa Lambda và lớp ẩn danh?
  4. 4Một lớp nội bộ không static, hoặc lớp ẩn danh tạo trong ngữ cảnh thể hiện, gây rò rỉ bộ nhớ khi nào?

Tóm tắt

  • Bốn loại lớp lồng là lớp lồng tĩnh, lớp nội bộ, lớp cục bộ và lớp ẩn danh. Chúng ngang hàng nhau, phân biệt theo hai câu hỏi: khai báo ở cấp thành viên hay trong thân phương thức, và có gắn với một thể hiện của lớp bao hay không.
  • Ba loại không static đều mang một tham chiếu ngầm tới đối tượng bao. Tham chiếu ấy cho phép đọc trường của lớp bao, nhưng cũng neo giữ đối tượng bao trong bộ nhớ, gây rò rỉ khi vòng đời hai bên lệch nhau.
  • Quy tắc mặc định: lớp lồng nên static, chỉ bỏ static khi thật sự cần trạng thái của đối tượng bao.
  • Lớp ẩn danh gộp khai báo và khởi tạo vào một biểu thức, hợp khi chỉ cần đúng một thể hiện, điển hình là bộ lắng nghe và bộ so sánh.
  • Lambda không sinh ra một lớp mới, không mở phạm vi mới, và this trong Lambda trỏ về lớp bao chứ không trỏ về chính nó. Với giao tiếp hàm, hãy mặc định dùng Lambda.
  • Lớp ẩn danh vẫn cần thiết khi giao tiếp có nhiều phương thức trừu tượng, khi phải ghi đè một lớp, hoặc khi cần this trỏ về chính đối tượng ấy.
  • Biến cục bộ được bắt phải là final hoặc thực chất không đổi, vì nó được sao chép theo giá trị; trường thể hiện thì không chịu ràng buộc này vì được truy cập qua tham chiếu ngầm.
Ghi nhớ

Lớp ẩn danh gộp khai báo và khởi tạo vào một biểu thức, hợp khi chỉ cần đúng một thể hiện. Với giao tiếp hàm, lớp ẩn danh là bậc thang dẫn vào Lambda ở chương 2.