Java基礎 Java-IO流 深入淺出

iX發表於2021-04-16

建議閱讀

重要性由高到低

  1. Java基礎-3 吃透Java IO:位元組流、字元流、緩衝流

  2. 廖雪峰Java IO

  3. Java-IO流

  4. JAVA設計模式初探之裝飾者模式

  5. 為什麼我覺得 Java 的 IO 很複雜?

本文簡要的這些文章做了一些總結

基本概念

IO,即inout,也就是輸入和輸出,指應用程式和外部裝置之間的資料傳遞,常見的外部裝置包括檔案(file)、管道 (pipe)、網路連線 (network)。

流(Stream),是一個抽象的概念,是指一連串的資料(字元或位元組),是以先進先出的方式傳送資訊的通道。

流的特性:

  • 先進先出:最先寫入輸出流的資料最先被輸入流讀取到。
  • 順序存取:可以一個接一個地往流中寫入一串位元組,讀出時也將按寫入順序讀取一串位元組,不能隨機訪問中間的資料。(RandomAccessFile除外)
  • 只讀或只寫:每個流只能是輸入流或輸出流的一種,不能同時具備兩個功能,輸入流只能進行讀操作,對輸出流只能進行寫操作。在一個資料傳輸通道中,如果既要寫入資料,又要讀取資料,則要分別提供兩個流。

IO流主要的分類方式有以下3種:

  1. 按資料流的方向:輸入流、輸出流
  2. 按處理資料單位:位元組流、字元流
  3. 按功能:節點流、處理流

輸入流和輸出流

輸入與輸出是相對於應用程式而言的,比如檔案讀寫,讀取檔案是輸入流,寫檔案是輸出流,這點很容易搞反。

位元組流和字元流

位元組流和字元流的用法幾乎完成全一樣,區別在於位元組流和字元流所操作的資料單元不同,位元組流操作的單元是資料單元是8位的位元組,字元流操作的是資料單元為16位的字元。

為什麼要有字元流?

Java中字元是採用Unicode標準,Unicode 編碼中,一個英文為一個位元組,一箇中文為兩個位元組。

而在UTF-8編碼中,一箇中文字元是3個位元組。例如下面圖中,“雲深不知處”5箇中文對應的是15個位元組:-28-70-111-26-73-79-28-72-115-25-97-91-27-92-124

那麼問題來了,如果使用位元組流處理中文,如果一次讀寫一個字元對應的位元組數就不會有問題,一旦將一個字元對應的位元組分裂開來,就會出現亂碼了。為了更方便地處理中文這些字元,Java就推出了字元流。

位元組流和字元流的其他區別:

  1. 位元組流一般用來處理影像、視訊、音訊、PPT、Word等型別的檔案。字元流一般用於處理純文字型別的檔案,如TXT檔案等,但不能處理影像視訊等非文字檔案。用一句話說就是:位元組流可以處理一切檔案,而字元流只能處理純文字檔案。
  2. 位元組流本身沒有緩衝區,緩衝位元組流相對於位元組流,效率提升非常高。而字元流本身就帶有緩衝區,緩衝字元流相對於字元流效率提升就不是那麼大了。詳見文末效率對比。

節點流和處理流

節點流:直接運算元據讀寫的流類,比如FileInputStream

處理流:對一個已存在的流的連結和封裝,通過對資料進行處理為程式提供功能強大、靈活的讀寫功能,例如BufferedInputStream(緩衝位元組流)

處理流和節點流應用了Java的裝飾者設計模式。

下圖就很形象地描繪了節點流和處理流,處理流是對節點流的封裝,最終的資料處理還是由節點流完成的。

緩衝流 是一個非常重要的處理流。

我們知道,程式與磁碟的互動相對於記憶體運算是很慢的,容易成為程式的效能瓶頸。減少程式與磁碟的互動,是提升程式效率一種有效手段。緩衝流,就應用這種思路:普通流每次讀寫一個位元組,而緩衝流在記憶體中設定一個快取區,緩衝區先儲存足夠的待運算元據後,再與記憶體或磁碟進行互動。這樣,在總資料量不變的情況下,通過提高每次互動的資料量,減少了互動次數。

然而緩衝流的效率卻不一定高,在某些情形下,緩衝流的效率反而更低

IO流常用物件

File 物件

在計算機系統中,檔案是非常重要的儲存方式。Java的標準庫java.io提供了File物件來操作檔案和目錄。

構造File物件時,既可以傳入絕對路徑,也可以傳入相對路徑。絕對路徑是以根目錄開頭的完整路徑,例如:

File f = new File("C:\\Windows\\notepad.exe");

注意Windows平臺使用\作為路徑分隔符,在Java字串中需要用\\表示一個\。Linux平臺使用/作為路徑分隔符:

File f = new File("/usr/bin/javac");

傳入相對路徑時,相對路徑前面加上當前目錄就是絕對路徑:

// 假設當前目錄是C:\Docs
File f1 = new File("sub\\javac"); // 絕對路徑是C:\Docs\sub\javac
File f3 = new File(".\\sub\\javac"); // 絕對路徑是C:\Docs\sub\javac
File f3 = new File("..\\sub\\javac"); // 絕對路徑是C:\sub\javac

可以用.表示當前目錄,..表示上級目錄。

File物件有3種形式表示的路徑,一種是getPath(),返回構造方法傳入的路徑,一種是getAbsolutePath(),返回絕對路徑,一種是getCanonicalPath,它和絕對路徑類似,但是返回的是規範路徑。

public class Main {
    public static void main(String[] args) throws IOException {
        File f = new File("..");
        System.out.println(f.getPath());
        System.out.println(f.getAbsolutePath());
        System.out.println(f.getCanonicalPath());
    }
}

..
/app/..
/

絕對路徑可以表示成C:\Windows\System32\..\notepad.exe,而規範路徑就是把...轉換成標準的絕對路徑後的路徑:C:\Windows\notepad.exe

檔案和目錄

File物件既可以表示檔案,也可以表示目錄。特別要注意的是,構造一個File物件,即使傳入的檔案或目錄不存在,程式碼也不會出錯,因為構造一個File物件,並不會導致任何磁碟操作。只有當我們呼叫File物件的某些方法的時候,才真正進行磁碟操作。

例,呼叫isFile(),判斷該File物件是否是一個已存在的檔案,呼叫isDirectory(),判斷該File物件是否是一個已存在的目錄。

File物件獲取到一個檔案時,還可以進一步判斷檔案的許可權和大小:

  • boolean canRead():是否可讀;
  • boolean canWrite():是否可寫;
  • boolean canExecute():是否可執行;
  • long length():檔案位元組大小。

建立和刪除檔案

當File物件表示一個檔案時,可以通過createNewFile()建立一個新檔案,用delete()刪除該檔案:

File file = new File("/path/to/file");
if (file.createNewFile()) {
    // 檔案建立成功:
    // TODO:
    if (file.delete()) {
        // 刪除檔案成功:
    }
}

有些時候,程式需要讀寫一些臨時檔案,File物件提供了createTempFile()來建立一個臨時檔案,以及deleteOnExit()在JVM退出時自動刪除該檔案。

public class Main {
    public static void main(String[] args) throws IOException {
        File f = File.createTempFile("tmp-", ".txt"); // 提供臨時檔案的字首和字尾
        f.deleteOnExit(); // JVM退出時自動刪除
        System.out.println(f.isFile());
        System.out.println(f.getAbsolutePath());
    }
}

遍歷檔案和目錄

當File物件表示一個目錄時,可以使用list()listFiles()列出目錄下的檔案和子目錄名。listFiles()提供了一系列過載方法,可以過濾不想要的檔案和目錄:

public class Main {
    public static void main(String[] args) throws IOException {
        File f = new File("C:\\Windows");
        File[] fs1 = f.listFiles(); // 列出所有檔案和子目錄
        printFiles(fs1);
        File[] fs2 = f.listFiles(new FilenameFilter() { // 僅列出.exe檔案
            public boolean accept(File dir, String name) {
                return name.endsWith(".exe"); // 返回true表示接受該檔案
            }
        });
        printFiles(fs2);
    }

    static void printFiles(File[] files) {
        System.out.println("==========");
        if (files != null) {
            for (File f : files) {
                System.out.println(f);
            }
        }
        System.out.println("==========");
    }
}

和檔案操作類似,File物件如果表示一個目錄,可以通過以下方法建立和刪除目錄:

  • boolean mkdir():建立當前File物件表示的目錄;
  • boolean mkdirs():建立當前File物件表示的目錄,並在必要時將不存在的父目錄也建立出來;
  • boolean delete():刪除當前File物件表示的目錄,當前目錄必須為空才能刪除成功。

Path 物件

Java標準庫還提供了一個Path物件,它位於java.nio.file包。Path物件和File物件類似,但操作更加簡單:

public class Main {
    public static void main(String[] args) throws IOException {
        Path p1 = Paths.get(".", "project", "study"); // 構造一個Path物件
        System.out.println(p1);
        Path p2 = p1.toAbsolutePath(); // 轉換為絕對路徑
        System.out.println(p2);
        Path p3 = p2.normalize(); // 轉換為規範路徑
        System.out.println(p3);
        File f = p3.toFile(); // 轉換為File物件
        System.out.println(f);
        for (Path p : Paths.get("..").toAbsolutePath()) { // 可以直接遍歷Path
            System.out.println("  " + p);
        }
    }
}

./project/study
/app/./project/study
/app/project/study
/app/project/study
  app
  ..

練習

請利用File物件列出指定目錄下的所有子目錄和檔案,並按層次列印。

例如,輸出:

Documents/
  word/
    1.docx
    2.docx
    work/
      abc.doc
  ppt/
  other/
import java.io.*;
import java.nio.file.*;

public class fasta {
    public static void main(String[] args) throws IOException {
        File pwd = new File("./src");
        System.out.println(pwd);
        printFiles(pwd, 1);
    }

    public static void printFiles(File pwd, int depth) throws IOException {
        String[] fs = pwd.list();
        if (fs != null) {
            for (String f : fs) {
                for (int i = 0; i < depth; i++) {
                    System.out.print("  ");
                }
                System.out.println(f+'/');
                Path temp = Paths.get(pwd.toString(), f);
                printFiles(temp.toFile(), depth + 1);
            }
        }
    }
}

InputStream

InputStream就是Java標準庫提供的最基本的輸入流。它位於java.io這個包裡。java.io包提供了所有同步IO的功能。

要特別注意的一點是,InputStream並不是一個介面,而是一個抽象類,它是所有輸入流的超類。這個抽象類定義的一個最重要的方法就是int read(),簽名如下:

public abstract int read() throws IOException;

這個方法會讀取輸入流的下一個位元組,並返回位元組表示的int值(0~255)。如果已讀到末尾,返回-1表示不能繼續讀取了。

FileInputStream

FileInputStreamInputStream的一個子類。顧名思義,FileInputStream就是從檔案流中讀取資料。下面的程式碼演示瞭如何完整地讀取一個FileInputStream的所有位元組:

public void readFile() throws IOException {
    // 建立一個FileInputStream物件:
    InputStream input = new FileInputStream("src/readme.txt");
    for (;;) {
        int n = input.read(); // 反覆呼叫read()方法,直到返回-1
        if (n == -1) {
            break;
        }
        System.out.println(n); // 列印byte的值
    }
    input.close(); // 關閉流
}

InputStreamOutputStream都是通過close()方法來關閉流。關閉流就會釋放對應的底層資源。

我們還要注意到在讀取或寫入IO流的過程中,可能會發生錯誤,例如,檔案不存在導致無法讀取,沒有寫許可權導致寫入失敗,等等,這些底層錯誤由Java虛擬機器自動封裝成IOException異常並丟擲。因此,所有與IO操作相關的程式碼都必須正確處理IOException

仔細觀察上面的程式碼,會發現一個潛在的問題:如果讀取過程中發生了IO錯誤,InputStream就沒法正確地關閉,資源也就沒法及時釋放。

因此,我們需要用try ... finally來保證InputStream在無論是否發生IO錯誤的時候都能夠正確地關閉:

public void readFile() throws IOException {
    InputStream input = null;
    try {
        input = new FileInputStream("src/readme.txt");
        int n;
        while ((n = input.read()) != -1) { // 利用while同時讀取並判斷
            System.out.println(n);
        }
    } finally {
        if (input != null) { input.close(); }
    }
}

try ... finally來編寫上述程式碼會感覺比較複雜,更好的寫法是利用Java 7引入的新的try(resource)的語法,只需要編寫try語句,讓編譯器自動為我們關閉資源。推薦的寫法如下:

public void readFile() throws IOException {
    try (InputStream input = new FileInputStream("src/readme.txt")) {
        int n;
        while ((n = input.read()) != -1) {
            System.out.println(n);
        }
    } // 編譯器在此自動為我們寫入finally並呼叫close()
}

實際上,編譯器並不會特別地為InputStream加上自動關閉。編譯器只看try(resource = ...)中的物件是否實現了java.lang.AutoCloseable介面,如果實現了,就自動加上finally語句並呼叫close()方法。InputStreamOutputStream都實現了這個介面,因此,都可以用在try(resource)中。

緩衝

在讀取流的時候,一次讀取一個位元組並不是最高效的方法。很多流支援一次性讀取多個位元組到緩衝區,對於檔案和網路流來說,利用緩衝區一次性讀取多個位元組效率往往要高很多。InputStream提供了兩個過載方法來支援讀取多個位元組:

  • int read(byte[] b):讀取若干位元組並填充到byte[]陣列,返回讀取的位元組數
  • int read(byte[] b, int off, int len):指定byte[]陣列的偏移量和最大填充數

利用上述方法一次讀取多個位元組時,需要先定義一個byte[]陣列作為緩衝區,read()方法會盡可能多地讀取位元組到緩衝區, 但不會超過緩衝區的大小。read()方法的返回值不再是位元組的int值,而是返回實際讀取了多少個位元組。如果返回-1,表示沒有更多的資料了。

利用緩衝區一次讀取多個位元組的程式碼如下:

public void readFile() throws IOException {
    try (InputStream input = new FileInputStream("src/readme.txt")) {
        // 定義1000個位元組大小的緩衝區:
        byte[] buffer = new byte[1000];
        int n;
        while ((n = input.read(buffer)) != -1) { // 讀取到緩衝區
            System.out.println("read " + n + " bytes.");
        }
    }
}

阻塞

在呼叫InputStreamread()方法讀取資料時,我們說read()方法是阻塞(Blocking)的。它的意思是,對於下面的程式碼:

int n;
n = input.read(); // 必須等待read()方法返回才能執行下一行程式碼
int m = n;

執行到第二行程式碼時,必須等read()方法返回後才能繼續。因為讀取IO流相比執行普通程式碼,速度會慢很多,因此,無法確定read()方法呼叫到底要花費多長時間。

OutputStream

InputStream相反,OutputStream是Java標準庫提供的最基本的輸出流。

InputStream類似,OutputStream也是抽象類,它是所有輸出流的超類。這個抽象類定義的一個最重要的方法就是void write(int b),簽名如下:

public abstract void write(int b) throws IOException;

這個方法會寫入一個位元組到輸出流。要注意的是,雖然傳入的是int引數,但只會寫入一個位元組,即只寫入int最低8位表示位元組的部分(相當於b & 0xff)。

Flush

InputStream類似,OutputStream也提供了close()方法關閉輸出流,以便釋放系統資源。要特別注意:OutputStream還提供了一個flush()方法,它的目的是將緩衝區的內容真正輸出到目的地。

為什麼要有flush()?因為向磁碟、網路寫入資料的時候,出於效率的考慮,作業系統並不是輸出一個位元組就立刻寫入到檔案或者傳送到網路,而是把輸出的位元組先放到記憶體的一個緩衝區裡(本質上就是一個byte[]陣列),等到緩衝區寫滿了,再一次性寫入檔案或者網路。對於很多IO裝置來說,一次寫一個位元組和一次寫1000個位元組,花費的時間幾乎是完全一樣的,所以OutputStream有個flush()方法,能強制把緩衝區內容輸出。

通常情況下,我們不需要呼叫這個flush()方法,因為緩衝區寫滿了OutputStream會自動呼叫它,並且,在呼叫close()方法關閉OutputStream之前,也會自動呼叫flush()方法。

但是,在某些情況下,我們必須手動呼叫flush()方法。舉個例子:

小明正在開發一款線上聊天軟體,當使用者輸入一句話後,就通過OutputStreamwrite()方法寫入網路流。小明測試的時候發現,傳送方輸入後,接收方根本收不到任何資訊,怎麼肥四?

原因就在於寫入網路流是先寫入記憶體緩衝區,等緩衝區滿了才會一次性傳送到網路。如果緩衝區大小是4K,則傳送方要敲幾千個字元後,作業系統才會把緩衝區的內容傳送出去,這個時候,接收方會一次性收到大量訊息。

解決辦法就是每輸入一句話後,立刻呼叫flush(),不管當前緩衝區是否已滿,強迫作業系統把緩衝區的內容立刻傳送出去。

實際上,InputStream也有緩衝區。例如,從FileInputStream讀取一個位元組時,作業系統往往會一次性讀取若干位元組到緩衝區,並維護一個指標指向未讀的緩衝區。然後,每次我們呼叫int read()讀取下一個位元組時,可以直接返回緩衝區的下一個位元組,避免每次讀一個位元組都導致IO操作。當緩衝區全部讀完後繼續呼叫read(),則會觸發作業系統的下一次讀取並再次填滿緩衝區。

FileOutputStream

我們以FileOutputStream為例,演示如何將若干個位元組寫入檔案流:

public void writeFile() throws IOException {
    OutputStream output = new FileOutputStream("out/readme.txt");
    output.write(72); // H
    output.write(101); // e
    output.write(108); // l
    output.write(108); // l
    output.write(111); // o
    output.close();
}

每次寫入一個位元組非常麻煩,更常見的方法是一次性寫入若干位元組。這時,可以用OutputStream提供的過載方法void write(byte[])來實現:

public void writeFile() throws IOException {
    OutputStream output = new FileOutputStream("out/readme.txt");
    output.write("Hello".getBytes("UTF-8")); // Hello
    output.close();
}

InputStream一樣,上述程式碼沒有考慮到在發生異常的情況下如何正確地關閉資源。寫入過程也會經常發生IO錯誤,例如,磁碟已滿,無許可權寫入等等。我們需要用try(resource)來保證OutputStream在無論是否發生IO錯誤的時候都能夠正確地關閉:

public void writeFile() throws IOException {
    try (OutputStream output = new FileOutputStream("out/readme.txt")) {
        output.write("Hello".getBytes("UTF-8")); // Hello
    } // 編譯器在此自動為我們寫入finally並呼叫close()
}

阻塞

InputStream一樣,OutputStreamwrite()方法也是阻塞的。

同時操作多個AutoCloseable資源時,在try(resource) { ... }語句中可以同時寫出多個資源,用;隔開。例如,同時讀寫兩個檔案:

// 讀取input.txt,寫入output.txt:
try (InputStream input = new FileInputStream("input.txt");
     OutputStream output = new FileOutputStream("output.txt"))
{
    input.transferTo(output); // transferTo的作用是?
}

Reader

Reader是Java的IO庫提供的另一個輸入流介面。和InputStream的區別是,InputStream是一個位元組流,即以byte為單位讀取,而Reader是一個字元流,即以char為單位讀取:

InputStream Reader
位元組流,以byte為單位 字元流,以char為單位
讀取位元組(-1,0~255):int read() 讀取字元(-1,0~65535):int read()
讀到位元組陣列:int read(byte[] b) 讀到字元陣列:int read(char[] c)

java.io.Reader是所有字元輸入流的超類,它最主要的方法是:

public int read() throws IOException;

FileReader

FileReaderReader的一個子類,它可以開啟檔案並獲取Reader。下面的程式碼演示瞭如何完整地讀取一個FileReader的所有字元:

public void readFile() throws IOException {
    // 建立一個FileReader物件:
    Reader reader = new FileReader("src/readme.txt"); // 字元編碼是???
    for (;;) {
        int n = reader.read(); // 反覆呼叫read()方法,直到返回-1
        if (n == -1) {
            break;
        }
        System.out.println((char)n); // 列印char
    }
    reader.close(); // 關閉流
}

如果我們讀取一個純ASCII編碼的文字檔案,上述程式碼工作是沒有問題的。但如果檔案中包含中文,就會出現亂碼,因為FileReader預設的編碼與系統相關,例如,Windows系統的預設編碼可能是GBK,開啟一個UTF-8編碼的文字檔案就會出現亂碼。

要避免亂碼問題,我們需要在建立FileReader時指定編碼:

Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8);

InputStream類似,Reader也是一種資源,需要保證出錯的時候也能正確關閉,所以我們需要用try (resource)來保證Reader在無論有沒有IO錯誤的時候都能夠正確地關閉:

try (Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8) {
    // TODO
}

Reader還提供了一次性讀取若干字元並填充到char[]陣列的方法:

public int read(char[] c) throws IOException

它返回實際讀入的字元個數,最大不超過char[]陣列的長度。返回-1表示流結束。

利用這個方法,我們可以先設定一個緩衝區,然後,每次儘可能地填充緩衝區:

public void readFile() throws IOException {
    try (Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8)) {
        char[] buffer = new char[1000];
        int n;
        while ((n = reader.read(buffer)) != -1) {
            System.out.println("read " + n + " chars.");
        }
    }
}

小結

Reader定義了所有字元輸入流的超類:

  • FileReader實現了檔案字元流輸入,使用時需要指定編碼;
  • CharArrayReaderStringReader可以在記憶體中模擬一個字元流輸入。

Reader是基於InputStream構造的:可以通過InputStreamReader在指定編碼的同時將任何InputStream轉換為Reader

總是使用try (resource)保證Reader正確關閉。

Writer

Reader是帶編碼轉換器的InputStream,它把byte轉換為char,而Writer就是帶編碼轉換器的OutputStream,它把char轉換為byte並輸出。

WriterOutputStream的區別如下:

OutputStream Writer
位元組流,以byte為單位 字元流,以char為單位
寫入位元組(0~255):void write(int b) 寫入字元(0~65535):void write(int c)
寫入位元組陣列:void write(byte[] b) 寫入字元陣列:void write(char[] c)
無對應方法 寫入String:void write(String s)

Writer是所有字元輸出流的超類,它提供的方法主要有:

  • 寫入一個字元(0~65535):void write(int c)
  • 寫入字元陣列的所有字元:void write(char[] c)
  • 寫入String表示的所有字元:void write(String s)

FileWriter

FileWriter就是向檔案中寫入字元流的Writer。它的使用方法和FileReader類似:

try (Writer writer = new FileWriter("readme.txt", StandardCharsets.UTF_8)) {
    writer.write('H'); // 寫入單個字元
    writer.write("Hello".toCharArray()); // 寫入char[]
    writer.write("Hello"); // 寫入String
}

小結

Writer定義了所有字元輸出流的超類:

  • FileWriter實現了檔案字元流輸出;
  • CharArrayWriterStringWriter在記憶體中模擬一個字元流輸出。

使用try (resource)保證Writer正確關閉。

Writer是基於OutputStream構造的,可以通過OutputStreamWriterOutputStream轉換為Writer,轉換時需要指定編碼。

Filter 模式

又稱裝飾者模式

定義:動態給一個物件新增一些額外的職責,就象在牆上刷油漆.使用Decorator模式相比用生成子類方式達到功能的擴充顯得更為靈活。

設計初衷: 通常可以使用繼承來實現功能的擴充,如果這些需要擴充的功能的種類很繁多,那麼勢必生成很多子類,增加系統的複雜性,同時,使用繼承實現功能擴充,我們必須可預見這些擴充功能,這些功能是編譯時就確定了,是靜態的。

要點: 裝飾者與被裝飾者擁有共同的超類,繼承的目的是繼承型別,而不是行為

Java的IO標準庫提供的InputStream根據來源可以包括:

  • FileInputStream:從檔案讀取資料,是最終資料來源;
  • ServletInputStream:從HTTP請求讀取資料,是最終資料來源;
  • Socket.getInputStream():從TCP連線讀取資料,是最終資料來源;

如果我們要給FileInputStream新增緩衝功能,則可以從FileInputStream派生一個類:

BufferedFileInputStream extends FileInputStream

如果要給FileInputStream新增計算簽名的功能,類似的,也可以從FileInputStream派生一個類:

DigestFileInputStream extends FileInputStream

如果要給FileInputStream新增加密/解密功能,還是可以從FileInputStream派生一個類:

CipherFileInputStream extends FileInputStream

這還只是針對FileInputStream設計,如果針對另一種InputStream設計,很快會出現子類爆炸的情況。

因此,直接使用繼承,為各種InputStream附加更多的功能,根本無法控制程式碼的複雜度,很快就會失控。

為了解決這個問題,JDK首先將InputStream分為兩大類:

一類是直接提供資料的基礎InputStream,例如:

  • FileInputStream
  • ByteArrayInputStream
  • ServletInputStream
  • ...

一類是提供額外附加功能的InputStream,例如:

  • BufferedInputStream
  • DigestInputStream
  • CipherInputStream
  • ...

上述這種通過一個“基礎”元件再疊加各種“附加”功能元件的模式,稱之為Filter模式(或者裝飾器模式:Decorator)。它可以讓我們通過少量的類來實現各種功能的組合:

20210416230306

簡單來說,裝飾模式在基類上增加的每一個功能(簡單稱做功能類)都能夠互相呼叫,每一個功能類之間都是平行層級的,與直接使用extend不同,直接繼承的類之間是樹狀結構而不是平行的。這樣就避免功能之間的巢狀。

假如,我們基於A類,又實現了三個不同的功能類(A1,A2,A3),但是此時我們需要同時用到A1和A2的功能,按照直接繼承的思路而言,就要繼承A1或者A2實現A12的一個新類。但是對裝飾模式而言,我們不需要新建一個類,直接A1(A2),相當於A1去呼叫A2,這樣就可以同時實現A1A2的功能。

例子

下面舉個例子:

假如我們要去買一個漢堡,漢堡有多種類,還可以選擇是否新增生菜、辣椒等配料。這樣給漢堡定價格,就可以使用裝飾者模式。

這裡如果我們直接使用繼承來做的話,假如有n種配料,我們就需要將n種配料之間的不同組合的類全部實現出來,直接爆炸。

如果使用裝飾者模式來做,我們只需要定義n個類就可以完成漢堡定價的功能,因為n個類之間可以相互呼叫,我們可以很方便的類的組合。

下面是程式碼:

首先是漢堡的基類,這裡定義了一個抽象類,返回了漢堡的名字和價格。

package decorator;  
  
public abstract class Humburger {  
    protected  String name;
    public String getName(){ return name; }
    public abstract double getPrice();  
}  

然後是漢堡的種類,這裡用的雞腿堡

package decorator;  

public class ChickenBurger extends Humburger {  
    public ChickenBurger(){ name = "雞腿堡"; }  
    @Override  
    public double getPrice() { return 10; }  
}

配料的基類,返回配料的名稱

package decorator;  

public abstract class Condiment extends Humburger {  
    public abstract String getName();   
}

生菜(裝飾的第一層)

package decorator;  
  
public class Lettuce extends Condiment {     
    Humburger hburger;  
    public Lettuce(Humburger burger){  
        this.hburger = burger;  
    }
    @Override  
    public String getName() {  
        return hburger.getName()+" 加生菜";  
    }
    @Override  
    public double getPrice() {  
        return hburger.getPrice()+1.5;  
    }
} 

辣椒(裝飾者的第二層)

package decorator;  
  
public class Chilli extends Condiment {        
    Humburger hburger;  
    public Chilli(Humburger burger){  
        this.hburger = burger;     
    }
    @Override  
    public String getName() {  
        return hburger.getName()+" 加辣椒";  
    }
    @Override  
    public double getPrice() {  
        return hburger.getPrice();  //辣椒是免費的哦  
    } 
}

測試類

package decorator;  

public class Test {  
    public static void main(String[] args) {  
        // 只要一個雞肉堡
        Humburger humburger = new ChickenBurger();  
        System.out.println(humburger.getName()+"  價錢:"+humburger.getPrice());  
        // 雞肉堡加生菜,呼叫雞肉堡
        Lettuce lettuce = new Lettuce(humburger);
        System.out.println(lettuce.getName()+"  價錢:"+lettuce.getPrice());  
        // 雞肉堡加辣椒,呼叫雞肉堡
        Chilli chilli = new Chilli(humburger);  
        System.out.println(chilli.getName()+"  價錢:"+chilli.getPrice()); 
 		// 雞肉堡加生菜加辣椒,呼叫雞肉生菜堡
        Chilli chilli2 = new Chilli(lettuce);  
        System.out.println(chilli2.getName()+"  價錢:"+chilli2.getPrice());  
    }
}


雞腿堡  價錢:10.0  
雞腿堡 加生菜  價錢:11.5  
雞腿堡 加辣椒  價錢:10.0  
雞腿堡 加生菜 加辣椒  價錢:11.5  

相關文章