RM-BLOG

IT系技術職のおっさんがIT技術とかライブとか日常とか雑多に語るブログです。* 本ブログに書かれている内容は個人の意見・感想であり、特定の組織に属するものではありません。/All opinions are my own.*

【Javascript】でJavaのStream APIでいうcollectみたいなことをやりたかったんだがreduceしかなかったので仕方なくreduceでやったら思いのほかあっさり出来た上にJavaも結局collectなんか使わなかった件

タイトルの通りなのだが…

仕事の都合でJavascriptで集約処理みたいなことをする必要が出てきて、JavaのStream APIみたいのないかな、というのを探したのがスタートだった。
最初パっと思いついたのは、「JavaのStream APIでいうところのcollectみたいなやつ、javascriptにもないかな」だった。
ちょっとググってみるとMozillaのサイトJavaのStream APIっぽい関数群がいくつか並んでいるのだが(reduce、join、filter、flatMap、forEachなどなど)、collectが見当たらない。
で、reduceはあったので、これ使って頑張るしかないのか、と思って挑んでみたら、思いのほかあっさりできた、という話。
そして、そのあと結局Javaにも手を出してやってみたら、collectなんか使わなくてもやりたいことが実現できました、という苦労話。。


はじめに

以下のような配列があったとする

var arr = [
    {id:'1',data_id:'id1data1',price:100},
    {id:'2',data_id:'id2data1',price:200},
    {id:'2',data_id:'id2data2',price:210},
    {id:'3',data_id:'id3data1',price:300},
    {id:'3',data_id:'id3data2',price:310},
    {id:'3',data_id:'id3data3',price:320}
];


これを"id"が同一の値をキーにして集約したかった。
(data_idは配列にして、priceは合算して、それぞれ集約)
イメージ的には下記のような結果を欲していた

{ '1': { datas: [ 'id1data1' ], sumPrice: 100 },
  '2': { datas: [ 'id2data1', 'id2data2' ], sumPrice: 410 },
  '3': { datas: [ 'id3data1', 'id3data2', 'id3data3' ], sumPrice: 930 } }

 

実装

どうするかというと、Javascriptには配列に「reduce」というプロパティがあるのでこれを使う。
使い方はこのページに載っている(基本的なことしか載ってないが…)

let red = arr.reduce((acc,cur)=>{
    let obj = acc[cur.id] || {datas:[] , sumPrice: 0};
    obj.sumPrice += cur.price;
    obj.datas.push(cur.data_id);
    acc[cur.id] = obj;
    return acc;
},{});
console.log(red);


こんだけである。
作り上げてみると思ったより簡単だった。

reduceの第一引数はコールバック関数で、第二引数は初期値。
この辺はJavaと似ている(引数の位置が逆だが)
この第二引数=初期値を指定しないと、コールバック関数の第一引数accumulatorに一番目の要素(id1data1)が、第二引数curretntValueに二番目の要素(id2data1)が入ってくる。
単純に全要素の特定の項目の値を集計するとか、そういうレベルならこれでもいいのだが、集約結果を別の形に整形しておきたい場合、第一引数が元の要素のままだと困ることがある。
そこで、第二引数に空っぽのオブジェクト→「{}」のことを渡して、初回で初期化→let obj = acc[cur.id] || {datas: , sumPrice: 0};するように工夫する。
初回はacc[cur.id]が当然nullなので{datas:
, sumPrice: 0};が渡されて、これがこの集約結果を格納するオブジェクトの基礎形になっている。
最後にacc[cur.id] = obj;で集約結果を詰めなおして次の要素に引き渡す。
これによってID別の集約ができる。

Javaだとどうやって実現するのか

多分いろいろやり方があるんだと思うが、個人的にたどり着いたのがCollectors#toMapを使うやり方である。
「ID別の集約結果」を保持するための別クラスを用意し、それに結果を蓄積するようにしていく。

まずは初期の配列を用意する。
の、前に、配列の各要素に用いるクラスを用意する。

static class Data {
	private String id;
	private String dataId;
	private int price;

	public Data(String id,String dataId,int price) {
		this.id = id;
		this.dataId = dataId;
		this.price = price;
	}
	public String getId() {
		return this.id;
	}
	public String getDataId() {
		return this.dataId;
	}
	public int getPrice() {
		return this.price;
	}
	public String toString() {
		return "id:" + getId() + ",DataId:" + getDataId() + ",Price:" + String.valueOf(getPrice());
	}
}

toStringはdebug用である(いちいちSystem.out.printnの中で加工するのが面倒くさいから)

で、このオブジェクトをJavascriptと同じケースの分作って配列にする。

Data[] arr = new Data[] {
	new Data("1","id1data1",100),
	new Data("2","id2data1",200),
	new Data("2","id2data2",210),
	new Data("3","id3data1",300),
	new Data("3","id3data2",310),
	new Data("3","id3data3",320)
};


さらに、このオブジェクトをID別に集約した結果を格納するクラスも用意する。

static class SummaryData {
	private List<String> dataList = new ArrayList<String>();
	private int summaryPrice = 0;

	public SummaryData(Data data) {
		this.dataList.add(data.getDataId());
		this.summaryPrice += data.getPrice();
	}
	public SummaryData summary(SummaryData sd) {
		this.dataList.addAll(sd.getDataList());
		this.summaryPrice += sd.getSummaryPrice();
		return this;
	}
	public List<String> getDataList() {
		return this.dataList;
	}
	public int getSummaryPrice() {
		return this.summaryPrice;
	}
	public String toString() {
		StringBuilder sb = new StringBuilder();
		sb.append("dataId=");
		for(String dataId : getDataList()) {
			sb.append(dataId + ",");
		}
		sb.append("summaryPrice=" + String.valueOf(getSummaryPrice()));

		return sb.toString();
		}
}

相変わらず、toStringはdebug用である。

ここまでが準備。
肝心のStream APIが以下。

Map map = Arrays.asList(arr).stream()
	.collect(
		Collectors.toMap(
			Data::getId
			,SummaryData::new
			,(s1,s2)->{
				return s1.summary(s2);
			}
		)
	);

	map.forEach((k,v)->{
		System.out.println(k + "," + v);
	});


toMapの第一引数にはKeyとなる値(を決定するFunctionオブジェクト)を渡す。
今回はID別に集約したいのでData#getIdとする(記述はそれのメソッド参照)。

第二引数にはValueとなる値(を決定するFunctionオブジェクト)を渡す。
今回はSummaryDataクラスがそれに該当するのでSummaryDataのコンストラクタを指定する(こっちも記述はメソッド参照)。
なお、正確に言うとこの第二引数のメソッド参照は

(Data d) -> {
	return new SummaryData(d);
}

の省略形である(まあ、SummaryDataの定義にこのコンストラクタしかないから決まってるんだが)
Collectors#toMapの定義は、(Java Doc参照)

  • 第一引数:Function<? super T,? extends K> keyMappe
  • 第二引数:Function<? super T,? extends U> valueMapper

なので、第一引数も第二引数もFunctionオブジェクトであり、かつ、Functionオブジェクトが受け取ることになる第一引数の型は「? super T」型で共通している→第一引数も第二引数も同じ型の引数を取ることを前提としている。
このため第二引数のFunctionは、「DataをもらってSummaryDataを返す」処理でなくてはならない。
ちなみに、第一引数も”「? super T」型をもらって「? extends K」型を返すFunction”で、このケースでは「DataをもらってString(Data::getId)を返す」処理として定義している。

第三引数には実際の集約処理の中身をBinaryOperatorで書く。
このBinaryOperatorはメソッド定義上「BinaryOperator<U> mergeFunction」となっており、この型Uは第二引数で出てきた型Uと連動している。
したがってSummaryDataを受けとる必要がある。
また、型パラメータは一つしかないので、「SummaryDataをもらってSummaryDataを返す」処理にする必要がある。
さらにいえばBinaryOperatorのため引数はSummaryData2つになる。
これに対応するため、SummaryDataに下記の定義のメソッドを用意している。

	...
	public SummaryData summary(SummaryData sd) {
		this.dataList.addAll(sd.getDataList());
		this.summaryPrice += sd.getSummaryPrice();
		return this;
	}
	...


ここが個人的に少し悩んだところで、Javascriptのケースでいうと、集約処理の過程で「もらう」のはあくまで最小単位であるDataオブジェクトだと思っていたため、Dataを引数にとる集約処理しか最初は作っていなかった。
ただこの考え方がBinaryOperatorの定義(同一の型2つを引数にもらって同一の型で返す必要がある)とどうしても相容れず、どうしたものかと悩んだ。
集約の過程で、次の要素を集約するための中間の(最終的には全部の、になるが)蓄積結果として、戻り値をSummaryDataにするのは大賛成なのだが、引数はあくまでDataであり、SummaryDataにするという発想にならなかった。
今でもなんかこのつくりには微妙な違和感があるが…まあ…できたからいいかという。。

この実行結果は以下の通りになる。(Map#forEachで標準出力した結果)

1,dataId=id1data1,summaryPrice=100
2,dataId=id2data1,id2data2,summaryPrice=410
3,dataId=id3data1,id3data2,id3data3,summaryPrice=930

正常に処理されているかを知りたかっただけだったので、単なるtoStringで中身をバーッと出してるだけになっており、見栄えは悪いが、結果の正当性は確認できたので良しとする。

なお、こと今回の処理をするだけであれば、SummaryDataクラスに定義した以下2つのメソッド

	...
	public List<String> getDataList() {
		return this.dataList;
	}
	public int getSummaryPrice() {
		return this.summaryPrice;
	}
	...

は、必要ない(実際、使ってない)。
だが結果を後続で取り扱う場合には必要だろうな、と思ったので(設計的な思想に基づき)残してある。
まあこれは余談である。w

おわりに

Javaで実装することを考えた場合、「ID別に集約する」という考え方に取りつかれていて、当初は「Collectors#groupingBy(Stream#collect)を使う」としか考えてなかった(それ以外の発想がなかった)。
このため「Javascriptにはcollectがないのか…」と、調べたときに軽く絶望したのだった。
これが冒頭の記述である。
ふたを開けてみれば、javascriptはreduceで十分やりたいことが実現できたし、JavaにしてもStream#collectなんて使うことなく普通にCollectors#toMapで片づけたことになる。
最初に結果のイメージ(ID別に集約したMapにする、というイメージ)が先行して出てきたこともあり、漠然と「Collectors#groupingBy(Stream#collect)を使う」という考えに取りつかれ、視野が狭くなっていたのは反省点と言えるだろう…
というかまあ単純にこの辺のStream APIの理解がやはりまだ足りないんだろうなあ…
というわけで、断念したが、Javaに関してはreducceやgroupingByでもできなくはない気がしている。

Javascriptをreduceで実装できた時点で「なんだ、こんなアッサリでいけるのか」と思ったが、JavaをtoMapで実装してみるとJavascriptよりもっとアッサリしていて、見栄えは恐らくJavaのほうがいい。。
そんなもんなのかね。
まあ実際のところそんな難しい集計はしてないしな。
クラス定義とかある分、総合的な行数はJavaのほうが増えそうだが、その分集約処理の実態はスマートになる…という感じか。

この件の最初(最低限)の目的はあくまで「JavascriptでID別の集約をする」だったので、reduce使って実装できた時点でもう目的は達したのだが、さらにJavaの実装にも踏み込んで学習できたのは良かったと思う。
プロジェクトの事情で言語を好き勝手に変えることは難しいことも多いはずなので、いろいろな言語のこうした「似た色の処理のテクニック」には、なるべく幅広く手を伸ばして知見を広めていきたいなあ、と思ったケースだった。