設計模式-原型模式
定義
原型模式是用原型實例指定創建對象的種類,並通過拷貝這些原型創建新的對象。簡單地說就是,首先創建一個實例,然後通過這個實例去拷貝(克隆)創建新的實例。
需求
我們還是通過一個簡單需求開始說起,通常情況下,找工作時,需要準備多份簡歷,簡歷資訊大致相同,但是可以根據不同的公司的崗位需求微調工作經歷細節,以及薪資要求,例如有的公司要求電商經驗優先,那麼就可以把電商相關的工作細節多寫一點,而有的要求管理經驗,那麼工作細節就需要更多的體現管理才能,薪資要求也會根據具體情況填寫具體數值或者面議等。
我們先拋開原型模式不談,我們可以考慮一下,前面講到的幾個創建型模式能否滿足需求呢?
首先,我們需要多份簡歷,單例模式直接就可以Pass掉了,其次,由於簡歷資訊比較複雜,起碼也有幾十個欄位,並且根據不同情況,可能會發生部分修改,因此,三個工廠模式也不能滿足需求。不過想到這裡,我們想到建造者模式或許滿足需求,因為它就是用來創建複雜對象的,不妨先用建造者模式試一下。
先定義簡歷:
public abstract class ResumeBase
{
/// <summary>
/// 姓名
/// </summary>
public string Name { get; set; }
/// <summary>
/// 性別
/// </summary>
public string Gender { get; set; }
/// <summary>
/// 年齡
/// </summary>
public int Age { get; set; }
/// <summary>
/// 期望薪資
/// </summary>
public string ExpectedSalary { get; set; }
public abstract void Display();
}
/// <summary>
/// 工作經歷
/// </summary>
public class WorkExperence
{
public string Company { get; set; }
public string Detail { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public void Display()
{
Console.WriteLine("工作經歷:");
Console.WriteLine($"{this.Company}\t{this.StartDate.ToShortDateString()}-{EndDate.ToShortDateString()}");
Console.WriteLine("工作詳細:");
Console.WriteLine(this.Detail);
}
}
public class ItResume : ResumeBase
{
/// <summary>
/// 工作經歷
/// </summary>
public WorkExperence WorkExperence { get; set; }
public override void Display()
{
Console.WriteLine($"姓名:\t{this.Name}");
Console.WriteLine($"性別:\t{this.Gender}");
Console.WriteLine($"年齡:\t{this.Age}");
Console.WriteLine($"期望薪資:\t{this.ExpectedSalary}");
Console.WriteLine("--------------------------------");
if (this.WorkExperence != null)
{
this.WorkExperence.Display();
}
Console.WriteLine("--------------------------------");
}
}
再定義建造者:
public class BasicInfo
{
/// <summary>
/// 姓名
/// </summary>
public string Name { get; set; }
/// <summary>
/// 性別
/// </summary>
public string Gender { get; set; }
/// <summary>
/// 年齡
/// </summary>
public int Age { get; set; }
/// <summary>
/// 期望薪資
/// </summary>
public string ExpectedSalary { get; set; }
}
public interface IResumeBuilder
{
IResumeBuilder BuildBasicInfo(Action<BasicInfo> buildBasicInfoDelegate);
IResumeBuilder BuildWorkExperence(Action<WorkExperence> buildWorkExperenceDelegate);
ResumeBase Build();
}
public class ResumeBuilder : IResumeBuilder
{
private readonly BasicInfo _basicInfo = new BasicInfo();
private readonly WorkExperence _workExperence = new WorkExperence();
public IResumeBuilder BuildBasicInfo(Action<BasicInfo> buildBasicInfoDelegate)
{
buildBasicInfoDelegate?.Invoke(_basicInfo);
return this;
}
public IResumeBuilder BuildWorkExperence(Action<WorkExperence> buildWorkExperenceDelegate)
{
buildWorkExperenceDelegate?.Invoke(_workExperence);
return this;
}
public ResumeBase Build()
{
ItResume resume = new ItResume()
{
Name = this._basicInfo.Name,
Gender = this._basicInfo.Gender,
Age = this._basicInfo.Age,
ExpectedSalary = this._basicInfo.ExpectedSalary,
WorkExperence = new WorkExperence
{
Company = this._workExperence.Company,
Detail = this._workExperence.Detail,
StartDate = this._workExperence.StartDate,
EndDate = this._workExperence.EndDate
}
};
return resume;
}
}
其中,定義一個BasicInfo類是為了向外暴漏更少的參數,Build()方法每次調用都會產生一個全新的ItResume對象。
調用的地方也非常簡單:
static void Main(string[] args)
{
IResumeBuilder resumeBuilder = new ResumeBuilder()
.BuildBasicInfo(resume =>
{
resume.Name = "張三";
resume.Age = 18;
resume.Gender = "男";
resume.ExpectedSalary = "100W";
})
.BuildWorkExperence(work =>
{
work.Company = "A公司";
work.Detail = "負責XX系統開發,精通YY。。。。。";
work.StartDate = DateTime.Parse("2019-1-1");
work.EndDate = DateTime.Parse("2020-1-1");
});
ResumeBase resume1 = resumeBuilder
.Build();
ResumeBase resume2 = resumeBuilder
.BuildBasicInfo(resume =>
{
resume.ExpectedSalary = "面議";
})
.BuildWorkExperence(work =>
{
work.Detail = "電商經驗豐富";
})
.Build();
resume1.Display();
resume2.Display();
}
這樣好像就已經滿足需求了,我們只需要少量修改就可以創建多份簡歷。但是呢,這種情況,每次創建一批簡歷之前,我們都必須先有一個Builder,否則無法完成簡歷的創建,而我們實際期望的是直接通過一份舊的簡歷就可以複製得到一份新簡歷,在這種期望下,並沒有所謂的Builder存在。
但是通過觀察我們不難發現,舊簡歷其實已經具備了生產新簡歷的所有參數,唯一缺少的就是Build()方法,因此,既然不能使用Builder,我們直接將Builder中的Build()方法Copy到Resume中不就可以了嗎?於是就有了如下改造,將Build()方法完整的Copy到ResumeBase和ItResume中,僅僅將方法名改成了Clone():
public abstract class ResumeBase
{
...
public abstract ResumeBase Clone();
}
public class ItResume : ResumeBase
{
...
public override ResumeBase Clone()
{
ItResume resume = new ItResume()
{
Name = this.Name,
Gender = this.Gender,
Age = this.Age,
ExpectedSalary = this.ExpectedSalary,
WorkExperence = new WorkExperence
{
Company = this.WorkExperence.Company,
Detail = this.WorkExperence.Detail,
StartDate = this.WorkExperence.StartDate,
EndDate = this.WorkExperence.EndDate
}
};
return resume;
}
}
調用的地方就可以直接通過resume.Clone()方法創建新的簡歷了!
完美!其實這就是我們的原型模式了,僅僅是對建造者模式進行了一點點的改造,就有了神奇的效果!
UML類圖
我們再來看一下原型模式的類圖:

改進
當然,這種寫法還有很大的優化空間,例如,如果對象屬性比較多,Clone()方法的維護就會變得非常麻煩,因此,我們可以使用Object.MemberwiseClone()來簡化調用,如下所示:
public override ResumeBase Clone()
{
ItResume itResume = this.MemberwiseClone() as ItResume;
itResume.WorkExperence = this.WorkExperence.Clone();
return itResume;
}
這樣就簡化很多了,但是又引入了新的問題,MemberwiseClone()是淺拷貝的,因此要完成深拷貝,就必須所有引用類型的屬性都實現Clone()功能,如WorkExperence,否則,在後續調用時可能出現由於數據共享而產生的未知錯誤,這可能是災難性的,因為很難排查出錯誤出在哪裡,因此,我們更建議使用序列化和反序列化的方式來實現深拷貝,如下所示:
[Serializable]
public sealed class ItResume : ResumeBase
{
...
public override ResumeBase Clone()
{
using (MemoryStream stream = new MemoryStream())
{
BinaryFormatter bf = new BinaryFormatter();
bf.Serialize(stream, this);
stream.Position = 0;
return bf.Deserialize(stream) as ResumeBase;
}
}
}
這裡需要注意的是,所涉及的所有引用類型的屬性(字元串除外),都需要打上Serializable標記,否則會拋出異常(拋出異常比MemberwiseClone()的什麼也不發生要好的多),注意,這裡的ItResume最好標記為sealed,原因後續解釋。
淺拷貝與深拷貝
上面提到了淺拷貝和深拷貝,這裡簡單解釋一下。
淺拷貝
- 對於基本類型的成員變數,淺拷貝會直接進行值傳遞。
- 對於引用類型的成員變數,比如數組、對象等,淺拷貝會進行引用傳遞。因此,在一個對象中修改該成員變數會影響到另一個對象的該成員變數值。
Object.MemberwiseClone()是淺拷貝。
深拷貝
- 對於一個對象無論其成員變數是什麼類型,都從記憶體中完整的拷貝一份出來,從堆記憶體中開闢一個新的區域存放新對象,且修改新對象不會影響原對象;
- 對對象先序列化,再反序列化是深拷貝。
淺拷貝和深拷貝是相對的,如果一個對象內部只有基本數據類型,那麼淺拷貝和深拷貝是等價的。
使用場景
- 當需要重複創建一個包含大量公共屬性,而只需要修改少量屬性的對象時;
- 當需要重複創建一個初始化需要消耗大量資源的對象時。
優點
- 創建大量重複的對象,同時保證性能
避免使用ICloneable介面
ICloneable介面只有一個Clone()成員方法,我們通常會用它充當Prototype基類來實現原型模式,但我這裡要說的是盡量避免使用ICloneable,原因在 《Effective C#:50 Specific Ways to Improve Your C#》 一書中的原則27 有給出,基本思想如下:
- 由於只有一個Clone方法,因此調用者無法區分到底是深拷貝還是淺拷貝,會給調用者造成極大的困擾;
- 如果基類繼承了
ICloneable介面,並且非Sealed類型,那麼它的所有派生類都需要實現Clone方法。否則,用派生類對象調用Clone方法,返回的對象將會是基類Clone方法創建的對象,這就給派生類帶來了沉重的負擔,因此在非密封類中應該避免實現ICloneable介面,但這個不是ICloneable特有的缺陷,任何一種方式實現原型模式都存在該問題,因此建議將原型模式的實現類設置為密封類。 - Clone方法返回值是
object,是非類型安全的;
ICloneable被很多人認為是一個糟糕的設計,其他理由如下:
ICloneable除了標識可被克隆之外,無論作為參數還是返回值都沒有任何意義;.Net Framework在升級支援泛型至今,都沒有添加一個與之對應的ICloneable<T>泛型介面;- 很多框架中為了向下兼容,雖然實現了
ICloneable介面,但是內部只提供了一個拋出異常的私有實現,例如SqlConnection。
鑒於上述諸多缺點,在實現原型模式時,ICloneable介面能不用就不要用了,自己定義一個更有意義的方法或許會更好。
總結
原型模式通常用在對象創建複雜或者創建過程需要消耗大量資源的場景。但由於其實現過程中會存在諸多問題,如果處理不當很容易對使用者造成困擾,因此,應盡量使用序列化反序列化的方式實現,盡量將其標記為sealed,另外,盡量避免對ICloneable介面的使用。

