Stream APIにチャレンジ!

今年3月に、JJUGJava SE 8のローンチイベントが開催されました。
【東京】JJUGイベント 「祝☆Java 8 Launch」3/21(金/春分の日)開催 | 日本Javaユーザーグループ

僕も参加したのですが、そこでは多くの有益な情報を得ることができました。
また、動画もYouTubeにアップされていたので、必要に応じて繰り返し見ることができたのも良かったです。
Java SE 8研修のコンテンツを作る際にも、何回も見返しました。

その中の1つで、@cero_tさん(谷本心さん)がStream APIについて発表されていました。
Java Oneスピーカーに対して非常に恐れ多いことではありますが、今回のブログでは、谷本さんのStream APIについて考察したいと思います。
ケンカを売るつもりなどは全くございませんし、むしろ発表していただいた谷本さんは尊敬しています。
実際にお話したことはないのですが…。

谷本さんの発表はこちらです↓
祝☆Java 8 Launch from old Java to modern Java - reloaded @cero_t #jjug - YouTube


話は逸れますが、僕は講師という、人前で話すのが当たり前な仕事をしていますが、JJUG CCCで100人以上の観客の方々を前に話すのは、とても緊張しました。
なので、ローンチイベントで300人以上を前に話されていた皆さんは凄いなあ…と、つくづく思います。


さて、話を戻します。
まず、こんな仮想の社員DBがあるとします。
(これは僕がサンプル用に作ったものです)
Empクラスは、id(社員番号)・name(氏名)・dept(部署を表すenum)・sal(給与)というフィールドを持つとします。
簡略化のため、フィールドはすべてpublicにしてsetter/getterは付けていません。

public enum Dept {
    ADMIN,
    PLANNING,
    SALES,
    OPERATIONS,
    NONE
}
public class Emp {
    public int id;
    public String name;
    public Dept dept;
    public int sal;
    public Emp(int id, String name, Dept dept, int sal) {
        this.id = id;
        this.name = name;
        this.dept = dept;
        this.sal = sal;
    }
}
public class EmpDB {
    private static final List<Emp> EMP_LIST = 
            Arrays.asList(
                    new Emp(101, "Nishida", Dept.ADMIN, 500000),
                    new Emp(102, "Nohira", Dept.SALES, 285000),
                    new Emp(103, "Kiyama", Dept.ADMIN, 245000),
                    new Emp(104, "Ohkawa", Dept.PLANNING, 297500),
                    new Emp(105, "Kajiyama", Dept.SALES, 125000),
                    new Emp(106, "Kohsaka", Dept.SALES, 160000),
                    new Emp(107, "Nishikawa", Dept.SALES, 150000),
                    new Emp(108, "Sasaki", Dept.SALES, 95000),
                    new Emp(109, "Ichikawa", Dept.SALES, 125000),
                    new Emp(110, "Yamamoto", Dept.PLANNING, 300000),
                    new Emp(111, "Komatsu", Dept.PLANNING, 80000),
                    new Emp(112, "Aizawa", Dept.PLANNING, 300000),
                    new Emp(113, "Saitoh", Dept.NONE, 110000),
                    new Emp(114, "Inoue", Dept.ADMIN, 180000)
            );
    
    // ここにメソッドを書く
}

★仕様

  1. 上記のList<Emp>をMap<Dept, Long>に変換する
  2. MapのValueは、Keyの部署に存在する「給与が200000以上」の社員の人数
  3. 「給与が200000以上」の社員が0人の部署も結果に含める

★制約

  1. 制御構造(if・switch・for・while・do-while)は使わず、Stream APIのみで書く
  2. Stream#forEach()禁止(@skrbさんのJJUG CCC Springでの発表曰く「forEach()使ったら負け!」らしいので)

まず、Java SE 7までの感じだとこうなります。

public static Map<Dept, Long> java7Map() {
    Map<Dept, Long> map = new HashMap<>();
    for (Emp emp : EMP_LIST) {
        if (!map.containsKey(emp.dept)) {
            map.put(emp.dept, 0L);
        }
        if (emp.sal >= 200000) {
            long count = map.get(emp.dept);
            count++;
            map.put(emp.dept, count);
        }
    }
    return map;
}

で、谷本さんが書かれていたStream APIでのコードは以下です。(給与額など、少し変更しています)

public static Map<Dept, Long> cero_t() {
    return EMP_LIST.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept))  // ①
            .entrySet()
            .stream()  // ②
            .collect(Collectors.toMap(  // ③
                    entry -> entry.getKey(), 
                    entry -> entry.getValue()
                            .stream()
                            .filter(emp -> emp.sal >= 200000)
                            .count()));
}

①Map<Dept, List<Emp>>に変換して、
②Map.Entry<Dept, List<Emp>>のStreamに変換して、
③KeyがDept、Valueが②のListに給与200000以上でフィルターしてカウント
という流れです。

初めてこのコードを見たときは、正直言って分かりませんでした。
当時は、Stream APIの各メソッドについて、まだよく理解していなかったこともありますが、やっぱり「長い!」と思いました。

それからJava SE 8の研究は続けていたのですが、この谷本さんのコードは頭から離れず、ずっと「更に簡潔に書ける方法ってないのかな?」と考え続けていました。
今の自分なら、上記のコードは読めるし書けるのですが、何か更に良い方法はないものか、と。
「出来れば2~3行、長くても5行くらいで書きたい!」と思っていました。

また、Stream APIは「Streamの生成」「中間操作」「終端操作」の3つから成り立っているのですが、上記のコードは終端操作が3回入っています(collect()、collect()、count())。
なので、「終端操作は1回で書きたい!」とも思っていました。

それで考えたのが、以下のコードです。

public static Map<Dept, Long> mtada1() {
    return EMP_LIST.stream()
            .filter(emp -> emp.sal >= 200000)
            .collect(Collectors.groupingBy(emp -> emp.dept, Collectors.counting()));
}

emp.deptでグループ化して、Collectors.counting()でカウントします。
このコードの問題点は、Stream生成直後にfilter()してからcollect()しているため、給与200000以上の社員が存在しない部署のデータが消えてしまうことです。
すなわち、仕様3を満たしていないのです。

むーん、どうしたものか?
いったんStream APIから離れて、MapにJava SE 8から追加されたメソッドを使って書いてみます。

public static Map<Dept, Long> java8Map2() {
    Map<Dept, Long> map = new HashMap<>();
    for (Emp emp : EMP_LIST) {
        map.computeIfAbsent(emp.dept, (Dept d) -> 0L);
        if (emp.sal >= 200000) {
            map.compute(emp.dept, (dept, count) -> ++count);
        }
    }
    return map;
}

computeIfAbsent()とcompute()を使ってみました。

うーん、でもやはりStream APIで書きたい。どうするか?

Mapに変換してから、各valueのListをStreamに変換するとどうだろう?あ、それ谷本さんのコードと全く同じだ。ListのMapじゃなくて、ListのListにするか?Optionalとか使うと出来るか?・・・

あと、filter()という中間操作で200000以上を抽出しているからデータが無くなるので、終端操作で抽出条件を指定できないものか?・・・

色々と考えましたが、結局は僕の力では、谷本さんのコード以外に思いつくことは出来ませんでした。


なんてなことをTwitterでつぶやいていたら、@backpaper0さん(うらがみさん)からメンションを頂きました。
ちょっと書き換えていますが、こんな感じです。

public static Map<Dept, Long> backpaper0() {
    return EMP_LIST.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept, 
                    Collectors.summingLong(emp -> emp.sal >= 200000 ? 1L : 0L)));
}

元コードはこちらです↓

うらがみさんのコードを見たとき、「ああー、なるほど!!」と思いました。
まるで、数学でエレガントな証明を見たかのような感覚です。

Collectors.summingLong()は、その名の通り合計を計算するメソッドです。
そして、引数はlongを返すラムダ(ToLongFunction)なので、ここで条件演算子を使って1または0を返す、と。
しかも、メソッドの中身は3行。素晴らしい!!!


「チャレンジ」と言っておきながら、自分自身ではなく、うらがみさんに解決して頂いたという結果になってしまいました・・・(^^;

ローンチイベントでご発表いただいた谷本さん、素敵なコードを教えていただいたうらがみさんに、いちエンジニアとして最大限の敬意を表すとともに、JJUG運営の方々やTwitterにも感謝したいと思います。
また、自分ももっとスキルを磨かねばならんなあ、と思いました。


JJUG CCC 2014 FallのCall for Papersにも応募したのですが、内容はStream API入門です。
どう学習すればStream APIのコードを読める/書けるようになるのか?ということを、ステップ・バイ・ステップで解説していく予定です。
そろそろ選考結果がくるころかなぁ・・・。