建議閱讀
重要性由高到低
本文簡要的這些文章做了一些總結
基本概念
IO,即in
和out
,也就是輸入和輸出,指應用程序和外部設備之間的數據傳遞,常見的外部設備包括文件(file)、管道 (pipe)、網絡連接 (network)。
流(Stream
),是一個抽象的概念,是指一連串的數據(字符或字節),是以先進先出的方式發送信息的通道。
流的特性:
- 先進先出:最先寫入輸出流的數據最先被輸入流讀取到。
- 順序存取:可以一個接一個地往流中寫入一串字節,讀出時也將按寫入順序讀取一串字節,不能隨機訪問中間的數據。(RandomAccessFile除外)
- 只讀或只寫:每個流只能是輸入流或輸出流的一種,不能同時具備兩個功能,輸入流只能進行讀操作,對輸出流只能進行寫操作。在一個數據傳輸通道中,如果既要寫入數據,又要讀取數據,則要分別提供兩個流。
IO流主要的分類方式有以下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就推出了字符流。
字節流和字符流的其他區別:
- 字節流一般用來處理圖像、視頻、音頻、PPT、Word等類型的文件。字符流一般用於處理純文本類型的文件,如TXT文件等,但不能處理圖像視頻等非文本文件。用一句話說就是:字節流可以處理一切文件,而字符流只能處理純文本文件。
- 字節流本身沒有緩沖區,緩沖字節流相對於字節流,效率提升非常高。而字符流本身就帶有緩沖區,緩沖字符流相對於字符流效率提升就不是那么大了。詳見文末效率對比。
節點流和處理流
節點流:直接操作數據讀寫的流類,比如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
FileInputStream
是InputStream
的一個子類。顧名思義,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(); // 關閉流
}
InputStream
和OutputStream
都是通過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()
方法。InputStream
和OutputStream
都實現了這個接口,因此,都可以用在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.");
}
}
}
阻塞
在調用InputStream
的read()
方法讀取數據時,我們說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()
方法。舉個栗子:
小明正在開發一款在線聊天軟件,當用戶輸入一句話后,就通過OutputStream
的write()
方法寫入網絡流。小明測試的時候發現,發送方輸入后,接收方根本收不到任何信息,怎么肥四?
原因就在於寫入網絡流是先寫入內存緩沖區,等緩沖區滿了才會一次性發送到網絡。如果緩沖區大小是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
一樣,OutputStream
的write()
方法也是阻塞的。
同時操作多個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
FileReader
是Reader
的一個子類,它可以打開文件並獲取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
實現了文件字符流輸入,使用時需要指定編碼;CharArrayReader
和StringReader
可以在內存中模擬一個字符流輸入。
Reader
是基於InputStream
構造的:可以通過InputStreamReader
在指定編碼的同時將任何InputStream
轉換為Reader
。
總是使用try (resource)
保證Reader
正確關閉。
Writer
Reader
是帶編碼轉換器的InputStream
,它把byte
轉換為char
,而Writer
就是帶編碼轉換器的OutputStream
,它把char
轉換為byte
並輸出。
Writer
和OutputStream
的區別如下:
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
實現了文件字符流輸出;CharArrayWriter
和StringWriter
在內存中模擬一個字符流輸出。
使用try (resource)
保證Writer
正確關閉。
Writer
是基於OutputStream
構造的,可以通過OutputStreamWriter
將OutputStream
轉換為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)。它可以讓我們通過少量的類來實現各種功能的組合:
簡單來說,裝飾模式在基類上增加的每一個功能(簡單稱做功能類)都能夠互相調用,每一個功能類之間都是平行層級的,與直接使用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