JavaのStreamってなに?for文と比べながらコードを読んでみよう

こんにちは、さるまりんです。

Javaのコードを読んでいると、

articles.stream()
        .filter(Article::published)
        .map(Article::title)
        .toList();

のような書き方を見かけることがあります。

stream()filter()map()toList()

それぞれの単語はなんとなく見たことがあっても、つながって出てくると、最初はちょっと読みにくいですよね。

僕も古いJavaを知っていたので、最初に見たときは、

「これは何を流しているんだろう」

と思いました。

最近は、AIにJavaのコードを書いてもらったときにも、Streamを使ったコードが出てくることがあります。

短く書けて便利です。

けれど、何をしているのかを読めないと、不安です。

今回は、JavaのStreamについて、

「どう書くか」

だけではなく、

「どう読むとわかりやすいか」

を、for文と比べながら見ていきます。

まずはfor文で考えてみます

たとえば、ブログ記事を表すこんなデータがあるとします。

public record Article(String title, String category, boolean published, int views) {
}

title は記事タイトル、category はカテゴリ、published は公開済みかどうか、views は閲覧数です。

この Article のリストを用意します。

List<Article> articles = List.of(
        new Article("Javaのrecordってなに?", "Java", true, 1800),
        new Article("PHPのtry-catchってなに?", "PHP", true, 1500),
        new Article("JavaのStreamってなに?", "Java", false, 0),
        new Article("SQLのEXISTSってなに?", "SQL", true, 2100)
);

ここから、

公開済みで、閲覧数が1000以上の記事タイトルだけを取り出したい

とします。

for文で書くと、こんな感じでしょうか。

List<String> titles = new ArrayList<>();

for (Article article : articles) {
    if (article.published() && article.views() >= 1000) {
        titles.add(article.title());
    }
}

System.out.println(titles);

やっていることは、それほど難しくありません。

1件ずつ記事を見て、

公開済みかどうかを確認する。

閲覧数が1000以上かを確認する。

条件に合っていれば、タイトルをリストに入れる。

という処理です。

これはこれで、とてもわかりやすいです。

では、これをStreamで書くとどうなるでしょうか。

Streamで書いてみます

同じ処理をStreamで書くと、こんな感じになります。

List<String> titles = articles.stream()
        .filter(Article::published)
        .filter(article -> article.views() >= 1000)
        .map(Article::title)
        .toList();

System.out.println(titles);

短くなりました。

ひとまとまりの処理として読める形になっています。

ただ、慣れていないと、短くなったぶんだけ読みにくく感じることもあります。

こういうときは、上から順に日本語にしてみるとわかりやすいです。

articles.stream()

記事のリストを、Streamとして扱う。

.filter(Article::published)

公開済みの記事だけにしぼる。

.filter(article -> article.views() >= 1000)

閲覧数が1000以上の記事だけにしぼる。

.map(Article::title)

記事そのものではなく、タイトルに変換する。

.toList();

最後にリストとして受け取る。

こう読むと、

「記事一覧を流して、条件でしぼって、タイトルに変えて、リストにする」

という流れが見えてきます。

Streamは、魔法の書き方というより、

データの流れを上から順に書いているもの

と見ると、読みやすくなります。

filterは「しぼる」

filter() は、条件に合うものだけを残すためのものです。

たとえば、

.filter(Article::published)

は、

公開済みの記事だけを残す

という意味です。

もう少し普通のラムダ式で書くと、こうなります。

.filter(article -> article.published())

Article::published は、これを短く書いたものです。

最初は少し不思議に見えますが、

article -> article.published()

とだいたい同じことをしている

と考えると読みやすいかもしれません。

次の条件も見てみます。

.filter(article -> article.views() >= 1000)

これは、

閲覧数が1000以上の記事だけを残す

という意味です。

filter() は、リストの中身を別の形に変えるものではありません。

条件に合うものだけを通して、合わないものは通さない。

そんなイメージです。

データが関所を通過して、条件に合うものだけが選ばれていく感じですね。

mapは「形を変える」

次は map() です。

.map(Article::title)

これは、

ArticleString のタイトルに変える

という意味です。

Article からタイトルだけを取っている感じです。

ここが filter() との違いです。

filter() は数をしぼります。

map() は形を変えます。

今回の例では、最初は Article の流れでした。

Article
Article
Article

そこから map(Article::title) を通ると、タイトルの流れになります。

String
String
String

つまり、

記事そのものを使いたいわけではなく、タイトルだけがほしい

というときに map() が使えます。

for文で書いていた、

titles.add(article.title());

の部分に近いですね。

toListで最後に受け取る

最後に出てくるのが toList() です。

.toList();

ここで、Streamの流れをリストとして受け取ります。

Streamは、途中の流れを作るだけでは終わりません。

最後に、

リストにする
件数を数える
1件ずつ表示する
最初の1件を取り出す

といった処理をして、結果を受け取ります。

この最後の処理を、終端操作と呼びます。

たとえば、こんなものがあります。

.toList()
.count()
.forEach(...)
.findFirst()
.anyMatch(...)

名前は少し難しいですが、

最後に結果を取りにいくところ

と考えると、イメージしやすいかもしれません。

Streamは「途中」と「最後」に分けると読みやすいです

Streamの処理は、大きく分けると、

途中の処理

最後の処理

があります。

途中の処理には、たとえば filter()map() があります。

.filter(Article::published)
.map(Article::title)

これらは、Streamの流れを作っていく処理です。

一方で、最後の処理には toList()count()forEach() などがあります。

.toList()
.count()
.forEach(System.out::println)

こちらは、結果を取り出す処理です。

Streamのコードを読むときは、

どこまでが途中の処理か
どこで最後に結果を受け取っているか

を見ると、かなり読みやすくなります。

たとえば、さっきのコードなら、

List<String> titles = articles.stream()
        .filter(Article::published)
        .filter(article -> article.views() >= 1000)
        .map(Article::title)
        .toList();

filter()map() が途中の処理。

toList() が最後の処理。

と分けて読むことができます。

forEachは「最後に1件ずつ使う」

forEach() もよく見かけます。

たとえば、タイトルを画面に出したいだけなら、こんなふうに書けます。

articles.stream()
        .filter(Article::published)
        .map(Article::title)
        .forEach(System.out::println);

これは、

公開済みの記事だけにしぼる
タイトルに変える
1件ずつ表示する

という流れです。

forEach() は、最後に1件ずつ何かをするためのものです。

なので、

新しいリストを作りたい

というときは toList()

表示したい、ログに出したい、1件ずつ処理したい

というときは forEach()

そんなふうに分けておくと、読みやすくなります。

ただし、forEach() の中であれこれ変更する処理を書きすぎると、Streamの良さが少しわかりにくくなることもあります。

その場合は、無理にStreamにせず、for文で書いた方が読みやすいこともあります。

Java 8や11ならcollectもよく見ます

最近のJavaでは、

.toList()

と書けます。

一方で、Java 8やJava 11のコードでは、次のような書き方もよく見ます。

List<String> titles = articles.stream()
        .filter(Article::published)
        .map(Article::title)
        .collect(Collectors.toList());

collect(Collectors.toList()) も、Streamの結果をリストに集める書き方です。

少し長いですが、古いJavaのコードではよく出てきます。

なので、コードを読むときは、

.toList()

.collect(Collectors.toList())

は、どちらも

最後にリストとして受け取っている

と考えるとよさそうです。

ただし、細かい性質は少し違います。

toList() で作ったリストは変更できないリストです。

あとから add() したいような場合は、別の書き方を考える必要があります。

たとえば、簡単にはこう書けます。

List<String> titles = new ArrayList<>(
        articles.stream()
                .filter(Article::published)
                .map(Article::title)
                .toList()
);

titles.add("あとから追加したタイトル");

このあたりは、使っているJavaのバージョンや、あとから変更したいかどうかで選ぶところですね。

なお、今回の例では record を使っています。

Java 8やJava 11のコードでは record は使えないので、普通のclassで同じようなデータを表すことになります。

ここでは、Streamの読み方に集中するために、今のJavaらしい形で例を書いています。

AIが出してきたStreamも、流れで読みましょう

最近は、AIにコードを書いてもらうことも増えました。

たとえば、こんなコードが出てくるかもしれません。

List<String> emails = users.stream()
        .filter(User::active)
        .map(User::email)
        .toList();

これを見たときに、

なんとなく動きそう

で終わらせるのではなく、上から順に読んでみます。

users.stream()
ユーザー一覧をStreamとして扱う。

filter(User::active)
有効なユーザーだけにしぼる。

map(User::email)
ユーザーからメールアドレスだけを取り出す。

toList()
リストとして受け取る。

こう読めると、AIが書いたコードをそのまま使うのではなく、

本当にやりたいことと合っているか

を確認しやすくなります。

たとえば、

退会済みユーザーは含まれていないか。
メールアドレスがnullのときは大丈夫か。
リストにしたあと変更する必要はないか。

そういうところも見えてきます。

Streamを覚えることは、短く書くためだけではなく、

人が書いたコードやAIが出してきたコードを読む力

にもつながるのだと思います。

無理に全部Streamにしなくても大丈夫

ここまでStreamを見てきました。

とても便利です。

ただ、何でもStreamにすればいい、というものでもないと思います。

たとえば、

途中で複雑な条件分岐がある
何行もログを出したい
例外処理を細かく書きたい
変数を更新しながら処理したい

そんなときは、for文の方がわかりやすいこともあります。

Streamは、

データをしぼる
形を変える
集める
数える
どれかに当てはまるか調べる

といった処理と相性がいいです。

一方で、処理の途中でいろんなことをしたくなるなら、for文の方が素直なこともあります。

大事なのは、

Streamを使えるかどうか

ではなく、

この処理はどう書くと読みやすいか

なのだと思います。

短く書けることより、読んで意味がわかること。

ここは、大切なことだと思っています。

まとめ

今回は、JavaのStreamについて、for文と比べながら見てみました。

Streamは、データを流れとして扱う書き方です。

filter() は条件でしぼる。

map() は形を変える。

toList()forEach() は、最後に結果を受け取る。

このように分けて読むと、長くつながったStreamのコードも少し見やすくなります。

最初は、

stream()
filter()
map()
toList()

が並んでいるだけで、ちょっと圧を感じるかもしれません。

でも、上から順に、

何を流しているのか
何でしぼっているのか
何に変えているのか
最後に何として受け取っているのか

を見ていくと、だんだん読めるようになります。

Streamは、短く書くためだけのものではありません。

データの流れを表す書き方でもあります。

今のJavaのコードを読むときにも、AIが出してきたコードを確認するときにも、この読み方を知っていると少し安心です。

無理に全部Streamで書く必要はありません。

for文で書いた方がわかりやすければ、それでいいと思います。

ただ、Streamで書かれたコードに出会ったとき、

ああ、これは流れを作っているんだな

と読めるようになると、Javaのコードがちょっと身近になるかもです。

読んでくださってありがとうございます。

ではまた!