クイズに挑戦:Collectorsの使用(上級者向け)
Collectorsクラスから想定どおりの結果を得るために注意すべきこと
著者:Simon Roberts、Mikalai Zaikin
2019年8月26日
過去にこのクイズの設問に挑戦したことがある方なら、どれ1つとして簡単な問題はないことをご存じでしょう。クイズの設問は、認定試験の中でも難しいものに合わせています。「中級者向け」「上級者向け」というレベルは、設問の難易度ではなく、対応する試験による分類です。しかし、ほとんどすべての場合において、「上級者向け」の方が難しくなります。設問は認定試験対策として作成しており、認定試験と同じルールの適用を意図しています。文章は文字どおりに解釈してください。回答者を引っかけようとする設問ではなく、率直に言語の詳細な知識を試すものだと考えてください。
collectメソッドと、Collectorsクラスのグループまたはパーティションのデータを使ってコレクションに結果を保存したいと思います。次のStudentクラスがあり、初期化済みで未使用のStream<Student> sがスコープ内にあるとします。このsには、さまざまな年齢の学生が含まれています。
class Student {
private String name;
private Integer age;
Student(String name, Integer age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public Integer getAge() { return age; }
}
次のコード部分のうち、18歳未満の学生の数と18歳以上の学生の数が最適な方法で表示されるものはどれですか。1つ選んでください。
-
s.collect(Collectors.groupingBy( Student::getAge() >= 18, Collectors.counting())) .forEach((c, d) -> System.out.println(d)); -
s.collect(Collectors.groupingBy( a -> a.getAge() >= 18, Collectors.mapping( Student::getName, Collectors.counting()))) .forEach((c, d) -> System.out.println(d)); -
s.collect(Collectors.partitioningBy( a -> a.getAge() >= 18, Collectors.counting())) .forEach((c, d) -> System.out.println(d)); -
List<Integer> l = Arrays.asList(0, 0); s.forEach(w -> { if (w.getAge() >= 18) { l.set(1, l.get(1) + 1); } else { l.set(0, l.get(0) + 1); }}); l.forEach(System.out::println);
解答:この設問は、データを収集し、ある基準に従ってグループ化するいくつかの方法を示しています。選択肢A、B、Cでは、CollectorsクラスのユーティリティとStream.collectメソッドを使ってこれを実現しようとしています。選択肢Dでは、その処理を手作業で行おうとしています。
まずは、簡単な選択肢から見ていきます。選択肢Aのコードには、重大な構文エラーが存在します。ここでは、メソッド参照を使用し、次の式でメソッドを呼び出そうとしています。
Student::getAge()
この構文は正しくありません。メソッド参照の構文をこのような形で使うことはできません。そのため、選択肢Aは誤りです。
通常、この試験では「人間コンパイラ」的な問題は避けられる傾向にあります。IDE全盛のこの時代では、コードを打ち込めばすぐに構文チェックが行われるため、そのようなスキルは役立つものではないからです。この選択肢が誤りであると判断できる理由がそれだけなら、実際の試験でこの選択肢が採用されることはないでしょう。しかし、この選択肢には、誤りであると判断できる理由が他に2つあります。1つは、別の選択肢にあるアプローチの方が設計的に優れていることです。少しばかりではありますが、実際の改善であることはもうすぐわかっていただけるはずです。2つ目の理由は、他に正解があるものの、この設問が単一選択問題であることです。そのため、別の答えを見つけた時点で、注意深くコードを見るようになり、エラーに気づくことになるはずです。ここでヒントとなるのは、この試験の際に、正しいと思われる最初の答えをそのまま解答に選ぶのは賢明でないということです。時間が足りない場合以外、他のすべての答えが確かに誤りであると納得するまで時間をかけるべきです。
注目すべきは、構文エラーを含む式
Student::getAge() >= 18
を正しい式
a -> a.getAge() >= 18
に置き換えれば、選択肢Aから正しい出力が得られ、その場合に問題となるのは設計の質だけであるという点です。
ただし、この時点で、選択肢Aをすぐに除外すべきであることは明らかであり、選択肢Aよりも優れた設計のアプローチが見つかって、その設計が優れている理由がわかることははっきりしています。
選択肢Bのコードは問題なくコンパイルでき、正しい結果が出力されます。これを理解するために、Collectionsクラスが持ついくつかの重要な動作について詳しく見てみます。groupingByメソッドは、関数(Function)を引数として受け取るCollector動作を生成します。この関数を、分類関数(classifier)と呼びます。たとえば、次のコードについて考えてみます。名前のストリームに対してgroupingByコレクション操作を実行するものです。
Stream.of("Fred", "Jim", "Sheila", "Chris",
"Steve", "Hermann", "Andy", "Sophie")
.collect(Collectors.groupingBy(n -> n.length()))
ここでの分類関数は、名前(String)を受け取ってその長さを返す次の式です。
n -> n.length()
結果として、表1に示すようなMap<Integer, List<String>>が生成されます。

表1:キーと、各キーに関連付けられた値
分類関数からキーとして返されるそれぞれの値がこのMapにどのように格納されているかと、各キーに関連付けられた値が、そのキーを生成したストリームのアイテムすべてを含むListであることに注目してください。
groupingBy動作の亜種として、「ダウンストリーム・コレクタ」を持つものがあります。ダウンストリーム・コレクタを使うことにより、特定のキーを生成するアイテムを、同種のアイテムすべてからなるリストにそのまま追加するのではなく、後続のコレクション操作を使って各アイテムを処理することができます。これは、Listに送信されるアイテムを処理するセカンダリ・ストリーム・プロセスのようなものです。ダウンストリームでよく使われるコレクタの1つが、countingコレクタです。このコレクタを処理で使うことにより、値を名前のリストからリストの要素数に変えることができます。
次のコード
Stream.of("Fred", "Jim", "Sheila", "Chris",
"Steve", "Hermann", "Andy", "Sophie")
.collect(Collectors.groupingBy(
n -> n.length(),
Collectors.counting()))
は、表2に示すMap<Integer, Long>を返します。

表2:ダウンストリーム・コレクタを使用した場合のキーと、各キーに関連付けられた値
選択肢Bの動作はこの考え方に似ていますが、異なる点もいくつかあります。まず、分類関数(すなわち、結果のMapのキー)が数値ではなくブール値であることです。これは問題ありません。18歳以上の学生と18歳未満の学生を分類するという目的に沿っているからです。しかし、選択肢Bのコードでのダウンストリーム操作は1つではなく、2つの操作を連鎖させています。もちろんこのような連鎖は許可されており、大変役立つこともあります。しかし、この設問の場合、最初のダウンストリーム・コレクタで実際に行っているのは、各StudentオブジェクトからStudentの名前を抽出するマッピング操作です。2つ目のダウンストリーム・コレクタでは、その結果として得られる名前の数を数えています。
マッピング操作が結果を誤ることはありません。このコードでは実質的に、18歳以上の学生と18歳未満の学生の名前が数えられ、同じ数値が生成されます。ただし、これは無駄な努力です。そのため、このコードは効率の面でも、可読性の面でも、最適なものではありません。無関係で紛らわしいコードが含まれており、混乱が生じるリスクがあるからです。このような無駄な努力をしていない別の選択肢が後ほど登場するため、そこから選択肢Bは誤りであることがわかります。選択肢Bでは正しいレスポンスが生成されますが、もっとも効率のよい選択肢ではありません。
Collectorsクラスには、groupingBy動作のファクトリに加えて、似たような結果を生成する別のファクトリも存在します。このファクトリは、partitioningByと呼ばれています。動作の違いは、任意の型のキーを持つMapを作成するのではなく、Boolean型のキーを持つMapを作成するという点だけです。そのため、分類関数として関数(Function)ではなく条件(Predicate)を指定します。選択肢Cでは、partitioningBy動作を使って正しい出力が行われています。これは、2つの理由で選択肢Bよりも優れています。1つ目は、学生の名前を抽出するという無駄な手順が含まれていないことです。2つ目は、partitioningByが、単純なtrue/falseの結果を使ってグループ化するという目的のみに特化したものであることです。そのため、設計的に(少しばかり)優れた選択です。同じ論理で、(構文が有効だったとしても)選択肢Aより優れています。
手作業で分類するコードを使っている選択肢Dでも、正しい結果が生成される可能性があります。重大な問題は、このコードが副作用によって動作していることです。具体的に言えば、ラムダ式の外側にある変数を変更している部分です。この種の動作は、安全に同時実行(パラレル実行)することができません。ストリームは簡単にパラレル・モードで実行できるからこそ、特にストリームベースのシステムではこの種のコードは避けるべきです。
なお、この考え方が、ラムダ式(およびその登場より前のメソッド・ローカル・クラス)が設計されたときに漠然と示されていたことに注目すべきです。具体的に言うなら、ラムダ式やネスト・クラスの中からは、実質的にfinalである場合を除き、他のメソッド・ローカル変数へのアクセスが禁止されているという点です。選択肢Dのコードでは、実質的にfinalな参照を使って、参照先のListに格納されている可変データにアクセスすることで副作用が発生しています。
このコードは問題なくコンパイルでき、ストリームが逐次実行されれば、正しい結果が出力されます。ただし、設計はよくなく、ストリームがパラレルであれば失敗します。設計に問題があり、ストリームがパラレルの場合は失敗するため、選択肢Dは誤りです。
正解は選択肢Cです。
Java Magazine December 2019の他の記事
プロパティベース・テストを習得する
Arquillian:簡単なJakarta EEテスト
ArchUnitでアーキテクチャの単体テストを行う
新しいJava Magazine
作ってみよう:自分だけのテキスト・エディタ(パート1)
クイズに挑戦:ループ構造の比較(中級者向け)
クイズに挑戦:スレッドとExecutor(上級者向け)
クイズに挑戦:ラッパー・クラス(中級者向け)
書評:Core Java, 11th Ed. Volumes 1 and 2
![]() |
Simon RobertsSimon Roberts:Sun Microsystemsがイギリスで初めてJavaの研修を行う少し前にSunに入社し、Sun認定Javaプログラマー試験とSun認定Java開発者試験の作成に携わる。複数のJava認定ガイドを執筆し、現在はフリーランスでPearson InformITにおいて録画やライブによるビデオ・トレーニングを行っている(直接またはO’Reilly Safari Books Onlineサービス経由で視聴可能)。OracleのJava認定プロジェクトにも継続的に関わっている。 |
![]() |
Mikalai ZaikinMikalai Zaikin:ベラルーシのミンスクを拠点とするIBA IT ParkのリードJava開発者。OracleによるJava認定試験の作成に携わるとともに、複数のJava認定教科書のテクニカル・レビューを行っている。Kathy Sierra氏とBert Bates氏による有名な学習ガイド『Sun Certified Programmer for Java』では、3版にわたってテクニカル・レビューを務めた。 |


