Taste of Tech Topics

Acroquest Technology株式会社のエンジニアが書く技術ブログ

ラムダ禁止について本気出して考えてみた - 9つのパターンで見るStream API

こんにちは @ です。

今日のテーマは・・・ラピュタ禁止令!

バルス!

いや違う。ラムダ禁止令、です。


さて、なかなかの滑り出しですが、今日はただのラムダの紹介ではなく、禁止令に主眼を置いて語ります。

このエントリーは、Java Advent Calendar 2013の12/16分の投稿です。
http://www.adventar.org/calendars/145

前日は @sugarlife さんの JDK 8 新機能ダイジェスト (JDK 8 Features) です。
翌日は @setoazusa さんです。

ラムダ禁止令はあり得るのか?

勉強会やその懇親会などで、たびたび「ラムダ禁止令が出るのではないか」が話題に上ることがあります。
「そりゃ禁止する組織もあるでしょうね」というのがお決まりの答えなのですが、ただそれに従うだけでは面白くありませんし、要素技術の発展も滞ってしまうでしょう。そもそも新しい技術を食わず嫌いせず、必要に応じて利用できる文化にしたいですよね。

そんな事を考えながらも、ただStream APIについてはちょっと考えないといけないな、と思わされたのが、Java Advent Calendar 5日目の記事でした。

ToIntFunction<Map.Entry<Car, List<Sale>>> toSize = e -> e.getValue().size();
Optional<Car> mostBought;
mostBought = sales.collect(Collectors.groupingBy(Sale::getCar))
        .entrySet()
        .stream()
        .sorted(Comparator.comparingInt(toSize).reversed())
        .map(Map.Entry::getKey)
        .findFirst();
if(mostBought.isPresent()) {
    System.out.println(mostBought.get());
}

Java Advent Calendar 2013 5日目 - Java 8 Stream APIを学ぶ - kagamihogeの日記

ここで取り上げられている例は、特にStream APIに慣れていないうちは、パッと見ても何をしたいのかがよく分かりません。これを見て「可読性が低い」と捉える向きがいてもおかしくありません。

こういう事がエスカレートすると・・・

若者が複雑なStream APIを書く
 ↓
先輩がレビューができない or テスト不十分で見落としが起きる
 ↓
問題が発生する
 ↓
なぜなぜ分析でStream APIの可読性が槍玉にあがる
 ↓
Stream API全面禁止
 ↓
ついでにラムダも全面禁止

_人人人人人人人人人人_
> 突然のラムダ禁止 <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y ̄

こんな事態が起きるかも知れない、と思いました。特にStream APIが何なのかをよく分かっていない人たちが、Stream APIラムダ式を混同して「ラムダ式禁止」と言いだすことは十分にあり得ます。

今日はこの辺りの話、つまり「業務でStream APIをどのぐらいまでなら使えるか」「使うための注意点は何か」ということを本気出して考えたいと思います。

ラムダ式禁止」だけは抵抗しよう

まず最初に言っておくと「ラムダ式」の禁止はあり得ません。言ってしまえばほら、ラムダ式はただの匿名クラス(無名クラス)のシンタックスシュガーみたいなものであって、何も難しいところはないからです。

簡単なおさらいとして、Comparatorの例を見てみましょう。

Comparator<Emp> comp1 = new Comparator<Emp>() {
    @Override
    public int compare(Emp emp1, Emp emp2) {
        return emp1.getSal() - emp2.getSal();
    }
};

Comparator<Emp> comp2 = (emp1, emp2) -> emp1.getSal() - emp2.getSal();

Comparator<Emp> comp3 = Comparator.comparingInt(Emp::getSal);

このcomp1、comp2、comp3はいずれも同じ処理をするComparatorです。comp3はやや見慣れない形だとしても、comp2までなら昔ながらのエンジニアにもまだ理解できる記述だと思います。

そのため、匿名クラス(無名クラス)自体を禁止している組織ならまだしも、そうでない組織でラムダ式自体が禁止されることは賛同できません。それこそ「食わず嫌い」の典型なので、もし皆さんの組織(の標準化グループ)がラムダ式禁止だと判断しそうなら、ぜひ抵抗してください。

あるいは、ラムダ式禁止を逆手に取って、Stream APIを匿名クラスまみれで書くことで、抵抗しても良いかも知れません(笑)
# 半年後、そこには元気に走り回るStream API禁止令の姿が!

ただし、単に禁止に反対するだけでなく、相手の懸念している「可読性が下がって困る」というところに応えるためにも、「ここまでなら使っても問題は起きないでしょう」という提案も同時に行なうべきだと思います。

その辺りが、今日のテーマになります。

Stream APIは、どこまで許されるのか?

さて、繰り返しになりますが、今日の本題はラムダ式ではなく、Stream APIです。
ラムダ式自体は簡単なので禁止にする理由がないと書きましたが、Stream APIの方は簡単ではなく、これをむやみやたらに使って炎上すると、Stream API自体を禁止とする組織が出てきてもおかしくありません。

では、どこまでならサクサクと読めるのか、考えてみましょう。

今回は、特に「あまりStream APIに慣れていない人」をターゲットにして書きますので、「自分、streamの数珠つなぎ200行ぐらい普通に読めるんで」のようなマサカリを装備する必要ありません。


なお、今回紹介する機能や処理の「禁止度」について、以下のようにレベルづけをします。

C : 業務で使っても全く問題ないレベル。
B : やや疑問を呈されるけど、積極的に使いたいレベル。
A : 業務では使わない方が良いレベル。
S : 積極的に禁止したいっていうか、使ったら書き直させるレベル。

特に禁止度Bあたりは、古豪のエンジニアから「読めないから使うな」と言われかねないところなので、組織のレベルを見ながら利用するようにすべきです。

1. 同じオブジェクトに対する連続した操作(禁止度:C)

まずStream APIを使った典型的な例が、同じオブジェクトに対してフィルタやソートなどの連続した操作を行なうというものです。

List<Emp> filterAndSort1(List<Emp> list) {
    return list.stream()
            .filter(emp -> emp.getSal() > 1000)
            .sorted((emp1, emp2) -> emp2.getSal() - emp1.getSal())
            .collect(Collectors.toList());
}

このぐらいであれば、「給料が1000より大きい」ものを抽出して「降順にソート」していることを読み取ることは容易です。問題ありませんね。

2. Comparatorのstaticメソッドを使ったソート(禁止度:B)

上に書いたソースを少し修正して、ソートのところをComparator.comparingIntにすることもできます。

List<Emp> filterAndSort2(List<Emp> list) {
    return list.stream()
            .filter(emp -> emp.getSal() > 1000)
            .sorted(Comparator.comparingInt(Emp::getSal).reversed())
            .collect(Collectors.toList());
}

IntelliJ IDEA 12.1.6を使っていると、Emp::getSalのところで「cyclic inference」というエラーを出してくるのですが、実際には問題なく動作します。IDEがエラーを出してくる辺りに、少し不吉なにおいを感じますね。そういう背景もあって禁止度を一つ上げてBにしました。


さらに、OpenJDK8 build111では、以下のような記述は正常にコンパイルされるのですが、

.sorted(Comparator.comparingInt(emp -> emp.sal))
.sorted(Comparator.comparingInt(emp -> emp.getSal()))
.sorted(Comparator.comparingInt(Emp::getSal).reversed())

以下のようにすると、コンパイルエラーになってしまいます。

.sorted(Comparator.comparingInt(emp -> emp.sal).reversed())
.sorted(Comparator.comparingInt(emp -> emp.getSal()).reversed())

どうもreversedをつけると、comparingIntに渡すToIntFunctionの型解決ができなくなってしまうようです。

開発途中とは言え、コンパイラーですら理解できなくなるのだから、人間にも理解しにくい構文なのでしょうか。
昔ながらのJavaエンジニアに配慮するなら、Comparator.comparing*などは使わず、最初の例に挙げたように、

.sorted((emp1, emp2) -> emp2.getSal() - emp1.getSal())

こう書くのが、一番分かりやすいでしょう。

3. mapとreduceを使った操作(禁止度:B)

続いて、特定の項目のみを抽出して集計するような処理を考えます。
下の例は、1000より大きい給料のうち、その2倍の値の平均を取るという、やや謎の処理です。

Double averageSal(List<Emp> list) {
    return list.stream()
            .filter(emp -> emp.getSal() > 1000)
            .mapToInt(emp -> emp.getSal() * 2)
            .average()
            .getAsDouble();
}

これも「map*」メソッドの役割さえきちんと理解してもらえれば、前からスラスラ読むことができます。

続いて、reduceを使う例も見てみましょう。下の例は、1000より大きい給料のうち、給与から1000を引いたものを合計するという、これまたやや謎の処理です。もちろんsum()でも合計はできますが、あえてreduceを使ってみました。

int someCalc(List<Emp> list) {
    return list.stream()
            .filter(emp -> emp.getSal() > 1000)
            .mapToInt(emp -> emp.getSal() - 1000)
            .reduce(0, (x, y) -> x + y);
}

これも「reduce」メソッドの役割さえきちんと理解すれば、スラスラ読んでもらえるものでしょう。

しかしながら、いわゆる関数型プログラミングに慣れていないエンジニアには、mapやreduceという処理には親しみがないため、これも読みにくいと言われかねません。

この辺りを標準的に使いたいのであれば、社内勉強会などを開催して、まずは「filter」「sorted」「map」「reduce」あたりを説明すると良いのではないかと思います。ここまで使えれば、だいたい勝てます(何に?)

4. groupingByやtoMapを使ったMapへの変換(禁止度:B)

Stream APIを使っていて、便利だなと感じるのがこのgroupingByによる集計処理。SQLのgroup byと同じようなものです。
これは型を変えてしまうだけに処理がやや分かりにくくなるのですが、やはりこれも積極的に使いたいですね。2例続けて見てみましょう。

Map<Dept, List<Emp>> groupByDept(List<Emp> list) {
    return list.stream()
            .sorted((emp1, emp2) -> emp2.sal - emp1.sal)
            .collect(Collectors.groupingBy(emp -> emp.dept));
}
Map<Integer, List<Emp>> rankedBySal(List<Emp> list) {
    return list.stream()
            .sorted((emp1, emp2) -> emp2.sal - emp1.sal)
            .collect(Collectors.groupingBy(emp -> {
                if (emp.sal > 4000) return 1;
                if (emp.sal > 2500) return 2;
                if (emp.sal > 1000) return 3;
                return 4;
            }));
}

前者は部署(dept)によるグループ化を行い、後者は給与水準によるグループ化を行なっています。groupingByがCollectionからMapに変換するための処理であることさえ把握していれば、このソースもサクサクと読むことができます。


また、groupingByでは、SQLと同じように値に集計結果を入れることもできます。

Map<Dept, Long> groupByDeptAndAve(List<Emp> list) {
    return list.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept, Collectors.averagingInt(Emp::getSal)));
}

この例では部署ごとに平均給与を計算しています。とてもありそうな処理ですね。


しかしながら、SQLにおいても「集計関数がよく分からない勢」が一定数存在することが現在までに確認されています。
数学の勉強をしてから出直してこいと思うのですが 集計関数によく習熟したメンバでチームを作り、お互いにレビューをできるような体制を作れば、事故ることを減らせると思います。

5. groupingByからのentrySet.stream大作戦(禁止度:A)

続いて、部署(dept)を、人数の少ない順に並べるという処理です。

List<Dept> sortDept1(List<Emp> list) {
    return list.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept, Collectors.counting()))
            .entrySet()
            .stream()
            .sorted(Comparator.comparingLong(Entry::getValue))
            .map(Entry::getKey)
            .collect(Collectors.toList());
    }
}

Mapのstream処理自体が分かりにくいという事情はあるにせよ、もともとListであったものをMapにして、さらにentrySetを取り出してstream処理を続けるというのは、型が分かりにくくなり、可読性が落ちると言わざるを得ません。というか、私のこの日本語の説明自体も、よく分かりません

しかもこの辺りから、IntelliJ IDEAの自動補完はほぼ効かなくなりますし、エラーもたくさん出るようになります(でも実際にはコンパイルが通って実行できるので、余計に厄介)

こうなってくると、禁止度はさらに上がってAクラスになるでしょう。


では、このソースのどこが可読性を落としているのでしょうか。ポイントは「entrySet().stream()」にあると私は思います。ここで、せめてcollectした後にローカル変数に代入すれば、まだしも読みやすくなるのではないでしょうか。

List<Dept> sortDept2(List<Emp> list) {
    Map<Dept, Long> map = list.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept, Collectors.counting()));

    return map.entrySet()
            .stream()
            .sorted(Comparator.comparingLong(Entry::getValue))
            .map(Entry::getKey)
            .collect(Collectors.toList());
}

部署ごとの人数を一度Mapに代入してから、人数でソートしてkeyのみ(部署のみ)取り出しています。このようにすれば、禁止度はBクラスまで落とせるように思います。

「collectした後は、一時変数に代入する」という新しい鉄則を作るという案、どうでしょうかね。


それにしても、Mapの(entrySetの)Streamは分かりづらいですよね。実は2012年前半の時点ではMapStreamというクラスがあり、分かりやすくMapのstream処理を記述することができました。
なぜ消えたのか、背景や理由はよく把握していませんが、いずれにせよMapStreamが消えたせいで、いまのようなstream処理しかできなくなり、可読性が低くなっているというのが現状です。

6. ネストしたstream(禁止度:A)

Mapの集計を考えると、streamをネストさせたくなることがあります。これも2つ例を続けて掲載します。

Map<Dept, Long> groupByDeptAndFilter1(List<Emp> list) {
    return list.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept, Collectors.collectingAndThen(Collectors.toList(),
                    emps -> emps.stream()
                            .filter(e -> e.sal > 1000)
                            .count())));
}
Map<Dept, Long> groupByDeptAndFilter2(List<Emp> list) {
    return list.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept))
            .entrySet()
            .stream()
            .collect(Collectors.toMap(entry -> entry.getKey(),
                    entry -> entry.getValue()
                            .stream()
                            .filter(emp -> emp.sal > 1000)
                            .count()));
}

ソースコードの意味、分かりますか?

部署をキーにしたMapを作ったうえで、Mapの値のほうには給与が1000を超える社員の人数を入れています。フィルタ処理を入れたいがために、ただの集計処理が使えず、Streamを利用しています。
ここまで来るとかなり可読性が下がり、事故の原因にもなります。

※2013/12/20 修正 - 初出時のソースに誤りがあり、訂正しました。
Streamをネストせざるを得ない時は、たとえば昔ながらのfor文も併用するというのも、ひとつ読みやすくするための手段になると思います。

Map<Dept, Long> groupByDeptAndFilter3(List<Emp> list) {
    Map<Dept, List<Emp>> map = list.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept));

    Map<Dept, Long> result = new HashMap<>();
    for (Entry<Dept, List<Emp>> entry : map.entrySet()) {
        long count = entry.getValue()
                .stream()
                .filter(emp -> emp.sal > 1000)
                .count();
        result.put(entry.getKey(), count);
    }

    return result;
}

ちょっと微妙? ただ、Java8時代には意外とこんなコードが出てくるかも知れません。


もしかすると、業務要件を考えて「あれ、事前にフィルタリングすれば良いだけじゃないの?」と思った方もいるかも知れません。

Map<Dept, Long> groupByDeptAndFilter4(List<Emp> list) {
    return list.stream()
            .filter(emp -> emp.sal > 1000)
            .collect(Collectors.groupingBy(emp -> emp.dept, Collectors.counting()));
}

事前にフィルタリングすれば、シンプルに記述することができます。しかしこうしてしまうと、処理の結果が少し変わってしまうのです。
「給与が1000未満の人しかいない部署」があった場合、groupByDeptAndFilter3までは「0人」として結果を取得できますが、groupByDeptAndFilter4ではそもそも結果のMapに当該の部署は表れません。
今回の例ではそれでも良いかも知れませんが、より複雑な業務処理になると、そのような差分が問題になることも多々あるでしょう。

このように、集計結果が分かりづらくなってくるところも、SQLとよく似ていますね。SQLのエキスパートが必要なことと同様に、Stream APIのエキスパートも必要だと思いますね。

7. streamの外に結果を残す(禁止度:A)

給与の平均と合計を同時に算出したい、という場合です。

void averageAndSum1(List<Emp> list) {
    final Map<String, Integer> dummy = new HashMap<>();
    dummy.put("RESULT", 0);
    double ave = list.stream()
            .mapToInt(emp -> {
                int sal = emp.getSal();
                dummy.put("RESULT", dummy.get("RESULT") + sal);
                return sal;
            })
            .average()
            .getAsDouble();
    System.out.println("ave=" + ave + ",sum=" + dummy.get("RESULT"));
}

ラムダ式がアクセスできる対象はfinalのみなので、ここではdummyというfinalのオブジェクトを定義して、そのオブジェクトに対して値の出し入れをしています。
特にparallelStreamで並列化した時には、確実に問題が起きることも踏まえると(たとえConcurrentHashMapにしていてもです)この記述は避けるべきでしょう。

ちなみに合計と平均の両方を計算したいだけであれば、summaryStatisticsメソッドを利用することで、代表的な集計結果をできます

void averageAndSum2(List<Emp> list) {
    IntSummaryStatistics statistics = list.stream()
            .mapToInt(Emp::getSal)
            .summaryStatistics();
    System.out.println("ave=" + statistics.getAverage() + ",sum=" + statistics.getSum());
}

IntSummaryStatisticsから集計結果を取得することができるのです。

このクラスから取得できないような独自の計算をしたい場合は、大人しくfor文で書くか、自分でCollectorを作るという方法がありそうです。自分でCollectorを作る方法は、また改めて紹介します。

8. stream中に元のオブジェクトを操作(禁止度:S)

やる人がいそうなので、念のため。

Double someOperation(List<Emp> list) {
    return list.stream()
            .filter(emp -> {
                list.remove(emp);
                return emp.getSal() > 1000;
            })
            .mapToInt(Emp::getSal)
            .average()
            .getAsDouble();
}

listのstream処理中に、listに対して要素の追加や削除をするというものです。この例のlist.remove(emp)は恣意的なものなので業務的な意味はありませんが、このように元のオブジェクトに対して操作をするという人は必ず出てくると思います。

元のListがArrayListなのかCopyOnWriteArrayListなのかで動きも変わりますし、ただでさえ動きの掴みづらいstream処理で、このような危険な実装は避けるべきでしょう。
どうしても元のオブジェクトに手を入れながら処理するのであれば(最適なstream処理が思いつかないなら)大人しくfor文で書くべきでしょう。

9. parallelStreamを使ってDB/Webアクセス(禁止度:S)

parallelStreamの効果を試すために、Webにアクセスをするようなサンプルもありますが、実案件ではそのような実装は避けましょう。
マルチスレッドでDB/Webにアクセスをしたいのであれば、スレッド数や、各スレッドの状態をきちんと管理・把握できる、ExecutorService使うべきです。

っていうか、parallelStreamを使ってDBやWebにアクセスなんかしたら絶対に問題が起きるし、むしろ運用中に発現して大問題になってStream API禁止令の引き金になるから、ホントやめて!(><)

まとめ

1. ラムダ式は禁止される理由がない!
2. filter/sort/map/reduce/collect/groupingByあたりの勉強会を行うべし!
3. Comparator.comparing* の使用は少し控えよう!
4. collectをしたら一度ローカル変数に入れよう!
5. Stream APIのエキスパートが近くにいたほうがいい!
6. streamからのstreamとか、ネストしたstreamとかは避けよう!
7. streamの親オブジェクトや、外の変数を触るんじゃない!
8. DBとかWebにアクセスするんじゃない!

まめ知識

「ラムダ」で画像検索したらラピュタのロボット兵が出てきたので、なにごとかと思って調べてみたら、ルパン三世「さらば愛しきルパンよ」に、ロボット兵が「ラムダ」という名前で登場していたようですね。
http://ja.wikipedia.org/wiki/%E3%81%95%E3%82%89%E3%81%B0%E6%84%9B%E3%81%97%E3%81%8D%E3%83%AB%E3%83%91%E3%83%B3%E3%82%88

そう、最初に敢えて滑ったのは、この伏線だったのですよ。敢えての、滑りだったんですよ。敢えての(←しつこい)

それでは皆さん、良いラムダライフを! バルス!

Acroquest Technologyでは、キャリア採用を行っています。


  • 日頃勉強している成果を、Hadoop、Storm、NoSQL、HTML5/CSS3/JavaScriptといった最新の技術を使ったプロジェクトで発揮したい。
  • 社会貢献性の高いプロジェクトに提案からリリースまで携わりたい。
  • 書籍・雑誌等の執筆や対外的な勉強会の開催を通した技術の発信や、社内勉強会での技術情報共有により、技術的に成長したい。
  • OSSの開発に携わりたい。

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。
 キャリア採用ページ