Java中的设计模式:适配与装饰

本来这节课应该是安排在Java的对象的介绍之后的。但是我们因为要写程序来验证各种东西,所以我想必须先把输入输出搞清楚了,才好说其他的。

先列出本周课程的目标:写一个表达式求值的程序。输入一个表达式,程序可以输出它的值。例如:

((11 + 33) / 11 - 2 * 4) * (8 - 3)
-20

上面一行是输入,我们的程序读入这个字符串,并打印出它的计算结果。如果有一些基础的同学,可以先动动脑筋,看看你有没有什么思路。没有基础的同学,也不要着急,经过未来十天的课程,你应该也能在2017年之前完成表达式求值,这个经典题目了。

我们先从读入字符串讲起。

输入与输出

再回过头,看我们的第一个程序,hello world。

public class Main {
    public static void main(String args[]) {
        System.out.println("Hello World!");
    }
}

我们之前的课程里介绍过 static 是什么,今天的重点则在于 System.out 是什么。通过查看JDK源代码,可以看到,System.java 里,out是这么定义的:

public final static PrintStream out = null;

可见,out 是一个 static 变量,所以我们才可以使用类名直接引用它。

在Java语言中,所有的输入都被抽象成了输入流(InputStream),所有的输出都被抽象成了输出流(OutputStream)。以OutputStream为例,它的几个子类,PrintStream可以向控制台上输出字节,FileOutputStream可以向文件中写入字节,SocketOutputStream可以向网络连接上写入字节,等等,它们都是OutputStream的子类。

与之相对应的InputStream也有各种子类分别负责不同的功能。只是Output负责向外写,而Input负责向程序里读。

把字节的输入输出抽象成一个连续的流,确实形象了很多。有了IO,我们的程序终于可以与外界进行交互了。例如:

        byte[] buf = new byte[512];
        System.out.println("hey, may I have your name, please? ");
        int n = 0;
        try {
            n = System.in.read(buf);
        } catch (IOException e) {
            e.printStackTrace();
        }
        System.out.print("hello, ");
        System.out.write(buf, 0, n);

大家可以手动单步调试一下这个程序。看看如果输入中文,buf中的内容会是怎么样的。例如,我输入“海纳”,会读入7个字节,分别是

-26, -75, -73, -25, -70, -77, 10

前三个字节是一组,通过UTF-8(我们会在后续的课程中陆续介绍编码的知识)的解码,可以得到前三个字节代表的十进制数是28023,这刚好就是中文字符“海”字的 unicode 码。可见,直接的字节操作对非ascii的字符会比较麻烦。例如,程序读入一个名字,想判断这个名字的姓氏是否为李,如果是字节的操作,我们就得先把读到的这些字节,解码到 unicode,或者反过来,把“李”编码为UTF-8再进行比较。这显然太麻烦了,编解码这么机械的工作,干嘛不让机器替我们做呢?基于这个想法,Java引入了一个可以把字节流转成字符流的适配器——InputStreamReader

适配器模式

今天我们会介绍两种设计模式。在面向对象的程序设计中,我们经常会反复地遇到相同的问题,于是有人就做了抽象,把这些可能反复出现的场景提取出来,用一种通用的方法去解决它。我们把这种通用的方法叫做设计模式。

例如,我们上面的问题。需求是直接处理字符,但是,输入进来的却是编码的字节。我们希望有这么一个类,能自动解码并向我们提供字符读写的接口。这个类打通了字节处理与字符处理之间的堑沟。这个类就叫做适配器类。下面是它的类图

通过这个图,可以看到,我们期望的接口是Target类型的,这个类型定义了request这个方法。但是我们只有一个Adaptee的对象,它只能提供specificRequest,所以我们就自己做了一个适配器类Adaptor,这个类中有一个成员变量是Adaptee类型的。使用Adaptee提供的方法实现Target接口,这就是适配器做的事情。

回到我们的具体问题,现在已经有了字节码处理的 InputStream,我们的目标接口是可以处理字符的Reader,所以我们就需要一个可以把字节码转成字符的 InputStreamReader。这就是适配器的能力啊。

    public static void main(String args[]) {
        char[] cbuf = new char[256];
        System.out.println("hey, may I have your name, please? ");
        int n = 0;
            Reader r = new InputStreamReader(System.in);
        try {
            n = r.read(cbuf);
        } catch (IOException e) {
            e.printStackTrace();
        }
        System.out.println("hello, Mr. " + cbuf[0]);
    }

可以看到,我们终于可以通过 char 类型的数组直接拿到解码好的汉字了。这个程序里,字节InputStream 到字符 Reader 的适配器类 InputStreamReader 居功至伟。

我们把适配器类的分析拿过来,分析一下 java io 这个 package 里的类都是些什么。

已经有了操作文件的 File,我们的目标接口是可以处理字节的 InputStream,所以我们就需要一个可以把文件操作变成流操作的 FileInputStream。

已经有了操作网络接口的 Socket,我们的目标接口是可以处理字节的 InputStream,所以我们就需要一个可以把网络操作变成流操作的 SocketInputStream。

已经有了操作字符串的String,我们的目标接口是可以处理字符的 Reader,所以我们就需要一个可以把字符串操作变成字符操作的 StringReader。

…………

这个可以写很长。如果有读者曾经读过的我一个关于如何学习Java的文章,应该还记得,我曾经说过,IO的类是不用去死记硬背的,学完了几个常见的设计模式,有些类名,自己猜都能猜出来。呐,这就是我的课程里介绍的第一个设计模式:适配器模式。是不是感觉到 java.io 这个package瞬间就毫无秘密可言了呢?

记住 InputStreamReader / OutputStreamWriter 是连通字节与字符的桥梁。XxxReader(InputStream / OutputStream / Writer) 则是连通某一类操作与输入输出流的桥梁。那么 java.io 就掌握了大部分了。

装饰者模式

java io 这个包里有一个类,比较特别,这就是BufferedReader。我们从JDK的源码里,找到它的实现:

public class BufferedReader extends Reader {
    public BufferedReader(Reader in, int sz) {
        super(in);
        if (sz <= 0)
            throw new IllegalArgumentException("Buffer size <= 0");
        this.in = in;
        cb = new char[sz];
        nextChar = nChars = 0;
    }
}

可以看到,BufferedReader本身就是一个Reader,因为它继承自Reader,同时,还有一个名为 in 的成员变量,也是Reader类型的,然后还开辟了一个数组。实际上,这个数组是为了做缓存的,我们可以一次从 in 这个成员对象中读取多个字符存入 cb 中。当真正调用BufferedReader 的 read 方法的时候,就直接从 cb 中读取了,提高了读取的性能。也就是说BufferedReader所提供的 read 方法经过了 cb 这个缓存的加速,其性能会高于直接从 in 这个对象去读取。这是一种增强普通的 Reader 对象的技术。

注意一点,BufferedReader在对一个Reader的对象做增强的时候,只要求了对象是Reader类型的,并没有要求对象是具体哪个类型。这个in对象可以是SocketReader,也可以是FileReader。

这种本身是一种类型,保持类型接口不变,又能对该类型的其他子类进行加强的能力,就叫装饰者。就好像,我们在一个对象上面增加一种装饰,使得它更好看了,或者功能更强大了。看完了代码再看类图就比较清楚了:

可见,decorator模式,是一种可以为对象增加额外能力的设计模式。

BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

我们的完整版。从此以后,就可以直接使用 br 了。即可以使用 Reader 接口规定的read方法,也可以使用 BufferedReader 自己增强的 readLine方法,一次读入一行字符串。

看,小小的一行代码,包含了适配器和装饰器。Java 的 IO的全部知识都在这里了。从设计模式的角度去学习输入输出,就像庖丁手里的那把刀,真是 “恢恢乎其于游刃必有余地矣”。

好了。今天的讲解就到了这里了。

作业:

1. 查看 java.io 这个 package 的所有类,看看哪些是适配器,哪些是装饰器。

2. 自己实现一个适配器。把标准输入System.in 转成 TokenStream,其中TokenStream的定义如下:

public interface TokenStream {
    public Token getToken()  throws IOException;
    public void consumeToken();
}

具体用法如下:

TokenStream ts = new YourTokenStream(System.in); // 标准输入的适配器
while (ts.getToken().tokenType != Token.NONE) {ts.consumeToken();}

就是说,ts仍然是一个流,每次getToken都会返回当前游标所指向的那个token,每次consumeToken都会使得游标向后移动一位。

其中,用到的Token的定义如下:

public class Token {
    public enum TokenType {
        LPAR, RPAR,
        PLUS,
        MINUS,
        MULT,
        DIV,
        INT,
        NONE,
    }
    public TokenType tokenType;
    public Object value;

    public Token(TokenType tt, Object v) {
        this.tokenType = tt;
        this.value = v;
    }
}

最终的效果是,程序运行以后,我输入一个表达式: (5 + 8) * 13,可以从TokenStream中得到这样的 Token 序列:

{LPAR, "("} {INT, Integer(5)}, {PLUS, "+"}, {INT, INTEGER(8)}, {RPAR, ")"}, 
{MULT, "*"}, {INT, INTEGER(13)}

3. 其实,作业2是就是一个词法分析器哦~~自己动手写一个编译器的第一步。如果你完成了,请自己吃顿好的吧,奖励一下自己。

上一节:构建工具的进化

下一节:数据结构(一):栈

回目录:课程目录

编辑于 2019-10-26

文章被以下专栏收录