TungDaDev's Blog

visitor pattern

Visitor pattern.webp
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:

  1. Dynamic Type (Kiểu động lúc Runtime) của đối tượng gọi hàm (receiver).
  2. 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):

  1. 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).
  2. Lần Dispatch 2 (Static to Specific): Bên trong method accept() của class Book, con trỏ this có kiểu tường minh là Book. Khi gọi visitor.visit(this), compiler biết chính xác 100% cần map vào hàm visit(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:

  1. SQL Query (WHERE clause).
  2. Elasticsearch Query DSL (JSON).
  3. 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?

  1. Zero Boilerplate: Không cần khai báo interface Visitor, không cần viết method accept() lặp đi lặp lại ở mọi class con.
  2. 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.
  3. 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 NotNode vào sealed interface, mọi lệnh switch trong codebase chưa xử lý NotNode sẽ lập tức báo lỗi biên dịch, loại trừ 100% bug runtime.
  4. 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ánhGoF Visitor Pattern (Java 8/11)Pattern Matching on Sealed Types (Java 21+)
Cơ chế cốt lõiDouble Dispatch (accept + visit)Type Pattern Matching with Switch Exhaustiveness
Độ ô nhiễm EntityCao (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ớiTạo class Visitor mới (OCP)Viết thêm hàm switch mới (OCP)
Thêm loại Node mớiPhá 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ứcKhó hiểu với lập trình viên mớiTrực quan, dễ đọc, mang phong cách Functional Programming
Phiên bản Java hỗ trợMọi phiên bản JavaJava 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 😎 👍🏻 🚀 🔥.

← Previous postiterator pattern
Next post →cqrs pattern