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
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ể:
- 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ị.
- 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.
- 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:
- Bạn chưa đo. Không có profiler cấp phát chỉ ra hotspot thật, Flyweight là đoán mò. Bản thân cache lookup có thể chậm hơn cấp phát một record nhỏ. Luôn đo.
- Giá trị share mutate. Flyweight phụ thuộc immutability. Một
Badgeshare mà màu bị đổi bởi một caller phá mọi caller khác thầm lặng. Record với init-only property là điểm bắt đầu an toàn. - Cache thành memory leak. Cache mọi giá trị riêng biệt theo
request mãi mãi là ngược của tối ưu. Dùng cache có giới hạn
(
MemoryCachecó size limit) khi không gian key không giới hạn.
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?
- Bài trước: Facade — pattern structural ít được gọi tên nhất.
- Bài kế: Proxy — pattern structural cuối, nói về kiểm soát truy cập object.
- Tham chiếu chéo: Singleton — khi object share là một instance cho cả process, không phải một mỗi giá trị riêng biệt.
- Tham chiếu chéo: Prototype — khi bạn muốn bản gần copy template, không phải reference share.
- Cây quyết định: Cách chọn design pattern phù hợp.
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ì?
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?
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?
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?
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 và tiết kiệm cấp phát.