Skip to content

Summary: Lambda Expressions & Functional Programming

Combined Knowledge from: Tim Buchalka's Course (Section 14) + Effective Java (Items 42–44)
Mastery Level:


Topic Overview

This topic marks Java's paradigm shift from purely object-oriented programming to a hybrid OOP + Functional model. Lambda expressions, introduced in JDK 8, are not just syntactic sugar — they triggered a fundamental redesign of Java's APIs, compilation strategy, and developer mindset. Understanding lambdas at an expert level means understanding three dimensions: the language semantics (syntax, scoping, type inference), the API ecosystem (functional interfaces, convenience methods, Comparator fluent API), and the JVM internals (invokedynamic, lambda metafactory, and desugar strategies).

mindmap
  root((Topic 5))
    **Lambda Expressions**
      Syntax Variations
      Effectively Final
      Deferred Execution
      Target Typing
    **Functional Interfaces**
      Consumer / Predicate
      Function / Supplier
      Operators (Unary/Binary)
      Custom + @FunctionalInterface
    **Method References**
      Static
      Bounded Receiver
      Unbounded Receiver
      Constructor
    **Chaining & Composition**
      andThen / compose
      and / or / negate
      Comparator Convenience
    **JVM Internals**
      invokedynamic
      LambdaMetafactory
      Desugar Strategies
      Performance Model

Core Concepts

1. Lambda Expressions

Definition: A lambda expression is a concise representation of a function object — an anonymous method that targets a functional interface (an interface with exactly one abstract method). It replaces the ceremony of anonymous classes with clean, expressive syntax.

flowchart LR
    subgraph before["Pre-Java 8"]
        A["new Comparator<String>() {\n  public int compare(String a, String b) {\n    return a.compareTo(b);\n  }\n}"]
    end
    subgraph after["Java 8+"]
        L["(a, b) -> a.compareTo(b)"]
    end
    before -->|"5 lines → 1 line"| after

    style before fill:#b71c1c,color:#fff
    style after fill:#7cb342,color:#fff

Syntax Quick Reference

Form Syntax Notes
No params () -> expr Parens required
One param, no type s -> expr Parens optional
One param, typed (String s) -> expr Parens required
Multiple params (a, b) -> expr All types or none
var params (var a, var b) -> expr All must be var
Expression body (a, b) -> a + b Implicit return, no semicolons
Block body (a, b) -> { return a + b; } Explicit return + semicolons

The Three Laws of Lambda Scoping

flowchart TD
    subgraph laws["Lambda Scoping Rules"]
        L1["1. Effectively Final\nEnclosing locals used in lambda\nmust never be reassigned"]
        L2["2. No Name Conflicts\nLambda params cannot shadow\nlocal variables in enclosing scope"]
        L3["3. this = Enclosing Class\nUnlike anonymous classes,\n'this' refers to the outer class"]
    end

    style L1 fill:#1565c0,color:#fff
    style L2 fill:#5b8ba4,color:#fff
    style L3 fill:#7cb342,color:#fff

2. Functional Interfaces

Definition: An interface with exactly one abstract method (SAM — Single Abstract Method). The @FunctionalInterface annotation is optional but strongly recommended — it prevents accidental API breakage.

The Six Fundamental Interfaces

flowchart TD
    subgraph core["The Six Bases of java.util.function"]
        direction TB

        subgraph operators["Operators — Same-Type I/O"]
            UO["UnaryOperator<T>\napply(T) → T\nString::toUpperCase"]
            BO["BinaryOperator<T>\napply(T, T) → T\nInteger::sum"]
        end

        subgraph conversions["Conversions — Type Transformers"]
            FN["Function<T, R>\napply(T) → R\nArrays::asList"]
            PR["Predicate<T>\ntest(T) → boolean\nCollection::isEmpty"]
        end

        subgraph endpoints["Source / Sink"]
            SU["Supplier<T>\nget() → T\nInstant::now"]
            CO["Consumer<T>\naccept(T) → void\nSystem.out::println"]
        end
    end

    style operators fill:#1565c0,color:#fff
    style conversions fill:#5b8ba4,color:#fff
    style endpoints fill:#7cb342,color:#fff

The Full 43-Interface Derivation

All 43 interfaces in java.util.function derive from the six basics via three axes of variation:

flowchart LR
    SIX["6 Basic Interfaces"] --> BI["Bi- Variants\n(2-argument versions)\nBiFunction, BiConsumer,\nBiPredicate"]
    SIX --> PRIM["Primitive Specializations\n(avoid autoboxing)\nIntFunction, LongPredicate,\nDoubleConsumer, etc."]
    SIX --> CROSS["Cross-Type Primitives\nIntToDoubleFunction,\nLongToIntFunction,\nToIntFunction, etc."]
    SIX --> OBJ["ObjXxxConsumer\nObjIntConsumer,\nObjLongConsumer,\nObjDoubleConsumer"]

    style BI fill:#1565c0,color:#fff
    style PRIM fill:#e65100,color:#fff
    style CROSS fill:#9ece6a,color:#fff
    style OBJ fill:#5b8ba4,color:#fff

API Methods That Accept Functional Interfaces

Method Accepts Category Purpose
Iterable.forEach(...) Consumer<T> Consumer Iterate and execute
List.removeIf(...) Predicate<T> Predicate Remove matching elements
List.replaceAll(...) UnaryOperator<T> Function Transform each element
List.sort(...) Comparator<T> Custom FI Sort with custom logic
Arrays.setAll(...) IntFunction<T> Function Populate array by index
Map.merge(...) BiFunction<V,V,V> Function Resolve key conflicts
String.transform(...) Function<String,R> Function Apply function to string

3. Method References

Definition: A method reference is a compact lambda expression that delegates to an existing method. It removes the boilerplate of declaring parameters and forwarding them.

The Four Types

flowchart TD
    subgraph types["Method Reference Types"]
        ST["1. Static Method\nInteger::sum\n→ (a, b) -> Integer.sum(a, b)"]
        BR["2. Bounded Receiver\nSystem.out::println\n→ (x) -> System.out.println(x)\nInstance known at declaration"]
        UR["3. Unbounded Receiver\nString::toUpperCase\n→ (s) -> s.toUpperCase()\n1st arg becomes the instance"]
        CR["4. Constructor\nArrayList::new\n→ () -> new ArrayList&lt;&gt;()\nWorks with Supplier, Function"]
    end

    style ST fill:#1565c0,color:#fff
    style BR fill:#9ece6a,color:#fff
    style UR fill:#e65100,color:#fff
    style CR fill:#7e56c2,color:#fff

The Unbounded Receiver — The Confusing One

The key insight: for unbounded receivers, the first argument to the functional method becomes the instance on which the method is called:

flowchart LR
    subgraph bop["BinaryOperator.apply(s1, s2) with String::concat"]
        S1["s1 = 'Hello '"] -->|"Becomes instance"| CALL["s1.concat(s2)"]
        S2["s2 = 'World'"] -->|"Becomes argument"| CALL
        CALL --> RES["'Hello World'"]
    end
Functional Interface Params Unbounded Receiver ClassName::method Works?
UnaryOperator<String> 1 1 instance + 0 args for toUpperCase()
BinaryOperator<String> 2 1 instance + 1 arg for concat(String)
UnaryOperator<String> 1 1 instance + 1 arg for concat(String) ❌ Not enough!

4. Chaining & Convenience Methods

Functional interfaces provide default methods for composition — chaining multiple operations into a single pipeline:

Function Chaining: andThen vs compose

flowchart LR
    subgraph at["andThen: f.andThen(g)"]
        direction LR
        I1["Input"] --> F1["f(x)"] --> G1["g(result)"] --> O1["Output"]
    end
    subgraph co["compose: f.compose(g)"]
        direction LR
        I2["Input"] --> G2["g(x)"] --> F2["f(result)"] --> O2["Output"]
    end

    style at fill:#1565c0,color:#fff
    style co fill:#7e56c2,color:#fff
Function<String, String> uCase = String::toUpperCase;
Function<String, String> addSuffix = s -> s.concat(" Egyptian");

// andThen: uCase FIRST, then addSuffix
uCase.andThen(addSuffix).apply("Ahmed"); // "AHMED Egyptian"

// compose: addSuffix FIRST, then uCase
uCase.compose(addSuffix).apply("Ahmed"); // "AHMED EGYPTIAN"

Intermediate types can differ — only the final type must match the declaration:

Function<String, Integer> pipeline = uCase                      // String → String
    .andThen(s -> s.concat(" Egyptian"))                         // String → String
    .andThen(s -> s.split(" "))                                  // String → String[]
    .andThen(s -> String.join(", ", s))                          // String[] → String
    .andThen(String::length);                                    // String → Integer
// Returns: 15

Complete Convenience Methods Reference

Method Available On Behavior
andThen(after) Function, UnaryOp, BiFunction, BinaryOp, Consumer, BiConsumer Execute this first, then after
compose(before) Function, UnaryOperator only Execute before first, then this
and(other) Predicate, BiPredicate Logical AND of two predicates
or(other) Predicate, BiPredicate Logical OR of two predicates
negate() Predicate, BiPredicate Invert the boolean result

Comparator Fluent API

flowchart LR
    C1["Comparator.comparing(Person::lastName)"]
    C1 -->|".thenComparing"| C2["Person::firstName"]
    C2 -->|".reversed()"| C3["Reversed multi-level sort"]
    C3 --> RESULT["Peter PumpkinEater\nPeter Pan\nMinnie Mouse\nMickey Mouse"]

    style C1 fill:#1565c0,color:#fff
    style C3 fill:#9ece6a,color:#fff
Method Type Purpose
Comparator.comparing(keyExtractor) Static Create from key extraction function
Comparator.naturalOrder() Static Sort by Comparable natural order
Comparator.reverseOrder() Static Reverse natural order
.thenComparing(keyExtractor) Default Add secondary sort level
.reversed() Default Reverse the entire comparator chain

Key Internals to Understand

1. How Lambdas Are Compiled: invokedynamic

This is one of the most important JVM internals to understand. Lambdas are NOT compiled to anonymous classes. Instead, they use the invokedynamic bytecode instruction (introduced in Java 7 for dynamic languages, repurposed for lambdas in Java 8).

The Anonymous Class Approach (What Java Doesn't Do)

If lambdas were compiled like anonymous classes, each lambda would generate a separate .class file:

flowchart TD
    subgraph anon["❌ Anonymous Class Compilation (REJECTED)"]
        SRC["Source: list.forEach(s -> println(s))"]
        SRC --> GEN["Generates: Main$1.class"]
        GEN --> LOAD["ClassLoader loads Main$1.class"]
        LOAD --> INST["new Main$1() — object allocation on heap"]
    end

    N1["❌ Class loading overhead"]
    N2["❌ Disk space (one .class per lambda)"]
    N3["❌ Object allocation every invocation"]
    N4["❌ Prevents JIT optimizations"]

    INST --> N1
    INST --> N2
    INST --> N3
    INST --> N4

The invokedynamic Approach (What Java Actually Does)

flowchart TD
    subgraph indy["✅ invokedynamic Compilation (ACTUAL)"]
        SRC2["Source: list.forEach(s -> println(s))"]
        SRC2 --> DESUGAR["1. DESUGAR\nLambda body → private static method\nin the enclosing class"]
        DESUGAR --> INDY["2. INVOKEDYNAMIC\nFirst call: bootstrap links to\nLambdaMetafactory.metafactory()"]
        INDY --> CALLSITE["3. CALL SITE\nFactory creates a CallSite that\nproduces the functional interface impl"]
        CALLSITE --> CACHE["4. CACHE\nSubsequent calls reuse the\nlinked CallSite — near-zero overhead"]
    end

    Y1["✅ No extra .class files"]
    Y2["✅ No class loading overhead"]
    Y3["✅ JVM can inline the lambda body"]
    Y4["✅ Can optimize to zero-allocation"]

    CACHE --> Y1
    CACHE --> Y2
    CACHE --> Y3
    CACHE --> Y4

Step-by-Step: What Happens at Runtime

sequenceDiagram
    participant Compiler as javac
    participant Bytecode as .class file
    participant JVM
    participant Bootstrap as LambdaMetafactory
    participant CallSite as CallSite (cached)

    Compiler->>Bytecode: 1. Desugar lambda body into private static method
    Compiler->>Bytecode: 2. Emit invokedynamic instruction

    Note over JVM: First invocation only:
    JVM->>Bootstrap: 3. Bootstrap: call LambdaMetafactory.metafactory()
    Bootstrap->>Bootstrap: 4. Generate implementation class (in memory, NOT on disk)
    Bootstrap->>CallSite: 5. Create ConstantCallSite → functional interface proxy
    CallSite-->>JVM: 6. Return linked CallSite

    Note over JVM: All subsequent invocations:
    JVM->>CallSite: 7. Invoke via cached CallSite (no bootstrap)
    CallSite->>Bytecode: 8. Delegate to desugared private method

The Desugar Step in Detail

When the compiler encounters a lambda, it:

  1. Extracts the lambda body into a private method in the same class
  2. If the lambda captures local variables, they become method parameters
  3. If the lambda captures this, the method is instance (not static)
// Source code:
public class Main {
    public void process(List<String> list) {
        String prefix = "Hello";
        list.forEach(s -> System.out.println(prefix + " " + s));
    }
}

// After desugaring (conceptual — actual bytecode):
public class Main {
    public void process(List<String> list) {
        String prefix = "Hello";
        list.forEach(/* invokedynamic → lambda$process$0(prefix, s) */);
    }

    // Compiler-generated private method:
    private static void lambda$process$0(String prefix, String s) {
        System.out.println(prefix + " " + s);
    }
    // 'prefix' is passed as a parameter because it's captured from the enclosing scope
}

Why Effectively Final Is Required

Now the technical reason is clear: since the captured variable is copied as a parameter to the desugared method, any subsequent mutation to the original variable would create an inconsistency. Java prevents this by requiring the variable to be effectively final — guaranteeing the copy and the original always have the same value.


2. Lambda vs Anonymous Class — Performance Comparison

flowchart LR
    subgraph anonPerf["Anonymous Class"]
        AP1["1. Generates a .class file on disk"]
        AP2["2. ClassLoader loads and verifies it"]
        AP3["3. Allocates new object per invocation"]
        AP4["4. JIT can inline but harder to optimize"]
    end
    subgraph lambdaPerf["Lambda (invokedynamic)"]
        LP1["1. No .class file on disk"]
        LP2["2. In-memory class generated once at first call"]
        LP3["3. Can reuse same instance (no state = singleton)"]
        LP4["4. JIT can inline aggressively"]
    end
Aspect Anonymous Class Lambda
Class files One .class per anonymous class None (in-memory generation)
First invocation Class loading + verification Bootstrap + metafactory (similar cost)
Subsequent invocations new AnonymousClass() allocation Cached CallSite (no allocation for non-capturing)
Memory New object every time Singleton when non-capturing
JIT optimization Limited inlining Aggressive inlining possible
Startup impact More class loading at startup Deferred to first use

Non-Capturing Lambdas Are Free

A lambda that doesn't capture any variables (no local variables from the enclosing scope, no this) can be implemented as a singleton — the JVM creates one instance and reuses it forever. This means zero allocation overhead after the first call.

// Non-capturing — singleton, zero allocation:
list.forEach(System.out::println);

// Capturing 'prefix' — new instance each invocation:
list.forEach(s -> System.out.println(prefix + s));

3. The LambdaMetafactory — JVM Machinery

The java.lang.invoke.LambdaMetafactory is the bootstrap method that the JVM calls to link lambda call sites. It receives three critical pieces of information:

flowchart TD
    subgraph inputs["Bootstrap Arguments"]
        I1["1. samMethodType\nFunctional interface method signature\ne.g., void accept(Object)"]
        I2["2. implMethod\nHandle to the desugared private method\ne.g., Main::lambda$process$0"]
        I3["3. instantiatedMethodType\nThe specialized (erased) signature\ne.g., void accept(String)"]
    end

    subgraph factory["LambdaMetafactory.metafactory()"]
        F1["Generates an inner class\nimplementing the functional interface"]
        F2["The generated class's SAM method\ndelegates to the desugared method"]
    end

    subgraph output["Output"]
        O1["ConstantCallSite\nLinked once, cached forever\nReturns the functional interface impl"]
    end

    inputs --> factory --> output

    style factory fill:#1565c0,color:#fff
    style output fill:#2e7d32,color:#fff

Why invokedynamic Over Direct Class Generation?

The key advantage of invokedynamic is future flexibility:

flowchart TD
    subgraph fixed["Direct compilation (hypothetical)"]
        F["javac generates\nconcrete inner class"]
        F --> LOCK["Strategy is LOCKED\nat compile time\n❌ Can't change without recompiling"]
    end

    style fixed fill:#b71c1c,color:#fff
flowchart TD
    subgraph flexible["invokedynamic (actual)"]
        I["javac emits\ninvokedynamic instruction"]
        I --> RUNTIME["JVM chooses strategy\nAT RUNTIME"]
        RUNTIME --> S1["Generate inner class\n(current default)"]
        RUNTIME --> S2["Use MethodHandle directly\n(future optimization)"]
        RUNTIME --> S3["Inline the lambda entirely\n(JIT can do this today)"]
        RUNTIME --> S4["Allocate nothing\n(singleton for non-capturing)"]
    end

    style flexible fill:#5b8ba4,color:#fff

The JVM is free to change its lambda implementation strategy in future versions without recompiling any existing code. This is why invokedynamic was chosen over simply generating anonymous classes or method handles directly.


4. Target Typing and Type Inference

Lambda expressions don't carry explicit type information — the compiler infers the target functional interface from context. This is called target typing.

flowchart TD
    subgraph inference["Type Inference Chain"]
        CTX["Method signature:\nlist.sort(Comparator&lt;Person&gt;)"]
        CTX --> TARGET["Target type:\nComparator&lt;Person&gt;"]
        TARGET --> SAM["SAM method:\nint compare(Person, Person)"]
        SAM --> PARAMS["Lambda params:\np1 = Person, p2 = Person"]
        SAM --> RETURN["Return type:\nint (implicit)"]
        PARAMS --> LAMBDA["(p1, p2) -> p1.lastName().compareTo(p2.lastName())"]
        RETURN --> LAMBDA
    end

    style CTX fill:#1565c0,color:#fff
    style LAMBDA fill:#2e7d32,color:#fff

Where Target Typing Works

Context Example Target Type Source
Variable declaration Predicate<String> p = s -> s.isEmpty() Declared variable type
Method argument list.removeIf(s -> s.isEmpty()) Method parameter type
Return statement return s -> s.isEmpty() Method return type
Cast expression (Predicate<String>) s -> s.isEmpty() Cast type
Ternary expression condition ? s -> s.isEmpty() : s -> true Inferred from other branch

When Inference Fails

// ❌ Ambiguous — which interface to target?
var p = s -> s.isEmpty();  // Error! 'var' can't infer the functional interface type

// ✅ Provide the target type explicitly:
Predicate<String> p = s -> s.isEmpty();
Function<String, Boolean> f = s -> s.isEmpty();
// Both are valid targets for the SAME lambda!

5. Effectively Final — The Technical Deep Dive

Why It's Required (The Real Reason)

When a lambda captures a local variable, the variable's value is copied into the lambda's environment (it's passed as a parameter to the desugared method). If the original variable could change after the copy, the lambda and the enclosing method would disagree about the variable's value — a data consistency bug.

flowchart TD
    subgraph problem["If mutation were allowed (hypothetical)"]
        V["String prefix = 'Hello'"]
        V --> COPY["Lambda captures copy: prefix = 'Hello'"]
        V --> MUTATE["prefix = 'Goodbye'\n(after lambda creation)"]
        COPY --> STALE["Lambda uses stale 'Hello'\n💥 Inconsistent with enclosing scope!"]
        MUTATE --> STALE
    end

    subgraph solution["Effectively Final (actual)"]
        V2["String prefix = 'Hello'"]
        V2 --> COPY2["Lambda captures copy: prefix = 'Hello'"]
        V2 --> NOPE["prefix = 'Goodbye'\n❌ COMPILE ERROR:\n'must be final or effectively final'"]
    end

    style STALE fill:#c62828,color:#fff
    style NOPE fill:#2e7d32,color:#fff

Effectively Final vs final

Term Meaning Example
final Explicitly declared immutable final String prefix = "Hello";
Effectively final Implicitly immutable — assigned once, never reassigned String prefix = "Hello"; (no subsequent prefix = ...)
Not effectively final Reassigned at any point in the scope String prefix = "Hello"; prefix = "Bye";

A variable is effectively final if removing final from a final declaration would not change program semantics. The compiler checks this — if any assignment to the variable exists anywhere after initialization, the lambda won't compile.

The Check Is Scope-Wide

Even if the reassignment appears after the lambda, it still breaks the lambda:

String prefix = "Hello";
list.forEach(s -> System.out.println(prefix + s)); // ❌ Breaks!
prefix = "Goodbye"; // This line breaks the lambda ABOVE, not just below
The compiler considers the entire scope of the variable, not just execution order.


6. Deferred Execution — When Lambdas Actually Run

A lambda assigned to a variable is NOT executed at assignment time. The code is captured as a function object — it runs only when the functional method is explicitly invoked.

sequenceDiagram
    participant Code as Your Code
    participant Lambda as Lambda Object
    participant Runtime as Method Body

    Code->>Lambda: Supplier<Obj> ref = Obj::new (DECLARATION — nothing happens)
    Note over Lambda: Lambda is created but NOT executed
    Code->>Code: ... other code ...
    Code->>Lambda: ref.get() (INVOCATION)
    Lambda->>Runtime: Execute Obj::new → constructor runs NOW
    Runtime-->>Code: Returns new Obj instance

This matters for understanding:

  1. Performance: The constructor doesn't run until .get() — lazy initialization
  2. Side effects: System.out::println stored in a variable prints nothing until .accept() is called
  3. Effectively final: Since the lambda might execute much later (or on another thread), the captured variables must remain constant

Design Patterns & Best Practices

The Lambda Decision Tree

flowchart TD
    Q1["Need a function object?"]
    Q1 --> Q2{"Target is a\nfunctional interface?"}
    Q2 -->|"No — abstract class\nor multi-method interface"| ANON["Use Anonymous Class"]
    Q2 -->|"Yes"| Q3{"Lambda body is a\nsingle method call?"}
    Q3 -->|"Yes"| Q4{"Method reference is\nshorter AND clearer?"}
    Q4 -->|"Yes"| MR["✅ Use Method Reference"]
    Q4 -->|"No — MR is confusing"| LAMBDA["✅ Use Lambda"]
    Q3 -->|"No — multiple statements"| Q5{"Body is 1-3 lines?"}
    Q5 -->|"Yes"| LAMBDA
    Q5 -->|"No — too complex"| EXTRACT["Extract to named method\n→ then use method reference"]

    style MR fill:#2e7d32,color:#fff
    style LAMBDA fill:#1565c0,color:#fff
    style EXTRACT fill:#f57c00,color:#fff
    style ANON fill:#c62828,color:#fff

Effective Java Best Practices Applied

Practice Item Why It Matters
Prefer lambdas to anonymous classes 42 Anonymous classes are 5× more verbose and harder to read
Omit lambda parameter types 42 Type inference is powered by generics — trust the compiler
Prefer method references when clearer 43 Less parameter noise; IntelliJ suggests conversions
Five types of method references 43 Static, bound, unbound, class constructor, array constructor
Use standard functional interfaces 44 43 built-in interfaces cover virtually all use cases
@FunctionalInterface always 44 Prevents adding a second abstract method accidentally
Custom FI only when it adds value 44 Justified by: descriptive name, strong contract, or convenience methods

Common Pitfalls

1. Using return Without Braces

(a, b) -> return a + b     // ❌ Compile error!
(a, b) -> a + b            // ✅ Expression body — implicit return
(a, b) -> { return a + b; } // ✅ Block body — explicit return

2. Mixing var and Explicit Types

(Integer a, var b) -> a + b  // ❌ Cannot mix!
(var a, var b) -> a + b      // ✅ All var
(Integer a, Integer b) -> a + b // ✅ All typed

3. Modifying Captured Variables

String prefix = "Hello";
list.forEach(s -> System.out.println(prefix + s));
prefix = "Goodbye";  // ❌ Even though it's AFTER the lambda, it breaks it!
// "Variable used in lambda should be final or effectively final"

4. Confusing Static and Unbounded Receiver

Integer::sum     // STATIC method (sum is static on Integer)
String::concat   // UNBOUNDED RECEIVER (concat is an instance method!)
// Both use ClassName::method syntax — the compiler decides based on static vs instance

5. Wrong Argument Count for Unbounded Receiver

UnaryOperator<String> u = String::concat;  // ❌ 1 param → 1 instance + 0 args for concat
                                           //    But concat needs 1 arg! → mismatch
BinaryOperator<String> b = String::concat; // ✅ 2 params → 1 instance + 1 arg for concat

6. Forgetting compose Reverses Order

f.andThen(g).apply(x);   // f(x) first, then g(result)
f.compose(g).apply(x);   // g(x) first, then f(result) ← REVERSED!

7. Using var with Lambdas

var p = s -> s.isEmpty();  // ❌ Compile error!
// 'var' cannot infer functional interface type from lambda
// Which one? Predicate<String>? Function<String, Boolean>? Custom?

Predicate<String> p = s -> s.isEmpty();  // ✅ Explicit target type

8. Verbose Comparator When Convenience Methods Exist

// ❌ Verbose and error-prone
list.sort((o1, o2) -> {
    int result = o1.lastName().compareTo(o2.lastName());
    return result == 0 ? o1.firstName().compareTo(o2.firstName()) : result;
});

// ✅ Fluent and readable
list.sort(Comparator.comparing(Person::lastName)
                    .thenComparing(Person::firstName));

Best Practices Checklist

Lambda Expressions:

  • Use expression bodies for single-expression lambdas (no braces, no return)
  • Omit parameter types — let the compiler infer from context
  • Keep lambda bodies to 1–3 lines; extract longer logic to named methods
  • Never serialize lambdas — the serialized form is brittle and implementation-dependent
  • Remember this refers to the enclosing class, not the lambda itself

Functional Interfaces:

  • Memorize the six basic interfaces: Consumer, Predicate, Function, Supplier, UnaryOp, BinaryOp
  • Always add @FunctionalInterface to custom functional interfaces
  • Prefer standard interfaces — custom only when name, contract, or default methods add value
  • Use primitive specializations (IntPredicate, DoubleBinaryOperator) to avoid autoboxing

Method References:

  • Default to method references when they are shorter AND clearer
  • Understand all four types: static, bounded, unbounded, constructor
  • Watch for the unbounded receiver argument-count trap
  • Use IntelliJ's gutter icons to identify and convert lambda ↔ method reference

Chaining & Composition:

  • Use andThen for left-to-right pipelines; compose only when right-to-left makes sense
  • For Predicates: and, or, negate — compose test logic without imperative conditionals
  • For Comparators: Comparator.comparing() + .thenComparing() + .reversed()
  • Remember Consumer chains all receive the same input; Function chains pipe output → input

Learning Resources

Lambda Expressions & Functional Interfaces

JVM Internals

Functional Interface Library

Effective Java



References


Completed: 2026-03-12 | Confidence: 9/10