[JAVA] Tips for making APIs generic

Overview

Generics are convenient, aren't they? But isn't your head messed up with a lot of symbols (type arguments)? The recommended steps to genericize your API are:

  1. First, write the process you want to cut out and generalize with the specific type.
  2. In the process, identify what you want to change the type
  3. Replace them with type arguments and make them generic

Let's take a concrete example.

Simple example

I have the following code.

        // In: List<SalesLine>
        // Out:A map with the product code as the key and the total number of sales as the value
        Map<String, Integer> map =
                list.stream().collect(
                        Collectors.groupingBy(SalesLine::getProductCode,
                                Collectors.summingInt(SalesLine::getQuantity)
                        ));

Now, let's say you want to make the process of "grouping list elements with a certain key, summing some numerical values for each and returning it in a map" as a utility method because it is often used in other ways. Instead of writing in Generics all at once, write in solid first.

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

Next, in this process, identify the type you want to variate. The first thing that is clear is the type of list element called SalesLine. Replace this with T. (At this point, a compile error will occur)

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

Next, we want to be able to use ʻIntegerand proprietary types without limiting the key to a string, so replaceString with S. The map value will be calculated as a total value, so leave it as ʻInteger.

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

Let's get rid of the T :: getProductCode and T :: getQuantity that are causing the compilation error. Since these expect the process of extracting the value from the object of list element type (T), pass it as an argument in the functional interface. Considering that the conversion is to T-> S and the conversion to T-> Integer, respectively, it is as follows.

    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)
                ));
    }

That's it. The program on the user side is as follows.

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

A little more difficult example

Consider the following report output program. This is the so-called key break process that is common in business applications, where a sorted sales statement list is input and a subtotal line is issued when the product code changes.

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)) {
                //Output subtotal line at key break
                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();
        }
        //Process the last subtotal group
        sb.append(makeSubtotalLine(currentProductCode, subtotalQty, subtotalAmount)).append("\n");

        return sb.toString();
    }

The output example of this program is as follows.

Details 2020-04-01 A 2 pieces 200 yen
Details 2020-04-01 A 3 pieces 300 yen
Details 2020-04-02 A 1 piece 100 yen
Details 2020-04-02 A 1 piece 100 yen
Subtotal A 7 pieces 700 yen
Details 2020-04-01 B 1 piece 150 yen
Details 2020-04-02 B 2 pieces 300 yen
Details 2020-04-02 B 2 pieces 300 yen
Subtotal B 5 pieces 750 yen
Details 2020-04-01 C 2 pieces 400 yen
Subtotal C 2 pieces 400 yen

Now, let's say you want to generalize the process because keybreak processing occurs frequently in business applications. First, write a solid class that performs key break processing. Only the processing flow should be shared, and the specific output logic should be passed as an argument.

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);
    }

}

Consider the type you want to variate in this process. The SalesLine type that represents the detail line and the String type of the break key are the targets, so if you replace them with L and K, respectively, it will be as follows.

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);
    }
}

This completes the generic API. The report output program side is as follows.

ReportComponent.java


    public String outputReportWithGenerics(List<SalesLine> sales) {
        GeneralKeyBreakProcessor<SalesLine, String> gkbp = new GeneralKeyBreakProcessor<>(sales);
        final StringBuilder sb = new StringBuilder();
        //Key generation
        Function<SalesLine, String> keyGenerator = SalesLine::getProductCode;
        //Process 1 detail line and output
        Consumer<SalesLine> processLine = sl -> sb.append(makeNormalLine(sl)).append("\n");
        //Process line items grouped by key and output subtotal
        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();
    }

Summary

If you follow the steps, implementing an API using Generics is not scary!

bonus

This is a generic version of the report output program, but it remains a little difficult to read. Reviewing the API using the fluent API technique will improve readability a bit.

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

The explanation of this implementation is written in detail in Blog.

Recommended Posts

Tips for making APIs generic
tips for java.nio.file.Path
Tips for making good use of Canvas in Xcode
Gradle TIPS collection (for myself)
[Java] Tips for writing source
Memo for making Niconico API (Materials)
Tips for handling pseudo-elements in Selenium