Skip to content

Latest commit

 

History

History

fifteen

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 

Chapter 15 - Functional Programming

Java Stream API

  • Describe the Stream interface and pipelines
  • Use lambda expressions and method references

Built-in Functional Interfaces

  • Use interfaces from the java.util.function package
  • Use core functional interfaces including Predicate, Consumer, Function and Supplier
  • Use primitive and binary variations of base interfaces of java.util.function package

Lambda Operations on Streams

  • Extract stream data using map, peek and flatMap methods
  • Search stream data using search findFirst, findAny, anyMatch, allMatch and noneMatch methods
  • Use the Optional class
  • Perform calculations using count, max, min, average and sum stream operations
  • Sort a ccollection using lambda expressions
  • Use Collectors with streams, including the groupingBy and partitioningBy operations

Introduction

This chapter will be focusing on the Streams API. Note that Streams API in this chapter is used for functional programming. Many more functional classes and Optional classes will be introduced. Then the Stream pipeline to end it tieing it all together. This is a long chapter but the many objectives of it will cover similar topics.

Working with Built-in Functional Interfaces (p.670-681)

The last chapter presented some basic functional interfaces that are used with the Collections Framework. Now, we will learn them in more detail and more thoroughly. Remember that a functional inteface has exactly one abstract method.

Now we are going to look at some functional interfaces, that are provided in the java.util.function package. The convention here is to use the generic type T for the type parameter. If a second type parameter is needed, then the letter U is used. If a distinct return type is needed, R for return is used for the generic type.

Functional interface Return type Method name # of parameters
Supplier<T> T get() 0
Consumer<T> void accept(T) 1 (T)
BiConsumer<T, U> void accept(T,U) 2 (T, U)
Predicate<T> boolean test(T) 1 (T)
BiPredicate<T, U> boolean test(T,U) 1 (T, U)
Function<T, R> R apply(T) 1 (T)
BiFunction<T, U, R> R apply(T,U) 2 (T, U)
UnaryOperator<T> T apply(T) 1 (T)
Supplier<T> T apply(T,T) 2 (T, T)

There is one functional interface here that was not in the first interfaces table (Collections Framework), which is BinaryOperator. These are not all the functional interfaces available, but these are the most important for this section of the chapter. There are even functional interfaces for handling primitives (we'll see them later in the chapter). It's important to memorize all these functions listed above, because they will be on the exam.

Notes: Many of the functional interfaces are defined in the java.util.function package. On Chapter 18 "Concurrency", there will be two more functional interfaces called Runnable and Callable, they are used for concurrency the majority of time, However, you need to know them for the exam and know that they are both functional interfaces that don't take any parameters, with Runnable returning void and Callable returning a generic type.

Implementing Supplier

A Supplier is used when you want to generate or supply values without taking any input. The interface is defined like this:

@FunctionalInterface
public interface Supplier<T> {
    T get();
}

You can create a LocalDate using the factory method now(). This example shows how to use a Supplier to call it:

Supplier<LocalDate> s1 = LocalDate::now;
Supplier<LocalDate> s2 = () -> LocalDate.now();

LocalDate d1 = s1.get();

System.out.println(d1);
System.out.println(s2.get()); // Prints the same date as the println above

Both implementations create Suppliers. This is a good example of using static methods with lambda and method reference. A Supplier is often used when constructing new objects. For example, we can print two empty StringBuilder objects.

Supplier<StringBuilder> s1 = StringBuilder::new;
Supplier<StringBuilder> s2 = () -> StringBuilder();

System.out.println(s1.get());
System.out.println(s2.get());

On this example we used a constructor reference to create the object (first line) and we've been using generics to define what type of Supplier we are using. The last example is a little bit different but is simple, just look at it carefully:

Supplier<ArrayList<String>> s3 = ArrayList<String>::new;
ArrayList<String> a1 = s3.get();
System.out.println(a1);

When we call s3.get(), we get a new instance of ArrayList<String>, which is the generic type of the Supplier, in other words, a generic that contains another generic.

Implementing Consumer and BiConsumer

You use a Consumer when you want to do something with a parameter but not return anything. BiConsumer does the same thing, except that it takes two parameters. They are defined as follows:

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
    // omitted default method
}

@FunctionalInterface
public interface BiConsumer<T, U> {
    void accept(T t, U u);
    // omitted default method
}

Here are some examples of the Consumer and BiConsumer interface:

Consumer<String> c1 = System.out::println;
Consumer<String> c2 = x -> System.out.println(x);

c1.accept("Annie");
c2.accept("Annie"); // Both print Annie

var map = new HashMap<String, Integer>();
BiConsumer<String, Integer> b1 = map::put;
BiConsumer<String, Integer> b2 = (k, v) -> map.put(k, v);

b1.accept("chicken", 7);
b2.accept("chick", 1);

System.out.println(map); // {chicken=7, chick=1}

var map2 = new HashMap<String, String>();
BiConsumer<String, String> b3 = map::put;
BiConsumer<String, String> b4 = (k, v) -> map.put(k, v);

b3.accept("chicken", "Cluck");
b4.accept("chick", "Tweep");

System.out.println(map2); // {chicken=Cluck, chick=Tweep}

A BiConsumer can have doesn't need the two parameters to be the same type, they can be too. As we can see, we can use method references in most of the cases with Consumer and BiConsumer.

Implementing Predicate and BiPredicate

A Predicate is often used when filtering or matching, both are common operations. A BiPredicate is just like a Predicate except that it takes two parameters. The following are the interfaces definitions:

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
    // omitted default and static methods
}

@FunctionalInterface
public interface BiPredicate<T, U> {
    boolean test(T t, U u);
    // omitted default methods
}

Here are some examples of Predicate and BiPredicate usage:

Predicate<String> p1 = String::isEmpty;
Predicate<String> p2 = b -> b.isEmpty();

System.out.println(p1.test("")); // true
System.out.println(p2.test("")); // true

BiPredicate<String> b1 = String::startsWith;
BiPredicate<String> b2 = (string, prefix) -> string.startsWith(prefix);

System.out.println(b1.test("chicken", "chick")); // true
System.out.println(b2.test("chicken", "chick")); // true

We can see that method references save a good bit of typing, but the downside is that they are less explicit.

Implementing Function and BiFunction

A Function is responsible for turning one parameter into a value of a potentially different type and returning it. A BiFunction is responsible for turning two parameters into a value and returning it. The interfaces are defined as follows:

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
    // omitted default and static methods
}

@FunctionalInterface
public interface BiFunction<T, U, R> {
    R apply(T t, U u);
    // omitted default method
}

The following example converts a String to the length of the String:

Function<String, Integer> f1 = String::Length;
Function<String, Integer> f2 = x -> x.length();

System.out.println(f1.apply("cluck")); // 5
System.out.println(f2.apply("cluck")); // 5

This function turns a String into an Integer. Technically it turns the String into an int, which is autoboxed into an Integer. The types don't have to be different. The following combines two String objects and produces another String:

BiFunction<String, String, String> b1 = String::concat;
BiFunction<String, String, String> b2 = (string, toAdd) -> string.concat(toAdd);

System.out.println(b1.apply("baby", "chick")); // baby chick
System.out.println(b2.apply("baby", "chick")); // baby chick

The first two types in the BiFunction are the input types, the third is the return type. For the method reference on the example above, the first parameter is the instance that concat() is called on and the second is passed to concat() as the parameter.

Implementing UnaryOperator and BinaryOperator

UnaryOperator and BinaryOperator are a special case of a Function (UnaryOperator extends Function and BinaryOperator extends BiFunction). They require all type parameters to be the same type. A UnaryOperator transform its value into one of the same type. For example, incrementing by one is a unary operation. A BinaryOperator merges two values into one of the same type. Adding two numbers is a binary operation. The following are the interfaces definitions:

@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> { }

@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T, T, T> {
    // omitted static methods
 }

This means that method signatures look like this:

T apply(T t); // UnaryOperator

T apply(T t1, T t2); // BinaryOperator

These methods are the actually inherited from the Function and BiFunction superclasses. But the generic declarations on the subclasses are what force the types to be the same. For example:

UnaryOperator<String> u1 = String::toUpperCase;
UnaryOperator<String> u2 = x -> x.toUpperCase();

System.out.println(u1.apply("chirp")); // CHIRP
System.out.println(u2.apply("chirp")); // CHIRP

We don't need to specify the return type in the generics, since UnaryOperator requires it to be the same as the parameter. And here's the binary example:

BinaryOperator<String> b1 = String::concat;
BinaryOperator<String> b2 = (string, toAdd) -> string.concat(toAdd);

System.out.print(b1.apply("baby ", "chick")); // baby chick
System.out.print(b2.apply("baby ", "chick")); // baby chick

The code here is way easier to understand, since we don't need to declare more than one generic type.

Creating Your Own Functional Interfaces

Java provides a built-in interface for functions with one or two parameters, what if we need more than that? We can create a funtional interface with more than that, like this:

@FunctionalInterface
interface TriFunctional<T,U,V,R> {
    R apply(T t, U u, V v);
}

@FunctionalInterface
interface QuadFunctional<T,U,V,W,R> {
    R apply(T t, U u, V v, W w);
}

On both these examples, the last type parameter is the return type, just like the Function and BiFunction interfaces. This Java built-in interfaces are meant to facilitate the most common functional interfaces that you'll need. Remember that we can add any functional interfaces that we want to, Java matches them when we use lambdas or method references.

Convenience Methods on Functional Interfaces

By definition, a functional interface can only contain a single abstract method, but this doesn't mean that it can't have other methods, like default methods. It's common for interfaces to have several helpful default methods. All of these methods facilitate using functional interfaces. The following table shows only the main methods methods on the built-in interfaces, that you'll need to know for the exam:

Note: BiConsumer, BiFunction and BiPredicate interfaces have similar methods available.

Interface instance Method return type Method name Method parameters
Consumer Consumer andThen() Consumer
Function Function andThen() Function
Function Function compose() Function
Predicate Predicate and() Predicate
Predicate Predicate negate() -
Predicate Predicate or() Predicate

Let's start with these two simple examples:

Predicate<String> egg = s -> s.contains("egg");
Predicate<String> brown = s -> s.contains("brown");

Now we need a predicate for brown eggs and another for all other eggs:

Predicate<String> brownEggs = s -> s.contains("egg") && s.contains("brown");
Predicate<String> otherEggs = s -> s.contains("egg") && ! s.contains("brown");

This works, but it can be better. A better way to deal with this case is to use two default methods on Predicate:

Predicate<String> brownEggs = egg.and(brown);
Predicate<String> brownEggs = egg.and(brown.negate); // These are the first two Predicate example variables

Now these two are way shorter, cleaner and even easier to understand what is going on.

Moving on to the Consumer interface now. We can use the andThen() method, which runs two functional interfaces in sequence:

Consumer<String> c1 = x -> System.out.print("1: " + x);
Consumer<String> c2 = x -> System.out.print(",2: " + x);

Consumer<String> combined = c1.andThen(c2);
combined.accept("Annie"); // 1: Annie,2: Annie

Notice that the same parameter is passed to both c1 and c2 in this case. The Consumer instances are run in sequence and are independent of each other.

Now we can have a look at the Function interface compose() method, that chains functional interfaces. But instead it passes along the output of one to the input of another:

Functional<Integer, Integer> before = x -> x + 1;
Functional<Integer, Integer> after = x -> x * 2;

Function<Integer, Integer> combined = after.compose(before);
System.out.println(combined.apply(3)); // 8

This time before runs first, turning 3 into a 4, then after runs, doubling the 4 to 8.

Returning an Optional (p.681-685)

How do we express a "we don't know" or "not applicable" answer in Java? We use the Optional type. An Optional is created using a factory. You can either request an empty Optional or pass a value for the Optional to wrap. You can image an Optional as a box that might have something inside.

Creating an Optional

Here's an example of an average method that uses Optional and creates it both ways commented above:

public static Optional<Double> average(int... scores) {
    if (scores.length == 0) return Optional.empty();
    int sum = 0;
    for (int score: scores) sum += score;
    return Optional.of((double) sum / scores.length);
}

If we call the method above we can see what is inside our two boxes:

System.out.println(average(90, 100)); // Optional[95.0]
System.out.println(average()); // Optional.empty

We can check if an Optional contains a value (isPresent()) and then get() that value if present:

Optional<Double> opt = average(90,100);
if (opt.isPresent()) {
    System.out.println(opt.get()); // 95.0
}

If we don't check before calling get() on an Optional, a NoSuchElementException is thrown.

java.util.NoSuchElementException: No value present

When creating an Optional, we can define it wrapping an value with of(value) method or empty(). If the value is null, we can use the factory method ofNullable(value) to wrap the value and create the Optional.

Note: A variable whose type is Optional should never itself be null. null can be assigned to an Optional, but it's not a good practice, always use one of these factory methods commented above to create an Optional.

These were the static methods that we need to know about Optional for the exam. The following table shows most of the instance methods on Optional that we need to know too. There are a few others that will be covered later in the chapter.

Method When Optional is empty When Optional contains a value
get() Throws an exception Returns value
ifPresent(Consumer c) Does nothing Calls Consumer with value
isPresent() Returns false Returns true
orElse(T other) Returns other parameter Returns value
orElseGet(Supplier s) Retruns result of calling Supplier Returns value
orElseThrow() Throws NoSuchElementException Returns value
orElseThrow(Supplier s) Throws exception created by calling the Supplier Returns value

With these methods we can do code that it's easier to read, that instead of using an if statement, we can do it all in one line, with ifPresent and a Consumer for example.

Note: orElseThrow() was added on Java 10.

Dealing with an Empty Optional

The other methods (orElse...) allow us to specify what to do if a value is not present. So the following are some examples of them all and how they behave:

Optional<Double> opt = average(); // no value present on opt
System.out.println(opt.orElse(Double.NaN)); // NaN
System.out.println(opt.orElseGet(() -> Math.random())); // 0.564594598248
System.out.println(opt.orElseThrow()); // Throws NoSuchElementException
System.out.println(opt.orElseThrow(() -> new IllegalStateException())); // Throws IllegalStateException
System.out.println(opt.orElseGet(() -> new IllegalStateException())); // DOES NOT COMPILE, the opt variable is an Optional<Double>, Supplier must return a Double.

Optional<Double> optWithValue = average(90, 100);
System.out.println(optWithValue.orElse(Double.NaN)); // 95.0
System.out.println(optWithValue.orElseGet(() -> Math.random())); // 95.0
System.out.println(optWithValue.orElseThrow()); // 95.0
// orElse is not used in these examples because there is a value present

Is Optional the same as null?

Before Java 8, the common return was null instead of Optional. But there were some problems with this approach. It wasn't a clear way to express that null might be a special value, because when returning an Optional, is clear that there might not be a value in there. Another advantage of Optional is that we can use functional programming style with the methods rather than using some common statements, like the if statement.

Using Streams (p.685-706)

A stream in Java is a sequence of data. A stream pipeline consists of the operations that run on a stream to produce a result.

To understand the pipeline flow we need to think of a stream pipeline as an assembly line in a factory, where we have a number of jobs that need different persons to do them, and the steps require to follow a certain order, where the second depends on the first finishing it's assignment. This assembly line is finite. Finite streams have a limit. There are others assembly lines that run forever, where a cycle is treated as infinite, since it does not end for an inordinately large period of time. Another important feature of an assembly line is that each person touches each element to do their operation and then that piece of data is gone, it does not come back. They are different than lists and queues as we saw in the other chapter, where elements of a list can be accessed any time and queue elements are limited in which elements we can access, but they are all there. With streams, the data is not generated up front, it is only created when needed. This is an example of lazy evaluation, which delays execution until necessary.

Many things can happen along the assembly line stations, in functional programming these are called stream operations, operations are ocurr in a pipeline, where someone has to start and end the work. There can be any number of stations in between. There are three parts to a stream pipeline, respectively:

  • Source: Where the stream comes from.
  • Intermediate operations: Transforms the stream into another one. There can be as few or as many intermediate operations as we'd like. Since streams use lazy evaluation, the intermediate operations do not run until the terminal operation runs.
  • Terminal operation: Actually produces a result. Since streams can be used only once, the stream is no longer valid after a terminal operation completes.

The important thing on these 'assembly lines' is what comes in and goes out, what happens in between the intermediate operations is an implementation detail. The following table shows some scenarions to make sure that we are clear about the differences between intermediate operations and terminal operations:

Scenario Intermediate operation Terminal operation
Required part of a useful pipeline? No Yes
Can exist multiple times in a pipeline? Yes No
Return type is a stream type? Yes No
Executed upon method call? No Yes
Stream valid after call? Yes No

A factory typically has a foreman who oversees the work. Java serves as the 'foreman' when working with stream pipelines. He takes care of everything envolving running a stream pipeline.

Creating Finite Streams

In Java, the streams we have been talking about are represented by the Stream<T> interface.

We'll start with finite streams. There are some ways to create them:

Stream<String> empty = Stream.empty(); // count = 0
Stream<Integer> singleElement = Stream.of(1); // count = 1
Stream<Integer> fromArray = Stream.of(1,2,3); // count = 3

The third example shows how to create a stream from a varargs. The method signature uses varargs, which let us specify an array or individual elements. Java also provides a convenient way of converting a Collection to a stream:

var list = List.of("a", "b", "c");
Stream<String> fromList = list.stream();

It's just a simple method call to create a stream from a list.

Note: We can create a parallel stream from a list too, just calling the method list.parallelStream(). This stream can be used when concurrency is needed, you can have multiple threads working on your stream, but keep in mind that some tasks cannot be done in parallel, for example a task that needs ordering. More about concurrency is discussed on it's own chapter.

Creating Infinite Streams

We can't create an infinite list, but we can create infinite streams:

Stream<Double> randoms = Stream.generate(Math::random);
Stream<Integer> oddNumbers = Stream.iterate(1, n -> n + 2);

Both of these operations will generate numbers as long as we need them, if we call a randoms.forEach(System.out::println) the program will print numbers until we kill it. Later in the chapter we will discuss the limit() method which turns the infinite stream into a finite stream.

Note that if we print the stream, it will not print the elements like a List, it will print something like this: java.util.stream.ReferencePipeline$3@4517d9a3.

Imagine that we wanted just odd numbers less than 100. Java 9 introduced an overloaded version of iterate() that solves this:

Stream<Integer> oddNumbersUnder100 = Stream.iterate(
    1,              // seed
    n -> n < 100,   // Predicate to specify when is done
    n -> n + 2      // UnaryOperator to get next value
);

Reviewing Stream Creation Methods

To review, make sure you know all the methods on the following table. These are the ways of creating a source for streams, given a Collection instace coll.

Method Finite or infinite? Notes
Stream.empty() Finite Creates Stream with zero elements
Stream.of(varargs) Finite Creates Stream with elements listed
coll.stream() Finite Creates Stream from a Collection
coll.parallelStream() Finite Creates Stream from a Collection where the stream can run in parallel
Stream.generate(supplier) Infinite Creates Stream by calling the Supplier for each element upon request
Stream.iterate(seed, unaryOperator) Infinite Creates Stream by using the seed for the first element and then calling the UnaryOperator for each subsequent element upon request
Stream.iterate(seed, predicate, unaryOperator) Finite or Infinite Creates Stream by using the seed for the first element and then calling the UnaryOperator for each subsequent element upon request. Stops if the Predicate returns false

Using Common Terminal Operations

We can perform a terminal operation without any intermediate operations, but not the other way around.

Reductions are a special type of terminal operation where all of the contents of the stream are combined into a single primitive or Object (int or String).

The following table summarizes this section:

Method What happens for infinite streams Return value Reduction
count() Does not terminate long Yes
min() and max() Does not terminate Optional<T> Yes
findAny() and findFirst() Terminates Optional<T> No
allMatch(), anyMatch() and noneMatch() Sometimes terminates boolean No
forEach() Does not terminate void No
reduce() Does not terminate Varies Yes
collect() Does not terminate Varies Yes
  • count():

    • Counts the number of elements in a finite stream (in an infinite stream it never terminates).

    • It's a reduction because it looks at each element in the stream and returns a single value.

    • Method signature:

      long count()
      
    • Example:

      Stream<String> s = Stream.of("monkey", "gorilla", "bonobo"); // finite stream
      System.out.println(s.count()); // 3
      
  • min() and max():

    • Allow us to pass a custom comparator and find the smallets or largest value in a finite stream according to that sort order.

    • These two hang will hang on an infinite stream because they cannot be sure that a smaller or larger value isn't coming later on the stream.

    • Both are reductions, since they return a single value after looking at the entire stream.

    • Method signature:

      Optional<T> min(Comparator<? super T> comparator)
      Optional<T> max(Comparator<? super T> comparator)
      
    • Example:

      Stream<String> s = Stream.of("monkey", "ape", "bonobo"); // finite stream
      Optional<String> min = s.min((s1, s2) -> s1.length() - s2.length());
      min.ifPresent(System.out::println); // ape
      
    • Notice that the example above returned an Optional, this allows the method to specify that no minimum or maximum was found. An example of where there isn't a minimum, is as follows:

      Optional<?> minEmpty = Stream.empty().min((s1, s2) -> 0);
      System.out.println(minEmpty.isPresent()); // false
      
    • Since the stream above is empty, the comparator is never called and no value is present for the Optional (returning false with isPresent()).

    Note: As these methods are terminal to the stream, you can't use them both in the same stream.

  • findAny() and findFirst():

    • They return an element of the stream unless the stream is empty. With an empty stream, they'll return an empty Optional.

    • These methods can terminate with an infinite stream.

    • findAny() can return any element of the stream. When called on the streams seen up until now, it commonly returns the first element, but this behavior is not guarateed. On the concurrency chapter, when working with parallel streams, the method is more likely to return a random element.

    • These methods are terminal operations, but not reductions, the reason is that they sometimes return without processing all of the elements in the stream. Meaning that they return a value based on the stream but do not reduce the entire stream into one value.

    • Method signatures:

      Optional<T> findAny()
      Optional<T> findFirst()
      
    • Examples:

      Stream<String> s = Stream.of("monkey", "gorilla", "bonobo");
      Stream<String> infinite = Stream.generate(() -> "chimp");
      
      s.findAny().ifPresent(System.out::println); // monkey (usually)
      infinite.findAny().ifPresent(System.out::println); // chimp
      
  • allMatch(), anyMatch() and noneMatch()

    • These methods search a stream and return information about how the stream pertains to the predicate.

    • These may or may not terminate for infinite streams. It depends on the data.

    • Like the find methods, they are not reductions, since they may not look at all the elements in the stream.

    • Method signatures:

      boolean anyMatch(Predicate<? super T> predicate)
      boolean allMatch(Predicate<? super T> predicate)
      boolean noneMatch(Predicate<? super T> predicate)
      
    • Examples:

      var list = List.of("monkey", "2", "chimp");
      Stream<String> infinite = Stream.generate(() -> "chimp");
      Predicate<String> pred = x -> Character.isLetter(x.charAt(0));
      
      System.out.println(list.stream().anyMatch(pred)); // true
      System.out.println(list.stream().allMatch(pred)); // false
      System.out.println(list.stream().noneMatch(pred)); // false
      System.out.println(infinite.anyMatch(pred)); // true
      
    • Notice that we can reuse the same predicate multiple times, but we need a different stream each time. If we used allMatch() instead of anyMatch() with the infinite stream, the program would run until we killed it, this would happen because with anyMatch() it would find one element and terminate the call.

      Note: These methods return a boolean. By contrast, the find methods return an Optional because they return an element of the stream.

  • forEach()

    • Used to iterate over elements of a stream.

    • On an infinite stream, the loop does not terminate.

    • Since there is no return value, it is not a reduction.

    • Method signature:

      void forEach(Consumer<? super T> action)
      
    • Notice that this is the only terminal operation with a return type of void. If something needs to happen, it must happen in the Consumer. For example:

      Stream<String> s = Stream.of("Monkey", "Gorilla", "Bonobo");
      s.forEach(System.out::print); // MonkeyGorillaBonobo
      

    Note: It's possible to call forEach() directly on a Collection or on a Stream.

    • It's not possible to use a traditional for loop on a stream.

      Stream<String> s = Stream.of("Monkey", "Gorilla", "Bonobo");
      for (String str : s) { } // DOES NOT COMPILE
      
    • forEach() sounds like a loop, but it is really a terminal operator for streams. Streams cannot be used as the source in a for-each loop to run because they don't implement the Iterable interface.

  • reduce()

    • Combines a stream into a single object.

    • It is a reduction, which means it processes all elements.

    • There are three method signatures:

      T reduce(T identity, BinaryOperator<T> accumulator)
      
      Optional<T> reduce(BinaryOperator<T> accumulator)
      
      <U> U reduce(U identity, BinaryOperator<U, ? super T, U> accumulator,   BinaryOperator<U> combiner)
      
    • First one is the most common way of doing a reduction, by starting with an initial value and keep merging it with the next value.

    • If we needed to concatenate an array of String objects into a single String without functional programming, it might look like this:

      var array = new String[] { "w", "o", "l", "f" };
      var result = "";
      for (var s: array) result = result + s;
      System.out.println(result); // wolf
      
    • The identity is the initial value of the reduction, in this case would be an empty String.

    • The accumulator combines the current result with the current value in the stream.

    • With lambdas we can do the same thing as we did above, but with a stream and reduction:

      Stream<String> stream = Stream.of("w", "o", "l", "f");
      String word = stream.reduce("", (s, c) -> s + c);
      System.out.println(word); // wolf
      
    • identity isn't really necessary, we can omit it. When we omit the identity, an Optional is returned because there might be no data. There are three choices for what is in the Optional:

      • If the stream is empty, an empty Optional is returned.
      • If the stream has one element, it is returned.
      • If the stream has multiple elements, the accumulator is applied to combine them.
    • The following example shows these scenarios:

      BinaryOperator<Integer> op = (a, b) -> a * b;
      Stream<Integer> empty = Stream.empty();
      Stream<Integer> oneElement = Stream.of(3);
      Stream<Integer> threeElements = Stream.empty(3, 5, 6);
      
      empty.reduce(op).ifPresent(System.out::println);         // no output
      oneElement.reduce(op).ifPresent(System.out::println);    // 3
      threeElements.reduce(op).ifPresent(System.out::println); // 90
      
    • The last method signature is used when we are dealing with different types.

    • It allows Java to create intermediate reductions and then combine them at the end.

    • The following example counts the number of chars in each String:

      Stream<String> stream = Stream.of("w", "o", "l", "f!");
      int length = stream.reduce(0, (i, s) -> i+s.length(), (a, b) -> a+b);
      System.out.println(length); // 5
      
    • In this example, the first argument is an Integer (i), while the second argument is a String (s) on the accumulator. This one handles mixed data types.

    • The third parameter is called the combiner, which combines anyt intermediate totals. In this case, a and b are both Integer values.

    • This signature of reduce is useful when working with parallel streams, because it allows the stream to be decomposed and reassebled by separate threads.

  • collect()

    • Is a special type of reduction called mutable reduction.

    • It is more efficient than a regular reduction because we use the same mutable object while accumulating.

    • Common mutable objects include StringBuilder and ArrayList.

    • The methods signatures are as follows:

      <R> R collect(Supplier<R> supplier, BiConsumer<R, ? super T> accumulator, BiConsumer<R, R> combiner)
      
      <R,A> R collect(Collector<? super T, A, R> collector)
      
    • With the first signature, we define how collecting should work. For example:

      Stream<String> stream = Stream.of("w", "o", "l", "f");
      
      StringBuilder word = stream.collect(
          StringBuilder::new
          StringBuilder::append,
          StringBuilder::append)
      
      System.out.println(word); // wolf
      
    • The first parameter is the supplier, which creates the object that will store the results as we collect data. Remember that a Supplier doesn't take any parameters and returns a value.

    • The second parameter is the accumulator, which is a BiConsumer that takes two parameters and doesn't return anything. It is responsible for adding one more element to the data collection.

    • The third parameter is the combiner, which is a BiConsumer. It is responsible for taking two data collections and merging them.

    • Another example with another logic:

      Stream<String> stream = Stream.of("w", "o", "l", "f");
      
      TreeSet<String> set = stream.collect(
          TreeSet::new,
          TreeSet::add,
          TreeSet::addAll);
      System.out.println(set); // [f, l, o, w]
      
    • The collector has three parts as before. The combiner adds all of the elements of one TreeSet to another in case the operations were done in parallel and need to be merged.

    • Java provides a class with common collectors named Collectors. This approach makes the code easier to read because it is more expressive than using a custom made collector.

    • We can rewrite the previous example with Collectors:

      Stream<String> stream = Stream.of("w", "o", "l", "f");
      TreeSet<String> set = stream.collect(Collectors.toCollection(TreeSet::new));
      System.out.println(set); // [f, l, o, w]
      
    • If we didn't need the set to be sorted, we could make the code even shorter:

      Stream<String> stream = Stream.of("w", "o", "l", "f");
      Set<String> set = stream.collect(Collectors.toSet());
      System.out.println(set); // [f, w, l, o]
      
    • Using toSet() might get us a different output every time. Another thing is that it doens't guarantee which implementation of Set you'll get. It is likely to be a HashSet, but don't count on that.

    • The exam, expects that us know about common predefined collections in addition to being able to write our own by passing a supplier, accumulator and combiner.

Using Common Intermediate Operations

  • An intermediate operation produces a stream as its result.

  • Can also deal with an infinite stream simply by returning another infinite stream, since elements are produced only as needed.

  • It focus on the current element rather than the other elements of a stream.

  • filter()

    • Returns a Stream with elements that match a given expression.

    • Method signature is as follows:

      Stream<T> filter(Predicate<? super T> predicate)
      
    • We can pass any Predicate to it. For example:

      Stream s = Stream.of("monkey", "gorilla", "bonobo"); s.filter(x -> x.startsWith("m")).forEach(System.out::print); // monkey

  • distinct()

    • Returns a stream with duplicate values removed.

    • The duplicates do not need to be adjacent to be removed.

    • As you might imagine, Java calls equals() to determine whether the objects are the same.

    • The method signature is as follows:

      Stream<T> distinct()
      
    • Here's an example:

      Stream<String> s = Stream.of("duck", "duck", "duck", "goose");
      s.distinct().forEach(System.out::print); // duckgoose
      
  • limit() and skip()

    • Can make a Stream smaller or they could make a finite stream out of an infinite stream.

    • Method signatures are the following:

      Stream<T> limit(long maxSize)
      Stream<T> skip(long n)
      
    • The following example creates an infinite stream of numbers counting from 1:

      Stream<Integer> s = Stream.iterate(1, n -> n + 1);
      s.skip(5).limit(2).forEach(System.out::print); /// 67
      
    • The skip() operation returns an infinite stream staring with the numbers counting from 6, since it skips the first five elements.

    • The limit() call takes the first two of those.

    • Then we have a finite stream with two elements, which we can then print with the forEach() method.

  • map()

    • Creates a one-to-one mapping from the elements in the stream to the elements of the next step in the stream.

    • Method signature is as follows:

      <R> Stream<R> map(Function<? super T, ? extends R> mapper)
      
    • It uses the lambda expression to figure out the type passed to that function and the one returned. The return types is the stream that gets returned.

    • On streams is for transforming data. Don't confuse it with the Map interface, which maps keys to values.

    • The following example converts a list of String objects to a list of Integer objects representing their lengths:

      Stream<String> s = Stream.of("monkey", "gorilla", "bonobo");
      s.map(String::length).forEach(System.out::porint); // 676
      
    • String::length is shorthand for the lambda x -> x.length(), which clearly shows it is a function that turns a String into a Integer.

  • flatMap()

    • Takes each element in the stream and makes any elements it contains top-level elements in a single stream.

    • Helpful when you want to remove empty elements from a stream or you want to combine a stream of lists.

    • Method signature:

      <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper )
      
    • Basically this signature means that it returns a Stream of the type that the function contains at a lower level.

    • The following example gets all the elements into the same level along with getting rid of the empty list:

      List<String> zero = List.of();
      var one = List.of("Bonobo");
      var two = List.of("Mama Gorilla", "Baby Gorilla");
      Stream<List<String>> animals = Stream.of(zero, one, two);
      
      animals.flatMap(m -> m.stream()).forEach(System.out::println); // prints Bonobo (/n) Mama Gorilla (/n) Baby Gorilla
      
    • Notice that it removed the empty list and changed al elements of each list to be at top level of the stream.

  • sorted()

    • It sorts the elements within a stream.

    • Just like sorting arrays, Java uses the natural ordering unless we specify a comparator.

    • Method signatures:

      Stream<T> sorted()
      Stream<T> sorted(Comparator<? super T> comparator)
      
    • If we call the first signature, it will use the default sort order:

      Stream<String> s = Stream.of("brown", "bear-");
      s.sorted().forEach(System.out::print); // bear-brown-
      
    • We can optionally use a Comparator implementation via a method or a lambda. In this example, we are using a method:

      Stream<String> s = Stream.of("brown bear-", "grizzly-");
      s.sorted(Comparator.reverseOrder()).forEach(System.out::print); // grizzly-brown bear
      
  • peek()

    • This is the final intermediate operation. It is used mainly for debugging because it allows us to perform a stream operation without actually changing the stream.

    • Method signature:

      Stream<T> peek(Consumer<? super T> action)
      
    • Notice that it takes the same argument as the terminal forEach() operation. Think of peek() as an intermediate version of forEach() that returns the original stream back to you.

    • The most common use for peek() is to output the contents of the stream as it goes by.

    • Remember that peek() is intended to perform an operation without changing the result. But be careful, Java does not prevent us from writing bad code, for example:

      var numbers = new ArrayList();
      var letters = new ArrayList();
      numbers.add(1);
      letters.add('a');
      Stream<List<?>> bad = Stream.of(numbers, letters);
      bad.peek(x -> x.remove(0)).map(List::size).forEach(System.out::print); // 00
      
    • Notice why this is a bad code example? Because peek() is modifying the data structure that is used in the stream.

Putting Together the Pipeline

  • Streams allow you to use chaining and express what you want to accomplish rather than how to do so.

This is an example of code that can be refactored with streams:

    var list = List.of("Toby", "Anna", "Leroy", "Alex");
    List<String> filtered = new ArrayList();
    for(String name: list) 
        if (name.length() == 4) filtered.add(name);
    
    Collections.sort(filtered);
    var iter = filtered.iterator();
    if (iter.hasNext()) System.out.println(iter.next());
    if (iter.hasNext()) System.out.println(iter.next());

This code piece works fine, but with streams is shorter, briefer and clearer to read:

    var list = List.of("Toby", "Anna", "Leroy", "Alex");
    list.stream()
        .filter(n -> n.length() == 4)
        .sorted()
        .limit(2)
        .forEach(System.out::println);

As we can see, the code is way simpler to understand. This stream call followed by multiple intermediate operations and a terminal operation is called a stream pipeline.

Notes: The following explanations will use the stream above to explain the pipeline behavior.

The pipeline on this code works this way:

  1. The stream() is created with the list values starting the stream pipeline, then first thing it does its calling the filter() intermediate operation for each stream value.
  2. The filter() operation will filter out the values based on its condition and will hold the values until all of them pass through it, once all values are filtered it'll call the next operation on the pipeline, which is the sorted() intermediate operation.
  3. The sorted() operation sorts the values in the stream and keeps sending them down to the limit() operation by the sorted order until the limit is reached.
  4. As limit() is set to two, so while it receives each one of the values at a time from sort() it'll send each one of them to the forEach() terminal operation to print them until the set limit is reached. Once the limit is reached it stops to process other values.

Note: Keep an eye out for Stream.generate() since it can create infinite streams and result in executions that never stop until the thread gets killed or the program runs out of memory.

Real World Scenario - Peeking Behind the Scenes

You can use the peek() method to see how a stream pipeline works behind the scenes. Remember that the methods run agains each element one at a time until processing is done. This is an example of the peek() usage to understand the pipeline:

    var infinite = Stream.iterate(1, x -> x + 1);
    infinite.limit(5)
        .peek(System.out::println)
        .filter(x -> x % 2 == 1)
        .forEach(System.out::println);            

This example prints the following: 11233455. First it prints the peek value and then it prints the filtered value in the forEach, but as you can see it'll process each element at a time.

Working with Primitive Streams

Until now we've been working with Streams created with generic types, but Java actually includes stream classes besides Stream that can be used to work with select primitives like int, double and long. Supposing that we want to calculate the sum of numbers in a finite stream:

    Stream<Integer> stream = Stream.of(1, 2, 3);
    System.out.println(stream.reduce(0, (s, n) -> s + n)); // prints 6

This is not bad, but it could be way easier to implement and read using the primitive Stream classes:

    Stream<Integer> stream = Stream.of(1, 2, 3);
    System.out.println(stream.mapToInt(x -> x).sum()); // prints 6

This time we used what's called an IntStream and asked for this class to calculate the sum for us instead of making a reduction. An IntStream contains many of the same intermediate and terminal methods as a Stream but includes specialized methods for working with numeric data. This looks like more a convenience than an important thing, but if you need for example how to compute an average, it would take a lot of work compared to using the methods that are in an IntStream:

    IntStream intStream = IntStream.of(1, 2, 3);
    OptionalDouble avg = intStream.average();
    System.out.println(avg.getAsDouble()); // 2.0            

So this enables you to calculate an average in a much simpler way than hard coding it.

Creating Primitive Streams

These are three types of primitive streams:

  • IntStream: Used for primitive types int, short, byte and char;
  • LongStream: Used for the primitive type long;
  • DoubleStream: Used for the primitive types double and float.

These are the common primitive stream methods:

Method Primitive Stream Description
OptionalDouble average() IntStream, LongStream and DoubleStream The arithmetic mean of the elements
Stream<T> boxed() IntStream, LongStream and DoubleStream A Stream<T> where T is the wrapper class associated with the primitive value
OptionalInt max(), OptionalLong max() and OptionalDouble max() IntStream, LongStream and DoubleStream The maximum element of the stream
OptionalInt min(), OptionalLong min() and OptionalDouble min() IntStream, LongStream and DoubleStream The minimum element of the stream
IntStream range(int a, int b) and LongStream range(long a, long b) IntStream and LongStream Returns a primitive stream from a (inclusive) to b (exclusive)
IntStream rangeClosed(int a, int b) and LongStream rangeClosed(long a, long b) IntStream and LongStream Returns a primitive stream from a (inclusive) to b (exclusive)
int sum(), long sum() and double sum() IntStream, LongStream and DoubleStream Returns the sum of the elements in the stream
IntSummaryStatistics summaryStatistics(), LongSummaryStatistics summaryStatistics() and DoubleSummaryStatistics summaryStatistics() IntStream, LongStream and DoubleStream Returns an object containing numerous stream statistics such as the average, min, max, etc.

Some other methods for creating a primitive Stream are equivalent to how we create the source for a regular Stream. For example we can use methods such as: primitiveStream.empty(), primitiveStream.of(n...), primitiveStream.generate() or primitiveStream.iterate().

Mapping Streams

Source stream class To create Stream To create DoubleStream To create IntStream To create LongStream
Stream<T> map() mapToDouble() mapToInt() mapToLong()
DoubleStream mapToObj() map() mapToInt() mapToLong()
IntStream mapToObj() mapToDouble() map() mapToLong()
LongStream mapToObj() mapToDouble() mapToInt() map()

Note: Obviously, they have to be compatible types for this to work. Java requires a mapping function to be provided as a parameter. For ex: Stream<String> stringStream is created -> to create a IntStream from it we need to call stringStream.mapToInt(s -> s.length()).

Function parameters when mapping between types of streams:

Source stream class To create Stream To create DoubleStream To create IntStream To create LongStream
Stream<T> Function<T,R> ToDoubleFunction<T> ToIntFunction<T> ToLongFunction<T>
DoubleStream DoubleFunction<R> DoubleUnaryOperator DoubleToIntFunction DoubleToLongFunction
IntStream IntFunction<R> IntToDoubleFunction IntUnaryOperator IntToLongFunction
LongStream LongFunction<R> LongToDoubleFunction LongToIntFunction LongUnaryOperator

Note: Both these tables about mapping streams should be memorized to the exam, since they are describe the basis of mapping streams.

Another important map method that also exists in primtive streams is the flatMap() method. It works the same way as on a regular Stream except the method name is different. Examples:

    IntStreams ints = integerList.stream().flatMapToInt(x -> IntStream.of(x));
    DoubleStream doubles = integerList.stream().flatMapToDouble(x -> DoubleStream.of(x));
    LongStream longs = integerList.stream().flatMapToLong(x -> LongStream.of(x));

Additionally, you can create a Stream from a primitive stream. These are two ways of doing it:

        private static Stream<Integer> mapping(IntStream stream) {
            return stream.mapToObj(x -> x);
        }
        
        private static Stream<Integer> boxing(IntStream stream) {
            return stream.boxed();
        }

The first one works as the ones we saw before, transforming the object into an Stream. The second one is different, basically it autoboxes the primitive stream type to the corresponding wrapper object, that's why it does not need a mapping function. The boxing method exists in all three primitive stream types.

Using Optional with Primitive Streams

The Optional version of Primitive Streams adds methods that the common Optional type contains and also adds some other methods that are select for those primitive types. The following are optional types for primitives:

Action OptionalDouble OptionalInt OptionalLong
Getting as a primitive getAsDouble() getAsInt() getAsLong()
orElseGet() parameter type DoubleSupplier IntSupplier LongSupplier
Return type of max() and min() OptionalDouble OptionalInt OptionalLong
Return type of sum() double int long
Return type of average() OptionalDouble OptionalDouble OptionalDouble

Summarizing Statistics

With Summary Statistics we don't need to worry about needing to get max and min from the same stream at a time, since they are both terminal operations. All we gotta do is call summaryStatistics(), this method perform many calculations about the stream, including the following:

  • Smallest number (min): getMin()
  • Largest number (max): getMax()
  • Average: getAverage()
  • Sum: getSum()
  • Number of values: getCount()

If the stream were empty, we'd have a count and sum of zero, while the other methods would return an empty optional.

Functional Interfaces for Primitives

If you didn't memorize the functional interfaces table in the start of this chapter, go take a look at it now if you want to remember about them, because we're going to use them here with primitives. Most of them are for the double, int and long types for streams and optionals. There is one exception, which is the BooleanSupplier, it'll be the first one to be covered.

Functional Interfaces for boolean

BooleanSupplier is a separate type, it has one method to implement:

        boolean getAsBoolean()

It works as you've come to expect with functional interfaces:

        BooleanSupplier b1 = () -> true;
        BooleanSupplier b2 = () -> Math.random() > .5;
        System.out.println(b1.getAsBoolean()); // true
        System.out.println(b2.getAsBoolean()); // false

Basically it returns true or false depending on the condition that you put in the lambda.

Functional Interfaces for double, int and long

The following are common functional interfaces for primitives:

Functional interfaces # parameters Return type Single abstract method
DoubleSupplier, IntSupplier and LongSupplier 0 double, int and long getAsDouble, getAsInt and getAsLong
DoubleConsumer, IntConsumer and LongConsumer 1 (double), 1 (int) and 1 (long) void accept
DoublePredicate, IntPredicate and LongPredicate 1 (double), 1 (int) and 1 (long) boolean test
DoubleFunction<R>, IntFunction<R> and LongFunction<R> 1 (double), 1 (int) and 1 (long) R apply
DoubleUnaryOperator, IntUnaryOperator and LongUnaryOperator 1 (double), 1 (int) and 1 (long) double, int and long applyAsDouble, applyAsInt and applyAsLong
DoubleBinaryOperator, IntBinaryOperator and LongBinaryOperator 2 (double, double), 2 (int, int) and 2 (long, long) double, int and long applyAsDouble, applyAsInt and applyAsLong

Some things to notice on these functional interfaces for primitives are:

  • Generics are gone from some of the interfaces and instead the type name tells us what primitive type is involved;
  • The single abstract method is often renamed when a primitive type is returned.

The following are some pritive-specific funtional interfaces:

Functional interfaces # parameters Return type Single abstract method
ToDoubleFunction<T>, ToIntFunction<T> and ToLongFunction<T> 1 (T) double, int and long applyAsDouble, applyAsInt and applyAsLong
ToDoubleBiFunction<T>, ToIntBiFunction<T> and ToLongBiFunction<T> 2 (T, U) double, int and long applyAsDouble, applyAsInt and applyAsLong
DoubleToIntFunction and DoubleToLongFunction 1 (double) and 1 (double) int and long applyAsInt and applyAsLong
IntToDoubleFunction and IntToLongFunction 1 (int) and 1 (int) double and long applyAsDouble and applyAsLong
LongToDoubleFunction and LongToIntFunction 1 (long) and 1 (long) double and int applyAsDouble and applyAsInt
ObjDoubleConsumer<T>, ObjIntConsumer<T> and ObjLongConsumer<T> 2 (T, double), 2 (T, int) and 2 (T, long) void accept

Working with Advanced Stream Pipeline Concepts

We're almost ending this chapter, there are only a few topics left. Now we'll learn about some of the more advanced concepts such as the relationship between streams and the underlying data, chaining Optional and grouping collectors.

Linking Streams to the Underlying Data

This code will output 3:

        var cats = new ArrayList<_String_>();
        cats.add("Annie");
        cats.add("Ripley");
        var stream = cats.stream();
        cats.add("KC");
        System.out.println(stream.count()); // 3

A Stream is created from the 'cats' list, but as we already saw earlier Streams are lazily evaluated, this means that the stream isn't actually created on that .stream() call, only an object was created and that object knows where to look for the data when needed. After cats add "KC" to the List, we call the method count() to get values from the List, finally the Stream pipeline runs and it looks for the source and there it finds three values.

Chaining Optionals

We have some Stream operations for Optionals that can save some of our time and also improve our code, for example the following code does not use a Stream pipeline to manipulate the Optional object:

        if (optional.isPresent()) {
            Integer num = optional.get();
            var string = "" + num;
            if (string.length() == 3) System.out.println(string);
        }

As always we need to check if the optional is present and then use get to return the actual value from the object, but this code contains an extra complexety that doesn't need to be there since we have the ability of using functional programming with Optionals, the following uses a stream pipeline to do the same as the code above:

        optional.map(n -> "" + n)
                .filter(s -> s.length() == 3)
                .ifPresent(System.out::println);

As you can see, this is much shorter and more expressive than the other example.

If we had an case where we received an empty optional, the first approach if would not let the code inside the if block be executed, on the second approach the empty optional would pass through both map() and filter(), then the ifPresent() wouldn't call the Consumer parameter. Same thing would happen on the second approach if we had an value that does not match the filter(), the filter() operation would pass down to the ifPresent() an empty Optional which would also make the ifPresent() not call the Consumer parameter.

Two other examples are these:

  • If we wanted to get an Optional<Integer> that represents the length of the String contined in another Optional:

          Optional<Integer> result = optional.map(String::length); // Returns Optional<Integer>
    
  • But if we wanted to call another method that already returns an Optional, this could be a trick in the exam because the following is not compilant:

          Optional<Integer> result = optional.map(ChainingOptionals::calculator); // Returns Optional<Optional<Integer>>, hence it does not compile
    

This last one will not compile because map returns an Optional and the calculator already returns an Optional<Integer> for us, so what we need to do to get an actual Optional<Integer> is use flatMap() instead of map():

        Optional<Integer> result = optional.flatMap(ChainingOptionals::calculator); // Returns Optional<Integer>

This works because flatMap removes the unnecessary layer, flattening the result. Chained flatMap calls are also useful when we need to transform one Optional type to another.

Collecting Results

This is the last topic on this chapter, so far we learned a little about grouping results with the collect() terminal operation. There are many predefined collectors, including some that will be shown in a following list. These collectors are available via static methods on the Collectors interface:

Collector Description Return value when passed to collect
averagingDouble(ToDoubleFunction f), averagingInt(ToIntFunction f) and averagingLong(ToLongFunction f) Calculates the average for our three core primitive types Double
counting() Counts the number of elements Long
groupingBy(Function f), groupingBy(Function f, Collector dc) and groupingBy(Function f, Supplier s, Collector dc) Creates a map grouping by the specified function with the optional map type supplier and optional downstream collector Map<K, List<T>>
joining(CharSequence cs) Creates a single String using cs as a delimiter between elements if one is specified String
maxBy(Comparator c) and minBy(Comparator c) Finds the largest/smallest elements Optional<T>
mapping(Function f, Collector dc) Adds another level of collectors Collector
partitioningBy(Predicate p) and partitioningBy(Predicate p, Collector dc) Creates a map grouping by thge specified predicate with the optional further downstream collector Map<Boolean, List<T>>
summarizingDouble(ToDoubleFunction f), summarizingInt(ToIntFunction f) and summarizingLong(ToLongFunction f) Calculates average, min, max and so on DoubleSummaryStatistics, IntSummaryStatistics and LongSummaryStatistics
summingDouble(ToDoubleFunction f), summingInt(ToIntFunction f) and summingLong(ToLongFunction f) Calculates the sum for our three core primitive types Double, Integer and Long
toList() and toSet() Creates an arbitrary type of list or set List and Set
toCollection(Supplier s) Creates a Collection of the specified type Collection
toMap(Function k, Function v), toMap(Function k, Function v, BinaryOperation m) and toMap(Function k, Function v, BinaryOperator m, Supplier s) Creates a map using functions to map the keys, values, an optional merge function, and an optional map type supplier Map

These are some grouping/partitioning collectors.

Collecting Using Basic Collectors

It's important to pass the Collector to the collect() method. The Collectors exists to help collect elements and a Collector doesn't do anything on its own. For example:

        String result = stringList.collect(Collectors.joining(", ")); // returns a string joining all elements with a comma and a space
        String result2 = stringList.collect(Collectors.averagingInt(String::length)); // return the average length of the string elements
        TreeSet<String> result3 = stringList.filter(s -> s.startsWith("t")).collect(Collectors.toCollection(TreeSet::new)); // filters values that starts with 't'
                                     // and returns those inside a new instance of the TreeSet collection -- Could use toSet if we wanted a Set instead of TreeSet

With these examples you should be able to use almost all of the Collectors from the list, except groupingBy(), mapping(), partitioningBy() and toMap().

Collecting into Maps

When creating a map you need to specify two functions. The first function tells the collector how to create the key and the second tells the collector how to create the value. For example:

        Map<String, Integer> map = stringList.collect(Collectors.toMap(s -> s, String::length)); // results in {ex=2, ex2=3, ex3=3} for ex.

Note: Returning the same value passed into a lambda is a common operation, so Java provides a method for it. We can rewrite the s -> s as Function.identity().

If we wanted to run the same code but reversed, it would probably throw an exception since the keys could easily repeat themselfs and that would throw a IllegalStateException: Duplicate key 'n'. But there's a way to solve this with a overload of the toMap method, which is the following:

        Map<Integer, String> map = stringList.collect(Collectors.toMap(String::length, s -> s, (s1, s2) -> s1 + "," + s2));
        // results in {2=ex, 3=ex2,ex3} instead of an exception.

The adition of the third lambda makes the toMap method understand how to handle such cases where the keys are the same, and the method will return a HashMap instead of a Map reference. If we wanted to define which map type we wanted to return, we could use the last overload of the toMap method, which is the follwoing:

        Map<Integer, String> map = stringList.collect(Collectors.toMap(String::length, s -> s, (s1, s2) -> s1 + "," + s2, TreeMap::new));
        // results in {2=ex, 3=ex2,ex3}, but is a TreeMap ref instead of HashMap.
Collecting Using Grouping, Partitioning and Mapping

The methods groupingBy() and partitioningBy() appear often on the exam, so be sure to understand them well. Let's suppose that we wanted to get groups of names by their length, we can do that by saying that we want to group by length:

        Map<Integer, List<String>> map = stringList.collect(Collectors.groupingBy(String::length)); // returns {2=[ex], 3=[ex2,ex3]}

The groupingBy() collector tells collect() that it should group all of the elements of the stream into a Map.The function determines the keys in the Map and each value in the Map is a List of all entries that match that key.

Note: The functional we call in groupingBy() cannot return null. It does not allow null keys.

Suppose we want a Set instead of a List. There's another signature of the method that lets us pass a downstream collector, this second collector does something special with the values:

                    Map<Integer, Set<String>> map = stringList
                            .collect(Collectors.groupingBy(String::length, Collectors.toSet())); // returns {2=[ex], 3=[ex2,ex3]} but in a Set

We can even change the Map type returned through yet another parameter:

                    TreeMap<Integer, Set<String>> map = stringList
                            .collect(Collectors.groupingBy(String::length, TreeMap::new, Collectors.toSet()));

As this is very flexible you can do a lot of things with it.

Partitioning is a special case of grouping. With partitioning there are only two possible groups: true and false. Partitioning is like splitting a list into two parts. For example:

        Map<Boolean, List<String>> map = stringList.collect(Collectors.partitioningBy(s -> s.length() <= 5)); 
        // returns two lists in a map {false=[tigers],true=[lions,bears]}

For this example we passed a Predicate with the logic for which group each string belongs in. If we wanted to check for 7 instead of 5, the false array would be empty.

As with groupingBy() we can change the type of List to something else:

        Map<Boolean, Set<String>> map = stringList.collect(Collectors.partitioningBy(s -> s.length() <= 5, Collectors.toSet())); 

But unlike groupingBy we can't change the type of Map that gets returned.

And finally to end the chapter, instead of using the downstream collector to specify the type, we can use any of the collectors that we've already studied:

        Map<Integer, Long> map = stringList.collect(Collectors.groupingBy(String::length, Collectors.counting()));  // {5=2, 6=1}

With this we could also use the mapping Collector instead of counting or any other else:

        Map<Integer, Long> map = stringList.collect(Collectors.groupingBy(String::length,
                                                               Collectors.mapping(
                                                                          s -> s::chartAt(0),
                                                                          Collectores.minBy((a,b) -> a - b)
                                                               )));  
        // returns {5=optional[b], 6=Optional[t]} supposing we have a List with these elements: "lions, tigers, bears" 

This is one of the most difficult codes to read since mapping takes two paramenters: the function for the value and how to group it further. On this case we wanted to get the first letter of the first animal alphabetically of each length.