Cấu trúc Trung bình 8 phút đọc

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
  1. Composite pattern giải quyết bài toán gì trong C#?
  2. Hierarchy Composite trong .NET 10 trông thế nào?
  3. Cài thao tác chung trên cây thế nào?
  4. Khi nào dùng list phẳng thay vì cây Composite?
  5. So sánh Composite với Decorator thế nào?
  6. Một ví dụ thật trong .NET 10 trông thế nào?
  7. Đọc tiếp gì trong series?

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 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()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ể:

  1. Shopping cart với bundle. Line item và bundle dùng chung Total()Render(); bundle có thể chứa bundle khác.
  2. File system. File và folder đều có Size, Path, Permissions. Folder là nhóm file sub-folder.
  3. 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:

Cho lazy iteration, IEnumerable<T> cộng yield returnSelectMany 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:

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?

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ì?
Decorator bọc một component để thêm hành vi xung quanh; wrapper expose cùng interface với object bị bọc. Composite gom nhiều component vào một node expose cùng interface với leaf. Decorator một-một (thêm hành vi); Composite một-nhiều (cây). Trông giống vì cả hai có class giữ reference cùng interface — số lượng reference giữ là dấu hiệu phân biệt.
Record có dùng làm Composite leaf được không?
Được, và là lựa chọn tự nhiên cho leaf immutable như 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?
Hai cách. Cây nông (dưới vài trăm tầng) đệ quy là ổn. Cây sâu hoặc không giới hạn, dùng 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?
Khi data chỉ có một tầng (không lồng) hoặc khi caller luôn flatten cây trước khi xử lý. Nếu mọi consumer ngay lập tức gọi 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.