Using Streams and Method References to Write Self-Documenting Processes in Java

Method references make excellent self-documenting code, and using method references with Streams makes complicated processes simple to read and understand. Consider the following code:

public interface Ordered {
     default int getOrder(){
         return 0;
     }
}

public interface Valued {
     boolean hasPropertyTwo();
     V getValue();
}

public interface Thing {
     boolean hasPropertyOne();
     Valued getValuedProperty();
}

public List myMethod(List> things) {
     List results = new ArrayList();
     for (Thing thing : things) {
         if (thing.hasPropertyOne()) {
             Valued valued = thing.getValuedProperty();
             if (valued != null && valued.hasPropertyTwo()){
                 V value = valued.getValue();
                 if (value != null){
                      results.add(value);
                 }
             }
         }
    }
    results.sort((a, b)->{
         return Integer.compare(a.getOrder(), b.getOrder());
    });
    return results;
}

This last method rewritten using Streams and method references is much more legible and each step of the process is quickly and easily understood – it’s not just shorter, it also shows at a glance which interfaces and classes are responsible for the code in each step:

public List myMethod(List> things) {
return things.stream()
.filter(Thing::hasPropertyOne)
.map(Thing::getValuedProperty)
.filter(Objects::nonNull)
.filter(Valued::hasPropertyTwo)
.map(Valued::getValue)
.filter(Objects::nonNull)
.sorted(Comparator.comparing(Ordered::getOrder))
.collect(Collectors.toList());
}

Converting a Stream of Optional to a Stream of Values

You may need to convert a Stream emitting Optional to a Stream of values, emitting only values from existing Optional. (ie: without null value and not dealing with Optional.empty()).

Optional<String> op1 = Optional.empty();
Optional<String> op2 = Optional.of("Hello World");

List<String> result = Stream.of(op1, op2)
                     .filter(Optional::isPresent)
                     .map(Optional::get)
                     .collect(Collectors.toList());
System.out.println(result); //[Hello World]

Get a Slice of a Stream

Example: Get a Stream of 30 elements, containing 21st to 50th (inclusive) element of a collection.

final long n = 20L; // the number of elements to skip
final long maxSize = 30L; // the number of elements the stream should be limited to
final Stream slice = collection.stream().skip(n).limit(maxSize);

Notes:

  • IllegalArgumentException is thrown if n is negative or maxSize is negative
  • both skip(long) and limit(long) are intermediate operations
  • if a stream contains fewer than n elements then skip(n) returns an empty stream
  • both skip(long) and limit(long) are cheap operations on sequential stream pipelines, but can be quite expensive on ordered parallel pipelines.

Related Article: Finding Statistics about Numerical Streams in Java

Create a Map based on a Stream

Simple case without duplicate keys

Stream<String> characters = Stream.of("A", "B", "C");

Map<Integer, String> map = characters
.collect(Collectors.toMap(element -> element.hashCode(), element -> element));
// map = {65=A, 66=B, 67=C}

To make things more declarative, we can use static method in Function interface – Function.identity(). We can replace this lambda element -> element with Function.identity().

Case where there might be duplicate keys

The javadoc for Collectors.toMap states:

If the mapped keys contains duplicates (according to Object.equals(Object)), an IllegalStateException is thrown when the collection operation is performed. If the mapped keys may have duplicates, use toMap(Function, Function, BinaryOperator) instead.

Stream<String> characters = Stream.of("A", "B", "B", "C");

Map<Integer, String> map = characters
                   .collect(Collectors.toMap(
                    element -> element.hashCode(),
                    element -> element,
                   (existingVal, newVal) -> (existingVal + newVal)));
// map = {65=A, 66=BB, 67=C}

The BinaryOperator passed to Collectors.toMap(…) generates the value to be stored in the case of a collision. It can:

  • return the old value, so that the first value in the stream takes precedence,
  • return the new value, so that the last value in the stream takes precedence, or
  • combine the old and new values

Grouping by value

You can use Collectors.groupingBy when you need to perform the equivalent of a database cascaded “group by” operation. To illustrate, the following creates a map in which people’s names are mapped to surnames:

List people = Arrays.asList(
     new Person("Sam", "Rossi"),
     new Person("Sam", "Verdi"),
     new Person("John", "Bianchi"),
     new Person("John", "Rossi"),
     new Person("John", "Verdi")
);

Map<String, List<String> map = people.stream()
         .collect(
          // function mapping input elements to keys
          Collectors.groupingBy(Person::getName,
          // function mapping input elements to values,
          // how to store values
          Collectors.mapping(Person::getSurname, Collectors.toList()))
);
// map = {John=[Bianchi, Rossi, Verdi], Sam=[Rossi, Verdi]}

Joining a stream to a single String

A use case that comes across frequently, is creating a String from a stream, where the stream-items are separated by a certain character. The Collectors.joining() method references can be used for this, like in the following example:

Stream fruitStream = Stream.of("apple", "banana", "pear", "kiwi", "orange");

String result = fruitStream.filter(s -> s.contains("a"))
          .map(String::toUpperCase)
          .sorted()
          .collect(Collectors.joining(", "));
System.out.println(result);

Output:

APPLE, BANANA, ORANGE, PEAR

The Collectors.joining() method can also cater for pre- and postfixes:

String result = fruitStream.filter(s -> s.contains("e"))
      .map(String::toUpperCase)
      .sorted()
       .collect(Collectors.joining(", ", "Fruits: ", "."));
System.out.println(result);

Output:

Fruits: APPLE, ORANGE, PEAR.

Sort Using Stream

List data = new ArrayList<>();
data.add("Sydney");
data.add("London");
data.add("New York");
data.add("Amsterdam");
data.add("Mumbai");
data.add("California");

System.out.println(data);

List sortedData = data.stream().sorted().collect(Collectors.toList());

System.out.println(sortedData);

Output:

[Sydney, London, New York, Amsterdam, Mumbai, California]
[Amsterdam, California, London, Mumbai, New York, Sydney]

It’s also possible to use different comparison mechanism as there is a overloaded sorted version which takes a comparator as its argument.

Also, you can use a lambda expression for sorting:

List sortedData2 = data.stream().sorted((s1,s2) ->
s2.compareTo(s1)).collect(Collectors.toList());

This would output
[Sydney, New York, Mumbai, London, California, Amsterdam]
You can use Comparator.reverseOrder() to have a comparator that imposes the reverse of the natural ordering.

List reverseSortedData = ata.stream().sorted(Comparator.reverseOrder()).collect(Collectors.toList());

Streams of Primitives

Java provides specialized Streams for three types of primitives IntStream (for ints), LongStream (for longs) and DoubleStream (for doubles). Besides being optimized implementations for their respective primitives, they also provide several specific terminal methods, typically for mathematical operations. E.g.:

IntStream is = IntStream.of(10, 20, 30);
double average = is.average().getAsDouble(); // average is 20.0

Stream operations categories

Stream operations fall into two main categories, intermediate and terminal operations, and two sub-categories, stateless and stateful.

Intermediate Operations:

An intermediate operation is always lazy, such as a simple Stream.map. It is not invoked until the stream is actually consumed. This can be verified easily:

Arrays.asList(1, 2 ,3).stream().map(i -> {
     throw new RuntimeException("not gonna happen");
     return i;
});

Intermediate operations are the common building blocks of a stream, chained after the source and are usually followed by a terminal operation triggering the stream chain.

Terminal Operations

Terminal operations are what triggers the consumption of a stream. Some of the more common are
Stream.forEach or Stream.collect. They are usually placed after a chain of intermediate operations and are almost always eager.

Stateless Operations

Statelessness means that each item is processed without the context of other items. Stateless operations allow for memory-efficient processing of streams. Operations like Stream.map and Stream.filter that do not require information on other items of the stream are considered to be stateless.

Stateful operations

Statefulness means the operation on each item depends on (some) other items of the stream. This requires a state to be preserved. Statefulness operations may break with long, or infinite, streams. Operations like Stream.sorted require the entirety of the stream to be processed before any item is emitted which will break in a long enough stream of items. This can be demonstrated by a long stream (run at your own risk):

// works - stateless stream
long BIG_ENOUGH_NUMBER = 999999999;
IntStream.iterate(0, i -> i + 1).limit(BIG_ENOUGH_NUMBER).forEach(System.out::println);

This will cause an out-of-memory due to statefulness of Stream.sorted:

// Out of memory - stateful stream
IntStream.iterate(0, i -> i+1).limit(BIG_ENOUGH_NUMBER).sorted().forEach(System.out::println);

Collect Results of a Stream into an Array

Analog to get a collection for a Stream by collect() an array can be obtained by the Stream.toArray() method:

List fruits = Arrays.asList("apple", "banana", "pear", "kiwi", "orange");

String[] filteredFruits = fruits.stream()
     .filter(s -> s.contains("a"))
     .toArray(String[]::new);

// prints: [apple, banana, pear, orange]
System.out.println(Arrays.toString(filteredFruits));

String[]::new is a special kind of method references: a constructor reference.

Generating random Strings using Streams

It is sometimes useful to create random Strings, maybe as Session-ID for a web-service or an initial password after registration for an application. This can be easily achieved using Streams.

First we need to initialize a random number generator. To enhance security for the generated Strings, it is a good idea to use SecureRandom.

Note: Creating a SecureRandom is quite expensive, so it is best practice to only do this once and call one of its setSeed() methods from time to time to reseed it.

private static final SecureRandom rng = new SecureRandom(SecureRandom.generateSeed(20));
//20 Bytes as a seed is rather arbitrary, it is the number used in the JavaDoc example

When creating random Strings, we usually want them to use only certain characters (e.g. only letters and digits). Therefore we can create a method returning a boolean which can later be used to filter the Stream.

//returns true for all chars in 0-9, a-z and A-Z
boolean useThisCharacter(char c){
      //check for range to avoid using all unicode Letter (e.g. some chinese symbols)
     return c >= '0' && c <= 'z' && Character.isLetterOrDigit(c);
}

Next we can utilize the RNG to generate a random String of specific length containing the charset which pass our useThisCharacter check.

public String generateRandomString(long length){
    //Since there is no native CharStream, we use an IntStream instead
   //and convert it to a Stream using mapToObj.
   //We need to specify the boundaries for the int values to ensure they can safely be cast to char
   Stream randomCharStream = rng.ints(Character.MIN_CODE_POINT,
Character.MAX_CODE_POINT).mapToObj(i -> (char)i).filter(c -> this::useThisCharacter).limit(length);

    //now we can use this Stream to build a String utilizing the collect method.
    String randomString = randomCharStream.collect(StringBuilder::new, StringBuilder::append, StringBuilder::append).toString();
return randomString;
}

LEAVE A REPLY

Please enter your comment!
Please enter your name here