Khởi tạo Trung bình 9 phút đọc

Prototype Pattern trong C#: with Expression vs ICloneable

Prototype pattern trong C# / .NET 10: clone object có sẵn bằng record with, vì sao ICloneable hỏng, và khi nào vẫn cần method Clone tường minh.

Mục lục
  1. Prototype pattern giải quyết bài toán gì trong C#?
  2. Vì sao ICloneable bị coi là hỏng trong .NET?
  3. Record với with expression diễn đạt Prototype pattern thế nào?
  4. Khi nào nên giữ method Clone tường minh?
  5. Deep clone hoạt động an toàn trong .NET 10 thế nào?
  6. Một ví dụ thật trong C# / .NET 10 trông thế nào?
  7. Đọc tiếp gì trong series?

Marketing manager tạo campaign giảm giá "Black Friday" trong admin: 20% off, áp mọi SKU, chạy 48 giờ, banner copy tiếng Anh. Campaign cần launch ở mười hai region. Mười một trong số đó kế thừa mọi setting trừ banner copy theo locale và override 1–3 điểm phần trăm theo địa phương. Hôm nay engineer copy lời gọi constructor mười hai lần, sửa tay khác biệt, và cầu nguyện không ai thêm field thứ mười ba vào DiscountCampaign quý sau.

Prototype pattern là câu trả lời. Lấy object đã cấu hình, copy nó, đổi vài field khác biệt, có instance mới. Ý đồ ngắn: tạo object mới bằng cách clone template có sẵn, không phải gọi constructor. Trong C#, đây là pattern già đẹp nhất nhóm creational — record hiện đại làm Prototype giáo khoa gần như một dòng. Chủ đề thú vị bài này dài là phải làm gì khi bản giáo khoa không đủ.

Vẫn là e-commerce shop. Builder chỉ cách lắp object từ input độc lập. Bài này chỉ cách tạo nhiều biến thể nhỏ của object đã có.

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

Pattern xứng chỗ khi dựng instance mới từ con số không tốn hơn copy một cái có sẵn rồi đổi vài field. Ba hình dạng cụ thể:

  1. Biến thể cấu hình. Một DiscountCampaign base mười hai field cộng mười một biến thể region khác nhau hai field mỗi cái. Viết lại cả mười hai field cho mỗi biến thể là bug copy-paste chờ ngày.
  2. Template tốn kém để dựng. Một HttpClient cấu hình retry policy, default header, base address. Clone client đã cấu hình cho mỗi call rẻ hơn dựng lại.
  3. Test fixture pre-loaded. Một Customer với địa chỉ đầy đủ, payment method, ba order lịch sử. Mỗi test cần bản gần copy với một field khác.

Cái không phải bài toán Prototype: "Tôi muốn share một instance" (đó là Singleton); "Tôi muốn lắp object mới từ input" (đó là Builder). Prototype xuất phát từ template; hai cái kia xuất phát từ con số không.

Vì sao ICloneable bị coi là hỏng trong .NET?

.NET BCL có System.ICloneable từ phiên bản 1. Đây là một trong những khuyến nghị nhất quán nhất trong lịch sử framework rằng bạn không nên implement nó. Ba lý do:

Tài liệu chính thức của Microsoft về ICloneable nói thẳng: đừng implement nó; cung cấp method clone typed thay vào đó. Prototype pattern không bị ảnh hưởng bởi lời khuyên này — ý đồ của nó không phụ thuộc interface hỏng. Chúng ta chỉ trao cho ý đồ một implementation tốt hơn.

Record với with expression diễn đạt Prototype pattern thế nào?

record C# 9 và biểu thức with biên dịch xuống đúng Prototype pattern: một copy constructor tạo instance mới giống hệt source, rồi áp các thay đổi bạn nêu.

public sealed record DiscountCampaign
{
    public required string Code      { get; init; }
    public required decimal Percent  { get; init; }
    public required DateTime StartsAt { get; init; }
    public required DateTime EndsAt  { get; init; }
    public string Region             { get; init; } = "global";
    public string BannerCopy         { get; init; } = "";
    public IReadOnlyList<string> ApplicableSkus { get; init; } = Array.Empty<string>();
    public bool   StackableWithCoupons { get; init; }
}

Tạo mười một biến thể region từ một template giờ là một biểu thức/cái:

var blackFriday = new DiscountCampaign
{
    Code = "BF24",
    Percent = 20m,
    StartsAt = new DateTime(2026, 11, 28, 0, 0, 0, DateTimeKind.Utc),
    EndsAt   = new DateTime(2026, 11, 30, 0, 0, 0, DateTimeKind.Utc),
    BannerCopy = "20% off everything",
};

var blackFridayEu = blackFriday with { Region = "EU", Percent = 22m,
    BannerCopy = "20 % auf alles" };

var blackFridayJp = blackFriday with { Region = "JP",
    BannerCopy = "全品20%オフ" };

Dòng chảy nhìn thế này:

flowchart LR
    Template["DiscountCampaign<br>blackFriday"]
    EU["DiscountCampaign<br>biến thể EU"]
    JP["DiscountCampaign<br>biến thể JP"]
    BR["DiscountCampaign<br>biến thể BR"]

    Template -->|with { Region=EU, Percent=22 }| EU
    Template -->|with { Region=JP, BannerCopy=... }| JP
    Template -->|with { Region=BR, Percent=18 }| BR

Ba điều cần để ý:

Cho 80% Prototype use case trong .NET 10, đây là toàn bộ pattern. Hai mục tiếp che các case cần nhiều hơn.

Khi nào nên giữ method Clone tường minh?

Hình dạng record with che các type immutable, shallow-clone-safe. Ba case cần method tường minh thay vào đó:

Implementation method clone tường minh trong C# hiện đại thường tự là một with:

public sealed record CartLine
{
    public required string Sku { get; init; }
    public required int Quantity { get; init; }
    public List<string> Notes { get; init; } = new();   // mutable

    public CartLine DeepClone() => this with { Notes = new List<string>(Notes) };
}

Pattern được giữ; body của Clone() giờ là một biểu thức xử lý các phần with không xử lý được.

Deep clone hoạt động an toàn trong .NET 10 thế nào?

Khi deep clone thật sự cần (đồ thị object mutable, domain object kéo từ EF Core có thể mang reference entity tracked), ba kỹ thuật vừa với codebase thật:

Cái bạn không nên làm trong .NET 10: dùng BinaryFormatter. Nó đã deprecated và gỡ vì lý do bảo mật; coi mọi tutorial gợi ý nó là lỗi thời.

Cho hầu hết Prototype use case, JSON round-trip là câu trả lời đơn giản nhất mà đúng. Tối ưu performance đến sau.

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

Hình dạng đầy đủ ship được codebase thật. Template campaign load từ config một lần lúc startup là AddSingleton, và mỗi request cần biến thể region clone qua with:

public sealed record DiscountCampaign
{
    public required string Code       { get; init; }
    public required decimal Percent   { get; init; }
    public required DateTime StartsAt { get; init; }
    public required DateTime EndsAt   { get; init; }
    public string Region              { get; init; } = "global";
    public string BannerCopy          { get; init; } = "";
    public IReadOnlyList<string> ApplicableSkus  { get; init; } = Array.Empty<string>();
    public bool StackableWithCoupons  { get; init; }
}

public interface ICampaignTemplates
{
    DiscountCampaign Get(string code);
    DiscountCampaign LocaliseFor(string code, string region, decimal? percentOverride = null, string? bannerCopy = null);
}

public sealed class CampaignTemplates : ICampaignTemplates
{
    private readonly IReadOnlyDictionary<string, DiscountCampaign> _templates;

    public CampaignTemplates(IConfiguration config)
    {
        _templates = config.GetSection("Campaigns")
            .Get<Dictionary<string, DiscountCampaign>>() ?? new();
    }

    public DiscountCampaign Get(string code) => _templates[code];

    public DiscountCampaign LocaliseFor(string code, string region,
        decimal? percentOverride = null, string? bannerCopy = null)
    {
        var template = _templates[code];
        return template with
        {
            Region     = region,
            Percent    = percentOverride ?? template.Percent,
            BannerCopy = bannerCopy ?? template.BannerCopy,
        };
    }
}

// Composition root
builder.Services.AddSingleton<ICampaignTemplates, CampaignTemplates>();

// Caller: regional pricing service xin biến thể đã localise
public sealed class RegionalPricing
{
    private readonly ICampaignTemplates _templates;
    public RegionalPricing(ICampaignTemplates templates) => _templates = templates;

    public DiscountCampaign GetForRegion(string code, string region)
        => region switch
        {
            "EU" => _templates.LocaliseFor(code, "EU", percentOverride: 22m, bannerCopy: "20 % auf alles"),
            "JP" => _templates.LocaliseFor(code, "JP", bannerCopy: "全品20%オフ"),
            _    => _templates.Get(code),
        };
}

// Test
[Fact]
public void Localised_variant_overrides_only_specified_fields()
{
    var template = new DiscountCampaign
    {
        Code = "BF24", Percent = 20m,
        StartsAt = new DateTime(2026,11,28), EndsAt = new DateTime(2026,11,30),
        BannerCopy = "20% off",
    };
    var eu = template with { Region = "EU", Percent = 22m, BannerCopy = "20 % auf alles" };

    Assert.Equal("BF24",   eu.Code);             // không đổi
    Assert.Equal(22m,      eu.Percent);          // override
    Assert.Equal("EU",     eu.Region);
    Assert.Equal("20 % auf alles", eu.BannerCopy);
    Assert.Equal(template.StartsAt, eu.StartsAt); // không đổi
}

Cái thiếu trong code là toàn bộ Prototype pattern giáo khoa. Không có interface IPrototype, không có method Clone(), không có ICloneable. Pattern sống trong biểu thức with. Nhận ra cái đó là giá trị thật của việc học pattern.

Đọc tiếp gì trong series?

Một ghi chú đóng nhóm Creational: mọi pattern trong nhóm này đều teo lại khi C# hiện đại tới. Singleton thành AddSingleton. Factory Method thành AddKeyedScoped. Abstract Factory thành DI registration per-scope. Builder thành record cộng class fluent optional. Prototype thành with. Pattern sống vì ý đồ không lỗi thời; implementation thuộc về một ngôn ngữ chậm hơn. Khi bạn vào nhóm Structural kế tiếp, để mắt mẫu thu nhỏ tương tự — và những case implementation giáo khoa vẫn thắng.

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

ICloneable còn an toàn dùng trong C# hiện đại không?
Không. ICloneable.Clone() trả object, không bao giờ chỉ rõ shallow hay deep, và ép mọi consumer phải cast. Hướng dẫn chính thức của Microsoft khuyến nghị không implement nó. Ưu tiên record với with, method Clone() typed tường minh, hoặc System.Text.Json.Serialize/Deserialize cho deep copy. Chỉ implement ICloneable khi API có sẵn ép phải dùng.
Shallow và deep cloning trong .NET khác nhau thế nào?
Shallow clone copy lớp ngoài nhưng dùng chung reference cho object lồng — sửa list lồng mutate cả hai bản. Deep clone copy đệ quy mọi thứ. record with là shallow. Cho deep clone, cách an toàn nhất là JSON round-trip (JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(x))) hoặc tự viết DeepClone() đệ quy with-clone từng record lồng.
Khi nào nên dùng Prototype thay vì Builder hoặc Factory Method?
Dùng Prototype khi đã có object đã cấu hình và cần bản gần copy với vài field khác. Dùng Builder khi lắp ráp từ input độc lập. Dùng Factory Method khi câu hỏi là khởi tạo concrete type nào. Tách biệt ở điểm xuất phát: Prototype xuất phát từ template, Builder từ số không, Factory Method từ key.
Record với with expression cài Prototype pattern thế nào?
Record tự động định nghĩa copy constructor và biểu thức with gọi nó. var blackFridayEu = blackFriday with { Region = "EU", Percent = 22 }; chính là ý đồ Prototype giáo khoa: lấy object đã cấu hình, đổi vài field, có instance immutable mới. CLR sinh boilerplate; bạn viết tập thay đổi.