现实生活中,对于一些日常使用的较复杂的产品,比如汽车或手机,它们并不是从零开始设计的;相反,通常会基于一个现有的设计方案并对其进行适当的改进。
这是普遍存在的场景,在软件世界中也有类似的情形:相比从零开始创建对象(此时构造器模式和工厂可以发挥作用),我们更希望使用预先构建好的对象或其拷贝,然后基于此做一些自定义设计。
由此产生了原型模式:一个原型是指一个模型对象,我们对其进行拷贝、自定义拷贝,然后使用它们。原型模式的挑战实际上是拷贝部分,其他一切都很简单。
0. 对象构建
大多数对象通过构造函数进行构建,但是如果已经有一个完整配置的对象,为什么不简单地拷贝该对象而非要重新创建一个相同的对象呢?我们先看一个简单但可以直接说明对象拷贝的示例:
Contact john{ "John Doe", Address{"123 East Dr", "London", 10 } };
Contact jane{ "Jane Doe", Address{"123 East Dr", "London", 11 } };
john和jane工作在同一栋建筑大楼的不同办公室。可能有许多人也在123 East Dr工作,在构建对象时我们想避免重复地对该地址信息做初始化,怎么做呢?
当然,我们没有通用的方法来拷贝对象,但是有一些可选择的对象拷贝方法。
1. 普通拷贝
对于一个值和一个其所有成员都是通过“值”的方式来存储的对象,那么普通拷贝毫无问题。
例如将Contact和Address定义如下:
class Address
{
public:
string street_, city_;
int suite_;
};
class Contact
{
public:
string name_;
Address address_;
};
但在实际应用中,这种按值存储和拷贝的方式极其少见。通常是将内部的对象作为指针或引用,这时普通拷贝将会拷贝地址指针,每一个原型拷贝都会共享同一个地址,这绝不是我们想要的。因此需要通过拷贝构造函数实现深度拷贝。
2. 通过拷贝构造函数进行拷贝
如果修改Contact的定义,使其保存Address的指针:
class Contact
{
public:
string name_;
Address* address_;
};
这时就需要给它添加完整的拷贝构造函数了,并且更明智的做法是给Address也定义拷贝构造函数,否则Address的修改将会引起Contact的拷贝构造函数无法使用。
Contact(const Contact& other) : name_{other.name_}, address_{new Address{*other.address_}} {}
Address(const Address& other){
street_ = other.street_;
city_ = other.city_;
suite_ = other.suite_;
}
避免拷贝指针的最简单的办法是确保对象的所有组成部分都完整定义了拷贝构造函数(进行深拷贝),同时还需要实现移动构造函数、拷贝赋值运算符等。
Contact& operator==(const Contact& other)
{
if(this == &other) {
return *this;
}
name_ = other.name_;
address_ = other.address_;
return *this;
}
3. “虚”构造函数
拷贝构造函数使用之处相当有限,并且存在的一个问题是:为了对变量做深度拷贝,我们需要知道变量具体是哪种类型。
如果我们要拷贝一个存在多态性质的变量:
class ExtendedAddress : public Address
{
public:
string country_, postcode_;
ExtendedAddress(const string& street, const string& city,
const int suite, const string& country, const string& postcode)
: Address(street, city, suite), country_{country}, postcode_{postcode} {}
};
ExtendedAddress ea = ...;
Address &a = ea;
// how do you deep-copy 'a'?
这样的做法存在问题,因为我们并不知道变量a的最终派生类型是什么。由于最终派生类引发的问题,以及拷贝构造函数不能是虚函数,我们需要采用其他办法来创建对象的拷贝。
我们可以在基类和派生类中引入一个虚函数clone(),并且返回对应的指针类型:
//在基类Address中
virtual Address* Address::clone() {
return new Address{street, city, suite};
}
//在派生类ExtendedAddress中
virtual ExtendedAddress* ExtendedAddress::clone() {
return new ExtendedAddress(street, city, suite, country, postcode);
}
//现在,可以安全放心地调用clone()函数,而不必担心对象由于继承体系被切割了
ExtendedAddress ea{...};
Address& a = ea;
auto cloned = a.clone();
//变量cloned的确是一个指向深度拷贝ExtendedAddress对象地指针了;
//当然,这个指针地类型是Address*, 所以如果我们需要额外的成员,则可以通过dynamic_cast进行转换或者调用某些虚函数。
使用clone()方法的不足之处是,编译器并不会检查整个继承体系每个类中实现的clone()方法。
4. 序列化
另外一种对整个对象进行显式拷贝的方式是序列化:
- 默认情况下,类应该可以直接写入字符串或流,而不必使用任何额外的注释(最多可能是一个或两个属性)来指定该类或其成员。
- 将对象序列化到文件或内存中,然后再将其反序列化,并保留其所依赖的对象在内的所有信息。
- 在C++中,暂不支持内置序列化操作,我们可以使用
Boost.Serialization
库;
如果希望以序列化的方式实现原型模式,我们需要对对象图中可能出现的每种类型提供serialization()
方法的实现。
template <class Ar>
void Address::serialize(Ar& ar, const unsigned int version)
{
ar& street_;
ar& city_;
ar& suite_;
}
template <class Ar>
void Contact::serialize(Ar& ar, const unsigned int version)
{
ar& name_;
ar& address_; //不必对指针进行解引用
}
template <typename T>
T clone(T obj)
{
//1. serialize the object
std::ostringstream oss;
boost::archive::text_oarchive oa(oss);
oa << obj;
// string s = oss.str();
//2. deserialize it
std::istringstream iss(oss.str());
boost::archive::text_iarchive ia(iss);
T result;
ia >> result;
return result;
}
完整代码及使用示例见配套代码。
5. 原型工厂
如果我们预定义了要拷贝的对象,我们会将它们保存在哪里?
- 全局变量中:比如定义类的头文件中,这样任何使用类的人都可以获取这些全局变量并进行拷贝;
- 更明智的做法:使用某种专用的类来存储运行,即原型工厂;这将给我们带来更多的灵活性,比如我们可以定义工具函数,产生适当初始化的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> NewAuxOfficeEmployee(std::string name, int suite)
{
return NewEmployee(name, suite, aux);
}
};
为什么要使用工厂呢?考虑这样一种场景:
如果我们从某个原型拷贝得到一个对象,但忘记自定义该对象的某些属性。此时,该对象的某些本该有具体数值的参数将为0或者为空字符串。
如果我们使用工厂,就可以避免上面的情况:通过将所有非完全初始化的构造函数声明为私有的(这样就阻止了不完整对象的产生);然后将EmployeeFactory声明为friend class,只能从工厂创建完整对象!
6.总结
- 原型模式体现了对对象进行“深度拷贝”的概念,因此,不必每次都进行完全初始化,而是可以获取一个预定义的对象,拷贝它,稍微修改它,然后独立于原始的对象使用它。
- 有2种实现原型模式的方法,都需要手动操作:
- 编写正确拷贝原始对象的代码,也就是执行深度拷贝的代码;
- 编写支持序列化/反序列化的代码。