Stream API in JAVA

Photo by Stephen yu on Unsplash

Stream API in JAVA

Introduction to Streams:

  • A Stream in Java is a sequence of elements supporting sequential and parallel aggregate operations. It's not a data structure but a view of the data.

  • Useful while dealing with bulk processing (can do parallel processing)

Key Characteristics:

  1. No storage: Streams do not store elements; they are computed on demand.

  2. Functional in nature: Operations on a stream produce a result but do not modify the source.

  3. Lazy evaluation of streams: Intermediate operations are executed only when a terminal operation is initiated. for example until you call reduce, filter or map will not work

  4. Pipeline Processing: Each element goes through the complete pipeline from the source to the terminal operation one-by-one for example when we write filter.map.reduce it doesn’t run like map runs after filter is done and so on, instead each element goes through filter→map→reduce simultaneously

  5. Possibly unbounded: Streams can be finite or infinite.

  6. Consumable/Non-Reusable: You cannot reuse a stream

Streams in Java were introduced in Java 8 as part of the java.util.stream package. They represent sequences of elements processed in a functional manner, allowing you to perform bulk operations on collections of data efficiently and concisely. Streams solve the problem of traditional, imperative-style iteration over collections by providing a more declarative approach.

Core Problems Solved by Streams

  1. Conciseness and Readability: Streams drastically reduce boilerplate code, making your code more readable and maintainable.

  2. Parallel Processing: Streams offer built-in support for parallel processing, enhancing performance.

  3. Functional Programming: Provide a functional approach to handle collections, making use of lambda expressions and higher-order functions.

  4. Pipeline Operations: Allow chaining of multiple operations, creating a clear data processing pipeline.

  1. Predicate<T>: Functional Interface wid a single abstract method bool test(T t)

    • Use Case: Filtering elements based on conditions.

    • Example:

        Predicate<String> startsWithA = s -> s.startsWith("A");
      
  2. Function<T, R>: Functional Interface with single abstract method R apply (T t)

    • Use Case: Mapping elements to a different type.

    • Example:

        Function<String, Integer> stringLength = String::length;
      
  3. BiFunction<T, U, R>: Functional Interface with method R apply(T t, U u);

    • Use Case: Combining two values or performing operations with two arguments.

    • Example:

        BiFunction<String, String, Integer> compareLength = (s1, s2) -> s1.length() - s2.length();
      
  4. BinaryOperator: Interface extending BiFunction, so must implement apply

    • A specialized BiFunction for operations upon two operands of the same type, producing a result of the same type as the operands.

    • Use Case: Reduction operations, like summing elements.

    • Example:

      Above during each iteration of sum, n1 is running sum and n2 is the curr elem

    • identity is the initial value of running sum

        BinaryOperator<Integer> sum = Integer::sum;
      
  5. Consumer: Represents an operation that accepts a single input argument and returns no result.

    • Use Case: Performing actions on each element (e.g., printing using forEach).

    • Example:

    •     Consumer<String> print = System.out::println;
      
  6. Supplier: Represents a supplier of results.

    • Use Case: Generating or providing objects.

    • Example:

        Supplier<String> stringSupplier = () -> "Hello, World!";
      

Creating Streams

Streams can be created from various data sources like collections, arrays, or I/O channels.

From Collections

List<String> list = Arrays.asList("apple", "banana", "cherry");
Stream<String> stream = list.stream();

From Arrays

String[] array = {"apple", "banana", "cherry"};
Stream<String> stream = Arrays.stream(array);

From Values

Stream<String> stream = Stream.of("apple", "banana", "cherry");

Operations on Streams

Stream operations are divided into two categories: Intermediate operations and Terminal operations.

Intermediate Operations

These operations return a new stream, making them suitable for chaining.

  1. filter(Predicate<? super T> predicate): Filters elements based on a condition.

     stream.filter(s -> s.startsWith("a"));
    
  2. map(Function<? super T, ? extends R> mapper): Transforms elements using a function.

     stream.map(String::toUpperCase);
    
  3. flatMap(Function<? super T, ? extends Stream<? extends R>> mapper): Flattens nested structures.

     List<List<Integer>> listOfLists = Arrays.asList(Arrays.asList(1, 2), Arrays.asList(3, 4));
     listOfLists.stream().flatMap(Collection::stream);
    
  4. distinct(): Removes duplicate elements.

     stream.distinct();
    
  5. sorted(): Sorts the elements.

     stream.sorted();
    

Terminal Operations

These operations terminate the stream, producing a result or causing a side-effect.

  1. collect(Collector<? super T, A, R> collector): Collects the elements into a collection.

     List<String> result = stream.collect(Collectors.toList());
    
  2. forEach(Consumer<? super T> action): Performs an action for each element.

     stream.forEach(System.out::println);
    
  3. reduce(T identity, BinaryOperator<T> accumulator): Performs a reduction on the elements.

     int sum = stream.reduce(0, Integer::sum);
    
  4. count(): Returns the count of elements.

     long count = stream.count();
    
  5. anyMatch(Predicate<? super T> predicate): Returns true if any elements match the predicate.

     boolean anyStartsWithA = stream.anyMatch(s -> s.startsWith("a"));
    

Real-World Examples

Filtering and Collecting Data

List<String> names = Arrays.asList("John", "Jane", "Jack", "Doe");
List<String> filteredNames = names.stream()
                                  .filter(name -> name.startsWith("J"))
                                  .collect(Collectors.toList());
System.out.println(filteredNames); // Output: [John, Jane, Jack]

Transforming Data

List<String> names = Arrays.asList("John", "Jane", "Jack", "Doe");
List<String> upperCaseNames = names.stream()
                                   .map(String::toUpperCase)
                                   .collect(Collectors.toList());
System.out.println(upperCaseNames); // Output: [JOHN, JANE, JACK, DOE]

Reducing Data

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
                 .reduce(0, Integer::sum);
System.out.println(sum); // Output: 15

Multi-Level Data Transformation

List<List<String>> listOfLists = Arrays.asList(
    Arrays.asList("apple", "banana"),
    Arrays.asList("cherry", "date")
);
List<String> flattenedList = listOfLists.stream()
                                        .flatMap(Collection::stream)
                                        .collect(Collectors.toList());
System.out.println(flattenedList); // Output: [apple, banana, cherry, date]

Parallel Processing

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.parallelStream()
                 .reduce(0, Integer::sum);
System.out.println(sum); // Output: 15

NOTES

Functional Interfaces:

A functional interface in Java is an interface that contains exactly one abstract method. This concept is introduced in Java 8, and it's critical for enabling lambda expressions. The single abstract method defines the target type for a lambda expression. The @FunctionalInterface annotation is used to indicate that an interface is intended to be a functional interface, although it's not mandatory.

Example:

@FunctionalInterface
public interface MyFunctionalInterface {
    void execute();
}

Anonymous Inner Classes:

Before Java 8, the usual way to implement an interface or to provide an instance of an interface with one method was through an anonymous inner class. An anonymous inner class is a way of creating an instance of an interface or abstract class by providing an implementation inline.

Example:

public class Main {
    public static void main(String[] args) {
        MyFunctionalInterface myFunc = new MyFunctionalInterface() {
            @Override
            public void execute() {
                System.out.println("Executing with anonymous inner class.");
            }
        };

        myFunc.execute();
    }
}

Lambda Expressions:

With the introduction of Java 8, lambda expressions provide a more concise way to achieve the same functionality as anonymous inner classes, but they are often easier to read. Lambda expressions are essentially blocks of code that you can pass around to be executed later, which makes them very powerful. They can be used only with functional interfaces.

Example:

public class Main {
    public static void main(String[] args) {
        MyFunctionalInterface myFunc = () -> System.out.println("Executing with lambda expression.");

        myFunc.execute();
    }
}

In the example above, () -> System.out.println("Executing with lambda expression.") is a lambda expression. It takes no arguments (as indicated by ()) and its body is System.out.println("Executing with lambda expression.");.

Can Any Interface be Instantiated with Anonymous Inner Classes?

Yes, any interface can be instantiated using anonymous inner classes, including those with multiple abstract methods. However, only functional interfaces can be used with lambda expressions because lambda expressions must correspond to exactly one abstract method.

Example with Multiple Methods (Not Lambda-compatible):

public interface AnotherInterface {
    void method1();
    void method2();
}

// Using anonymous inner class
public class Main {
    public static void main(String[] args) {
        AnotherInterface another = new AnotherInterface() {
            @Override
            public void method1() {
                System.out.println("Method 1 implementation.");
            }

            @Override
            public void method2() {
                System.out.println("Method 2 implementation.");
            }
        };

        another.method1();
        another.method2();
    }
}

In this case, you cannot use a lambda expression because AnotherInterface has more than one abstract method. Always remember that lambda expressions are intended for single-method interfaces (functional interfaces).

Optional class

The Optional class in Java is part of the java.util package introduced in Java 8, designed to handle null values in a more graceful way. It provides a container that may or may not contain a non-null value, thereby helping to avoid the common pitfalls associated with NullPointerExceptions. Here’s a detailed breakdown, including some practical examples:

  1. Optional.empty(): Returns an empty Optional instance.

  2. Optional.of(T value): Returns an Optional with the specified non-null value.

  3. Optional.ofNullable(T value): Returns an Optional describing the specified value, if non-null; otherwise, it returns an empty Optional.

  4. isPresent(): Returns true if there is a value present, otherwise false.

  5. ifPresent(Consumer<? super T> consumer): If a value is present, it invokes the specified consumer with the value; otherwise, it does nothing.

  6. orElse(T other): Returns the value if present; otherwise, it returns the specified other value.

  7. orElseGet(Supplier<? extends T> other): Returns the value if present; otherwise, it returns the result produced by the supplying function.

  8. orElseThrow(Supplier<? extends X> exceptionSupplier): Returns the contained value if present; otherwise, it throws an exception provided by the exception-supplying function.

  9. map(Function<? super T, ? extends U> mapper): If a value is present, it applies the provided mapping function to it, and if the result is non-null, returns an Optional describing the result.

  10. flatMap(Function<? super T, Optional<U>> mapper): Similar to map, but the mapping function is one whose result is already an Optional.

  11. filter(Predicate<? super T> predicate): If a value is present and it matches the given predicate, it returns an Optional describing the value; otherwise, it returns an empty Optional.

Practical Examples

Basic Usage

import java.util.Optional;

public class OptionalExample {
    public static void main(String[] args) {
        Optional<String> optionalString = Optional.of("Hello, World!");

        // Check if value is present
        if(optionalString.isPresent()) {
            System.out.println(optionalString.get());
        }

        // Print via ifPresent
        optionalString.ifPresent(System.out::println);

        // Using orElse
        String value = optionalString.orElse("Default Value");
        System.out.println(value);

        // Using orElseGet
        String value2 = optionalString.orElseGet(() -> "Generated Value");
        System.out.println(value2);

        // Using map
        Optional<Integer> stringLength = optionalString.map(String::length);
        stringLength.ifPresent(length -> System.out.println("Length: " + length));
    }
}

Handling Nulls

import java.util.Optional;

public class OptionalNullHandling {
    public static void main(String[] args) {
        String nullableString = getNullableString();

        // Wrap in Optional
        Optional<String> optionalString = Optional.ofNullable(nullableString);

        // Value handling
        String result = optionalString.orElse("Default String");
        System.out.println(result);

        // Exception handling
        try {
            String result2 = optionalString.orElseThrow(IllegalArgumentException::new);
            System.out.println(result2);
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }

    private static String getNullableString() {
        return null; // or return non-null to see different behavior
    }
}

Optional with Streams

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

public class StreamWithOptional {
    public static void main(String[] args) {
        List<String> list = Arrays.asList("One", "Two", "Three", "Four");

        // Example: Find first element containing 'w'
        Optional<String> foundElement = list.stream()
                .filter(s -> s.contains("w"))
                .findFirst();

        foundElement.ifPresentOrElse(
                element -> System.out.println("Found: " + element),
                () -> System.out.println("Not Found")
        );

        // Example: Stream with flatMap and Optional
        List<Optional<String>> lists = Arrays.asList(Optional.of("a"), Optional.empty(), Optional.of("b"));
        List<String> resultList = lists.stream()
                .flatMap(opt -> opt.isPresent() ? Stream.of(opt.get()) : Stream.empty())
                .collect(Collectors.toList());

        resultList.forEach(System.out::println);
    }
}

Summary

Using Optional is a best practice to avoid nullable checks that can lead to NullPointerException. It encourages a more functional approach to handling optional values and can make your code cleaner and more readable.

By integrating Optional with other Java features like Stream, you can create very expressive and concise code that's less prone to null-related bugs.