28
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Java Stream API を無頓着に使うのはやめよう

Posted at

はじめに

Java 8で導入されたStream APIはとても便利なので、筆者もふだんから開発で活用しています。
ところが、Stream APIを使うことで逆に可読性が落ちたり、見通しが悪くなっているコードというのもしばしば目にします。
本稿では、可読性が高く見通しの良いコード(クリーンコード)を書くためのテクニックを紹介します。

なお、本稿のソースコードはAdoptOpenJDK 16で動作確認をしました。

こんなコード書いてませんか?

簡単なサンプルとして、以下のレシピ検索サービスの実装を題材にします。

RecipeService.java
// レシピ検索サービス
public class RecipeService {

    private Recipes recipes;

    public RecipeService(Recipes recipes) {
        this.recipes = recipes;
    }

    // 指定した材料を含み、指定した時間(分)内に作れるレシピを、評価の高い順に3つ推薦する
    public List<Recipe> getRecommendation(String ingredientName, int minutes) {
        return recipes.recipes().stream()
                .filter(r -> r.cookingMinutes() <= minutes)
                .filter(r -> r.ingredients().stream().anyMatch(
                     i -> i.name().equals(ingredientName)))
                .sorted(Comparator.comparing(Recipe::stars).reversed())
                .limit(3L).toList();
    }
}

「指定した材料を含み、指定した時間(分)内に作れるレシピを、評価の高い順に3つ推薦する」というアプリケーション仕様をStream APIを使った処理として実装しています。
サンプルなのでそこまで読みにくいということもないのですが、実際のプロダクトコードはもっと複雑になりがちです。
例えば、

  • filterに渡すラムダ式がワンライナーではなく、長いコードブロックとなっている
  • ラムダ式が何重にもネストされている

といった具合で可読性がひどく低下したコードを目にすることがあります。

たしかにStream API/ラムダ式を使って記述した処理は、以前のforループを使ったJavaコードと比べてより宣言的で読みやすさが向上します。しかしStream API/ラムダ式を使っただけではクリーンなコードとは言えません。
以降、サンプルを少しずつリファクタリングしていきましょう。

説明変数

まずはサンプルの以下の部分に注目します。

RecipeService.java
                .filter(r -> r.cookingMinutes() <= minutes)
                .filter(r -> r.ingredients().stream().anyMatch(
                     i -> i.name().equals(ingredientName)))

ネストされているものも含めて3つのラムダ式があります。それぞれ短いものですが、何をする処理なのか、読み手がコードを読み解き理解をする必要があります。
説明変数とは処理結果をローカル変数に代入し、その変数に意図を込めた名前を与えることで、コードの見通しをよくする実装テクニックです。

Javaのラムダ式は関数インタフェースを実装するコード断片なので、対応する関数インタフェース型の変数に代入して使うことができます。
リファクタリングして以下のようになりました。

RecipeService.java
    // 指定した材料を含み、指定した時間(分)内に作れるレシピを、評価の高い順に3つ推薦する
    public List<Recipe> getRecommendation(String ingredientName, int minutes) {
        // 説明変数
        Predicate<Recipe> thatIsCookedInSpecifiedMinutes =
            r -> r.cookingMinutes() <= minutes;
        Predicate<Recipe> thatContainsIngredientWithSpecifiedName =
            r -> r.ingredients().stream()
                  .anyMatch(i -> i.name().equals(ingredientName));

        return recipes.recipes().stream()
                .filter(thatIsCookedInSpecifiedMinutes)
                .filter(thatContainsIngredientWithSpecifiedName)
                .sorted(Comparator.comparing(Recipe::stars).reversed())
                .limit(3L).toList();
    }

Stream APIを使った処理が、変更前より読みやすくなったと思います。thatIs... という述語的な命名をすることで、自然な英語に近い感じで読めるように工夫しました。
sortedに渡しているComparatorにも説明変数を適用してみましょう。

RecipeService.java
    // 指定した材料を含み、指定した時間(分)内に作れるレシピを、評価の高い順に3つ推薦する
    public List<Recipe> getRecommendation(String ingredientName, int minutes) {
        // 説明変数
        Predicate<Recipe> thatIsCookedInSpecifiedMinutes =
            r -> r.cookingMinutes() <= minutes;
        Predicate<Recipe> thatContainsIngredientWithSpecifiedName =
            r -> r.ingredients().stream()
                  .anyMatch(i -> i.name().equals(ingredientName));
        Comparator<Recipe> byStarsDescending =
            Comparator.comparing(Recipe::stars).reversed();

        return recipes.recipes().stream()
                .filter(thatIsCookedInSpecifiedMinutes)
                .filter(thatContainsIngredientWithSpecifiedName)
                .sorted(byStarsDescending)
                .limit(3L).toList();
    }

すっきりしました。
ついでにもう一つリファクタリング。Predicateは合成関数andを持っているので、filter処理は1回にまとめることができます。

RecipeService.java
        return recipes.recipes().stream()
                .filter(thatIsCookedInSpecifiedMinutes
                    .and(thatContainsIngredientWithSpecifiedName))
                .sorted(byStarsDescending)
                .limit(3L).toList();

ドメインオブジェクトに振る舞いを持たせる

次に、処理自体がサービスに記述するのがふさわしいのかを考えます。

**ドメイン駆動設計(DDD)**の考え方では、ビジネス上の重要な知識やルールは、エンティティ、値オブジェクト、ドメインサービスなどのドメイン層のオブジェクトに実装するのがよいとされます。
何が重要な知識やルールかは、そのドメインの専門家との対話を通して見出していく必要がありますが、ここでは仮に「評価の高い順に3つ推薦する」という仕様がそれにあたるとします。

この振る舞いを、Recipes(レシピ集)クラスへ移動させます。

Recipes.java
// レシピ集。子に複数のレシピを持つ
public record Recipes(List<Recipe> recipes) {

    // レシピの推薦
    public List<Recipe> recommend(Predicate<Recipe> bySearchCondition) {
        Comparator<Recipe> byStarsDescending =
            Comparator.comparing(Recipe::stars).reversed();

        return recipes.stream()
                .filter(bySearchCondition)
                .sorted(byStarsDescending)
                .limit(3L).toList();
    }
}

どんな条件でレシピを検索するかはユースケース毎によると考え、Predicate型の引数で渡すようにしました。
また、小さな工夫ですが、メソッド名をgetRecommendationという味気のない名前からrecommendに変更することで、ドメインオブジェクトが生き生きとしてきました(気がします)。

サービス側の実装は以下のように変わります。

RecipeService.java
    // 指定した材料を含み、指定した時間(分)内に作れるレシピを、評価の高い順に3つ推薦する
    public List<Recipe> getRecommendation(String ingredientName, int minutes) {
        // 説明変数
        Predicate<Recipe> thatIsCookedInSpecifiedMinutes =
            r -> r.cookingMinutes() <= minutes;
        Predicate<Recipe> thatContainsIngredientWithSpecifiedName =
            r -> r.ingredients().stream()
                  .anyMatch(i -> i.name().equals(ingredientName));

        return recipes.recommend(
                thatIsCookedInSpecifiedMinutes.and(
                    thatContainsIngredientWithSpecifiedName));
    }

Tell, Don't Ask

ここまででかなりコードの見通しがよくなりましたが、まだ気になる点があります。

RecipeService.java
        Predicate<Recipe> thatContainsIngredientWithSpecifiedName =
                r -> r.ingredients().stream()
                      .anyMatch(i -> i.name().equals(ingredientName));

このラムダ式では、レシピの材料リストを取得して材料名がマッチするものがあるかどうかを判定していますが、ちょっと処理が長く、書き過ぎている感がありますね。

Tell, Don't Askという有名なオブジェクト指向設計原則があります。日本語に訳すと「尋ねるな、命じよ。」です。
何かを達成するために、呼び出し側から細かなデータをオブジェクトに尋ねて処理を行うのではなく、そのデータを持っているオブジェクトに対して何をしてほしいのかを命じよ、というものです。
データを管理するオブジェクトに振る舞いを持たせるという、オブジェクト指向の基本ですね。

Recipe(レシピ)クラスに振る舞いを追加します。

Recipe.java
// レシピ。子に複数の材料を持つ。
public record Recipe(String name, int cookingMinutes,
                     List<Ingredient> ingredients, int stars) {

    public boolean hasAnyIngredients(Predicate<Ingredient> predicate) {
        return ingredients.stream().anyMatch(predicate);
    }
}

サービス側の実装は、この新しく追加されたメソッドを使って以下のようになります。

RecipeService.java
        Predicate<Recipe> thatContainsIngredientWithSpecifiedName =
                r -> r.hasAnyIngredients(i -> i.name().equals(ingredientName));

さらに同様のリファクタリングをいくつか加えて、最終的にサービスの実装は以下となりました。

RecipeService.java
    // 指定した材料を含み、指定した時間(分)内に作れるレシピを、評価の高い順に3つ推薦する
    public List<Recipe> getRecommendation(String ingredientName, int minutes) {
        // 説明変数
        Predicate<Recipe> thatIsCookedInSpecifiedMinutes =
            r -> r.canBeCookedIn(minutes);
        Predicate<Recipe> thatContainsIngredientWithSpecifiedName =
            r -> r.hasAnyIngredients(i -> i.isNamed(ingredientName));

        return recipes.recommend(
                thatIsCookedInSpecifiedMinutes.and(
                    thatContainsIngredientWithSpecifiedName));
    }

まとめ

Java8でのStream API/ラムダ式の導入は画期的なもので、これによりJavaプログラムも関数型指向、宣言的な記述が可能となりました。

一方でその便利さゆえに何も考えずにユースケース層・サービス層で何でもかんでも処理を書いてしまうと、本来ドメインオブジェクトに持たせてしかるべきロジックがドメイン層の外側に漏れ出してしまい、結果としてドメインモデル貧血症というアンチパターンに陥ってしまいます。

振る舞いをどこに置くか、はまさに設計行為そのものです。Stream APIを使う際にもそれを意識するかしないかで設計品質に差が生まれます。また、実装コードに説明変数などのテクニックを用いて可読性を向上することは、その後の保守性担保に寄与します。

本稿でご紹介したテクニックが役立つ場面があれば幸いです。

(参考)ソースコード

リファクタリング後の最終的なソースコードです。

Recipes.java
// レシピ集。子に複数のレシピを持つ
public record Recipes(List<Recipe> recipes) {

    // レシピの推薦
    public List<Recipe> recommend(Predicate<Recipe> bySearchCondition) {
        Comparator<Recipe> byStarsDescending =
            Comparator.comparing(Recipe::stars).reversed();

        return recipes.stream()
                .filter(bySearchCondition)
                .sorted(byStarsDescending)
                .limit(3L).toList();
    }
}
Recipe.java
// レシピ。子に複数の材料を持つ。
public record Recipe(String name, int cookingMinutes,
                     List<Ingredient> ingredients, int stars) {

    public boolean hasAnyIngredients(Predicate<Ingredient> predicate) {
        return ingredients.stream().anyMatch(predicate);
    }

    public boolean canBeCookedIn(int minutes) {
        return cookingMinutes <= minutes;
    }
}
Ingredient.java
// 材料
public record Ingredient(String name) {

    public boolean isNamed(String name) {
        return this.name.equals(name);
    }
}
RecipeService.java
// レシピ検索サービス
public class RecipeService {

    private Recipes recipes;

    public RecipeService(Recipes recipes) {
        this.recipes = recipes;
    }

    // 指定した材料を含み、指定した時間(分)内に作れるレシピを、評価の高い順に3つ推薦する
    public List<Recipe> getRecommendation(String ingredientName, int minutes) {
        // 説明変数
        Predicate<Recipe> thatIsCookedInSpecifiedMinutes =
            r -> r.canBeCookedIn(minutes);
        Predicate<Recipe> thatContainsIngredientWithSpecifiedName =
            r -> r.hasAnyIngredients(i -> i.isNamed(ingredientName));

        return recipes.recommend(
                thatIsCookedInSpecifiedMinutes.and(
                    thatContainsIngredientWithSpecifiedName));
    }
}
28
31
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?