3
4

More than 3 years have passed since last update.

APIをジェネリクス化するコツ

Posted at

概要

ジェネリクスって便利ですよね。でも、記号(型引数)が多くて頭がごちゃごちゃしませんか?
APIをジェネリクス化するおすすめの手順は以下です。

  1. 切り出して汎用化したい処理を、まずは具体的な型のままで書く
  2. その処理の中で、型を可変にしたいものを洗い出す
  3. それらを型引数に置き換え、ジェネリクス化

具体例で見ましょう。

簡単な例

以下のようなコードがあります。

        // In: List<SalesLine>
        // Out: 商品コードをキー、販売数合計を値とするマップ
        Map<String, Integer> map =
                list.stream().collect(
                        Collectors.groupingBy(SalesLine::getProductCode,
                                Collectors.summingInt(SalesLine::getQuantity)
                        ));

いま、「リストの要素をあるキーでグルーピングし、それぞれに対して何かの数値を合計してマップで返す」処理を、他でもよく使うのでユーティリティメソッドにしたいとしましょう。

いきなりジェネリクスで書き始めるのではなく、まずはベタに書きます。

    public static Map<String, Integer> groupTotal(List<SalesLine> list) {
        return list.stream().collect(
                Collectors.groupingBy(SalesLine::getProductCode,
                        Collectors.summingInt(SalesLine::getQuantity)
                ));
    }

次に、この処理の中で可変化したい型を洗い出します。
まず明らかなのはSalesLineというリスト要素の型ですね。これをTに置き換えます。(この時点ではコンパイルエラーとなります)

    public static <T> Map<String, Integer> groupTotal(List<T> list) {
        return list.stream().collect(
                Collectors.groupingBy(T::getProductCode,
                        Collectors.summingInt(T::getQuantity)
                ));
    }

次に、キーは文字列に限定せずIntegerや独自型を使えるようにしたいので、StringSに置き換えます。マップの値の方は合計値計算を行うのでIntegerのままにします。

    public static <S, T> Map<S, Integer> groupTotal(List<T> list) {
        return list.stream().collect(
                Collectors.groupingBy(T::getProductCode,
                        Collectors.summingInt(T::getQuantity)
                ));
    }

コンパイルエラーの原因となっているT::getProductCodeT::getQuantityを片付けましょう。
これらは、リスト要素型(T)のオブジェクトから値を抽出する処理を期待するので、関数型インタフェースで引数で渡すようにします。
それぞれ、T->Sへの変換、T->Integerへの変換であることを考えると、以下のようになります。

    public static <S, T> Map<S, Integer> groupTotal(
            List<T> list, Function<T, S> keyExtractor, Function<T, Integer> valueExtractor) {
        return list.stream().collect(
                Collectors.groupingBy(keyExtractor::apply,
                        Collectors.summingInt(valueExtractor::apply)
                ));
    }

以上で完成です。
利用側のプログラムは以下のようになります。

        Map<String, Integer> map = groupTotal(list, SalesLine::getProductCode, SalesLine::getQuantity);

もう少し難しい例

以下のようなレポート出力プログラムを考えます。
ソート済みの売上明細リストを入力とし、商品コードが変わると小計行を出すという、業務アプリケーションにありがちな、いわゆるキーブレイク処理です。

ReportComponent.java
    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();
    }

このプログラムの出力例は以下です。

明細 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円

さて、キーブレク処理は業務アプリケーションで頻出するので処理を汎用化したくなったとしましょう。

キーブレイク処理を行うクラスを、まずはベタに書きます。
処理フローのみを共通化し、具体的な出力ロジックは引数で渡すようにします。

KeyBreakProcessor.java
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型と、ブレイクキーのString型が対象となりますので、それぞれLKに置き換えると以下のようになります。

GeneralKeyBreakProcessor.java
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);
    }
}

これでジェネリックなAPIが完成。レポート出力プログラム側は以下のようになります。

ReportComponent.java
    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();
    }

まとめ

手順を踏めば、ジェネリクスを用いたAPIの実装は怖くないです!

おまけ

ジェネリクス版のレポート出力プログラムですが、若干の読みづらさが残ります。
流暢なAPIのテクニックを用いてAPIを見直すと、少し可読性が向上します。

ReportComponent.java
    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());
    }

この実装の説明はブログの方に詳しく書きました。

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4