kagamihogeの日記

kagamihogeの日記です。

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

Java Advent Calendar 2013 5日目向けのエントリーです。前日は日本人のためのDate and Time API Tips - Programming Studio

この企画は、クリスマスとかいうリア充爆破祭を迎えるにあたりJava等技術ネタでブログなどでエントリーを書くことで高まっていこう、というものです(大嘘)

俺のエントリではJava 8で新設されるStream APIjava.util.stream (Java Platform SE 8 b120))を勉強するためのエントリにします。進め方としてはJava Streams API Preview | Javalobbyの方が書かれたエントリを読み進めていきます。こちらのエントリに掲載されているサンプルコードをひとつずつ動かし、その挙動を確認することで、理解を深めていくのがねらいです。

なお、Java Advent Calendar 2013の2日目のI am programmer and proud: Stream APIの始め方ではStreamのインスタンスに絞った解説記事もされておりコチラも大変勉強になるのでオススメです。

Stream APIとは?

Stream APIとは何モンなのか。

List<String> list = new ArrayList<>(Arrays.asList("kagami", "hoge", "foobar"));
list.stream().forEach(e -> System.out.println(e));

出力結果はこんな感じ。

kagami
hoge
foobar

ざっくり言ってしまえば、こんな感じにコレクションをいじるコードをシンプルに書けるようになる、というものです。従来通りのforを使えば下記のようなコードと同等ですが、コレクション(のStream)にLambda式を渡すことでシンプルになります。

for (String e : list) {
    System.out.println(e);
}

Stream APIを学ぶ

というわけでJava Streams API Preview | Javalobbyの掲載されているサンプルコードを動かすことで、Stream APIの理解を深めていきます。

その前に、このサンプルで使うデータの入れ物用クラスがあるので、そちらを別途ダウンロードしておきます。

https://github.com/edalorzo/jdk8-experiments/blob/master/src/codemasters/lambda/domain/Person.java
https://github.com/edalorzo/jdk8-experiments/blob/master/src/codemasters/lambda/domain/Car.java
https://github.com/edalorzo/jdk8-experiments/blob/master/src/codemasters/lambda/domain/Sale.java

Challenge 1: すべての自動車メーカ・ブランド名を表示する Print All Car Brands
String s = cars.map(Car::getBrand).collect(Collectors.joining(","));
System.out.println(s);

*1

とりあえずStream APIを使わないで同じことをするコードを書いてみます。

StringJoiner sj = new StringJoiner(",");
for (Car car : set) {
    sj.add(car.getBrand());
}
System.out.println(sj.toString());

*2

というわけで、やってることとしては下記のような感じです。

  1. コレクションから自動車メーカ・ブランド名(getBrand)を抽出し(map)
  2. 抽出した名称を単一の文字列にまとめる(collect)
    1. 文字列にまとめるとき、名称を","(カンマ)で区切る

従来のコードでは、forなどで手続きとして記述する必要があります。が、Stream APIでは抽出(map)して、まとめ(collect)て、がひとつの流れとして表現できる、といったところが大きな特徴のようです。

Car::getBrandてのはLambda式のMethod Referencesというもので、一先ずはこう書くとそのメソッド使ってくれるモンだ、という理解でよさそうです。参考→http://www.oracle.com/technetwork/articles/java/lambda-1984522.htmlのMethod References

collect(Collectors.joining(","))てのは、引数で渡したやり方でまとめる、というもの。ここで引数に渡しているやり方は、Collectors.joining(",")で、これはカンマで区切ってまとめる、という動作をします。そのため、最終的には、コレクションの内で名称をカンマで区切った文字列として出力する、という動作になります。

Challenge 2: 売れたトヨタ車をすべて表示する Select all Sales on Toyota
List<Sale> toyotaSales;
toyotaSales = sales.filter(s -> s.getCar().getBrand().equals("Toyota")).collect(Collectors.toList());
toyotaSales.forEach(System.out::println);

Stream APIを使わないで書いてみる。

List<Sale> toyotaSales = new LinkedList<>();
for (Sale sale : sales) {
    if (sale.getCar().getBrand().equals("Toyota")) {
        toyotaSales.add(sale);
    }
}
for (Sale s : toyotaSales) {
    System.out.println(s);
}

いっこ前のサンプルと異なるのは、filterです。フィルタの名の通り、条件に合致したものだけを通過させます。collectに渡すCollectors.toList()は、要素をリスト形式でまとめる、という動作をします。

よって、最終的には、トヨタ車という条件を満たすものをフィルタ(filter)し、そのフィルタされた要素をリスト形式でまとめ(collect)る、というものになります。

forEachは、引数に渡したLambda式でコレクションを走査する、という動作をします。collection.forEach(System.out::println);てのはLambda式のサンプル見てるとしばしば見られるコードで、要素の全表示という動作をします。

Challenge 3: 最も若いお客さんの購入リスト Find Buys of the Youngest Person
Optional<List<Sale>> byYoungest = sales
        .collect(Collectors.groupingBy(Sale::getBuyer))
        .entrySet()
        .stream()
        .sorted((x, y) -> x.getKey().getAge() - y.getKey().getAge())
        .map(Map.Entry::getValue)
        .findFirst();
if (byYoungest.isPresent()) {
    System.out.println(byYoungest.get());
}

*3 *4

Stream APIを使用したコードでは、こんな感じの流れで進んでいきます。

  1. お客さんごとにグループ化(groupingBy(Sale::getBuyer))するというまとめ方(collect)をして、
  2. まとめたものをSet(Set>> お客さん:販売 = 1:多)に変換(entrySet)して
  3. そのsetからstreamを得て、
  4. お客さん対販売リストのSetを年齢順に並び替え(sorted(...))、
  5. 並び替えたSetから、販売リストだけを抽出する(map(Map.Entry::getValue))
  6. 先頭を取得する(findFirst)

日本語で書くと非常に長ったらしくなるのですが、やりたいことは「最も若い人物が購入したもの一覧」なだけです。ただ、コードを見れば、コレクションがあーなって→こーなって→そーなる、という流れが表現されてるのが分かるかと思います。

とはいえ、ぶっちゃけ初見では俺はサッパリ理解不能でした。そのため、実際に勉強するときはメソッドを一個ずつ増やして挙動を確認する方法で進めていました。上のコード例でいうと、まずcollectだけにして、どんな型で値が返ってくるかを確認する。次にentrySetを増やして同じことをする、sterem、sorted・・・といった感じ。

さらに、従来のやり方で書き直することもやってみます。

SortedMap<Person, List<Sale>> sortedByAgeMap = new TreeMap<>(new Comparator<Person>() {
    @Override
    public int compare(Person x, Person y) {
        return x.getAge() - y.getAge();
    }
});
for (Sale s : sales1) {
    if (sortedByAgeMap.containsKey(s.getBuyer())) {
        sortedByAgeMap.get(s.getBuyer()).add(s);
    } else {
        List<Sale> t = new ArrayList<>();
        t.add(s);
        sortedByAgeMap.put(s.getBuyer(), t);
    }
}
System.out.println(sortedByAgeMap.get(sortedByAgeMap.firstKey()));

まず、Comparatorが5行かかるところが1行で済んでいます。ここはStream APIというよりLambda式の範疇ですが、宣言的に書けると非常に簡潔になることが伺えます。グループ化するコードは、まずお客さんをキー、値に販売リストを取るSortedMapを用意するやり方にしました。実のところ、こんな冗長な書き方になるんだっけ? とかかなり悩んだコードです。なのでもうちょっとナントカなるとは思いますが、Stream APIの簡潔さを見る上では良い比較になると思うんでこういうコードを書きました。

もうひとつ見逃せないのがOptional (Java Platform SE 8 b120)です。これはnullを抑止するためのパターンとも言えるものです。コード中にあるisPresent()は、返せる値があればtrueで、そうでなければfalseを返します。なお、この例の場合は下記のようにも書いても同じです。

byYoungest.ifPresent(System.out::println);
Challenge 4: 最も高価な販売 Find Most Costly Sale
Comparator<Sale> byCost = Comparator.comparingDouble((ToDoubleFunction<Sale>)Sale::getCost).reversed();
Optional<Sale> mostCostlySale = sales
        .sorted(byCost)
        .findFirst();
if (mostCostlySale.isPresent()) {
    System.out.println(mostCostlySale.get());
}

この例ではまず、Comparatorを構築します。Comparator.comparingDoubleは、引数にToDoubleFunctionを受け取ります。まずToDoubleFunctionですが、これは、引数にTを受けてdoubleを返す、というものです。この関数型インタフェースは自前で定義してもかまわないんですが、よくあるパターンはライブラリ側に用意されている、といった程度のものです。ここでは、引数にSaleを受けて返り値にgetCostを取る、といったことを記述しています。この部分は、Method Referenceを使用してもいいし、Lambda式で書いても同じ結果になります。

Sale::getCost
(s) -> s.getCost()

そうして作成したToDoubleFunctionインスタンスをComparator.comparingDoubleに渡します。このメソッドは、引数で受け取った関数をソートキーとするComparatorのインスタンスを作成します。ここでは、SaleのgetCostを返す関数をソートキーにしているので、getCostでソートするComparatorのインスタンスが得られます。最後にreversedをして、降順に切り替えます。

あとは、このコスト降順Comparatorをsoretedの引数に指定して、findFirstをすればコスト最大のSaleが取得できます。

せっかくなので従来通りの書き方をしてみます。

List<Sale> tmpSales = new ArrayList<Sale>(sales1);
Collections.sort(tmpSales, new Comparator<Sale>() {
    @Override
    public int compare(Sale x, Sale y) {
        if (x.getCost() == y.getCost()) {
            return 0;
        } else if (x.getCost() > y.getCost()) {
            return -1;
        }
        return 1;
    }
});
if (tmpSales.size() > 0) {
    System.out.println(tmpSales.get(0));
}

compareのところは頑張ればもうちょっとナントカなるとは思うものの、愚直に書けばこうなるかと思います。

Challenge 5: 男性のバイヤーかつお客さんの販売の合計コスト Sum of Sales from Male Buyers & Sellers
double sum = sales.filter(s -> s.getBuyer().isMale() && s.getSeller().isMale())
        .mapToDouble(Sale::getCost)
        .sum();
System.out.println(sum);

これもまずはStream APIの流れを見ていきます。

  1. 「バイヤーが男性かつお客さんも男性」の条件を満たすSaleをフィルタ(filter)し、
  2. Saleからコスト(Sale::getCost)を抽出(mapToDouble)し、
  3. 抽出したコストを合計(sum)

いままでのサンプルコードと比べると、mapToDouble, sumが初登場していますが、概ねメソッド名通りの動作をします。

これも従来通りのforループを回すやり方で書いてみます。

double sum = 0;
for (Sale s : sales1) {
    if (s.getBuyer().isMale() && s.getSeller().isMale()) {
        sum += s.getCost();
    }
}
System.out.println(sum);

コレクション内要素の合計という素朴な例ですが、比べるとかなり異なることが分かります。Stream APIを使用した場合、filterしてmapしてsumせよ、とコレクションに対して一連の処理の流れを設定しています。従来通りforループで解く場合、処理の順序を記述しています。

Challenge 6: 最も若いお客さんの年齢 Find the Age of the Youngest Buyer

最も若いお客さんの年齢を探す、ただしコストが40,000より大きいもの、というケースです。

OptionalInt ageOfYoungest;
ageOfYoungest = sales.filter(sale -> sale.getCost() > 40000)
        .map(Sale::getBuyer)
        .mapToInt(Person::getAge)
        .sorted()
        .findFirst();
if(ageOfYoungest.isPresent()) {
    System.out.println(ageOfYoungest.getAsInt());
}

条件(cost > 40000)でフィルタ(filter)をかけて絞込み、お客さん(Sale::getBuyer)だけを抽出(map)して、さらに年齢(Person::getAge)だけを抽出して、並べ替えて(sorted)、一番最初のものを選択(findFirst)する。そして、返り値は結果が存在するとは限らないのでOptionalIntで受ける。

これも従来通りのやり方で書いてみます。

SortedSet<Integer> ages = new TreeSet<>();
for (Sale s : sales1) {
    if (s.getCost() > 40000) {
        ages.add(s.getBuyer().getAge());
    }
}
if (ages.size() > 0) {
    System.out.println(ages.first());
}
Challenge 7: コストでソート Sort Sales by Cost

Challenge 4とほぼ同等の内容です。

Comparator<Sale> byCost= Comparator.comparingDouble((ToDoubleFunction<Sale>) Sale::getCost);
List<Sale> sortedByCost;
sortedByCost = sales.sorted( byCost ).collect(Collectors.toList());
sortedByCost.forEach(System.out::println);
Challenge 8: カーブランドごとの車種一覧 Index Cars by Brand
Map<String,List<Car>> byBrand;
byBrand = cars.collect( Collectors.groupingBy(Car::getBrand ));
byBrand.forEach((k,v) -> System.out.println(k + ":" + v));

Challenge 3でも出てきたグループ化を使用して、カーブランドごとにCarを集約します。MapでforEachを使用する場合、Listとは異なり引数を2つ取り、それぞれMapエントリのキーと値です。ここでは、キーと値を引数k, vに取るLambda式で記述しています。

Challenge 9: 最も売れた車種 Find Most Bought Car

コストではなく売れた数が最大の車種を探します。

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());
}

これも流れを見ていきます。

  1. Saleを車種ごとにグループ化(roupingBy(Sale::getCar))するというまとめ方(collect)をして、
  2. 車種ごとにまとめられたSaleリストの大きさ(=つまり車種ごとの売れた数)を降順で並べ替え(soreted(toSize).reversed)をして、
  3. 車種だけを抽出(Map.Entry::getKey)して
  4. 先頭を取得する(findFirst)

この流れそのものは今までのChallengeで出てきたものの組み合わせですが、ToIntFunctionのところはChallenge 4のところでも出てきたのだけどちょっと分かりにくい。

まず、ToIntFunctionは、T型の引数を1つ取りintを返す、というJDKのライブラリが用意している関数型インタフェースです。次に、Comparator.comparingIntは、引数のToIntFunctionをソートキーにしてComparatorを返すstaticメソッドです。このサンプルではToIntFunctionの返り値は、Listのsizeです。Comparator.comparingIntは、そのToIntFunctionを引数に取るので、ListのsizeをソートキーとするComparatorを返します。最後にreversedをすれば降順になり、最初の要素が最大値となる、といった流れです。

感想とか

とまぁそんなわけでStream APIがどんなもんかざっくり見てきました。こっからは俺個人の感想とかを書いていきます。

Stream API単体だけに関しては、新しいコレクション処理のライブラリが次のバージョンで増えるだけ、と言い切れなくもないです。使い方の例を見て大枠をつかみ、折を見てjavadocを参照してどんなメソッドやクラスが提供されているかを少しずつ覚えていき、より効率的なコーディングのやり方を覚えていく。このステップ自体はタブンあんまり変わらないと思います。

しかし、Streamはそれ単体で使うというよりLambda式とセットで使ってこそ価値が出る。そして、Lambda式は今までのJavaの、というより手続き型プログラミングの知識だけで立ち向かうことはちょっと難しい。関数型言語クラスタの方々から見れば2000年前に通過した場所かもしれないですが、Javaプログラマにとっては―少なくとも俺にとっては―衝撃的なものでした。Lambda式の意義とか、クロージャではないとか、まぁいろいろ議論はあるんですがそこは置いておくとして。

関数型言語型的特長を持つLambda式とStreamをどのように理解すればよいのか。これは俺自身がJavaScriptとかActionScriptとかで悩まされたトピックであり、そのへんを振り返りながら書いていきます。

俺が理解した言葉で書けば、コレクション「を」処理するのではなくコレクション「に」処理を渡す、というものになります。例えば下記のようなコードは、コレクション「に」標準出力する処理を渡しています。

list.stream().forEach(e -> System.out.println(e));

こちらは、コレクション「を」ループで処理しています。

for (String e : list) {
    System.out.println(e);
}

この両者が決定的に異なるのは、コレクションをどのように処理するかを明示的に書いているかどうか、です。Streamの方では、各要素に対して処理をするよう指示はしているものの、繰り返し処理が内部ではどんなアルゴリズムになるかは一切コードにしていません。一方for文の方では、インデックス0番地の要素から順に処理するというアルゴリズムが明示的に書かれています。

このメリットのひとつは最適化の余地が広がることです。Java 8関連の文書を見ると、とりわけ並行性について言及されています。プログラマの側からは、コレクションがどう処理されるか知ったこっちゃなくなるので、ライブラリ側で好き放題できる余地が広がりんぐなわけです。一方、for文などで処理の順序を固定してしまうと、その順番でしかコードを実行できなくなってしまう。コレクションライブラリに任してしまえば「おっこれとれこは最適化できるからやっちまえ」とかそーいうのができるようになる。また、繰り返し処理のアルゴリズムをブン投げすることで、プログラマの負担が減る可能性があります。

処理を書かず、処理を宣言することがなぜ最適化につながるのか。この感覚はSQLを考えてみると分かりやすいです。たとえば、下記のような、お客さんごとのコスト合計をコストが100以上のものに絞って出す、クエリを考えてみます*5

SELECT   buyer_id, sum(cost)
FROM     sales
WHERE    cost > 100
GROUP BY buyer_id;

SQLでは、どんな結果が欲しいかを記述します。このとき、このクエリがどのようなアルゴリズムで処理されるかは知ったことではないです。集約が1から順番に足しこんでいくのか、それともある一定範囲ごとに分割してスレッドで合計したあとその結果を足すのか、メモリを使うのかディスクから読むのか……とまぁ、SQLではどんな結果が欲しいか記述することと、それがどんなアルゴリズムを経て出力されるのか、は別次元の話です。

このことは、SQLで実現したい何がしかのビジネス的な論理レベルの要求と、そのクエリをコンピュータ上でどのように実現するかの物理レベルの要求とを、分離していると言えます*6。でまぁコレが嬉しいことのひとつは最適化の余地が広がるわけです。RDBMSのエンジン側では、そのSQLが何を実現したいのかの結果を変えない範囲において、SQLを好き勝手チューニングできる。

そんでまぁJavaに戻ってくるわけですが、さっきのSQLをStreamで書けばこんな感じになる。

Map<Person, Double> sumOfCostByBuyer = sales
        .collect(Collectors.groupingBy(
                Sale::getBuyer,
                Collectors.summingDouble(s -> {return s.getCost() > 10000 ? s.getCost() : 0;})));
sumOfCostByBuyer.forEach((k, v) -> System.out.println(k + ":" + v));

SQLのように、コレクションからどんな結果が欲しいか、が書かれています。コレクション「を」処理するのではなく、コレクション「に」処理を渡す。ループで処理するのかはたまたもっとややこしいアルゴリズムで処理するのかは知る由も無い、知らないことで最適化の余地が生じる。

……と、あれこれロマンあふれることを書いてしまったのだけれども。やはり後付で導入される代物なので、不都合であったりムリであったりとがググると色々見つかります。そこはまぁ……困ったら今まで通りのやり方で回避できるだけマシ、と思い込むことにしてます。

しかし!

そんな些細なことよりもよっぽど重要なLambda式とStreamを使う理由があります。それは……





COOL!!*7

これ以上に重要な理由などある筈も無い。

終わりに

明日は@muraken720さんです。

*1:java.util.stream.Collectors#toStringJoinerは廃止され、http://download.java.net/jdk8/docs/api/java/util/stream/Collectors.html#joining-java.lang.CharSequence-:title=java.util.stream.Collectors#joining に統合されているようです。

*2:http://download.java.net/jdk8/docs/api/java/util/StringJoiner.html はJava 8から追加されたクラス

*3:参照元URLでは、comparing になっているが、CompartorのLambda式での比較を書きたかったのでこうしている。

*4:年齢のcompareはそれでいいんかいって感じのテキトーなアレなのでスルーして頂きたく

*5:あまり意味を持たない例ですまぬ……すまぬ……

*6:そりゃまぁ実際にはそうキレイに分けられんけども、まぁ多少はね?

*7:出典:許斐剛「COOL -RENTAL BODY GUARD-」