argius note

プログラミング関連

Javaで1行ずつテキストデータを読み込むイディオムの変遷

テキストデータを読み込む場合、言語にかかわらず1行ずつ読み込んで処理します。ここではこのイディオムを"read-lines"と呼ぶことにします。


スクリプト言語なんかだと大抵、最初からforeachで済んでしまっていたりしますが、初期のJavaではちょっと面倒な手順が必要でした。
新しいバージョンではだいぶ簡単簡潔に書けるようになりましたが、今度はたくさんありすぎて、どれがなんなのかが良くわからなくなっています(大袈裟)。


そこで今回は、"read-lines"の変遷についてまとめてみました。
最近Javaを始めた方がバージョンが古い時に書かれたソースコードを読むときに役に立つ...かも知れません。



目次

  • 基本
  • BufferedReader (Java1.1~)
  • Scannerと拡張for文 (Java5~)
  • java.nio.file.Filesとtry-with-resources(Java7~)
  • Streamとラムダ式 (Java8~)
  • 付録:標準入力の"read-lines"


※Java1.1より前については調べていません。
※本文中の標準APIJavadocへのリンクは、すべてJava8のものにリンクしています。


基本

Javaでは、入力ソースを扱うクラスとして、主にjava.io.InputStreamjava.io.Readerという2つの基本クラスがあります。InputStreamはバイトストリームの入力、Readerは文字ストリームの入力を行うクラスです。いずれも入力ストリームを表します。



以下の項では、小文字のアルファベットで書いたテキストファイルを読み込み、逐次その内容のアルファベットを大文字に変換する処理を書きます。

  • 入力データ(files.txt)

aaa
bbb
ccc

  • 実行結果の例

AAA
BBB
CCC


BufferedReader (Java1.1~)

Java5より前のバージョンでは、read-linesする一般的な方法はjava.io.BufferedReaderで入力文字ストリームをラップするものでした。

機能的には十分なんですけれど、簡潔さに欠けますね。
ちなみに、対応するReaderがない場合は、InputStreamReaderでラップする必要があります。

  • 例:BufferedReaderを使ったread-lines

// import java.io.*;

try {
    BufferedReader br = new BufferedReader(new FileReader("file.txt"));
    try {
        while (true) {
            String line = br.readLine();
            if (line == null) {
                break;
            }
            System.out.println(line.toUpperCase());
        }
    } finally {
        br.close();
    }
} catch (IOException e) {
    e.printStackTrace();
}



Scannerと拡張for文 (Java5~)

Java5では、java.util.Scannerという、よりread-linesをしやすいクラスが追加されました。

Scannerには、複数の入力ソースに対応したコンストラクターが用意されています。
Stringが引数のコンストラクターもあります。FileInputStreamなどのクラスから類推すると、文字列はファイルパスかと思ってしまい紛らわしいのですが、Scannerの場合、文字列はそれ自体が入力ソースです。

  • 例:Scannerを使ったread-lines

// import java.io.*;
// import java.util.Scanner;

Scanner scanner = new Scanner(new File("file.txt")); // throws java.io.FileNotFoundException
try {
    while (scanner.hasNextLine()) {
        String line = scanner.nextLine();
        System.out.println(line.toUpperCase());
    }
} finally {
    scanner.close();
}

// 文字列自体を入力にする
Scanner scanner2 = new Scanner("aaa\nbbb\nccc");

// 以下略




ScannerIteratorインターフェイスを実装しているので、イテレーターとして使うこともできます。

// import java.util.*; // Iterator, Scanner

Scanner scanner = new Scanner("aaa\nbbb\nccc");
try {
    Iterator<String> it = scanner; // わざわざ代入しなくても良い
    while (it.hasNext()) {
        String line = it.next();
        System.out.println(line.toUpperCase());
    }
} finally {
    scanner.close();
}


でも、これだと直接使うのと変わらないのであまり面白くありません。
どうせなら(?)直接、拡張for文で使ってみたいですね。
ただそれにはIterableインターフェイスを実装している必要があります。

あまり得することはありませんが、いくつかの例を書いておきます。

  • 例:ScannerIterableに変換して拡張for文を使う

// import java.util.*; // Iterator, Scanner

Scanner scanner = new Scanner("aaa\nbbb\nccc");
try {
    final Iterator<String> it = scanner; // 外側のを直接finalにしてもOK
    Iterable<String> iterable = new Iterable<String>() {
        // @Override // ※インターフェイスのメソッドに@OverrideをつけられるのはJava6から
        public Iterator<String> iterator() {
            return it;
        }
    };
    for (String line : iterable) {
        System.out.println(line.toUpperCase());
    }
} finally {
    scanner.close();
}



Apache Commons Collections4(バージョン4)のIteratorUtilsというクラスを使うともっと簡潔に書けます。

  • 例:Commons Collections4のIteratorUtils.asIterableを使ってScannerIterableに変換

// import java.util.Scanner;
// import org.apache.commons.collections4.IteratorUtils;

Scanner scanner = new Scanner(new File("file.txt")); // throws java.io.FileNotFoundException
try {
    for (String line : IteratorUtils.asIterable(scanner)) {
        System.out.println(line.toUpperCase());
    }
} finally {
    scanner.close();
}



java.nio.file.Filesとtry-with-resources(Java7~)

Java7では、New I/O 2の機能のひとつとして、java.nio.file.Filesクラスが追加されました。このクラスはユーティリティークラスです。

Java7では他にも、言語機能にtry-with-resourcesが追加されています。これは、直接read-linesとは関係ありませんが、read-linesのコードが少し簡潔に書けるようになります。特に、コンストラクターでは無くファクトリーからインスタンスを生成する場合は、if (in != null) in.close;のようなコードを書かなくてはいけなくなりますので、try-with-resourcesがあればそれを考えなくても良くなります。

  • 例:Files.newBufferedReaderとtry-with-resourcesでread-lines

// import java.io.*;
// import java.nio.file.*;

// (追記)Java7の時点ではCharsetは必須 Java8では第2引数を省略してUTF_8をデフォルトとするオーバーロードメソッドが追加された
try (BufferedReader br = Files.newBufferedReader(Paths.get("file.txt"), StandardCharsets.UTF_8)) {
    // 自動close brがnullでもOK
    while (true) {
        String line = br.readLine();
        if (line == null) {
            break;
        }
        System.out.println(line.toUpperCase());
    }
}
catch (IOException e) {
    e.printStackTrace();
}




Filesクラスには他にも、1行ずつ読み込むread-linesとは違いますが、全ての行をListとして返すFiles.readAllLinesというメソッドもあります。
ただし、一度にすべてのデータをArrayListに読み込んでしまうので、大きなサイズのファイルの読み込みには向きません。ドキュメントにもそう書いてあります。


Streamとラムダ式+関数型インターフェイス (Java8~)

Java8では、Stream APIと言語機能としてラムダ式と関数型インターフェイスが追加されました。
Streamはread-linesにぴったりの機能なわけですが、ラムダ式+関数型インターフェイスと組み合わせることで真価を発揮します。

Filesクラスには、Streamに対応したFiles.linesメソッドが追加されています。また、同類の機能としてBufferedReader#linesメソッドが追加されています。

  • 例:Files.linesでread-lines

// import java.io.IOException;
// import java.util.stream.Stream;
// import java.nio.file.*; // Files, Paths;

try (Stream<String> a = Files.lines(Paths.get("file.txt"))) { // 自動close
    a.map(String::toUpperCase).forEach(System.out::println);
} catch (IOException e) {
    e.printStackTrace();
}



付録:標準入力の"read-lines"

標準入力は"read-lines"を使って読み取るケースが多いです。そのため、ここまで書いてきた方法をそのまま使うことはできますが、ひとつ気を付けなければいけないことがあります。

それは、「クローズしてはいけない」ということです。

標準入力からの読み込みは、System.in (java.lang.Systemクラスのstaticフィールド)をInputStreamとして
BufferedReaderを使って書くと、こうなります。

// import java.io.*;

try {
    BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
    try {
        while (true) {
            String line = br.readLine();
            // 入力せずENTER
            if (line == null || line.length() == 0) {
                break;
            }
            System.out.println(line.toUpperCase());
        }
    } finally {
        br.close();
    }
    System.in.read(); // => java.io.IOException: Stream closed
} catch (IOException e) {
    e.printStackTrace();
}


BufferedReader#closeから間接的にSystem.inのストリームを閉じてしまうと、次からは標準入力を読み込むことができなくなってしまいます。

回避方法のうちひとつは、単にクローズしないようにするだけですが、同じPrintStreamなのにクローズするかしないかを選択しなければいけないというのは、どうも腑に落ちません。
それに、コンパイラースイッチのpotential resource leakに引っかかってしまうのでちょっと鬱陶しいのです。


別の方法のひとつは、Apache Commons IOのCloseShieldInputStreamを使うものです。
System.inを更にCloseShieldInputStreamでラップすれば、外側でcloseした場合でもいちばん内側のSystem.inまでcloseが伝播しません。

  • 例:CloseShieldInputStreamSystem.inがcloseされるのを防ぐ

// import java.io.*;
// import org.apache.commons.io.input.CloseShieldInputStream;

try {
    BufferedReader br = new BufferedReader(new InputStreamReader(new CloseShieldInputStream(System.in)));
    try {
        while (true) {
            String line = br.readLine();
            // 入力せずENTER
            if (line == null || line.length() == 0) {
                break;
            }
            System.out.println(line.toUpperCase());
        }
    } finally {
        br.close();
    }
    System.in.read(); // 入力せずENTER => OK
} catch (IOException e) {
    e.printStackTrace();
}


Scannerの場合も同様に使えます。


このアイディアは、下記のページで知りました。



(おわり)