Unit 1: Collections, Streams, Filters, and Lambdas

CSE406 — Advanced Java Programming 10 min read

I. Functional Data Processing in Java

Java’s collections framework stores groups of objects, while lambda expressions and streams provide a declarative way to process them. Introduced mainly in Java 8 (2014), these features support concise iteration, filtering, transformation, reduction, and aggregation without manually controlling every loop.

  • Collections: Interfaces such as List, Set, and Map represent stored, reusable data structures.
  • Lambda expression: An anonymous function written as (parameters) -> expression or (parameters) -> { statements; }.
  • Functional interface: An interface with one abstract method, such as Predicate<T>, Function<T,R>, or Consumer<T>.
  • Stream: A single-use sequence of elements processed through a pipeline; it does not itself store data.
  • Pipeline: A source followed by zero or more intermediate operations and one terminal operation.
  • Declarative style: Code states what result is required rather than explicitly describing loop control.
  • Non-interference: Stream operations should not modify their source while the pipeline is executing.
  • Statelessness: Lambda results should generally not depend on mutable state that changes during processing.

II. Object Construction

A. Describing the Builder pattern

The Builder pattern constructs complex objects incrementally while keeping the final object valid and readable.

  • Purpose: It avoids constructors containing many positional parameters, such as new Student("Asha", 20, "CS", true).
  • Structure:
    • The target class usually has a private constructor.
    • A static nested Builder stores temporary field values.
    • Builder methods return this, enabling method chaining.
    • build() validates data and creates the final object.
  • Immutability: Declaring target fields private final prevents modification after construction.
  • Concrete form:
JAVA
class Student {
    private final String name;
    private final int age;

    private Student(Builder b) {
        name = b.name;
        age = b.age;
    }

    static class Builder {
        private String name;
        private int age;

        Builder name(String value) {
            name = value;
            return this;
        }

        Builder age(int value) {
            age = value;
            return this;
        }

        Student build() {
            if (name == null || age < 0)
                throw new IllegalStateException();
            return new Student(this);
        }
    }
}

Student s = new Student.Builder()
        .name("Asha")
        .age(20)
        .build();
  • Limitation: A builder introduces additional classes and methods, so it is unnecessary for very small objects.

III. Lambdas and Collection Processing

A. Iterating through a collection using lambda syntax

The forEach method accepts a Consumer<T> lambda and performs an action for every collection element.

  • Syntax: collection.forEach(element -> action); uses element as the lambda parameter.
  • Example:
JAVA
List<String> names = List.of("Ana", "Ben", "Chen");
names.forEach(name -> System.out.println(name));
  • Method reference: The equivalent names.forEach(System.out::println); passes the existing println method.
  • Difference from a loop: forEach hides index and iterator management; an enhanced for loop remains preferable when break or continue is required.
  • Ordering: List.forEach follows encounter order, whereas an unordered collection such as HashSet does not guarantee a stable order.

B. Filtering a collection using lambda expressions

Filtering retains only elements for which a Predicate<T> lambda evaluates to true.

  • Predicate form: Predicate<Integer> positive = n -> n > 0; maps an integer to a Boolean result.
  • Stream filter:
JAVA
List<Integer> even = List.of(1, 2, 3, 4, 5, 6)
        .stream()
        .filter(n -> n % 2 == 0)
        .toList();
  • Result: The predicate accepts 2, 4, and 6; the original list remains unchanged.
  • Composition: Predicates support and, or, and negate, as in adult.and(active).

C. Program to implement Lambda operations

A lambda program commonly defines functional interfaces and supplies behavior without creating named implementation classes.

  • Operations: The following program demonstrates calculation, testing, transformation, and consumption.
  • Interface mapping:
    • BinaryOperator<Integer> combines two integers.
    • Predicate<Integer> tests one integer.
    • Function<Integer,Integer> transforms a value.
    • Consumer<Integer> performs an action.
JAVA
import java.util.function.*;

public class LambdaDemo {
    public static void main(String[] args) {
        BinaryOperator<Integer> add = (a, b) -> a + b;
        Predicate<Integer> even = n -> n % 2 == 0;
        Function<Integer, Integer> square = n -> n * n;
        Consumer<Integer> print = n -> System.out.println(n);

        int total = add.apply(10, 5);
        if (even.test(total))
            print.accept(square.apply(total));
        else
            print.accept(total);
    }
}
  • Execution: total becomes 15; because it is odd, the program prints 15.

IV. Streams and Pipelines

A. Describing the Stream interface

Stream<T> represents a sequence of elements supporting aggregate operations over values of type T.

  • Creation: Streams arise from collection.stream(), Stream.of(...), arrays, files, or generator methods.
  • No storage: A stream reads from a source such as List<String> but is not another collection.
  • Single use: After a terminal operation, reusing the stream causes IllegalStateException.
  • Internal iteration: The stream library controls element traversal, enabling optimization and parallel execution.
  • Specialized streams: IntStream, LongStream, and DoubleStream avoid boxing primitive values.

B. Describing the types of stream operations

Stream operations are classified by whether they build or execute a pipeline.

  1. Intermediate operations: filter, map, sorted, distinct, and limit return another stream and are generally lazy.
  2. Terminal operations: collect, reduce, count, forEach, and findFirst consume the stream and produce a result or side effect.
  • Stateful distinction: sorted and distinct may need information about previously encountered elements; map and filter are normally stateless.
  • Short-circuiting: limit, findFirst, anyMatch, and similar operations may finish without processing every element.

C. Chaining multiple methods together

Method chaining passes each operation’s returned stream directly to the next operation.

  • Example:
JAVA
long count = names.stream()
        .filter(n -> n.length() >= 4)
        .map(String::toUpperCase)
        .distinct()
        .count();
  • Data flow: Elements are filtered, converted to uppercase, deduplicated, and counted.
  • Readability: Placing one operation per line makes processing stages visible.
  • Requirement: The chain must end in a terminal operation such as count() for execution to begin.

D. Defining pipelines in terms of lambdas and collections

A stream pipeline consists of a collection source, lambda-driven intermediate stages, and a terminal result.

  • General form:
JAVA
result = collection.stream()
        .intermediateOperation(lambda)
        .terminalOperation();
  • Source: collection.stream() supplies elements.
  • Processing: filter(x -> condition) and map(x -> value) express pipeline logic as lambdas.
  • Termination: toList(), collect(...), or reduce(...) initiates traversal and produces the result.
  • Constraint: Lambdas should avoid modifying the source collection because interference can produce errors or unpredictable results.

E. Describing lazy processing

Lazy processing delays intermediate operations until a terminal operation requests elements.

  • No immediate work: Calling stream.filter(predicate) creates a pipeline stage but does not yet test elements.
  • Element-by-element flow: An element can pass through filter and map before the next element is read.
  • Optimization: In filter(...).findFirst(), processing stops as soon as the first matching element is found.
  • Observation:
JAVA
Stream<Integer> s = List.of(1, 2, 3).stream()
        .filter(n -> {
            System.out.println(n);
            return n > 1;
        });
// Nothing printed until:
s.findFirst();
  • Benefit: Laziness supports large or potentially infinite streams when combined with short-circuiting operations.

V. Transformation and Ordering

A. Extracting data from an object using map

map applies a Function<T,R> to each element, transforming a stream of type T into a stream of type R.

  • Extraction: For Student objects, map(Student::getName) produces student names.
  • Example:
JAVA
List<String> studentNames = students.stream()
        .map(Student::getName)
        .toList();
  • Type change: The pipeline changes from Stream<Student> to Stream<String>.
  • Null handling: Mapping to null is legal but risky; filtering nulls or using Optional is safer.
  • Related operation: flatMap transforms each element into a stream and then flattens nested streams.

B. Sorting a stream

The sorted operation arranges elements by natural order or a supplied Comparator.

  • Natural order: numbers.stream().sorted() orders integers in ascending order.
  • Custom order:
JAVA
List<Student> ordered = students.stream()
        .sorted(Comparator.comparingInt(Student::getAge)
                .thenComparing(Student::getName))
        .toList();
  • Comparison sequence: Students are ordered first by age and then by name when ages match.
  • Descending order: Use .reversed() on a comparator.
  • Cost: Sorting is stateful and generally requires all elements, with typical time complexity O(n log n).

VI. Reduction and Parallel Execution

A. Defining reduction

Reduction combines many stream elements into one summary value by repeatedly applying an associative operation.

  • Examples: Summation, multiplication, minimum, maximum, and string concatenation are reductions.
  • Identity: A neutral starting value satisfies combine(identity, x) = x; 0 is the identity for addition.
  • Associativity: (a op b) op c must equal a op (b op c) for reliable parallel reduction.
  • Non-example: Subtraction is not associative because (10 - 5) - 2 differs from 10 - (5 - 2).

B. Calculating a value using reduce

The reduce terminal operation repeatedly combines stream elements using a binary operator.

  • Identity overload:
JAVA
int sum = List.of(2, 4, 6).stream()
        .reduce(0, (subtotal, n) -> subtotal + n);
  • Calculation: Starting with 0, the steps produce 2, 6, and finally 12.
  • Method reference: reduce(0, Integer::sum) expresses the same operation.
  • No-identity overload: reduce(Integer::max) returns Optional<Integer> because an empty stream has no maximum.
  • Three-argument form: Parallel reductions may specify identity, accumulator, and combiner separately.

C. Describing how to make a stream pipeline execute in parallel

A pipeline executes in parallel when created with parallelStream() or converted using parallel().

  • Creation:
JAVA
long total = numbers.parallelStream()
        .filter(n -> n > 0)
        .mapToLong(Integer::longValue)
        .sum();
  • Execution pool: Parallel streams normally use the common ForkJoinPool.
  • Suitable work: Large, splittable datasets and CPU-intensive, independent operations benefit most.
  • Risks: Shared mutable state, blocking input/output, small datasets, and ordering requirements can remove performance gains.
  • Ordering: forEach may appear unordered in parallel; forEachOrdered preserves encounter order at additional cost.

D. Describing the process for decomposing and then merging work

Parallel streams use divide-and-conquer: divide the source, process partitions concurrently, and combine partial results.

  1. Decomposition: A Spliterator divides data into smaller partitions, such as two ranges of an array.
  2. Local processing: Worker threads independently run the same filter, map, or accumulation logic.
  3. Merging: A combiner joins partial values, for example 30 + 70 = 100.
  4. Completion: The root task returns the final merged result.
  • Correctness condition: The combiner must be associative and compatible with the accumulator.
  • Performance condition: Partitioning and merging overhead must be smaller than the time saved through concurrency.

VII. Optional and Result Collection

A. Describing the Optional class

Optional<T> is a container that either holds one non-null value or is empty.

  • Creation: Use Optional.of(value), Optional.ofNullable(value), or Optional.empty().
  • Stream usage: findFirst, min, max, and identity-free reduce return Optional because no result may exist.
  • Safe access:
JAVA
String name = optionalName.orElse("Unknown");
optionalName.ifPresent(System.out::println);
  • Transformation: map transforms a present value; filter retains it only when a predicate succeeds.
  • Caution: Calling get() without checking presence can throw NoSuchElementException; orElse, orElseGet, or orElseThrow is clearer.

B. Saving results to a collection using the collect method

collect performs mutable reduction by accumulating stream elements into a result container.

  • List collection:
JAVA
List<String> names = students.stream()
        .map(Student::getName)
        .collect(Collectors.toList());
  • Other destinations: Collectors.toSet() removes duplicates, while Collectors.toMap(keyMapper, valueMapper) creates key-value entries.
  • Modern alternative: Stream.toList() returns an unmodifiable list, whereas the mutability of Collectors.toList() is not guaranteed by its specification.
  • Parallel support: A collector supplies creation, accumulation, and combination behavior for partial containers.

C. Grouping and partitioning data using the Collectors class

Collectors provides predefined reductions for classifying elements into maps.

  1. Grouping: groupingBy uses a classifier and can create any number of categories.
JAVA
Map<String, List<Student>> byDepartment =
        students.stream()
                .collect(Collectors.groupingBy(Student::getDepartment));
  1. Partitioning: partitioningBy uses a predicate and always creates Boolean categories, true and false.
JAVA
Map<Boolean, List<Student>> byAdultStatus =
        students.stream()
                .collect(Collectors.partitioningBy(s -> s.getAge() >= 18));
  • Downstream collector: groupingBy(Student::getDepartment, Collectors.counting()) stores the number of students per department.
  • Distinction: Grouping classifies by a key such as department; partitioning divides data according to one condition.