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:
No storage: Streams do not store elements; they are computed on demand.
Functional in nature: Operations on a stream produce a result but do not modify the source.
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
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
Possibly unbounded: Streams can be finite or infinite.
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
Conciseness and Readability: Streams drastically reduce boilerplate code, making your code more readable and maintainable.
Parallel Processing: Streams offer built-in support for parallel processing, enhancing performance.
Functional Programming: Provide a functional approach to handle collections, making use of lambda expressions and higher-order functions.
Pipeline Operations: Allow chaining of multiple operations, creating a clear data processing pipeline.
Related Terms
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");
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;
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();
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;
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;
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.
filter(Predicate<? super T> predicate)
: Filters elements based on a condition.stream.filter(s -> s.startsWith("a"));
map(Function<? super T, ? extends R> mapper)
: Transforms elements using a function.stream.map(String::toUpperCase);
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);
distinct()
: Removes duplicate elements.stream.distinct();
sorted()
: Sorts the elements.stream.sorted();
Terminal Operations
These operations terminate the stream, producing a result or causing a side-effect.
collect(Collector<? super T, A, R> collector)
: Collects the elements into a collection.List<String> result = stream.collect(Collectors.toList());
forEach(Consumer<? super T> action)
: Performs an action for each element.stream.forEach(System.out::println);
reduce(T identity, BinaryOperator<T> accumulator)
: Performs a reduction on the elements.int sum = stream.reduce(0, Integer::sum);
count()
: Returns the count of elements.long count = stream.count();
anyMatch(Predicate<? super T> predicate)
: Returnstrue
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:
Optional.empty()
: Returns an emptyOptional
instance.Optional.of(T value)
: Returns anOptional
with the specified non-null value.Optional.ofNullable(T value)
: Returns anOptional
describing the specified value, if non-null; otherwise, it returns an emptyOptional
.isPresent()
: Returnstrue
if there is a value present, otherwisefalse
.ifPresent(Consumer<? super T> consumer)
: If a value is present, it invokes the specified consumer with the value; otherwise, it does nothing.orElse(T other)
: Returns the value if present; otherwise, it returns the specified other value.orElseGet(Supplier<? extends T> other)
: Returns the value if present; otherwise, it returns the result produced by the supplying function.orElseThrow(Supplier<? extends X> exceptionSupplier)
: Returns the contained value if present; otherwise, it throws an exception provided by the exception-supplying function.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 anOptional
describing the result.flatMap(Function<? super T, Optional<U>> mapper)
: Similar tomap
, but the mapping function is one whose result is already anOptional
.filter(Predicate<? super T> predicate)
: If a value is present and it matches the given predicate, it returns anOptional
describing the value; otherwise, it returns an emptyOptional
.
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.