
キーブレイク処理をジェネリクスで汎用化する
キーブレイク処理とは
業務アプリケーションの開発をやっていると、キーブレイク処理を書く場面にしばしば遭遇する。COBOLerなど昔からプログラムを書いている人にはお馴染みの処理であるが、知らない人のために說明すると、(ソート済みの)明細データの集合を順次処理していき、特定のキー項目が変わったタイミングで何らかの処理を差し込みながら出力を行っていく処理方法のことである。
具体例を示そう。以下のような売上明細クラスがある。※C#となっているが実際はJava
public class SalesLine {
// 売上日
private String salesDate;
// 商品コード
private String productCode;
// 販売単価
private int unitPrice;
// 販売数量
private int quantity;
// 販売金額
private int amount;
DBから取得した売上明細リスト(List<SalesLine>
)があり、SQLで既に商品コード順、売上日順にソート済みであるとする。このデータを元に帳票を出力するプログラムでは、各明細行を表示用にフォーマットするとともに、商品コード単位で小計行を出す要件だったとしよう。
出力例は以下である。
明細 2020-04-01 A 2個 200円
明細 2020-04-01 A 3個 300円
明細 2020-04-02 A 1個 100円
明細 2020-04-02 A 1個 100円
小計 A 7個 700円
明細 2020-04-01 B 1個 150円
明細 2020-04-02 B 2個 300円
明細 2020-04-02 B 2個 300円
小計 B 5個 750円
明細 2020-04-01 C 2個 400円
小計 C 2個 400円
この帳票出力プログラムの実装例は以下である。
public String outputReport(List<SalesLine> sales) {
StringBuilder sb = new StringBuilder();
String currentProductCode = null;
int subtotalQty = 0;
int subtotalAmount = 0;
for (SalesLine sl: sales) {
String productCode = sl.getProductCode();
if (!productCode.equals(currentProductCode)) {
// キーブレイク時は小計行を出力
if (currentProductCode != null) {
sb.append(makeSubtotalLine(currentProductCode, subtotalQty, subtotalAmount)).append("\n");
}
currentProductCode = productCode;
subtotalQty = 0;
subtotalAmount = 0;
}
sb.append(makeNormalLine(sl)).append("\n");
subtotalQty += sl.getQuantity();
subtotalAmount += sl.getAmount();
}
// 最後の小計グループを処理
sb.append(makeSubtotalLine(currentProductCode, subtotalQty, subtotalAmount)).append("\n");
return sb.toString();
}
private String makeNormalLine(SalesLine sl) {
return String.format("明細 %s %s %s個 %s円",
sl.getSalesDate(), sl.getProductCode(), sl.getQuantity(), sl.getAmount());
}
private String makeSubtotalLine(String code, int qty, int amount) {
return String.format("小計 %s %s個 %s円", code, qty, amount);
}
如何にも手続き的であるが、誰が書いても大体こんな処理の流れになるだろう。
キーブレイクするまでの途中結果を退避しておき、キーブレイクのタイミングでそのデータを用いて小計行を出力(11-14行目)。退避用の変数をリセットするとともに現在のキーを切り替える(15-17行目)。ループから抜けると未処理の明細行が残っているので、最後にもう一度小計行を出力(23-24行目)。
このようなキーブレイク処理は、アプリケーションの種類によっては頻繁に出てくる。Stream APIでグルーピングして処理すればよいのでは?という意見もあろうが、データ量が多くSQLでソートするのが理にかなっている場合もあり、やはりこのような処理はいまだに必要だ。
問題なのは、かなり典型的な処理であるのに、容易にバグが発生しがちな点である。キーの判定や、1行目の処理、最後の未処理行の処理、といったところで実装を誤りがちなのだ。
このような典型的な処理こそ、共通化して再利用するようにしたい。
ジェネリクスによる解決
繰り返し出現する処理パターンの汎用化には、ジェネリクスを用いることができる。
先ほどの手続き的な処理をいきなりジェネリクスを使って書き直すのは少々飛躍があるので、まずはキーブレイク処理を行うクラスを作成する。
public class KeyBreakProcessor {
private List<SalesLine> lines;
public KeyBreakProcessor(List<SalesLine> lines) {
this.lines = lines;
}
public void execute(Function<SalesLine, String> keyGenerator, Consumer<SalesLine> lineProcessor,
BiConsumer<String, List<SalesLine>> keyBreakLister) {
String currentKey = null;
List<SalesLine> subList = new ArrayList<>();
for (SalesLine line : lines) {
String key = keyGenerator.apply(line);
if (!key.equals(currentKey)) {
if (currentKey != null) {
keyBreakLister.accept(currentKey, subList);
subList = new ArrayList<>();
}
currentKey = key;
}
lineProcessor.accept(line);
subList.add(line);
}
keyBreakLister.accept(currentKey, subList);
}
}
すでにジェネリクスを使ってるじゃないかって?
キーブレイク処理というテンプレート処理に差し込む具体的な処理は、メソッドの引数としてラムダ式で記述して渡せるように関数型インターフェースを導入した。そのため、そこは一足先にジェネリクスを使用する形となった。しかしまだSalesLine
という具体的な型が残っている。
この時点で、レポート出力プログラム側は以下のような実装となった。
public String outputReportBetter(List<SalesLine> sales) {
KeyBreakProcessor kbp = new KeyBreakProcessor(sales);
final StringBuilder sb = new StringBuilder();
// キーの生成
Function<SalesLine, String> keyGenerator = SalesLine::getProductCode;
// 1明細行を処理して出力
Consumer<SalesLine> processLine = sl -> sb.append(makeNormalLine(sl)).append("\n");
// キーでグループされた明細行を処理し、小計を出力
BiConsumer<String, List<SalesLine>> subTotal = (code, lines) -> {
int qty = lines.stream().mapToInt(SalesLine::getQuantity).sum();
int amount = lines.stream().mapToInt(SalesLine::getAmount).sum();
sb.append(makeSubtotalLine(code, qty, amount)).append("\n");
};
kbp.execute(keyGenerator, processLine, subTotal);
return sb.toString();
}
ラムダ式から、外側のスコープのStringBulder
の中身をいじっているのはお行儀が悪いが、ここでは目を瞑るとしよう。
これが動作することを確認(もちろんユニットテストを使って、だ)したら、ついに最終的なジェネリクス化を施す時だ。
といっても、この作業はほとんど単純作業である。まずは可変となる型を洗い出す。SalesLine
がその対象であるのは明らかだろう。
List<SalesLine> subList = new ArrayList<>();
さらに、キーもString
型とは限らず、Integer
型だったり、あるいは複数の項目を組み合わせた独自型だったりするだろう。
String key = keyGenerator.apply(line);
というわけで、SalesLine
→型引数L
、String
→型引数K
の置き換えを行うと以下のようなコードとなる(クラス名も変更している)。
public class GeneralKeyBreakProcessor<L, K> {
private List<L> lines;
public GeneralKeyBreakProcessor(List<L> lines) {
this.lines = lines;
}
public void execute(Function<L, K> keyGenerator, Consumer<L> lineProcessor,
BiConsumer<K, List<L>> keyBreakLister) {
K currentKey = null;
List<L> subList = new ArrayList<>();
for (L line: lines) {
K key = keyGenerator.apply(line);
if (!key.equals(currentKey)) {
if (currentKey != null) {
keyBreakLister.accept(currentKey, subList);
subList = new ArrayList<>();
}
currentKey = key;
}
lineProcessor.accept(line);
subList.add(line);
}
keyBreakLister.accept(currentKey, subList);
}
}
型引数名は、英大文字1文字とすることが多い。S
、T
、U
、と順に割り当てていくパターンと、KeyのK
、ValueのV
といったふうに多少意味をこめた名前を使うパターンがあるが、ここでは後者のやり方で名付けた。
レポート出力プログラム側は以下のとおり。
public String outputReportWithGenerics(List<SalesLine> sales) {
GeneralKeyBreakProcessor<SalesLine, String> gkbp = new GeneralKeyBreakProcessor<>(sales);
final StringBuilder sb = new StringBuilder();
// キーの生成
Function<SalesLine, String> keyGenerator = SalesLine::getProductCode;
// 1明細行を処理して出力
Consumer<SalesLine> processLine = sl -> sb.append(makeNormalLine(sl)).append("\n");
// キーでグループされた明細行を処理し、小計を出力
BiConsumer<String, List<SalesLine>> subTotal = (code, lines) -> {
int qty = lines.stream().mapToInt(SalesLine::getQuantity).sum();
int amount = lines.stream().mapToInt(SalesLine::getAmount).sum();
sb.append(makeSubtotalLine(code, qty, amount)).append("\n");
};
gkbp.execute(keyGenerator, processLine, subTotal);
return sb.toString();
}
こちらは、キーブレイク処理クラスを生成する際に型引数の具体的な値を指定するようになったことを除けば、コードに変更はない。
ジェネリクスを用いた処理の汎用化の流れは以下となる。
- 切り出して汎用化したい処理を、まずは具体的な型のままで書く
- その処理の中で、型を可変にしたいものを洗い出す
- それらを型引数に置き換え、ジェネリクス化
Template Methodパターン
今回汎用化したキーブレイク処理を改めて見てみる。
public void execute(Function<L, K> keyGenerator, Consumer<L> lineProcessor,
BiConsumer<K, List<L>> keyBreakLister) {
K currentKey = null;
List<L> subList = new ArrayList<>();
for (L line: lines) {
K key = keyGenerator.apply(line);
if (!key.equals(currentKey)) {
if (currentKey != null) {
keyBreakLister.accept(currentKey, subList);
subList = new ArrayList<>();
}
currentKey = key;
}
lineProcessor.accept(line);
subList.add(line);
}
keyBreakLister.accept(currentKey, subList);
}
汎用的な処理の手続きがテンプレート化され、可変処理部分は、引数で渡された関数インターフェースを用いて行っている。
これはまさにTemplate Method
パターンである。
具体的な処理の実装は、サブクラス化してフックメソッドをオーバーライドする代わりに、ラムダ式を書いて引数で渡す形になっている(前述の帳票出力プログラムのコードを参照)。
いちいちサブクラスを書かなくていいというメリットがあるが、反面、フックしたい処理が多くなるとラムダ式が増えてごちゃつくという欠点はある。
流暢なAPI
最終的な帳票出力プログラムのコード記述を再掲する。
public String outputReportWithGenerics(List<SalesLine> sales) {
GeneralKeyBreakProcessor<SalesLine, String> gkbp = new GeneralKeyBreakProcessor<>(sales);
final StringBuilder sb = new StringBuilder();
// キーの生成
Function<SalesLine, String> keyGenerator = SalesLine::getProductCode;
// 1明細行を処理して出力
Consumer<SalesLine> processLine = sl -> sb.append(makeNormalLine(sl)).append("\n");
// キーでグループされた明細行を処理し、小計を出力
BiConsumer<String, List<SalesLine>> subTotal = (code, lines) -> {
int qty = lines.stream().mapToInt(SalesLine::getQuantity).sum();
int amount = lines.stream().mapToInt(SalesLine::getAmount).sum();
sb.append(makeSubtotalLine(code, qty, amount)).append("\n");
};
gkbp.execute(keyGenerator, processLine, subTotal);
return sb.toString();
}
execute
メソッドの引数に渡す3つのラムダ式を、引数に直接書いてしまうとコードがごちゃごちゃする上に、それぞれが何の処理なのかわからなくなるため、先に変数に代入するようにしている。それぞれのラムダ式の役割を変数名で表現しており、いわゆる説明変数
というテクニックだ
それでも、やはりちょっと読みづらい。頻繁に使われるAPIは、もうひと手間加えて可読性を高めるようにしたい。
流暢なAPI(Fluent APi)
のテクニックを使ってAPIを書き直すと、帳票出力プログラム側は以下のように書くことができる。
public String outputReportWithFluent(List<SalesLine> sales) {
FluentKeyBreakProcessor<SalesLine, String, String, String> processor = new FluentKeyBreakProcessor<>();
List<String> groupList =
processor.source(sales)
.key(SalesLine::getProductCode)
.eachLine(sl -> makeNormalLine(sl))
.whenKeyChanged((key, list1, list2) -> {
String lines = list2.stream().collect(Collectors.joining("\n")) + "\n";
int qty = list1.stream().mapToInt(SalesLine::getQuantity).sum();
int amount = list1.stream().mapToInt(SalesLine::getAmount).sum();
return lines + makeSubtotalLine(key, qty, amount) + "\n";
})
.execute();
return groupList.stream().collect(Collectors.joining());
※Fluent API化のついでに、ラムダ式から外側のオブジェクトをいじるというお行儀の悪さを直した。
キーブレイク処理クラスは以下のように実装している(少し長い)。
public class FluentKeyBreakProcessor<L, K, L2, G> {
private List<L> lines;
private Function<L, K> keyGenerator;
private Function<L, L2> lineProcessor;
private KeyBreakProcessor<K, L, L2, G> keyBreakProcessor;
public FluentKeyBreakProcessor<L, K, L2, G> source(List<L> lines) {
this.lines = lines;
return this;
}
public FluentKeyBreakProcessor<L, K, L2, G> key(Function<L, K> keyGenerator) {
this.keyGenerator = keyGenerator;
return this;
}
public FluentKeyBreakProcessor<L, K, L2, G> eachLine(Function<L, L2> lineProcessor) {
this.lineProcessor = lineProcessor;
return this;
}
public FluentKeyBreakProcessor<L, K, L2, G> whenKeyChanged(KeyBreakProcessor<K, L, L2, G> keyBreakProcessor) {
this.keyBreakProcessor = keyBreakProcessor;
return this;
}
public List<G> execute() {
List<G> groupList = new ArrayList<>();
K currentKey = null;
List<L> subList = new ArrayList<>();
List<L2> subList2 = new ArrayList<>();
for (L line: lines) {
K key = keyGenerator.apply(line);
if (!key.equals(currentKey)) {
// キーブレイク
if (currentKey != null) {
G group = keyBreakProcessor.apply(currentKey, subList, subList2);
groupList.add(group);
subList = new ArrayList<>();
subList2 = new ArrayList<>();
}
currentKey = key;
}
L2 converted = lineProcessor.apply(line);
subList.add(line);
subList2.add(converted);
}
// 最終行
G group = keyBreakProcessor.apply(currentKey, subList, subList2);
groupList.add(group);
return groupList;
}
@FunctionalInterface
public static interface KeyBreakProcessor<S, T, U, V> {
V apply(S key, List<T> subList, List<U> subList2);
}
}
(実装の解説)
- ソースとなるデータリストや、各ラムダ式(関数型インターフェース)を受け取るメソッドを用意(
source
、key
、eachLine
、whenKeyChanged
)。メソッドチェーンを構成できるように、this
をリターンしている。 - 各行の変換処理を49行目で行うが、変換後の値を溜め込んで(51行目)キーブレク処理のラムダ式へ渡すようにしている(42行目)。
- キーブレイク処理の変換結果も溜め込み(43行目)、最後にメソッドの戻り値としてリストで返却する(57行目)。
- 3つの変数を受け取るFunction型はJava標準にはないので、独自に定義した(60-63行目)。
メソッドチェーンのためにthis
を返すというのは、Builder
パターンっぽいやり方である。
まとめ
- 頻出する処理パターンは汎用化するべきだ。
- 手順を踏めば、ジェネリクスは怖くない。