java-beginner.com ブログ

プログラミングを学習するブログ(Javaをメインに)

Stream APIを使い、グループ化して合計を求める

投稿日:

最終更新日:2022年11月19日

アイキャッチ

こんにちは。今回は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文字とします。

  1. HashMapを新規生成し、以下のように値を格納する。
    キー 内容
    String
    グループ化のキー
    List<Product>
    グループ化のキーを持つ商品のリスト
  2. グループのそれぞれに対して、価格の合計を求める。

実際に以下のように、実装しました。

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()メソッドの引数に記述するラムダ式で、グループ化のキーを返却することでグループ化がうまくできました。

グループ化のStream APIの説明図
図:グループ化のキーを指定する箇所は上図のように型宣言の一部分に対応しています。
グループ化のStream APIの説明図
図:ラムダ式の引数の型は上図のように型宣言のの一部分に対応しています。

(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を使うとグループ化のキーの分類を手間が省けることがわかりました。また、状況によってはひとつのメソッドチェーンで済むこともわかりました。

以上、参考になれば幸いです。