Practical Functional Java

Streams

Jeff Butler

https://github.com/jeffgbutler/practical-functional-java

https://jeffgbutler.github.io/practical-functional-java/

Streams

Streams == Lazy Pipelines

All code examples in examples.basics.streams.StreamTest.java

Declarative vs. Imperative

  • Streams are declarative
  • Streams are to Collections as SQL is to Data
  • Streams can replace some very common patterns in your code
  • Streams require some trust - that they work better than your imperative code
  • In my experience, the best way to learn to work with streams is to swear you will never write another for/do/while loop

Laziness

  • Streams are lazy
  • Streams can be parallel
  • Laziness means that intermediate operations are not executed until a terminal operation is called
  • Typical intermediate operations are map, flatMap and filter
  • Typical terminal operations are reduce, collect, count, min, max
  • There are a HUGE number of different types of collectors. We will only cover a few.

Streams can be created from a List

          
    private Stream<ImmutablePerson> getTheFlintstones() {
        List<ImmutablePerson> people = new ArrayList<>();
        
        people.add(ImmutablePerson.of("Fred", "Flintstone"));
        people.add(ImmutablePerson.of("Wilma", "Flintstone"));
        people.add(ImmutablePerson.of("Pebbles", "Flintstone"));

        return people.stream();
    }
          
        

Streams can be created directly with the "of" method

          
    private Stream<ImmutablePerson> getTheRubbles() {
        return Stream.of(ImmutablePerson.of("Barney", "Rubble"),
                ImmutablePerson.of("Betty", "Rubble"),
                ImmutablePerson.of("Bamm Bamm", "Rubble"));
    }
          
        

The "collect" method can gather a stream into a List

          
    @Test
    public void testCollect() {
        List<ImmutablePerson> flintstones = getTheFlintstones()
                .collect(Collectors.toList());
        
        assertThat(flintstones.size()).isEqualTo(3);
        assertThat(flintstones.get(1).getFirstName()).isEqualTo("Wilma");
    }
          
        

The "filter" method is used to create a new stream containing only items that match the filter

          
    // the "filter" method is used to create a new stream containing only
    // items that match the filter
    @Test
    public void testFilterConciseLambda() {
        List<ImmutablePerson> flintstones = getTheFlintstones()
              .filter(p -> p.getFirstName().equals("Fred") || p.getFirstName().equals("Wilma"))
              .collect(Collectors.toList());
        
        assertThat(flintstones.size()).isEqualTo(2);
        assertThat(flintstones.get(1).getFirstName()).isEqualTo("Wilma");
    }
          
        

You can use a method reference in a filter method.

          
    @Test
    public void testFilterWithMethodReference() {
        List<ImmutablePerson> flintstones = getTheFlintstones()
                .filter(this::isFredOrWilma)
                .collect(Collectors.toList());
        
        assertThat(flintstones.size()).isEqualTo(2);
        assertThat(flintstones.get(1).getFirstName()).isEqualTo("Wilma");
    }
    
    private boolean isFredOrWilma(ImmutablePerson p) {
        return p.getFirstName().equals("Fred") || p.getFirstName().equals("Wilma");
    }
          
        

The "map" method is used to transform a stream from one type to another

          
    @Test
    public void getMap() {
        String directoryListing = getTheFlintstones()
                .map(this::personAsString)  // Stream<ImmutablePerson> -> Stream<String>
                .collect(Collectors.joining("\n")); // Returns a String joined with "\n" 
        
        String expected = "Flintstone, Fred\n"
                + "Flintstone, Wilma\n"
                + "Flintstone, Pebbles";
        
        assertThat(directoryListing).isEqualTo(expected);
    }
    
    private String personAsString(ImmutablePerson person) {
        return person.getLastName() + ", " + person.getFirstName();
    }
          
        

The "sorted" method is used to reorder a Stream

          
    // you can supply a lambda for the sort function, or use
    // the natural order of a comparable
    @Test
    public void testSorted() {
        String directoryListing = getTheFlintstones()
                .sorted((p1, p2) -> p1.getFirstName().compareTo(p2.getFirstName()))
                .map(this::personAsString)
                .collect(Collectors.joining("\n"));
        
        String expected = "Flintstone, Fred\n"
                + "Flintstone, Pebbles\n"
                + "Flintstone, Wilma";
        
        assertThat(directoryListing).isEqualTo(expected);
    }
          
        

Stream methods can be chained together to create a pipeline

          
    @Test
    public void testStreamMethodChaining() {
        String directoryListing = getTheFlintstones()
                .filter(this::isFredOrWilma)
                .sorted((p1, p2) -> p1.getFirstName().compareTo(p2.getFirstName()))
                .map(this::personAsString)
                .collect(Collectors.joining("\n"));
        
        String expected = "Flintstone, Fred\n"
                + "Flintstone, Wilma";
        
        assertThat(directoryListing).isEqualTo(expected);
    }
          
        

The skip method can be used to skip over items in a stream

          
    @Test
    public void testSkip() {
        String directoryListing = getTheFlintstones()
                .skip(1)
                .filter(this::isFredOrWilma)
                .sorted((p1, p2) -> p1.getFirstName().compareTo(p2.getFirstName()))
                .map(this::personAsString)
                .collect(Collectors.joining("\n"));
        
        String expected = "Flintstone, Wilma";
        
        assertThat(directoryListing).isEqualTo(expected);
    }
          
        

flatMap is used to apply a many to one mapping

          
    @Test
    public void testFlatMap1() {
        ImmutablePerson fred = ImmutablePerson.of("Fred", "Flintstone");
        fred = fred.withNickNames("The Fredmeister", "Yabba Dabba Dude");
        
        ImmutablePerson barney = ImmutablePerson.of("Barney",  "Rubble");
        barney = barney.withNickNames("The Barnster", "Little Buddy");

        String expectedAllNickNames = "The Fredmeister,Yabba Dabba Dude,"
                + "The Barnster,Little Buddy";
        
        // (not so good) map each ImmutablePerson to a Stream<String> of nicknames,
        // then use flatMap for flatten
        String allNickNames = Stream.of(fred, barney)  // Stream<ImmutablePerson>
                .map(ImmutablePerson::nickNames)  // Stream<Stream<String>>
                .flatMap(Function.identity()) // Stream<String>
                .collect(Collectors.joining(","));
        
        assertThat(allNickNames).isEqualTo(expectedAllNickNames);
    }
          
        

This is a better use of flatMap

          
    @Test
    public void testFlatMap2() {
        ImmutablePerson fred = ImmutablePerson.of("Fred", "Flintstone");
        fred = fred.withNickNames("The Fredmeister", "Yabba Dabba Dude");
        
        ImmutablePerson barney = ImmutablePerson.of("Barney",  "Rubble");
        barney = barney.withNickNames("The Barnster", "Little Buddy");

        String expectedAllNickNames = "The Fredmeister,Yabba Dabba Dude,"
                + "The Barnster,Little Buddy";
        
        // (better) use flatMap instead of map with the mapping function
        String allNickNames = Stream.of(fred, barney)  // Stream<ImmutablePerson>
                .flatMap(ImmutablePerson::nickNames)  // Stream<String>
                .collect(Collectors.joining(","));
        
        assertThat(allNickNames).isEqualTo(expectedAllNickNames);
    }
          
        

Streams can be concatenated with the concat method

          
    @Test
    public void testConcatenationWithConcat() {
        // concat is OK to use when concatenating two streams, but can cause problems
        // with multiple streams and recursion.  There is a warning in the JavaDocs
        // about being careful with concat.  If you have multiple streams to concatenate,
        // then it is better to use the flatMap technique below.
        List<ImmutablePerson> allPeople = Stream.concat(getTheFlintstones(), getTheRubbles())
                .collect(Collectors.toList());

        assertThat(allPeople.size()).isEqualTo(6);
        assertThat(allPeople.get(1).getFirstName()).isEqualTo("Wilma");
        assertThat(allPeople.get(4).getFirstName()).isEqualTo("Betty");
    }
          
        

Streams can also be concatenated with flatMap

          
    @Test
    public void testConcatenationWithFlatMap() {
        List<ImmutablePerson> allPeople = Stream.of(getTheFlintstones(), getTheRubbles())
                .flatMap(Function.identity())
                .collect(Collectors.toList());

        assertThat(allPeople.size()).isEqualTo(6);
        assertThat(allPeople.get(1).getFirstName()).isEqualTo("Wilma");
        assertThat(allPeople.get(4).getFirstName()).isEqualTo("Betty");
    }
          
        

The Stream.forEach method is for side effects. We don't want side effects, so avoid doing this

          
    @Test
    public void testForEach() {
        StringBuilder sb = new StringBuilder();
        
        getTheFlintstones().forEach(p -> {
            sb.append(personAsString(p));
            sb.append("\n"); // no good way to tell if we are at the end of the stream
        });

        String expected = "Flintstone, Fred\n"
                + "Flintstone, Wilma\n"
                + "Flintstone, Pebbles\n"; // note the extra \n at the end
        
        assertThat(sb.toString()).isEqualTo(expected);
    }
          
        

Streams Summary

Map/Filter/Collect can replace virtually all loop structures in your code and drive you towards cleaner code.