BufferedReader と Stream API
BufferedReader と Stream API とは
BufferedReader の発想を引き継ぎつつ、Files.lines で 1 行ずつ Stream
としてファイルを読み込み、Stream API で集計するモダンな書き方を学ぼう。
「1 行ずつ読む」を Stream で書き直す
Java でテキストファイルを 1 行ずつ読みたいとき、ひと昔前までは BufferedReader を try-with-resources で開いて、readLine が null を返すまで while で回す、という書き方が王道でした。コードはこんなイメージです。
Java
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class OldStyle {
public static int sum(Path p) throws IOException {
int total = 0;
try (BufferedReader br = Files.newBufferedReader(p)) {
String line;
while ((line = br.readLine()) != null) {
if (line.isEmpty()) continue;
total += Integer.parseInt(line);
}
}
return total;
}
}動作は問題ありませんが、Java 8 以降を使っているなら、これは Files.lines と Stream API を組み合わせて、もっとスッキリ書けます。
Java
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
public class NewStyle {
public static int sum(Path p) throws Exception {
try (Stream<String> lines = Files.lines(p)) {
return lines
.filter(s -> !s.isEmpty())
.mapToInt(Integer::parseInt)
.sum();
}
}
}見比べると、ループも一時変数も消え、「空行を除いて、整数に変換して、合計する」という意図がそのままコードになっています。本レッスンでは、この「BufferedReader から Files.lines へ」という発想の橋渡しをじっくり追いかけていきます。
BufferedReader自体が古いわけではありません。Files.linesは内部でBufferedReaderを使って 1 行ずつ読み込むので、両者は対立する存在ではなく「同じ仕組みの違う見せ方」です。違いは、要素をwhileで取り出すか、Streamとして宣言的に流すかだけです。
BufferedReader と Files.lines の関係
Files.lines(Path) の戻り値は Stream<String> です。内部実装を覗くと、ちゃんと Files.newBufferedReader(Path) で BufferedReader を開いて、それを Spliterator でラップして Stream にしているだけ、というシンプルな構造になっています。
つまり、利用者から見ると「1 行 = Stream の 1 要素」というシンプルな抽象になります。バッファリングや改行コードの判定 (\n \r\n) は BufferedReader が裏でやってくれるので、Stream 側のコードでは意識する必要がありません。
Files.readAllLines(Path)という似たメソッドもありますが、こちらはList<String>を一気に作るので、巨大ファイルではメモリを食いつぶす危険があります。Files.linesなら遅延読み込みなので、ファイルが何GBあっても 1 行分のメモリで処理できます。OutOfMemoryErrorを避ける意味でも、迷ったらFiles.linesを選ぶのが安全です。
Stream<String> を mapToInt で IntStream に
ファイルから 1 行ずつ取り出した String を、整数として合計するには、まず IntStream に変換する必要があります。そのための定番が mapToInt です。
Java
IntStream nums = lines.mapToInt(Integer::parseInt);mapToInt は Stream<T> の特殊な変換メソッドで、引数に「T を受け取って int を返す関数」を渡すと、戻り値が IntStream (プリミティブの int を流すストリーム) になります。IntStream には sum average max min といった集計用メソッドが標準で生えているので、Stream<Integer> のままで reduce を書くより簡潔に集計できます。
Integer::parseInt の部分は メソッド参照 と呼ばれる書き方で、s -> Integer.parseInt(s) というラムダ式と完全に同じ意味です。String を 1 つ受け取って int を返す静的メソッドの「型」が mapToInt の引数の型 (ToIntFunction<String>) と一致するので、こうして名前を渡すだけで使えるわけです。
Java
// 同じ動き
.mapToInt(s -> Integer.parseInt(s))
.mapToInt(Integer::parseInt)メソッド参照には大きく分けて 4 種類あります。
ClassName::staticMethod(Integer::parseInt)、instance::method(System.out::println)、ClassName::instanceMethod(String::length)、ClassName::new(コンストラクタ参照)。filterやmapを書くたびにラムダ式と見比べて、置き換えられるかを意識すると自然に身についていきます。
try-with-resources で Stream を閉じる
Stream というと「ただのデータの流れ」というイメージで、close が必要なものに見えないかもしれません。しかし Files.lines が返す Stream<String> は、裏で BufferedReader を握り続けていて、そこから OS のファイルディスクリプタを 1 つ消費しています。閉じ忘れるとリソースリークになります。
そのため Files.lines を使うときは、必ず try-with-resources で囲むのが鉄則です。
Java
try (Stream<String> lines = Files.lines(path)) {
return lines.mapToInt(Integer::parseInt).sum();
}try の ( ) の中で宣言された変数 (今回は lines) は、ブロックを抜けるときに自動で close() が呼ばれます。例外が出ようが正常終了しようが、必ず閉じてくれるので安心です。
Stream インタフェースは AutoCloseable を実装しているので、try-with-resources の対象として使えます。逆に言えば、stream() から始めた普通の Stream (たとえば list.stream()) は閉じる必要がないので、わざわざ try-with-resources で囲む必要はありません。I/O リソースを背負った Stream のときだけ閉じる、と覚えておけば十分です。
図のように、今回の課題では「content 文字列をいったん一時ファイルに書き出してから、その一時ファイルを Files.lines で 1 行ずつ読む」という流れになります。テスト用に文字列で渡したデータを、実ファイル経由で Stream 処理する練習だと思ってください。
一時ファイルの作り方
一時ファイルは Files.createTempFile("prefix", ".suffix") で作るのが一番カンタンです。
Java
import java.nio.file.Files;
import java.nio.file.Path;
Path tmp = Files.createTempFile("sum", ".txt");
Files.writeString(tmp, content);createTempFile は OS の標準的な一時ディレクトリ (Linux なら /tmp、Windows なら %TEMP%) に空ファイルを作って、その Path を返します。続けて Files.writeString(path, content) で、文字列を UTF-8 でファイルに書き込めます。両方とも java.nio.file.Files クラスのメソッドなので、import java.nio.file.Files; と import java.nio.file.Path; を忘れないようにしてください。
Files.writeStringは Java 11 で追加された便利メソッドです。Java 8 まではFiles.write(path, content.getBytes(StandardCharsets.UTF_8))のようにbyte[]経由で書く必要がありましたが、現代の Java では 1 行で済みます。本レッスンでも Java 11 以降を前提に進めます。
空行をどう扱うか
今回の課題では「空行は無視」というルールがあります。素直に filter(s -> !s.isEmpty()) を挟むだけで OK ですが、いくつか注意点があります。
まず、content が空文字列 "" だったときの挙動です。Files.writeString(tmp, "") で空ファイルを書き、Files.lines(tmp) で開くと、戻ってくる Stream の要素数は 0 です。Integer.parseInt("") が呼ばれる前に終端操作 sum が走るので、結果は 0 になります。filter を入れなくても今回のテストは pass しますが、後述する別パターンに備えて入れておくのが安全策です。
次に、"100\n200" のように末尾に改行がないケース。BufferedReader (および Files.lines) は末尾改行の有無にかかわらず、最後の 1 行も正しく拾ってくれます。逆に "100\n200\n" のように末尾に改行があると、最終要素として空文字列 "" が現れることがあり、そのまま Integer.parseInt を呼ぶと NumberFormatException になります。filter(s -> !s.isEmpty()) を挟んでおけば、こうした末尾改行付きの入力でも安全に処理できます。
よくある間違い
初学者が Files.lines まわりで踏みがちな落とし穴を 3 つ紹介します。
Streamを閉じ忘れる —Files.lines(p).mapToInt(...).sum()のようにワンライナーで書いてしまうと、Streamがcloseされずファイルディスクリプタがリークします。少量なら気づきにくいですが、ループの中で何回も呼ぶと「Too many open files」エラーで突然落ちます。必ずtry (Stream<String> lines = Files.lines(p)) { ... }の形にしましょう- 空行のパースで
NumberFormatException—Integer.parseInt("")は実行時例外を投げます。改行コードの違いや末尾改行で空文字列が混ざることがあるので、filter(s -> !s.isEmpty())を挟むか、s -> s.trim().isEmpty()のように空白だけの行も弾く処理を入れると安心です - エンコーディングの指定を忘れる —
Files.lines(p)は引数 1 つだとUTF-8を仮定します。日本語を含むShift_JISのファイルなどを読もうとするとMalformedInputExceptionが出ます。明示的に指定したいときはFiles.lines(p, StandardCharsets.UTF_8)のように 2 引数版を使い、入力がShift_JISならCharset.forName("MS932")を渡します
余談ですが、本格的な業務コードでは
IOExceptionをそのままthrowsするのではなく、独自の例外型に包んで投げ直すパターンも多いです。例外設計は別レッスンに譲りますが、まずは「Files.linesはIOExceptionを投げる可能性がある」という事実だけ覚えておいてください。
やってみよう
それでは今回の課題に挑戦してみましょう。やることは次のとおりです。
Solution.sumOfNumbers(String content)メソッドを完成させるFiles.createTempFile("sum", ".txt")で一時ファイルを作り、Files.writeStringでcontentを書き込むtry-with-resourcesでFiles.lines(path)を開くfilterで空行を除外し、mapToInt(Integer::parseInt)でintに変換し、sum()で合計を返す
戻り値の型は int です。sum() は IntStream の終端操作で、そのまま int を返してくれるのでキャストは不要です。IOException が投げられる可能性があるので、メソッドシグネチャに throws Exception を付けておくのが手軽です。
テストでは sumOfNumbers("1\n2\n3") が 6、sumOfNumbers("10") が 10、sumOfNumbers("") が 0、sumOfNumbers("100\n200") が 300 を返すかを確認します。空文字列のときに 0 が返るのは、Files.writeString で空ファイルが作られ、Files.lines が空の Stream を返すからです。filter で空行を弾いておけば、末尾改行付きのデータが来てもまったく同じコードで動きます。
慣れてきたら、mapToInt(Integer::parseInt) を mapToInt(s -> Integer.parseInt(s.trim())) に変えてみたり、sum を average max min に変えて結果を観察してみたりすると、Stream API の引き出しが一気に増えます。次のレッスンでは、Files.lines で読み込んだ行を Collectors.groupingBy でグループ化する処理に踏み込んでいきます。まずはここで「ファイル → 行 → 数値 → 合計」のパイプラインを完成させて、BufferedReader 時代との違いを体で感じてください。
よくある質問
Q. Stream は for ループより遅いですか?
A. 通常規模ではほぼ同等で、可読性のメリットが上回ります。並列ストリーム(parallelStream)にすると CPU コアを活用できますが、データ量が多い純粋な計算でないとオーバーヘッドの方が大きくなるので注意してください。
Q. 中間操作と終端操作の違いは?
A. filter/map など中間操作は遅延評価で Stream を返すだけで、終端操作(collect、forEach、count 等)が呼ばれて初めて実行されます。終端操作なしの Stream は何もしないため、リファクタ時に「結果が返らない」バグを起こさないよう注意しましょう。
Q. Stream を 2 回使えますか?
A. 使えません。Stream は 1 度しか走査できないため、再利用したい場合は List に collect しておくか、Supplier<Stream<...>> として何度も生成する関数を持っておきます。「IllegalStateException: stream has already been operated upon」が典型エラーです。
次のレッスン
次は ファイルの存在確認と削除 で、ファイルの存在確認と削除 を学びます。
事前確認 — 進む前に次の 3 つができることを確認しましょう。
- BufferedReader の要点を自分の言葉で説明できる
- このレッスンの最小コード (または操作手順) を見ずに書ける
- 練習問題やクイズで間違えた箇所を読み直して理解した
理解度チェック (30 秒)
Q. BufferedReader とは何か、1 文で説明してください。
A. 本文の「このレッスンで分かること」または冒頭の説明文を見直し、自分の言葉で要約できれば OK。詰まったら本レッスンの最初の H2 セクションを読み返してみましょう。
関連レッスン
要件
Files.createTempFileとFiles.writeStringでcontentを一時ファイルに書き出すことtry-with-resourcesでFiles.lines(path)を開き、抜けるときにStreamを自動で閉じることfilterで空行を除外し、mapToInt(Integer::parseInt)とsum()を使ってintの合計を返すこと
入出力例
test-cases.txt
sumOfNumbers("1
2
3") → 6
sumOfNumbers("10") → 10
sumOfNumbers("") → 0
sumOfNumbers("100
200") → 300
sumOfNumbers("1
2
3
") → 6
sumOfNumbers("5
7
8") → 20