Project Based Learning in Java

Stream API and Data Processing

Lazy pipelines, filter, map, sorting, collectors, reduction, and processing marks from files.

Lecture 2.2.3 CO3 aligned Unit 2 · Chapter 2.2

Learning outcome: Build stream pipelines with filtering, mapping, sorting, and reduction; distinguish intermediate and terminal operations; and combine UTF-8 file input with validated data processing.

A java.util.stream.Stream<T> describes a computation over elements. It does not store elements like a list and is distinct from an I/O stream. A collection, array, generator, or file can supply its source.

Source, Intermediate Operations, and Terminal Operation

Intermediate operations describe transformations and return another stream. They are lazy: traversal is driven by a terminal operation. The implementation may optimize away work that does not affect the result, so do not rely on side effects in intermediate operations. A terminal operation consumes the pipeline; obtain a fresh stream for another traversal. See the stream package contract.

Operation Kind Purpose
filter(predicate) Intermediate Keep matching elements
map(function) Intermediate Transform each element
flatMap(function) Intermediate Flatten streams produced from elements
distinct() Intermediate Remove duplicates using equality
sorted() / sorted(comparator) Intermediate Order elements
limit(n) Intermediate Keep at most n elements
collect(collector) Terminal Build a result, such as a list or grouped map
reduce(...) Terminal Combine elements into a value
count() Terminal Count elements
forEach(action) Terminal Perform an action for each element
anyMatch(predicate) Terminal, short-circuiting Test whether an element matches
findFirst() Terminal, short-circuiting Return the first element, if present

filter can reduce the number of elements. map changes each element into one result. For example, map(String::length) turns names into lengths; it does not remove short names. A short-circuiting operation may produce its answer without reading the entire source.

Filter, Transform, Sort, and Collect

Save as StreamMarksDemo.java:

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class StreamMarksDemo {
    public static void main(String[] args) {
        List<Integer> marks = Arrays.asList(35, 72, 90, 72, 48);

        List<Integer> revised = marks.stream()
                .filter(mark -> mark >= 40)
                .map(mark -> Math.min(mark + 5, 100))
                .distinct()
                .sorted()
                .collect(Collectors.toList());

        int total = marks.stream().reduce(0, Integer::sum);
        double average = marks.stream()
                .mapToInt(Integer::intValue)
                .average()
                .orElse(0.0);

        System.out.println("Revised: " + revised);
        System.out.println("Original: " + marks);
        System.out.println("Total: " + total);
        System.out.println("Average: " + average);
    }
}

Output:

Revised: [53, 77, 95]
Original: [35, 72, 90, 72, 48]
Total: 317
Average: 63.4

Trace the first pipeline: filtering yields 72, 90, 72, 48; adding five yields 77, 95, 77, 53; removing duplicates yields 77, 95, 53; sorting yields 53, 77, 95. Removing duplicates is appropriate for this demonstration of distinct score values, but would lose student records if one entry represented one student's result.

Each calculation starts with a fresh marks.stream(). The original list is unchanged because these operations only read its values and create results. A lambda can still mutate a mutable source object if written to do so; streams do not enforce deep immutability.

Reduction and Primitive Streams

reduce(0, Integer::sum) combines elements using addition, with zero as the identity. For an empty stream, this overload returns zero. The reduction function should be associative and compatible with the identity, especially when work may be partitioned. Subtraction is not associative, and using it in a parallel reduction can change the result.

IntStream, LongStream, and DoubleStream support primitive processing. mapToInt avoids boxed values for subsequent numeric operations and provides sum, min, max, and average. Operations such as average have no numeric result on an empty stream, so they return an optional value. Choosing orElse(0.0) is an application policy; sometimes reporting "no marks" is more accurate.

Collectors.toList() is used here for Java 8 compatibility; its contract does not guarantee a particular list implementation or mutability. If an ArrayList is specifically required, use Collectors.toCollection(ArrayList::new). See the Collectors API.

Integrated Example: Read and Summarize Marks

This program connects the chapter's wrapper parsing, file I/O, lambdas, and method references. Create input-marks.txt in the working directory with the following content:

35
72
90
48

Save as FileMarksSummary.java:

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.IntSummaryStatistics;
import java.util.stream.Stream;

public class FileMarksSummary {
    static int parseMark(String text) {
        int mark = Integer.parseInt(text);
        if (mark < 0 || mark > 100) {
            throw new IllegalArgumentException("Mark out of range: " + mark);
        }
        return mark;
    }

    public static void main(String[] args) {
        try (Stream<String> lines = Files.lines(
                Paths.get("input-marks.txt"), StandardCharsets.UTF_8)) {
            IntSummaryStatistics summary = lines
                    .map(String::trim)
                    .filter(line -> !line.isEmpty())
                    .mapToInt(FileMarksSummary::parseMark)
                    .filter(mark -> mark >= 40)
                    .summaryStatistics();

            System.out.println("Pass count: " + summary.getCount());
            if (summary.getCount() > 0) {
                System.out.println("Highest pass mark: " + summary.getMax());
                System.out.println("Pass average: " + summary.getAverage());
            } else {
                System.out.println("No passing marks");
            }
        } catch (IOException | UncheckedIOException e) {
            System.err.println("Could not read marks: " + e.getMessage());
        } catch (IllegalArgumentException e) {
            System.err.println("Invalid mark: " + e.getMessage());
        }
    }
}

Output:

Pass count: 3
Highest pass mark: 90
Pass average: 70.0

Blank lines are ignored. Every nonblank mark is parsed and range-checked before the pass filter. Thus, invalid values are reported rather than silently dropped as failures. NumberFormatException extends IllegalArgumentException, so the final catch also handles nonnumeric input. An error stops the calculation and no partial summary is printed.

The stream returned by Files.lines holds an open file and must be closed. A read error may occur while the terminal operation traverses the file, not just when opening it; later I/O errors are wrapped in UncheckedIOException. See Files.lines.

Ordering, Side Effects, and Parallel Execution

stream() creates a sequential stream. parallelStream() or parallel() allows partitioned execution but does not guarantee faster results. Small datasets and I/O-heavy operations may gain nothing from parallelism.

Avoid changing the source while a pipeline runs or updating a shared ArrayList inside a parallel forEach. Prefer a collector or reduction. Parallel forEach does not guarantee encounter order; use forEachOrdered when the source has an encounter order that must be respected. Stateful operations such as sorting can require substantial buffering even though streams are lazy.

In simple terms: A stream pipeline describes what to keep, how to change it, and what result to produce. The terminal operation asks Java to perform the work. A second question needs a fresh pipeline.

Practice

  1. Return passing marks in descending order without changing the source list.
  2. Count passing students without removing repeated marks.
  3. Group names by their length using Collectors.groupingBy(String::length).
  4. Extend the file program to report the overall average as well as the pass average.
  5. Test the file program with blank input, no passes, invalid text, and a mark above 100.

Quick Check

1. Does filter alone execute a complete pipeline?

No. It adds an intermediate stage. A terminal operation drives traversal.

2. Can the same stream be used for count and then collect?

No. A stream is single-use. Obtain a fresh stream from the source for the second terminal operation.

3. Why does the example validate before filtering passing marks?

Otherwise a value such as -5 could be silently discarded as a failing mark, hiding invalid input.

4. Must a stream created from a list normally be closed?

No external resource normally needs closing. Streams backed by resources such as Files.lines must be closed.

Chapter 2.2 Review

Design a student results tool that parses marks into a List<Integer>, writes and reads a UTF-8 report, serializes trusted student records, and creates independent copies of mutable marks arrays. Use predicates to validate marks, method references for existing operations, and streams to compute result summaries. Explain which steps represent data, which move data, and which process data.

The chapter's central distinctions are primitive versus wrapper, bytes versus characters, serialization versus copying, and I/O streams versus Stream API pipelines. Apply each mechanism where its contract fits the task.