本章用来介绍设计模式中的原型模式,该模式也是设计模式中较为简单、也是比较常见的一种设计模式。同样给出场景及相应代码层层推进的方式来学习原型模式。

一、场景

假设现在公司内部要开发一款周报提交系统。周报需要填写的表单设计如下:

1

比如第一周我们填写了一份表单如下:

2

现在过了第二周,我们再填写一份表单,如下:

3

仔细看这两份表单,有什么问题?是不是主要产生变化的,只有表单中被加粗的这三项(本周总结、下周计划、提交日期),其它的几项是不是都不会怎么变动?如果,我们每周填写周报都要填写全部的项,是不是显得很虎?所以,我们的需求是能够保留上周填写的周报,在其基础上进行修改。

下面开始进行代码的模拟及步步推进。

二、代码模拟

为了方便模式的讲解,我们用一个WeekReport类,来模拟这份表单。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class AppTest {

public static void main(String[] args) throws CloneNotSupportedException, InterruptedException {
WeekReport reportFirst = new WeekReport("Chemlez", "IT部门", "看了一本书", "再看一本书", "Nothing", new Date());
WeekReport reportSecond = new WeekReport("Chemlez", "IT部门", "又看完了一本书", "看两本书", "Nothing", new Date(new Date().getTime() + 7 * 24 * 3600 * 1000));
System.out.println(reportFirst);
System.out.println(reportSecond);

}
}

/**
* 该类用来模拟周报 不使用Cloneable接口就要重复输入很多内容
* 下面是使用Cloneable接口进行原型模式
*/
class WeekReport { // 该方式是浅拷贝,如果其中包含引用,将引用一并进行拷贝
private static int id = 0;
private String emp;
private String summary;
private String plain;
private String suggestion;
private Date date;

public WeekReport() {
++id;
}

public WeekReport( String emp, String summary, String plain, String suggestion, Date date) {
++id;
this.emp = emp;
this.summary = summary;
this.plain = plain;
this.suggestion = suggestion;
this.date = date;
}

// 省略setter和getter方法,以及toString方法
}

===输出===
WeekReport{name='Chemlez', dept='IT部门', summary='看了一本书', plain='再看一本书', suggestion='Nothing', date=Tue May 18 21:18:38 CST 2021}
WeekReport{name='Chemlez', dept='IT部门', summary='又看完了一本书', plain='看两本书', suggestion='Nothing', date=Tue May 25 21:18:38 CST 2021}

我们看到第二周周报,尽管第二周周报的大部分内容与第一周周报内容一致,但是仍然需要重复设置。

此时,我们便可以通过使用”原型模式”来解决这个问题。

市容

2.1 代码一

现在,我需要保留上周需要填写的内容。从面向对象的角度出发,就是我们能够”克隆”出原来的对象,然后复用这个对象的属性,直接在”克隆”出的对象上进行修改我们需要修改的部分即可,不用修改的部分就不去改动它即可。

这里使用Java中提供的接口,Cloneable接口。

1
2
public interface Cloneable {
}

可以看到这个接口没有定义任何的方法,其实这只是一个标记接口,类似于Serializable这个接口。只是告诉JVM该类实例化的对象是可以被”克隆”的。

要想达到这个上面”克隆”的效果,我们需要重写该接口。即:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class WeekReport implements Cloneable { // 该方式是浅拷贝,如果其中包含引用,将引用一并进行拷贝
private static int id = 0;
private String name;
private String dept;
private String summary;
private String plain;
private String suggestion;
private Date date;

public WeekReport() {
++id;
}

public WeekReport(String name, String dept, String summary, String plain, String suggestion, Date date) {
++id;
this.name = name;
this.dept = dept;
this.summary = summary;
this.plain = plain;
this.suggestion = suggestion;
this.date = date;
}

@Override
public Object clone() throws CloneNotSupportedException { // 将修饰符提升到public,可以在任何类下进行使用
return super.clone();
}

// 省略getter、setter以及toString()方法

}

clone方法是直接复制内存中的二进制(重新开辟的一块内容空间用来存储),效率更高。

这个时候我们就可以直接进行对象的克隆,使用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class AppTest {

public static void main(String[] args) throws CloneNotSupportedException,InterruptedException {


WeekReport reportFirst = new WeekReport("Chemlez", "IT部门", "看了一本书", "再看一本书", "Nothing", new Date());

WeekReport reportSecond = (WeekReport) reportFirst.clone();
reportSecond.setSummary("又看了一本书");
reportSecond.setPlain("看两本书");
Date date = reportSecond.getDate();
date.setTime(date.getTime() + 7*24*3600*1000); // 模拟过了一周
System.out.println(reportFirst);
System.out.println(reportSecond);
}
}

====输出====
WeekReport{name='Chemlez', dept='IT部门', summary='看了一本书', plain='再看一本书', suggestion='Nothing', date=Tue May 25 21:19:35 CST 2021}
WeekReport{name='Chemlez', dept='IT部门', summary='又看了一本书', plain='看两本书', suggestion='Nothing', date=Tue May 25 21:19:35 CST 2021}

可以看出,我们将源对象进行克隆,在现有对象上修改我们想要修改(通过set和get方法)的属性即可,是不是就满足了我们所提出的需求?

不过细心的读着可能看出了,为什么我们的周报一的时间和周报二的时间是一样的了?难道克隆出来的对象和源对象指向同一个地址?

我们可以来测试一下,比较两个对象是否指向同一个地址。

1
2
3
System.out.println(reportFirst==reportSecond);
===输出===
false

结果为false,所以可以看到两个对象并非指向同一个地址。其实,在上面我们也写到了,clone方法是直接复制内存中的二进制(重新开辟的一块内容空间用来存储),所以两个对象并非指向同一片地址。而这种拷贝的方式是一种浅拷贝方式。请看下图:

4

一图胜千言。对象是两个对象,但是对象中的属性,如果是引用类型,那么在拷贝的时候就会指向同一片堆内存空间,也就是说他们的引用类型的属性指向的是同一个对象,所以改动其中一个属性也会改动另外一个对象的属性。

那么朝着这个目的,我们让其引用类型属性指向对象也是各自的对象,从而互不干扰。

2.2 代码二

我们改造的思路是,将其中的引用类型属性通过get获取后,再进行clone,然后再通过set方法传回到原来clone对象中。这么说有点绕,直接看代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class AppTest {

public static void main(String[] args) throws CloneNotSupportedException{

WeekReport reportFirst = new WeekReport("Chemlez", "IT部门", "看了一本书", "再看一本书", "Nothing", new Date());

WeekReport reportSecond = (WeekReport) reportFirst.clone();
Date date = reportSecond.getDate();
Date dateCloned = (Date) date.clone();
reportSecond.setDate(dateCloned); // 保证我们可以保留原有的值,并且在后续的改动中,不会干扰到原对象

// 设置新值
reportSecond.setSummary("又读完了一本书");
reportSecond.setPlain("看两本书");
Date dateC = reportSecond.getDate();
dateC.setTime(dateC.getTime() + 7 * 24 * 3600 * 1000);

System.out.println(reportFirst);
System.out.println(reportSecond);
}
}

====输出====
WeekReport{id=1, name='Chemlez', dept='IT部门', summary='看了一本书', plain='再看一本书', suggestion='Nothing', date=Tue May 18 21:31:29 CST 2021}
WeekReport{id=1, name='Chemlez', dept='IT部门', summary='又读完了一本书', plain='看两本书', suggestion='Nothing', date=Tue May 25 21:31:29 CST 2021}

通过将date进行克隆,返回的对象也是二进制复制,并且是新的内存空间。如下图:

5

一图胜千言。在代码:

1
2
Date dateCloned = (Date) date.clone(); // 对象clone,返回新的对象,即新开辟的内存空间
reportSecond.setDate(dateCloned); // 保证我们可以保留原有的值,并且在后续的改动中,不会干扰到原对象

第二句通过set,给reportSecond重新设置了一个Date对象,并且保证了,其内容值与原来的值一样,这样既能够保证我们可以复用原来的属性值,也可保证我们修改其属性,不会影响到过去的属性值。

到了这里,代码的改动似乎已经很完美了,但是肯定有读者有会产生这么一个疑问,你现在对象中只有一个引用类型的属性,如果我给你成百上千的引用类型的属性你该怎么办,难道还去一个一个clone么?如果你属性中引用类型的属性中中又存在引用类型的属性怎么办?层层嵌套的引用类型,用这种逐个属性克隆再赋值的方式,岂不是要累死。

因此,就需要对其继续进行改进。

3.3 代码三

代码二遗留下的来的问题是:如果一个类中包含的引用型对象过多,即如果对象的深度比较深,则深拷贝实现起来较为繁琐。

进一步的改进,我们还是需要实现Cloneable接口,重写clone克隆方法。即:在这里就需要修改WeekReportclone方法。不过,在这里我们还需要将类去实现Serializable接口目的:是将对象进行序列化,然后再将对象进行反序列化,当反序列化为对象时,此时得到的对象就是一种天然的深拷贝方式得来的对象。

对象中无论存在多少的层级关系(引用关系),将这个对象序列化到硬盘上(二进制,序列化就是天生的深拷贝),在序列化的同时,也会将这种层级关系进行序列化。

实现方式一

根据上面的思路,现在我们将对象进行序列化,然后再将对象进行反序列化,从而达到深拷贝的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public class AppTest {

public static void main(String[] args) throws CloneNotSupportedException {

// 第一周
WeekReport reportFirst = new WeekReport("Chemlez", "IT部门", "看了一本书", "再看一本书", "Nothing", new Date());

// 第二周
WeekReport reportSecond = (WeekReport) reportFirst.clone();

// 改写值
Date date = reportSecond.getDate();
date.setTime(date.getTime() + 7 * 24 * 3600 * 1000); // 日期修改
reportSecond.setSummary("又对了一本书");
reportSecond.setPlain("再读两本书");
System.out.println(reportFirst);
System.out.println(reportSecond);

}
}

class WeekReport implements Cloneable, Serializable {
private static int id = 0;
private String name;
private String dept;
private String summary;
private String plain;
private String suggestion;
private Date date;

public WeekReport() {
++id;
}

public WeekReport(String name, String dept, String summary, String plain, String suggestion, Date date) {
++id;
this.name = name;
this.dept = dept;
this.summary = summary;
this.plain = plain;
this.suggestion = suggestion;
this.date = date;
}

@Override
public Object clone() throws CloneNotSupportedException {
try {
// 序列化
OutputStream os = new FileOutputStream("a.txt"); // 输出流
ObjectOutputStream oos = new ObjectOutputStream(os); // 对象的输出流
oos.writeObject(this); // 将对象输出
oos.close();

// 反序列化
InputStream fis = new FileInputStream("a.txt");
ObjectInputStream ois = new ObjectInputStream(fis);
Object clone = ois.readObject(); // 需要返回的对象,该对象 "深拷贝"
ois.close();
return clone; // 将反序列化的对象进行返回

} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
return null;
}

====结果输出====
WeekReport{name='Chemlez', dept='IT部门', summary='看了一本书', plain='再看一本书', suggestion='Nothing', date=Wed May 19 13:33:51 CST 2021}
WeekReport{name='Chemlez', dept='IT部门', summary='又对了一本书', plain='再读两本书', suggestion='Nothing', date=Wed May 26 13:33:51 CST 2021}

从结果看出,我们达到了所要的要求。

但是从代码中,我们又看到了一种问题,就是我们将序列化的路径给写死了,这里又产生了耦合。又有读着肯定想到,那我们干脆使用当前路径下的相对路径不就好了?但是,你有没有想过在LinuxWindows的路径设置有很大的区别,其中Linux中就不存在盘符的级别。如果设定写死的路径,那么就根本没有达到跨平台的目的。那么,有什么好的方法呢?

答案是肯定有的。我们可以将上面序列化到硬盘,再从硬盘反序列化到内存的操作,全部放到内存层面上进行操作。即:将对象序列化到内存上,再从内存上进行反序列化操作。

实现方式二

这里就只给出clone方法,其他方法都一样就不再重复写出了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Override
public Object clone() throws CloneNotSupportedException {
try {
// 序列化
ByteArrayOutputStream bos = new ByteArrayOutputStream();// 内存层面上输出流
ObjectOutputStream oos = new ObjectOutputStream(bos); // 对象的输出流
oos.writeObject(this); // 序列化对象,对象的所有属性层级关系会被序列化进行自动处理
oos.close();
byte[] bytes = bos.toByteArray(); // 从内存中取出数据
// 反序列化
ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bis);
Object clone = ois.readObject(); // 需要返回的对象,该对象 "深拷贝"
ois.close();
return clone;

} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
return null;
}

至此,代码已经完美解决场景提出的问题。

三、总结

通过原型模式,创建对象新的方式,即用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。创建出的对象,和原对象属性值相同,但是修改不会影响源对象。

又由于原型模式是通过clone的方式,所以它创建对象的方式会很快。所以当直接创建对象的代价比较大时,则采用这种模式。


Comment