Lambdas and the Stream API

Lambda expressions are anonymous functions. The concept of a lambda expression stems from the Lambda calculus which is a turing-complete formal system for expressing computation. The Lamdba calculus builds the foundation of the functional programming paradigm where programs are constructed from functions only. Recall that Java is an imperative language. Nonetheless like many other modern languages, Java borrows the idea of Lambda expressions from functional programming. Before discussing lambdas in more detail, we need to introduce the concept of a Functional Interface in Java. Any interface that contains a single non-static method is called a functional interface.

In [1]:
package lecture;

public class Person {
    public String name;
    public String address;
    public int age;
    
    public Person(String name, String address, int age) {
        this.name = name;
        this.address = address;
        this.age = age;
    }
    
    public String toString() {
        return String.format("Person[%s lives in %s, age: %d]", name, address, age);
    }
}
Out[1]:
lecture.Person
In [3]:
package lecture;

// a PersonFilter is a functioal interface
public interface PersonFilter {
    
    public boolean test(Person p);
}
Out[3]:
lecture.PersonFilter
In [4]:
package lecture;

import java.util.List;

// a class for searching persons in a list
public class PersonListSearcher {
    
    // use a person filter to evaluate whether the Person matches our search criteria
    public static Person find (List<Person> persons, PersonFilter check) {
        for(Person p: persons)
            if(check.test(p))
                return p;
        return null;
    }
}
Out[4]:
lecture.PersonListSearcher
In [8]:
package lecture;

public class AddressFilter implements PersonFilter {
    
    public boolean test(Person p) {
        return (p.address.contains("Chicago"));
    }
}
Out[8]:
lecture.AddressFilter
In [7]:
import java.util.ArrayList;
import lecture.Person;
import lecture.PersonFilter;
import lecture.PersonListSearcher;

ArrayList<Person> l = new ArrayList<Person>();
l.add(new Person("Alice", "Chicago, 60611", 43));
l.add(new Person("Bob", "Milwaukee, 44444", 3));
l.add(new Person("Jane", "Chicago, 60612", 13));

// to search for a person that is younger than 10 years we can create a PersonFilter using an anonymous class
return PersonListSearcher.find(l, new PersonFilter() { 
    public boolean test(Person p) {
        return (p.age < 3);
    }
}).toString();
Out[7]:
Person[Jane lives in Chicago, 60612, age: 13]
In [10]:
import java.util.ArrayList;
import lecture.Person;
import lecture.PersonFilter;
import lecture.AddressFilter;
import lecture.PersonListSearcher;

ArrayList<Person> l = new ArrayList<Person>();
l.add(new Person("Alice", "Chicago, 60611", 43));
l.add(new Person("Bob", "Milwaukee, 44444", 3));
l.add(new Person("Jane", "Chicago, 60612", 13));

// to search for a person that is younger than 10 years we can create a PersonFilter using an anonymous class
return PersonListSearcher.find(l, new AddressFilter()).toString();
Out[10]:
Person[Alice lives in Chicago, 60611, age: 43]

The -> operator

The disadvantage of anonymous classes are quite verbose. In particular, for functional interfaces where we are providing the implementation of a single method. Lambda expression greatly simplify the definition of such methods. A lambda expression is of the form:

<parameters> -> <return_expression>

where the result of <return_expression> is returned. Lambdas can also have multiline bodies like regular methods which are enclosed in {}, e.g.,

<parameters> -> {
    <body>
}

Note that <parameters> is a list of parameters

In [11]:
import java.util.ArrayList;
import lecture.Person;
import lecture.PersonFilter;
import lecture.PersonListSearcher;

ArrayList<Person> l = new ArrayList<Person>();
l.add(new Person("Alice", "Chicago, 60611", 43));
l.add(new Person("Bob", "Milwaukee, 44444", 3));
l.add(new Person("Jane", "Chicago, 60612", 13));

// to search for a person that is younger than 10 years we can create a PersonFilter using a lambda expression (p -> p.age < 10)
return PersonListSearcher.find(l, p -> p.age < 10).toString();
Out[11]:
Person[Bob lives in Milwaukee, 44444, age: 3]

Lambdas and Generics

Lambdas cannot be generic. However, it is possible for a Lambda to implement a functional interface that use generics. Let us explore this using a common higher-order function (a function that takes a function as an input parameter) in functional programming called fold (fold left). fold takes three arguments: a list with input data, a binary function which two inputs of the type of the elements of the list and returns an ouput of the same type, and a intial value (also of the element type of the list). fold returns a single result of the same type as the elements of the list that is computed as follows:

  1. The result is set to the initial value provided as input: result = initial
  2. For each element the result is set to the result of applying the binary function to the current result and the element: result = f(result, element)
In [24]:
package lecture;

import java.util.List;
import java.util.ArrayList;

public class FoldDemo {

    interface BinaryOp<T> {
        public T compute(T lop, T rop);
    }

    public static <T> T fold (List<T> l, BinaryOp<T> f, T init) {
        T res = init;
        for(T el: l)
            res = f.compute(res, el);
        return res;
    }
    
    public static void main(String [] args) {
        List<Integer> l = new ArrayList<Integer> ();
        l.add(1);
        l.add(3);
        l.add(15);

        //BinaryOp<Integer> s = new BinaryOp<Integer> () {
        //    public Integer computer(Integer lop, Integer rop) {
        //        return lop + rop;
         //   }
        //};
       
        // (x, y) -> x+y 
        Integer res = fold(l, (x, y) -> x+y, 0);
        System.out.println(res);
    }
}
Out[24]:
lecture.FoldDemo
In [25]:
import lecture.FoldDemo;
FoldDemo.main(null);
19
Out[25]:
null

Assigning Lambdas to Variables

To assign a lambda to a variable you can define a variable of a type that is a functional interface and then assign the lambda to this variable. As an example consider some functional interfaces provided by Java in java.util.function:

  • Function<T,R> a function that (apply method) that takes an argument of type T and returns a result of type R.
  • BiConsumer<T,U> a method that takes arguments of types T and U and does not return a value
In [26]:
import java.util.function.*;

Function<String,Integer> myLen = s -> s.length();

return "" + myLen.apply("hello world!");
Out[26]:
12

Method References

Sometimes a class already implements a method that we want to use as a lambda. Say a class C has a static method f with a single input of type T. Then when we want to define a lambda that applies this method we have to write:

in -> C.f(in)

This can be written more concisely using Java's method references. A method reference uses the :: operator to refer to a method. Method references can refer to static methods, instance methods, and constructors:

  • Static method - if f is a static method with result type R and parameter types T1, ..., Tn of class C then a reference to f is written as C::f and this is equivalent to the lambda expression (t1, ..., tn) -> C.f(t1, ..., tn)
  • Instance method - if f is a non-static methof with result type R and parameter types T1, ..., Tn of class C then a reference to f is also written as C::f and this is equivalent to the lambda expression (o, t1, ..., tn) -> o.f(t1, ..., tn). That is, the lambda takes an instance of class C as input and calls f on this instance.
In [3]:
import java.util.function.*;

// a BiPredicate takes two inputs and returns a boolean
BiPredicate<String,String> streq = String::equals; // recall that the signature of equals is boolean String.equals(Object other)

return streq.test("ABC", "ABC");
Out[3]:
true
In [4]:
import java.util.function.*;

Function<String,Integer> str = String::length;

return str.apply("ABC");
Out[4]:
3

Stream API

Java provides several high-level functions over collections thought the Stream API. A stream is constructed from an array or collection. For Collections you can create stream by calling stream() on the collection. You can perform computations over streams by constructing a stream pipeline which consists of a source (e.g., a collection .stream()), zero or more intermediate operations, and one terminal operation. The intermediate operations produce streams as outputs and are otherwise side-effect free while the terminal operations may produce a non-stream output and can have side-effects. Stream pipelines are lazy. Only once a terminal operation is added, the pipeline is executed. This, at least in principle, enables operations to be optimized having knowledge of the full pipeline. Some example intermediate and terminal operations are described below. For a full list see the Java documentation https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/stream/Stream.html.

Intermediate Operations

  • filter(Predicate predicate) - removes all elements from the stream for which the predicate (a function mapping stream elementst to boolean) evaluates to true
  • map(Function<T,R> mapper) - replaces each element of the stream of type T with the result of applying the Function of type T -> R to the element.
  • reduce(T identity, BinaryOperator<T> accumulator) - applies the binary operator (a function T, T -> T) to aggregate the elements of the stream (this is fold!).
  • skip(long n) - returns a stream which is identical to the input except that the first n elements are removed.

Terminal Operations

  • count() - returns the number of elements in the stream
  • collect(Supplier<R> supplier, BiConsumer<R,T> accumulator, BiConsumer<R,R> combiner) - This constructs a result from the stream by creating an object of type R using the supplier and accumulating the elements of the stream by applying the accumulator to the current result and the current stream element (of type T). The combiner combines two partitial results (this is used for optimization).
  • sorted() - sorts the elements of this stream
In [5]:
import java.util.List;
import java.util.ArrayList;
import java.util.stream.*;
import java.util.Arrays;
import java.util.HashMap;
import java.util.function.*;

List<String> mystr = Arrays.asList(new String[] { "Abc", "Gbd", "asdasd", "AbAA", "ZAere", "AZ"});

// create a stream that contains all strings from the list that 
// start with "A". This will not execute the filter!
Stream<String> st = mystr.stream().filter( s -> s.startsWith("A")); 

/* Now let's place the result in a map where the key is the second characters of 
 * a string and the value is the list of strings that have this second character.
 */
// this is a function that extracts the second character from a string
final Function<String,Character> getSecChar = s -> s.charAt(1);

// this is a function that takes a hashmap<Characters,List<String>> 
// and a string, extracts the second characters from the string and 
// appends the string to the list of string for this character in the 
// HashMap.
final BiFunction<HashMap<Character,List<String>>,String,HashMap<Character,List<String>>> mapAppendToList = (map, s) -> {
    List<String> val = map.get(getSecChar.apply(s));
    if (val == null) {
        val = new ArrayList<String> ();
        map.put(getSecChar.apply(s), val);
    }
    val.add(s);
    return map;
};

// 
HashMap<Character, List<String>> groupBySecondChar = st.collect(
    HashMap::new,
    (map, s) -> mapAppendToList.apply(map, s),
    HashMap::putAll
);

return groupBySecondChar;