BufferedReader と Stream API

生田 陸人
LuaGate エンジニア / 現役エンジニア
編集 LuaGate編集部

BufferedReader と Stream API とは

BufferedReader の発想を引き継ぎつつ、Files.lines で 1 行ずつ Stream としてファイルを読み込み、Stream API で集計するモダンな書き方を学ぼう。

「1 行ずつ読む」を Stream で書き直す

Java でテキストファイルを 1 行ずつ読みたいとき、ひと昔前までは BufferedReadertry-with-resources で開いて、readLinenull を返すまで 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.linesStream 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 として宣言的に流すかだけです。

BufferedReaderFiles.lines の関係

Files.lines(Path) の戻り値は Stream<String> です。内部実装を覗くと、ちゃんと Files.newBufferedReader(Path)BufferedReader を開いて、それを Spliterator でラップして Stream にしているだけ、というシンプルな構造になっています。

diagram (will load when visible)

つまり、利用者から見ると「1 行 = Stream の 1 要素」というシンプルな抽象になります。バッファリングや改行コードの判定 (\n \r\n) は BufferedReader が裏でやってくれるので、Stream 側のコードでは意識する必要がありません。

Files.readAllLines(Path) という似たメソッドもありますが、こちらは List<String> を一気に作るので、巨大ファイルではメモリを食いつぶす危険があります。Files.lines なら遅延読み込みなので、ファイルが何 GB あっても 1 行分のメモリで処理できます。OutOfMemoryError を避ける意味でも、迷ったら Files.lines を選ぶのが安全です。

Stream<String>mapToIntIntStream

ファイルから 1 行ずつ取り出した String を、整数として合計するには、まず IntStream に変換する必要があります。そのための定番が mapToInt です。

Java

IntStream nums = lines.mapToInt(Integer::parseInt);

mapToIntStream<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 (コンストラクタ参照)。filtermap を書くたびにラムダ式と見比べて、置き換えられるかを意識すると自然に身についていきます。

try-with-resourcesStream を閉じる

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 のときだけ閉じる、と覚えておけば十分です。

diagram (will load when visible)

図のように、今回の課題では「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 なら /tmpWindows なら %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() のようにワンライナーで書いてしまうと、Streamclose されずファイルディスクリプタがリークします。少量なら気づきにくいですが、ループの中で何回も呼ぶと「Too many open files」エラーで突然落ちます。必ず try (Stream<String> lines = Files.lines(p)) { ... } の形にしましょう
  • 空行のパースで NumberFormatExceptionInteger.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.linesIOException を投げる可能性がある」という事実だけ覚えておいてください。

やってみよう

それでは今回の課題に挑戦してみましょう。やることは次のとおりです。

  1. Solution.sumOfNumbers(String content) メソッドを完成させる
  2. Files.createTempFile("sum", ".txt") で一時ファイルを作り、Files.writeStringcontent を書き込む
  3. try-with-resourcesFiles.lines(path) を開く
  4. filter で空行を除外し、mapToInt(Integer::parseInt)int に変換し、sum() で合計を返す

戻り値の型は int です。sum()IntStream の終端操作で、そのまま int を返してくれるのでキャストは不要です。IOException が投げられる可能性があるので、メソッドシグネチャに throws Exception を付けておくのが手軽です。

テストでは sumOfNumbers("1\n2\n3")6sumOfNumbers("10")10sumOfNumbers("")0sumOfNumbers("100\n200")300 を返すかを確認します。空文字列のときに 0 が返るのは、Files.writeString で空ファイルが作られ、Files.lines が空の Stream を返すからです。filter で空行を弾いておけば、末尾改行付きのデータが来てもまったく同じコードで動きます。

慣れてきたら、mapToInt(Integer::parseInt)mapToInt(s -> Integer.parseInt(s.trim())) に変えてみたり、sumaverage 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 つができることを確認しましょう。

  1. BufferedReader の要点を自分の言葉で説明できる
  2. このレッスンの最小コード (または操作手順) を見ずに書ける
  3. 練習問題やクイズで間違えた箇所を読み直して理解した

理解度チェック (30 秒)

Q. BufferedReader とは何か、1 文で説明してください。

この章のポイント

A. 本文の「このレッスンで分かること」または冒頭の説明文を見直し、自分の言葉で要約できれば OK。詰まったら本レッスンの最初の H2 セクションを読み返してみましょう。

関連レッスン

要件

  1. Files.createTempFileFiles.writeStringcontent を一時ファイルに書き出すこと
  2. try-with-resourcesFiles.lines(path) を開き、抜けるときに Stream を自動で閉じること
  3. 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

ヒント

main.java
main.java
学習モード

メモ

BufferedReader と Stream API

⌘S で保存