Stream#mapの使い方について考えてみた

【前提条件】

[環境]

【概要】

Stream#mapの使い方でサンプルを作りながら
こんな使い方は動だろうかと考えてみたので書いています。

基本的には最終的なcollectだったり、reduceだったりを行うため、
プリミディブ型に変換することが多くなるのではないかと思います。

個人的にStreamの処理を行う最初に
処理用のクラスをmapでラップするというやり方が
良さそうかなと思いました。

ちょっと複雑な処理をしている例でやってみます。

【サンプルの内容】

ということでまた売り上げ系のネタです。

ある期間の売り上げがあって、
そこから条件を満たす売り上げのポイントを算出するという
サンプルを作ります。

抽出する条件としては春のゲームセール対象売り上げとします。
春のゲームセール対象売り上げとしては

  • 商品種別がゲーム
  • 売り上げ期間が2014/04/05-2014/04/09の間
  • 売り上げ金額が3000円以上

とします。

[データ用クラス]

DBにあるデータをORMで変換したことを想定するクラスを作ります。

public class Sales {

    public final int salesId;
    public final LocalDate date;
    public final ItemType type;
    public final int price;
    public final int count;

    public Sales(final int salesId, final LocalDate date, final ItemType type, final int price, final int count) {

        this.salesId = salesId;
        this.date = date;
        this.type = type;
        this.price = price;
        this.count = count;
    }
}

ItemTypeには商品種別が入ります。PC、GAME、TOOL、PARTSの四つです。

データは下記のように作ります。

    private List<Sales> createSaleList() {

        final List<Sales> sales = new ArrayList<>();
        int salesId = 1;

        sales.add(new Sales(salesId++, LocalDate.of(2014, 4,  1), ItemType.PC, 120000, 1));
        sales.add(new Sales(salesId++, LocalDate.of(2014, 4,  2), ItemType.GAME, 5000, 5));
        sales.add(new Sales(salesId++, LocalDate.of(2014, 4,  3), ItemType.PARTS, 600, 7));
        sales.add(new Sales(salesId++, LocalDate.of(2014, 4,  4), ItemType.TOOL, 7000, 2));

        sales.add(new Sales(salesId++, LocalDate.of(2014, 4,  5), ItemType.GAME, 3000, 1));
        sales.add(new Sales(salesId++, LocalDate.of(2014, 4,  6), ItemType.GAME, 4000, 1));
        sales.add(new Sales(salesId++, LocalDate.of(2014, 4,  7), ItemType.GAME, 2000, 2));

        sales.add(new Sales(salesId++, LocalDate.of(2014, 4,  8), ItemType.GAME, 1000, 2));
        sales.add(new Sales(salesId++, LocalDate.of(2014, 4,  9), ItemType.PC, 120000, 1));
        sales.add(new Sales(salesId++, LocalDate.of(2014, 4, 10), ItemType.GAME, 5000, 5));
        sales.add(new Sales(salesId++, LocalDate.of(2014, 4, 11), ItemType.PARTS, 600, 7));
        sales.add(new Sales(salesId++, LocalDate.of(2014, 4, 12), ItemType.TOOL, 7000, 2));

        return sales;
    }

真ん中のデータ3つが対象となるデータです。

【Streamに直接書く】

まずは個人的にあんまり良いコードではないなぁと思う
Streamに直接書く方法です。

public class Sample {

    public double calculatePoints01() {

        final LocalDate springGameSaleFrom = LocalDate.of(2014, 4, 5);
        final LocalDate springGameSaleTo = LocalDate.of(2014, 4, 9);

        final double result = createSaleList().stream()
            .filter(v -> {

                if (springGameSaleFrom.isAfter(v.date)) {

                    return false;
                }

                if (springGameSaleTo.isBefore(v.date)) {

                    return false;
                }

                if (!ItemType.GAME.equals(v.type)) {

                    return false;
                }

                if (v.price * v.count < 3000) {

                    return false;
                }

                return true;
            })
            .collect(Collectors.summingDouble(v -> v.price * v.count * 0.1));

        return result;
    }
}

パッと見たときに個別の条件や計算はわかりやすいのですが、
filter内部が何を意味しているか、collectが何をしているか読み取りにくいです。

【メソッドに分ける】

次にメソッドに分けるやり方です。

public class Sample {

    public double calculatePoints02() {

        final double result = createSaleList().stream()
            .filter(v -> isSpringGameSales(v))
            .collect(Collectors.summingDouble(v -> calculatePoins(v)));

        return result;
    }

    private boolean isSpringGameSales(final Sales sales) {

        final LocalDate springGameSaleFrom = LocalDate.of(2014, 4, 5);
        final LocalDate springGameSaleTo = LocalDate.of(2014, 4, 9);

        if (springGameSaleFrom.isAfter(sales.date)) {

            return false;
        }

        if (springGameSaleTo.isBefore(sales.date)) {

            return false;
        }

        if (!ItemType.GAME.equals(sales.type)) {

            return false;
        }

        if (calculateSalesPrice(sales) < 3000) {

            return false;
        }

        return true;
    }

    private int calculateSalesPrice(final Sales sales) {

        return sales.price * sales.count;
    }

    private double calculatePoins(final Sales sales) {

        return calculateSalesPrice(sales) * 0.1;
    }

先ほどのものよりは見やすいと思います。
これで問題はないと思います。

次からが本題。

【ようやく本題】

calculatePoints02のサンプルでわかりやすいコードにはなったと思います。

ですが、そもそもポイントを計算するメソッドと
春のゲームセールをメソッドを同じクラス内で扱ってよいのか?
と言うクラス設計的な問題を抱えていると思うのです。

また、ユニットテストするにもcalculatePoints02メソッドを
通さないといけないのでやりにくいです。

ではどうするのかと言うとStreamAPIの処理を行う用のクラスを作成します。
今回で言えば、春のゲームセール用の文脈・コンテキスト用の
クラスを作成します。

[作成するクラス]
public class SpringGameSales {
    private final Sales sales;

    public SpringGameSales(final Sales sales) {

        this.sales = sales;
    }

    public boolean isSpringGameSales() {

        final LocalDate springGameSaleFrom = LocalDate.of(2014, 4, 5);
        final LocalDate springGameSaleTo = LocalDate.of(2014, 4, 9);

        if (springGameSaleFrom.isAfter(sales.date)) {

            return false;
        }

        if (springGameSaleTo.isBefore(sales.date)) {

            return false;
        }

        if (!ItemType.GAME.equals(sales.type)) {

            return false;
        }

        if (calculateSalesPrice() < 3000) {

            return false;
        }

        return true;
    }

    public int calculateSalesPrice() {

        return sales.price * sales.count;
    }

    public double calculatePoins() {

        return calculateSalesPrice() * 0.1;
    }
}

判定用のメソッドと計算用のメソッドを
Sampleクラスから持ってきただけです。

春のゲームセールスに特化したクラスで
クラスの責務としては小さくなっていると思います。

テストデータのSalesオブジェクトをわたすだけで
ユニットテストするのも簡単になりました。

[呼び出し側]
public class Sample {

    public double calculatePoints03() {

        final double result = createSaleList().stream()
            .map(v -> new SpringGameSales(v))
            .filter(SpringGameSales::isSpringGameSales)
            .collect(Collectors.summingDouble(SpringGameSales::calculatePoins));

        return result;
    }
}

コードがとてもすっきりしたと思います。

最初のmapでSpringGameSalesクラスでラップするよ
と言うのがなんとなくわかると思います。
(実際はわかって欲しいと言う願望です)

以降はメソッド参照を使って無駄なパラメータを
渡すのを省略しています。

また、SpringGameSales::calculatePoinsのように
ラップしたクラスのメソッドを使用しているので
文脈も伝わりやすいと思います。
(伝わってくれるといいなという願望です。)

欠点としては新しくクラスを作成しないといけないことと
毎回インスタンスを生成するのでメモリを消費することです。

前者に関しては欠点と言うよりは
ルールがガチガチに決められすぎていると賛同されにくい
と言ったところですが。

【まとめ】

ということでStream#mapで
データ用のクラスをラップする方法を書いてみました。

JavaのStreamAPIが公開されたばかりで
グッドプラクティス的なものがでてきていはないが現状です。

このやり方が良いか悪いかというのは
わかりませんが、個人的には良いやり方ではないかなと思います。

あまり賛同を得られる気はしないですが・・・