在上一篇文章中,我們以面向字節(jié)的輸入為例,介紹了 Java 中 IO 的結構。在這篇文章中,主要介紹面向字節(jié)的輸入輸出是怎么轉換到面向字符的輸入輸出的。
面向字符的輸入輸出指的是輸入輸出的單位是字符。根據(jù)字符編碼方案的不同,一個字符可能會對應多個字節(jié),如: ASCII 碼中一個字符對應一個字節(jié),而采用 Unicode 字符集以 UTF-8 為編碼轉換方案時,一個字符可能對應了一到三個字節(jié)。
所以,面向字符的輸入輸出從本質上還是面向字節(jié)的,只是輸入輸出的單位不再是單個字節(jié)了。讀一個字符,可能要讀取多個字節(jié)。
然而,事情并沒有那么簡單,這中間還存在著編碼轉換問題。在討論這個問題之前,我們先了解一下 Java 使用的字符編碼方案。
一、Java 中的字符編碼方案
Java 使用的是 Unicode 字符集,并以 UTF-16 作為編碼轉換方案,將 Unicode 中的碼位轉換為實際存儲的二進制序列。也就是說,Java 中表示字符的哪些類型,像 String,char array 中表達的字符都是以 UTF-16 的方式存儲的。你可能會問,char 呢,String 和 char array 本質上不都是 char 的數(shù)組嗎?
其實,UTF-16 是一種可變長的編碼轉換方案。對于 Unicode 中碼位 (code point),在 U+0000-U+FFFF
的字符,采用 2 個字節(jié)來編碼,而碼位在這之上的字符采用 4 個字節(jié)來編碼。由于常用字符的碼位都在 U+0000-U+FFFF
范圍內,與定長的 4 字節(jié)編碼方案相比,幾乎可以節(jié)省一半的空間。
而我們的 char 有且只有兩個字節(jié)長。一個 char 只能表達出 UTF-16
中 2 字節(jié)編碼的部分,而那些需要用 4 字節(jié)編碼的字符,其實是用 2 個 char 以代理對的形式來表達的。下面的代碼是一個用 2 個 char 來表達一個字符的例子。
public static void main(String[] args) {
String s = "??";
System.out.printf("The code point of the character: %02x \n", s.codePointAt(0));
System.out.printf("The length of string \"%s\": %d\n", s, s.length());
char[] chars = s.toCharArray();
System.out.printf("The content of string \"%s\":", s);
for(char c : chars) {
System.out.printf("%02x ", (int)c);
}
}
輸出:
The code point of the character: 202b7
The length of string "??": 2
The content of string "??":d840 deb7
其中,codePointAt(int index)
方法可以得到 String 中對應字符的碼位,注意這個方法的形參是字符的索引,而不是 char 數(shù)組的索引。可以看到“??”在 Unicode 中的碼位為 U+202B7
,這在 UTF-16 中是需要用 4 個字節(jié)來編碼的。而的確,雖然這個字符串只含 1 個字符,但它的長度是 2,其中包含了兩個 char。在輸出的第三行可以看到這兩個 char 的具體內容,清楚 UTF-16 編碼的同學可以手動將它的碼位轉換一下,U+202B7
用 UTF-16 來進行編碼轉換的結果就是 D840 DEB7
。
在 Java IO 中許多輸入函數(shù)的返回值為 int,而不是 char,byte,那是因為程序通常會用返回值為 -1 來表示數(shù)據(jù)已經(jīng)讀取完了。你可能會將它與前面所講的內容聯(lián)系起來,但其實并不是這樣。Java IO 中 read()
方法的返回值的范圍是 0~65535。如果遇到了用 4 個字節(jié)來編碼的字符,雖然 int 是 4 字節(jié)的,但咱們需要 read()
兩次才能把它讀出來。
上文中提到了許多 Unicode 中的概念,像碼位,代理對,UTF……不清楚的可以看這篇文章:Unicode 的那些事兒。
二、編碼轉換
雖然 Java 中用 UTF-16 來表示字符,但是輸入時,輸入源的字符編碼不一定是 UTF-16,輸出時,目的地所要求的字符編碼也不一定是 UTF-16。如果不加處理,直接讀入數(shù)據(jù),很可能會出現(xiàn)亂碼。因此,在輸入輸出時,還需要進行編碼轉換。
對于 Java 的輸入輸出來說,輸入時,要將字符串的編碼方式從原先的編碼方式轉換為 UTF-16,輸出時,要將字符串的編碼方式從 UTF-16 轉換為目標編碼方式。在 Java 的 API 中,常常把輸入時的這種轉換過程叫做 Decode
,而輸出時的這種轉換過程叫做 Encode
。
進行編碼轉換時,需要知道 2 個信息,內容在轉換前是以什么編碼方式進行編碼的,以及要轉換成用什么編碼方式進行編碼的。為了方便敘述,我們把前者叫做原始編碼方式,把后者叫做目標編碼方式。
轉換時,一般根據(jù)原始編碼方式,以字符為單位,一次取一個字符所對應的 01 序列進行轉換。如果目標編碼方式和原始編碼方式有線性關系,那么就通過加減乘除位運算來將這個 01 序列轉化為目標編碼方式下的 01 序列,如 UTFs 之間的轉換。而如果不存在線性關系,那么就會通過查表來進行轉換。轉換程序會事先準備一張原始編碼和目標編碼的字符編碼對應表,其中是每個字符在兩種編碼方式下的 01 序列。轉換程序可以通過查找這張表來得到對應的 01 序列。
而對于 Java 的輸入輸出來說,在輸入時,目標編碼是 UTF-16,原始編碼未知,需要我們告訴它,在輸出時,原始編碼是 UTF-16,目標編碼未知,需要我們告訴它。注意,如果沒有告訴程序正確的編碼方式,就會出現(xiàn)亂碼。這也是大部分亂碼問題出現(xiàn)的原因。
綜上,要得到面向字符的輸入,需要一個面向字節(jié)的輸入來讀取源的數(shù)據(jù),以及源的編碼方式來進行編碼轉換。要得到面向字符的輸出,需要一個面向字節(jié)的輸出來向目的地輸出數(shù)據(jù),以及目的地所要求的編碼方式來進行編碼轉換。
順便一提,Java 中對 UTF-8 的處理方式與標準的 UTF-8 有一些不同。具體可見 JDK文檔中的描述。
三、InputStreamReader 和 OutputStreamWriter
在之前的內容中,我們敘述了如何從面向字節(jié)的輸入輸出轉換到面向字符的輸入輸出。在 Java 的 IO 系統(tǒng)中,有專門的兩個類來將面向字節(jié)的輸入輸出轉化為面向字符的輸入輸出。它們是 InputStreamReader
和 OutputStreamWriter
,它們分別對應輸入和輸出。以下的敘述以 InputStreamReader
為例,OutputStreamWriter
同理。
以下是 InputStreamWriter
的構造函數(shù)。
-
InputStreamReader?(InputStream in)
. Creates an InputStreamReader that uses the default charset. -
InputStreamReader?(InputStream in, String charsetName)
. Creates an InputStreamReader that uses the named charset. -
InputStreamReader?(InputStream in, Charset cs)
. Creates an InputStreamReader that uses the given charset. -
InputStreamReader?(InputStream in, CharsetDecoder dec)
. Creates an InputStreamReader that uses the given charset decoder.
就像前文中敘述的那樣,我們要得到一個面向字符的輸入,需要一個面向字節(jié)的輸入和源的編碼方式。這分別對應了構造函數(shù)中的兩個形參。使用第一個構造函數(shù)時,會將源的編碼方式指定為平臺默認的編碼方式。下面是一個用 InputStreamReader
進行讀取的例子。
import java.io.*;
public class FileIn {
private static String path = "/Users/grandfather/test.txt";
public static void main(String[] args) {
InputStreamReader isr;
int a;
try {
isr = new InputStreamReader(new FileInputStream(path),"UTF-8");
while ((a = isr.read()) != -1)
System.out.print((char)a);
} catch (IOException e) {
e.printStackTrace();
}
}
}
文件內容:
你好呀!
輸出:
你好呀!
上面這個例子實現(xiàn)了從文件中讀取字符并顯示。我們只需要提供對應的 InputStream
和輸入源的編碼方式就能實現(xiàn)面向字符的輸入,十分方便。
至于 InputStreamReader
的實現(xiàn)方式。之前,我們已經(jīng)提到過大致的思路,用面向字節(jié)的輸入流讀入數(shù)據(jù),把字符一個個地從原始編碼方式“翻譯“到目標編碼方式。這里只簡略的講一下。
InputStreamReader
本身其實并沒有干什么事情,它的所有功能都是利用 StreamDecoder
實現(xiàn)的,去看源碼的話會發(fā)現(xiàn) InputStreamReader
只是簡單地調用了一下 StreamDecoder
的方法而已。將面向字節(jié)轉換為面向字符的工作都是由 StreamDecoder
完成的。StreamDecoder
的工作是組織數(shù)據(jù)的讀取和字符編碼的轉換。其中,讀取數(shù)據(jù)是通過傳入的 InputStream
對象完成的。而字符編碼的轉換是由 CharsetDecoder
完成的,它能將以其他字符編碼方式編碼的字符重新編碼為 UTF-16 格式。而 CharsetDecoder
則是通過我們傳入的輸入源的編碼方式得到的,具體流程為:
- 通過傳入的
charsetName
找到對應的Charset
,如 UTF-8 對應的Charset
就是UTF-8
。在代碼上,是通過調用Charset
的public static Charset forName(String charsetName)
方法來得到對應的Charset
。這個方法會根據(jù)charsetName
去查找對應的Charset
,注意不是創(chuàng)建,因為 Java 在啟動時會自動創(chuàng)建一些常用的Charset
對象,并把他們緩存起來,如果需要的Charset
在緩存中,那么直接拿來用就行了。當沒有找到時,會根據(jù)charsetName
,利用反射動態(tài)地將對應的Charset
類加載進來,并創(chuàng)建出相應的對象返回回去。 - 通過得到的
Charset
對象創(chuàng)建出對應的CharsetDecoder
。Charset
類中都會有一個newDecoder()
方法,該方法會返回對應的CharsetDecoder
。
以上就是 InputStreamReader
的大致邏輯。想要詳細了解的,可以去看源碼。不過 StreamDecoder
的代碼并不是開源的,想要看到這些代碼,需要去下一個 OpenJDK。
四、類結構
面向字符的 IO 的類圖如下圖所示:
與面向字節(jié)的輸入一樣,面向字符的輸入也是一個裝飾模式。它也分為裝飾器和輸入源。Reader
是所有面向字符輸入類的基類。而我們提到的 InputStreamReader
其實是一個適配器類,將 InputStream
轉換成一個 Reader
。這個過程的類圖如下所示。
從類圖中可以看到,InputStreamReader
通過 StreamReader
間接地聚合了一個 InputStream
。具體的實現(xiàn)就如我們之前講的那樣,StreamReader
通過 InputStream
來讀如字節(jié),并通過編碼轉化,來返回字符。而 InputStreamReader
則通過調用 StreamReader
實現(xiàn)了 Reader
中的接口。從而將 InputStream
轉化為了 Reader
。
從面向字符 IO 的結構上來看,應該將 InputStreamReader
看作是輸入源,它是面向對象的輸入源轉化而來的。
按之前的敘述來看,面向字符的輸入源應該是下面這個樣子的。
XXReader 相當于 InputStreamReader(new XXInputStream());
面向字符輸入源即 InputStreamReader(對應的面向字節(jié)輸入源)
。有些面向字符的類的確是這樣實現(xiàn)的,像 FileReader
,我們可以看看他的代碼。
public class FileReader extends InputStreamReader {
public FileReader(String fileName) throws FileNotFoundException {
super(new FileInputStream(fileName));
}
public FileReader(File file) throws FileNotFoundException {
super(new FileInputStream(file));
}
public FileReader(FileDescriptor fd) {
super(new FileInputStream(fd));
}
}
總共就這么幾行代碼。它本質上就是一個 InputStreamReader(new FileInputStream())
,只是包裝了一下而已,換了層皮。
但是對于一些輸入源來說,并不需要用到 InputStreamReader
。如 CharArrayReader
,它的輸入源已經(jīng)是 char
了,并不需要再讀入字節(jié)和進行編碼轉換,直接把我們要讀的字符返回回來就可以完成輸入了。
另外,在面向字符的 IO 中,并不是所有的適配器都是繼承自 FilterReader
的,如 BufferedReader
直接繼承自 Reader
。暫時不清楚為什么要這么設計。這要注意的是,并不是繼承自裝飾器基類的才叫裝飾器,裝飾器類的特點是其中組合了一個被裝飾對象,并在它之上擴展了功能。裝飾器基類的作用是復用代碼和使結構清晰,他不是必要的。
五、參考資料
- JDK文檔
- 《Thinking in Java》
- 《設計模式之禪》
- Unicode 的那些事兒。