關于 Java IO(二):從面向字節(jié)到面向字符

在上一篇文章中,我們以面向字節(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é)的輸入輸出轉化為面向字符的輸入輸出。它們是 InputStreamReaderOutputStreamWriter ,它們分別對應輸入和輸出。以下的敘述以 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 則是通過我們傳入的輸入源的編碼方式得到的,具體流程為:

  1. 通過傳入的 charsetName 找到對應的 Charset,如 UTF-8 對應的 Charset 就是 UTF-8。在代碼上,是通過調用 Charsetpublic static Charset forName(String charsetName) 方法來得到對應的 Charset。這個方法會根據(jù) charsetName 去查找對應的Charset,注意不是創(chuàng)建,因為 Java 在啟動時會自動創(chuàng)建一些常用的 Charset 對象,并把他們緩存起來,如果需要的 Charset 在緩存中,那么直接拿來用就行了。當沒有找到時,會根據(jù) charsetName,利用反射動態(tài)地將對應的 Charset 類加載進來,并創(chuàng)建出相應的對象返回回去。
  2. 通過得到的 Charset 對象創(chuàng)建出對應的 CharsetDecoder。Charset 類中都會有一個 newDecoder() 方法,該方法會返回對應的 CharsetDecoder。

以上就是 InputStreamReader 的大致邏輯。想要詳細了解的,可以去看源碼。不過 StreamDecoder 的代碼并不是開源的,想要看到這些代碼,需要去下一個 OpenJDK。

四、類結構

面向字符的 IO 的類圖如下圖所示:

面向字符 IO 的類圖

與面向字節(jié)的輸入一樣,面向字符的輸入也是一個裝飾模式。它也分為裝飾器和輸入源。Reader 是所有面向字符輸入類的基類。而我們提到的 InputStreamReader 其實是一個適配器類,將 InputStream 轉換成一個 Reader。這個過程的類圖如下所示。

InputStreamReader使用的適配器模式

從類圖中可以看到,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。暫時不清楚為什么要這么設計。這要注意的是,并不是繼承自裝飾器基類的才叫裝飾器,裝飾器類的特點是其中組合了一個被裝飾對象,并在它之上擴展了功能。裝飾器基類的作用是復用代碼和使結構清晰,他不是必要的。

五、參考資料

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,401評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,011評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,263評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,543評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,323評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,874評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,968評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,095評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,605評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,551評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,720評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,242評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 43,961評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,358評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,612評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,330評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,690評論 2 370

推薦閱讀更多精彩內容