Wednesday, December 11, 2019

Java 8 Lambdas - Lambdas in Action

We said in the previous chapter that lambdas allow functional programming. Also, when lambdas are understood, the code with lambdas will be more readable and more reusable by reducing the boiler plate code. They also enable parallel processing.
Beside these, there are benefits that are consequences of the functional programmimg model of lambdas.

1. Lambdas involved in earlier Java versions
As we said in the first chapter, a functional interface is an interface with ONLY one method.
This means that we may always use the earlier Java interfaces that need to implement ONLY one method.
For example, let's consider java.lang.Thread. We know that when we define a thread, we have to implement its Runnable interface.
The Runnable interface has ONLY one abstract method, namely "run()".
The next code will show how to use lambdas with threads.
Consider the classic way:

Thread thread = new Thread(new Runnable() {
public void run() {
System.out.println("Hello");
}
});
thread.start();

This code may be transformed by using lambdas in:
Thread thread = new Thread(() -> System.out.println("Hello"))
thread.start();

Let's also consider the sorting of a java.util.List. In order to sort the list, we need the Comparator interface. We know that Comparator should implement the "compare()" method.
The next code will show how to sor list with lambdas.
Consider the classic way:
List<String> list = Arrays.asList("str3", "str1", "str4", "str2");
Collections.sort(list, new Comparator<String>() {

@Override
public int compare(String s1, String s2) {
return s1.compareTo(s2);
}

});

This code may be transformed by using lambdas in:

List<String> list = Arrays.asList("str3", "str1", "str4", "str2");
Collections.sort(list, (s1, s2) -> s1.compareTo(s2));

There exists also other interfaces, for example java.awt.event.ActionListener. This interface must implement the "actionPerformed()" method.

2. Java 8 streams support
In order to better support lambdas and as a consequence of how functional programming was implemented, java 8 added several features over collections of elements.
Java 8 Streams should not be confused with Java I/O streams. Streams should be considered as wrappers around a set of elements (collections, arrays, aso), allowing us to operate with that data source and making bulk processing convenient and fast.
A stream does not store data and, in that sense, is not a data structure. It also never modifies the underlying data source.
Streams supports functional-style operations (this they support lambdas), on streams of elements, such as map-reduce transformations on collections.

2.1. Define a stream
A Stream may be applied in these forms:
"Stream.of(T t)"
, where T is the type of stream elements, or:
"Stream.of (T ... values)"
, where you may stream more than 1 type of stream elements,
"Stream.builder().accept(T t).accept(U u)...",
where we use Builder to stream more than 1 type of elements (note here the Consumer "accept" method), or:
"listInstance.stream()",
where we stream a list.

2.2. Stream Pipelines
In order to use streaming over a collection of elements, we deal with stream operations which are methods of intermediate type, in the sense they return a result, or of terminal type, in the sense they return void.
A stream pipeline consists of a stream source, followed by zero or more intermediate operations and a terminal operation.

Integer count = list.stream().filter(s -> "str5".equals(s)).count();
Here we have a stream pipeline consisting of the "filter()" intermediate operation and followed by "count()" terminal operation.
Any of the stream pipeline operations waits for the preceeding operation to finish, except for the short-circuit operations that we'll discuss later.

2.3. Stream Operations
forEach()
This is a terminal operation loops over the elements of a stream.
Let's consider the following classic code:
List<String> list = Arrays.asList(new String[] { "str5", "other5", "str1", null, "str5", "other2", "str3", null, "str5", "str6", "other4" });

// classic prints the elements of list
System.out.println("classic initial list");
for (String s : list) {
System.out.println(s);
}
This may be transformed in:
list = Arrays.asList(new String[] { "str5", "other5", "str1", null, "str5", "other2", "str3", null, "str5", "str6", "other4" });

//print all with forEach Consumer
System.out.println("lambda sorted list with forEach Consumer");
list.forEach(s -> System.out.println(s));

, and further more, by using method reference in:

//print all with forEach Consumer and method reference
System.out.println("lambda sorted list with forEach Consumer and reference");
list.forEach(System.out::println);

Why this is a terminal operation ? We see that the forEach() argument is a Consumer and we know that Consumer is a functional interface that accepts 1 argument and returns void.

sorted()
This sorts the stream elements based on the passed Comparator. Notice that Comparator may be considered a functional interface, as we have to implement ONLY one method, the "compare()" method.
Let's consider the following classic code:

Collections.sort(list, Comparator.nullsFirst(new Comparator<String>() {

@Override
public int compare(String arg0, String arg1) {
return arg0.compareTo(arg1);
}

}));
for (String s : list) {
System.out.println(s);
}

This may be transformed in:

// first sort the list
list = list.stream().sorted(Comparator.nullsFirst((arg0, arg1) -> arg0.compareTo(arg1))).collect(Collectors.toList());

// second print the list with forEach Consumer and method reference
list.forEach(System.out::println);

When defining the comparator, we apply the "Comparator.nullsFirst()" in order to avoid the nulls.
We see here that we used the intermediate operation "sorted()" that also returns a stream of elements, followed by "collect()" operation which will discuss later.

Other stream operations that use the Comparator interface are min() and max().

collect()
It is used to assign the result of a stream() operation to an object defined by Collector interface. "collect()" is an intermediate operation, in the sense we may continue the pipeline with other operations.
Consider the example given at "sorted()" operation. in this case, the "collect()" operation simply collects a stream of elements and converts it into a "java.util.List" by specifying "Collectors.toList()".
There are also "Collectors.toMap()", "Collectors.toSet()" and a lot of methods of this final class.

However, when we want to collect to an array, we should use the "toArray()" operation which may be considered as a specific form of "collect()", returning the results to an array:

// using streams over an array of strings
String[] strs = list.stream().toArray(String[]::new);
Stream.of(strs).forEach(System.out::println);

filter()
It uses the Predicate functional interface to filter the elements of a stream.
You may apply more than a filter operation, as a filter() operation will result also in a stream.
Consider the following code:

// re-init the list
list = Arrays.asList(new String[] { "str5", "other5", "str1", null, "str5", "other2", "str3", null, "str5", "str6", "other4" });

// sort the list
list.stream().sorted(Comparator.nullsFirst((arg0, arg1) -> arg0.compareTo(arg1))).filter(Objects::nonNull).forEach(System.out::println);

This will out:
> lambda list not nulls, with stream()
> other2
> other4
> other5
> str1
> str3
> str5
> str5
> str5
> str6

We see here that null object values were omitted from the list by filtering the initial list.

Another sample:

// prints only elements with str5 and Function
// lambda type is java.util.function.Function
System.out.println("lambda list startsWith str");
list.stream().filter(s -> s != null && s.startsWith("str")).forEach(System.out::println);;

This will out:
> lambda list startsWith str
> str1
> str3
> str5
> str5
> str5
> str6

Here, from the initial list, there were filtered only the strings starting with "str".

anyMatch()
This is a boolean operation terminal operation that checks any occurence in elements of a collection
Consider the code:

//print if list contains str5 with stream()
String str = "str5";
System.out.println("check lambda list contains str5, with stream()");
System.out.println(list.stream().anyMatch(str::equals));

This will out:
> lambda list contains only str5, with stream()
> true

There is also "allMatch()" boolean terminal operation that checks all elements for an occurence.

map()
It accepts a Function fnctional interface as parameter and converts a stream of a type T elements to a stream of type U elements.
Consider the code:

// re-init the list
list = Arrays.asList(new String[] { "str5", "other5", "str1", null, "str5", "other2", "str3", null, "str5", "str6", "other4" });

// prints elements of list toUpperCase, with map
System.out.println("lambda otherList, elements toUpperCase, with map");
List<String> otherList = list.stream().sorted(Comparator.nullsFirst((arg0, arg1) -> arg0.compareTo(arg1))).filter(Objects::nonNull).map(String::toUpperCase).collect(Collectors.toList());
otherList.forEach(System.out::println);

Here we transformed the initial list of strings in a new list of strings toUpperCase.
Let's suppose the code:

// prints the length of elements of otherList, with map
System.out.println("lambda anotherList, elements length, with map");
List<Integer> anotherList = otherList.stream().map(String::length).collect(Collectors.toList());
anotherList.forEach(System.out::println);

Here, we used map to transform from a list of strings into a list of integers.

It may also be used as a kind of "filter()":

// prints a new list containing str5, with map
System.out.println("lambda str5List, new list containing str5, with map");
List<String> str5List = list.stream().map(s -> "str5".equals(s) ? s : null).filter(Objects::nonNull).collect(Collectors.toList());
str5List.forEach(System.out::println);

Here, we returned a new list consisting of elements of the list equal with "str5".

distinct()
It eliminates duplicates in a stream:

// distinct
System.out.println("lambda otherList, distinct elements");
otherList = list.stream().distinct().collect(Collectors.toList());
otherList.forEach(System.out::println);

findFirst()
Returns the first element in a stream as an Optional object:

// find first
System.out.println("lambda list, findFirst occurence of str5");
String strFindFirst = list.stream().filter(s -> "str5".equals(s)).findFirst().orElse(null);
System.out.println(strFindFirst);

Here, we return the first occurence string if it is found or null if it is not found at all.

skip() and limit()
These is a short-circuiting operation, allowing to skip over some elements of a stream and limit the results.
Consider the following code:

// skip, limit
System.out.println("lambda initial list, used for skip and limit");
list.forEach(System.out::println);
System.out.println("lambda list, skip 4, limit 2");
List<String> skippedList = list.stream().skip(4).limit(2).collect(Collectors.toList());
skippedList.forEach(System.out::println);

The output will be:
> lambda initial list, used for skip and limit
> str5
> other5
> str1
> null
> str5
> other2
> str3
> null
> str5
> str6
> other4
> lambda list, skip 4, limit 2
> str5
> other2

So, we skipped 4 elements from list and returned the next 2 elements.

The short-circuiting operations are often used to complete infinite streams.
The short-circuiting will not be applied for "sorted()", so you'll have to "collect()" first and then reuse.
As these short-circuit operations applies on number of stream elements, they should count those elements, so they must interfer with the previous operations from the stream pipeline. For example, "limit()" will not wait for a "filter()" to finish, because it should know at any time the number of elements "filter()" returned.

There are many other stream operations, but we'll not focus on all of them.

2.4 Optional object
Optional container was added to better support the functional programming in the sense that it may be considered as a wrapper on a java object with some methods that test that wrapped object for nulls.
We consider an example in which the lambda expression was set as a parameter for a method:

public static void main(String[] args) {
...
// prints only not null elements of the list with Function
// lambda type matched is java.util.function.Function
System.out.println("lambda list not nulls, with Function");
printList(list, s -> s != null ? s : null);
...
}

/**
* prints the list with forEach, Optional object, with Consumer and Function functional interfaces
* @param list
* @param p
*/
static void printList(List<String> list, Function<String, String> f) {
// we use Optional to test if s is null
// if s is not null then we use the Optional.ifPresent Consumer
list.forEach(s -> Optional.ofNullable(f.apply(s)).ifPresent(System.out::println));
}

The code above uses the functional interface Function to test the elements of a list for null values.
The output will be:
> lambda list not nulls, with Function
> str5
> other5
> str1
> str5
> other2
> str3
> str5
> str6
> other4

2.5. Stream Specializations
Stream is a stream of object references. However, there are also the IntStream, LongStream, and DoubleStream – which are primitive specializations for int, long and double respectively.
These specialized streams do not extend Stream but extend BaseStream on top of which Stream is also built.
We will not focus on these particular cases of streams.

2.6. Parallel Streams
When we deal with multi-core processors, we should consider parallel streams. They look like a sequential stream, but some aspects should be considered.
The parallel streams should be considered only in cases when we deal with multicore processors. Only in these cases we may expect an increase of speed of execution when using parallel streams.
As parallel streams implies execution on many threads, the parallel stream code should be thread-safe, meaning the pipeline should not allow intermediary data modification or intermediary different data results, as the intermediary data may be used as input by the following execution thread.

For example:
//print count of filtered list values that are equal to str5 with stream() and forEach Consumer
System.out.println("lambda count when list contains only str5, with parallelStream()");
System.out.println(list.parallelStream().filter(s -> "str5".equals(s)).count());

3. Treating exceptions in lambdas
Taken into consideration the example of Comparator above, we have the following code:
// print all with stream(), Collectors and forEach Consumer, with Exception handling
System.out.println("lambda sorted list, with stream, catch exception");
list.stream().sorted((arg0, arg1) -> {
try {
return arg0.compareTo(arg1);
} catch (Exception e) {
return -1;
}
}).collect(Collectors.toList()).forEach(System.out::println);

So, treating exception may be done in the body of the lambda expression.


5. Final words
There are many other things to say about streams, the rest should be discoverable by each of us. Why so? Because lambdas were introduced to ease the way of writing code.

For the overall code of the above, please write me at "vs_octavian[around]yahoo[dot]com".

Previous chapter -> I. Introduction to Lambdas

No comments:

Post a Comment