第 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 prototype
Contact worker{"", {"123 East Dr", "London", 0}};
// make a copy pf prototype and customize it
Contact 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; //upcast
auto cloned = a.clone();
printf("\n\nea: %s\n", typeid(ea).name()); // ExtendedAddress
printf("\n\na: %s\n", typeid(a).name()); // ExtendedAddress
printf("\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=就够了。