likes
comments
collection
share

Java面试必知必会 —— 全面解读 Java IO (装饰器模式篇)

作者站长头像
站长
· 阅读数 16

在上一篇,我们知道了 Java IO 的基本使用,从本篇开始,我们一起来探究设计模式是如何在 Java IO 中应用的。首先,我们先要学习的是装饰器模式。

我相信,友好的讨论交流会让彼此快速进步!文章难免有疏漏之处,十分欢迎大家在评论区中批评指正。

什么是装饰器模式

装饰器模式通过组合替代继承的方式在不改变原始类的情况下添加增强功能,主要解决继承关系过于复杂的问题(Java IO 就属于这种复杂情况)。

刚上来,我们先知道装饰器是干啥的,解决啥问题就行,具体的是如何做的,我们边分析边说。

Java IO 庞大的类库

Java IO 的类库十分庞大,有 40 多个类,负责 IO 数据的读取和写入。我们可以从以下角度将其划分为四类,具体如下:

(抽象基类)字节流字符流
输入流InputStreamReader
输出流OutputStreamWriter

针对不同的读取和写入场景,Java IO 又在四个父类基础上,扩展了很多子类。具体如下(只列举了一些常用的类):

Java面试必知必会 —— 全面解读 Java IO (装饰器模式篇)

Java IO 流的嵌套用法

还记得我们在 Java IO 基础篇中流的使用案例吗?若要使用缓存字节输入流,我们需要在 BufferedInputStream 的构造函数中传递一个 FileInputStream 对象来使用(这就是,使用 BufferedInputStream 增强 FileInputStream 的功能)。具体如下:

try (BufferedInputStream bis = 
     	new BufferedInputStream(new FileInputStream("test.txt"))) {
    byte[] b = new byte[128];
    while (bis.read(b) != -1) {
        // ...
    }
}

或许,你可能想为什么 Java IO 不设计一个继承 FileInputStream 并且支持缓存的 BufferedFileInputStream 类呢?

如果是这样的话,我们岂不是可以直接创建一个 BufferedFileInputStream 类对象,支持缓存并且可以打开文件读取数据,这样多省事简单啊。

try (InputStream in = new BufferedFileInputStream("test.txt")) {
   byte[] b = new byte[128];
   while (bis.read(b) != -1) {
        // ...
    }
}

我们的这种思路就是基于继承的设计方案了。

基于继承的设计方案

如果说 InputStream 只有一个子类 FileInputStream 的话,那么我们在 InputStream 基础上,再设计一个孙子类 BufferedFileInputStream,也算是可以,毕竟继承结构比较简单,能够接受。

然而,事实上,我们在上面的常用类图中也看到了,继承 InputStream 的子类非常多,那么我们就需要给每一个 InputStream 子类都派生一个支持缓存读取的子类,这数量太庞大了!

而且,支持缓存只是拓展功能之一,我们还要对其他功能进行增强,比如 DataInputStream 类,它支持按照所有 Java 基本数据类型来读取数据。

try (DataInputStream dis = new DataInputStream(new FileInputStream("test.txt"))) {
    int data = dis.readInt();
}

如果我们继续按照继承的方式来实现的话,那我们就需要派生出 DataFileInputStreamDataPipedInputStream 等类。

如果我们还需要既支持缓存、又支持按照基本数据类型读取的类,那就要再继续派生出 BufferedDataFileInputStreamBufferedDataPipedInputStream 等超多的类。

现在只是附加了两个增强功能,如果要添加更多增强功能,那就会导致类数量爆炸,类的继承结构将变得无比复杂,代码既不好拓展,也不好维护。

那有没有什么办法可以解决这个问题呢?当然有,我们可以使用组合(composition)和委托(delegation)达到继承行为的效果。这种方案符合设计原则:多用组合,少用继承

基于继承的设计方案,所有的子类都会继承到相同的行为。而使用组合和委托,我们可以动态地组合对象,可以写新的代码添加新的功能,而无需修改现有代码,引进 bug 的机会将大幅减少。这也符合另一个设计原则:开闭原则,类应该对扩展开放,对修改关闭。

基于装饰器模式的设计方案

装饰器模式的标准类图

由于使用继承实现的结构过于复杂,Java IO 采用了基于装饰器模式的设计方案。我们先来看看装饰器模式的标准类图是什么样子的。 Java面试必知必会 —— 全面解读 Java IO (装饰器模式篇)

从类图角度分析 Java IO 是如何使用装饰者模式的

首先我们先从类图的角度来看看 Java IO 是如何使用装饰者模式的。

Java面试必知必会 —— 全面解读 Java IO (装饰器模式篇)

从源码角度分析 Java IO 是如何使用装饰者模式的

我们再从源码的角度去查看 Java IO 是如何使用装饰者模式的。

InputStream(抽象组件)

下面是简化后的 InputStream 源码,它是一个抽象类,作为一个抽象组件。我们具体看 read() 方法。

public abstract class InputStream {
	// ...
    public abstract int read() throws IOException;
    public int read(byte b[]) throws IOException {
        return read(b, 0, b.length);
    }
    public int read(byte b[], int off, int len) throws IOException {
        // 具体的实现逻辑
    }
    //...
}

FileInputStream (具体组件)

FileInputStream 继承自 InputStream,有公有的构造方法可以直接使用,也可以被装饰者包起来使用。 功能函数的实现逻辑与 InputStream 的实现逻辑不同,是新的行为。

public class FileInputStream extends InputStream {
    public FileInputStream(String name) throws FileNotFoundException {
        this(name != null ? new File(name) : null);
    }
    public FileInputStream(File file) throws FileNotFoundException {
        // 代码略...
    }
    public FileInputStream(FileDescriptor fdObj) {
        // 代码略...
    }
    public int read() throws IOException {
        // 新行为,没有调用抽象组件的 read() 方法
    }
    public int read(byte b[]) throws IOException {
        // 新行为,没有调用抽象组件的 read(byte b[]) 方法
    }
    public int read(byte b[], int off, int len) throws IOException {
        // 新行为,没有调用抽象组件的 read(byte b[], int off, int len) 方法
    }
}

FilterInputStream(抽象的装饰者)

下面是 FilterInputStream 源码,它继承了 InputStream,作为一个装饰者,它保存了抽象组件的引用。构造函数声明为 protected,表明用户不能直接构造该类的对象,只能构造该类的子类对象。

FilterInputStream 没有对 InputStreamread() 进行增强,但是还是将其重新实现了一遍,简单地包裹了对 InputStream 对象的函数调用,委托给传递进来的 InputStream 对象来完成。

请务必查看代码中的关键注释!

public class FilterInputStream extends InputStream {
    
    protected volatile InputStream in; // 保存抽象组件组件的引用
    // 构造函数声明为 protected
    // 表明用户不能直接构造该类的对象,只能构造该类的子类
    protected FilterInputStream(InputStream in) {
        this.in = in;
    }
    // 直接调用了抽象组件的 read() 方法
    public int read() throws IOException {
        return in.read(); // 委托给传递进来的 InputStream 对象来完成
    }
    public int read(byte b[]) throws IOException {
        return read(b, 0, b.length);
    }
    // 直接调用了抽象组件的 read(byte b[], int off, int len) 方法
    public int read(byte b[], int off, int len) throws IOException {
        return in.read(b, off, len); 
    }
    
}

BufferedInputStream(具体的装饰者)

BufferedInputStream 继承了 FilterInputStream,作为一个具体的装饰者,它增强了 read() 的功能,添加了缓存功能。请务必查看代码中的关键注释!

public class BufferedInputStream extends FilterInputStream {
    public BufferedInputStream(InputStream in) {
        this(in, DEFAULT_BUFFER_SIZE);
    }
    public BufferedInputStream(InputStream in, int size) {
        super(in); // 记录装饰者所包着的抽象组件
        if (size <= 0) {
            throw new IllegalArgumentException("Buffer size <= 0");
        }
        buf = new byte[size];
    }
    // 在旧方法的基础上实现了缓存功能
    public synchronized int read() throws IOException {
        if (pos >= count) {
            fill();
            if (pos >= count)
                return -1;
        }
        return getBufIfOpen()[pos++] & 0xff;
    }
    // 在旧方法的基础上实现了缓存功能
    public synchronized int read(byte b[], int off, int len)
        throws IOException
    {
        // ... 具体逻辑省略
    }
}

PushbackInputStream(具体的装饰者)

BufferedInputStream 一样,继承了 FilterInputStream,它添加了一种在读取输入流时将数据“推回”流中的功能,从而可以重新读取该数据。请务必查看代码中的关键注释!

public class PushbackInputStream extends FilterInputStream {
	public PushbackInputStream(InputStream in) {
        this(in, 1);
    }
    public PushbackInputStream(InputStream in, int size) {
        super(in); // 记录装饰者所包着的抽象组件
        if (size <= 0) {
            throw new IllegalArgumentException("size <= 0");
        }
        this.buf = new byte[size];
        this.pos = size;
    }
    // 功能增强
    public int read() throws IOException {
        ensureOpen();
        if (pos < buf.length) {
            return buf[pos++] & 0xff;
        }
        return super.read(); // 旧的方法
    }
    // 功能增强
    public int read(byte[] b, int off, int len) throws IOException {
        // 省略了部分代码,这些代码用于增强...
        if (len > 0) {
            len = super.read(b, off, len); // 旧的方法
            if (len == -1) {
                return avail == 0 ? -1 : avail;
            }
            return avail + len;
        }
        return avail;
    }

}

从上面的代码可以知道,为了避免代码重复,Java IO 抽象出来一个装饰者父类 FilterInputStreamInputStream 的所有具体的装饰器类(BufferedInputStreamDataInputStreamPushbackInputStream)都继承自这个装饰器父类。具体的装饰器类只需要实现它需要增强的方法就可以了,其他方法都继承装饰器父类的默认实现。

装饰器模式的代码结构

我们将上述的内容整理出来一个代码结构,具体如下所示:

// 抽象类也可以替换成接口
// 抽象组件
public abstract class Component {
    void f();
}
// 具体组件
public class ConcreteComponent {
    public ConcreteComponent() {}
    public void f() {
        // 新的实现逻辑
    }
}
// 抽象装饰器(具体装饰器的父类)
public class Decorator extends Component {
    protected Component c; // 组合
    // 无法构造自己的对象,只能构造自己的子类对象
    protected Decorator(Component c) {
        this.c = c;
    }
    public void f() {
        c.f(); // 委托
    }
}
// 具体装饰器
public class ConcreteDecoratorA extends Decorator {
    public ConcreteDecoratorA(Component c) {
        super(c); // 通过构造器组合抽象组件
    }
    public void f() {
        // 功能增强代码
        super.f(); // 委托
        // 功能增强代码
    }
}

疑问解答时间

为什么装饰器模式还是用到继承了呢,不是说要利用组合取代继承吗?

在之前的基于继承的设计方案中,我们谈到使用继承的方案会导致类数量爆炸,类的继承结构将变得无比复杂,代码既不好拓展,也不好维护。

在装饰器模式中,使用继承的主要目的是让装饰器和抽象组件是一样的类型,也就是要有共同的超类,也就是使用继承达到「类型匹配」,而不是利用继承获得「行为」。

当我们将装饰器与组件组合时,就是在加入新的行为。这种新的行为并不是继承自超类,而是由组合对象得来的(在代码结构中已给出了明确注释)。

另外,如果是基于继承的设计方案,那么所有的类的行为只能在编译时静态决定,也就是说,行为不是来自于超类,就是子类覆盖后的版本。如果需要新的行为,必须修改现有的代码,这不符合开放关闭原则。

而在装饰器模式中,我们利用组合,可以把装饰器混合使用,而且,可以在任何时候,实现新的装饰器增加新的行为。

为什么 Component 设计成一个抽象类,而不是一个接口呢?

通常装饰器模式是采用抽象类,在 Java 中也可以使用接口。文中给出的代码结构是从源码中提取而来的。

总结一下

装饰器模式主要用于解决继承关系复杂的问题,通过组合和委托来替代继承。

装饰器模式的主要作用就是给组件添加增强功能,可以在组件功能代码的前面、后面添加自己的功能代码,甚至可以将组件的功能代码完全替换掉。

装饰器和具体的组件都继承相同的抽象类或接口(组件),所以可以使用无数个装饰器包装一个具体的组件。

装饰器模式也是有问题的,它会导致设计中出现许多小类,如果过度使用,会让程序变得很复杂。

练习题

现在我们已经知道了装饰器模式,也看过 Java IO 的类图和源码,那么接下来我们来编写一个自己的输入装饰器吧。

需求

编写一个装饰器,把输入流内的所有大写字符转成小写。比如,"HELLO WORLD!",装饰器会把它转换成 "hello world!"

代码实现

首先,我们得扩展 FilterInputStream,这是所有 InputStream 的抽象装饰器。

我们必须实现两个 read() 方法,一个针对字节,一个针对字节数组,把每个大写字符的字节转成小写。

public class LowerCaseInputStream extends FilterInputStream {
    public LowerCaseInputStream(InputStream in) {
        super(in); // 保存 FilterInputStream 的引用
    }
    // 处理字节
    public int read() throws IOException {
        int c = super.read();
        return (c == -1 ? c : Character.toLowerCase((char) c));
    }
    // 处理字节数组
    public int read(byte[] b, int off, int len) throws IOException {
        int result = super.read(b, off, len);
        for (int i = off; i < off + result; i++) {
            b[i] = (byte) Character.toLowerCase((char) b[i]);
        }
        return result;
    }
}

测试一下

public class InputTest {
    public static void main(String[] args) {
        int c;
        try (InputStream in =
                     new LowerCaseInputStream(
                             new BufferedInputStream(
                                     new FileInputStream("test.txt")))) {
            while ((c = in.read()) != -1) {
                System.out.print((char) c);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

test.txt 文件中保存着 "HELLO WORLD!"

运行结果如下:

hello world!

好啦,以上就是本篇文章的全部内容了。我们讲解了什么是装饰器模式,装饰器模式的标准类图、代码结构,知道了 Java IO 是如何使用装饰器模式的。

希望以上内容对你有帮助,一起加油!

参考资料

  1. Head First 设计模式
  2. 设计模式之美
  3. Patterns in Java APIs