Core and Web-based Java
Lambdas, Streams, Optional and Nested Classes
PGCP-AC
Java can represent behaviour as data. A lambda can be passed to a sorting, filtering, validation or asynchronous API. Streams combine such operations into lazy data-processing pipelines and Optional makes the possible absence of one result explicit. Nested and anonymous classes remain important when behaviour needs its own fields, several methods or a close relationship with an enclosing type. This chapter explains how these constructs execute, rather than treating them as shorthand syntax.
1. Functional interfaces
A functional interface has one abstract method after inherited methods and relevant Object method rules are considered. It may still contain default, static and private methods.
@FunctionalInterface
interface PriceRule {
double apply(double amount);
default String description() {
return "Custom price rule";
}
}
@FunctionalInterface is optional but useful because the compiler reports an error if later changes violate the single-abstract-method requirement.
A lambda does not have a complete type by itself. Its target functional-interface type supplies parameter types, return requirements and allowed checked exceptions:
PriceRule discount = amount -> amount * 0.90;
The same lambda shape may target different compatible interfaces depending on the surrounding assignment, argument, cast or return context.
2. Lambda syntax
Common lambda forms are:
() -> 42
name -> name.length()
(left, right) -> left.compareTo(right)
(int x, int y) -> {
int sum = x + y;
return sum;
}
Parentheses can be omitted for one inferred parameter. If a parameter type is written, all parameter types must be written. A single expression returns its value when the target requires one. A block body uses normal statements and must explicitly return along every required path.
The compiler checks lambda parameters and result against the target method. Overloaded methods accepting different functional interfaces can become ambiguous, in which case an explicit target variable or cast can clarify the intended type.
3. Standard functional interfaces
The java.util.function package supplies reusable contracts:
| Interface | Abstract operation | Purpose |
|---|---|---|
Predicate<T> | boolean test(T) | Test a condition |
Function<T,R> | R apply(T) | Transform a value |
Consumer<T> | void accept(T) | Perform an action using a value |
Supplier<T> | T get() | Produce a value without input |
UnaryOperator<T> | T apply(T) | Transform within one type |
BinaryOperator<T> | T apply(T,T) | Combine two values of one type |
Predicates compose with and, or and negate. Functions compose with andThen and compose. Primitive specializations such as IntPredicate, IntFunction and ToIntFunction avoid unnecessary boxing in numeric work.
4. Variable capture
A lambda may capture instance fields, static fields and local variables. A captured local variable must be final or effectively final:
int minimum = 40;
Predicate<Integer> passing = mark -> mark >= minimum;
// minimum = 50; // would make capture illegal
A local variable is effectively final when assigned once and never reassigned. The restriction gives the lambda a stable captured value even if it executes after the declaring method returns.
Object state reached through a final reference may still mutate, but mutation inside a lambda can make behaviour stateful and unsafe under concurrency. Prefer stateless functions in reusable and parallel operations.
5. Lambda scope and this
A lambda does not introduce a new this. Inside an instance method, this continues to refer to the enclosing object. An anonymous class creates a new object scope, so this refers to the anonymous object.
class Counter {
int value;
Runnable lambda() {
return () -> System.out.println(this.value);
}
Runnable anonymous() {
return new Runnable() {
@Override public void run() {
System.out.println(this.getClass().getName());
}
};
}
}
Lambdas also share the enclosing lexical scope and cannot redeclare a local name already used there. Anonymous-class bodies have ordinary class scope and can declare fields.
6. Method references
A method reference is a compact lambda when an existing method already matches the target:
| Form | Example | Equivalent idea |
|---|---|---|
| Static method | Integer::parseInt | s -> Integer.parseInt(s) |
| Bound instance | prefix::concat | s -> prefix.concat(s) |
| Unbound instance | String::length | s -> s.length() |
| Constructor | ArrayList::new | () -> new ArrayList<>() |
The target type still determines how arguments are supplied. String::compareToIgnoreCase, for example, can receive one String as the receiver and another as its method argument when used as a Comparator.
Use a method reference when it improves clarity; a lambda is better when adaptation or extra logic is needed.
7. Nested and anonymous classes
Java's nested forms have different ownership:
- a static nested class belongs to the outer type and has no implicit outer object;
- a member inner class carries a reference to an enclosing instance;
- a local class is declared inside a block and can capture effectively final locals;
- an anonymous class defines and creates one unnamed implementation at an expression.
A lambda is not an anonymous-class spelling. It supplies an implementation of a functional interface without declaring a new class body in the language model. An anonymous class is useful when an implementation needs extra fields, initialization or several overridden methods. A lambda is concise for one behaviour contract.
8. Stream model
A stream is a single-use sequence of elements supporting aggregate operations. It is not a data structure and normally does not store elements. A pipeline consists of:
- a source, such as a collection, array, generator or file;
- zero or more intermediate operations;
- one terminal operation.
long passing = List.of(30, 70, 80).stream()
.filter(mark -> mark >= 40)
.count(); // 2
filter is intermediate and count is terminal. The stream pulls values from its source only when the terminal operation begins traversal.
9. Laziness and operation fusion
Intermediate operations such as filter, map, peek, distinct and sorted are lazy. Building the pipeline performs no element processing:
Stream<String> pipeline = names.stream()
.filter(name -> {
System.out.println("checking " + name);
return name.startsWith("A");
});
// No checking yet
long count = pipeline.count();
During traversal, operations can be fused per element. Short-circuiting terminal operations such as findFirst, anyMatch and limit may avoid visiting the whole source. Stateful operations such as sorted or distinct may need to retain information before yielding results.
10. Filtering and transformation
filter retains elements for which a Predicate returns true. map replaces each element with one result:
List<String> result = List.of("Neel", "Asha", "Nora").stream()
.filter(name -> name.startsWith("N"))
.map(String::toUpperCase)
.sorted()
.toList(); // [NEEL, NORA]
The original list remains unchanged. distinct removes duplicates using equality and hashing. sorted uses natural ordering or a Comparator.
flatMap transforms one input into zero or more output elements and flattens the nested streams:
List<String> words = sentences.stream()
.flatMap(line -> Arrays.stream(line.split("\\s+")))
.toList();
Use map for one-to-one transformation and flatMap when each input contains or produces a sequence.
11. Terminal operations
Terminal operations produce a result or side effect and consume the stream. Examples include:
count,min,max,findFirstandfindAny;anyMatch,allMatchandnoneMatch;forEachandforEachOrdered;reduce,collectandtoList.
A stream cannot ordinarily be used after a terminal operation:
Stream<String> stream = names.stream();
long size = stream.count();
// stream.findFirst(); // IllegalStateException
Create another stream from the reusable source. If stream construction is expensive, a Supplier<Stream<T>> can create a fresh pipeline on demand.
12. Reduction
Reduction combines elements into one value:
int total = List.of(2, 3, 4).stream()
.reduce(0, Integer::sum); // 9
The identity must be neutral: combining it with any value returns that value. The accumulator and combiner must be associative for regrouping in parallel:
(a operation b) operation c
=
a operation (b operation c)
Addition is associative for mathematical integers, while subtraction is not. Floating-point addition may differ slightly under regrouping because of rounding. A reduction should not mutate shared external state.
The one-argument reduce returns Optional because an empty stream has no value to return.
13. Collectors
collect performs mutable reduction into containers or summaries:
Map<String, List<Employee>> byDepartment =
employees.stream().collect(
Collectors.groupingBy(Employee::department));
Common collectors include:
toList,toSetandtoCollection;joiningfor text;groupingByandpartitioningBy;counting,summingIntandaveragingDouble;mapping,filteringandcollectingAndThen;toMap.
toMap requires a merge function when duplicate keys are possible:
Map<String, Integer> totals = orders.stream().collect(
Collectors.toMap(
Order::customer,
Order::amount,
Integer::sum));
Without a merge policy, duplicate keys cause IllegalStateException.
14. Primitive streams
IntStream, LongStream and DoubleStream avoid wrapper allocation and add numeric operations:
double average = students.stream()
.mapToInt(Student::mark)
.average()
.orElse(0.0);
mapToInt changes an object stream to IntStream. boxed() converts a primitive stream to a wrapper stream. Range factories distinguish an exclusive upper bound in range(1, 5) from an inclusive upper bound in rangeClosed(1, 5).
Numeric summary statistics can produce count, sum, minimum, maximum and average in one traversal.
15. Encounter order and parallel streams
An ordered source such as List has encounter order; HashSet generally does not promise one. forEach on a parallel stream may process in any order, while forEachOrdered preserves encounter order at a possible performance cost.
parallelStream() is not automatically faster. Splitting, coordination, data size, operation cost, ordering constraints and the shared common ForkJoinPool all matter. Parallel operations should be stateless, non-interfering, associative where required and free from unsafe shared mutation.
// Unsafe idea: several workers mutate the same ArrayList
List<Integer> output = new ArrayList<>();
numbers.parallelStream().forEach(output::add);
Use a collector designed for the reduction instead of mutating shared state.
16. Resource-backed streams
A stream over an in-memory collection normally owns no external resource. Some streams, including those from Files.lines, Files.list and Files.walk, hold open resources and must be closed:
try (Stream<String> lines =
Files.lines(path, StandardCharsets.UTF_8)) {
long errors = lines.filter(s -> s.contains("ERROR")).count();
}
Closing a pipeline also invokes registered onClose handlers. Terminal consumption alone should not be assumed to close every resource-backed stream.
17. Optional fundamentals
Optional<T> represents either one non-null value or absence:
Optional<Student> found = repository.findById(id);
String name = found.map(Student::name)
.orElse("Unknown");
Create it with Optional.of(nonNull), ofNullable(possiblyNull) or empty(). of(null) throws NullPointerException.
map transforms a present value and keeps absence. flatMap is used when the mapping function already returns Optional, avoiding Optional<Optional<T>>. filter retains a present value only when it satisfies a predicate.
18. Optional extraction and fallbacks
get() on an empty Optional throws NoSuchElementException. Prefer methods that make the absence policy visible:
Student student = found.orElseThrow(
() -> new StudentNotFoundException(id));
orElse(value) evaluates its argument before the method call, even when the Optional is present. orElseGet(supplier) invokes the supplier only when empty:
Profile profile = cached.orElseGet(this::loadProfile);
ifPresent, ifPresentOrElse and or support other policies. Optional works best as a return type for a possibly missing single result. It is usually inappropriate for required fields, method parameters or collections; an empty collection already represents no elements.
19. Side effects and debugging
Stream functions should not modify the source or depend on changing external state. Such interference makes results order-dependent and especially unsafe in parallel.
peek observes elements as they flow and is mainly useful for temporary debugging:
long count = values.stream()
.peek(v -> logger.debug("before: {}", v))
.filter(this::valid)
.count();
Because execution is lazy and may be optimized or short-circuited, peek is unsuitable for essential business effects. Use an explicit loop or terminal action when side effects are the purpose.
20. Choosing loops or streams
Streams are effective for declarative transformations, filtering, grouping and aggregation. A loop may be clearer for stateful algorithms, several exits, checked-exception-heavy processing or logic whose stream version requires hidden mutation.
Readability is the deciding factor. Avoid pipelines so long that the data shape becomes hard to follow. Extract named predicates or functions or divide the transformation into meaningful stages.
Practical considerations
| Mistake | Correct approach |
|---|---|
| Treating any interface as a lambda target | It must be functional |
| Reassigning a captured local | Keep it final or effectively final |
Assuming lambda this is a new object | It is lexical enclosing this |
| Expecting intermediate operations to run immediately | A terminal operation triggers traversal |
| Reusing a consumed stream | Obtain a fresh stream from the source |
| Using map when the result is itself a stream | Use flatMap |
| Mutating shared state in a parallel stream | Use reduction or collection |
| Calling Optional.get without a presence policy | Map, default or throw explicitly |
| Using orElse for an expensive fallback | Use orElseGet |
| Forgetting to close Files.lines | Use try-with-resources |
Worked pipeline trace
List<String> result = List.of("Neel", "Asha", "Nora").stream()
.filter(name -> name.startsWith("N"))
.map(String::toUpperCase)
.sorted()
.toList();
toList triggers traversal. Neel passes and becomes NEEL; Asha is discarded; Nora passes and becomes NORA. Sorting compares the two transformed strings, giving [NEEL, NORA]. The source list is unchanged and the stream is consumed.
Continue learning
Related notes
Put this topic into timed practice
Open mock tests when you want full-exam pacing, or keep drilling in practice mode.