Composite Pattern trong C#: Cây Với Thao Tác Đồng Nhất
Composite pattern trong C# / .NET 10: interface dùng chung cho leaf và group để total, render, validate hoạt động giống nhau ở mọi tầng cây.
Mục lục
Shop bán bundle quà — "Holiday Box" chứa ba sản phẩm bán cùng với giảm 10%. Cart chứa line item bình thường và bundle, mọi thao tác cart phải xử cả hai: tính total, render hoá đơn, validate inventory, áp thuế. Phiên bản đầu rẽ nhánh khắp nơi:
foreach (var entry in cart.Entries)
{
if (entry is LineItem line) total += line.Price * line.Quantity;
else if (entry is Bundle bundle)
{
foreach (var inner in bundle.Items)
total += inner.Price * inner.Quantity;
total *= 0.9m; // discount bundle
}
}
Khi marketing thêm bundle lồng ("Holiday Mega Box" chứa ba Holiday
Box), if mọc thêm tầng. Thêm sales tax là ghé mọi nhánh if cập
nhật cả hai. Hình dạng sai; traversal cây đồng nhất là cái bạn cần.
Composite pattern là câu trả lời. Định nghĩa interface mà cả
leaf lẫn group đều implement. Thao tác như Total() và Render()
đệ quy tự nhiên. Thêm node type mới (một "free gift" giá zero) là một
class mới không sửa code có sẵn.
Composite pattern giải quyết bài toán gì trong C#?
Pattern xứng chỗ khi cùng thao tác phải áp cho cả một thứ đơn và nhóm thứ, có thể lồng. Ba hình dạng cụ thể:
- Shopping cart với bundle. Line item và bundle dùng chung
Total()vàRender(); bundle có thể chứa bundle khác. - File system. File và folder đều có
Size,Path,Permissions. Folder là nhóm file và sub-folder. - Menu UI. Menu item và sub-menu đều có
Label,IsEnabled,Click(). Sub-menu chứa menu item, có thể đệ quy.
Cái không phải bài toán Composite: "Tôi có một object đơn và muốn
thêm hành vi" — đó là Decorator. "Tôi
có list item nhưng không lồng" — đó chỉ là List<T>. Composite đặc
biệt nói về đệ quy đồng nhất qua cây.
Hierarchy Composite trong .NET 10 trông thế nào?
Một interface chung cộng hai implementation — leaf và composite.
Trong C# hiện đại, leaf thường là record và composite là class
vì giữ list children mutable:
public interface ICartNode
{
decimal Total();
string Render(int indent = 0);
int ItemCount();
}
public sealed record LineItem(string Sku, decimal UnitPrice, int Quantity) : ICartNode
{
public decimal Total() => UnitPrice * Quantity;
public string Render(int indent) => $"{new string(' ', indent)}{Sku} x{Quantity} = {Total():C}";
public int ItemCount() => Quantity;
}
public sealed class Bundle : ICartNode
{
private readonly List<ICartNode> _children = new();
public string Name { get; }
public decimal DiscountFactor { get; }
public Bundle(string name, decimal discountFactor = 1.0m)
=> (Name, DiscountFactor) = (name, discountFactor);
public Bundle Add(ICartNode child) { _children.Add(child); return this; }
public decimal Total() => _children.Sum(c => c.Total()) * DiscountFactor;
public int ItemCount() => _children.Sum(c => c.ItemCount());
public string Render(int indent)
{
var header = $"{new string(' ', indent)}{Name} (-{(1m - DiscountFactor) * 100:0}%):";
var lines = _children.Select(c => c.Render(indent + 2));
return string.Join("\n", new[] { header }.Concat(lines));
}
}
Để ý số if/switch bạn viết theo node type là zero. Total()
gọi Total(). Mỗi node tự biết xử lý mình.
Bức tranh cấu trúc:
classDiagram
class ICartNode {
<<interface>>
+Total() decimal
+Render(indent) string
+ItemCount() int
}
class LineItem {
+Sku, UnitPrice, Quantity
}
class Bundle {
-List children
+Name, DiscountFactor
+Add(child) Bundle
}
ICartNode <|.. LineItem
ICartNode <|.. Bundle
Bundle o-- ICartNode : children
Một cart lồng giờ đọc tự nhiên:
var cart = new Bundle("Cart");
cart.Add(new LineItem("BOOK-1", 9.99m, 2));
cart.Add(new Bundle("Holiday Box", 0.9m)
.Add(new LineItem("MUG", 12m, 1))
.Add(new LineItem("PEN", 3m, 1))
.Add(new LineItem("PAD", 5m, 1)));
Console.WriteLine(cart.Render());
Console.WriteLine($"Total: {cart.Total():C}");
Output:
Cart (-0%):
BOOK-1 x2 = $19.98
Holiday Box (-10%):
MUG x1 = $12.00
PEN x1 = $3.00
PAD x1 = $5.00
Total: $37.98
Discount bundle 10% áp tự động bởi Bundle.Total(); total cart-level
chỉ tổng children. Thêm bundle lồng không đổi một dòng code Bundle
nào.
Cài thao tác chung trên cây thế nào?
Ba mẫu thao tác che hầu hết case thật:
- Aggregation (sum, count, max). Composite gọi thao tác trên mỗi
child rồi gộp. Ví dụ trên làm vậy cho
Total()vàItemCount(). - Render / serialize. Composite render mình rồi ủy thác render cho child. Indent là tham số phổ biến nhất truyền xuống đệ quy.
- Validate / iterate. Trả về kết quả đơn (
bool IsValid()) hoặc yield kết quả lazy (IEnumerable<string> Errors()).
Cho lazy iteration, IEnumerable<T> cộng yield return và
SelectMany giữ code gọn:
public IEnumerable<LineItem> Flatten() => this switch
{
LineItem leaf => new[] { leaf },
Bundle comp => comp.Children.SelectMany(c => c.Flatten()),
_ => throw new InvalidOperationException("Unknown ICartNode")
};
(Đúng, snippet này dùng switch — nhưng nó sống trong một
extension method biết tập đóng node type, không ở mọi call site.)
Khi nào dùng list phẳng thay vì cây Composite?
Composite có chi phí cố định — mọi thao tác traversal, mọi leaf phải implement interface — và chi phí đó chỉ trả về khi cây thật sự là cây. Ba dấu hiệu không nên dùng:
- Không lồng trong data thực. Nếu product không bao giờ cho
bundle trong bundle, chia Bundle/LineItem là quá đà. Inline item
của bundle vào cart và dùng field
decimal Discounttrên line. - Caller luôn flatten. Nếu việc đầu mọi consumer là
tree.Flatten().ToList(), bạn có list khoác áo cây. Lưu list. - Thao tác thực ra không chung. Nếu
Bundle.Total()làm gì cơ bản khácLineItem.Total()và caller phải phân biệt, interface là dối trá. Hai type khác với hai method khác mới thật thà.
Litmus test: viết Composite, rồi viết cùng code dạng list phẳng với một field thêm cho group. Cái ngắn hơn thắng.
So sánh Composite với Decorator thế nào?
Cả hai bọc node sau interface; khác nhau ở một-một vs một-nhiều:
| Khía cạnh | Composite | Decorator |
|---|---|---|
| Giữ | Một list ICartNode children |
Một ICartNode bọc |
| Thêm | Một điểm rẽ nhánh mới | Hành vi mới quanh cái có sẵn |
| Quan hệ interface | Composite cũng là ICartNode |
Decorator cũng là ICartNode |
| Mô hình tâm trí | Một cây | Một củ hành |
Bạn có thể — và thường — dùng cả hai: cây Composite của LineItem và Bundle, với Decorator log bọc gốc để trace mọi lần tính total. Pattern compose sạch vì cùng tuân hợp đồng interface.
Một ví dụ thật trong .NET 10 trông thế nào?
Hình dạng đầy đủ chạy được — cart từ domain checkout xuyên suốt, với extension method nhỏ cho traversal:
public interface ICartNode
{
decimal Total();
int ItemCount();
string Render(int indent = 0);
}
public sealed record LineItem(string Sku, decimal UnitPrice, int Quantity) : ICartNode
{
public decimal Total() => UnitPrice * Quantity;
public int ItemCount() => Quantity;
public string Render(int indent) => $"{new string(' ', indent)}{Sku} x{Quantity} = {Total():C}";
}
public sealed class Bundle : ICartNode
{
private readonly List<ICartNode> _children = new();
public string Name { get; }
public decimal DiscountFactor { get; }
public IReadOnlyList<ICartNode> Children => _children;
public Bundle(string name, decimal discountFactor = 1.0m)
{
if (discountFactor <= 0 || discountFactor > 1)
throw new ArgumentOutOfRangeException(nameof(discountFactor));
(Name, DiscountFactor) = (name, discountFactor);
}
public Bundle Add(ICartNode child) { _children.Add(child); return this; }
public decimal Total() => _children.Sum(c => c.Total()) * DiscountFactor;
public int ItemCount() => _children.Sum(c => c.ItemCount());
public string Render(int indent)
{
var pct = (1m - DiscountFactor) * 100;
var header = $"{new string(' ', indent)}{Name} ({(pct == 0 ? "no discount" : $"-{pct:0}%")}):";
var lines = _children.Select(c => c.Render(indent + 2));
return string.Join("\n", new[] { header }.Concat(lines));
}
}
public static class CartExtensions
{
public static IEnumerable<LineItem> Flatten(this ICartNode node) => node switch
{
LineItem leaf => new[] { leaf },
Bundle comp => comp.Children.SelectMany(Flatten),
_ => Array.Empty<LineItem>(),
};
}
// Sử dụng
var cart = new Bundle("Cart")
.Add(new LineItem("BOOK-1", 9.99m, 2))
.Add(new Bundle("Holiday Box", 0.9m)
.Add(new LineItem("MUG", 12m, 1))
.Add(new LineItem("PEN", 3m, 1))
.Add(new LineItem("PAD", 5m, 1)));
Console.WriteLine(cart.Render());
Console.WriteLine($"Total: {cart.Total():C}");
Console.WriteLine($"Items: {cart.ItemCount()}");
Console.WriteLine($"Distinct: {cart.Flatten().Select(l => l.Sku).Distinct().Count()}");
[Fact]
public void Bundle_applies_discount_to_children_only()
{
var bundle = new Bundle("Box", 0.9m)
.Add(new LineItem("A", 10m, 1))
.Add(new LineItem("B", 10m, 1));
Assert.Equal(18m, bundle.Total()); // 20 * 0.9 = 18
Assert.Equal(2, bundle.ItemCount());
}
Thêm node type thứ ba — ví dụ FreeGift (luôn giá zero) — là một
record mới không sửa đâu khác:
public sealed record FreeGift(string Sku) : ICartNode
{
public decimal Total() => 0m;
public int ItemCount() => 1;
public string Render(int indent) => $"{new string(' ', indent)}🎁 {Sku} (free)";
}
Total() của cart tiếp tục chạy không đổi, vì mọi node tuân hợp
đồng giống nhau.
Đọc tiếp gì trong series?
- Bài trước: Bridge — khi hai trục biến đổi độc lập và bạn không muốn N×M class.
- Bài kế: Decorator — cùng interface, ý đồ khác (bọc một-một thêm hành vi).
- Tham chiếu chéo: Visitor — khi cần thêm thao tác mới trên cây mà không sửa mọi node type.
- Tham chiếu chéo: Iterator —
Flatten()ở trên chính là pattern đó, expose quaIEnumerable<T>. - Cây quyết định: Cách chọn design pattern phù hợp.
Một quan sát thực dụng: gần như mọi domain có data hierarchy đều
giấu một Composite. Cây JSON, AST, sơ đồ tổ chức, BOM, file system.
Nhận ra hình dạng tạo khác biệt giữa viết cùng if (isLeaf) ... else ... mười lần và viết một interface co giãn theo cây.
Câu hỏi thường gặp
Composite khác Decorator ở điểm gì?
Record có dùng làm Composite leaf được không?
LineItem. Composite node thường phải là class vì giữ list children mutable, nhưng bạn có thể giữ children dạng IReadOnlyList<ICartNode> rồi rebuild composite mới qua helper copy-and-add nếu muốn cả cây immutable.Visit cây Composite không bị stack overflow đệ quy thế nào?
Stack<T> hoặc Queue<T> tường minh và iterate. Visitor pattern tổng quát hoá traversal cây thêm khi bạn cần thêm thao tác mới mà không sửa mọi node type.Khi nào list phẳng tốt hơn cây Composite?
tree.SelectMany(t => t.Flatten()), Composite đang trả phí cho cấu trúc không ai dùng. Lưu IReadOnlyList<LineItem> phẳng và thêm GroupBy khi thỉnh thoảng cần group.