C++20設計模式
- 第 4 章 原型模式
- 4.1 對象構建
- 4.2 普通拷貝
- 4.3 通過拷貝構造函數進行拷貝
- 4.4 “虛”構造函數
- 4.5 序列化
- 4.6 原型工廠
- 4.7 總結
- 4.8 代碼
第 4 章 原型模式
考慮一下我們日常使用的東西,比如汽車或手機。它們并不是從零開始設計的,相反,制造商會選擇一個現有的設計方案對其作適當的改進,使其外觀區別于以往的設計,然后淘汰老式的方案,開始銷售新產品。這是普遍存在的場景,在軟件世界中,我們也會遇到類似的情形:有時,相比從零開始創建對象(此時工廠和構造器可以發揮作用),我們更希望使用預先構建好的對象或拷貝或基于此做一些自定義設計。
由此,我們產生了一種想法,即原型模式:一個原型是指一個模型對象,我們對其進行拷貝、自定義拷貝,然后使用它們。原型模式的挑戰實際上是拷貝部分,其他一切都很簡單。
4.1 對象構建
大多數對象通過構造函數進行構建。但是如果已經有一個完整配置的對象,為ieshme不簡單的拷貝該對象而非要重新創建一個相同的對象呢?如果必須使用構造器模式來簡化逐段構建對象的過程,那么理解原型模式尤其重要。
我們先看一個簡單但可以直接說明對象拷貝的示例:
Contact john{"John Doe", Address{"123 East Dr" , "Londo", 10}};
Contact jane{"Jane Doe", Address{"123 East Dr" , "Londo", 11}};
john和jane工作在同一棟建筑大樓的不同辦公室。可能有許多人也在123 East Dr工作,在構建對象時我們想避免重復對該地址信息做初始化。怎么做呢?
原型模式與對象拷貝相關。當然,我們沒有通用的方法來拷貝對象,但是可以選擇一些可選的對象拷貝方法。
4.2 普通拷貝
如果曾在拷貝一個值和一個其所有成員都是通過值的方式來存儲的對象,那么拷貝毫無問題。例如,在之前的示例中,如果Contact和Address定義為:
class Address{public:std::string street;std::string city;int suite;};class Contact{public:std::string name;Address address;};
那么在使用賦值運算符進行拷貝時,絕對不會有問題(string類型拷貝為深拷貝):
void testOrdinaryCopy() {// here is the prototypeContact worker{"", {"123 East Dr", "London", 0}};// make a copy pf prototype and customize itContact john = worker;john.name = "John Doe";john.address.suite = 10;}
但是,在實際應用中,這種按值存儲和拷貝的方式較少見。在許多場景中,通常將內部的Address對象作為指針或者引用,例如:
class Contact{public:std::string name;Address* address;~Contact() {delete address;}};
現在有一個很棘手的問題,因為代碼Contact jane = john將會拷貝地址指針,所以john和jane以及其他每一個原型拷貝都會共享同一個地址,這絕對不是我們想要的。
4.3 通過拷貝構造函數進行拷貝
避免拷貝指針的最簡單的方法時確保對象的所有組成部分(如上面的實例中的Contact和Address)都完整定義了拷貝構造函數。例如如果使用原始指針保存地址,即:
class Contact{public:std::string name;Address* address;~Contact() {delete address;}};
那么,我們需要定義一個拷貝構造函數。在本示例中,實際上有兩種方法可以做到這一點。迎頭而來的方法看起來像下面這種:
Contact(const Contact& other):name(other.name)/*, address(new Address(*other.address)*/) {address = new Address{other.address->street,other.address->city,other.address->suite}
不幸的是,這種方法并不通用。這種方法在上面的示例中當然沒有問題(前提是Address提供了一個初始化其所有成員的構造函數)。但是如果Address的street的成員是由街道名稱、門牌號和一些附加信息組成的,那該怎么版?那時,我們又會遇到同樣的拷貝問題。
一種明智的做法是,為Address定義拷貝構造函數。在本示例中,Address的拷貝構造函數相當簡單(C++ string類型數據實現為深拷貝致使該拷貝構造函數非常簡單):
Address(std::string street, std::string city, int suite):street(street), city(city), suite(suite) {}
現在我們可以重寫Contact的構造函數中可以重用拷貝構造函數,即:
Contact(const Contact& other):name(other.name), address(new Address(*other.address)) {}
請注意,ReSharper代碼生成器在生成拷貝構造函數和移動構造函數的同時,也會生成拷貝賦值函數。在本實例中,拷貝賦值函數定義為:
Contact operator=(const Contact& other) {if (this == &other) {return *this;}name = other.name;address = other.address;return *this;}
【注】上述的拷貝賦值函數存在一定的問題,當我們調用到賦值函數時,并沒有為address重新指定新的Address地址。會存在多個對象指向一塊Address地址的問題,這個可能不是我們所想見到的。
完成這些函數定義后,我們可以像之前一樣構造對象的原型,然后重用它:
void testCopyConstructor() {Contact worker{"",new Address{"123 East Dr", "London", 0}};Contact john = worker;john.name = "john";john.address->suite = 10;}
【注】在上述的測試代碼中,雖然使用了 “=”,但是并不會發生異常,這和我們上一個注釋說的就有點矛盾了,是什么原因導致的呢?
當對象賦值給另一個對象時,C++會根據情況調用拷貝構造函數或者拷貝賦值函數。如果在賦值操作時對象已經被初始化過,那么會調用拷貝賦值函數。但如果在賦值操作時對象尚未初始化,即對象已經存在,那么會調用拷貝構造函數。這是因為賦值操作需要先創建對象,然后再將值賦給已經存在的對象。因此,這時會調用拷貝構造函數來初始化新對象。
所以,這里雖然使用了 “=”, 但是其調用的是拷貝構造函數,并不會調用拷貝賦值,因此,不會存在問題,我們不妨把測試代碼改寫如下:
void testCopyConstructor() {Contact worker{"",new Address{"123 East Dr", "London", 0}};Contact john;john = worker;john.name = "john";john.address->suite = 10;}
然后猜想下會發生什么異常呢?
使用當前這種通過拷貝構造函數進行拷貝的方法是有效。使用這種方法唯一不足而且難以解決的問題是,我們為此需要付出額外的工作,以實現拷貝構造函數,移動構造函數,拷貝賦值函數等。誠然,類似于ReSharper代碼生成器一類的工具可以為大多數場景快速生成代碼,但會產生很多警告。例如,我們編寫如下的嗲嗎,并且忘記了提供Address類的拷貝賦值函數的實現,會發生什么:
Contact john = worker;
是的, 程序仍然會通過編譯。如果提供了拷貝構造函數會更好一些,因為如果在沒有定義構造函數的情況下嘗試調用構造函數,程序將會出錯,然而賦值運算符 “=” 是普遍存在的。即使你沒有為賦值運算符提供特殊的定義和實現。
還有一個問題:假設使用類似二級指針的東西(例如 void **)或unique_str呢?即使它們各有獨特之處,但此時像ReSharper和Clion這樣的工具也不可能生成正確的代碼,所以使用工具為這些類型快速生成代碼也許不是一個好主意。
4.4 “虛”構造函數
拷貝構造函數使用之處相當有限,并且存在的一個問題是,為了對變量的深度拷貝。我們需要知道變量具體是那種類型。假設ExtendedAddress類繼承自Addressl類:
class ExtendedAddress : public Address {public:std::string country;std::string postcode;ExtendedAddress(const std::string& street, const std::string& city, const int suite, const std::string& country,const std::string& postcode):Address(street, city, suite), country(country) {}ExtendedAddress* clone()override {return new ExtendedAddress(street, city, suite, country, postcode);}};
若我們要拷貝一個存在多態性質的變量:
ExtendedAddress ea = ...;
Address& a = ea;
// dow do you deep-copy 'a' ?
這樣的做法存在問題,因為我們并不知道變量a的最終派生類型時是么。由于最終派生類引發的問題,以及拷貝構造函數不能是虛函數。因此我們需要采用其他方法來創建對象的拷貝。
首先,我們以Address對象為例,引入一個虛函數clone(),然后,我們嘗試:
virtual Address clone() {return Address{street, city, suite};}
不幸的是,這并不能解決繼承場景下的問題。請記住,對于派生對象,我們想返回的是ExtendedAddress類型。但上述代碼展示的接口將返回類型固定為Address。我們需要是指針形式的多態,因此再次嘗試:
virtual Address* clone() {return new Address{street, city, suite};}
現在,我們可以在派生類中做同樣的事情,只不過要提供對應的返回類型:
ExtendedAddress* clone()override {return new ExtendedAddress(street, city, suite, country, postcode);}
現在,我們可以安全放心的調用clone()函數,而不必擔心對象由于繼承體系被切割:
void testVirtualConstructor() {std::cout << __FUNCTION__ <<"() begin.\n\n";ExtendedAddress ea{"123 East Dr", "London", 0, "UK", "SW101EG"};Address& a = ea; //upcastauto cloned = a.clone();printf("\n\nea: %s\n", typeid(ea).name()); // ExtendedAddressprintf("\n\na: %s\n", typeid(a).name()); // ExtendedAddressprintf("\n\ncloned: %s\n", typeid(cloned).name()); // Address*std::cout << __FUNCTION__ <<"() end.\n\n";}
現在,變量cloned的確是一個指向深度拷貝ExtendedAddress對象的指針了。當然,這個指針的類型是Address*,所以,如果我們需要額外的成員,則可以通過dynamic_cast進行轉換或者調用某些虛函數。
如果處于某些原因,我們想要使用拷貝構造函數,則clone()接口可以簡化為
ExtendedAddress* clone()override {return new ExtendedAddress(*this);}
之后,所有的工作都可以在拷貝構造函數中完成。
使用clone()方法的不足之處是,編譯器并不會檢查整個繼承體系每個類中實現的clone()方法(并且也沒有強行進行檢查的方法)。例如,如果忘記在ExtendedAddress類中實現clone()方法,示例代納同樣可以通過編譯并且正常運行,但當調用clone()方法是, clone()將構造一個Address而不是ExtendedAddress。
4.5 序列化
其他編程語言的設計者也遇到同樣的問題,即必須對整個對象顯式定義拷貝操作,并很快意識到類需要“普通可序列化”—默認情況下,類應該可以直接寫入字符串和流,而不必使用任何額外的注釋(最多可能是一個或兩個屬性)來指定類或其成員。
這與我們正在討論的問題有關系嗎?當然有,如果可以將類對象序列化到文件或內存中,則可以再將其反序列化,并保留包括其所依賴的對象在內的所有信息。這樣,我們就不需要在通過顯式定義拷貝操作這種方式做處理獲得一個在某個對象基礎上的新對象。
遺憾的是,與其他語言不同的是,當提到序列化時,C++不提供免費的午餐。我們不能將復雜的對象序列化為文件。為什么不能?在其他編程語言中,編譯的二進制文件不僅包括可執行代碼,還包括大量的元數據,而序列化是通過一種反射的特性來實現的,目前這個在C++中是不支持的。
如果我們想要序列化,那么就像顯式拷貝操作一樣,我們需要自己實現它。幸運的是,我們可以使用名為Boost.Serialization的現成的庫來解決序列化的問題,而不用費勁的處理和思考序列化std::string的方法。
【注】由于暫時不使用Boost庫,序列化就看到這塊了,后面有需要在補充…
4.6 原型工廠
如果我們預定義了要拷貝的對象,那么我們會將它們保存在哪里?全局變量中嗎?或許吧!事實上,假設我們公司有主辦公室和備用辦公室,我們可以這樣聲明全局變量:
Contact main{"", new Address{"123 East Dr", "London", 0}};
Contact aux{"", new Address{"123B East Dr", "London", 0}};
我們可以將這些預定義的對象放在 Contact.h文件中, 任何使用Contact類的人都可以獲取這些全局變量并進行拷貝。但更明智的方法是使用某種專用的類來存儲原型,并基于所謂的原型,根據需要產生自定義拷貝。這將給我們帶來更多的靈活性。例如,我們可以定義工具函數,產生適當初始化的unique_ptr:
class EmployeeFactory {static Contact main;static Contact aux;static std::unique_ptr<Contact> NewEmployee(std::string name,int suite, Contact& proto) {auto result = std::make_unique<Contact>(proto); //這里會調用拷貝構造result->name = name;result->address->suite = suite;return result;}public:static std::unique_ptr<Contact> NewMainOfficeEmployee(std::string name , int suite) {return NewEmployee(name, suite, main);}static std::unique_ptr<Contact> NewAuxMainOfficeEmployee(std::string name, int suite) {return NewEmployee(name, suite, aux);}};
現在可以按如下方式使用:
void testPrototypeFactory() {auto john = EmployeeFactory::NewMainOfficeEmployee("John Doe", 123);auto jane = EmployeeFactory::NewAuxMainOfficeEmployee("Jane Doe", 125);}
為什么要使用工廠呢?考慮這樣一種場景:我們從某個原型拷貝得到一個對象,但忘記自定義該對象的某些屬性,此時該對象的某些本該有具體參數值的參數將為0或者空字符串。如果使用之前討論的工廠,我們可以將所有非完全初始化的構造函數聲明為私有的,并且將EmployeeFactory聲明為friend class。現在,客戶將不再得到為完整構建的Contact對象。
4.7 總結
原型模式體現了對對象進行深度拷貝的概念,因此,不必每次都進行完全初始化,而是可以獲取一個預定義的對象,拷貝它,稍微修改它,然后獨立于原始的對象使用它。
在C++中,有兩種方式實現原型模式的方法,它們都需要手動操作:
- 編寫正確拷貝原始對象的代碼,也就是執行深度拷貝的代碼。這項工作可以在拷貝構造函數 / 拷貝賦值運算符或者單獨的成員函數中完成。
- 編寫支持序列化 / 反序列化的代碼,使用序列化 / 反序列化機制,在完成序列化后立即進行反序列化,由此完成復制。該方法會引入額外的開銷,是否使用這種方法取決于具體使用場景下的拷貝頻率。與使用拷貝構造函數相比,這種方法的唯一優點是可以不受限制地使用序列化功能。
不論選擇那種方法,有些工作是必須完成的。如果決定采取上述兩種方法的一種。則可采用一些代碼生成工具(比如,類似于ReShareper和CLion的集成開發環境)來輔助。
最后,別忘了,如果對所有數據采用按值存儲的方式,實際上并不會有問題,只需要operator=就夠了。
4.8 代碼
本章學習代碼