Java Top

I just announced the new Learn Spring course, focused on the fundamentals of Spring 5 and Spring Boot 2:

>> CHECK OUT THE COURSE

1. Introduction

Java 8 introduced functional style programming, allowing us to parameterize general-purpose methods by passing in functions.

We're probably most familiar with the single-parameter Java 8 functional interfaces like Function, Predicate, and Consumer.

In this tutorial, we're going to look at functional interfaces that use two parameters. Such functions are called binary functions and are represented in Java with the BiFunction functional interface.

2. Single-Parameter Functions

Let's quickly recap how we use a single-parameter or unary function, as we do in streams:

List<String> mapped = Stream.of("hello", "world")
  .map(word -> word + "!")
  .collect(Collectors.toList());

assertThat(mapped).containsExactly("hello!", "world!");

As we can see, the map uses Function, which takes a single parameter and allows us to perform an operation on that value, returning a new value.

3. Two-Parameter Operations

The Java Stream library provides us with a reduce function that allows us to combine the elements of a stream. We need to express how the values we have accumulated so far are transformed by adding the next item.

The reduce function uses the functional interface BinaryOperator<T>, which takes two objects of the same type as its inputs.

Let's imagine we want to join all the items in our stream by putting the new ones at the front with a dash separator. We'll take a look at a few ways to implement this in the following sections.

3.1. Using a Lambda

The implementation of a lambda for a BiFunction is prefixed by two parameters, surrounded by brackets:

String result = Stream.of("hello", "world")
  .reduce("", (a, b) -> b + "-" + a);

assertThat(result).isEqualTo("world-hello-");

As we can see, the two values, a and b are Strings. We have written a lambda that combines them to make the desired output, with the second one first, and a dash in between.

We should note that reduce uses a starting value — in this case, the empty string. Thus, we end up with a trailing dash with the above code, as the first value from our stream is joined with it.

Also, we should note that Java's type inference allows us to omit the types of our parameters most of the time. In situations where the type of a lambda is not clear from the context, we can use types for our parameters:

String result = Stream.of("hello", "world")
  .reduce("", (String a, String b) -> b + "-" + a);

3.2. Using a Function

What if we wanted to make the above algorithm not put a dash on the end? We could write more code in our lambda, but that might get messy. Let's extract a function instead:

private String combineWithoutTrailingDash(String a, String b) {
    if (a.isEmpty()) {
        return b;
    }
    return b + "-" + a;
}

And then call it:

String result = Stream.of("hello", "world") 
  .reduce("", (a, b) -> combineWithoutTrailingDash(a, b)); 

assertThat(result).isEqualTo("world-hello");

As we can see, the lambda calls our function, which is easier to read than putting the more complex implementation inline.

3.3. Using a Method Reference

Some IDEs will automatically prompt us to convert the lambda above into a method reference, as it's often clearer to read.

Let's rewrite our code to use a method reference:

String result = Stream.of("hello", "world")
  .reduce("", this::combineWithoutTrailingDash);

assertThat(result).isEqualTo("world-hello");

Method references often make the functional code more self-explanatory.

4. Using BiFunction

So far, we've demonstrated how to use functions where both parameters are of the same type. The BiFunction interface allows us to use parameters of different types, with a return value of a third type.

Let's imagine that we're creating an algorithm to combine two lists of equal size into a third list by performing an operation on each pair of elements:

List<String> list1 = Arrays.asList("a", "b", "c");
List<Integer> list2 = Arrays.asList(1, 2, 3);

List<String> result = new ArrayList<>();
for (int i=0; i < list1.size(); i++) {
    result.add(list1.get(i) + list2.get(i));
}

assertThat(result).containsExactly("a1", "b2", "c3");

4.1. Generalize the Function

We can generalize this specialized function using a BiFunction as the combiner:

private static <T, U, R> List<R> listCombiner(
  List<T> list1, List<U> list2, BiFunction<T, U, R> combiner) {
    List<R> result = new ArrayList<>();
    for (int i = 0; i < list1.size(); i++) {
        result.add(combiner.apply(list1.get(i), list2.get(i)));
    }
    return result;
}

Let's see what's going on here. There are three types of parameters: T for the type of item in the first list, U for the type in the second list, and then R for whatever type the combination function returns.

We use the BiFunction provided to this function by calling its apply method to get the result.

4.2. Calling the Generalized Function

Our combiner is a BiFunction, which allows us to inject an algorithm, whatever the types of input and output. Let's try it out:

List<String> list1 = Arrays.asList("a", "b", "c");
List<Integer> list2 = Arrays.asList(1, 2, 3);

List<String> result = listCombiner(list1, list2, (a, b) -> a + b);

assertThat(result).containsExactly("a1", "b2", "c3");

And we can use this for completely different types of inputs and outputs, too.

Let's inject an algorithm to determine if the value in the first list is greater than the value in the second and produce a boolean result:

List<Double> list1 = Arrays.asList(1.0d, 2.1d, 3.3d);
List<Float> list2 = Arrays.asList(0.1f, 0.2f, 4f);

List<Boolean> result = listCombiner(list1, list2, (a, b) -> a > b);

assertThat(result).containsExactly(true, true, false);

4.3. A BiFunction Method Reference

Let's rewrite the above code with an extracted method and a method reference:

List<Double> list1 = Arrays.asList(1.0d, 2.1d, 3.3d);
List<Float> list2 = Arrays.asList(0.1f, 0.2f, 4f);

List<Boolean> result = listCombiner(list1, list2, this::firstIsGreaterThanSecond);

assertThat(result).containsExactly(true, true, false);

private boolean firstIsGreaterThanSecond(Double a, Float b) {
    return a > b;
}

We should note that this makes the code a little easier to read, as the method firstIsGreaterThanSecond describes the algorithm injected as a method reference.

4.4. BiFunction Method References using this

Let's imagine we want to use the above BiFunction-based algorithm to determine if two lists are equal:

List<Float> list1 = Arrays.asList(0.1f, 0.2f, 4f);
List<Float> list2 = Arrays.asList(0.1f, 0.2f, 4f);

List<Boolean> result = listCombiner(list1, list2, (a, b) -> a.equals(b));

assertThat(result).containsExactly(true, true, true);

We can actually simplify the solution:

List<Boolean> result = listCombiner(list1, list2, Float::equals);

This is because the equals function in Float has the same signature as a BiFunction. It takes an implicit first parameter of this, an object of type Float. The second parameter, other, of type Object, is the value to compare.

5. Composing BiFunctions

What if we could use method references to do the same thing as our numeric list comparison example?

List<Double> list1 = Arrays.asList(1.0d, 2.1d, 3.3d);
List<Double> list2 = Arrays.asList(0.1d, 0.2d, 4d);

List<Integer> result = listCombiner(list1, list2, Double::compareTo);

assertThat(result).containsExactly(1, 1, -1);

This is close to our example but returns an Integer, rather than the original Boolean. This is because the compareTo method in Double returns Integer.

We can add the extra behavior we need to achieve our original by using andThen to compose a function. This produces a BiFunction that first does one thing with the two inputs and then performs another operation.

Next, let's create a function to coerce our method reference Double::compareTo into a BiFunction:

private static <T, U, R> BiFunction<T, U, R> asBiFunction(BiFunction<T, U, R> function) {
    return function;
}

A lambda or method reference only becomes a BiFunction after it has been converted by a method invocation. We can use this helper function to convert our lambda into the BiFunction object explicitly.

Now, we can use andThen to add behavior on top of the first function:

List<Double> list1 = Arrays.asList(1.0d, 2.1d, 3.3d);
List<Double> list2 = Arrays.asList(0.1d, 0.2d, 4d);

List<Boolean> result = listCombiner(list1, list2,
  asBiFunction(Double::compareTo).andThen(i -> i > 0));

assertThat(result).containsExactly(true, true, false);

6. Conclusion

In this tutorial, we've explored BiFunction and BinaryOperator in terms of the provided Java Streams library and our own custom functions. We've looked at how to pass BiFunctions using lambdas and method references, and we've seen how to compose functions.

The Java libraries only provide one- and two-parameter functional interfaces. For situations that require more parameters, see our article on currying for more ideas.

As always, the complete code samples are available over on GitHub.

Java bottom

I just announced the new Learn Spring course, focused on the fundamentals of Spring 5 and Spring Boot 2:

>> CHECK OUT THE COURSE
Comments are closed on this article!