Java8 で Stream を使ってみる

こんにちは、エンジニアの宮坂です。

Java8 がリリースされて1年以上が経ちました。
Java7 のサポートが4月末で終了し、Java8 への移行も徐々に進んでいるかと思います。

今更ながら、Java8 の新機能 Stream のポイントと気付いた点など書いてみたいと思います。
 

Stream とは

Stream は簡単に言うと、データ集合に対する操作を流れるように記述して処理してしまおうという代物です。

例えば、以下のようなファイルを読み込んで3の倍数だけ文字列に置換します。

# コメント
1
2
3
4
5
6
7
8
9
10
# 終わり
Path path = [ファイルパス];
try (Stream<String> lines = Files.lines(path)) {
    lines.filter(line -> !line.startsWith("#"))
         .mapToInt(Integer::parseInt)
         .mapToObj(i -> (i % 3 == 0 ? "xxx" : String.valueOf(i)))
         .forEach(System.out::println);
}
1
2
xxx
4
5
xxx
7
8
xxx
10

Stream の最大のメリットは、for や if などの処理を制御するコードの記述を省けることです。
データ処理に注目することができ、処理の意図がわかりやすいシンプルで見やすいコードになります。
 

Stream は「夏休みの宿題を最後までやらない派」

Stream を使った場合、以下のような流れで処理を記述します。

1.データソースから Stream を作成
2.中間操作で各要素を加工
3.終端操作で結果を取得

Stream のメソッドの中でどれが中間操作かどれが終端操作かは、乱暴に区別すると、Stream を返すメソッドが中間操作、それ以外を返すの終端操作になります。

Stream の処理の考え方は、「今やらなくてよいことは今やらない」という怠け者の考え方で、小学生が8/31に必死に夏休みの宿題をやるがごとく、中間操作のメソッドをコールした時点ではその処理を実行せずタスクを積んでいって、終端操作の実行時にまとめて処理されます。

上記の例だと、終端処理 forEach() でそれぞれの要素に filter()map() が実行されます。

今まで for や if を駆使してコレクションの処理をしていた人は、遅延処理は初めてだとちょっと戸惑うこともあるかもしれません。

また、一回終端操作を行った Stream を再利用できません。「もう宿題は2度とやりたくない!」ので例外が投げられます。

try (Stream<String> lines = Files.newBufferedReader(path).lines()) {
    IntStream s = lines.filter(line -> !line.startsWith("#"))
                       .mapToInt(Integer::parseInt);
    // 3の倍数を置き換えたい
    s.mapToObj(i -> (i % 3 == 0 ? "xxx" : String.valueOf(i)))
     .forEach(System.out::println);
    // 5の倍数を置き換えたいが、エラー
    s.mapToObj(i -> (i % 5 == 0 ? "xxx" : String.valueOf(i)))
     .forEach(System.out::println); 
}
1
2
xxx
4
5
xxx
7
8
xxx
10
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed

 

Collectors を駆使する

終端操作 collect() で Stream 処理結果を様々なオブジェクトに変換できます。よく使われる処理が Collectors にまとめられています。ラムダ式のオンパレードに最初は驚きますが、使い方がわかれば便利です。

文字列リストをデリミタで連結する (joining())

例) 「,」で連結して「()」でくくる

Java7 で書くとこんな感じ。

String[] array = new String[] { "a", "b", "c" };
String delim = "";
StringBuilder b = new StringBuilder("(");
for (String s : array) {
    b.append(delim).append(s);
    delim = ",";
}
b.append(")");
String csv = b.toString();

↓ Java8 で書き換えます。

Stream.of("a", "b", "c")
      .collect(Collectors.joining(",", "(", ")"));

文字列リストをルールに従って分類する (grouping())

例) 先頭文字で文字列を分類

Java7 で書くとこんな感じ。

List<String> list = Arrays.asList("aaa", "bbb", "bcc", "ccc", "xxx");
Map<String, List<String>> map = new HashMap<>();
for (String e : list) {
    if (e.isEmpty()) {
        continue;
    }
    String key = e.substring(0, 1); // キーを作る
    List<String> value = map.get(key);
    if (value == null) {
        value = new ArrayList<>();
        map.put(key, value);
    }
    value.add(e);
}

↓ null チェックとか新しいリストを作ってとか面倒ですが、Java8 で書くとこれだけに。

map = list.stream()
          .filter(s -> !s.isEmpty())
          .collect(Collectors.groupingBy(s -> s.substring(0, 1)));

せっかくだからキーの辞書順にならんだ SortedMap で欲しい (collectingAndThen())

上記の処理は collectingAndThen() で結果をさらに変換処理できます。

Collector<String, ?, Map<String, List<String>>> grouping = 
      Collectors.groupingBy(s -> s.substring(0, 1));
map = list.stream()
          .filter(s -> !s.isEmpty())
          .collect(Collectors.collectingAndThen(grouping, TreeMap::new));

Collector を自由自在に組み合わせるのは、少々頭の体操が必要ですが、よく使う処理は各アプリケーションで作っておいて、共有しておくと便利かと思います。
 

こんなところにも Stream

Stream のファクトリメソッドはいろんなところにあります。
ファイル操作や文字列操作を Stream でというメソッドが多いですね。

Java8 以降は、データソースを取得するような (例えば、WebAPI の取得) 実装を自分で行う場合は、結果を Stream で取得できるような実装も考慮しておくと、Stream の資産を最大限に活用できるので、便利かと思います。
 

Stream の使って引っかかること

ちょっと引っかかった点を挙げておきます。

this が匿名クラスとは違う

ラムダ式は、一つのメソッドを持つインターフェースの実装を匿名クラスで記述して部分を書き換えることができるわけですが、ラムダ式の this と匿名クラスでの this は別物を指すみたいです。

Consumer<String> noName = new Consumer<String>() {
    @Override
    public void accept(String t) {
        System.out.println("匿名クラス: " + this);
    }
};
noName.accept("");

Consumer<String> lambda = s -> System.out.println("ラムダ式: " + this);
lambda.accept("");

System.out.println("呼び出し元: " + this);
匿名クラス: Sample$1@31befd9f
ラムダ式: Sample@2f2c9b19
呼び出し元: Sample@2f2c9b19

ラムダ式の場合は、ラムダ式が定義されたクラスのインスタンスオブジェクトを指します。

Java7 とは、コンパイラや JVM の動きも異なるので、ラムダ式は匿名クラスの単純な文法的な置き換えではないことは注意が必要です。

例外はなんとかならんのか

中間処理内で発生したチェック例外は外にそのまま投げられません。その処理で中断したい場合は、チェック例外を catch して非チェック例外にして投げないといけません。
try – catch を書く必要があるので、見通しも悪くなりますし、何よりコードを書くのが若干億劫になります…。

parallel() で並列処理にしている場合は、全スレッドの終了を待たないで処理が中断されるらしいので注意が必要です。

lines.filter(line -> !line.startsWith("#"))
     .mapToInt(Integer::parseInt)
     .peek(i -> {
         try {
             TimeUnit.SECONDS.sleep(1);
         } catch (InterruptedException e) {
             throw new RuntimeException(e); // もっと適切な例外を投げましょう…
         }
     })
     .mapToObj(i -> (i % 3 == 0 ? "xxx" : String.valueOf(i)))
     .forEach(System.out::println);
}

例えば、IOException の非チェック例外の UncheckedIOExceptionのように、対応する例外があればそれで包んで投げられるんですが、何で包んで投げればいいか迷います…。

例外の扱いについては、扱うデータや処理内容によって試行錯誤が必要そうです。
 

まとめ

今更ながら、Java8 の Stream を振り返ってみました。parallel() で呼ぶだけで並列処理にもなるので、最大限に駆使することによって、データ集合の取り回しをシンプルかつスピーディに実現できるのではないでしょうか。

Java8 では他にも Date-Time APIOptional, CompletableFuture など流れるように記述できる新しい API がたくさん追加されていて、今まで以上にシンプルなコードが書けるように進化しています。
今までは JDK の API をラップしてメソッド一発で処理を完結するようなユーティリティメソッド群をたくさん実装することが多かったと思います。ですが、Java8 の世界では、ラムダ式で処理を差し込めるので、Stream など JDK の API をベースに必要に応じて定義した処理を組み合わせいくやり方がしっくり来る気がしています。処理内容やコードの見通しなど考慮して、どんなコーディングの仕方が良いのか、試行錯誤してみるのも楽しいですね。

「Java8にはまだ行けない…。」という場合は、Guava の FuluentIterable を使って練習しておくと良いかと思います。こちらも便利なライブラリです。
 

最後に

マネーフォワードでは、新しい機能を積極的に取り入れ、サービスとともに自ら進化し続けられるエンジニアを募集しています。

ご応募お待ちしております!

【採用サイト】
『マネーフォワード採用サイト』 https://recruit.moneyforward.com/
『Wantedly』 https://www.wantedly.com/companies/moneyforward

【公開カレンダー】
マネーフォワード公開カレンダー

【プロダクト一覧】
家計簿アプリ・クラウド家計簿ソフト『マネーフォワード』 https://moneyforward.com/
家計簿アプリ・クラウド家計簿ソフト『マネーフォワード』 iPhone,iPad
家計簿アプリ・クラウド家計簿ソフト『マネーフォワード』 Android
クラウド型会計ソフト『MFクラウド会計』 https://biz.moneyforward.com/
クラウド型請求書管理ソフト『MFクラウド請求書』 https://invoice.moneyforward.com/
クラウド型給与計算ソフト『MFクラウド給与』 https://payroll.moneyforward.com/

Pocket