Combinators in Isolation

Combinators in Isolation #

In Java, streams are instances of the generic type Stream<T> which has a type parameter T for the type of stream elements.

The map combinator #

The map combinator has the following signature1.

<R> Stream<R> map(Function<T,R> function);

It expects a Function as argument, which is itself a generic type with type parameters for the argument and result types. The argument type of the passed function needs to match the element type of the original stream. The element type of the returned stream matches the result type of the passed function. The following test asserts that map applies the given function to each element of the stream it is called on and returns a new stream containing the results.

@Test
void testThatMapAppliesGivenFunctionToEachElement() {
  final Stream<String> words = Stream.of("Hello", "Streams");
  final Stream<Integer> result = words.map(w -> w.length());
  assertStreamEquals(Stream.of(5, 7), result);
}

The static method Stream.of can be used to create a stream from given elements. In this test we create a stream of strings and use map with a function that computes the length of strings. The lambda expression used as argument of map is equivalent to the following instance of an anonymous class.

new Function<String,Integer>() {
  public Integer apply(String w) {
    return w.length();
  }
}

The apply method is applied to each string in the stream words and all results are collected in the new stream result.

Note that we do not see in which order a given function is applied to the elements of a stream by map. In fact it might be applied in parallel depending on how the stream was created. All we can see is that the function is applied to each element uniformly not necessarily one element at a time.

The filter combinator #

The filter combinator has the following signature.

Stream<T> filter(Predicate<T> predicate);

It expects a Predicate as argument, which is a generic type for functions that return a boolean result. The argument type of the passed predicate needs to match the element type of the original stream which is also the element type of the returned stream. The following test asserts that filter applies the given predicate to each element of the stream it is called on and returns a new stream containing only those elements which are accepted by the predicate, removing the others.

@Test
void testThatFilterRemovesNonMatchingElements() {
  final Stream<String> words = Stream.of("Hello", "Streams");
  final Stream<String> result = words.filter(w -> w.length() > 6);
  assertStreamEquals(Stream.of("Streams"), result);
}

The lambda expression used as argument of filter is equivalent to the following instance of an anonymous class.

new Predicate<String>() {
  public boolean test(String w) {
    return w.length() > 6;
  }
}

The test method is applied to each string in the stream words and those strings where test returns true are collected in the new stream result. In general, the given predicate is applied uniformly to the elements of a stream by filter, not necessarily one at a time, and possibly in parallel.

The flatMap combinator #

The flatMap combinator has the following signature.

<R> Stream<R> flatMap(Function<T, Stream<R>> function);

It expects a function as argument where the argument type needs to match the element type of the original stream. The result of the function passed as argument to flatMap needs to be a stream itself, and its type matches the return type of flatMap. The following test asserts that flatMap applies the given function to each element of the stream it is called on and returns a new stream combining all elements of streams returned by the given function.

@Test
void testThatFlatMapCombinesStreamResults() {
  final Stream<String> words = Stream.of("Hello", "Streams");
  final Stream<Integer> result = words.flatMap(w -> w.chars().boxed());
  assertEquals(12, result.count());
}

The lambda expression used as argument of flatMap is equivalent to an instance of an anonymous class implementing Function<String, Stream<Integer>>. The method chars on strings returns an IntStream which is a stream where the elements are primitive int values (one for each character of the string.) We use the boxed method to convert the IntStream into a stream of type Stream<Integer> to match the result type of the argument passed to flatMap. This time we use the terminal operation count on the stream result to collect all 12 elements into a single number.

The flatMap combinator can be used to flatten a nested stream as the following test demonstrates.

@Test
void testThatFlatMapWithIdentityFunctionFlattensNestedStreams() {
  final Stream<Stream<Integer>> nested = Stream.of(Stream.of(2),Stream.of(3,4));
  final Stream<Integer> flat = nested.flatMap(s -> s);
  assertStreamEquals(Stream.of(2, 3, 4), flat);
}

The lambda expression passed as argument is a function that takes a stream as argument and returns it as result.

Task: Reasoning #

Think about properties that can be used to manipulate or reason about expressions involving the presented combinators and write more tests to check these properties. Can you express some of the presented combinators using others in a way that would allow arbitrary applications of one combinator to be replaced by a corresponding application of another?


  1. Real signatures might be more general than what is shown here because we specialize bounded type parameters. ↩︎