Lập trình · Mẫu thiết kế
Mẫu Composite (Design Pattern) giải thích kèm ví dụ
Trả lời nhanh
Composite là một mẫu thiết kế cấu trúc (structural) cho phép ghép các đối tượng thành cấu trúc cây rồi xử lý từng đối tượng đơn lẻ và cả nhóm đối tượng theo cùng một cách. Nút lá (như một tệp) và composite (như một thư mục chứa tệp và các thư mục khác) cài đặt cùng một interface, chẳng hạn size(). Composite cài đặt thao tác bằng cách gọi nó trên các phần tử con, nên mã client có thể làm việc với cả cây mà không cần kiểm tra loại nút.
Mục đích
Ghép các đối tượng thành cấu trúc cây để biểu diễn hệ phân cấp bộ phận - toàn thể. Composite cho phép client đối xử thống nhất với đối tượng đơn lẻ và tập hợp đối tượng. Đây là một trong bảy mẫu cấu trúc trong sách Gang of Four (GoF).
Vấn đề mà mẫu này giải quyết
Hãy tưởng tượng một ứng dụng lưu trữ cần hiển thị tổng dung lượng của bất kỳ mục nào. Một tệp có dung lượng riêng. Dung lượng của thư mục là tổng mọi thứ bên trong, và thư mục có thể chứa tệp lẫn thư mục khác, lồng nhau ở độ sâu bất kỳ.
Nếu không có interface chung, mọi đoạn mã duyệt cây đều phải hỏi “đây là tệp hay thư mục?” rồi xử lý từng trường hợp, thường bằng đệ quy viết đi viết lại. Thêm một loại nút mới, như lối tắt hay tệp nén, nghĩa là phải tìm và sửa mọi chỗ kiểm tra đó. Composite đặt phần đệ quy vào ngay trong các nút.
Cấu trúc và các thành phần
| Thành phần | Trách nhiệm | Trong ví dụ |
|---|---|---|
| Component | Khai báo interface chung cho mọi đối tượng trong cây. | FsNode |
| Leaf | Đối tượng không có phần tử con; cài đặt thao tác trực tiếp. | FileNode |
| Composite | Lưu các component con và cài đặt thao tác bằng cách ủy quyền cho chúng. | Folder |
| Client | Làm việc với mọi nút thông qua interface của Component. | report |
Cài đặt bằng TypeScript
interface FsNode {
readonly name: string;
size(): number;
print(indent?: string): string[];
}
class FileNode implements FsNode {
constructor(readonly name: string, private bytes: number) {}
size(): number { return this.bytes; }
print(indent = ""): string[] { return [`${indent}${this.name} (${this.bytes})`]; }
}
class Folder implements FsNode {
private children: FsNode[] = [];
constructor(readonly name: string) {}
add(...nodes: FsNode[]): this { this.children.push(...nodes); return this; }
size(): number { return this.children.reduce((sum, c) => sum + c.size(), 0); }
print(indent = ""): string[] {
const lines = [`${indent}${this.name}/ (${this.size()})`];
for (const child of this.children) lines.push(...child.print(indent + " "));
return lines;
}
}
const root = new Folder("project").add(
new FileNode("README.md", 120),
new Folder("src").add(new FileNode("main.ts", 900), new FileNode("util.ts", 300)),
);
// The client treats a single file and a whole tree the same way.
const report = (node: FsNode) => console.log(node.print().join("\n"));
report(root);
// project/ (1320)
// README.md (120)
// src/ (1200)
// main.ts (900)
// util.ts (300)
report(new FileNode("notes.txt", 42)); // notes.txt (42)report không bao giờ kiểm tra loại nút. Một loại nút mới, như Symlink báo dung lượng bằng không, chỉ cần cài đặt FsNode.
Quyết định thiết kế: tính trong suốt hay tính an toàn
Các phương thức quản lý phần tử con như add() và remove() nên đặt ở đâu? Sách GoF mô tả sự đánh đổi này:
| Lựa chọn | Ý nghĩa | Đánh đổi |
|---|---|---|
| An toàn (Safety) | Chỉ Composite có add() và remove(), như trong ví dụ trên. | An toàn về kiểu: bạn không thể thêm phần tử con vào một tệp. Nhưng client phải biết khi nào mình đang cầm một composite. |
| Trong suốt (Transparency) | Component khai báo add() và remove(), còn các leaf ném lỗi hoặc bỏ qua. | Mọi nút trông giống hệt nhau với client, nhưng lỗi chỉ xuất hiện lúc chạy. |
Trong TypeScript, cách an toàn thường hiệu quả hơn, vì trình kiểm tra kiểu bắt lỗi ngay lúc biên dịch.
Khi nào nên dùng và khi nào không
Dùng Composite khi:
- Dữ liệu của bạn vốn là một cây: tệp và thư mục, widget giao diện trong các container, menu và menu con, sơ đồ tổ chức, hình vẽ và nhóm hình.
- Client nên bỏ qua sự khác biệt giữa một đối tượng và một nhóm đối tượng.
Tránh dùng khi:
- Cấu trúc phẳng hoặc luôn chỉ có một cấp. Một mảng thông thường đơn giản hơn.
- Leaf và nhóm cần những thao tác rất khác nhau. Ép chúng vào một interface sẽ tạo ra những phương thức vô nghĩa với một số nút.
- Bạn cần hạn chế các phần tử con mà composite được phép chứa, chẳng hạn chỉ một số loại nút. Interface thống nhất khiến các quy tắc như vậy khó áp đặt hơn trong hệ thống kiểu.
Ứng dụng thực tế
- DOM: mọi phần tử và nút văn bản đều là
Node. Phần tử có thể chứa các nút khác, và nhiều thao tác, nhưtextContenthaycloneNode(true), hoạt động giống nhau trên một nút đơn lẻ lẫn cả cây con. - Các framework giao diện như React, Vue và Angular xây dựng giao diện dưới dạng cây, trong đó một component có thể render các component khác.
- Java AWT và Swing:
Containerlà lớp con củaComponentvà chứa các component khác, nên một panel đầy nút bấm cũng là một component. - Cây cú pháp trừu tượng trong trình biên dịch, scene graph trong game engine và menu lồng nhau đều theo cùng một ý tưởng.
Các mẫu liên quan
- Iterator: duyệt các nút của composite mà không để lộ cách lưu các phần tử con.
- Visitor: thêm thao tác mới trên cây composite (xuất, kiểm tra hợp lệ, thống kê) mà không sửa các lớp nút.
- Decorator: cũng có interface component và bọc đối tượng theo kiểu đệ quy, nhưng decorator có đúng một phần tử con và thêm hành vi, còn composite có nhiều phần tử con và kết hợp kết quả của chúng.
- Builder: thường dùng để dựng từng bước các cây composite phức tạp.
- Chain of Responsibility: thường dùng liên kết cha của composite, chuyển yêu cầu từ con lên cha, như sự kiện trình duyệt nổi bọt lên DOM.
Câu hỏi thường gặp
Leaf và composite khác nhau thế nào?
Leaf không có phần tử con và tự làm việc. Composite giữ các component con và thường làm việc bằng cách gọi cùng thao tác đó trên từng phần tử con rồi kết hợp kết quả.
Composite chỉ dành cho cây thôi sao?
Nó được thiết kế cho hệ phân cấp bộ phận - toàn thể dạng cây. Đồ thị tổng quát có chu trình cần thêm lưu ý, như ghi nhận các nút đã duyệt, để tránh đệ quy vô hạn.
Mẫu Composite khác mẫu Decorator ở điểm nào?
Cả hai đều dùng interface chung và composition đệ quy. Decorator bọc đúng một đối tượng để thêm hành vi, còn composite gom nhiều phần tử con để xử lý như một.
Có nên đặt add() cả trong lớp leaf không?
Chỉ khi bạn muốn tính trong suốt hoàn toàn và chấp nhận lỗi lúc chạy khi ai đó thêm phần tử con vào leaf. Với các ngôn ngữ có kiểu, thường an toàn hơn nếu chỉ đặt add() trong composite.