計算機程式的思維邏輯 (58) - 文字檔案和字元流

swiftma發表於2016-12-20

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (58) - 文字檔案和字元流

上節我們介紹瞭如何以位元組流的方式處理檔案,我們提到,對於文字檔案,位元組流沒有編碼的概念,不能按行處理,使用不太方便,更適合的是使用字元流,本節就來介紹字元流。

我們首先簡要介紹下文字檔案的基本概念、與二進位制檔案的區別、編碼、以及字元流和位元組流的區別,然後我們介紹Java中的主要字元流,它們有:

  • Reader/Writer:字元流的基類,它們是抽象類。
  • InputStreamReader/OutputStreamWriter:介面卡類,輸入是InputStream,輸出是OutputStream,將位元組流轉換為字元流。
  • FileReader/FileWriter:輸入源和輸出目標是檔案的字元流。
  • CharArrayReader/CharArrayWriter: 輸入源和輸出目標是char陣列的字元流。
  • StringReader/StringWriter:輸入源和輸出目標是String的字元流。
  • BufferedReader/BufferedWriter:裝飾類,對輸入輸出流提供緩衝,以及按行讀寫功能。
  • PrintWriter:裝飾類,可將基本型別和物件轉換為其字串形式輸出的類。

除了這些類,Java中還有一個類Scanner,類似於一個Reader,但不是Reader的子類,可以讀取基本型別的字串形式,類似於PrintWriter的逆操作。

理解了位元組流和字元流後,我們介紹一下Java中的標準輸入輸出和錯誤流。

最後,我們總結一些簡單的實用方法。

基本概念

文字檔案

上節我們提到,處理檔案要有二進位制思維。從二進位制角度,我們通過一個簡單的例子解釋下文字檔案與二進位制檔案的區別,比如說要儲存整數123,使用二進位制形式儲存到檔案test.dat,程式碼為:

DataOutputStream output = new DataOutputStream(new FileOutputStream("test.dat"));
try{
    output.writeInt(123);
}finally{
    output.close();
}
複製程式碼

使用UltraEdit開啟該檔案,顯示的卻是:

{                        
複製程式碼

開啟十六進位制編輯器,顯示的為:

計算機程式的思維邏輯 (58) - 文字檔案和字元流
在檔案中儲存的實際有四個位元組,最低位位元組7B對應的十進位制數是123,也就是說,對int型別,二進位制檔案儲存的直接就是int的二進位制形式。這個二進位制形式,如果當成字元來解釋,顯示成什麼字元則與編碼有關,如果當成UTF-32BE編碼,解釋成的就是一個字元,即{。

如果使用文字檔案儲存整數123,則程式碼為:

OutputStream output = new FileOutputStream("test.txt");
try{
    String data = Integer.toString(123);
    output.write(data.getBytes("UTF-8"));
}finally{
    output.close();
}
複製程式碼

程式碼將整數123轉換為字串,然後將它的UTF-8編碼輸出到了檔案中,使用UltraEdit開啟該檔案,顯示的就是期望的:

123
複製程式碼

開啟十六進位制編輯器,顯示的為:

計算機程式的思維邏輯 (58) - 文字檔案和字元流
檔案中實際儲存的有三個位元組,31 32 33對應的十進位制數分別是49 50 51,分別對應字元'1','2','3'的ASCII編碼。

編碼

在文字檔案中,編碼非常重要,同一個字元,不同編碼方式對應的二進位制形式可能是不一樣的,我們看個例子,對同樣的文字:

hello, 123, 老馬
複製程式碼

UTF-8編碼,十六進位制為:

計算機程式的思維邏輯 (58) - 文字檔案和字元流
英文和數字字元每個佔一個位元組,而每個中文佔三個位元組。

GB18030編碼,十六進位制為:

計算機程式的思維邏輯 (58) - 文字檔案和字元流

英文和數字字元與UTF-8編碼是一樣的,但中文不一樣,每個中文佔兩個位元組。

UTF-16BE編碼,十六進位制為:

計算機程式的思維邏輯 (58) - 文字檔案和字元流

無論是英文還是中文字元,每個字元都佔兩個位元組。UTF-16BE也是Java記憶體中對字元的編碼方式。

字元流

位元組流是按位元組讀取的,而字元流則是按char讀取的,一個char在檔案中儲存的是幾個位元組與編碼有關,但字元流給我們封裝了這種細節,我們操作的物件就是char。

需要說明的是,一個char不完全等同於一個字元,對於絕大部分字元,一個字元就是一個char,但我們之前介紹過,對於增補字符集中的字元,比如'?',它需要兩個char表示,對於這種字元,Java中的字元流是按char而不是一個完整字元處理的。

理解了文字檔案、編碼和字元流的概念,我們再來看Java中的相關類,從基類開始。

Reader/Writer

Reader

Reader與位元組流的InputStream類似,也是抽象類,主要有如下方法:

public int read() throws IOException
public int read(char cbuf[]) throws IOException
abstract public int read(char cbuf[], int off, int len) throws IOException;
abstract public void close() throws IOException;
public long skip(long n) throws IOException
public boolean markSupported()
public void mark(int readAheadLimit) throws IOException
public void reset() throws IOException
public boolean ready() throws IOException
複製程式碼

方法的名稱和含義與InputStream中的對應方法基本類似,但Reader中處理的單位是char,比如read讀取的是一個char,取值範圍為0到65535。Reader沒有available方法,對應的方法是ready()。

Writer

Writer與位元組流的OutputStream類似,也是抽象類,主要有如下方法:

public void write(int c)
public void write(char cbuf[])
abstract public void write(char cbuf[], int off, int len) throws IOException;
public void write(String str) throws IOException
public void write(String str, int off, int len)
abstract public void close() throws IOException;
abstract public void flush() throws IOException;
複製程式碼

含義與OutputStream的對應方法基本類似,但Writer處理的單位是char,Writer還接受String型別,我們知道,String的內部就是char陣列,處理時,會呼叫String的getChar方法先獲取char陣列。

InputStreamReader/OutputStreamWriter

InputStreamReader和OutputStreamWriter是介面卡類,能將InputStream/OutputStream轉換為Reader/Writer。

OutputStreamWriter

OutputStreamWriter的主要構造方法為:

public OutputStreamWriter(OutputStream out)
public OutputStreamWriter(OutputStream out, String charsetName)
public OutputStreamWriter(OutputStream out, Charset cs) 
複製程式碼

一個重要的引數是編碼型別,可以通過名字charsetName或Charset物件傳入,如果沒有傳,則為系統預設編碼,預設編碼可以通過Charset.defaultCharset()得到。OutputStreamWriter內部有一個型別為StreamEncoder的編碼器,能將char轉換為對應編碼的位元組。

我們看一段簡單的程式碼,將字串"hello, 123, 老馬"寫到檔案hello.txt中,編碼格式為GB2312:

Writer writer = new OutputStreamWriter(
        new FileOutputStream("hello.txt"), "GB2312");
try{
    String str = "hello, 123, 老馬";
    writer.write(str);
}finally{
    writer.close();
}
複製程式碼

建立一個FileOutputStream,然後將其包在一個OutputStreamWriter中,就可以直接以字串寫入了。

InputStreamReader

InputStreamReader的主要構造方法為:

public InputStreamReader(InputStream in)
public InputStreamReader(InputStream in, String charsetName)
public InputStreamReader(InputStream in, Charset cs)
複製程式碼

與OutputStreamWriter一樣,一個重要的引數是編碼型別。InputStreamReader內部有一個型別為StreamDecoder的解碼器,能將位元組根據編碼轉換為char。

我們看一段簡單的程式碼,將上面寫入的檔案讀進來:

Reader reader = new InputStreamReader(
        new FileInputStream("hello.txt"), "GB2312");
try{
    char[] cbuf = new char[1024];
    int charsRead = reader.read(cbuf);
    System.out.println(new String(cbuf, 0, charsRead));
}finally{
    reader.close();
}
複製程式碼

這段程式碼假定一次read呼叫就讀到了所有內容,且假定長度不超過1024。為了確保讀到所有內容,可以藉助待會介紹的CharArrayWriter或StringWriter。

FileReader/FileWriter

FileReader/FileWriter的輸入和目的是檔案。FileReader是InputStreamReader的子類,它的主要構造方法有:

public FileReader(File file) throws FileNotFoundException
public FileReader(String fileName) throws FileNotFoundException
複製程式碼

FileWriter是OutputStreamWriter的子類,它的主要構造方法有:

public FileWriter(File file) throws IOException
public FileWriter(File file, boolean append) throws IOException
public FileWriter(String fileName) throws IOException
public FileWriter(String fileName, boolean append) throws IOException
複製程式碼

append引數指定是追加還是覆蓋,如果沒傳,為覆蓋。

需要注意的是,FileReader/FileWriter不能指定編碼型別,只能使用預設編碼,如果需要指定編碼型別,可以使用InputStreamReader/OutputStreamWriter。

CharArrayReader/CharArrayWriter

CharArrayWriter

CharArrayWriter與ByteArrayOutputStream類似,它的輸出目標是char陣列,這個陣列的長度可以根據資料內容動態擴充套件。

CharArrayWriter有如下方法,可以方便的將資料轉換為char陣列或字串:

public char[] toCharArray()
public String toString()
複製程式碼

使用CharArrayWriter,我們可以改進上面的讀檔案程式碼,確保將所有檔案內容讀入:

Reader reader = new InputStreamReader(
        new FileInputStream("hello.txt"), "GB2312");
try{
    CharArrayWriter writer = new CharArrayWriter();
    char[] cbuf = new char[1024];
    int charsRead = 0;
    while((charsRead=reader.read(cbuf))!=-1){
        writer.write(cbuf, 0, charsRead);
    }
    System.out.println(writer.toString());
}finally{
    reader.close();
}
複製程式碼

讀入的資料先寫入CharArrayWriter中,讀完後,再呼叫其toString方法獲取完整資料。

CharArrayReader

CharArrayReader與上節介紹的ByteArrayInputStream類似,它將char陣列包裝為一個Reader,是一種介面卡模式,它的構造方法有:

public CharArrayReader(char buf[])
public CharArrayReader(char buf[], int offset, int length) 
複製程式碼

StringReader/StringWriter

StringReader/StringWriter與CharArrayReader/CharArrayWriter類似,只是輸入源為String,輸出目標為StringBuffer,而且,String/StringBuffer內部是由char陣列組成的,所以它們本質上是一樣的。

之所以要將char陣列/String與Reader/Writer進行轉換也是為了能夠方便的參與Reader/Writer構成的協作體系,複用程式碼。

BufferedReader/BufferedWriter

BufferedReader/BufferedWriter是裝飾類,提供緩衝,以及按行讀寫功能。BufferedWriter的構造方法有:

public BufferedWriter(Writer out)
public BufferedWriter(Writer out, int sz)
複製程式碼

引數sz是緩衝大小,如果沒有提供,預設為8192。它有如下方法,可以輸出平臺特定的換行符:

public void newLine() throws IOException
複製程式碼

BufferedReader的構造方法有:

public BufferedReader(Reader in)
public BufferedReader(Reader in, int sz)
複製程式碼

引數sz是緩衝大小,如果沒有提供,預設為8192。它有如下方法,可以讀入一行:

public String readLine() throws IOException
複製程式碼

字元'\r'或'\n'或'\r\n'被視為換行符,readLine返回一行內容,但不會包含換行符,當讀到流結尾時,返回null。

FileReader/FileWriter是沒有緩衝的,也不能按行讀寫,所以,一般應該在它們的外面包上對應的緩衝類。

我們來看個例子,還是上節介紹的學生列表,這次我們使用可讀的文字進行儲存,一行儲存一條學生資訊,學生欄位之間用逗號分隔,儲存的程式碼為:

public static void writeStudents(List<Student> students) throws IOException{
    BufferedWriter writer = null;
    try{
        writer = new BufferedWriter(new FileWriter("students.txt"));
        for(Student s : students){
            writer.write(s.getName()+","+s.getAge()+","+s.getScore());
            writer.newLine();
        }
    }finally{
        if(writer!=null){
            writer.close();    
        }
    }
}
複製程式碼

儲存後的檔案內容顯示為:

張三,18,80.9
李四,17,67.5
```
從檔案中讀取的程式碼為:
```java
public static List<Student> readStudents() throws IOException{
    BufferedReader reader = null;
    try{
        reader = new BufferedReader(
                new FileReader("students.txt"));
        List<Student> students = new ArrayList<>();
        String line = reader.readLine();
        while(line!=null){
            String[] fields = line.split(",");
            Student s = new Student();
            s.setName(fields[0]);
            s.setAge(Integer.parseInt(fields[1]));
            s.setScore(Double.parseDouble(fields[2]));
            students.add(s);
            line = reader.readLine();
        }
        return students;
    }finally{
        if(reader!=null){
            reader.close();
        }
    }
}
```
使用readLine讀入每一行,然後使用String的方法分隔欄位,再呼叫Integer和Double的方法將字串轉換為int和double,這種對每一行的解析可以使用類Scanner進行簡化,待會我們介紹。

## PrintWriter

PrintWriter有很多過載的print方法,如:
```java
public void print(int i)
public void print(long l)
public void print(double d)
public void print(Object obj)
```
它會將這些引數轉換為其字串形式,即呼叫String.valueOf(),然後再呼叫write。它也有很多過載形式的println方法,println除了呼叫對應的print,還會輸出一個換行符。除此之外,PrintWriter還有格式化輸出方法,如:
```java
public PrintWriter printf(String format, Object ... args)
```
format表示格式化形式,比如,保留小數點後兩位,格式可以為:
```java
PrintWriter writer = ...
writer.format("%.2f", 123.456f);
```
輸出為:
```
123.45
```
更多格式化的內容可以參看Java文件,本文就不贅述了。

PrintWriter的方便之處在於,它有很多構造方法,可以接受檔案路徑名、檔案物件、OutputStream、Writer等,對於檔案路徑名和File物件,還可以接受編碼型別作為引數,如下所示:
```java
public PrintWriter(File file) throws FileNotFoundException
public PrintWriter(File file, String csn)
public PrintWriter(String fileName) throws FileNotFoundException
public PrintWriter(String fileName, String csn)
public PrintWriter(OutputStream out)
public PrintWriter(OutputStream out, boolean autoFlush)
public PrintWriter (Writer out)
public PrintWriter(Writer out, boolean autoFlush)
```
引數csn表示編碼型別,對於以檔案物件和檔名為引數的構造方法,PrintWriter內部會構造一個BufferedWriter,比如:
```java
public PrintWriter(String fileName) throws FileNotFoundException {
    this(new BufferedWriter(new OutputStreamWriter(new FileOutputStream(fileName))),
         false);
}
```
對於以OutputSream為引數的構造方法,PrintWriter也會構造一個BufferedWriter,比如:
```java
public PrintWriter(OutputStream out, boolean autoFlush) {
    this(new BufferedWriter(new OutputStreamWriter(out)), autoFlush);
    ...
}
```
對於以Writer為引數的構造方法,PrintWriter就不會包裝BufferedWriter了。

構造方法中的autoFlush參數列示同步緩衝區的時機,如果為true,則在呼叫println, printf或format方法的時候,同步緩衝區,如果沒有傳,則不會自動同步,需要根據情況呼叫flush方法。

可以看出,<span style="color:blue">PrintWriter是一個非常方便的類,可以直接指定檔名作為引數,可以指定編碼型別,可以自動緩衝,可以自動將多種型別轉換為字串,在輸出到檔案時,可以優先選擇該類。</span>

上面的儲存學生列表程式碼,使用PrintWriter,可以寫為:
```java
public static void writeStudents(List<Student> students) throws IOException{
    PrintWriter writer = new PrintWriter("students.txt");
    try{
        for(Student s : students){
            writer.println(s.getName()+","+s.getAge()+","+s.getScore());
        }
    }finally{
        writer.close();
    }
}
```
PrintWriter有一個非常相似的類PrintStream,除了不能接受Writer作為構造方法外,PrintStream的其他構造方法與PrintWriter一樣,PrintStream也有幾乎一樣的過載的print和println方法,只是自動同步緩衝區的時機略有不同,在PrintStream中,只要碰到一個換行字元'\n',就會自動同步緩衝區。

PrintStream與PrintWriter的另一個區別是,雖然它們都有如下方法:
```java
public void write(int b)
```
但含義是不一樣的,PrintStream只使用最低的八位,輸出一個位元組,而PrintWriter是使用最低的兩位,輸出一個char。

## Scanner

Scanner是一個單獨的類,它是一個簡單的文字掃描器,能夠分析基本型別和字串,它需要一個分隔符來將不同資料區分開來,預設是使用空白符,可以通過useDelimiter方法進行指定。Scanner有很多形式的next方法,可以讀取下一個基本型別或行,如:
```java
public float nextFloat()
public int nextInt()
public String nextLine()
```
Scanner也有很多構造方法,可以接受File物件、InputStream、Reader作為引數,它也可以將字串作為引數,這時,它會建立一個StringReader,比如,以前面的解析學生記錄為例,使用Scanner,程式碼可以改為:
```java
public static List<Student> readStudents() throws IOException{
    BufferedReader reader = new BufferedReader(
            new FileReader("students.txt"));
    try{
        List<Student> students = new ArrayList<Student>();
        String line = reader.readLine();
        while(line!=null){
            Student s = new Student();
            Scanner scanner = new Scanner(line).useDelimiter(",");
            s.setName(scanner.next());
            s.setAge(scanner.nextInt());
            s.setScore(scanner.nextDouble());
            students.add(s);
            line = reader.readLine();
        }
        return students;
    }finally{
        reader.close();
    }
}
```
## 標準流

我們之前一直在使用System.out向螢幕上輸出,它是一個PrintStream物件,輸出目標就是所謂的"標準"輸出,經常是螢幕。除了System.out,Java中還有兩個標準流,System.in和System.err。

System.in表示標準輸入,它是一個InputStream物件,輸入源經常是鍵盤。比如,從鍵盤接受一個整數並輸出,程式碼可以為:
```java
Scanner in = new Scanner(System.in);
int num = in.nextInt();
System.out.println(num);
```
System.err表示標準錯誤流,一般異常和錯誤資訊輸出到這個流,它也是一個PrintStream物件,輸出目標預設與System.out一樣,一般也是螢幕。

標準流的一個重要特點是,它們可以<span style="color:blue">重定向</span>,比如可以重定向到檔案,從檔案中接受輸入,輸出也寫到檔案中。在Java中,可以使用System類的setIn, setOut, setErr進行重定向,比如:
```java
System.setIn(new ByteArrayInputStream("hello".getBytes("UTF-8")));
System.setOut(new PrintStream("out.txt"));
System.setErr(new PrintStream("err.txt"));

try{
    Scanner in = new Scanner(System.in);
    System.out.println(in.nextLine());
    System.out.println(in.nextLine());
}catch(Exception e){
    System.err.println(e.getMessage());
}
```
標準輸入重定向到了一個ByteArrayInputStream,標準輸出和錯誤重定向到了檔案,所以第一次呼叫in.nextLine就會讀取到"hello",輸出檔案out.txt中也包含該字串,第二次呼叫in.nextLine會觸發異常,異常訊息會寫到錯誤流中,即檔案err.txt中會包含異常訊息,為"No line found"。

在實際開發中,經常需要重定向標準流。比如,在一些自動化程式中,經常需要重定向標準輸入流,以從檔案中接受引數,自動執行,避免人手工輸入。在後臺執行的程式中,一般都需要重定向標準輸出和錯誤流到日誌檔案,以記錄和分析執行的狀態和問題。

在Linux系統中,<span style="color:blue">標準輸入輸出流也是一種重要的協作機制</span>。很多命令都很小,只完成單一功能,實際完成一項工作經常需要組合使用多個命令,它們協作的模式就是通過標準輸入輸出流,每個命令都可以從標準輸入接受引數,處理結果寫到標準輸出,這個標準輸出可以連線到下一個命令作為標準輸入,構成管道式的處理鏈條。比如,查詢一個日誌檔案access.log中"127.0.0.1"出現的行數,可以使用命令:
```
cat access.log | grep 127.0.0.1 | wc -l
```
有三個程式cat, grep, wc,|是管道符號,它將cat的標準輸出重定向為了grep的標準輸入,而grep的標準輸出又成了wc的標準輸入。

## 實用方法

可以看出,字元流也包含了很多的類,雖然很靈活,但對於一些簡單的需求,卻需要寫很多程式碼,實際開發中,經常需要將一些常用功能進行封裝,提供更為簡單的介面。下面我們提供一些實用方法,以供參考。

### 拷貝

拷貝Reader到Writer,程式碼為:
```java
public static void copy(final Reader input,
        final Writer output) throws IOException {
    char[] buf = new char[4096];
    int charsRead = 0;
    while ((charsRead = input.read(buf)) != -1) {
        output.write(buf, 0, charsRead);
    }
}
```
### 將檔案全部內容讀入到一個字串

引數為檔名和編碼型別,程式碼為:
```java
public static String readFileToString(final String fileName,
        final String encoding) throws IOException{
    BufferedReader reader = null;
    try{
        reader = new BufferedReader(new InputStreamReader(
                new FileInputStream(fileName), encoding));
        StringWriter writer = new StringWriter();
        copy(reader, writer);
        return writer.toString();
    }finally{
        if(reader!=null){
            reader.close();
        }
    }
}
```
這個方法利用了StringWriter,並呼叫了上面的拷貝方法。

### 將字串寫到檔案

引數為檔名、字串內容和編碼型別,程式碼為:
```java
public static void writeStringToFile(final String fileName,
        final String data, final String encoding) throws IOException {
    Writer writer = null;
    try{
        writer = new OutputStreamWriter(new FileOutputStream(fileName), encoding);
        writer.write(data);
    }finally{
        if(writer!=null){
            writer.close();
        }
    }
}
```
### 按行將多行資料寫到檔案

引數為檔名、編碼型別、行的集合,程式碼為:
```java
public static void writeLines(final String fileName,
        final String encoding, final Collection<?> lines) throws IOException {
    PrintWriter writer = null;
    try{
        writer = new PrintWriter(fileName, encoding);
        for(Object line : lines){
            writer.println(line);
        }
    }finally{
        if(writer!=null){
            writer.close();
        }
    }
}
```
### 按行將檔案內容讀到一個列表中

引數為檔名、編碼型別,程式碼為:
```java
public static List<String> readLines(final String fileName,
        final String encoding) throws IOException{
    BufferedReader reader = null;
    try{
        reader = new BufferedReader(new InputStreamReader(
                new FileInputStream(fileName), encoding));
        List<String> list = new ArrayList<>();
        String line = reader.readLine();
        while(line!=null){
            list.add(line);
            line = reader.readLine();
        }
        return list;
    }finally{
        if(reader!=null){
            reader.close();
        }
    }
}
```
Apache有一個類庫Commons IO,裡面提供了很多簡單易用的方法,實際開發中,可以考慮使用。

## 小結

本節我們介紹瞭如何在Java中以字元流的方式讀寫文字檔案,我們強調了二進位制思維、文字文字與二進位制檔案的區別、編碼、以及字元流與位元組流的不同,我們介紹了個各種字元流、Scanner以及標準流,最後總結了一些實用方法。

寫檔案時,可以優先考慮PrintWriter,因為它使用方便,支援自動緩衝、支援指定編碼型別、支援型別轉換等。讀檔案時,如果需要指定編碼型別,需要使用InputStreamReader,不需要,可使用FileReader,但都應該考慮在外面包上緩衝類BufferedReader。

通過上節和本節,我們應該可以從容的讀寫檔案內容了,但檔案本身的操作,如檢視後設資料資訊、重新命名、刪除,目錄的操作,如遍歷檔案、查詢檔案、新建目錄等,又該如何進行呢?讓我們下節繼續探索。

------
未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

![](https://lc-gold-cdn.xitu.io/475ed6bd9976ad39e829.jpg)複製程式碼

相關文章