Giỏ hàng của một cửa hàng online có sản phẩm lẻ, có combo, và có combo chứa combo khác. Thư mục trên máy chủ có file và thư mục con. Menu có mục và menu con. Cả ba đều là cây, và cả ba đều gặp cùng một câu hỏi: làm sao để code tính tổng tiền, tổng dung lượng hay in cây mà không phải viết if (x instanceof ...) ở mọi nơi? Composite pattern trả lời bằng một ý đơn giản: cho nút lá và nút chứa cùng một interface, để người gọi không cần biết mình đang cầm một món hay cả một nhóm. Bài này cài Composite pattern bằng Java 17 (sealed interface và record), chạy thật, rồi bàn kỹ phần dễ bị bỏ qua: nên đặt phương thức quản lý con ở đâu, và khi nào cây đối tượng không đáng làm Composite.

Bài trước trong series nói về Facade pattern: một cửa vào cho hệ thống phức tạp, nơi ta giấu nhiều lớp sau một mặt tiền. Composite pattern làm việc ngược lại về hướng: thay vì giấu, nó làm cho nhiều đối tượng trông như một. Nếu bạn chưa đọc phần nền tảng, hãy xem Nguyên lý SOLID trong Java qua ví dụ thực tế, vì cả lợi ích lẫn cái giá của Composite pattern đều gắn với Open/Closed và Interface Segregation.

Vấn đề mà Composite pattern giải quyết: cây đối tượng và những câu lệnh kiểm tra kiểu

Giả sử cửa hàng bán sản phẩm lẻ (Product) và combo (Bundle). Combo giảm giá theo phần trăm và có thể chứa sản phẩm lẻ hoặc combo khác. Cách làm ngây thơ là để Bundle giữ hai danh sách riêng: một cho sản phẩm, một cho combo con. Khi tính tiền, ta viết hai vòng lặp. Khi thêm cân nặng, ta lại viết hai vòng lặp nữa. Khi thêm loại thứ ba, ví dụ "quà tặng kèm", mọi nơi duyệt cây đều phải sửa.

Gốc của vấn đề là người gọi bị buộc phải biết kiểu cụ thể của từng nút. Mỗi thao tác lên cây (tính tiền, in, tìm kiếm, kiểm tra tồn kho) thành một hàm có instanceof ở bên trong. Composite pattern chuyển trách nhiệm đó vào chính các nút: mỗi nút tự biết cách trả lời, nút chứa trả lời bằng cách hỏi con rồi gộp kết quả.

Cấu trúc của Composite pattern

Theo mô tả trên refactoring.guru (cũng là cách sách Design Patterns của Gang of Four trình bày), Composite pattern có bốn vai trò:

Vai tròNhiệm vụTrong ví dụ giỏ hàng
ComponentInterface chung cho cả nút lá và nút chứaItem
LeafNút không có con, thường làm việc thậtProduct
CompositeGiữ danh sách con, uỷ quyền rồi gộp kết quảBundle
ClientChỉ làm việc qua ComponentHàm describe, code thanh toán

Điểm mấu chốt của Composite pattern nằm ở câu "Composite chỉ nói chuyện với con qua Component". Nhờ vậy cây sâu bao nhiêu tầng cũng được, và thêm loại nút mới không làm hỏng code đang duyệt cây, đúng tinh thần Open/Closed.

Cài đặt Composite pattern bằng sealed interface và record

Từ Java 17, sealed interface cho phép khai báo danh sách đóng các lớp con, còn record cho nút lá bất biến chỉ trong một dòng khai báo. Hai tính năng này rất hợp với Composite pattern vì tập kiểu nút thường là hữu hạn và biết trước. Toàn bộ ví dụ nằm trong một file, biên dịch với tuỳ chọn --release 17 để đảm bảo chỉ dùng API của Java 17:

    sealed interface Item permits Product, Bundle {
        String name();
        long price();            // VND
        double weightKg();
        Stream<Item> flatten();  // duyệt toàn cây
        void print(StringBuilder sb, int depth);
    }

    record Product(String name, long price, double weightKg) implements Item {
        public Stream<Item> flatten() { return Stream.of(this); }
        public void print(StringBuilder sb, int depth) {
            sb.append("  ".repeat(depth)).append("- ").append(name)
              .append(" : ").append(price).append(" đ\n");
        }
    }

    /** Composite: giữ danh sách con, chỉ nói chuyện với con qua interface Item. */
    static final class Bundle implements Item {
        private final String name;
        private final int discountPercent;
        private final List<Item> children = new ArrayList<>();

        Bundle(String name, int discountPercent) {
            this.name = name;
            this.discountPercent = discountPercent;
        }
        Bundle add(Item item) {
            if (item == this) throw new IllegalArgumentException("Combo không thể chứa chính nó");
            children.add(item);
            return this;
        }
        public String name() { return name; }
        public long price() {
            long sum = children.stream().mapToLong(Item::price).sum();
            return sum * (100 - discountPercent) / 100;
        }
        public double weightKg() { return children.stream().mapToDouble(Item::weightKg).sum(); }
        public Stream<Item> flatten() {
            return Stream.concat(Stream.of(this), children.stream().flatMap(Item::flatten));
        }
        public void print(StringBuilder sb, int depth) {
            sb.append("  ".repeat(depth)).append("+ ").append(name)
              .append(" (giảm ").append(discountPercent).append("%) : ").append(price()).append(" đ\n");
            children.forEach(c -> c.print(sb, depth + 1));
        }
    }

Có ba quyết định thiết kế đáng nhìn kỹ. Thứ nhất, Bundle.price() không hề kiểm tra kiểu của con: nó gọi Item::price và để đa hình lo phần còn lại. Thứ hai, thao tác duyệt cây được tách thành flatten() trả về Stream<Item>, nên đếm nút, lọc sản phẩm, tìm theo tên đều là một dòng stream. Thứ ba, add chỉ có trên Bundle, và ta sẽ quay lại lựa chọn này ngay bên dưới.

Phần main của ví dụ Composite pattern dựng cây hai tầng và chạy các thao tác:

    public static void main(String[] args) {
        Item chuot = new Product("Chuột không dây", 250_000, 0.1);
        Item banPhim = new Product("Bàn phím cơ", 900_000, 0.9);
        Item taiNghe = new Product("Tai nghe", 600_000, 0.3);
        Item lot = new Product("Lót chuột", 100_000, 0.2);

        Bundle phuKien = new Bundle("Combo phụ kiện", 10).add(chuot).add(lot);
        Bundle gaming = new Bundle("Combo gaming", 5).add(banPhim).add(taiNghe).add(phuKien);

        StringBuilder sb = new StringBuilder();
        gaming.print(sb, 0);
        System.out.print(sb);
        System.out.println("Tổng tiền cây: " + gaming.price() + " đ");
        System.out.println("Tổng cân nặng: " + gaming.weightKg() + " kg");
        System.out.println("Số nút (cả combo): " + gaming.flatten().count());
        System.out.println("Số sản phẩm lẻ: " + gaming.flatten().filter(i -> i instanceof Product).count());

        // Client đối xử đồng nhất: cùng một hàm nhận Product hay Bundle
        for (Item i : new Item[]{chuot, phuKien, gaming}) {
            System.out.println(describe(i));
        }

        // Vòng lặp: A chứa B, B chứa A
        Bundle a = new Bundle("A", 0);
        Bundle b = new Bundle("B", 0);
        a.add(b);
        b.add(a);
        try {
            a.price();
        } catch (StackOverflowError e) {
            System.out.println("Vòng lặp trong cây gây StackOverflowError");
        }
    }

    static String describe(Item i) {
        return String.format("%-18s giá=%,d đ  nặng=%.1f kg", i.name(), i.price(), i.weightKg());
    }

Kết quả chạy thật (java -cp out Composite1, biên dịch với --release 17, máy 16 GB RAM):

+ Combo gaming (giảm 5%) : 1724250 đ
  - Bàn phím cơ : 900000 đ
  - Tai nghe : 600000 đ
  + Combo phụ kiện (giảm 10%) : 315000 đ
    - Chuột không dây : 250000 đ
    - Lót chuột : 100000 đ
Tổng tiền cây: 1724250 đ
Tổng cân nặng: 1.5 kg
Số nút (cả combo): 6
Số sản phẩm lẻ: 4
Chuột không dây    giá=250,000 đ  nặng=0.1 kg
Combo phụ kiện     giá=315,000 đ  nặng=0.3 kg
Combo gaming       giá=1,724,250 đ  nặng=1.5 kg
Vòng lặp trong cây gây StackOverflowError

Hãy kiểm tra phép tính bằng tay để chắc rằng đa hình đang làm đúng. Combo phụ kiện: (250.000 + 100.000) × 90% = 315.000 đ. Combo gaming: (900.000 + 600.000 + 315.000) × 95% = 1.724.250 đ. Cân nặng 0,9 + 0,3 + 0,1 + 0,2 = 1,5 kg. Hàm describe nhận một sản phẩm lẻ, một combo nhỏ và combo lớn bằng cùng một chữ ký: đó chính là lợi ích "đối xử đồng nhất" mà Composite pattern hứa hẹn.

Composite pattern minh bạch hay an toàn: add/remove nên nằm ở đâu

Đây là chỗ các tài liệu về mẫu này hay tranh luận nhất. GoF gọi hai phương án là minh bạch (transparency) và an toàn (safety).

  • Minh bạch: đặt add/remove ngay trên Component. Client xử lý mọi nút như nhau, nhưng nút lá phải cài các phương thức vô nghĩa, thường là ném UnsupportedOperationException. Refactoring.guru lưu ý điều này vi phạm Interface Segregation Principle.
  • An toàn: chỉ đặt add/remove trên Composite. Không có phương thức giả, nhưng muốn sửa cây, client phải biết nút nào là nút chứa, tức là quay lại kiểm tra kiểu.

Ví dụ Composite pattern ở trên chọn phương án an toàn: Item chỉ có các thao tác đọc, còn Bundle.add là phương thức riêng. Lý do là trong thực tế, phần dựng cây (nhập combo trong trang quản trị) và phần dùng cây (tính tiền lúc thanh toán) thường nằm ở hai nơi khác nhau, và nơi dựng cây vốn đã biết nó đang tạo combo. Để thấy cái giá của phương án minh bạch, đây là cây thư mục kiểu đó:

Đoạn dưới là bản rút gọn của code/s6-10/src/Transparent.java: khai báo lớp bao ngoài, import và chú thích được lược bớt, mã đầy đủ nằm trong file đó. Đây là nội dung bên trong lớp Transparent, với import java.util.ArrayList; và import java.util.List; ở đầu file:

    interface Node {
        long size();
        default void add(Node child) { throw new UnsupportedOperationException(getClass().getSimpleName() + " là nút lá"); }
    }
    record File(String name, long bytes) implements Node {
        public long size() { return bytes; }
    }
    static class Dir implements Node {
        private final List<Node> kids = new ArrayList<>();
        @Override public void add(Node child) { kids.add(child); }
        public long size() { return kids.stream().mapToLong(Node::size).sum(); }
    }
    public static void main(String[] args) {
        Dir root = new Dir();
        root.add(new File("a.log", 1_200));
        Dir sub = new Dir();
        sub.add(new File("b.log", 800));
        root.add(sub);
        System.out.println("Dung lượng thư mục: " + root.size() + " byte");
        Node leaf = new File("c.log", 10);
        try {
            leaf.add(new File("d.log", 5));
        } catch (UnsupportedOperationException e) {
            System.out.println("Lỗi lúc chạy: " + e.getMessage());
        }
    }
}

Chạy java -cp out Transparent:

Dung lượng thư mục: 2000 byte
Lỗi lúc chạy: File là nút lá

Lỗi chỉ xuất hiện lúc chạy, không phải lúc biên dịch. Từ Java 16, pattern matching cho instanceof giúp phương án an toàn gọn hơn: nếu cần sửa cây, bạn viết if (node instanceof Bundle b) b.add(child) và không phải ép kiểu thủ công. sealed interface còn cho một lợi ích khác: từ Java 21, switch trên kiểu sealed được trình biên dịch kiểm tra đủ nhánh, nên thêm một loại nút mới sẽ báo lỗi ở những switch chưa xử lý. Quy tắc thực dụng khi cài Composite pattern: ưu tiên phương án an toàn cho cây chủ yếu để đọc và tính toán, chỉ dùng phương án minh bạch khi client thật sự cần sửa cây qua giao diện chung.

Composite pattern trong JDK: java.awt.Container

Bộ giao diện AWT/Swing của JDK là ví dụ dễ kiểm chứng nhất cho Composite pattern. java.awt.Component là Component, java.awt.Container kế thừa nó và giữ danh sách con, JButton là nút lá về mặt khái niệm. Có một chi tiết thú vị: vì JComponent kế thừa Container, thực tế mọi component Swing đều là container về mặt kiểu, kể cả nút bấm. JDK chọn phương án minh bạch theo cách rất rộng. Đoạn mã sau duyệt cây và xác nhận điều đó:

    public static void main(String[] args) {
        System.out.println("headless = " + GraphicsEnvironment.isHeadless());
        JPanel root = new JPanel();
        JPanel form = new JPanel();
        form.add(new JButton("Lưu"));
        form.add(new JButton("Huỷ"));
        root.add(form);
        root.add(new JButton("Đóng"));

        System.out.println("root có " + root.getComponentCount() + " con trực tiếp");
        StringBuilder chain = new StringBuilder("JButton");
        for (Class<?> c = JButton.class.getSuperclass(); c != Object.class; c = c.getSuperclass()) {
            chain.append(" -> ").append(c.getSimpleName());
        }
        System.out.println("Chuỗi kế thừa: " + chain);
        System.out.println("Component là Container? JButton=" + isContainer(new JButton()) + ", JPanel=" + isContainer(form));
        System.out.println("Số nút trong cây: " + countButtons(root));
    }
    static boolean isContainer(Component c) { return c instanceof Container; }
    static int countButtons(Component c) {
        int n = c instanceof JButton ? 1 : 0;
        if (c instanceof Container ct) {
            for (Component child : ct.getComponents()) n += countButtons(child);
        }
        return n;
    }

Kết quả (java -Djava.awt.headless=true -cp out JdkComposite, chạy không cần màn hình):

headless = true
root có 2 con trực tiếp
Chuỗi kế thừa: JButton -> AbstractButton -> JComponent -> Container -> Component
Component là Container? JButton=true, JPanel=true
Số nút trong cây: 3

Chuỗi kế thừa cho thấy JButton đi qua Container, nên câu hỏi "nút này có phải nút chứa không" không còn trả lời được bằng instanceof Container. Bài học: nếu bạn muốn hai khái niệm lá và chứa tách bạch, đừng để lá kế thừa lớp có khả năng chứa. Mô hình tương tự còn có ở java.nio.file khi duyệt cây thư mục, ở cây DOM của XML, hay ở các biểu thức tính toán dạng cây trong trình biên dịch.

Biến thể của Composite pattern: cache kết quả, con trỏ cha và thứ tự con

Composite pattern cơ bản thường được mở rộng theo ba hướng.

  • Con trỏ tới cha: mỗi nút giữ tham chiếu nút cha để đi ngược lên, ví dụ hiển thị đường dẫn breadcrumb hay thông báo thay đổi lên trên. Cái giá là phải đồng bộ hai chiều khi thêm/xoá, và dễ rò bộ nhớ nếu quên gỡ.
  • Cache kết quả tổng hợp: với cây lớn, price() chạy mỗi lần sẽ duyệt lại toàn bộ. Có thể lưu kết quả ở nút chứa và vô hiệu hoá khi con thay đổi. Chỉ làm khi đo được chi phí, vì cache thêm một loại lỗi mới là dữ liệu cũ.
  • Thứ tự con: khi thứ tự có ý nghĩa (menu, lớp xếp chồng đồ hoạ), dùng List; khi không, Set sẽ chặn trùng.

Composite pattern cũng ghép tốt với các pattern khác. Visitor tách thao tác ra khỏi cây để thêm thao tác mà không sửa nút. Iterator giúp duyệt cây mà không lộ cấu trúc. Nếu hàng triệu nút lá giống nhau, lá có thể được chia sẻ bằng Flyweight, nội dung bài kế tiếp. Còn Decorator, đã trình bày ở Decorator pattern: học từ java.io và áp dụng cho dịch vụ, có cấu trúc đệ quy gần giống nhưng mục đích khác: Decorator bọc đúng một đối tượng để thêm hành vi, Composite pattern gom nhiều đối tượng để biểu diễn quan hệ bộ phận và tổng thể.

Khi nào không nên dùng Composite pattern

Composite pattern có giá riêng, và tài liệu của refactoring.guru nêu rõ một nhược điểm: khi các lớp có chức năng quá khác nhau, interface chung khó xác định và dễ bị khái quát hoá quá mức, khiến người đọc khó hiểu. Một số dấu hiệu bạn đang ép Composite pattern vào chỗ không hợp:

  • Dữ liệu không thực sự là cây nên Composite pattern không hợp, ví dụ đồ thị có nhiều cha cho một nút. Khi đó phải xử lý chu trình và trùng lặp, Composite pattern đơn thuần không đủ.
  • Nút lá và nút chứa có thao tác hoàn toàn khác nhau. Interface chung sẽ chứa toàn phương thức ném ngoại lệ.
  • Cây nông (Composite là thừa), ví dụ chỉ hai cấp cố định (đơn hàng và dòng hàng). Hai lớp riêng đơn giản hơn nhiều.
  • Cần kiểm soát kiểu con chặt, ví dụ "combo chỉ được chứa sản phẩm lẻ". Interface chung của Composite pattern làm mất ràng buộc này ở trình biên dịch, bạn phải kiểm tra lúc chạy.

Lỗi thường gặp khi dùng Composite pattern

  • Chu trình: nếu A chứa B và B chứa A, mọi phép duyệt đệ quy sẽ không dừng. Ví dụ trên cố tình tạo vòng này và nhận StackOverflowError; lưu ý add trong ví dụ chỉ chặn việc nút tự chứa chính nó, chưa chặn chu trình gián tiếp qua tổ tiên. Cách phòng: kiểm tra khi add (nút được thêm không được là tổ tiên của nút nhận), hoặc dùng cây bất biến dựng từ dưới lên, vì khi đó chu trình không thể xảy ra.
  • Đệ quy quá sâu: cây hàng chục nghìn tầng (ví dụ danh sách liên kết biến tướng) làm tràn stack. Dùng duyệt lặp bằng ArrayDeque nếu độ sâu không kiểm soát được.
  • Sửa cây khi đang duyệt: thêm hoặc xoá con trong lúc forEach trên danh sách con gây ConcurrentModificationException. Duyệt trên bản sao, hoặc gom thay đổi rồi áp dụng sau.
  • Chia sẻ nút giữa hai cha: một Product nằm trong hai combo là bình thường nếu nút bất biến (như record ở trên), nhưng nếu nút có con trỏ cha hoặc trạng thái thay đổi, việc chia sẻ gây lỗi khó thấy.
  • Luồng: ArrayList không an toàn đa luồng. Cây dùng chung giữa các luồng nên bất biến sau khi dựng xong.

Checklist áp dụng Composite pattern

  1. Dữ liệu có thật sự là cây, nhiều hơn hai cấp, và client thường thao tác trên cả nhóm lẫn phần tử?
  2. Các thao tác (tính tiền, in, tìm) có nghĩa với cả nút lá lẫn nút chứa, và nút chứa tính được bằng cách gộp kết quả của con?
  3. Chọn phương án minh bạch hay an toàn một cách có chủ đích, và ghi lại lý do trong code review.
  4. Có chặn chu trình lúc add hoặc dựng cây bất biến?
  5. Nếu cần thêm thao tác thường xuyên, cân nhắc Visitor cho Composite pattern để khỏi sửa mọi lớp nút.

Nguồn tham khảo thêm: tài liệu java.awt.Container trong Java SE 17 và mô tả Composite trên refactoring.guru. Toàn bộ mã nguồn Composite pattern của bài đã được biên dịch và chạy lại trước khi dán kết quả.

Bài tiếp theo

Bài kế tiếp trong series là Bridge và Flyweight pattern: tách trừu tượng và chia sẻ trạng thái. Sau Composite pattern, ta sẽ xem khi cây có hàng triệu nút lá giống nhau thì chia sẻ trạng thái bằng Flyweight giúp tiết kiệm bộ nhớ đến đâu, và đo bằng số liệu thật.