visitor pattern

- Published on
- /10 mins read/
Trong cuốn sách kinh điển của nhóm Gang of Four (GoF), Visitor Pattern luôn được đánh giá là một trong những mẫu thiết kế khó hiểu và tinh tế nhất. Nó thường xuất hiện trong các bài toán phức tạp như: trình biên dịch (Compilers), bộ phân tích cú pháp truy vấn (SQL/GraphQL Query Parsers), hoặc các engine xuất bản tài liệu đa định dạng (PDF/HTML/Markdown Exporter).
Mục tiêu cốt lõi của Visitor Pattern là: Tách biệt thuật toán xử lý ra khỏi cấu trúc dữ liệu của đối tượng, cho phép bạn thêm các hành vi mới mà không cần chỉnh sửa bất kỳ class phần tử nào.
Tuy nhiên, câu hỏi lớn nhất mà các kỹ sư thường thắc mắc là: Tại sao phải dùng một phương thức vòng vo element.accept(visitor) rồi lại gọi ngược visitor.visit(this)? Tại sao không gọi thẳng visitor.visit(element)?
Bài viết này sẽ giải mã cơ chế Double Dispatch dưới góc nhìn của máy ảo JVM, xây dựng một AST Query Engine thực chiến, và khám phá xem Pattern Matching for Switch (Java 21+) đã thay đổi toàn bộ cuộc chơi ra sao.
# bí mật đằng sau Double Dispatch: giới hạn của Single Dispatch trong Java
Java là một ngôn ngữ Single Dispatch (Đơn điều phối). Điều này có nghĩa là khi bạn gọi một phương thức, việc lựa chọn phiên bản mã nào được thực thi phụ thuộc vào:
- Dynamic Type (Kiểu động lúc Runtime) của đối tượng gọi hàm (
receiver). - Static Type (Kiểu tĩnh lúc Compile-time) của các tham số truyền vào hàm (
arguments).
# tại sao không thể gọi thẳng Visitor.visit(element)?
Hãy xem đoạn code sau:
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)'!Dù lúc chạy biến item trỏ tới một instance Book, trình biên dịch Java chỉ biết kiểu tĩnh của nó là Element. Do TaxVisitor không có hàm visit(Element), code bị từ chối ngay lúc compile.
# giải pháp Double Dispatch của GoF
Để vượt qua rào cản này, GoF thực hiện hai lần điều phối (Double Dispatch):
- Lần Dispatch 1 (Dynamic): Client gọi
element.accept(visitor). Tính đa hình (polymorphism) của Java sẽ tìm đúng class cụ thể lúc runtime (ví dụ:Book). - Lần Dispatch 2 (Static to Specific): Bên trong method
accept()của classBook, con trỏthiscó kiểu tường minh làBook. Khi gọivisitor.visit(this), compiler biết chính xác 100% cần map vào hàmvisit(Book).
# nghịch lý mở rộng của GoF Visitor (the extensibility dilemma)
Visitor Pattern tuân thủ hoàn hảo nguyên lý Open/Closed Principle (OCP) khi thêm mới hành vi, nhưng lại vi phạm OCP nghiêm trọng khi thêm mới dữ liệu:
WARNING
Quy Tắc Kiến Trúc: Chỉ áp dụng GoF Visitor Pattern khi cấu trúc phân cấp lớp dữ liệu đã ổn định và hiếm khi thay đổi, nhưng các phép toán trên cấu trúc đó liên tục phát sinh theo thời gian.
# case study thực tế: AST query engine cho search engine
Trong một search engine doanh nghiệp, câu truy vấn của người dùng được parse thành một cây cú pháp trừu tượng (Abstract Syntax Tree - AST). Chúng ta cần hỗ trợ chuyển đổi cây này thành nhiều dạng:
- SQL Query (WHERE clause).
- Elasticsearch Query DSL (JSON).
- Lucene In-Memory Filter.
# cấu trúc AST elements
// 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 định nghĩa các phép duyệt
public interface QueryVisitor<R> {
R visit(TermNode node);
R visit(AndNode node);
R visit(OrNode node);
}# concrete Visitor: biên 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: biên 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 + ")";
}
}# cuộc cách mạng Java 21+: pattern matching thay thế GoF Visitor
Với sự ra đời của Sealed Interfaces (Java 17) và Pattern Matching for switch (Java 21 - JEP 441), liệu chúng ta có còn cần sự cồng kềnh của Visitor Pattern?
Câu trả lời của phần lớn kiến trúc sư hiện đại là: Không! Pattern Matching trên Sealed Hierarchies đã biến GoF Visitor thành di sản.
# tái cấu trúc cùng một bài toán với Java 21
# khai báo sealed interface thuần khiết (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 {}# thuật toán biên dịch SQL được viết thẳng mà không cần bất kỳ 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 ")) + ")";
};
}
}# tại sao pattern matching Java 21 vượt trội hơn GoF Visitor?
- Zero Boilerplate: Không cần khai báo interface
Visitor, không cần viết methodaccept()lặp đi lặp lại ở mọi class con. - Bảo toàn tính đóng gói: Các class Model không bị ô nhiễm bởi các phương thức hỗ trợ hạ tầng.
- Exhaustiveness Guarantee: Trình biên dịch Java đảm bảo kiểm tra đầy đủ mọi loại node. Nếu sau này bạn thêm
NotNodevào sealed interface, mọi lệnhswitchtrong codebase chưa xử lýNotNodesẽ lập tức báo lỗi biên dịch, loại trừ 100% bug runtime. - Hiệu năng cao hơn: Không có chi phí gọi gián tiếp (indirection method calls) của Double Dispatch, mã nguồn được JIT compiler tối ưu hóa inline dễ dàng hơn.
# ma trận so sánh kiến trúc
| Tiêu chí so sánh | GoF Visitor Pattern (Java 8/11) | Pattern Matching on Sealed Types (Java 21+) |
|---|---|---|
| Cơ chế cốt lõi | Double Dispatch (accept + visit) | Type Pattern Matching with Switch Exhaustiveness |
| Độ ô nhiễm Entity | Cao (buộc mọi entity phải có accept) | Không có (POJO / Record thuần túy) |
| Mở rộng thao tác mới | Tạo class Visitor mới (OCP) | Viết thêm hàm switch mới (OCP) |
| Thêm loại Node mới | Phá vỡ toàn bộ các Visitor hiện có | Thêm nhánh case; compiler chỉ điểm chính xác vị trí cần cập nhật |
| Độ phức tạp nhận thức | Khó hiểu với lập trình viên mới | Trực quan, dễ đọc, mang phong cách Functional Programming |
| Phiên bản Java hỗ trợ | Mọi phiên bản Java | Java 21 trở lên (LTS) |
# tổng kết
Visitor Pattern là một tuyệt tác tư duy của GoF nhằm khắc phục giới hạn Single Dispatch của các ngôn ngữ hướng đối tượng thế hệ trước. Nó đã hoàn thành xuất sắc vai trò lịch sử trong các bộ parser, AST engine và enterprise reporting.
Tuy nhiên, với các dự án hiện đại chạy trên nền tảng Java 21+, việc kết hợp Sealed Interfaces, Records và Pattern Matching for switch là sự thay thế hoàn hảo: mang lại sự an toàn kiểu tuyệt đối lúc biên dịch, cú pháp biểu cảm cao và loại bỏ hoàn toàn mã 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 😎 👍🏻 🚀 🔥.
On this page
- # bí mật đằng sau Double Dispatch: giới hạn của Single Dispatch trong Java
- # tại sao không thể gọi thẳng Visitor.visit(element)?
- # giải pháp Double Dispatch của GoF
- # nghịch lý mở rộng của GoF Visitor (the extensibility dilemma)
- # case study thực tế: AST query engine cho search engine
- # cấu trúc AST elements
- # Visitor interface định nghĩa các phép duyệt
- # concrete Visitor: biên dịch sang Elasticsearch query dsl
- # concrete Visitor: biên dịch sang SQL where clause
- # cuộc cách mạng Java 21+: pattern matching thay thế GoF Visitor
- # tái cấu trúc cùng một bài toán với Java 21
- # khai báo sealed interface thuần khiết (không cần method accept)
- # thuật toán biên dịch SQL được viết thẳng mà không cần bất kỳ class Visitor nào
- # tại sao pattern matching Java 21 vượt trội hơn GoF Visitor?
- # ma trận so sánh kiến trúc
- # tổng kết