天天看點

Java面試必知必會 —— 全面解讀 Java IO (裝飾器模式篇)

作者:架構師之道

什麼是裝飾器模式

裝飾器模式通過組合替代繼承的方式在不改變原始類的情況下添加增強功能,主要解決繼承關系過于複雜的問題(Java IO 就屬于這種複雜情況)。

剛上來,我們先知道裝飾器是幹啥的,解決啥問題就行,具體的是如何做的,我們邊分析邊說。

Java IO 龐大的類庫

Java IO 的類庫十分龐大,有 40 多個類,負責 IO 資料的讀取和寫入。我們可以從以下角度将其劃分為四類,具體如下:

(抽象基類) 位元組流 字元流
輸入流 InputStream Reader
輸出流 OutputStream Writer

針對不同的讀取和寫入場景,Java IO 又在四個父類基礎上,擴充了很多子類。具體如下(隻列舉了一些常用的類):

Java面試必知必會 —— 全面解讀 Java IO (裝飾器模式篇)

Java IO 流的嵌套用法

還記得我們在 Java IO 基礎篇中流的使用案例嗎?若要使用緩存位元組輸入流,我們需要在 BufferedInputStream 的構造函數中傳遞一個 FileInputStream 對象來使用(這就是,使用 BufferedInputStream 增強 FileInputStream 的功能)。具體如下:

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

或許,你可能想為什麼 Java IO 不設計一個繼承 FileInputStream 并且支援緩存的 BufferedFileInputStream 類呢?

如果是這樣的話,我們豈不是可以直接建立一個 BufferedFileInputStream 類對象,支援緩存并且可以打開檔案讀取資料,這樣多省事簡單啊。

java複制代碼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 基本資料類型來讀取資料。

java複制代碼try (DataInputStream dis = new DataInputStream(new FileInputStream("test.txt"))) {
    int data = dis.readInt();
}
           

如果我們繼續按照繼承的方式來實作的話,那我們就需要派生出 DataFileInputStream、DataPipedInputStream 等類。

如果我們還需要既支援緩存、又支援按照基本資料類型讀取的類,那就要再繼續派生出 BufferedDataFileInputStream、BufferedDataPipedInputStream 等超多的類。

現在隻是附加了兩個增強功能,如果要添加更多增強功能,那就會導緻類數量爆炸,類的繼承結構将變得無比複雜,代碼既不好拓展,也不好維護。

那有沒有什麼辦法可以解決這個問題呢?當然有,我們可以使用組合(composition)和委托(delegation)達到繼承行為的效果。這種方案符合設計原則:多用組合,少用繼承。

基于繼承的設計方案,所有的子類都會繼承到相同的行為。而使用組合和委托,我們可以動态地組合對象,可以寫新的代碼添加新的功能,而無需修改現有代碼,引進 bug 的機會将大幅減少。這也符合另一個設計原則:開閉原則,類應該對擴充開放,對修改關閉。

基于裝飾器模式的設計方案

裝飾器模式的标準類圖

由于使用繼承實作的結構過于複雜,Java IO 采用了基于裝飾器模式的設計方案。我們先來看看裝飾器模式的标準類圖是什麼樣子的。

Java面試必知必會 —— 全面解讀 Java IO (裝飾器模式篇)

從類圖角度分析 Java IO 是如何使用裝飾者模式的

首先我們先從類圖的角度來看看 Java IO 是如何使用裝飾者模式的。

Java面試必知必會 —— 全面解讀 Java IO (裝飾器模式篇)

從源碼角度分析 Java IO 是如何使用裝飾者模式的

我們再從源碼的角度去檢視 Java IO 是如何使用裝飾者模式的。

InputStream(抽象元件)

下面是簡化後的 InputStream 源碼,它是一個抽象類,作為一個抽象元件。我們具體看 read() 方法。

java複制代碼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 的實作邏輯不同,是新的行為。

java複制代碼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 沒有對 InputStream 的 read() 進行增強,但是還是将其重新實作了一遍,簡單地包裹了對 InputStream 對象的函數調用,委托給傳遞進來的 InputStream 對象來完成。

請務必檢視代碼中的關鍵注釋!

java複制代碼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() 的功能,添加了緩存功能。請務必檢視代碼中的關鍵注釋!

java複制代碼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,它添加了一種在讀取輸入流時将資料“推回”流中的功能,進而可以重新讀取該資料。請務必檢視代碼中的關鍵注釋!

java複制代碼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 抽象出來一個裝飾者父類 FilterInputStream,InputStream 的所有具體的裝飾器類(BufferedInputStream、DataInputStream、PushbackInputStream)都繼承自這個裝飾器父類。具體的裝飾器類隻需要實作它需要增強的方法就可以了,其他方法都繼承裝飾器父類的預設實作。

裝飾器模式的代碼結構

我們将上述的内容整理出來一個代碼結構,具體如下所示:

java複制代碼// 抽象類也可以替換成接口
// 抽象元件
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() 方法,一個針對位元組,一個針對位元組數組,把每個大寫字元的位元組轉成小寫。

java複制代碼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;
    }
}
           

測試一下

java複制代碼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 是如何使用裝飾器模式的。

希望以上内容對你有幫助,一起加油!

作者:Mr_Persimmon

連結:https://juejin.cn/post/7234450421888974905

繼續閱讀