visitor pattern
Tung Bui Thanh (TungDaDev) (updated 6 months ago)10 mins read

Trong cuốn sách kinh điển của Gang of Four (GoF), Visitor Pattern thuộc hàng khó hiểu và "lắt léo" nhất. Nó hay xuất hiện ở mấy bài toán phức tạp như compiler, bộ parse truy vấn (SQL/GraphQL Query Parsers), hay engine xuất tài liệu ra nhiều định dạng (PDF/HTML/Markdown Exporter).
Mục tiêu của Visitor Pattern: tách thuật toán xử lý ra khỏi cấu trúc dữ liệu, để thêm hành vi mới mà không phải sửa class phần tử nào.
Có điều câu mà ai cũng thắc mắc là: Sao phải vòng vo gọi element.accept(visitor) rồi bên trong lại gọi ngược visitor.visit(this)? Sao không gọi thẳng visitor.visit(element) cho rồi?
Bài này giải thích cơ chế Double Dispatch từ góc nhìn của JVM, dựng một AST Query Engine ví dụ, rồi xem Pattern Matching for Switch (Java 21+) đã thay đổi chuyện này thế nào.
# đằng sau Double Dispatch: Java chỉ có Single Dispatch
Java là ngôn ngữ Single Dispatch. Nghĩa là khi bạn gọi một method, việc chọn chạy phiên bản code nào phụ thuộc vào:
- Dynamic Type (kiểu lúc runtime) của object gọi hàm (
receiver). - Static Type (kiểu lúc compile) của các tham số truyền vào (
arguments).
# sao không gọi thẳng Visitor.visit(element) được?
Xem đoạn code này:
public interface Element {}
public class Book implements Element {}
public class Medicine implements Element {}
public class TaxVisitor {
public void visit(Book b) { /* tính thuế sách */ }
public void visit(Medicine m) { /* tính thuế thuốc */ }
}
// Client code:
Element item = new Book();
TaxVisitor visitor = new TaxVisitor();
visitor.visit(item); // 🔴 BIÊN DỊCH LỖI: Cannot resolve method 'visit(Element)'!Lúc chạy thì item đúng là trỏ tới một instance Book, nhưng compiler chỉ biết kiểu tĩnh của nó là Element. TaxVisitor lại không có hàm visit(Element), nên code bị từ chối ngay lúc compile.
# cách GoF giải: Double Dispatch
Để lách chỗ này, GoF cho điều phối hai lần (Double Dispatch):
- Dispatch lần 1 (Dynamic): client gọi
element.accept(visitor). Nhờ đa hình (polymorphism), Java tìm đúng class cụ thể lúc runtime (ví dụBook). - Dispatch lần 2 (Static to Specific): bên trong
accept()của classBook,thiscó kiểu rõ ràng làBook. Gọivisitor.visit(this)là compiler biết chắc phải map vàovisit(Book).
# cái khó khi mở rộng GoF Visitor (the extensibility dilemma)
Visitor Pattern tuân thủ Open/Closed Principle (OCP) rất tốt khi thêm hành vi mới, nhưng lại phá OCP khá nặng khi thêm loại dữ liệu mới. Giới lý thuyết ngôn ngữ gọi thế giằng co này là expression problem: OOP thông thường dễ thêm kiểu mới mà khó thêm phép toán mới, Visitor thì đảo ngược lại.
WARNING
Nguyên tắc: Chỉ dùng GoF Visitor Pattern khi cây class dữ liệu đã ổn định, ít khi đổi, còn các phép xử lý trên nó thì cứ phát sinh thêm theo thời gian.
# ví dụ thực tế: AST query engine cho search engine
Trong một search engine nội bộ, câu truy vấn của người dùng được parse thành cây cú pháp trừu tượng (Abstract Syntax Tree - AST). Mình cần chuyển cây này sang nhiều dạng:
- SQL Query (WHERE clause).
- Elasticsearch Query DSL (JSON).
- Lucene In-Memory Filter.
# các element của AST
// Base Node Interface
public interface QueryNode {
<R> R accept(QueryVisitor<R> visitor);
}
// 1. Term Node: key = "value"
public record TermNode(String field, Object value) implements QueryNode {
@Override
public <R> R accept(QueryVisitor<R> visitor) {
return visitor.visit(this);
}
}
// 2. Logical AND Node
public record AndNode(List<QueryNode> children) implements QueryNode {
@Override
public <R> R accept(QueryVisitor<R> visitor) {
return visitor.visit(this);
}
}
// 3. Logical OR Node
public record OrNode(List<QueryNode> children) implements QueryNode {
@Override
public <R> R accept(QueryVisitor<R> visitor) {
return visitor.visit(this);
}
}# Visitor interface khai báo các phép duyệt
public interface QueryVisitor<R> {
R visit(TermNode node);
R visit(AndNode node);
R visit(OrNode node);
}# concrete Visitor: dịch sang Elasticsearch query dsl
public class ElasticsearchQueryVisitor implements QueryVisitor<Map<String, Object>> {
@Override
public Map<String, Object> visit(TermNode node) {
return Map.of("term", Map.of(node.field(), node.value()));
}
@Override
public Map<String, Object> visit(AndNode node) {
List<Map<String, Object>> mustClauses = node.children().stream()
.map(child -> child.accept(this))
.toList();
return Map.of("bool", Map.of("must", mustClauses));
}
@Override
public Map<String, Object> visit(OrNode node) {
List<Map<String, Object>> shouldClauses = node.children().stream()
.map(child -> child.accept(this))
.toList();
return Map.of("bool", Map.of("should", shouldClauses));
}
}# concrete Visitor: dịch sang SQL where clause
public class SqlWhereClauseVisitor implements QueryVisitor<String> {
@Override
public String visit(TermNode node) {
String valStr = node.value() instanceof String ? "'" + node.value() + "'" : node.value().toString();
return String.format("%s = %s", node.field(), valStr);
}
@Override
public String visit(AndNode node) {
String inner = node.children().stream()
.map(child -> child.accept(this))
.collect(Collectors.joining(" AND "));
return "(" + inner + ")";
}
@Override
public String visit(OrNode node) {
String inner = node.children().stream()
.map(child -> child.accept(this))
.collect(Collectors.joining(" OR "));
return "(" + inner + ")";
}
}Chạy thử SqlWhereClauseVisitor với TermNode("name", "O'Brien") sẽ ra name = 'O'Brien', câu SQL hỏng ngay. Tệ hơn, nếu giá trị đến từ người dùng thì đây là cửa cho SQL injection. Code ở đây chỉ để minh hoạ Visitor; bản thật nên để visitor sinh câu có placeholder (name = ?) kèm danh sách tham số, và chỉ cho phép tên cột nằm trong một whitelist.
# Java 21+: pattern matching thay cho GoF Visitor
Từ khi có Sealed Interfaces (Java 17, JEP 409), Pattern Matching for switch (Java 21, JEP 441) và Record Patterns (Java 21, JEP 440, cho phép bóc tách record ngay trong case), mình còn cần tới Visitor Pattern cồng kềnh nữa không?
Với cây kiểu dữ liệu do chính mình định nghĩa và đóng kín được bằng sealed, câu trả lời của mình là phần lớn trường hợp thì không. Pattern matching làm được cùng việc mà gọn hơn hẳn.
# viết lại cùng bài toán với Java 21
# khai báo sealed interface gọn gàng (không cần method accept)
public sealed interface QueryNode permits TermNode, AndNode, OrNode {}
public record TermNode(String field, Object value) implements QueryNode {}
public record AndNode(List<QueryNode> children) implements QueryNode {}
public record OrNode(List<QueryNode> children) implements QueryNode {}# viết thẳng thuật toán dịch SQL, không cần class Visitor nào
public class ModernSqlCompiler {
public static String toSql(QueryNode node) {
// Compiler tự động kiểm tra exhaustive: nếu thiếu 1 case trong sealed interface -> BÁO LỖI BIÊN DỊCH!
return switch (node) {
case TermNode(String field, Object val) -> {
String formatted = val instanceof String ? "'" + val + "'" : val.toString();
yield field + " = " + formatted;
}
case AndNode(List<QueryNode> children) -> "(" + children.stream()
.map(ModernSqlCompiler::toSql)
.collect(Collectors.joining(" AND ")) + ")";
case OrNode(List<QueryNode> children) -> "(" + children.stream()
.map(ModernSqlCompiler::toSql)
.collect(Collectors.joining(" OR ")) + ")";
};
}
}# vì sao pattern matching Java 21 hơn GoF Visitor?
- Hết boilerplate: không cần khai báo interface
Visitor, không phải viết đi viết lạiaccept()ở mọi class con. - Model sạch sẽ: class model không bị nhét thêm mấy method chỉ để phục vụ hạ tầng.
- Compiler kiểm tra đủ case (exhaustiveness): sau này thêm
NotNodevào sealed interface thì mọiswitchchưa xử lýNotNodetrong codebase sẽ báo lỗi compile ngay, khỏi lo sót case thành bug lúc runtime. Điều kiện là switch không có nhánhdefault; lỡ thêmdefaultcho "chắc ăn" là tự tắt luôn cái lưới an toàn này. - Hiệu năng: không phải lý do để chọn bên nào. Double dispatch chỉ là hai lời gọi virtual mà JIT xử lý rất tốt, còn switch trên sealed type cũng biên dịch thành chuỗi kiểm tra kiểu qua
invokedynamic. Muốn biết chắc thì đo bằng JMH.
Visitor vẫn còn chỗ đứng. Khi cây kiểu không đóng kín được (plugin hay module khác được thêm node), khi codebase chưa lên Java 21, hoặc khi bạn cần một lớp "duyệt mặc định" để các visitor con chỉ override vài node (kiểu TreeScanner trong javac hay visitor sinh tự động của ANTLR), thì Visitor vẫn là lựa chọn hợp lý.
# bảng so sánh
| Tiêu chí | GoF Visitor Pattern (Java 8/11) | Pattern Matching on Sealed Types (Java 21+) |
|---|---|---|
| Cơ chế | Double Dispatch (accept + visit) | Type Pattern Matching with Switch Exhaustiveness |
| Entity bị "dính" code | Nhiều (entity nào cũng phải có accept) | Không (POJO / Record sạch) |
| Thêm thao tác mới | Tạo class Visitor mới (OCP) | Viết thêm một hàm switch mới (OCP) |
| Thêm loại Node mới | Sửa interface và mọi Visitor hiện có | Thêm nhánh case, compiler chỉ đúng chỗ cần sửa |
| Độ khó hiểu | Người mới đọc khá đau đầu | Dễ đọc, mang hơi hướng functional programming |
| Phiên bản Java | Mọi phiên bản Java | Java 21 trở lên (LTS) |
# tổng kết
Visitor Pattern là một lời giải rất khéo của GoF cho giới hạn Single Dispatch của các ngôn ngữ hướng đối tượng. Nó đã làm tốt vai trò của mình trong parser, AST engine và hệ thống báo cáo doanh nghiệp.
Còn với dự án mới chạy Java 21+ mà cây kiểu đóng kín được, kết hợp Sealed Interfaces, Records và Pattern Matching for switch là đủ thay thế: type-safe lúc compile, code dễ đọc hơn, lại bỏ được cả đống code thừa của Double Dispatch.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.