开始学习设计模式——迭代器模式和适配器模式

开始看结城浩的《图解设计模式》~(对日本人写的书总有种莫名其妙的亲近感,或许是因为他们的语言比较……轻松?)

这个老师似乎也写过 Java 多线程相关的书籍,先观望一个。

什么是设计模式(Design Pattern)?我认为,我们在不断地编写代码的过程中,在不断地 debug,重构,思考中,经常会发现一些“套路”,发现这样那样编写能让代码容易维护,容易复用,写起来优雅 etc… 对这些“套路”进行系统的分析,归纳出来概念,形式,确定它的适用范围……这样所诞生的就是设计模式,它归根结底是从丰富的实践经验中抽象出来的,又反作用于我们的实践,帮助我们编写更加有可复用性,可维护性,高内聚低耦合的代码。就这方面(!类比只是类比,而不是论证,只负责提供一种感性经验罢了)来说,设计模式与具体的编程的关系,就如哲学同具体科学的关系一样。

设计模式用来表现内部组件如何被组装在一起。一场话剧能由无数届演员来演,可是剧情(剧中人与人的关系)总是变动较少的。

其实我觉得粗糙地说,for (int i = 0; i < arr.length; i++)这样的也算是设计模式 w

面向对象是一种设计模式,面向过程也是一种设计模式,Lisp 语言的宏编程也是一种设计模式,这是毫无疑问的。

设计模式的目标有二——复用和易维护。应时时刻刻记住这两点。

不过也正如学习哲学一样,如果没有积累丰富的感性经验(无论是对日常生活的,还是对科学的),上来就整抽象的东西容易钻到牛角尖里,无法联系实际看问题。希望这本书能够提供足够的实例让我积累感性经验。

一个非常非常非常非常需要注意的地方是,此书基于 Java 2,所以没有泛型(因为这点,或许书中很多代码需要重构),没有函数式特性,没有 foreach 的语法糖……随便翻了几页,还觉得它有时候讲的不全面……应当批判地学习,同时应当多联系 Java 的源码。

先开个头,学一学它讲解的最早的两个设计模式——Iterator 迭代器模式和 Adapter 适配器模式。

Iterator 模式——迭代器

Iterator 模式归根结底是为(以各种方式)遍历各种不同的数据结构提供一套相同的接口,它满足这样的形式——

1
2
3
4
5
6
7
8
9
Iterator iter = colle.iterator(); // 这其实……也算是一个工厂模式?
while (iter.hasNext()) { // 当然我们也都知道,现在 Java 提供了一套 foreach 的语法糖来自动使用迭代器
Item item = iter.next();
. . .
}
// Java 为实现了 java.lang.iterable 的实现提供了这样的语法糖……
for (item i : aggregate) {
. . .
}

迭代器的优点在于,它抽象了对对象的遍历操作,让对各种不同集合(容器)对象的遍历的操作都变得统一。如果没有这种设计模式的话,该怎么办呢?那对每种数据结构,都需要根据它的具体实现(!)来确定对它的遍历方式,比如对于数组,我们就用for (int i = 0; i < arr.length; i++),对于链表,我们就for (Node i = head; i != null; i = i.next),那对于树,对于堆,对于图呢?对于哈希表的拉链实现和数组——红黑树实现呢?对这样的形式该如何实现?显然,必须要根据其内部数据结构(和需要迭代的方式)来决定实现。迭代器其实也可以说是对这里的循环变量 i 抽象化了。

因此就可以使用迭代器模式。根据书中的一个例子来说——

在这里,Aggregate 表示一种集合(容器)类型接口,它只提供 iterator 接口。Iterator 接口就是迭代器接口,hasNext 判断是否还能继续迭代,next 方法则是获取一个元素,并让迭代器指针后移指向下一个元素。BookShelf 是为了实验而创建的数据结构,它实现 Aggregate 接口,内部存储 Book 对象(以数组形式),last 表示下一个要插入的元素的位置(同时也是已插入的元素的数量)。它的 iterator 方法返回一个对它自身的迭代器。需要注意的是,是迭代器包含类,而不是类包含迭代器,因为一个类可以同时存在多个迭代器进行迭代。

上源码。

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
//Aggregate.java
public interface Aggregate<T> {
public MyIterator<T> iterator();
}

//MyIterator.java
public interface MyIterator<T> {
boolean hasNext();
T next();
}

//Book.java
public class Book {
private String name;
public Book(String name) {
this.name = name;
}
public String getName() {
return name;
}
}

//BookShelf.java
public class BookShelf implements Aggregate<Book>{
private int last = 0;
private Book[] books;
public int getLength() {
return last;
}
public Book getBookAt(int i) {
return books[i];
}
public void appendBook(Book book) {
books[last++] = book;
}
public BookShelf(int size) {
books = new Book[size];
}
@Override
public MyIterator<Book> iterator() {
return new BookIterator(this);
}
}

然后是最重要的迭代器代码——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//BookIterator.java
class BookIterator implements MyIterator<Book> {
private int index;
private BookShelf bookshelf;
public BookIterator(BookShelf obj) {
bookshelf = obj;
index = 0;
}
@Override
public boolean hasNext() {
return index < bookshelf.getLength();
}
@Override
public Book next() {
return bookshelf.getBookAt(index++);
}
}

它的使用如下——

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {
public static void main(String[] args) {
BookShelf books = new BookShelf(10);
books.appendBook(new Book("双城记"));
books.appendBook(new Book("毁灭"));
books.appendBook(new Book("罗密欧与朱丽叶"));
MyIterator<Book> iter = books.iterator();
while (iter.hasNext()) {
System.out.println(iter.next().getName());
}
//也可以使用 for 循环来使用迭代器
}
}

一个原则是,对于声明类型,能用基类就用基类,能用接口就用接口。

同时,迭代器也允许使用不同的迭代方式,只需要更改 next 和 hasNext 方法即可,比如跳跃着遍历,反向遍历……还能在迭代器内部暂存信息并不暴露给外界,比如遍历树的时候在迭代器里放个栈,队列之类的。这显然是符合抽象的。

问题:Java 中 iterable 是什么?它和迭代器有什么关系?

Adapter 模式——适配器

所谓适配器模式,就是将别人的不能直接复用的代码拿来进行一些包装,让它能够被复用。这里帮助别人的代码适配我们的需要的设计模式就叫适配器(Adapter),也叫包装器(Wrapper),因为它不直接使用别人的代码,而是进行一些包装后使用。就像笔记本的适配器把 220 伏的交流电转成 12 伏的直流电一样。Adapter 的意思是“使……相互适合的东西”。

适配器模式中需要四个角色——Client,Target,Adapter,Adaptee,它们的关系是——Client(笔记本电脑)使用 Adapter(电源适配器),将 Adaptee(220 伏交流电)转换成它所需要的 Target(12 伏直流电)。其中 Client 是调用适配器的对象,Adapter 是适配器,Adaptee 是别人的需要进行包装的代码(因此不应直接修改),Target 是 Client 所需的方法。

适配器模式有两种实现——

  • 类适配器模式(使用继承的适配器)
  • 对象适配器模式(使用委托的适配器)

此书给出的类适配器模式我觉得有一些漏洞,出现了抽象泄露的问题(虽然作者不可能没有意识到这个问题,因为习题 1 就涉及到了这个问题)。我认为,在设计自己的类库(就比如这里实现的适配器,对别人的类库进行包装,其实可以认为这里的 Target,Adapter,Adaptee 是自己设计出来的类库(其中 Adaptee 是“别人的代码”,在设计适配器的时候不应该对 Adaptee 进行任何改动),Client 是使用该类库的客户端)的时候,应该认为用户是“愚蠢”的,否则面向对象的封装原则就被破坏了。

这张图非常好地表现了适配器的功能和(对象适配器模式的)形式。

类适配器实例

所谓类适配器模式,就是编写 Adapter 继承 Adaptee,实现所需要的接口(Target)。

本书提供了这样一个实例——

首先有一个 Banner 类,这里假设是别人已经编写好的,要复用它。它提供了两个功能——将字符串用括号包围或用*包围,它的实现是非常简单的。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Banner.java
public class Banner {
private String str;
public Banner(String str) {
this.str = str;
}
public void showWithParen() {
System.out.println(String.format("(%s)", str));
}
public void showWithAster() {
System.out.println(String.format("*%s*", str));
}
}

我们的需求(也就是 Target)是 Print 接口,Print 接口假设有两个语法,使用成对括号包围字符串规定字符串变细,使用**包围则是规定字符串加粗。

1
2
3
4
5
//Print.java
public interface Print {
public abstract void printWeak(); // abstract 对于接口其实是无必要的,毕竟只有 default 方法能够添加实现
public abstract void printStrong();
}

PrintBanner 担任 Adapter 的角色,它要提供 Target(因此就要实现 Print 接口),同时继承 Adaptee 以复用其代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//PrintBanner.java

public class PrintBanner extends Banner implements Print{
public PrintBanner(String str) {
super(str);
}
@Override
public void printWeak() {
super.showWithParen();
}
@Override
public void printStrong() {
super.showWithAster();
}
}

然后是 Main,Main 担任 Client 的角色,它使用 Adapter 提供的 Print 接口。

1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
Print p = new PrintBanner("hello, happy world"); // 我觉得有点问题。
p.printStrong();
p.printWeak();
}
}

可见,Adaptee 的操作被包装(wrap)起来了,用户(Client)通过使用 Adapter 提供的接口(Target)来间接访问 Adaptee。

但我认为这里的抽象有一些问题——如何保证用户必然使用 Print 接口来作为声明类型?如果用户偏偏不按需求来,使用 PrintBanner 作为声明类型,进行这样的操作怎么办?

1
2
3
PrintBanner p = new PrintBanner("hello,happy world");
p.showWithAster(); // 合法!
p.showWithParen();

这在语法上并无问题,但是这显然破坏了封装!用户不应该能通过任何途径来直接访问到 Adaptee。在这个示例中不会出问题,但若是联想上面的笔记本电源适配器的例子,就相当与是说用户可以直接把 220V 的交流电连接在笔记本电脑(而不是转换成 12V 直流电之后)上了,这不就出了事?我想也没有语法能够屏蔽父类的方法,只能重写或将父类方法改成 protected,但这也是不好的,我们很多时候不能更改别人的代码。

因此我认为类适配器有这样的缺陷——类库设计者必须与使用者做约定,要求使用者必须使用 Target 作为声明类型,才能正常使用。而且如果想要复用的是 final 类,则是不能使用的。

对象适配器

对象适配器容易描述。它是指 Adapter 包含一个 Adaptee 实例,通过委托(也就是将方法的执行交给另一个类(实例)执行)的方式让 Client 访问 Adaptee。

该方法相对于类适配器只需要更改 PrintBanner 类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//PrintBanner.java
public class PrintBanner implements Print{
private Banner banner;
public PrintBanner(String str) {
banner = new Banner(str);
}
@Override
public void printWeak() {
banner.showWithParen();
}
@Override
public void printStrong() {
banner.showWithAster();
}
}

这样的一个好处是,不再需要和 Client 做额外约定了,无论 Client 使用 Print 还是 PrintBanner 作为声明类型,都不会出问题。

这一段是自己的想法,可能有错误。
我认为对象适配器模式中适配器的角色更加符合其概念。一般来说,Adapter 提供的功能应当是 Adaptee 的子集,也就是说,Adapter 能实现的方法,Adaptee 应当能实现,Adapter 不能实现的方法,Adaptee 也可能可以实现。比如,有一个库,能够把各种图片格式相互转换,比如 png, jpg, tiff, psd, pdf 等,但这时我只需要将 png 转换成其它格式,我就据此编写一个适配器,使用 Adaptee 功能的子集。而且使用委托的对象适配器模式,其结构也更加符合之前对适配器的感性理解。
然后考虑类适配器,类适配器使用继承来复用代码。而继承,虽然就概念来说,子类是基类的属概念,但是就功能,具体程度来说,子类是比基类更加具体的,在基类的基础上提供更多功能。因此这就和上面我所认为的 Adapter 同 Adaptee 提供的功能的关系相悖了。就上面的电源适配器的例子来说,就像是给 220V 交流电的电源加了个组件,让它能够转换成 12V 直流电,这个电源原本输出 220V 交流电的能力是没有改变的。这时就必须和用户做约定(Target),要求用户使用该 12V 的直流电。但是用户也有权力不遵守约定……

对象适配器的 UML 类图如图。

学会看 UML 图还是比较方便的。

使用适配器模式的目的是对现有的类进行适配,让其可复用,在出现 bug 的时候,也能够容易确定 bug 的位置出于 Adapter 中(假如 Adaptee 经过充分测试的话)。如果要去修改已经充分测试的类,则必须重新进行测试。

适配器模式也能够方便地让版本适配,从而让新旧版本兼容,让能够同时维护新版本和旧版本变得简单。

习题

java.util.Properties 可以像这样管理键值对——

1
2
3
year=2021
month=4
day=2

它提供了两个方法,帮助从这个需要去学习!)中取出属性(property)或将属性写入流中。要求使用 Adapter 适配器模式,编写一个将属性保存在文件中的 FileProperties 类。

扮演 Target 角色的 FileIO 接口如下——

1
2
3
4
5
6
7
8
//FileIO.java
import java.io.IOException;
public interface FileIO {
public void readFromFIle(String filename) throws IOException;
public void writeToFile(String filename) throws IOException;
public void setValue(String key, String value); // 设置键值对
public String getValue(String key);
}

扮演 Adapter 角色的 FileProperties 类如下——

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
//FileProperties.java
import java.io.*;
import java.util.Properties;
public class FileProperties implements FileIO {
private Properties p;
public FileProperties() {
p = new Properties();
}
@Override
public void readFromFIle(String filename) throws IOException {
try (FileInputStream file = new FileInputStream(new File(filename))) {
p.load(file);
}
}
@Override
public void writeToFile(String filename) throws IOException {
try (FileOutputStream file = new FileOutputStream(new File(filename))) {
p.store(file,"");
}
}
@Override
public void setValue(String key, String value) {
p.setProperty(key, value);
}
@Override
public String getValue(String key) {
return p.getProperty(key);
}
}

Client 角色如下——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) {
FileIO f = new FileProperties();
try {
f.readFromFIle("file.txt");
f.setValue("year", "21435");
f.setValue("month", "4");
f.setValue("day", "2222");
f.writeToFile("newFile.txt");
} catch (IOException e) {
e.printStackTrace();
}
}
}

这本书将设计模式中各个组成部分称为“角色”相当精妙,而且有趣。感觉遇到了一个好的开始。

等回家后第一时间去学 Spring……其它的都缓缓。


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!