こんにちは。今回はStream APIを使ったグループ化についての記事です。
例えば、IDと価格を持つ商品クラスのデータ一覧が与えられていたとします。商品IDがA001, B001のような形式だとします。IDの先頭1文字をキーとしてグループ化し、価格の合計を求めたいとします。
今回は、そのような合計を求めるサンプルプログラムのJavaクラスを以下の3パターンで考えました。
- SampleAクラス
- Stream APIを使わないで、商品をグループ化し、価格の合計を計算する。
- SampleBクラス
- Stream APIを使って、商品をグループ化する。その後、Stream APIを使って、価格の合計を計算する。
- SampleCクラス
- Stream APIを使って、ひとつのメソッドチェーンで、商品をグループ化して価格の合計を計算する。
この記事では、最初にデータの与えられ方の前提を記載しました。そのあと、上記の3つのサンプルを載せました。
目次
データの前提と目的
最初に以下のような商品クラスを作ることにしました。
- クラス名
- Product
- フィールド
-
修飾子と型 変数名 用途 private String id 商品のID private int price 商品の価格 - その他
-
- 各フィールドに値を格納するための、コンストラクタを実装する。
- 各値のゲッターメソッドを実装する。
- 値の出力用に、toString()メソッドをオーバーライドする。
上記のようなクラスを以下のように作りました。
ソース
public class Product {
/** 商品のID*/
private String id;
/** 商品の価格 */
private int price;
public Product(String id, int price) {
super();
this.id = id;
this.price = price;
}
public String getId() {
return id;
}
public int getPrice() {
return price;
}
@Override
public String toString() {
return "Product [id=" + id + ", price=" + price + "]";
}
}
今回は、idはA001、B001というように、「アルファベット1文字 + 3桁の数字」という想定にします。
以下は、今回の目的の実装の基にするサンプルプログラムです。
Baseクラス
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Base {
public void test() {
System.out.println("-----------------------------");
System.out.println("実行するクラス");
System.out.println(getClass().getSimpleName());
System.out.println("-----------------------------");
// データ取得
List<Product> list = getData();
// データ出力
for (Product product : list) {
System.out.println(product);
}
// グループ化
Map<String, Integer> sums = calc(list);
// 結果出力
System.out.println("-----------------------");
System.out.println("グループごとの合計");
System.out.println("-----------------------");
System.out.println(sums);
}
protected Map<String, Integer> calc(List<Product> list) {
// グループ化の結果を格納するマップ
Map<String, Integer> sums = new HashMap<>();
// -----------------------------------
// TODO 以下、グループ化して合計を求める処理
// -----------------------------------
return sums;
}
private List<Product> getData() {
List<Product> list = new ArrayList<>();
// データ生成
list.add(new Product("A001", 10));
list.add(new Product("B001", 100));
list.add(new Product("B002", 200));
list.add(new Product("C001", 1000));
list.add(new Product("C002", 2000));
list.add(new Product("C003", 3000));
// シャッフル
Collections.shuffle(list);
return list;
}
}
上記サンプルのgetData()メソッドで、Productクラスのリストとしてデータを取得し、それをある条件でグループ化して価格の合計を計算するという想定です。なお、上記メソッドで使っているCollections.shuffle()メソッドは引数に渡されたListの要素をランダムに並び替えるメソッドです。
test()メソッドは他のテスト用クラスから呼び出す想定です。変数listにデータを格納し、calc()メソッドに渡しています。
上記クラスの拡張クラスではcalc()メソッドをオーバーライドし、calc()メソッド内で商品をグループ化して価格の合計を計算する処理を書いていく方針です。
以下は上記サンプルのテストです。
上記サンプルのテスト
public class BaseTest {
public static void main(String[] args) {
Base base = new Base();
base.test();
}
}
結果
-----------------------------
実行するクラス
Base
-----------------------------
Product [id=A001, price=10]
Product [id=C003, price=3000]
Product [id=B002, price=200]
Product [id=B001, price=100]
Product [id=C001, price=1000]
Product [id=C002, price=2000]
-----------------------
グループごとの合計
-----------------------
{}
上記は、test()メソッドの実行結果です。calc()メソッドが返却したHashMapは、この段階では何も格納されません。そのため、出力結果は空欄です。
なお、Collections.shuffle()メソッドを使っているため、出力結果のように、Listの要素の順番はランダムになっています。
Stream APIを使わないでグループ化する
後のサンプルと比較がしやすいように、以下の手順で合計を計算することにします。ただし、今回は商品のグループ化のキーはidの最初の1文字とします。
- HashMapを新規生成し、以下のように値を格納する。
キー 内容 String
グループ化のキーList<Product>
グループ化のキーを持つ商品のリスト - グループのそれぞれに対して、価格の合計を求める。
実際に以下のように、実装しました。
SampleAクラス
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class SampleA extends Base {
@Override
protected Map<String, Integer> calc(List<Product> list) {
// グループ化の結果を格納するマップ
Map<String, Integer> sums = new HashMap<>();
// (A-1) グループ化のキーにする文字を用意
Set<String> targetSigns = new HashSet<>();
for (Product product : list) {
targetSigns.add(product.getId().substring(0, 1));
}
// (A-2) 商品をグループ化する。
Map<String, List<Product>> group = new HashMap<>();
Iterator<String> targetSignsIterator = targetSigns.iterator();
while (targetSignsIterator.hasNext()) {
String targetSign = targetSignsIterator.next();
List<Product> products = new ArrayList<>();
for (Product product : list) {
String sign = product.getId().substring(0, 1);
if (targetSign.equals(sign)) {
products.add(product);
}
}
group.put(targetSign, products);
}
// (A-3) グループ各々について、価格を合計する。
Iterator<String> groupKeysIterator = group.keySet().iterator();
while (groupKeysIterator.hasNext()) {
String sign = groupKeysIterator.next();
// (A-4) 指定したグループの合計を求める。
int sum = 0;
List<Product> products = group.get(sign);
for (Product product : products) {
sum += product.getPrice();
}
sums.put(sign, sum);
}
return sums;
}
}
(A-1)では、後にグループ化のキーに使う文字の集合を保持しておくために、HashSet型のインスタンスを生成し、変数targetSignsに格納しています。
直後のfor文では、Listに格納されているProductに対して、IDの先頭1文字をtargetSignsの要素に追加しています。HashSetでは同じ要素を重複して持つことはないため、targetSignsにはグループ化のキーが重複なしに格納されます。Listではadd()メソッドを呼び出した分だけ要素がどんどん増えますが、Setの場合、既に保持しているものをadd()メソッドで追加しても内容に変化はありません。
変数targetSignsに格納されている文字は、[A, B, C]となります。要するに、グループ化のキーの種類が1個ずつの状態となります。
(A-2)では、最初にStringとList<Product>のHashMapを生成し、変数groupに格納しています。変数groupのキーにはグループ化のキーを格納し、要素には対象となるProductのListを格納する方針です。
後のwhile文で、変数targetSignsの要素の分だけ繰り返し処理をしています。
繰り返し処理の最初で、変数signに変数targetSignsの要素を格納しています。
その次に、変数productsを定義しています。これはグループ化のキーを持つProductを格納するためのListです。for文でlistの各要素のIDの最初の1文字について、変数signと等しいかどうかを判定しています。等しい場合、変数productsの要素に追加しています。
繰り返し処理の最後で、変数groupにキーとproductsを格納しています。
(A-3)では、グループごとの価格の合計を計算しています。groupのキーそれぞれに対して、List<Product>の各要素の価格を合計しています。キーと合計は、calc()メソッドの最初に定義した変数sumsに追加しています。
以下で、上記クラスのテストをしました。
SampleAクラスのテスト
public class SampleATest {
public static void main(String[] args) {
SampleA sampleA = new SampleA();
sampleA.test();
}
}
結果
-----------------------------
実行するクラス
SampleA
-----------------------------
Product [id=C002, price=2000]
Product [id=B001, price=100]
Product [id=A001, price=10]
Product [id=C003, price=3000]
Product [id=B002, price=200]
Product [id=C001, price=1000]
-----------------------
グループごとの合計
-----------------------
{A=10, B=300, C=6000}
上記結果のように、グループごとの合計が計算できました。
Stream APIを使ってグループ化する
方法1:グループ化、合計算出それぞれをStream APIで実装する
今度はStream APIを使って、グループ化し、合計を求めようと思います。
以下のように、グループ化の処理と、合計を求める処理にStream APIを使ってみました。
ソース
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class SampleB extends Base {
@Override
protected Map<String, Integer> calc(List<Product> list) {
// グループ化の結果を格納するマップ
Map<String, Integer> sums = new HashMap<>();
// (B-1) グループ化のキーにする文字を用意
// 後のStream APIの処理に含まれるので、処理なし。
// (B-2) 商品をグループ化する。
Map<String, List<Product>> group = list
.stream()
.collect(Collectors.groupingBy(
p -> p.getId().substring(0, 1)));
// (B-3) グループ各々について、価格を合計する。
Iterator<String> groupKeysIterator = group.keySet().iterator();
while (groupKeysIterator.hasNext()) {
String sign = groupKeysIterator.next();
// (B-4) 指定したグループの合計を求める。
int sum = group.get(sign)
.stream()
.mapToInt(product -> product.getPrice())
.sum();
sums.put(sign, sum);
}
return sums;
}
}
コメントの番号はSampleAと比較しやすいようにしました。
(B-1)では、(A-1)の処理内容が不要になったので、何も処理がありません。グループのキーに関しては後のStream APIの箇所で自動的に種類別にしてくれます。
(B-2)では、Stream APIを使って、Productをグループ化しています。この個所はAPIドキュメントの「クラスCollectors」のページに載っていたサンプル「Group employees by department」を参考にしました。
そのサンプルを参考した結果、Collectors.groupingBy()メソッドの引数に記述するラムダ式で、グループ化のキーを返却することでグループ化がうまくできました。
(B-3)では、Stream APIを使って、価格の合計を計算しています。mapToInt()メソッドを使って、価格(Integer型)から構成されるストリームを生成することができます。Integer型のストリームではsum()メソッドを呼び出すことができます。このメソッドは合計を返却します。
以下、動作の確認です。
SampleBクラスのテスト
public class SampleBTest {
public static void main(String[] args) {
SampleB sampleB = new SampleB();
sampleB.test();
}
}
結果
-----------------------------
実行するクラス
SampleB
-----------------------------
Product [id=C001, price=1000]
Product [id=B001, price=100]
Product [id=C002, price=2000]
Product [id=C003, price=3000]
Product [id=B002, price=200]
Product [id=A001, price=10]
-----------------------
グループごとの合計
-----------------------
{A=10, B=300, C=6000}
上記のように、SampleAと同じ結果が出力されました。
方法2:グループ化と合計をStream APIのひとつのメソッドチェーンで実装する
なお、Stream APIを使って、グループ化して合計を計算する処理を一つのメソッドチェーンで済ますこともできます。
そのための準備として、以下のようにProductを拡張したクラスを作りました。
ProductWithKey
public class ProductWithKey extends Product {
/** グループ化のキー */
private String key;
public ProductWithKey(String id, int price) {
super(id, price);
this.key = id.substring(0, 1);
}
public String getKey() {
return key;
}
}
上記クラスでは、コンストラクタでグループ化のキーをフィールド変数keyに設定しています。また、その値を取得するためのgetKey()メソッドを実装しています。
これを使って、以下のようにグループ化と合計を計算する処理を実装できました。
SampleC
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class SampleC extends Base {
@Override
protected Map<String, Integer> calc(List<Product> list) {
return list
.stream()
.map(p -> new ProductWithKey(p.getId(), p.getPrice()))
.collect(Collectors.groupingBy(
ProductWithKey::getKey,
Collectors.summingInt(ProductWithKey::getPrice)));
}
}
上記はAPIドキュメントの「クラスCollectors」のページに載っていたサンプル「Compute sum of salaries by department」を参考にして作りました。
「ProductWithKey::getKey」はラムダ式を使おうとしましたが、エラーが出てしまったので、この形式で記述しました(メソッド参照と呼ばれる記述方法)。ProductWithKeyは、この記述方法のために作ったということです。
SampleCクラスのテスト
public class SampleCTest {
public static void main(String[] args) {
SampleC sampleC = new SampleC();
sampleC.test();
}
}
結果
-----------------------------
実行するクラス
SampleC
-----------------------------
Product [id=B001, price=100]
Product [id=C003, price=3000]
Product [id=B002, price=200]
Product [id=A001, price=10]
Product [id=C002, price=2000]
Product [id=C001, price=1000]
-----------------------
グループごとの合計
-----------------------
{A=10, B=300, C=6000}
上記のように、SampleBと同じ結果が出力されました。
以上、グループ化と合計の計算を3つの方法で紹介しました。Stream APIを使うとグループ化のキーの分類を手間が省けることがわかりました。また、状況によってはひとつのメソッドチェーンで済むこともわかりました。
以上、参考になれば幸いです。