Effective Java: Using Streams & Lambdas for Cleaner, More Expressive Code

Description: Java's Streams API combined with lambda expressions lets you express collection-processing logic in a declarative, concise way. Below are practical patterns and examples to help you adopt functional-style programming in day-to-day Java.


1. Quick refresher: Lambda & Method Reference

Lambda syntax:

// Lambda
Runnable r = () -> System.out.println("Hello");

// Method reference
Runnable r2 = System.out::println;
  

2. Common Stream Operations

Filter, map, and collect are the most frequently used operations:

import java.util.*;
import java.util.stream.*;

List names = List.of("Alice", "Bob", "Charlie", "David");

// Get uppercase names starting with 'A' or 'D'
List result = names.stream()
    .filter(s -> s.length() > 3)
    .map(String::toUpperCase)
    .filter(s -> s.startsWith("A") || s.startsWith("D"))
    .collect(Collectors.toList());

System.out.println(result); // [ALICE, DAVID]
  

3. Transforming to Maps & Grouping

Use collectors to build maps or group elements:

List people = List.of(
    new Person("Alice", "Engineering"),
    new Person("Bob", "HR"),
    new Person("Charlie", "Engineering")
);

// Group by department
Map> byDept = people.stream()
    .collect(Collectors.groupingBy(Person::getDepartment));

// Count per department
Map counts = people.stream()
    .collect(Collectors.groupingBy(Person::getDepartment, Collectors.counting()));
  

4. Sorting with Streams

List sorted = people.stream()
    .sorted(Comparator.comparing(Person::getName))
    .collect(Collectors.toList());
  

5. Parallel Streams — When to Use (and When Not To)

Parallel streams can speed up CPU-bound, large-data workloads, but they have overhead and can cause surprising behavior with shared mutable state.

// Use for large, independent tasks
long count = IntStream.range(0, 1_000_000)
    .parallel()
    .filter(i -> isPrime(i))
    .count();
  

Avoid parallel streams when:

  • Operations are I/O bound
  • Order matters and you rely on stable iteration
  • You use non-thread-safe shared state

6. Practical Example: Processing CSV Rows

Process lines, filter, map to objects, then summarize:

Path path = Paths.get("data.csv");
try (Stream lines = Files.lines(path)) {
    List records = lines.skip(1) // skip header
        .map(line -> line.split(","))
        .filter(cols -> cols.length >= 3)
        .map(cols -> new Record(cols[0], Integer.parseInt(cols[1]), cols[2]))
        .collect(Collectors.toList());

    double avg = records.stream()
        .mapToInt(Record::getValue)
        .average()
        .orElse(0.0);

    System.out.println("Average: " + avg);
} catch (IOException e) {
    e.printStackTrace();
}
  

7. Tips & Best Practices

  • Prefer immutability: avoid modifying elements inside stream operations.
  • Keep lambdas short: if logic gets complex, extract a method with a clear name.
  • Use method references (`Class::method`) for readability when possible.
  • Avoid side-effects — streams are more predictable when pure functions are used.
  • Benchmark parallel streams before adopting them — they’re not always faster.

8. Small Utilities

// Safe parse helper for streams
public static OptionalInt safeParseInt(String s) {
    try {
        return OptionalInt.of(Integer.parseInt(s));
    } catch (NumberFormatException e) {
        return OptionalInt.empty();
    }
}

// Usage in stream
List ints = Stream.of("1","x","3")
    .map(JavaArticle::safeParseInt)
    .flatMapToInt(opt -> opt.isPresent() ? IntStream.of(opt.getAsInt()) : IntStream.empty())
    .boxed()
    .collect(Collectors.toList());
  

Start small: convert a few loops to streams in a safe, test-backed way. You’ll get cleaner code and a better mental model for collection processing.