MindIQ Academy

09 - Lambdas, Streams, and Functional Programming

A complete beginner-to-advanced guide to functional Java, aligned with the Oracle Certified Professional: Java SE 21 Developer (1Z0-830) exam objectives.


Table of Contents

  1. What Is Functional Programming?
  2. Lambda Expressions Step-by-Step
  3. Functional Interfaces
  4. Built-in Functional Interfaces
  5. Method References
  6. The Stream API From Scratch
  7. Creating Streams
  8. Intermediate Operations
  9. Terminal Operations
  10. Stream Execution Flow
  11. Collectors in Detail
  12. Optional
  13. Primitive Streams
  14. Certification Traps
  15. Common Mistakes
  16. Interview Questions
  17. Quick Revision Notes
  18. One-Page Cheat Sheet

1. What Is Functional Programming?

Functional programming (FP) treats computation as the evaluation of functions — passing behavior around as data, favoring immutability, and avoiding side effects.

ConceptMeaning
First-class functionsFunctions can be stored, passed, and returned (via lambdas).
ImmutabilityPrefer not modifying state.
DeclarativeSay what to do, not how (loops → streams).
Pure functionsSame input → same output, no side effects.
// Imperative (how):
int sum = 0;
for (int n : numbers) { if (n % 2 == 0) sum += n; }

// Functional (what):
int sum2 = numbers.stream().filter(n -> n % 2 == 0).mapToInt(i -> i).sum();

2. Lambda Expressions Step-by-Step

A lambda is a short block of code that takes parameters and returns a value — an anonymous implementation of a functional interface.

From Anonymous Class to Lambda

// Step 1: anonymous class
Runnable r1 = new Runnable() {
    public void run() { System.out.println("Hello"); }
};

// Step 2: lambda (same thing, concise)
Runnable r2 = () -> System.out.println("Hello");

Syntax Breakdown

(parameters) -> { body }
     |         |     |
   inputs    arrow  code
FormExample
No params() -> 42
One param (no parens needed)x -> x * 2
Multiple params(a, b) -> a + b
Explicit types(int a, int b) -> a + b
Block body(a, b) -> { int s = a + b; return s; }
With var (Java 11+)(var a, var b) -> a + b

Rules & Traps

RuleDetail
Single expressionNo {} and no return needed (implicit return).
Block bodyNeeds {} and explicit return (if returning).
Effectively finalLambdas can use local variables only if they're (effectively) final.
No new scope for thisthis refers to the enclosing instance, not the lambda.
int factor = 3;                       // effectively final
Function<Integer, Integer> f = x -> x * factor;
// factor = 5;                         // would break it — can't reassign

3. Functional Interfaces

A functional interface has exactly one abstract method (SAM — Single Abstract Method). A lambda provides that method's implementation.

@FunctionalInterface
interface Calculator {
    int operate(int a, int b);        // the single abstract method
    // default/static methods are allowed and don't count
    default void info() { System.out.println("A calculator"); }
}

Calculator add = (a, b) -> a + b;
System.out.println(add.operate(2, 3));   // 5

Rules

RuleDetail
Exactly one abstract methodMore than one = not functional.
@FunctionalInterfaceOptional annotation; compiler enforces SAM.
Default/static/private methodsAllowed; don't count toward the SAM.
Object methodsMethods like equals/toString don't count.

Trap: A functional interface can declare methods that override Object methods (like equals) — they don't break the "single abstract method" rule.


4. Built-in Functional Interfaces

The java.util.function package provides ready-made interfaces.

InterfaceAbstract MethodTakesReturnsUse
Supplier<T>get()nothingTProduce a value
Consumer<T>accept(T)TvoidConsume a value
Function<T,R>apply(T)TRTransform
Predicate<T>test(T)TbooleanTest a condition
UnaryOperator<T>apply(T)TTSame-type transform
BinaryOperator<T>apply(T,T)T,TTCombine two
BiFunction<T,U,R>apply(T,U)T,URTwo-arg transform
BiConsumer<T,U>accept(T,U)T,UvoidTwo-arg consume
BiPredicate<T,U>test(T,U)T,UbooleanTwo-arg test

Examples

Supplier<String> supplier = () -> "Hello";
System.out.println(supplier.get());          // Hello

Consumer<String> printer = s -> System.out.println(s);
printer.accept("World");                       // World

Function<Integer, Integer> square = x -> x * x;
System.out.println(square.apply(5));           // 25

Predicate<Integer> isEven = n -> n % 2 == 0;
System.out.println(isEven.test(4));            // true

BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
System.out.println(add.apply(2, 3));           // 5

Composing Functions

Function<Integer,Integer> times2 = x -> x * 2;
Function<Integer,Integer> plus3  = x -> x + 3;

System.out.println(times2.andThen(plus3).apply(5));  // (5*2)+3 = 13
System.out.println(times2.compose(plus3).apply(5));  // (5+3)*2 = 16

Predicate<Integer> positive = n -> n > 0;
Predicate<Integer> even     = n -> n % 2 == 0;
System.out.println(positive.and(even).test(4));      // true
System.out.println(positive.or(even).test(-2));      // true
System.out.println(positive.negate().test(-1));      // true

Trap: andThen runs this first, then the argument; compose runs the argument first, then this.


5. Method References

A method reference is shorthand for a lambda that just calls an existing method.

TypeSyntaxLambda Equivalent
StaticClassName::staticMethodx -> ClassName.staticMethod(x)
Instance (specific object)obj::methodx -> obj.method(x)
Instance (arbitrary object)ClassName::method(obj, args) -> obj.method(args)
ConstructorClassName::newargs -> new ClassName(args)
// Static
Function<String, Integer> parse = Integer::parseInt;
System.out.println(parse.apply("42"));   // 42

// Instance of a specific object
String prefix = "Hello ";
Function<String, String> greet = prefix::concat;
System.out.println(greet.apply("World"));   // Hello World

// Instance of an arbitrary object of a type
Function<String, Integer> len = String::length;
System.out.println(len.apply("Java"));      // 4

// Constructor
Supplier<ArrayList<String>> listMaker = ArrayList::new;
List<String> list = listMaker.get();
// Common in streams:
List<String> names = List.of("bob", "alice");
names.stream().map(String::toUpperCase).forEach(System.out::println);

6. The Stream API From Scratch

A Stream is a sequence of elements supporting functional-style operations. It does not store data — it processes data from a source (collection, array, etc.).

Anatomy of a Stream Pipeline

   SOURCE  ->  INTERMEDIATE ops (lazy)  ->  TERMINAL op (eager)
   list       filter, map, sorted...        collect, forEach, count
List<String> names = List.of("Alice", "Bob", "Charlie", "Dave");

long count = names.stream()              // source
    .filter(n -> n.length() > 3)         // intermediate
    .map(String::toUpperCase)            // intermediate
    .count();                            // terminal
System.out.println(count);               // 3

Key Properties

PropertyDetail
No storageStreams don't hold data; they process it.
LazyIntermediate ops run only when a terminal op is invoked.
Single-useA stream can be consumed once; reuse throws IllegalStateException.
Non-mutatingThe source is not modified.
Possibly parallel.parallelStream() for concurrency.
Stream<Integer> s = Stream.of(1, 2, 3);
s.forEach(System.out::println);
// s.forEach(System.out::println);   // IllegalStateException: stream already used

7. Creating Streams

// From a collection
List<Integer> list = List.of(1, 2, 3);
Stream<Integer> s1 = list.stream();

// From values
Stream<String> s2 = Stream.of("a", "b", "c");

// From an array
int[] arr = {1, 2, 3};
IntStream s3 = Arrays.stream(arr);

// Infinite streams (need limit!)
Stream<Integer> s4 = Stream.iterate(1, n -> n + 1);   // 1,2,3,...
Stream<Double> s5 = Stream.generate(Math::random);

// Range (IntStream)
IntStream s6 = IntStream.range(1, 5);        // 1,2,3,4 (end-exclusive)
IntStream s7 = IntStream.rangeClosed(1, 5);  // 1,2,3,4,5 (end-inclusive)

// Empty
Stream<String> s8 = Stream.empty();

Trap: Stream.iterate/generate are infinite — always add .limit(n) or a 3-arg iterate with a predicate.


8. Intermediate Operations

Intermediate operations are lazy and return a new stream. They run only when a terminal op executes.

filter — keep matching elements

Stream.of(1, 2, 3, 4, 5)
    .filter(n -> n % 2 == 0)
    .forEach(System.out::print);   // 24

map — transform each element

Stream.of("a", "b", "c")
    .map(String::toUpperCase)
    .forEach(System.out::print);   // ABC

flatMap — flatten nested structures

List<List<Integer>> nested = List.of(List.of(1, 2), List.of(3, 4));
List<Integer> flat = nested.stream()
    .flatMap(List::stream)         // flatten List<List> -> stream of ints
    .collect(Collectors.toList());
System.out.println(flat);          // [1, 2, 3, 4]

distinct — remove duplicates (uses equals)

Stream.of(1, 2, 2, 3, 3, 3)
    .distinct()
    .forEach(System.out::print);   // 123

sorted — order elements

Stream.of(3, 1, 2)
    .sorted()                       // natural order
    .forEach(System.out::print);    // 123

Stream.of("bb", "a", "ccc")
    .sorted(Comparator.comparingInt(String::length))
    .forEach(System.out::println);  // a, bb, ccc

limit & skip

Stream.iterate(1, n -> n + 1)
    .limit(5)                       // first 5
    .skip(2)                        // drop first 2
    .forEach(System.out::print);    // 345

peek — debug without consuming

Stream.of(1, 2, 3)
    .peek(n -> System.out.println("peeking: " + n))   // side-effect/debug
    .map(n -> n * 2)
    .forEach(System.out::println);
OperationPurpose
filter(Predicate)Keep matching elements
map(Function)Transform each element
flatMap(Function)Flatten nested streams
distinct()Remove duplicates
sorted()Sort elements
limit(n) / skip(n)Take / drop elements
peek(Consumer)Inspect elements (debugging)

Trap: peek is for debugging only; relying on it for logic is discouraged and it may be skipped under optimization.


9. Terminal Operations

Terminal operations trigger execution and produce a result (or side effect). After a terminal op, the stream is consumed.

forEach

Stream.of(1, 2, 3).forEach(System.out::println);

collect — gather into a collection

List<Integer> list = Stream.of(1, 2, 3).collect(Collectors.toList());
List<Integer> list2 = Stream.of(1, 2, 3).toList();   // Java 16+ (immutable)

reduce — combine into a single value

int sum = Stream.of(1, 2, 3, 4).reduce(0, Integer::sum);   // 10

Optional<Integer> product = Stream.of(1, 2, 3, 4)
    .reduce((a, b) -> a * b);                               // Optional[24]

count, anyMatch, allMatch, noneMatch

long c = Stream.of(1, 2, 3).count();                         // 3
boolean any  = Stream.of(1, 2, 3).anyMatch(n -> n > 2);      // true
boolean all  = Stream.of(1, 2, 3).allMatch(n -> n > 0);      // true
boolean none = Stream.of(1, 2, 3).noneMatch(n -> n > 5);     // true

findFirst, findAny, min, max

Optional<Integer> first = Stream.of(1, 2, 3).findFirst();    // Optional[1]
Optional<Integer> max = Stream.of(1, 2, 3).max(Integer::compare); // Optional[3]
OperationReturns
forEach(Consumer)void
collect(Collector)Collection/value
toList()Immutable List (Java 16+)
reduce(...)Combined value / Optional
count()long
anyMatch/allMatch/noneMatchboolean
findFirst/findAnyOptional
min/maxOptional

Short-circuiting terminal ops (anyMatch, findFirst, limit) stop early — they don't process the whole stream.


10. Stream Execution Flow

Streams are lazy: nothing runs until a terminal operation. Then elements flow through the pipeline one at a time (not stage-by-stage on the whole collection).

Element-by-Element (Vertical) Processing

Stream.of("a", "bb", "ccc")
    .filter(s -> { System.out.println("filter: " + s); return s.length() > 1; })
    .map(s -> { System.out.println("map: " + s); return s.toUpperCase(); })
    .forEach(s -> System.out.println("forEach: " + s));

Output (note the interleaving):

filter: a
filter: bb
map: bb
forEach: BB
filter: ccc
map: ccc
forEach: CCC
   "a"  -> filter (fail) -> dropped
   "bb" -> filter (pass) -> map -> forEach
   "ccc"-> filter (pass) -> map -> forEach

Each element traverses the entire pipeline before the next element starts. This enables short-circuiting and efficiency.

Lazy Evaluation Proof

Stream.of(1, 2, 3)
    .filter(n -> { System.out.println("checking " + n); return n > 1; });
// NOTHING prints — no terminal operation!

11. Collectors in Detail

Collectors are recipes for the collect() terminal op — gathering stream elements into collections, maps, or summaries.

To Collections

List<Integer> list = stream.collect(Collectors.toList());
Set<Integer>  set  = stream.collect(Collectors.toSet());
List<Integer> arr  = stream.collect(Collectors.toCollection(ArrayList::new));

joining (Strings)

String result = Stream.of("a", "b", "c")
    .collect(Collectors.joining(", ", "[", "]"));   // [a, b, c]

groupingBy

List<String> words = List.of("apple", "banana", "avocado", "cherry");
Map<Character, List<String>> byFirst = words.stream()
    .collect(Collectors.groupingBy(w -> w.charAt(0)));
// {a=[apple, avocado], b=[banana], c=[cherry]}

groupingBy with downstream

Map<Character, Long> countByFirst = words.stream()
    .collect(Collectors.groupingBy(w -> w.charAt(0), Collectors.counting()));
// {a=2, b=1, c=1}

partitioningBy (boolean split)

Map<Boolean, List<Integer>> parts = Stream.of(1, 2, 3, 4, 5)
    .collect(Collectors.partitioningBy(n -> n % 2 == 0));
// {false=[1, 3, 5], true=[2, 4]}

toMap

Map<String, Integer> lengths = Stream.of("a", "bb", "ccc")
    .collect(Collectors.toMap(s -> s, String::length));
// {a=1, bb=2, ccc=3}

Summaries

Double avg = Stream.of(1, 2, 3).collect(Collectors.averagingInt(i -> i)); // 2.0
Integer sum = Stream.of(1, 2, 3).collect(Collectors.summingInt(i -> i));  // 6
Long n = Stream.of(1, 2, 3).collect(Collectors.counting());               // 3
CollectorResult
toList() / toSet()Collection
toMap(k, v)Map (duplicate keys → exception unless merge fn)
joining(...)Concatenated String
groupingBy(fn)Map<K, List<T>>
groupingBy(fn, downstream)Map<K, R>
partitioningBy(predicate)Map<Boolean, List<T>>
counting/summingInt/averagingIntNumeric summary

Trap: toMap throws IllegalStateException on duplicate keys unless you supply a merge function: toMap(k, v, (a, b) -> a).


12. Optional

Optional<T> is a container that may or may not hold a value — designed to avoid NullPointerException and make "no result" explicit.

Optional<String> present = Optional.of("Hello");
Optional<String> empty   = Optional.empty();
Optional<String> nullable = Optional.ofNullable(maybeNull);   // null-safe

Reading Values

Optional<String> opt = Optional.of("Java");

opt.isPresent();                  // true
opt.isEmpty();                    // false (Java 11+)
opt.get();                        // "Java" (throws if empty — avoid!)
opt.orElse("default");            // value or "default"
opt.orElseGet(() -> compute());   // value or lazily computed default
opt.orElseThrow();                // value or NoSuchElementException
opt.ifPresent(System.out::println);

Transforming

Optional<String> name = Optional.of("alice");
int len = name.map(String::length).orElse(0);             // 5
Optional<String> upper = name.filter(s -> s.length() > 3)
                             .map(String::toUpperCase);     // Optional[ALICE]

Common Pitfalls (Traps)

PitfallBetter Approach
Calling get() without checkingUse orElse/orElseGet/ifPresent.
Optional.of(null)Throws NPE — use ofNullable.
Using Optional for fields/paramsIntended for return types.
orElse(expensive()) always runsUse orElseGet(() -> expensive()) (lazy).
// orElse vs orElseGet:
String a = opt.orElse(buildDefault());      // buildDefault() ALWAYS called
String b = opt.orElseGet(() -> buildDefault()); // called only if empty

Key trap: orElse(x) always evaluates x even if the Optional has a value; orElseGet(supplier) evaluates only when empty.


13. Primitive Streams

To avoid boxing overhead, use IntStream, LongStream, and DoubleStream.

IntStream.rangeClosed(1, 5).sum();                 // 15
IntStream.of(1, 2, 3).average().getAsDouble();     // 2.0
IntStream.rangeClosed(1, 5).boxed()                // IntStream -> Stream<Integer>
    .collect(Collectors.toList());

// Converting
Stream<Integer> boxed = IntStream.range(0, 3).boxed();
IntStream unboxed = Stream.of(1, 2, 3).mapToInt(Integer::intValue);

// Statistics
IntSummaryStatistics stats = IntStream.of(1, 2, 3, 4).summaryStatistics();
System.out.println(stats.getMax());     // 4
System.out.println(stats.getAverage()); // 2.5
MethodPurpose
mapToInt/mapToObjConvert between object & primitive streams
boxed()Primitive stream → Stream<Wrapper>
sum/average/max/minBuilt-in numeric terminals
summaryStatistics()Count, sum, min, max, average at once

14. Certification Traps

#Trap
1A stream can be used once; reuse → IllegalStateException.
2Intermediate ops are lazy — nothing runs without a terminal op.
3Stream.iterate/generate are infinite — need limit().
4peek is for debugging; may be skipped and shouldn't drive logic.
5findFirst/anyMatch/limit short-circuit.
6toMap with duplicate keys throws unless a merge function is given.
7orElse(x) always evaluates x; use orElseGet for laziness.
8Optional.of(null) throws NPE; use ofNullable.
9Optional.get() on empty throws NoSuchElementException.
10Lambdas capture only effectively final local variables.
11andThen = this then arg; compose = arg then this.
12A functional interface has exactly one abstract method (Object methods excluded).
13reduce with identity returns a value; without identity returns Optional.
14toList() (Java 16+) is immutable; Collectors.toList() is unspecified mutability.
15Elements flow through the pipeline one at a time, not stage-by-stage.

15. Common Mistakes

MistakeFix
Reusing a consumed streamCreate a new stream each time.
Forgetting a terminal op (nothing happens)Add collect/forEach/count, etc.
Infinite stream without limitAdd .limit(n).
Optional.get() everywhereUse orElse/ifPresent/map.
orElse(expensiveCall())Use orElseGet(() -> ...).
Mutating shared state in a lambdaKeep operations stateless/pure.
toMap on data with duplicate keysProvide a merge function.
Using streams for trivial loopsA plain loop can be clearer.

16. Interview Questions

Q1. What is a lambda expression? A concise anonymous function that implements a functional interface's single abstract method.

Q2. What is a functional interface? An interface with exactly one abstract method; it may also have default/static methods.

Q3. Name the core built-in functional interfaces. Supplier (produces), Consumer (consumes), Function (transforms), Predicate (tests), plus UnaryOperator/BinaryOperator and the Bi* variants.

Q4. What are the types of method references? Static (Class::m), instance of a specific object (obj::m), instance of an arbitrary object (Class::m), and constructor (Class::new).

Q5. What is a Stream? A sequence of elements supporting functional-style, lazy, single-use operations that don't modify the source.

Q6. Difference between intermediate and terminal operations? Intermediate ops are lazy and return a stream (e.g., map, filter); terminal ops trigger execution and produce a result (e.g., collect, count).

Q7. Difference between map and flatMap? map transforms each element 1:1; flatMap flattens nested streams into a single stream.

Q8. Difference between findFirst and findAny? findFirst returns the first element (respecting order); findAny may return any element, which is faster in parallel streams.

Q9. Why use Optional? To represent the possible absence of a value explicitly and avoid NullPointerException.

Q10. Difference between orElse and orElseGet? orElse always evaluates its argument; orElseGet evaluates the supplier only when the Optional is empty.

Q11. What does reduce do? It combines stream elements into a single value using an accumulator (optionally with an identity).

Q12. Why can't a lambda use a non-final local variable? Captured local variables must be effectively final to avoid concurrency/lifecycle issues; the lambda captures a copy of the value.


17. Quick Revision Notes

  • Lambda = anonymous implementation of a functional interface's single method.
  • Functional interface = exactly one abstract method (@FunctionalInterface).
  • Core interfaces: Supplier (get), Consumer (accept), Function (apply), Predicate (test).
  • andThen = this→arg; compose = arg→this.
  • Method refs: Class::static, obj::method, Class::method, Class::new.
  • Streams: lazy, single-use, non-mutating; reuse → IllegalStateException.
  • Intermediate (lazy): filter, map, flatMap, distinct, sorted, limit, skip, peek.
  • Terminal (eager): forEach, collect, toList, reduce, count, *Match, find*, min/max.
  • Infinite streams (iterate/generate) need limit.
  • Elements flow one-at-a-time through the pipeline; short-circuiting stops early.
  • Collectors: toList/toSet/toMap, joining, groupingBy, partitioningBy, summaries.
  • toMap duplicate keys → need merge fn.
  • Optional: of/ofNullable/empty; prefer orElseGet/ifPresent/map; avoid get.
  • orElse always evaluates; orElseGet is lazy.
  • Primitive streams (IntStream...) avoid boxing; boxed()/mapToInt convert.

18. One-Page Cheat Sheet

============ LAMBDAS, STREAMS & FUNCTIONAL PROGRAMMING CHEAT SHEET ============

LAMBDA SYNTAX
  () -> expr | x -> x*2 | (a,b) -> a+b | (a,b) -> { return a+b; }
  captures effectively-final locals ; this = enclosing instance

FUNCTIONAL INTERFACE  (@FunctionalInterface = exactly ONE abstract method)
  Supplier<T>      T get()
  Consumer<T>      void accept(T)
  Function<T,R>    R apply(T)        andThen(this->arg) compose(arg->this)
  Predicate<T>     boolean test(T)   and/or/negate
  UnaryOperator<T> T apply(T)
  BinaryOperator<T>T apply(T,T)
  Bi* variants: BiFunction/BiConsumer/BiPredicate

METHOD REFERENCES
  Class::staticM | obj::instanceM | Class::instanceM | Class::new

STREAM PIPELINE
  source.stream() -> intermediate(lazy)... -> terminal(eager)
  single-use (reuse -> IllegalStateException) ; non-mutating

CREATE
  list.stream() | Stream.of(...) | Arrays.stream(arr)
  Stream.iterate(seed,fn)/generate(sup) -> INFINITE, need limit()
  IntStream.range(a,b) [excl] / rangeClosed(a,b) [incl]

INTERMEDIATE (lazy)
  filter(Pred) map(Fn) flatMap(Fn) distinct() sorted()
  limit(n) skip(n) peek(Consumer=debug only)

TERMINAL (eager)
  forEach collect toList(16+,immutable) reduce(id,acc)
  count anyMatch allMatch noneMatch findFirst findAny min max
  short-circuit: anyMatch/findFirst/limit

EXECUTION
  lazy: nothing runs without terminal
  elements flow ONE AT A TIME through whole pipeline (vertical)

COLLECTORS
  toList/toSet/toMap(k,v[,merge]) joining(d,pre,suf)
  groupingBy(fn[,downstream]) partitioningBy(pred)
  counting summingInt averagingInt
  toMap duplicate key -> need merge fn

OPTIONAL
  of(x) ofNullable(x) empty()
  isPresent isEmpty get(avoid) orElse(x always eval)
  orElseGet(sup lazy) orElseThrow ifPresent map filter
  of(null) -> NPE ; get() on empty -> NoSuchElementException

PRIMITIVE STREAMS
  IntStream/LongStream/DoubleStream ; sum/average/max/min
  boxed() -> Stream<Wrapper> ; mapToInt/mapToObj ; summaryStatistics()
==============================================================================

End of 09 - Lambdas, Streams, and Functional Programming.