×

关于 Java IO(一):装饰模式

96
Happioo
2018.04.09 15:15* 字数 3166

Java 的 IO 系统采用了装饰器设计模式。其 IO 分为面向字节和面向字符两种,面向字节以字节为输入输出单位,面向字符以字符为输入输出单位。此外,在每部分中,又分为输入和输出两部分,相互对应,如InputStream类型和OutputStream类型。再往下分,又分为数据源类型和装饰器类型。数据源类型表示的是数据的来源和去处,而装饰器类型可以给输入输出赋予额外的功能。

Java IO的结构

在使用中,为了得到我们需要的输入输出功能,我们常常需要将一个数据源对象和多个装饰器对象组合起来。例如,我们需要从本地文件中以缓冲的方式按字节读入数据的话,就需要将一个FileInputStream对象和一个BufferedInputStream对象组合起来,其中 FileInputStream 对象负责从文件中按字节为单位读取数据,而 BufferedInputStream 对象负责对读取数据进行缓冲。

如果不明白装饰模式的话,Java IO 会变的难以理解。而如果不清楚 Java IO 的结构的话,又会觉得它难以使用。这篇博客结合装饰模式介绍了 Java IO 的结构,以及部分 IO 类的实现。这其实是我的学习笔记,如有不足,欢迎指出。


一、输入源

我们以输入为例,讲解 Java IO 的结构。输入的基本功能是将数据从某个输入源中读取出来。这个输入源可能是文件,也有可能是一个 ByteArray 对象,也有可能是一个 String 对象。数据源不同,读入的方式也不同。因此,Java 的开发者为每种输入源编写了相应的输入类,有从文件中读入数据的 FileInputStream,有从 ByteArray 对象中读入数据的 ByteArrayInputStream,……。为了统一接口,减少重复代码的编写,Java 的设计者从这些输入类中,抽取出了相同的部分,编写了抽象输入类 InputStream,作为所有输入类的基类。到目前为止,类图可以整理如下,为了方便叙述,省略了一些方法和成员变量。

输入源的结构

其中,InputStream 是一个抽象类,它是所有输入源的父类。它规定了输入源的接口,其中,read() 为从输入源中读入一个字节,并以返回值的形式返回。而 read(byte[] b) 为从输入源中读入一块数据到 byte[] b 中,其返回值为实际读入的字节数。而 read(byte[] b, int off, int len) 则为从输入源读入 len 个字节,填充到 byte[] bb[off] 及之后的位置上。

由于输入源的读入操作因输入源而异,因此,InputStream 中的 read() 方法是抽象的,由具体的输入源子类实现。

InputStream 中,read(byte[] b)read(byte[] b, int off, int len) 都是调用 read() 来实现的,即不断地使用 read() 来一个个地读入字节,并放到 byte[] b 的合适位置上。但这样读取,效率其实并不高。以搬砖为例,我们从 A 处搬 10 块砖给 B 处砌墙的老师傅。以 InputStream 的逻辑来搬运的话,我们需要从 A 处拿起一块砖,跑到 B 处,把砖给老师傅,跑回 B 处,再拿起一块……。多跑了好多趟,浪费了好多时间,力气大的话,完全可以拿起 10 块砖,一次性搬完。所以,在其大多数子类中,都重写了这些方法。

由于读取文件需要调用操作系统的系统调用,需要用 C/C++ 来完成,所以,在 FileInputStream 中,有两个 native 方法,read0()readBytes(byte[] b, int off, int len),分别用来调用系统调用读取文件中的 1 个字节和调用系统调用读取文件中的 1 堆字节。其他的读取方法都是通过调用这两个方法来实现的。

二、装饰器

有了输入源之后,我们已经可以完成各种读入数据的操作了。我们可以从数据源中读取一个字节,或者一堆字节。但是,出于性能以及其他方面的考虑,我们通常还会给输入操作添加一些功能,如缓冲。

1. 缓冲

之前讲过一个搬砖的例子,我们要从 A 处搬 10 块砖给 B 处的老师傅,考虑到老师傅今天砌墙任务繁重,之后很可能会再让我们去给他搬砖,于是我们不如一次性多给他搬几块过去放在 B 处,他再要砖我们直接从 B 处拿给他就好了,就不用再跑去 A 处搬砖过来了。这样就节省了许多传输的时间。

缓冲就是这么个道理。我们通常会给输入和输出都设立一个缓冲区。考虑到之后很可能会再次读取数据,在读入数据时,除了我们需要的数据之外,还会多读一些数据进来,放到缓冲区里。每次读入数据之前,都会先看看缓冲区里有没有我们要的数据,如果有的话就从缓冲区中读入,没有的话再去数据源里读取。而在输出数据时,会先把数据输出到缓冲区里去,当缓冲区满了,再将缓冲区里的数据全部输出到目的地里。

注意:缓冲区的读写还要考虑数据的一致性问题,这里没有过多的阐述。

2. 装饰器类

就像缓冲一样,我们通常会给输入输出加上一些额外的功能。于是问题来了,我们怎么才能让每种输入源都具备这些功能呢?最简单的,就是为每一种输入源的每种额外功能都写一个类,就像下面这样(为了让图小一点,省略了其他的输入源)。

不使用装饰器模式时的类结构

这样的设计会带来许多问题。

  • 首先,类太多了。在不考虑功能组合的情况下,如果有 m 个输入源,要实现 n 个功能,那就需要写 m 乘 n 个类,考虑功能组合的话,还要更多。
  • 其次,重复代码太多。其实同一个功能的代码都差不多,但要给每个输入源都写一遍。写的时候麻烦,到时候要改这个功能的代码,还得一个个改过去,不利于维护。

为了解决上面的问题,Java 的设计人员将各个功能拎了出来,给每个功能单独写了功能类,如通过 BufferedInputStream 类来为输入源提供缓冲功能,通过 DataInputStream 类来为输入源提供基本类型数据的读入功能。请注意,此时,功能类仅仅提供了功能,它本身并不能从输入源中读取数据,所以在功能类内部都会有一个数据源类的成员变量,从数据源中读取数据的操作都是通过这个成员变量来完成的。就像下面这样:

class Func1Decorator extends InputStream {
    private InputStream in;
    
    Func1Decorator(InputStream in){
        this.in = in;
    }
    
    public int read() {
        ...
        a = in.read();
        ...
    }
    ...
}

知识点:其实从这里可以看出,组合比继承要更灵活,因为组合可以和多态结合。

在功能类初始化时,就从外界传入了输入源对象,其后,从数据源读取数据的操作都由这个对象负责,而功能类仅负责对读入的数据进行处理来完成其功能。

注意到,这里的功能类还继承了输入源类 InputStream。一方面,这是因为从外界看来,功能类确实是一个 InputStream,它实现了 InputStream 中所有的接口。它的语意是一个带有 Func1 功能的 InputStream。另一方面,这也方便了功能的组合,当功能类同时也是 InputStream 时,要组合两个功能到一起时,只需要按一定的顺序把一个功能类的对象看作输入源对象传入进去即可。如:

DataInputStream in = new DataInputStream(
                        new BufferedInputStream(new FileInputStream("filename")));

上面这段代码创建了一个能读取基本数据类型数据并带有缓冲的文件输入对象。因为功能类也是一个 InputStream,它可以被当作其他功能类的数据源类,其他的功能类会在它的 read 方法的基础上,继续拓展自己的功能。

其实,之前我们所说的功能类就是装饰器,用来给基础类扩展功能。而这种用组合语法利用多态为基础类扩展功能的模式就是装饰模式。

3. 装饰器模式的优点

  1. 装饰模式分离了装饰类和被装饰类的逻辑。装饰器类中保持了一个被装饰对象的引用,当装饰器类需要底层的功能时,只需要通过这个引用调用对应方法即可,并不需要了解其具体逻辑。这对代码的维护有很大的帮助。

  2. 装饰模式可以减少类的数量。在前面我们已经看到了,用纯继承语法来扩展功能需要为每种基础类和功能的各种组合编写类,类的数量会非常地多。而通过装饰器模式,我们只需要写几个装饰器类就可以了。装饰器类中保持的被装饰对象的引用,会发挥其多态性,我们传入什么基础类对象,就执行对应的方法。这使得一个装饰器类可以和几乎所有基础类(及其子类,从语义上来说,子类是特殊的父类)结合产生相应的扩展类。

  3. 装饰模式的扩展性很好。当要为基础类扩展新的功能时,用纯继承语法需要为每种基础类,为另外的各种功能组合编写类。但使用装饰器模式的话,只需要编写一个装饰器类即可。

装饰模式利用了组合语法,在复用代码时,组合语法与继承语法相比有一个明显的优点,就是可以利用多态,从而根据组合对象的不同能够产生不同的语义

三、结构

装饰模式的通用类图如下:

装饰器模式的通用类图

在我们之前的叙述中,是没有中间这个 Decorator 抽象类的。它是所有装饰器类的父类,它一方面可以使类的结构更加清晰,另一方面这个抽象类可以减少各个子类中重复逻辑的书写。当然,我们刚才所叙述的也是装饰模式,只不过没有了 Decorator 抽象类,所有的装饰器类都是直接继承自 Component 的。这是一种简化的装饰模式。当装饰器数量比较少时,可以省略装饰器基类。另外在确定只有一种 Component 时,可以不写 Component 基类,用那一个 ConcreteComponent 来代替 Component 基类。

下面是 Java IO 的类图,只画了字节流的输入部分,其他部分相似。另外,因为页面的大小是有限的,而且一些类在类结构中的位置是相似的,所以省略了一些类。

Java IO 的结构

其中,FilterInputStream 就是装饰模式中的 Decorator 基类。继承自它的都是装饰器类,它们为输入扩展了功能。

四、参考资料

  • JDK文档
  • 《Thinking in Java》
  • 《设计模式之禅》
Java学习日常
Web note ad 1