Mert Özen Her Satırda Daha İleri
Yazılım Tasarımı Design Patterns — #06

Prototype Pattern: record ile with, Shallow ve Deep Copy ve ICloneable'ın Neden Önerilmediği

Mert Özen 7 Kas 2026 16 dk 4 görüntülenme
Prototype Pattern: record ile with, Shallow ve Deep Copy ve ICloneable'ın Neden Önerilmediği

Prototype, yeni object'i sıfırdan kurmak yerine konfigüre edilmiş bir örneği kopyalayarak üretir; C#'ta record'ların with expression'ı bunu runtime tipini koruyarak yapar ama kopya shallow'dur. Prototype registry, copy constructor ile kontrollü deep copy, ICloneable'ın belirsiz sözleşmesi, JSON ile kopyalamanın ölçülmüş bedeli ve with'in doğrulamayı atlayan tuzakları.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un kurumsal satış ekibi okullara her eylül aynı teklifi gönderiyor: iki klasik roman, otuzar adet, yüzde 10 indirim. Ekip bu teklifi bir kez hazırlayıp template olarak saklamış; her yeni okul için template'in kopyasını alıp müşteri adını değiştiriyor, gerekirse satır ekliyor. Teklif bir record ve kopya with ile alınıyor:

Quote template = new("Template: school year start", [
    new QuoteLine("978-0141439518", 30, 9.50m),
    new QuoteLine("978-0547928227", 30, 11.90m)
], DiscountRate: 0.10m);

Quote forHillside = template with { Customer = "Hillside High School" };
forHillside.Lines.Add(new QuoteLine("978-0061120084", 25, 8.75m));

Console.WriteLine($"Template lines: {template.Lines.Count}");
Console.WriteLine($"Hillside lines: {forHillside.Lines.Count}");

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public sealed record Quote(string Customer, List<QuoteLine> Lines, decimal DiscountRate);

Hillside High School'un teklifine bir satır eklendi. Template'in kaç satırı var? Bir sonraki okul teklifini bu template'ten kopyaladığında kaç satırla başlayacak?

Kopyalayarak Oluşturmak: Prototype'ın Asıl Derdi

Çıktı:

Template lines: 3
Hillside lines: 3

Template de üç satırlı oldu. Bir sonraki okul, Hillside için eklenen kitabı hiç istemediği hâlde teklifinde görecek. Nedenine birazdan geleceğiz; önce bu kodun hangi pattern'i uygulamaya çalıştığına bakalım.

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ı) Prototype'ı creational pattern'ler arasında sayar. Amacı kısaca şu: hangi tür object yaratılacağını örnek bir instance ile belirlemek ve yeni object'leri bu örneği kopyalayarak üretmek. Kitap üç durumda bu yolu önerir. Birincisi, yaratılacak class'ın runtime'da belli olması. İkincisi, ürün hiyerarşisine paralel bir factory hiyerarşisi kurmaktan kaçınmak. Üçüncüsü, object'lerin yalnız birkaç farklı state kombinasyonu olması: her seferinde sıfırdan doğru state'i kurmak yerine hazır prototipleri saklayıp kopyalamak daha kolaydır. Bookstore'un teklif template'leri tam bu üçüncü durum.

Pattern'in iki parçası var. Prototype, kendini kopyalayabilen object'tir. Client ise kopyalanacak object'in concrete class'ını bilmeden ondan yenisini ister. GoF bunun yanına bir de prototype manager (registry) koyar: prototipleri bir anahtarla saklayan ve her istekte kopyasını veren küçük bir katalog. C#'ta record'ların with expression'ı bu sözleşmeyi dilin içine yerleştirmiş durumda, çünkü kopya, değişkenin compile time tipine göre değil, object'in runtime tipine göre üretilir. Template'leri okul ve kurumsal müşteri için iki ayrı tipe ayırıp bir registry'ye koyalım:

QuoteTemplates templates = new();
templates.Register("school", new SchoolQuote("(template)", [
    new QuoteLine("978-0141439518", 30, 9.50m),
    new QuoteLine("978-0547928227", 30, 11.90m)
], DiscountRate: 0.10m, AcademicYear: "2026-2027"));
templates.Register("corporate", new CorporateQuote("(template)", [
    new QuoteLine("978-0132350884", 10, 39.90m)
], DiscountRate: 0.05m, PaymentTermDays: 60));

Quote hillside = templates.Create("school", "Hillside High School");
hillside.Lines.Add(new QuoteLine("978-0061120084", 25, 8.75m));
Quote riverside = templates.Create("corporate", "Riverside Logistics");

Console.WriteLine($"{hillside.GetType().Name}: {hillside.Customer}, lines = {hillside.Lines.Count}");
Console.WriteLine($"{riverside.GetType().Name}: {riverside.Customer}, lines = {riverside.Lines.Count}");
Console.WriteLine($"School template still has {templates.Create("school", "x").Lines.Count} lines");

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public abstract record Quote(string Customer, List<QuoteLine> Lines, decimal DiscountRate)
{
    // Copy constructor: with ve tüm türetilmiş record'lar bunu kullanır
    protected Quote(Quote original)
    {
        Customer = original.Customer;
        Lines = [.. original.Lines];
        DiscountRate = original.DiscountRate;
    }
}

public sealed record SchoolQuote(string Customer, List<QuoteLine> Lines, decimal DiscountRate, string AcademicYear)
    : Quote(Customer, Lines, DiscountRate);

public sealed record CorporateQuote(string Customer, List<QuoteLine> Lines, decimal DiscountRate, int PaymentTermDays)
    : Quote(Customer, Lines, DiscountRate);

// Prototype registry: konfigüre edilmiş instance'ları key ile saklar, her istekte kopyasını verir
public sealed class QuoteTemplates
{
    private readonly Dictionary<string, Quote> _prototypes = [];

    public void Register(string key, Quote prototype) => _prototypes[key] = prototype;

    // Çağıran concrete tipi bilmiyor; with, runtime tipini koruyarak kopyalar
    public Quote Create(string key, string customer) => _prototypes[key] with { Customer = customer };
}
SchoolQuote: Hillside High School, lines = 3
CorporateQuote: Riverside Logistics, lines = 1
School template still has 2 lines

QuoteTemplates.Create elinde yalnız bir Quote referansı olduğunu biliyor; SchoolQuote ya da CorporateQuote adını hiç anmıyor. Yine de çıkan kopyalar doğru tipte ve AcademicYear, PaymentTermDays gibi alt tipe özgü değerleri taşıyor. Bunu sağlayan, derleyicinin her record için ürettiği gizli <Clone>$ method'u: virtual olduğu için with her zaman en alttaki tipin kopyasını alır. Yeni bir teklif türü eklemek bir record yazıp onu registry'ye kaydetmek demek; ne QuoteTemplates değişiyor ne de her tür için ayrı bir factory class'ı açılıyor. Bu serinin Factory Method ve Abstract Factory yazılarında gördüğümüz paralel hiyerarşi burada hiç oluşmuyor.

Template de artık bozulmuyor: Hillside'a eklenen satır yalnız Hillside'ın teklifinde. Farkı yaratan, base record'a yazdığımız tek bir constructor. Neden gerektiğini anlamak için kopyanın ne kadar derine indiğine bakmak lazım.

Shallow Copy ve Deep Copy

Shallow copy, object'in field'larını birebir yeni bir object'e kopyalar. Field bir value type ise (int, decimal) değerin kendisi kopyalanır. Reference type ise (List<T>, başka bir class) yalnız referans kopyalanır; iki object aynı listeyi gösterir. Deep copy ise referansların gösterdiği object'leri de kopyalar, gerekiyorsa onların içindekileri de.

Derleyicinin record için ürettiği copy constructor shallow'dur; Microsoft Learn'deki with expression sayfası da bunu açıkça söyler. Açılıştaki örnekte forHillside.Lines ile template.Lines aynı List<QuoteLine> object'iydi, bu yüzden Add ikisini birden büyüttü. Record'larda bu davranışı değiştirmenin yolu copy constructor'ı kendin yazmak: with onu çağırır ve türetilmiş record'ların derleyicinin ürettiği copy constructor'ları da base'inkini çağırdığı için tek bir yerde yazman yeter. Record değilse aynı işi object'ten gelen protected MemberwiseClone ile yaparsın:

OrderDraft draft = new() { Customer = "Ada", ShippingCity = "Izmir" };
draft.Items.Add("978-0141439518");

OrderDraft shallow = draft.ShallowCopy();
OrderDraft deep = draft.DeepCopy();
draft.Items.Add("978-0547928227");

Console.WriteLine($"shallow items: {shallow.Items.Count}, deep items: {deep.Items.Count}");

public sealed class OrderDraft
{
    public required string Customer { get; set; }
    public required string ShippingCity { get; set; }
    public List<string> Items { get; private set; } = [];

    // MemberwiseClone protected: field'ları birebir kopyalar, reference'ları paylaşır
    public OrderDraft ShallowCopy() => (OrderDraft)MemberwiseClone();

    public OrderDraft DeepCopy()
    {
        OrderDraft copy = (OrderDraft)MemberwiseClone();
        copy.Items = [.. Items];
        return copy;
    }
}
shallow items: 2, deep items: 1

Burada dikkat edilecek nokta, "deep"in bir derece olduğu. QuoteLine immutable bir record; değişemeyeceği için iki teklifin aynı satır object'ini paylaşması zararsız. Kopyalanması gereken tek şey değişebilen kap, yani liste. Her şeyi recursive olarak kopyalamak hem pahalı hem de çoğu zaman gereksiz. Doğru soru "deep mi shallow mı" değil, "bu object'in hangi parçaları değişebilir ve kopyayla paylaşılmamalı" sorusu.

ICloneable Neden Önerilmiyor

.NET'in bir kopyalama sözleşmesi zaten var: System.ICloneable, tek method'u object Clone(). Sorun, sözleşmenin hiçbir şey vaat etmemesi. Microsoft Learn'deki API (Application Programming Interface) sayfası, implementasyonun deep mi shallow mı yoksa ikisinin arası mı kopyalayacağını belirtmediğini, hatta tüm property'leri kopyalamayı bile şart koşmadığını söyler ve bu yüzden ICloneable'ın public API'lerde implement edilmemesini önerir. .NET kütüphane tasarım kurallarını derleyen Framework Design Guidelines (Krzysztof Cwalina ve Brad Abrams) aynı tavsiyeyi verir. Dönüş tipinin object olması da her çağrıda bir cast demek.

BCL (Base Class Library, .NET'in temel kütüphanesi) içinde Clone adını taşıyan method'ların ne kadar farklı davrandığını görmek bu tavsiyeyi somutlaştırıyor:

using System.Data;
using System.Globalization;
using System.Text.Json.Nodes;
using System.Xml;

// string: Clone() kopya üretmez, aynı instance'ı döner
string isbn = "978-0141439518";
Console.WriteLine($"string.Clone same instance: {ReferenceEquals(isbn, isbn.Clone())}");

// Array: shallow copy; element'ler aynı object'ler
List<string>[] shelves = [["Emma"], ["Dune"]];
List<string>[] copy = (List<string>[])shelves.Clone();
copy[0].Add("Persuasion");
Console.WriteLine($"Array.Clone original shelf 0: {shelves[0].Count} books");

// NumberFormatInfo: shallow copy ama IsReadOnly her zaman false olur
NumberFormatInfo readOnly = CultureInfo.InvariantCulture.NumberFormat;
NumberFormatInfo nfiCopy = (NumberFormatInfo)readOnly.Clone();
Console.WriteLine($"NumberFormatInfo IsReadOnly: {readOnly.IsReadOnly} -> {nfiCopy.IsReadOnly}");

// XmlNode: derinliği çağıran seçer
XmlDocument doc = new();
doc.LoadXml("<order><item isbn='978-0141439518'/><item isbn='978-0547928227'/></order>");
XmlNode order = doc.DocumentElement!;
Console.WriteLine($"XmlNode.CloneNode(false) children: {order.CloneNode(false).ChildNodes.Count}");
Console.WriteLine($"XmlNode.CloneNode(true)  children: {order.CloneNode(true).ChildNodes.Count}");

// DataTable: Clone yalnız şemayı, Copy şema + veriyi kopyalar
DataTable books = new("Books");
books.Columns.Add("Isbn", typeof(string));
books.Rows.Add("978-0141439518");
books.Rows.Add("978-0547928227");
Console.WriteLine($"DataTable.Clone rows: {books.Clone().Rows.Count}, Copy rows: {books.Copy().Rows.Count}");

// JsonNode: ICloneable değil, adı her şeyi söylüyor
JsonNode cart = JsonNode.Parse("""{"items":[{"isbn":"978-0141439518"}]}""")!;
JsonNode cartCopy = cart.DeepClone();
cartCopy["items"]!.AsArray().Add(new JsonObject { ["isbn"] = "978-0547928227" });
Console.WriteLine($"JsonNode.DeepClone original items: {cart["items"]!.AsArray().Count}");
string.Clone same instance: True
Array.Clone original shelf 0: 2 books
NumberFormatInfo IsReadOnly: True -> False
XmlNode.CloneNode(false) children: 0
XmlNode.CloneNode(true)  children: 2
DataTable.Clone rows: 0, Copy rows: 2
JsonNode.DeepClone original items: 1
APINe kopyalıyor
string.Clone()Hiçbir şey; aynı instance'ı döner (string immutable)
Array.Clone()Shallow; element'ler paylaşılır
NumberFormatInfo.Clone()Shallow, ama IsReadOnly her zaman false
XmlNode.CloneNode(bool deep)Derinliği çağıran seçer
DataTable.Clone() / Copy()Yalnız şema / şema ve satırlar
JsonNode.DeepClone()Tüm ağaç; adı sözleşmeyi söylüyor

Aynı kelime altı farklı anlama geliyor. Son üç satırdaki API'ler bu belirsizliğin çözümünü de gösteriyor: derinliği parametreyle ya da method adıyla açık etmek. Kendi kodunda da aynısını yap: Clone yerine CopyForCustomer, DeepCopy gibi ne yaptığını söyleyen bir ad, dönüş tipi olarak da concrete tip. BCL'de buna güzel bir örnek JsonSerializerOptions'ın copy constructor'ı: new JsonSerializerOptions(existing), konfigüre edilmiş bir options object'inden değiştirilebilir bir kopya alır; prototype'ın en sade hâli.

Deep Copy'nin Yolları ve Bedeli

Deep copy için üç yol görürsün. Birincisi yukarıdaki gibi elle yazılmış copy constructor ya da MemberwiseClone artı düzeltmeler: hızlı ve açık, ama yeni bir field eklendiğinde onu da kopyalamayı unutma riski var. İkincisi serialize edip geri deserialize etmek: object graph'ını JSON'a (JavaScript Object Notation) yazıp okumak her şeyi gerçekten yeniden üretir ve kod yazmayı gerektirmez. Eski projelerde aynı işi BinaryFormatter ile yapan kod da görürsün; güvenlik açıkları yüzünden .NET 9'dan itibaren runtime'da implementasyonu yok ve her çağrıda PlatformNotSupportedException fırlatıyor. Üçüncüsü reflection ya da expression tree ile genel amaçlı kopyalayan üçüncü parti kütüphaneler.

JSON yolunun ilk bedeli doğruluk. Serializer yalnız public ve serialize edilebilir state'i görür; private field'lar, hesaplanan değerler, döngüsel referanslar ayrıca ayar ister. Polymorphism ise kendiliğinden çalışmaz. Registry örneğindeki template'i base tip üzerinden kopyalamaya çalışalım:

using System.Text.Json;

Quote template = new SchoolQuote("(template)", [new QuoteLine("978-0141439518", 30, 9.50m)], "2026-2027");

string json = JsonSerializer.Serialize(template);
Console.WriteLine(json);

try
{
    Quote copy = JsonSerializer.Deserialize<Quote>(json)!;
}
catch (NotSupportedException ex)
{
    Console.WriteLine(ex.Message);
}

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public abstract record Quote(string Customer, List<QuoteLine> Lines);

public sealed record SchoolQuote(string Customer, List<QuoteLine> Lines, string AcademicYear)
    : Quote(Customer, Lines);
{"Customer":"(template)","Lines":[{"Isbn":"978-0141439518","Quantity":30,"UnitPrice":9.50}]}
Deserialization of interface or abstract types is not supported. Type 'Quote'. Path: $ | LineNumber: 0 | BytePositionInLine: 1.

İki sorun birden: serialize edilen JSON'da AcademicYear yok, çünkü serializer declared tip olan Quote'un property'lerini yazdı; geri okurken de abstract tipten instance yaratamadı. Bunu [JsonPolymorphic] ve [JsonDerivedType] attribute'larıyla çözmek mümkün, ama artık kopyalama için serialization sözleşmesi tasarlıyorsun.

İkinci bedel performans. Yirmi satırlık bir teklifi üç yolla kopyalayan bir ölçüm (BenchmarkDotNet, .NET 10.0.13, M5 (Apple Silicon) işlemcili bir Mac):

using System.Text.Json;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<QuoteCopyBenchmarks>();

[MemoryDiagnoser]
public class QuoteCopyBenchmarks
{
    private readonly ShallowQuote _shallow;
    private readonly Quote _quote;

    public QuoteCopyBenchmarks()
    {
        List<QuoteLine> lines = [.. Enumerable.Range(1, 20).Select(i => new QuoteLine($"978-{i:D10}", 10 + i, 9.90m))];
        _shallow = new ShallowQuote("Hillside High School", lines, 0.10m);
        _quote = new Quote("Hillside High School", [.. lines], 0.10m);
    }

    [Benchmark(Baseline = true)]
    public ShallowQuote ShallowWith() => _shallow with { Customer = "Riverside Logistics" };

    [Benchmark]
    public Quote CopyConstructor() => _quote with { Customer = "Riverside Logistics" };

    [Benchmark]
    public Quote JsonRoundTrip()
    {
        string json = JsonSerializer.Serialize(_quote);
        return JsonSerializer.Deserialize<Quote>(json)! with { Customer = "Riverside Logistics" };
    }
}

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public sealed record ShallowQuote(string Customer, List<QuoteLine> Lines, decimal DiscountRate);

public sealed record Quote(string Customer, List<QuoteLine> Lines, decimal DiscountRate)
{
    // QuoteLine immutable olduğu için listeyi kopyalamak yeterince "deep"
    private Quote(Quote original)
    {
        Customer = original.Customer;
        Lines = [.. original.Lines];
        DiscountRate = original.DiscountRate;
    }
}
| Method          | Mean         | Ratio    | Gen0   | Allocated | Alloc Ratio |
|---------------- |-------------:|---------:|-------:|----------:|------------:|
| ShallowWith     |     3.673 ns |     1.00 | 0.0057 |      48 B |        1.00 |
| CopyConstructor |    18.235 ns |     4.97 | 0.0316 |     264 B |        5.50 |
| JsonRoundTrip   | 4,523.479 ns | 1,232.86 | 1.0300 |    8632 B |      179.83 |

Tablo kısaltıldı; ölçülen değerler BenchmarkDotNet raporundan aynen. Copy constructor shallow kopyadan yaklaşık beş kat yavaş, çünkü yirmi element'li yeni bir liste ayırıyor; yine de 18 nanosaniye. JSON gidiş-dönüşü 4,5 mikrosaniye ve 8,6 KB bellek: copy constructor'dan yaklaşık 250 kat yavaş. Adil olmak gerekirse JSON yolu QuoteLine'ları da yeniden üretiyor; ama immutable satırlar için o iş zaten gereksizdi. Saniyede birkaç teklif kopyalayan bir ekranda bu fark önemsiz, bir batch işinde milyon kez çalışan bir kopyada önemli. Ölçmeden karar verme; C# İleri serisindeki "C#'ta Performans: Allocation, Struct'lar ve BenchmarkDotNet ile Ölçmek" yazısı bu ölçümün nasıl kurulacağını anlatıyor.

with'in İki Sessiz Tuzağı

with bir constructor çağrısı değil. Önce copy constructor ile kopyayı üretir, sonra yalnız süslü parantez içinde verdiğin member'ları atar. Bu, constructor sırasında bir kez çalışan her kodun kopyada çalışmayacağı anlamına geliyor. Teklife toplam tutarı ve indirim oranı için bir sınır ekleyelim:

Quote template = new("(template)", [new QuoteLine("978-0141439518", 30, 9.50m)], DiscountRate: 0.10m);
Console.WriteLine($"Template total: {template.Total}");

Quote bigger = template with { Lines = [new QuoteLine("978-0141439518", 60, 9.50m)] };
Console.WriteLine($"Copy total:     {bigger.Total}");

Quote generous = template with { DiscountRate = 0.90m };
Console.WriteLine($"Copy discount:  {generous.DiscountRate}");

try
{
    Quote invalid = new("Hillside High School", [], DiscountRate: 0.90m);
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine(ex.Message);
}

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public sealed record Quote(string Customer, List<QuoteLine> Lines, decimal DiscountRate)
{
    // Constructor'da bir kez hesaplanıp saklanıyor
    public decimal Total { get; } = Lines.Sum(l => l.Quantity * l.UnitPrice) * (1 - DiscountRate);

    // Doğrulama yalnız primary constructor'dan geçerken çalışıyor
    public decimal DiscountRate { get; init; } =
        DiscountRate is >= 0 and <= 0.30m ? DiscountRate : throw new ArgumentOutOfRangeException(nameof(DiscountRate));
}
Template total: 256.5000
Copy total:     256.5000
Copy discount:  0.90
Specified argument was out of the range of valid values. (Parameter 'DiscountRate')

Satır sayısı iki katına çıkmış teklifin toplamı hâlâ 256,50: Total ilk object yaratılırken hesaplanıp field'a yazılmıştı, copy constructor da o field'ı kopyaladı. İndirim sınırı da yalnız primary constructor'dan geçen yolu koruyor; with { DiscountRate = 0.90m } yüzde 90 indirimli bir teklifi sorunsuz üretti. Microsoft Learn'deki with sayfası da hesaplanan property'lerin yaratılırken değil erişildiğinde hesaplanmasını önerir.

Düzeltme iki adım. Total expression-bodied property olur ve her erişimde hesaplanır. Doğrulama ise init accessor'ına taşınır; C# 14'teki field keyword'ü sayesinde backing field'ı elle tanımlamak gerekmiyor. Burada ölçerek bulduğum bir ayrıntı var: property initializer (= DiscountRate) backing field'a doğrudan yazar, init accessor'ından geçmez. Doğrulamayı yalnız accessor'a koyarsan bu kez constructor yolu korumasız kalır. İki yol da aynı kontrolü çağırmalı:

Quote template = new("(template)", [new QuoteLine("978-0141439518", 30, 9.50m)], DiscountRate: 0.10m);

Quote bigger = template with { Lines = [new QuoteLine("978-0141439518", 60, 9.50m)] };
Console.WriteLine($"Copy total: {bigger.Total}");

Try("ctor", () => new Quote("Hillside High School", [], DiscountRate: 0.90m));
Try("with", () => template with { DiscountRate = 0.90m });

static void Try(string path, Func<Quote> create)
{
    try { create(); Console.WriteLine($"{path}: accepted"); }
    catch (ArgumentOutOfRangeException ex) { Console.WriteLine($"{path}: rejected {ex.ParamName}"); }
}

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public sealed record Quote(string Customer, List<QuoteLine> Lines, decimal DiscountRate)
{
    // Her erişimde hesaplanır, kopyada da güncel kalır
    public decimal Total => Lines.Sum(l => l.Quantity * l.UnitPrice) * (1 - DiscountRate);

    // with init accessor'dan, primary constructor initializer'dan geçer; ikisi de aynı kontrolü çağırır
    public decimal DiscountRate
    {
        get;
        init => field = ValidRate(value);
    } = ValidRate(DiscountRate);

    private static decimal ValidRate(decimal rate) =>
        rate is >= 0 and <= 0.30m ? rate : throw new ArgumentOutOfRangeException(nameof(DiscountRate));
}
Copy total: 513.0000
ctor: rejected DiscountRate
with: rejected DiscountRate

Ne Zaman Kullanma

Prototype'ın gerekçesi, kopyalamanın sıfırdan kurmaktan daha ucuz ya da daha doğru olması. Object'i birkaç parametreyle yaratmak kolaysa, yeni bir instance yaratıp alanlarını doldurmak hem daha okunur hem de shallow copy riskini hiç doğurmaz. GoF'un örneklerinde prototipler pahalı kurulumlu, konfigüre edilmiş object'lerdi; üç property'li bir DTO (Data Transfer Object) için registry kurmak gereksiz bir katman.

İkinci durum immutable tipler. State değişemiyorsa paylaşmak güvenlidir ve kopyaya gerek yoktur. Teklifin satırlarını ImmutableArray<T> yapınca with artık bir tuzak değil:

using System.Collections.Immutable;

Quote template = new("(template)", [new QuoteLine("978-0141439518", 30, 9.50m)], DiscountRate: 0.10m);

// Kopya yok: Add yeni bir array döner, template'e dokunamaz
Quote hillside = template with
{
    Customer = "Hillside High School",
    Lines = template.Lines.Add(new QuoteLine("978-0061120084", 25, 8.75m))
};

Console.WriteLine($"Template lines: {template.Lines.Length}");
Console.WriteLine($"Hillside lines: {hillside.Lines.Length}");
Console.WriteLine($"Same line object shared: {ReferenceEquals(template.Lines[0], hillside.Lines[0])}");

public sealed record QuoteLine(string Isbn, int Quantity, decimal UnitPrice);

public sealed record Quote(string Customer, ImmutableArray<QuoteLine> Lines, decimal DiscountRate);
Template lines: 1
Hillside lines: 2
Same line object shared: True

Add mevcut array'i değiştirmiyor, yeni bir array dönüyor; satır object'leri de paylaşılıyor, ama ikisi de değişemediği için bunun bir zararı yok. Copy constructor yazmaya, deep copy'nin derinliğini tartışmaya gerek kalmadı. C# OOP (Object-Oriented Programming) serisindeki "C# record ve record struct: Value Equality ve with Expression'ı" yazısında with'in temellerini, Koleksiyonlar serisindeki "C# Immutable Collection'lar ve Doğru Collection'ı Seçmek" yazısında da bu collection'ların maliyetini bulabilirsin. Record'u gerçekten immutable tasarlarsan Prototype pattern'i, ayrı bir yapı olmaktan çıkıp dilin sıradan bir özelliğine dönüşür.

Üçüncüsü dependency injection ile yönetilen service'ler. Bir service'in "konfigüre edilmiş kopyasını" istiyorsan genellikle aradığın şey kopyalamak değil, container'a yeni bir instance ürettirmek: transient lifetime ya da bir factory.

Sık Düşülen Tuzaklar

with'i deep copy sanmak. Record'un içinde List<T>, Dictionary ya da mutable bir class varsa kopya ile orijinal onu paylaşır. Ya copy constructor'ı yaz ya da içerideki tipi immutable yap.

Copy constructor'a sonradan eklenen field'ı unutmak. Elle yazılmış kopya, yeni property eklendiğinde sessizce eksik kalır. Record'larda sorun daha da sinsi: kendi copy constructor'ını yazınca derleyici artık o tipin property'lerini senin yerine kopyalamaz. Kopyalamanın test'i, tüm property'leri dolu bir object'i kopyalayıp eşitliği karşılaştırmak kadar basit olabilir.

Constructor'daki doğrulamaya güvenmek. Yukarıda gördüğün gibi with primary constructor'ı atlıyor. Kural bir invariant ise onu init accessor'ında ya da her iki yolun çağırdığı bir method'da koru.

Kopyalamayı kimliği de kopyalayarak yapmak. Template'ten üretilen teklifin Id'si, oluşturulma tarihi ya da numarası template'ten gelmemeli. Bunları with içinde açıkça yeniden ver ya da kopyalama method'u yeni değerleri kendisi atasın.

Serialize/deserialize ile her şeyi kopyalamak. Kolay görünen bu yol polymorphism'i, private state'i ve performansı birlikte riske atıyor. Hızlı bir prototip için kabul edilebilir, sıcak bir kod yolunda değil.

ICloneable'ı public bir API'de sunmak. Çağıran kopyanın derinliğini bilemiyor ve sonucu cast etmek zorunda kalıyor. Ne yaptığını söyleyen, concrete tip dönen bir method daha dürüst.

Küçük Bir Deneme

★☆☆ Açılıştaki örneğe yalnız bir copy constructor ekleyerek template'in iki satırda kalmasını sağla. Sonra QuoteLine'ı mutable bir class'a çevir ve kopyadaki bir satırın Quantity'sini değiştir. Template etkilendi mi? Copy constructor'ı buna göre nasıl değiştirmen gerekir?

★★☆ QuoteTemplates'e bir Remove ve bir Keys ekle; sonra tüm template'lerin kopyalarını alıp her birinin orijinaliyle aynı satır listesini paylaşmadığını doğrulayan bir test yaz (ReferenceEquals ile).

★★★ Benchmark'a dördüncü bir benchmark method'u ekle: ImmutableArray<QuoteLine> kullanan bir teklif ve with. Ölçümü çalıştır ve sonucu copy constructor ile karşılaştır. Bir de JsonSerializerOptions'ı her çağrıda yeniden yaratan bir JSON yolu ekleyip farkı gözlemle.

Prototype ile creational pattern'leri bitirdik. Bir sonraki yazıda structural pattern'lere geçiyoruz ve ilk durak, elimizdeki class'ı değiştirmeden başka bir interface'e uydurmak: Adapter pattern ile iki farklı ödeme sağlayıcısının SDK'sını (Software Development Kit) Bookstore'un kendi IPaymentGateway'ine bağlamak.