Java: Demystifying The Stream API – Part 3

In our earlier articles part1 and part2, we have previously explored the significance of functional programming, Lambda Calculus, as well as various features such as Functional Interfaces, Lambda Expressions, and Method References.

This article delves into a crucial aspect, namely the Stream API, which JDK1.8 incorporated. It also covers how to utilize Lambda Expressions, Method References, and Functional Interfaces within Stream API functions.

So,

What is Stream API?

Succinctly, In Java, a Stream is a collection of data that developers can process on in a declarative and functional approach.

The Collections Framework closely links to the stream API. The Collections Framework stores and organizes data in the JVM’s memory, while the Stream API serves as an additional framework that aids in efficiently processing the data.

To efficiently process the data, we need to implement a particular algorithm; therefore, the Stream API uses the map-filter-reduce algorithm in the JDK.

The map/filter/reduce patterns resemble iterators in that they only collect and operate on one element at a time. However, in this case, we consider a sequence of elements as a single collection or unit.

So what are these three operations for sequences,

The Map operation applies a unary function to each element in a sequence, producing a new sequence with the desired results in the original order.

The Filter operation each element in a sequence as a unary predicate. It stores elements that meet the predicate’s criteria in memory for further processing, while keeping the source input data unchanged.

The Reduce operation collates a sequence of elements using a binary function. It requires an initial value to initialize the reduction, and if the input data is empty, the return value becomes this initial value.

Streams or For Loops?

As a developer and architect, I aim to enhance the readability, functionality, and efficiency of my code. We can achieve these objectives in a more elegant manner using StreamAPI.

Unlike the verbose nature of iterator or imperative approach, StreamAPI enables us to write declarative approach and easily understandable code.

Generally, loops are more efficient from a performance standpoint compared to streams. However, this dynamic may shift in scenarios involving parallelism.

In intricate situations necessitating custom thread management and coordination, we use for-loops because they provide manual concurrency control.

Internals of Streams

The java.util.stream package includes the Stream interface, which encompasses numerous intermediate and terminal operations.

You can access various types of Stream such as IntStream, LongStream, and DoubleStream.

public interface Stream<T> extends BaseStream<T,Stream<T>>

public interface IntStream extends BaseStream<Integer,IntStream>

public interface LongStream extends BaseStream<Long,LongStream>

public interface DoubleStream extends BaseStream<Double,DoubleStream>

IntStream specializes in handling elements of primitive int type.

LongStream specialized in handling elements of long-valued elements.

DoubleStream specialized in handling elements of double-valued elements.

The streams mentioned above are suitable for both sequential and parallel aggregate operations.

Intermediate Operations in the Stream API interface

map: Produces a stream containing the outcomes of implementing the specified function to the elements within the stream.

<R> Stream<R> map(Function<? super T, ? extends R> mapper);

List<Integer> numbers = List.of(1, 2, 3, 4, 5);
numbers.stream().map(Math::sqrt) // squaring each of the sequence element and mapping here
.forEach(System.out::println);

mapToInt: Generates an IntStream containing the outcomes of implementing the specified function to the elements within the stream.

IntStream mapToInt(ToIntFunction<? super T> mapper);

List<String> languages = List.of(“Java”, “Kotlin”, “Scala”);
IntStream wordsLength = languages.stream().mapToInt(String::length);
System.out.println(“Sum of the words length: ” + wordsLength.sum());

mapToLong: Creates a LongStream containing the outcomes of implementing the specified function to the elements within the stream.

LongStream mapToLong(ToLongFunction<? super T> mapper);

List<String> prices = List.of(“900”, “1800”, “2700”);
LongStream sumOfPrices = prices.stream().mapToLong(Long::parseLong);
System.out.println(“Sum of the prices: ” + sumOfPrices.sum());

mapToDouble: Yields a DoubleStream containing the outcomes of implementing the specified function to the elements within the stream.

DoubleStream mapToDouble(ToDoubleFunction<? super T> mapper);

List<Double> pricesOne = List.of(22.5, 23.0, 24.5, 27.0, 38.0);
DoubleStream priceDetails = pricesOne.stream().mapToDouble(Double::doubleValue);
double averagePrice = priceDetails.average().orElse(0.0);
System.out.println(“Average Price: ” + averagePrice);

flatMap: useful for converting individual elements within a stream into a new stream of elements, and subsequently merging these various streams into a unified stream.

<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);

List<List<Integer>> numberDetails = List.of(List.of(11, 12, 13), List.of(14, 15, 16), List.of(17, 18, 19));
// Use flatMap to transform the List of Lists into a unified Stream of Integers
List<Integer> flattenedNumbers = numberDetails.stream()
.flatMap(List::stream)
.toList();
System.out.println(“Flattened Number Details: ” + flattenedNumbers);

flatMapToInt: Use flatMapToInt to flatten the List that contains Arrays into a unified IntStream.

IntStream flatMapToInt(Function<? super T, ? extends IntStream> mapper);
List<int[]> numbersInfo = List.of(new int[]{1, 2, 3}, new int[]{4, 5, 6}, new int[]{7, 8, 9});
IntStream flattenedStream = numbersInfo.stream()
.flatMapToInt(Arrays::stream);

System.out.println(“Flattened Sum: ” + flattenedStream.sum());

filter: This stream produces a stream containing the elements that satisfy the specified predicate.

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

List<String> names = List.of(“Foojay”, “Mahi”, “Sathya”, “KC”, “APJ”);
List<String> filteredNames = names.stream()
.filter(name -> name.startsWith(“A”))
.toList();
System.out.println(“Filtered Names: ” + filteredNames);

Additionally, flatMapToDouble, mapMulti, mapMultiToInt, mapMultiToLong, mapMultiToDouble, and peek are several other methods. 

Some of these methods are stateful and involve short-circuiting intermediate operations, such as sorted, skip, limit, dropWhile, and takeWhile, which businesses can utilize according to specific needs.

Terminal Operations in the Stream API interface

forEach & forEachOrdered: This stream performs an action for each element and display it in Order

void forEach(Consumer<? super T> action);
void forEachOrdered(Consumer<? super T> action);

List<Integer> numberAnother = List.of(1, 2, 3, 4, 5);
numberAnother.forEach(System.out::println);

List<Integer> numberOrdered = List.of(2, 3, 1, 5, 4);
numberOrdered.parallelStream().forEachOrdered(System.out::println);

reduce: In Java Streams, we use the reduce method to perform a reduction on the elements of the stream by employing an associative accumulation function. We use it for summing numbers, concatenating strings, or combining elements into a single result.

T reduce(T identity, BinaryOperator<T> accumulator);

List<Integer> reduceNumbers = List.of(1, 2, 3, 4, 5);
int sum = reduceNumbers.stream().reduce(0, Integer::sum);
System.out.println(“Sum: ” + sum);

int maxValue = reduceNumbers.stream().reduce(0, Integer::max);
System.out.println(“Max Value: ” + maxValue);

int minValue = reduceNumbers.stream().reduce(0, Integer::min);
System.out.println(“Min Value: ” + minValue);

collect: The Java Streams collect method to gather the elements of a stream into a collection or alternative data structure. It works with predefined collectors from the Collectors class, including toList, toSet, joining, and various others.

<R, A> R collect(Collector<? super T, A, R> collector);

List<String> names = List.of(“Foojay”, “Mahi”, “Sathya”, “KC”, “APJ”);
List<String> filteredNames = names.stream()
.filter(name -> name.startsWith(“A”))
.collect(Collectors.toList());
System.out.println(“Filtered Names: ” + filteredNames);

toList: The toList default method has streamlined the process of defining the terminal operation, replacing the collect(Collectors.toList()) syntax.

default List<T> toList() {
return (List<T>) Collections.unmodifiableList(new ArrayList<>(Arrays.asList(this.toArray())));
}

List<String> names = List.of(“Foojay”, “Mahi”, “Sathya”, “KC”, “APJ”);
List<String> filteredNames = names.stream()
.filter(name -> name.startsWith(“A”))
.toList();
System.out.println(“Filtered Names: ” + filteredNames);

Furthermore, there are various other terminal operations such as min, max, and count() available.

Some of these approaches involve short-circuiting terminal operations, such as allMatch(), findFirst(), and findAny().

Final Thoughts

In a nutshell, Stream API facilitates a functional programming approach for handling collections. It enables chaining pipeline using map-filter-reduce algorithm.

Stream operations execute lazily, improving performance by running only when needed. The parallelStream() method allows streams to take advantage of parallel processing, enabling concurrent execution on multi-core processors.

Streams seamlessly integrate with Java’s functional interfaces like Predicate, Function, and Consumer. The Collectors utility class offers methods for converting streams to collections, grouping data, and performing other useful tasks.

Error handling in streams involves catching exceptions within lambdas or converting them to unchecked exceptions.

While streams can enhance readability and maintainability, users need to exercise caution when using parallel streams and dealing with large datasets to prevent performance issues.

Happy Learning

The complete code is available over on Github

References

https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html

The post Java: Demystifying The Stream API – Part 3 appeared first on foojay.