Flyweight Pattern: Paylaşılan State ile Bellek Kazanmak — string.Intern ve ArrayPool
Flyweight, çok sayıda object'in tekrar eden ve değişmeyen kısmını (intrinsic state) bir factory üzerinden bir kez saklayıp paylaşır, satıra özel kısmı (extrinsic state) dışarıda tutar. Bir milyon satırlık raporda belleği 377 MB'tan 30 MB'a indiren BookInfoFactory, string.Intern'ün yan etkileri, .NET'in önceden paylaştığı Task ve array instance'ları, ArrayPool'un neden flyweight değil object pool olduğu ve BenchmarkDotNet ölçümleri.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un muhasebe ekibi yıl sonunda bir satış raporu istiyor: geçen yılın bütün sipariş satırları, kitap bilgisiyle birlikte, bellekte gruplanıp toplanacak. Dışa aktarılan dosya bir milyon satır; her satırda ISBN (International Standard Book Number), başlık, yazar, yayınevi, kategori, adet ve birim fiyat var. Katalogda yalnızca 500 farklı kitap var, yani aynı kitap bilgisi ortalama iki bin kez tekrarlanıyor. Dosyayı okuyan kod son derece sıradan:
using System.Globalization;
namespace Bookstore.Reports;
// Naif satır: her satır kitabın bütün bilgisini kendi kopyası olarak taşır
public sealed record OrderLineRow(string Isbn, string Title, string Author, string Publisher, string Category,
int Quantity, decimal UnitPrice)
{
public static OrderLineRow Parse(string line)
{
string[] parts = line.Split(';');
return new(parts[0], parts[1], parts[2], parts[3], parts[4],
int.Parse(parts[5]), decimal.Parse(parts[6], CultureInfo.InvariantCulture));
}
}
namespace Bookstore.Reports;
public static class ExportFile
{
// 500 farklı kitaptan oluşan bir yıllık sipariş dökümünü taklit eder
public static IEnumerable<string> Lines(int count)
{
string[] publishers = ["Ace Books", "Penguin", "Vintage", "Tor Books", "Orbit"];
string[] categories = ["Science Fiction", "Fantasy", "Classics", "History", "Programming"];
for (int i = 0; i < count; i++)
{
int book = i % 500;
yield return $"978-{book:D10};Title {book};Author {book % 120};{publishers[book % 5]};{categories[book % 5]};{1 + i % 3};{10 + book % 30}.99";
}
}
}
Bir milyon OrderLineRow bir List'te durduğunda process'in bellekte tuttuğu ek yük sence ne kadar: 30 MB mı, 100 MB mı, 400 MB mı? Ve o belleğin ne kadarı gerçekten farklı bilgi?
Aynı Kitabın İki Bin Kopyası
Ölçümün tamamını birazdan göreceğiz; naif satırın sonucu şu:
naive : retained 377 MB, allocated 579 MB
İş bitip garbage collector tam bir toplama yaptıktan sonra bile yaklaşık 370 MB canlı kalıyor (birkaç koşuda 363 ile 377 MB arasında değişti); satır başına 380 byte civarı. Bunun küçük bir kısmı satıra özel bilgi: adet bir int, fiyat bir decimal. Geri kalanı her satırın kendi object header'ı ve beş ayrı string: Split her satırda "Science Fiction"ı, "Ace Books"u ve kitabın başlığını yeniden üretiyor. Bellekte aynı "Science Fiction" metninin iki yüz bin kopyası var ve hepsinin garbage collector'a yükü ayrı ayrı.
Bu bir algoritma problemi değil, bir paylaşım problemi. Bilgi zaten tekrar ediyor; tekrar eden kısmı bir kez saklayıp her satırdan ona işaret etmek yeterli. GoF'un bu fikre verdiği ad Flyweight.
GoF'un Tanımı: Intrinsic ve Extrinsic State
Flyweight, GoF (Gang of Four: Erich Gamma, Richard Helm, Ralph Johnson ve John Vlissides'in 1994 tarihli Design Patterns: Elements of Reusable Object-Oriented Software kitabı) kataloğunun structural pattern'lerinden biri. Amacını kendi cümlelerimle anlatırsam: çok sayıda küçük object'i, paylaşımı kullanarak verimli biçimde desteklemek. Adı boksun en hafif sıkletlerinden birinden geliyor: tek tek çok hafif object'ler.
Kitabın örneği bir metin editörü. Belgedeki her karakteri ayrı bir object olarak modellemek tasarım açısından çok temiz (her karakter çizilebilir, bir satırın, sütunun parçası olabilir), ama yüz binlerce karakterlik bir belgede bellek açısından imkânsız. Çözüm, state'i ikiye ayırmak. Intrinsic state, object'in kendisinde duran ve bağlamdan bağımsız olan kısım: karakterin kodu. Bu kısım paylaşılabilir; editörde "a" harfi için tek bir object vardır. Extrinsic state ise bağlama bağlı olan ve paylaşılamayan kısım: karakterin belgedeki konumu, o anki fontu. Bunu flyweight saklamaz; client hesaplar ya da kendisi tutar ve gerektiğinde flyweight'a parametre olarak verir.
GoF'un "Participants" bölümündeki roller: Flyweight (paylaşılan object'in interface'i), ConcreteFlyweight (intrinsic state'i taşıyan, paylaşılan class), FlyweightFactory (flyweight'ları oluşturan ve aynı anahtar için hep aynı instance'ı veren, paylaşımı garanti eden yer) ve Client (extrinsic state'i tutan taraf). GoF'un belirttiği kritik kural: client flyweight'ı doğrudan new ile oluşturmaz, factory'den ister. Aksi hâlde paylaşım tesadüfe kalır.
Bookstore'a çevirince eşleşme doğal: kitabın ISBN'i, başlığı, yazarı, yayınevi ve kategorisi intrinsic; bir milyon satırda 500 farklı değer var ve hiçbiri satıra göre değişmiyor. Adet ve o siparişteki birim fiyat extrinsic; her satırın kendine ait.
BookInfoFactory: Paylaşılan Kitap Bilgisi
using System.Globalization;
namespace Bookstore.Reports;
// Intrinsic state: bütün satırlarda aynı, paylaşılır, değişmez
public sealed record BookInfo(string Isbn, string Title, string Author, string Publisher, string Category);
// Extrinsic state: satıra özel; kitap bilgisi yalnızca bir referans
public readonly record struct OrderLine(BookInfo Book, int Quantity, decimal UnitPrice);
// Flyweight factory: aynı ISBN için hep aynı BookInfo instance'ını verir
public sealed class BookInfoFactory
{
private readonly Dictionary<string, BookInfo> _books = [];
private readonly Dictionary<string, BookInfo>.AlternateLookup<ReadOnlySpan<char>> _bySpan;
public BookInfoFactory() => _bySpan = _books.GetAlternateLookup<ReadOnlySpan<char>>();
public int Count => _books.Count;
public OrderLine Parse(string line)
{
ReadOnlySpan<char> span = line;
Span<Range> parts = stackalloc Range[8];
span.Split(parts, ';');
// Var olan kitap için hiç string üretmeden span ile ara
if (!_bySpan.TryGetValue(span[parts[0]], out BookInfo? book))
{
book = new BookInfo(span[parts[0]].ToString(), span[parts[1]].ToString(), span[parts[2]].ToString(),
span[parts[3]].ToString(), span[parts[4]].ToString());
_books.Add(book.Isbn, book);
}
return new OrderLine(book, int.Parse(span[parts[5]]), decimal.Parse(span[parts[6]], CultureInfo.InvariantCulture));
}
}
Üç karar var. BookInfo bir sealed record: paylaşılan state değiştirilemez olmalı, yoksa bir satır için yapılan "düzeltme" iki bin satırı birden değiştirir. OrderLine bir readonly record struct: satırın kendisi ayrı bir heap object'i değil, List'in iç array'inde yan yana duran 32 byte'lık bir değer. Factory de .NET 9 ile gelen Dictionary.GetAlternateLookup<ReadOnlySpan<char>>'u kullanıyor: ISBN'i satırdan bir span olarak kesip sözlükte arıyoruz, kitap zaten varsa tek bir string bile üretilmiyor. String'ler yalnızca bir kitabı ilk kez gördüğümüzde, 500 kez oluşuyor. Span ile ayrıştırma için C# İleri serisindeki "Span<T> ve Memory<T>" yazısına bakabilirsin.
İki versiyonu aynı veriyle ölçelim:
namespace Bookstore.Reports;
public static class MemoryDemo
{
private const int Count = 1_000_000;
public static void Run()
{
Measure("naive ", () =>
{
List<OrderLineRow> rows = new(Count);
foreach (string line in ExportFile.Lines(Count))
rows.Add(OrderLineRow.Parse(line));
return rows;
});
BookInfoFactory factory = new();
Measure("flyweight", () =>
{
List<OrderLine> lines = new(Count);
foreach (string line in ExportFile.Lines(Count))
lines.Add(factory.Parse(line));
return lines;
});
Console.WriteLine($"distinct BookInfo instances: {factory.Count}");
}
// retained: iş bitince hâlâ canlı olan bellek; allocated: iş boyunca ayrılan toplam bellek
private static void Measure<T>(string name, Func<T> build)
{
long before = GC.GetTotalMemory(forceFullCollection: true);
long allocatedBefore = GC.GetTotalAllocatedBytes(precise: true);
T result = build();
long allocated = GC.GetTotalAllocatedBytes(precise: true) - allocatedBefore;
long retained = GC.GetTotalMemory(forceFullCollection: true) - before;
GC.KeepAlive(result);
Console.WriteLine($"{name}: retained {retained / 1024 / 1024,4} MB, allocated {allocated / 1024 / 1024,4} MB");
}
}
naive : retained 377 MB, allocated 579 MB
flyweight: retained 30 MB, allocated 170 MB
distinct BookInfo instances: 500
Canlı kalan bellek 377 MB'tan 30 MB'a indi; bu 30 MB'ın neredeyse tamamı bir milyon tane 32 byte'lık OrderLine. Bir milyon satır için 500 BookInfo instance'ı var. Toplam allocation'daki 170 MB'ın büyük kısmı ise kaynağın kendisi: ExportFile.Lines her satırı bir string olarak üretiyor ve iki versiyon da bu bedeli ödüyor. Gerçek bir dosya okuyucuda bu da PipeReader ya da satır satır span okuma ile azaltılabilir, ama o artık Flyweight değil, I/O (input/output) optimizasyonu.
string.Intern: Runtime'ın Kendi Flyweight Factory'si
.NET runtime'ı string'ler için hazır bir flyweight factory taşır: intern pool. string.Intern aynı değere sahip her string için havuzdaki tek bir referansı döndürür; havuzda yoksa verdiğin string'i ekler.
using System.Text;
namespace Bookstore.Reports;
public static class InternDemo
{
public static void Run()
{
string a = OrderLineRow.Parse("978-0441013593;Dune;Frank Herbert;Ace Books;Science Fiction;1;12.99").Category;
string b = OrderLineRow.Parse("978-0765326355;The Way of Kings;Brandon Sanderson;Tor Books;Science Fiction;1;15.00").Category;
string built = new StringBuilder("History").Append(" of Rome").ToString();
Console.WriteLine($"a == b: {a == b}, same object: {ReferenceEquals(a, b)}");
Console.WriteLine($"after Intern, same object: {ReferenceEquals(string.Intern(a), string.Intern(b))}");
Console.WriteLine($"IsInterned(\"{built}\"): {string.IsInterned(built) ?? "null"}");
}
}
a == b: True, same object: False
after Intern, same object: True
IsInterned("History of Rome"): null
İki satırdan ayrıştırılan "Science Fiction" değerce eşit ama iki ayrı object; intern ettikten sonra aynı object. Runtime'da oluşturulan ve kimsenin intern etmediği bir string için IsInterned null döndürüyor.
Peki neden BookInfoFactory yerine her alanı string.Intern'den geçirmedik? Microsoft Learn'deki String.Intern sayfası iki yan etkiyi açıkça yazıyor. Birincisi, intern edilmiş string'lerin belleği büyük ihtimalle CLR (Common Language Runtime) kapanana kadar serbest kalmaz; havuz process boyunca yaşayan global bir state. Rapor bitip sonuçlar atıldığında bile o 500 başlık bellekte kalır; kullanıcıdan gelen sınırsız değerleri intern etmek ise yavaş bir bellek sızıntısıdır. İkincisi, intern etmek için önce string'i oluşturman gerekir; allocation zaten yapılmış olur, yalnızca sonra çöpe gider. Kendi factory'n ise işin ömrüne bağlı: rapor bittiğinde factory ile birlikte bütün paylaşılan object'ler toplanabilir. Bir not daha: aynı sayfa, C# derleyicisinin assembly'leri varsayılan olarak CompilationRelaxations.NoStringInterning ile işaretlediğini ve literal'lerin havuza girmesinin garanti olmadığını söylüyor; kodunu "literal'ler zaten intern edilmiştir" varsayımına dayandırma.
.NET'in Önceden Paylaştığı Instance'lar
Flyweight'ın en sade hâli, değişmeyen ve sık istenen bir object'i bir kez oluşturup herkese aynısını vermek. BCL (Base Class Library) bunu her gün kullandığın birçok yerde yapıyor:
namespace Bookstore.Reports;
public static class CachedDemo
{
public static async Task RunAsync()
{
int[] empty = [];
Console.WriteLine($"Task.CompletedTask: {ReferenceEquals(Task.CompletedTask, Task.CompletedTask)}");
Console.WriteLine($"Array.Empty<int>(): {ReferenceEquals(Array.Empty<int>(), Array.Empty<int>())}");
Console.WriteLine($"int[] empty = []: {ReferenceEquals(empty, Array.Empty<int>())}");
Console.WriteLine($"Task.FromResult(true): {ReferenceEquals(Task.FromResult(true), Task.FromResult(true))}");
Console.WriteLine($"Task.FromResult(1000): {ReferenceEquals(Task.FromResult(1000), Task.FromResult(1000))}");
Console.WriteLine($"async Task<bool>: {ReferenceEquals(IsInStockAsync(), IsInStockAsync())}");
await Task.CompletedTask;
}
// Senkron tamamlanan async method: true için runtime'ın cache'lediği Task döner
private static async Task<bool> IsInStockAsync()
{
await Task.CompletedTask;
return true;
}
}
Task.CompletedTask: True
Array.Empty<int>(): True
int[] empty = []: True
Task.FromResult(true): True
Task.FromResult(1000): False
async Task<bool>: True
Task.CompletedTask ve Array.Empty<T>() her seferinde aynı instance. C#'ın collection expression'ı da boş array için yeni bir array oluşturmuyor, Array.Empty<int>()'ı kullanıyor. Task.FromResult(true) iki çağrıda aynı Task'ı döndürüyor, Task.FromResult(1000) ise her seferinde yenisini; runtime yalnızca birkaç sık değer için (bool gibi) hazır task tutuyor. Senkron tamamlanan bir async Task<bool> method da aynı cache'ten yararlanıyor. Bu object'lerin paylaşılabilmesinin tek nedeni değiştirilemez olmaları: tamamlanmış bir task'ın sonucu ya da boş bir array'in içeriği değişemez. StringComparer.Ordinal, CultureInfo.InvariantCulture ve EqualityComparer<T>.Default gibi state'siz ya da salt okunur singleton'lar da aynı mantıkla bir kez oluşturulup paylaşılıyor.
ArrayPool<T>: Flyweight'ın Yakın Akrabası
Facade yazısında File.ReadAllTextAsync'in buffer'ını ArrayPool<char>.Shared'dan kiraladığını görmüştük. ArrayPool<T> sık sık Flyweight örneği olarak anılır, ama aradaki farkı bilmek önemli: flyweight aynı anda birçok client tarafından paylaşılır ve bu yüzden değişmezdir; pool'daki bir array ise kiralandığı süre boyunca tek bir client'ındır ve o client onu istediği gibi değiştirir. Ortak noktaları, ikisinin de yeniden kullanım yoluyla allocation sayısını düşürmesi. Doğru ad object pool; Flyweight'ın "paylaş" fikrinin zamana yayılmış hâli diyebilirsin.
using System.Buffers;
namespace Bookstore.Reports;
public static class PoolDemo
{
public static void Run()
{
byte[] first = ArrayPool<byte>.Shared.Rent(1000);
Console.WriteLine($"asked 1000, got {first.Length}");
first[0] = 42;
ArrayPool<byte>.Shared.Return(first);
// Havuz array'i temizlemeden geri verir: önceki kullanıcının verisi hâlâ içinde
byte[] second = ArrayPool<byte>.Shared.Rent(1000);
Console.WriteLine($"same array again: {ReferenceEquals(first, second)}, second[0] = {second[0]}");
ArrayPool<byte>.Shared.Return(second, clearArray: true);
byte[] third = ArrayPool<byte>.Shared.Rent(1000);
Console.WriteLine($"after clearArray: third[0] = {third[0]}");
ArrayPool<byte>.Shared.Return(third);
}
}
asked 1000, got 1024
same array again: True, second[0] = 42
after clearArray: third[0] = 0
Çıktının üç satırı da birer kural. 1000 byte istedin, 1024 aldın: Rent en az istenen uzunlukta bir array verir, daha uzununu da verebilir; buffer.Length'e güvenip array'in tamamını işleme, kendi uzunluğunu ayrıca taşı. İkinci kiralamada aynı array geri geldi ve içinde önceki kullanıcının yazdığı 42 duruyordu: dokümantasyonun dediği gibi kiralanan array sıfırlanmış olmayabilir. İçinde şifre, token ya da kişisel veri olan bir buffer'ı geri verirken clearArray: true kullan. Ve Return'den sonra array'e dokunma; o artık başka birinin buffer'ı.
BenchmarkDotNet ile Ölçmek
GC.GetTotalMemory kaba bir ölçü; tek koşu, ısınma yok, istatistik yok. Kararı vermeden önce BenchmarkDotNet ile ölçelim: on bin satırı iki yöntemle ayrıştırmak ve 64 KB'lık bir buffer'ı her seferinde yeniden ayırmak ile havuzdan kiralamak.
using System.Buffers;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Bookstore.Reports;
BenchmarkRunner.Run([typeof(ReportBenchmarks), typeof(BufferBenchmarks)]);
[MemoryDiagnoser]
public class ReportBenchmarks
{
private readonly string[] _lines = ExportFile.Lines(10_000).ToArray();
[Benchmark(Baseline = true)]
public int NaiveRows()
{
List<OrderLineRow> rows = new(_lines.Length);
foreach (string line in _lines)
rows.Add(OrderLineRow.Parse(line));
return rows.Count;
}
[Benchmark]
public int FlyweightRows()
{
BookInfoFactory factory = new();
List<OrderLine> lines = new(_lines.Length);
foreach (string line in _lines)
lines.Add(factory.Parse(line));
return lines.Count;
}
}
[MemoryDiagnoser]
public class BufferBenchmarks
{
private const int Size = 64 * 1024;
// Her çağrıda yeni 64 KB buffer
[Benchmark(Baseline = true)]
public byte NewBuffer()
{
byte[] buffer = new byte[Size];
buffer.AsSpan(0, Size).Fill(7);
return buffer[Size - 1];
}
// Aynı buffer havuzdan kiralanıp geri veriliyor
[Benchmark]
public byte PooledBuffer()
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(Size);
try
{
buffer.AsSpan(0, Size).Fill(7);
return buffer[Size - 1];
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
BenchmarkDotNet v0.15.8, macOS 27.0.1 (26A434) [Darwin 27.0.0]
Apple M5, 1 CPU, 10 logical and 10 physical cores
.NET SDK 10.0.402
[Host] : .NET 10.0.13 (10.0.13, 10.0.1326.50504), Arm64 RyuJIT armv8.0-a
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio |
|-------------- |-----------:|---------:|---------:|------:|--------:|---------:|---------:|--------:|-----------:|------------:|
| NaiveRows | 1,274.2 us | 24.61 us | 32.86 us | 1.00 | 0.04 | 531.2500 | 250.0000 | - | 4340.68 KB | 1.00 |
| FlyweightRows | 661.0 us | 7.89 us | 6.99 us | 0.52 | 0.01 | 99.6094 | 99.6094 | 99.6094 | 494.65 KB | 0.11 |
| Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|------------- |-----------:|---------:|---------:|-----------:|------:|--------:|-------:|----------:|------------:|
| NewBuffer | 1,533.5 ns | 10.49 ns | 8.76 ns | 1,530.4 ns | 1.00 | 0.01 | 7.8106 | 65560 B | 1.00 |
| PooledBuffer | 664.0 ns | 21.40 ns | 62.77 ns | 638.4 ns | 0.43 | 0.04 | - | - | 0.00 |
On bin satırda flyweight versiyonu allocation'ı yaklaşık 4.2 MB'tan 495 KB'a indirdi (yüzde 89 daha az) ve yaklaşık iki kat hızlı çalıştı; Gen0 ve Gen1 sütunlarındaki düşüş, kazancın garbage collector'a düşen işin azalmasından geldiğini gösteriyor. Bir ayrıntı dikkat çekici: flyweight satırında Gen2 sütunu dolu. Nedeni, 10.000 tane 32 byte'lık OrderLine tutan iç array'in 85.000 byte eşiğini geçip LOH'a (Large Object Heap) düşmesi; naif versiyonun referans array'i 80 KB ile eşiğin altında kalıyor. Benchmark'lar bu tür sürprizleri görünür kılar. Buffer tarafında havuzdan kiralamak her çağrıdaki 64 KB'lık allocation'ı tamamen ortadan kaldırdı ve süreyi yarıdan fazla düşürdü. new byte[] her seferinde hem bellek ayırır hem de array'i sıfırlar; havuz ise az önce gördüğümüz gibi array'i sıfırlamadan geri verir. Bu sayılar Apple M5 üzerinde, tek bir makinede alındı; kendi donanımında ve kendi veri dağılımınla ölç.
Ölçme alışkanlığı ve BenchmarkDotNet'in kendisi için C# İleri serisindeki "Performans: Allocation, Struct'lar ve BenchmarkDotNet ile Ölçmek" yazısına bakabilirsin.
Sık Düşülen Tuzaklar
Mutable flyweight. BookInfo bir class olsaydı ve Title'ın setter'ı olsaydı, raporda bir satırın başlığını "düzelten" kod o kitabın iki bin satırını birden değiştirirdi. Paylaşılan state'in değiştirilemez olması Flyweight'ın ön koşulu; değiştirmek gerekiyorsa with ile yeni bir instance oluştur ve factory'den yeni anahtarla iste.
Thread-safe olmayan factory. Örnekteki Dictionary tek thread'li ayrıştırma için. Satırları Parallel.ForEach ile işlemeye başladığın an iki thread aynı anda Add çağırabilir. ConcurrentDictionary kullan; o da .NET 9'dan beri GetAlternateLookup destekliyor.
Factory'yi atlamak. Bir yerde new BookInfo(...) yazılırsa o satır paylaşımın dışına çıkar ve kimse fark etmez; bellek yavaşça geri gelir. Paylaşılan tipin constructor'ını factory'nin olduğu assembly'ye kapatmak (internal) bunu derleyiciye denetletir.
Global ve sınırsız havuz. string.Intern ya da static bir sözlükte kullanıcı girdisini biriktirmek, flyweight'ı bir bellek sızıntısına çevirir. Havuzun ömrünü işin ömrüne bağla ya da boyutunu sınırla.
Ölçmeden optimize etmek. Flyweight kodu karmaşıklaştırır: satır artık kendi verisini taşımıyor, bir factory'ye ve paylaşılan bir object'e bağımlı. Yüz satırlık bir listede bunun hiçbir getirisi yok.
Ne Zaman Kullanma
Flyweight ancak üç koşul birlikte sağlandığında anlamlı: çok sayıda object var (yüz binler, milyonlar), bu object'lerin state'inin büyük kısmı tekrar ediyor ve o tekrar eden kısım değiştirilemez. Bunlardan biri eksikse kullanma. Veri zaten veritabanında normalize edilmiş duruyorsa, raporu bellekte kurmak yerine SQL'de (Structured Query Language) gruplamak çoğu zaman daha iyi bir cevap; Flyweight, belleğe almak zorunda olduğun büyük veri için. Satırların her biri farklı kitaptan geliyorsa paylaşacak bir şey yok. Ve profiler ya da benchmark bellek baskısını göstermiyorsa, okunaklı olan naif kodda kal.
Küçük Bir Deneme
★☆☆ BookInfo'yu sealed record yerine setter'lı bir class yap ve flyweight satırlarından birinin başlığını değiştir. Aynı kitabın kaç satırı etkilendi? Naif versiyonda aynı değişiklik ne yapardı?
★★☆ Kategori ve yayınevi için ayrı birer küçük flyweight factory ekle (Category, Publisher) ve BookInfo bu paylaşılan object'lere referans tutsun. 500 kitapta bu değişiklik ölçülebilir bir fark yaratıyor mu? 500.000 farklı kitapta ne olur?
★★★ Ayrıştırmayı Parallel.ForEach ile yap ve factory'yi ConcurrentDictionary ile thread-safe hâle getir. BenchmarkDotNet ile tek thread'li versiyonla karşılaştır; aynı ISBN için iki thread aynı anda gelirse kaç BookInfo oluşabiliyor?
Bir sonraki yazıda structural pattern'lerin sonuncusuna geçiyoruz: Proxy. Gerçek object'in önüne aynı interface'le geçip erişimi kontrol etmeyi; lazy loading'i, remote ve protection proxy'leri, DispatchProxy ile runtime'da proxy üretmeyi ve Entity Framework Core'un (EF Core) lazy-loading proxy'lerinin gizlediği N+1 bedelini konuşacağız.