Visitor pattern là mẫu thiết kế khó chịu nhất trong bộ GoF: để cộng giá hai loại sản phẩm, bạn phải viết interface Visitor, thêm phương thức accept vào từng lớp, rồi mỗi thao tác mới lại là một lớp visitor với đủ phương thức cho mọi loại phần tử. Từ Java 21, ba tính năng ngôn ngữ đã chốt phiên bản chính thức là sealed (Java 17), record pattern và pattern matching cho switch (cả hai ở Java 21) cho phép viết cùng bài toán ngắn hơn nhiều, mà trình biên dịch vẫn kiểm tra đủ nhánh.
Bài này cài Visitor pattern cổ điển, viết lại bằng sealed interface cùng switch, chạy thật cả hai trên JDK 21, và chỉ ra chỗ nào Visitor vẫn còn đất dùng.
Bài nối tiếp Concurrency patterns: Producer-Consumer, thread pool và CompletableFuture. Nếu bạn đã đọc DDD trong Java: Entity, Value Object và Aggregate, các record dùng ở đây chính là Value Object ở bài đó, và phần xử lý cây biểu thức bên dưới có thể xem là cách duyệt một mô hình miền bất biến.
Visitor pattern giải quyết vấn đề gì
Theo mô tả của Refactoring.Guru, Visitor pattern cho phép tách một thuật toán ra khỏi các đối tượng mà nó thao tác, nhờ vậy thêm thao tác mới mà không sửa các lớp đó. Bài toán điển hình của Visitor pattern là một cấu trúc gồm nhiều lớp khác nhau (cây tài liệu, giỏ hàng nhiều loại dòng hàng) cần được xử lý theo nhiều cách (tính giá, tính khối lượng giao hàng, xuất hoá đơn). Nếu nhét mọi thao tác vào từng lớp, các lớp miền phình ra và lẫn lộn trách nhiệm; nếu dùng chuỗi if (x instanceof ...), ta mất kiểm tra của trình biên dịch khi thêm một loại mới.
Cơ chế cốt lõi của Visitor pattern là double dispatch. Java chỉ chọn phương thức ghi đè (override) theo kiểu thực của đối tượng, còn chọn phương thức nạp chồng (overload) theo kiểu khai báo của biến lúc biên dịch. Vì vậy ta để phần tử gọi ngược visitor bằng phương thức phù hợp với chính nó: lần gửi thứ nhất chọn accept theo kiểu của phần tử, lần thứ hai chọn visitPhysical, visitDigital... theo kiểu visitor.
Cài đặt Visitor pattern cổ điển
Ví dụ nghiệp vụ cho Visitor pattern là giỏ hàng gồm ba loại dòng: hàng vật lý, hàng số (ebook) và mã giảm giá. Hai thao tác: tính tiền và tính khối lượng giao hàng. Mã trong code/s6-30/src/classic/Demo1Classic.java:
interface Visitor<R> {
R visitPhysical(Physical p);
R visitDigital(Digital d);
R visitVoucher(Voucher v);
}
interface LineItem { <R> R accept(Visitor<R> v); }
record Physical(String sku, long unitPriceVnd, int qty, double weightKg) implements LineItem {
public <R> R accept(Visitor<R> v) { return v.visitPhysical(this); }
}
record Digital(String sku, long priceVnd) implements LineItem {
public <R> R accept(Visitor<R> v) { return v.visitDigital(this); }
}
record Voucher(String code, long discountVnd) implements LineItem {
public <R> R accept(Visitor<R> v) { return v.visitVoucher(this); }
}
static class Pricing implements Visitor<Long> {
public Long visitPhysical(Physical p) { return p.unitPriceVnd() * p.qty(); }
public Long visitDigital(Digital d) { return d.priceVnd(); }
public Long visitVoucher(Voucher v) { return -v.discountVnd(); }
}Khi chạy với giỏ gồm hai áo thun (150.000 VND, 0,4 kg), một ebook 99.000 VND và mã giảm 50.000 VND:
[cổ điển] tổng = 349000 VND, khối lượng = 0.8 kg
overload với biến kiểu LineItem -> LineItem (không phải Physical)Dòng đầu: 2 × 150.000 + 99.000 − 50.000 = 349.000, đúng. Dòng thứ hai là lý do double dispatch tồn tại: với biến kiểu LineItem chứa một Physical, lời gọi describe(asItem) vẫn chọn bản nạp chồng describe(LineItem) vì Java chọn theo kiểu tĩnh. Visitor pattern vòng qua giới hạn đó bằng accept.
Ưu điểm của Visitor pattern đã rõ: thêm thao tác mới chỉ là thêm một lớp visitor, không đụng tới các record. Nhược điểm cũng rõ không kém, và là điều mà nguồn tham khảo cũng liệt kê: thêm một loại phần tử mới buộc sửa mọi visitor; và code nghi thức của Visitor pattern (accept ở mỗi lớp, interface Visitor phình ra theo số loại) che mất nghiệp vụ thật.
Sealed interface: cho trình biên dịch biết toàn bộ các loại
Gốc rễ của sự rườm rà trong Visitor pattern là Java trước đây không cho khai báo "chỉ có đúng những lớp con này". JEP 409 (chính thức ở Java 17) thêm sealed: một interface hoặc lớp có mệnh đề permits liệt kê các lớp con trực tiếp, và mỗi lớp con phải khai báo đúng một trong ba: final, sealed hoặc non-sealed. Record luôn ngầm là final nên rất hợp. JEP cũng nói rõ lợi ích: khi dùng cùng pattern matching cho switch, trình biên dịch xác nhận đã phủ hết các lớp con được phép, không cần nhánh default, và báo lỗi nếu thiếu.
sealed interface LineItem permits Physical, Digital, Voucher {}
record Physical(String sku, long unitPriceVnd, int qty, double weightKg) implements LineItem {}
record Digital(String sku, long priceVnd) implements LineItem {}
record Voucher(String code, long discountVnd) implements LineItem {}Thay Visitor pattern bằng switch pattern matching
JEP 441 chốt pattern matching cho switch ở Java 21: case nhận type pattern, có guard when, và với sealed hierarchy thì switch phải đủ nhánh. JEP 440 thêm record pattern, cho phép bóc các thành phần của record ngay trong case. Hai thao tác của Visitor pattern ban đầu giờ chỉ còn là hai hàm thường (code/s6-30/src/modern/Demo2Sealed.java):
static long price(LineItem i) {
return switch (i) {
case Physical p -> p.unitPriceVnd() * p.qty();
case Digital d -> d.priceVnd();
case Voucher v -> -v.discountVnd();
}; // không có default: compiler kiểm tra đủ nhánh
}
static double weight(LineItem i) {
return switch (i) {
case Physical(var sku, var price, var qty, var kg) -> kg * qty; // record pattern
case Digital d -> 0.0;
case Voucher v -> 0.0;
};
}
// Thao tác MỚI: chỉ thêm một hàm, không động tới các record
static String describe(LineItem i) {
return switch (i) {
case Physical(var sku, var price, var qty, var kg) when qty >= 10 -> "sỉ: " + sku + " x" + qty;
case Physical(var sku, var price, var qty, var kg) -> "lẻ: " + sku + " x" + qty;
case Digital(var sku, var price) -> "số: " + sku;
case Voucher(var code, var off) -> "mã giảm: " + code;
};
}Chạy với giỏ có thêm 12 cốc sứ (40.000 VND, 0,3 kg):
[sealed] tổng = 829000 VND, khối lượng = 4.4 kg
lẻ: AO-THUN x2
số: EBOOK-JAVA
mã giảm: GIAM50K
sỉ: COC-SU x12Kiểm tra tay: 300.000 + 99.000 − 50.000 + 480.000 = 829.000 VND; khối lượng 0,8 + 3,6 = 4,4 kg. So với Visitor pattern cổ điển, ta bỏ được interface Visitor, mọi phương thức accept và lớp visitor; mỗi thao tác nằm gọn trong một hàm mà switch đọc từ trên xuống. Guard when qty >= 10 là thứ Visitor cổ điển không có sẵn: phải lồng if trong visitPhysical. Thứ tự case cũng có ý nghĩa: JEP 441 nêu rõ nhãn đầu tiên khớp thì thắng, và một nhãn bị nhãn trước "che" hoàn toàn là lỗi biên dịch, vì vậy case có guard phải đặt trước case cùng kiểu không guard.
Record pattern lồng nhau: nơi Visitor pattern cổ điển thua rõ nhất
Với cấu trúc đệ quy, record pattern cho khớp mẫu sâu nhiều tầng trong một nhãn. Ví dụ cây biểu thức giá khuyến mãi gồm hằng, cộng, nhân, phần trăm và phủ định (code/s6-30/src/modern/Demo3Nested.java):
sealed interface Expr permits Const, Add, Mul, Percent, Neg {}
record Const(long v) implements Expr {}
record Add(Expr l, Expr r) implements Expr {}
record Mul(Expr l, Expr r) implements Expr {}
record Percent(Expr base, int pct) implements Expr {}
record Neg(Expr e) implements Expr {}
static long eval(Expr e) {
return switch (e) {
case Const(var v) -> v;
case Add(var l, var r) -> eval(l) + eval(r);
case Mul(var l, var r) -> eval(l) * eval(r);
case Percent(var b, var p) -> eval(b) * p / 100;
case Neg(var x) -> -eval(x);
};
}
static Expr simplify(Expr e) {
return switch (e) {
case Neg(Neg(var x)) -> simplify(x); // lồng 2 tầng
case Mul(Const(var v), var r) when v == 0 -> new Const(0);
case Mul(var l, Const(var v)) when v == 1 -> simplify(l);
case Add(var l, var r) -> add(simplify(l), simplify(r));
case Mul(var l, var r) -> new Mul(simplify(l), simplify(r));
case Percent(var b, var p) -> new Percent(simplify(b), p);
case Neg(var x) -> new Neg(simplify(x));
case Const c -> c;
};
}biểu thức : ((0 * 999) + (--2000000 * 15% + 0))
giá trị : 300000 VND
đơn giản : 2000000 * 15%
giá trị : 300000 VND (phải bằng nhau)Hai chi tiết đáng nhìn. Thứ nhất, Neg(Neg(var x)) và Mul(Const(var v), var r) khớp cấu trúc lồng nhau trong một dòng; viết bằng Visitor pattern, bạn phải trong visitNeg kiểm tra loại của phần tử con rồi ép kiểu, hoặc dựng visitor lồng. Thứ hai, phép đơn giản hoá bảo toàn giá trị (cùng 300.000 VND trước và sau), kiểu kiểm tra mà bài test nên viết khi biến đổi cây. Lưu ý null không khớp với bất kỳ record pattern nào theo JEP 440, nên cây phải được dựng không có null; record với thành phần có thể null cần xử lý riêng.
Sealed và biên dịch tách rời: trình biên dịch bắt lỗi, rồi runtime làm nốt
Với sealed, lời hứa "thêm loại mới thì compiler báo mọi chỗ thiếu" có hai tầng. Tầng biên dịch, thêm Triangle rồi biên dịch lại một hàm không có nhánh tương ứng:
tmp2/AreaMissing.java:3: error: the switch expression does not cover all possible input values
return switch (s) {
^
1 errorĐó là đúng hành vi mong muốn. Tầng thứ hai tinh tế hơn: nếu bạn thêm lớp con vào sealed interface nhưng không biên dịch lại lớp chứa switch (ví dụ nằm trong một thư viện đóng gói sẵn), file .class cũ vẫn chạy được. JEP 441 mô tả việc trình biên dịch chèn nhánh mặc định ném lỗi cho trường hợp này. Thư mục code/s6-30/sep có sẵn các file nguồn; Area.java chứa switch trên Shape chỉ với hai nhánh Circle và Square. Các bước (run.sh làm đúng như vậy, thư mục tmp là nơi ghép file):
# 1. Bản v1: Shape permits Circle, Square. Biên dịch tất cả vào out2 rồi chạy
javac -d out2 tmp/*.java && java -cp out2 Area
# 2. Bản v2: thêm Triangle. CHỈ biên dịch lại Shape, Triangle, Factory
# (Area.class cũ vẫn nằm nguyên trong out2, không được biên dịch lại)
javac -d out2 -cp out2 tmp/Shape.java tmp/Triangle.java tmp/Factory.java
java -cp out2 Area x # tham số x khiến Factory trả về TriangleKết quả của hai lần chạy:
== Thêm Triangle vào sealed interface, Area.class không biên dịch lại
diện tích = 4.0
Exception in thread "main" java.lang.MatchException
at Area.area(Area.java:3)
at Area.main(Area.java:9)Lần chạy đầu (Square) cho ra 4,0. Sau khi thêm Triangle và chỉ biên dịch lại Shape, Triangle và Factory, đưa một Triangle vào hàm area cũ ném MatchException chứ không im lặng trả kết quả sai. Hệ quả thực tế cho người viết thư viện: thêm lớp con vào sealed hierarchy là thay đổi phá vỡ tương thích ở mức nguồn và có thể gây lỗi runtime ở nơi dùng đã biên dịch trước, nên với API công khai phải coi đó là thay đổi phiên bản lớn. Đây là cái giá cùng nguồn với lợi ích: Visitor pattern cũng vỡ khi thêm loại phần tử, chỉ khác là vỡ ngay lúc biên dịch với lỗi "thiếu phương thức".
So sánh Visitor pattern với sealed và switch
| Tiêu chí | Visitor pattern cổ điển | Sealed + switch |
|---|---|---|
| Thêm thao tác mới | Thêm một lớp visitor, không sửa phần tử | Thêm một hàm, không sửa phần tử |
| Thêm loại phần tử mới | Sửa interface và mọi visitor | Compiler chỉ ra mọi switch thiếu nhánh |
| Lượng code nghi thức | Nhiều: accept, interface, lớp visitor | Gần như không |
| Kiểu trả về khác nhau mỗi thao tác | Generic Visitor<R> | Mỗi hàm tự khai báo |
| Hierarchy mở, do bên ngoài mở rộng | Hợp: phần tử tự chọn nhánh qua accept | Không hợp: sealed cần biết trước mọi lớp con |
| Trạng thái tích luỹ khi duyệt | Visitor là đối tượng có trường | Phải truyền tham số hoặc dùng đối tượng riêng |
| Khớp mẫu sâu nhiều tầng | Dài dòng | Một nhãn case |
Kết luận thực dụng về Visitor pattern: nếu tập loại phần tử đóng và bạn kiểm soát được nó (giỏ hàng, cây biểu thức, kết quả thanh toán), sealed cùng switch thường là lựa chọn đầu tiên. Còn nếu hierarchy cần mở cho plugin hoặc mô-đun khác, hoặc visitor cần mang trạng thái phức tạp khi duyệt một cấu trúc lớn, Visitor pattern vẫn có chỗ đứng. Ngay JEP 441 cũng không tuyên bố thay thế hẳn: phần "future work" chỉ nói deconstruction pattern cho lớp thường có thể giúp viết logic mà không cần visitor rườm rà, tức hiện tại giải pháp này mạnh nhất với record và sealed type.
Khi nào không nên dùng Visitor pattern
- Không dùng Visitor pattern khi tập phần tử còn thay đổi thường xuyên: mỗi loại mới kéo theo sửa mọi visitor, và nếu chỉ có một hai thao tác thì phương thức ngay trong lớp (đa hình thường) đơn giản hơn.
- Không dùng sealed cho hierarchy mà code bên ngoài cần mở rộng; hãy dùng interface thường hoặc
non-sealedở nhánh cần mở. - Không đặt nhánh
defaulttrongswitchtrên sealed type chỉ để "cho yên": JEP 441 cảnh báodefaultche mất các loại mới, đánh mất lợi ích kiểm tra đủ nhánh. - Không bóc quá sâu cùng lúc: record pattern bốn năm tầng khó đọc hơn một hàm phụ có tên rõ ràng.
- Đừng nhồi logic nghiệp vụ lớn vào một
switch: mỗi nhánh nên gọi hàm có tên;switchchỉ là bộ điều hướng.
Lỗi thường gặp
- Thứ tự case sai: case tổng quát đặt trước case có guard làm nhánh sau không bao giờ tới, và compiler báo lỗi dominance.
- Quên rằng pattern không khớp
null:switchtrên giá trịnullnémNullPointerExceptiontrừ khi cócase null. - Thêm lớp con vào sealed interface của thư viện đã phát hành: nơi dùng đã biên dịch có thể ném
MatchExceptionnhư đã chạy ở trên. - Dùng record chứa collection khả biến rồi
switchbóc ra sửa: record pattern chỉ đọc, nhưng collection bên trong vẫn có thể đổi ngoài tầm kiểm soát; hãy sao chép phòng thủ trong constructor. - Nhầm Visitor pattern với Strategy: Strategy thay thuật toán cho một đối tượng, Visitor pattern áp một thuật toán lên cả cấu trúc nhiều loại phần tử.
- Mong chờ switch kiểm tra đủ nhánh với lớp thường: chỉ sealed (và enum) mới có bảo đảm đó; với hierarchy mở,
switchmẫu luôn cầndefault.
Checklist
- Tập loại phần tử đóng và nằm trong tầm kiểm soát: dùng
sealed interfacecùng record. - Mỗi thao tác là một hàm
switchkhông códefault, để compiler bắt thiếu nhánh. - Dùng record pattern để bóc dữ liệu, guard
whencho điều kiện, và giữ nhãn đặc thù trước nhãn tổng quát. - Hierarchy mở hoặc cần visitor có trạng thái: giữ Visitor pattern cổ điển.
- Coi việc thêm lớp con vào sealed API công khai là thay đổi phá vỡ tương thích; biên dịch lại mọi nơi dùng.
- Viết test cho phép biến đổi (như
simplify) kiểm tra giá trị trước và sau bằng nhau.
Tóm lại, Visitor pattern giải một bài toán thật (thêm thao tác mà không sửa phần tử) bằng cơ chế double dispatch mà ngôn ngữ trước đây buộc ta dựng bằng tay. Với sealed và pattern matching ở Java 21, phần lớn nghi thức đó biến mất mà vẫn giữ được kiểm tra đủ nhánh lúc biên dịch. Toàn bộ mã nằm trong code/s6-30, chạy bằng JDK 21 (run.sh; số bản vá cụ thể nằm trong run.sh).
Bài tiếp theo
Với Java 21, bước đột phá không chỉ ở cú pháp mà còn ở mô hình luồng. Mời bạn đọc tiếp Virtual threads trong Java 21: khi nào thay thread pool.



