Cấu trúc Nâng cao 7 phút đọc

Flyweight Pattern trong C#: Share Instance Immutable Tỉnh Táo

Flyweight pattern trong C# / .NET 10: share instance immutable để cắt allocation trên hot path, từ string interning tới cache product badge.

Mục lục
  1. Flyweight pattern giải quyết bài toán gì trong C#?
  2. Cấu trúc Flyweight trong .NET 10 trông thế nào?
  3. string.Intern liên hệ gì với Flyweight?
  4. Khi nào nên bỏ qua Flyweight?
  5. So sánh Flyweight với Singleton và object pool 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?

Trang listing của shop render một triệu product card trong đợt Black Friday. Mỗi card hiển thị một trong ba badge: "New", "Sale", hoặc "Limited". Phiên bản đầu coi mỗi badge là cấp phát mới:

foreach (var product in products)
{
    var badge = new ProductBadge(product.Tag, product.BadgeColor, product.BadgeIcon);
    Render(product, badge);
}

Profiling cấp phát lộ ra điều hiển nhiên: một triệu object ProductBadge, ba giá trị riêng biệt giữa chúng. 999.997 cái còn lại là clone giống hệt. Cắt xuống ba sẽ tiết kiệm đủ memory để hoãn event scaling pod kế.

Flyweight pattern là câu trả lời. Giữ một instance immutable cho mỗi giá trị riêng biệt trong cache chia sẻ; mọi caller xin "Sale" đều nhận reference cùng object. Pattern hiếm khi là cái beginner với trước — đúng vậy, vì trong C# hiện đại các case phổ biến đã được xử (string.Intern, Enum, hằng số boxed). Khi cần, nhận ra hình dạng là việc.

Flyweight pattern giải quyết bài toán gì trong C#?

Pattern xứng chỗ khi một hot path cấp phát nhiều object immutable giống hệt. Ba hình dạng cụ thể:

  1. Value object lặp lại. Product badge, log severity tag, country code, currency symbol. Phần lớn biến thể đã được vét cạn bởi vài giá trị.
  2. Cache glyph trong rendering. Vẽ một triệu tile của một ký tự Unicode nghĩa là tra cứu metadata glyph đi đi lại lại. Một instance canonical cho mỗi code point tiết kiệm cả cấp phát và tra cứu.
  3. Domain primitive read-only. Một Currency("USD") với tên, ký hiệu, số chữ số thập phân. Có 180 currency trên thế giới; cấp phát một cho mỗi transaction là phí.

Cái không phải bài toán Flyweight: "Tôi muốn tái dùng object mutable" — đó là object pool. "Tôi muốn một service share cả app" — đó là Singleton. Flyweight đặc biệt nói về nhiều giá trị riêng biệt (vẫn nhiều object, nhưng số lượng nhỏ, dedup).

Cấu trúc Flyweight trong .NET 10 trông thế nào?

Hai pattern che các case thực dụng. Đầu tiên là static readonly instance cho tập đóng:

public sealed record ProductBadge(string Label, string ColorHex, string Icon)
{
    public static readonly ProductBadge New     = new("New",     "#16a34a", "✨");
    public static readonly ProductBadge Sale    = new("Sale",    "#dc2626", "%");
    public static readonly ProductBadge Limited = new("Limited", "#9333ea", "⏳");

    public static ProductBadge ForTag(string tag) => tag switch
    {
        "new"     => New,
        "sale"    => Sale,
        "limited" => Limited,
        _         => throw new ArgumentException($"Unknown tag: {tag}")
    };
}

Tổng cộng ba instance. ForTag("sale") trả cùng reference mỗi lần. Caller so sánh badge bằng == được so sánh reference nhanh; ngữ nghĩa record vẫn cho equality theo giá trị nếu ai cần.

Pattern thứ hai là cache keyed theo nội dung cho tập mở mà giá trị riêng biệt phát hiện lúc runtime:

public sealed record Currency(string Code, string Symbol, int DecimalDigits)
{
    private static readonly ConcurrentDictionary<string, Currency> _cache = new();

    public static Currency Of(string code)
    {
        return _cache.GetOrAdd(code.ToUpperInvariant(), c => new Currency(
            c,
            ResolveSymbol(c),
            ResolveDigits(c)));
    }
}

Lần gọi đầu cho "USD" cấp phát một instance và lưu; mọi lần sau trả instance đã cache. Số cấp phát giảm từ một mỗi call xuống một mỗi currency riêng biệt.

Bức tranh cấu trúc:

flowchart LR
    P1[Product 1] --> B1
    P2[Product 2] --> B1
    P3[Product 3] --> B2
    P4[Product 4] --> B1
    P5[Product 5] --> B3
    P6[Product 6] --> B2

    subgraph Cache[Flyweight cache]
        B1[Badge.Sale]
        B2[Badge.New]
        B3[Badge.Limited]
    end

Sáu product, ba instance badge share. Sơ đồ cấu trúc làm Flyweight nhìn thấy được theo cách class diagram không làm — giá trị nằm ở cạnh share, không phải ở cây class.

string.Intern liên hệ gì với Flyweight?

System.String.Intern là Flyweight cho string built-in của BCL. CLR giữ bảng process-wide các string duy nhất; Intern tra string theo nội dung và trả reference canonical. Compiler C# intern literal string giúp bạn, đó là lý do "a" == "a" là so sánh reference trả true.

var s1 = string.Intern("hello world");
var s2 = string.Intern("hel" + "lo " + "world");
Console.WriteLine(ReferenceEquals(s1, s2)); // True - cùng instance canonical

Bạn gần như không gọi Intern trực tiếp vì compiler làm cho literal và string.IsInterned đã làm tra cứu trong suốt. Nhưng đó là cùng pattern: một reference canonical cho mỗi giá trị riêng biệt.

Khi nào nên bỏ qua Flyweight?

Flyweight là tối ưu hóa dẫn dắt bởi profiling. Ba dấu hiệu với nó là sớm:

Một quy tắc thực dụng: nếu bạn không thể chỉ vào dòng dotnet-counters Microsoft.AspNetCore.Hosting requests-per-second giảm khi thêm Flyweight, bạn không cần.

So sánh Flyweight với Singleton và object pool thế nào?

Pattern Cái gì share Mutability Số instance
Flyweight Một instance mỗi giá trị riêng biệt Immutable Ít (thường < 100)
Singleton Một instance cho cả process Thường immutable, đôi khi mutable phối hợp Đúng một
Object pool Pool các instance mutable thay thế nhau được Mutable, reset khi trả Nhiều; giới hạn bởi size pool

Câu phân biệt: Singleton là một instance cho một service; Flyweight là một instance cho mỗi giá trị riêng biệt của một type; object pool là nhiều instance thay thế được với kỷ luật check-out/trả về.

Một ví dụ thật trong .NET 10 trông thế nào?

Hình dạng đầy đủ — cả hai style Flyweight, áp dụng cho domain catalog product. Để ý code consumer không đổi giữa "cấp phát ngây thơ" và "Flyweight" — chỉ factory call đổi:

public sealed record ProductBadge(string Label, string ColorHex, string Icon)
{
    public static readonly ProductBadge New     = new("New",     "#16a34a", "✨");
    public static readonly ProductBadge Sale    = new("Sale",    "#dc2626", "%");
    public static readonly ProductBadge Limited = new("Limited", "#9333ea", "⏳");

    private static readonly IReadOnlyDictionary<string, ProductBadge> _byTag =
        new Dictionary<string, ProductBadge>(StringComparer.OrdinalIgnoreCase)
        {
            ["new"]     = New,
            ["sale"]    = Sale,
            ["limited"] = Limited,
        };

    public static ProductBadge ForTag(string tag) =>
        _byTag.TryGetValue(tag, out var b) ? b
            : throw new ArgumentException($"Unknown tag: {tag}");
}

public sealed record Currency(string Code, string Symbol, int DecimalDigits)
{
    private static readonly ConcurrentDictionary<string, Currency> _cache = new();

    public static Currency Of(string code)
    {
        var key = code.ToUpperInvariant();
        return _cache.GetOrAdd(key, c => c switch
        {
            "USD" => new Currency(c, "$", 2),
            "EUR" => new Currency(c, "€", 2),
            "JPY" => new Currency(c, "¥", 0),
            _     => new Currency(c, c, 2),
        });
    }
}

// Sử dụng trên hot path render
public string RenderCard(Product p)
{
    var badge    = ProductBadge.ForTag(p.Tag);          // một trong ba reference
    var currency = Currency.Of(p.PriceCurrency);         // một mỗi mã riêng biệt
    return $"<span style='color:{badge.ColorHex}'>{badge.Icon} {badge.Label}</span> " +
           $"{currency.Symbol}{p.PriceAmount.ToString($"F{currency.DecimalDigits}")}";
}

// Test
[Fact]
public void Same_tag_returns_same_reference()
{
    var b1 = ProductBadge.ForTag("sale");
    var b2 = ProductBadge.ForTag("Sale");
    Assert.Same(b1, b2);                               // reference equality, không chỉ value
}

[Fact]
public void Currencies_are_cached_per_code()
{
    var c1 = Currency.Of("usd");
    var c2 = Currency.Of("USD");
    Assert.Same(c1, c2);
}

Cái thắng bằng số: render một triệu product card giờ cấp phát ba instance ProductBadge cộng số instance Currency mà catalog dùng. So với phiên bản ngây thơ ba triệu+ cấp phát, đó là cắt đo được trên hot path.

Đọc tiếp gì trong series?

Một lời nhắc về đo lường: Flyweight là một trong vài pattern mà tính đúng đắn được phán quyết bởi dotnet-counters thay vì code review. Cấu trúc class giống hệt phiên bản không-Flyweight (cùng record, cùng field). Cái đổi là factory — và hiệu quả của factory không nhìn thấy không có profile. Luôn đo trước và sau.

Câu hỏi thường gặp

Flyweight khác object pool ở điểm gì?
Flyweight share một instance cho mỗi giá trị riêng biệt — ba product có badge 'Sale' đều tham chiếu cùng object Badge.Sale. Object pool tái dùng object mutable để tránh chi phí cấp phát; cùng instance pool được trao một consumer tại một thời điểm và reset giữa các lần dùng. Flyweight phụ thuộc immutability; pool phụ thuộc kỷ luật check-out và trả lại.
string.Intern có làm cùng việc với Flyweight không?
Đúng — string.Intern là BCL implement Flyweight cho string. CLR giữ bảng process-wide các giá trị string duy nhất, và Intern trả instance canonical. Pattern hoạt động cho mọi type immutable; bạn chỉ cần tự viết dictionary.
Khi nào Flyweight không còn xứng độ phức tạp?
Khi bộ nhớ tiết kiệm dưới vài megabyte, khi bản thân cache lookup thành hotspot, hoặc khi instance share vô tình sống lâu hơn cần thiết và pin memory. Flyweight là tối ưu hóa dẫn dắt bởi profiling, không phải mặc định. Không có tiết kiệm đo được, ưu tiên record thường và để GC làm việc.
Record có tốt hơn Flyweight trong C# hiện đại vì equality theo giá trị không?
Hai công cụ khác nhau. record cho equality theo giá trị nhưng mỗi new vẫn cấp phát. Flyweight đảm bảo chỉ một cấp phát cho mỗi giá trị riêng biệt và làm equality thành so sánh reference — rẻ hơn so sánh giá trị. Hai cái kết hợp tốt: cache Flyweight trả record nghĩa là caller có cả equality semantics tiết kiệm cấp phát.