原型模式简单介绍
原型模式是一个创建型的模式。原型二字表明了该模式应该有一个样板实例,用户从这个样板对象中复制出一个内部属性一致的对象,这个过程也就是我们俗称的“克隆”。被复制的实例就是我们所称的“原型”,这个原型是可定制的。原型模式多用于创建复杂的或者构造耗时的实例,因为这种情况下,复制一个已经存在的实例可使程序运行更高效。
原型模式的定义
用原型实例指定创建对象的种类,并通过拷贝这些原型创建新的对象。
原型模式的使用场景
- (1)类初始化需要消耗非常多的资源,这个资源包括数据、硬件资源等,通过原型拷贝避免这些消耗。
- (2)通过 new 产生一个对象需要非常繁琐的数据准备或访问权限,这时可以使用原型模式。
- (3)一个对象需要提供给其他对象访问,而且各个调用者可能都需要修改其值时,可以考虑使用原型模式拷贝多个对象供调用者使用,即保护性拷贝。
*需要注意的是,通过实现 Cloneable 接口的原型模式在调用 clone 函数构造实例时并不一定比通过 new 操作速度快,只有当通过 new 构造对象较为耗时时或者说成本比较高时,通过 clone 方法才能够获得效率上的提升。因此,在使用 Cloneable 时需要考虑构建对象的成本以及做一些效率上的测试。当然,实现原型模式也不一定非要实现 Cloneable 接口,也有其他的实现方式,后面会详细介绍
原型模式 UML 类图
角色介绍:
- Client:客户端用户。
- Prototype:抽象类或者接口,声明具备 clone 能力。
- ConcretePrototype:具体的原型类。
原型模式的简单实现
下面以简单的文档拷贝为例来演示一下简单的原型模式,我们在这个例子中首先创建了一个文档对象,即 WordDocument,这个文档中含有文字和图片。用户经过了长时间的内容编辑后,打算对文档做进一步的编辑,但是,这个编辑后的文档是否会被采用还不确定,因此,为了安全起见,用户需要将当前文档拷贝一份,然后再在文档副本上进行修改,如此,这个原始文档就是我们上述所说的样板实例,也就是将要被“克隆”的对象,我们称为原型:
//文档类型,扮演的是 ConcretePrototype 角色,而 Cloneable 是代表 Prototype 角色
public class WordDocument implements Cloneable {
//文本
private String mText;
//图片名列表
private ArrayList<String> mImages = new ArrayList<>();
public WordDocument() {
}
@Override
public WordDocument clone() {
try {
WordDocument doc = (WordDocument) super.clone();
doc.mText = this.mText;
doc.mImages = this.mImages;
return doc;
} catch (CloneNotSupportedException e) {
}
return null;
}
public String getmText() {
return mText;
}
public void setmText(String mText) {
this.mText = mText;
}
public ArrayList<String> getmImages() {
return mImages;
}
public void setmImages(ArrayList<String> mImages) {
this.mImages = mImages;
}
public void showDocument() {
Log.d("WordDocument", "---------- Word Content Start -----------");
Log.d("WordDocument", "Text :" + mText);
Log.d("WordDocument", "Images List :" + mImages.toString());
Log.d("WordDocument", "---------- Word Content End -----------");
}
}
通过 WordDocument 类模拟了 Word 文档中的基本元素,即文字和图片。WordDocument 在该原型模式示例中扮演的角色是 ConcretePrototype,而 Cloneable 的角色则为 Prototype。WordDocument 中的 clone 方法用以实现对象克隆。注意,这个方法并不是 Cloneable 接口中的,而是 Object 中的方法。Cloneable 也是一个标识接口,它表明这个类的对象是可拷贝的。如果没有实现 Cloneable 接口确调用了 clone() 函数将抛出异常。 在这个示例中,我们通过实现 Cloneable 接口和覆写 clone 方法实现原型模式。
下面看看 Client 端的使用:
//1、构建文档对象
WordDocument originDoc = new WordDocument();
//2、编辑文档,添加图片等
originDoc.setmText("这是一篇文档");
originDoc.addImages("图片 1");
originDoc.addImages("图片 2");
originDoc.addImages("图片 3");
originDoc.showDocument();
//以原始文档为原型,拷贝一份副本
WordDocument doc2 = originDoc.clone();
doc2.showDocument();
//修改文档副本,不会影响原始文档
doc2.setmText("这是修改过的 doc2 文本");
doc2.showDocument();
originDoc.showDocument();
输出 log 如下显示:
从上面的代码可以看出 doc2 是通过 originDoc.clone() 创建的,并且 doc2 第一次输出的时候和 originDoc 输出是一样的,即 doc2 是 originDoc 的一份拷贝,它们的内容是一样的,而 doc2 修改了文本内容以后并不会影响 originDoc 的文本内容,这就保证了 originDoc 的安全性。还需要注意的是,通过 clone 拷贝对象时并不会执行构造函数!因此,如果在构造函数中需要执行一特殊的初始化操作的类型,在使用 Cloneable 实现拷贝时,需要注意构造函数不会执行的问题。
浅拷贝和深拷贝
上述原型模式的实现实际上只是一个浅拷贝,也称为影子拷贝,这份拷贝实际上并不是将原始文档的所有字段都重新构造了一份,而是副本文档的字段引用原始文档的字段,如下图所示:
我们知道 A 引用 B 就是说两个对象指向同一个内存地址,当修改 A 时 B 也会改变,B 修改时 A 同样会改变。我们直接看下面的例子,将内容修改为如下:
//1、构建文档对象
WordDocument originDoc = new WordDocument();
//2、编辑文档,添加图片等
originDoc.setmText("这是一篇文档");
originDoc.addImages("图片 1");
originDoc.addImages("图片 2");
originDoc.addImages("图片 3");
originDoc.showDocument();
//以原始文档为原型,拷贝一份副本
WordDocument doc2 = originDoc.clone();
doc2.showDocument();
//修改文档副本,不会影响原始文档
doc2.setmText("这是修改过的 doc2 文本");
//新加入代码
doc2.addImages("哈哈.jpg");
doc2.showDocument();
originDoc.showDocument();
再次运行程序,输出结果如下图所示:
正如我们红色标记出显示的那样,最后我们答应的修改后的 doc2 和 未修改之前 originDoc 中居然出现了相同的数据。我们在 doc2 中添加了一张名为 “哈哈.jpg”的照片,但是,同时也显示在了 originDoc 中,这是怎么一回事呢?学习过 C++ 的朋友都会有比较深刻的体会,这是因为上文中的 WordDocument 的 clone 方法中只是简单地进行了浅拷贝,引用类型的新对象 doc2 的 mImages 只是单纯地指向了 this.mImages 引用,并没有重新构造一个 mImages 对象,然后将原始文档中的图片添加到新的 mImages 对象中,这样就导致 doc2 中的 mImages 与原始文档中的是同一个对象,因此,修改了其中一个文档中的图片,另一个文档也会受影响。doc2 的 mImages 添加了新的图片,实际上也就是往 originDoc 里添加了新的图片,所以,originDoc 里面也有 “哈哈.jpg”图片文件。那如何解决这个问题呢?答案就是采用深拷贝,即在拷贝对象时,对与引用型的字段也要采用拷贝的形式,而不是单纯引用的形式。 clone 方法修改如下:
@Override
public WordDocument clone() {
try {
WordDocument doc = (WordDocument) super.clone();
doc.mText = this.mText;
doc.mImages = (ArrayList<String>) this.mImages.clone();
return doc;
} catch (CloneNotSupportedException e) {
}
return null;
}
如上述代码所示,将 doc.mImages 指向 this.mImages 的一份拷贝,并不是 this.mImages 本身,这样在 doc2 添加图片时并不会影响 originDoc,运行效果如下图所示:
原型模式是非常简单的一个模式,它的核心问题就是对原始对象进行拷贝,在这个模式的使用过程中需要注意的一点就是:深、浅拷贝的问题。在开发过程中,为了减少错误,这里建议大家在使用该模式时尽量使用深拷贝,避免操作副本时影响原始对象的问题。
原型模式实战
比如说有这么一个例子:在开发中,我们有时候会满足一些需求,就是有的对象中的内容只允许客户端程序读取,而不允许修改。我们的应用中一般都有用户登录模块,就拿这个模块来说吧,在用户登录之后,我们会通过一个 LoginSession 保存用户的登录信息,这些用户信息可能在 APP 的其他模块被用来做登录校验、用户个人信息显示等。但是,这些信息在客户端程序是不允许修改的,而需要在其他模块被调用,因此,需要开放已登录用户信息的访问接口。我们先来简单实现一下,然后在找出其中的问题:
初始代码如下所示:
//用户实体类
public class User {
public int age;
public String name;
public String phoneNum;
public Address address;
@Override
public String toString() {
return "User{" +
"age=" + age +
", name='" + name + '\'' +
", phoneNum='" + phoneNum + '\'' +
", address=" + address +
'}';
}
}
//用户地址类,存储地址的详细信息
public class Address {
public String city; //城市
public String district; //区
public String street; //街道
public Address(String city, String district, String street) {
this.city = city;
this.district = district;
this.street = street;
}
@Override
public String toString() {
return "Address{" +
"city='" + city + '\'' +
", district='" + district + '\'' +
", street='" + street + '\'' +
'}';
}
}
//登录接口
public interface Login {
void login();
}
//登录实现
public class LoginImpl implements Login {
@Override
public void login() {
//登录到服务器,获取到用户信息
User loginedUser = new User();
//模仿服务器返回的用户信息并设置给 loginedUser对象
loginedUser.age = 22;
loginedUser.name = "aKaiC";
loginedUser.address = new Address("北京市", "朝阳区", "华贸天地");
//登录校验完成以后,将登录的用户信息存储到 Session 中
LoginSession.getLoginSession().setsLoginSession(loginedUser);
}
}
//登录 Session
public class LoginSession {
static LoginSession sLoginSession = null;
private User loginedUser;
private LoginSession() {
}
;
public static LoginSession getLoginSession() {
if (sLoginSession == null) {
synchronized (LoginSession.class) {
if (sLoginSession == null) {
sLoginSession = new LoginSession();
}
}
}
return sLoginSession;
}
//设置已登录的用户信息,不对外开放
void setsLoginSession(User user) {
loginedUser = user;
}
public User getLoginedUser() {
return loginedUser;
}
}
上述代码非常简单,就是在用户登录之后通过 LoginSession 的 setLoginedUser 函数将登录用户的信息设置到 Session 中,这个 setLoginedUser 方法是包级私有的,因此,外部模块无法调用,我们如果要更新用户信息只需要调用 LoginImple 的 login 方法即可(当然这个 login 方法并不是很完善,大家能理解这个意思即可),也就是外部客户端程序不能修改已登录的用户信息。
现在我们就来找找上面我们程序中的 bug,现在假设有这么一种可能:比如说由于你的效率比较高,在分配给你的模块完成后,后台服务器那块功能还未完成不能进行测试?你也闲着无聊就开始了使用假数据进行测试的方案。由于不能采用网络请求,那么就不能通过自动调用 Login 接口的 login 方法来更新数据,然而聪明的你为了方便,变写出了如下代码来更新数据:
User newUser = LoginSession.getLoginSession().getLoginedUser();
newUser.address = new Address("上海市", "XX区", "XXXX");
经过测试,确实可以更改用户的地址信息,你也就放心啦!刚好这个时候朋友喊你出去抽烟,你代码没有恢复就跑出去了,回来由于一忙也把这茬事给忘了,等到后台布置完成后,开始真正的测试了,你会发现在网络请求未成功之前打出的 Log 信息却包含了“”上海市”, “XX区”, “XXXX””这些内容(这个例子并不是很恰当,现实开发中可能根本不存在,这里只是简单的举个例子),也就是在网络请求未成功的情况下修改了用户的 address 字段!你感觉自己设置的用户信息更新只限于与 LoginSession 类在同一个包下的限制瞬间被打破了,这样以来不管客户端代码是无意间写错了代码导致用户信息被修改,还是对代码理解有误导致的问题,最终结果都是用户信息被修改了,这时你就慌神了,开始各种百度找问题,于是就有了下面一个结论:“必须使用原型模式来进行保护性拷贝,也就是说在 LoginSession 的 getLoginUser() 函数中返回的是已登录用户的拷贝,当更新用户地址的网络请求完成时,再通过包私有的 LoginSession 中的 setLoginedUser 更新用户信息”于是就在 User 类中覆写了 clone 方法:
//用户实体类
public class User implements Cloneable {
public int age;
public String name;
public String phoneNum;
public Address address;
@Override
public String toString() {
return "User{" +
"age=" + age +
", name='" + name + '\'' +
", phoneNum='" + phoneNum + '\'' +
", address=" + address +
'}';
}
@Override
public User clone() {
User user = null;
try {
user = (User) super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return user ;
}
}
并且在 LoginSession 中将 getLoginedUser 函数修改如下:
public User getLoginedUser() {
return loginedUser.clone();
}
这就使得在任何时候调用 getLoginedUser 函数获取到的用户对象都是一个拷贝对象,即使客户端代码不小心修改了这个拷贝对象,也不会影响最初的已登录用户对象,对已登录用户信息的修改只能通过 setLoginedUser 这个方法,即使我们还是采用 new Address() 更新的也仅仅是拷贝对象,只有与 LoginSession 在同一个包下的类才能够访问这个包级私有方法,因此,确保了它的安全性。
总结
原型模式本质上就是对象拷贝,与 C++ 中的拷贝构造函数有些类似,它们之间容易出现的问题也都是深拷贝、浅拷贝。使用原型模式可以解决构建复杂对象的资源消耗问题,能够在某些场景下提升创建对象的效率。还有一个重要的途径就是保护性拷贝,也就是某个对象对外可能是只读的,为了防止外部对这个只读对象修改,通常可以通过返回一个对象拷贝的形式实现只读的限制。
优点与缺点:
- 优点
原型模式是在内存中二进制流的拷贝,要比直接 new 一个对象性能好很多,特别是要在一个循环体内产生大量的对象时,原型模式可以更好滴体现其优点。
- 缺点
这既是它的优点也是缺点,直接在内存中拷贝,构造函数是不会执行的,在实际开发中应该注意这个潜在问题,优点就是减少了约束,缺点也是减少了约束,需要大家在实际应用是考虑。