iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
自我挑戰組

一杯咖啡的設計課:30 天 Design Pattern 的自我修煉系列 第 13

Day 13|Prototype(原型模式) 用複製原型,整理客製化訂單

  • 分享至 

  • xImage
  •  

今天來聊聊 Prototype 原型模式

我們來看看原文的定義,來自 Design Patterns: Elements of Reusable Object-Oriented Software

Specify the kinds of objects to create using a prototypical instance, and create new objects by copying this prototype.

用中文理解可以是

先準備好一個「原型(Prototype)」實體當範本,之後要建立新物件時,不透過建構子重新跑一次建構流程,而是直接複製這個範本,再依需求做局部調整

核心特性

  1. 複製代替重建:不走耗時的建構流程,直接拷貝現有實體
  2. 範本自帶預設值:原型已設定好繁瑣細節,複製出來就自帶基礎設定
  3. 改動互不干擾:複製品與原型彼此獨立,修改局部不影響範本

先舉個生活化的例子,找工作投履歷的時候,我們通常會先準備好一份「範本履歷」,姓名、學歷、大部分的技能都先填好

public class Resume
{
    public string Name { get; set; }
    public string Title { get; set; }
    public int YearsOfExperience { get; set; }

    // 自定義 Clone(),回傳型別 Resume
    public Resume Clone()
    {
        return (Resume)this.MemberwiseClone(); // MemberwiseClone() C# 原生方法,回傳的是 object,這裡轉型一次就好
    }
}

每次要投一間新公司,就複製這份範本,只改職稱這個欄位就好,其他不用重打一次

var baseResume = new Resume { Name = "阿柴", Title = "全端工程師", YearsOfExperience = 1 };

var resumeForA = baseResume.Clone();
resumeForA.Title = "後端工程師"; // 投 A 公司時想掛的職稱

var resumeForB = baseResume.Clone();
resumeForB.Title = "前端工程師"; // 投 B 公司時想掛的職稱

Console.WriteLine(baseResume.Title);  // 全端工程師(範本沒被動到)
Console.WriteLine(resumeForA.Title);  // 後端工程師
Console.WriteLine(resumeForB.Title);  // 前端工程師

每份複製出來的履歷都是獨立的,改一份不會動到另一份

等等等,你是不是也會想到,改這麼麻煩,我不直接 new 三個實體就好了?

說的也是....(欸不是),因為這是簡單的範例(我比較好懂),但實際上來說,軟體結構是非常複雜且龐大的

並不是通通 new 就可以治百病,我們可以來初步判斷什麼時候不能/不想直接 new?

  1. 建立成本太高(昂貴的初始化)
    如果 new Resume() 的建構子內部需要讀設定檔、打 API 查資料庫、或是跑演算法計算(耗時 500ms),直接 new 3 次就要耗費 1.5 秒
    用 Clone() 是直接在記憶體層級拷貝現有狀態,耗時幾乎是 0ms

  2. 初始化狀態極其繁瑣(20+ 個欄位預設值)
    假設履歷有 30 個欄位,其中 28 個欄位(學歷、自傳、聯絡電話、專案經驗、排版設定...)在不同版本間都一模一樣,只有 2 個欄位要改
    若直接 new,每開一個新實體就得手動重新 assign 28 次相同屬性,程式碼會充滿冗長且重複的賦值邏輯
    用 Clone() 則是一行取得包含 28 個預設值的新物件,再改那 2 個即可

  3. 呼叫端不知道(也不該知道)具體類別與建構細節
    有時你的程式碼只拿到一個介面(例如 IResume template),你不知道它背後是哪一個具體的 Class,它可能是 FrontendResume、BackendResume 甚至 SpecialFormatResume,這時你根本無法寫 new ???(),但只要有 Prototype 介面,直接呼叫 template.Clone() 就能完美複製出正確型別的實體

好,那我們一樣回到柴咖啡

回到柴咖啡

某天下午兩點,隔壁科技公司的福委傳來一張下午茶團購訂單:「招牌柴拿鐵 20 杯!」

小黑樂了,因為這個科技公司點好多次,但店員苦哈哈,魔鬼藏在細節裡

小明:少冰、無糖、加肉桂粉

小華:去冰、半糖、換燕麥奶

公司老闆:熱、正常糖、加一份濃縮、加肉桂粉

...底下還有 17 個人各有各的微調,好不快樂

這個「招牌柴拿鐵」可講究了,系統裡設定了將近十個繁瑣欄位:選豆產區、烘焙度、義式萃取秒數、預設水溫、奶泡綿密度、預設拉花圖案...

阿柴想看看以前都怎麼處理訂單的

// 小黃留下來的歷史遺產:每杯都從零 new,然後一行一行複製貼上設定
var coffee1 = new ChaiLatte();
coffee1.BeanOrigin = "衣索比亞 耶加雪菲";
coffee1.RoastLevel = "中淺焙";
coffee1.BrewTemp = 92;
coffee1.MilkRatio = 0.7;
coffee1.FoamDensity = "極綿密";
coffee1.Ice = "少冰";
coffee1.Sugar = "無糖";
coffee1.Toppings.Add("肉桂粉");

var coffee2 = new ChaiLatte();
coffee2.BeanOrigin = "衣索比亞 耶加雪菲";
coffee2.RoastLevel = "中淺焙";
coffee2.BrewTemp = 92;
coffee2.MilkRatio = 0.7;
coffee2.FoamDensity = "極綿密";
coffee2.Ice = "去冰";
coffee2.Sugar = "半糖";
coffee2.MilkType = "燕麥奶";

// ...底下還有 18 杯要複製貼上

阿柴這時候心想,所以我要手動複製20筆訂單嗎,如果哪天要改咖啡豆產區,是不是又要改20次?

阿柴決定不再當複製貼上戰士,是時候用 Prototype 原型模式來拯救這堆遺產程式碼了!

阿柴的想法

既然 20 杯都是以「標準招牌柴拿鐵」為基底,何不先準備一個「拿鐵原型(Prototype)」?
每次來新訂單,直接拷貝一份出來,只改客人指定的客製化項目就好!

public class ChaiLatte
{
    // 繁瑣的固定基底參數
    public string BeanOrigin { get; set; } = "衣索比亞 耶加雪菲";
    public string RoastLevel { get; set; } = "中淺焙";
    public int BrewTemp { get; set; } = 92;
    public double MilkRatio { get; set; } = 0.7;
    public string FoamDensity { get; set; } = "極綿密";

    // 容易變動的客製化選項
    public string Sugar { get; set; } = "正常糖";
    public string Ice { get; set; } = "正常冰";
    public string MilkType { get; set; } = "全脂鮮乳";
    public List<string> Toppings { get; set; } = new List<string>();

    // 實作 Prototype 拷貝方法
    public ChaiLatte Clone()
    {
        return (ChaiLatte)this.MemberwiseClone();
    }
}

現在要處理那 20 杯訂單,程式碼變得乾淨好讀

// 1. 建立一杯預先設定好的原型範本
var standardLatte = new ChaiLatte();

// 2. 小明的訂單:直接複製範本,只改微調項目
var mingCoffee = standardLatte.Clone();
mingCoffee.Ice = "少冰";
mingCoffee.Sugar = "無糖";
mingCoffee.Toppings.Add("肉桂粉");

// 3. 小華的訂單:同樣複製範本
var huaCoffee = standardLatte.Clone();
huaCoffee.Ice = "去冰";
huaCoffee.Sugar = "半糖";
huaCoffee.MilkType = "燕麥奶";

這時候阿柴心滿意足:「不用每次都在那邊重複設定 5~6 個繁瑣的烘焙萃取參數了!改哪裡點哪裡,誣告讚!」

說時遲那時快,阿柴收到了小黑的抱怨:「 為什麼小華的燕麥奶拿鐵裡面,也被加了肉桂粉...」

阿柴:「???」

阿柴趕緊把每杯咖啡的配料清單印出來看:


Console.WriteLine($"小明配料: {string.Join(", ", mingCoffee.Toppings)}");
Console.WriteLine($"小華配料: {string.Join(", ", huaCoffee.Toppings)}");
Console.WriteLine($"範本配料: {string.Join(", ", standardLatte.Toppings)}");

出來的結果讓阿柴倒抽一口氣:

  • 小明配料: 肉桂粉
  • 小華配料: 肉桂粉
  • 範本配料: 肉桂粉

明明只有小明加了肉桂粉,怎麼連小華、甚至是作為範本的 standardLatte 都有肉桂粉了?

朋友們,這就是 Prototype 模式容易踩的地雷,淺拷貝(Shallow Copy) vs 深拷貝(Deep Copy)

有學過 JS 的朋友,應該對淺拷貝與深拷貝不陌生

那問題出在哪

在 C# 裡,MemberwiseClone() 做的是淺拷貝:

值型別(Value Type,如 int, double):會直接複製一份全新的數值過去,改了互不影響

字串(string):雖然是參考型別,但因為不可變(immutable)特性,修改時會建立新字串,所以也看似獨立

但參考型別(Reference Type,如 List, 陣列、自訂物件):只會複製「記憶體指標(Reference)」

這意味著:standardLattemingCoffeehuaCoffeeToppings 屬性,實際都指向記憶體中同一條 List

// 因為是淺拷貝,List 複製的只是記憶體位址,並沒有在記憶體裡建立一條新的 List,所以也觸發了狀態汙染(State Pollution)
mingCoffee.Toppings.Add("肉桂粉");

小明把肉桂粉使用.Add()丟進清單,等於所有人看到的配料碗裡都會多了一把肉桂粉

那我們來拯救柴咖啡吧,實作深拷貝(Deep Copy)

要解決這個問題,我們必須確保複製時,連同內部的參考型別物件也一併建立全新的複製品:

public class ChaiLatte
{
    // ...前面屬性保持不變
    public List<string> Toppings { get; set; } = new List<string>();

    public ChaiLatte Clone()
    {
        // 1. 先透過 MemberwiseClone 複製基本型別
        var clone = (ChaiLatte)this.MemberwiseClone();

        // 2. 手動為參考型別(List)建立一份全新實體(深拷貝)
        clone.Toppings = new List<string>(this.Toppings);
        return clone;
    }
}

改完之後再次執行:

var standardLatte = new ChaiLatte();

var mingCoffee = standardLatte.Clone();
mingCoffee.Toppings.Add("肉桂粉");

var huaCoffee = standardLatte.Clone();

Console.WriteLine($"小明配料: {string.Join(", ", mingCoffee.Toppings)}"); // 肉桂粉
Console.WriteLine($"小華配料: {string.Join(", ", huaCoffee.Toppings)}"); // (空的,正常了)

這次確定只有小明有肉桂粉,其他小華跟原型 standardLatte 都不受影響

Prototype 的取捨

如同上述我們採到的坑,先判斷欄位是值型別還是參照型別,才知道淺拷貝夠不夠LatteSizeIceLevelSugarLevelMilkType 都是 enum(值型別),淺拷貝直接複製就沒問題

但只要有一個參照型別欄位(List、另一個 class 的實例),就得針對那個欄位手動處理,不然會共享同一份內部資料

深拷貝不用對所有欄位都下手:如果每加一個欄位就把整個物件都寫成手動深拷貝,維護成本只會往上堆

只對參照型別的欄位處理就好,值型別欄位交給 MemberwiseClone() 就夠了

原型要維持乾淨,才能被安全複製standardLatte 是所有訂單共用的原型,一旦被某張訂單意外改到,後面每一杯複製出來的都會帶著錯誤的初始狀態

這也是為什麼「複製出來的東西彼此獨立、不會互相污染」是 Prototype 的核心特性之一

邊界在哪? 什麼時候「不該」用 Prototype?

設計模式就像調配咖啡的糖漿,加對了是風味,加過頭就只剩死甜,看到「複製」很方便,但不代表專案裡所有的 new 都要換成 Clone():

  1. 物件結構單純、建立成本極低時
    如果一個 class 只有 2~3 個基本欄位,建構子也不讀檔、不查 DB,直接 new SimpleCoffee() 既直觀又乾淨
    硬要為了套模式加上 Clone(),只是徒增維護負擔

  2. 內部包含極端複雜的深層巢狀物件(Object Graph)
    如果一個物件內部包了 5 層參考型別、甚至有循環引用(A 參照 B,B 又參照 A),手動實作深拷貝會是一場災難。每次新增或修改子類別欄位,Clone() 漏改一個地方就會引發隱性 Bug

  3. 跟其他建立型模式(Creational Patterns)的邊界:
    Factory(工廠模式):適合「從零依不同條件,決定產出哪種具體子類別」
    Builder(建造者模式):適合「物件建構過程極其複雜,需要一步步組裝零件」
    Prototype(原型模式):適合「已經有一個組裝好的範本,後續產出只是要拿現成狀態做『微調與衍伸』」。

判斷標準:

  • 如果你的需求是「拼裝一個全新物件」,請找 Factory 或 Builder;
  • 如果你的需求是「這杯做得很完美,照著這杯幫我再來一份,只要改甜度冰塊」,就是 Prototype 可以處理的事

創建型五天王講得差不多了,那明天我們來攤開來做個總結對比,看看它們各自解決什麼問題、又是怎麼分工的

如果看不懂我寫的,可以看看延伸的資料,希望可以幫助到你

Refactoring Guru - Prototype

Design Pattern: Creational Patterns — Prototype Pattern (原型模式)

Prototype

原型模式 Prototype Pattern|先複製一份,再微調就好


上一篇
Day 12|Singleton(單例模式) 柴咖啡大塞車,如何用 Lazy 搞定咖啡機連線
下一篇
Day 14|創建型 Pattern 總結對比
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言