Skip to main content
    Courses/Java/Lambda Expressions

    Lesson 22 • Advanced

    Lambda Expressions Deep Dive

    Lambdas let you pass behaviour as a value. By the end you'll write (a, b) -> a + b instead of a five-line anonymous class, and wire up the whole java.util.function toolkit with confidence.

    What You'll Learn in This Lesson

    • Write lambda expressions like (a, b) -> a + b
    • Define and use functional interfaces with @FunctionalInterface
    • Use Function, Consumer, Supplier, Predicate, and BiFunction
    • Replace lambdas with method references (Class::method, obj::method, Class::new)
    • Capture effectively-final variables safely inside a lambda
    • Choose a lambda vs an anonymous class — and know the this trap

    Before You Start

    You should be comfortable with interfaces and anonymous classes. A lambda is just a shorthand way to implement an interface that has a single method — so those two ideas are the foundation everything here is built on.

    Real-World Analogy: A Sticky Note of Instructions

    Imagine you run a kitchen. Sometimes you don't want to do a task yourself — you want to hand a coworker a quick instruction: "slice these", "throw out anything mouldy", "fetch me a clean plate".

    💡 Analogy: A lambda is a sticky note with instructions. Before Java 8 you had to write a whole formal letter (an anonymous class) just to say "sort by price". A lambda is the scribbled note instead: (a, b) -> a.price() - b.price(). The method you hand it to (like sort or forEach) reads the note and follows it. A functional interface is simply the kind of note the recipient accepts — one with a single, clear instruction on it.

    1️⃣ Lambda Syntax & Functional Interfaces

    A lambda has two parts split by an arrow: parameters -> body. The left side names the inputs; the right side is what to do with them. If the body is a single expression, its value is returned automatically — no return, no braces.

    (a, b) -> a + b        // two params, expression body (auto-returns a+b)
    n      -> n % 2 == 0   // one param needs no parentheses
    ()     -> "hello"      // no params still needs empty ()
    (a, b) -> {            // braces = a block: multiple statements,
        int r = a - b;     //   and you MUST write an explicit return
        return r;
    }

    A lambda doesn't exist on its own — it implements a functional interface: an interface with exactly one abstract method (often called the SAM, for Single Abstract Method). The annotation @FunctionalInterface is optional, but adding it tells the compiler to fail the build if anyone ever adds a second abstract method — a useful safety net.

    Lambda syntax & a functional interface
    import java.util.function.BiFunction;
    
    public class Main {
    
        // @FunctionalInterface means: exactly one abstract method.
        // The annotation is optional, but it makes the compiler
        // reject the interface if you ever add a second method.
        @FunctionalInterface
        interface Calculator {
            int operate(int a, int b);          // the single abstract method (SAM)
        }
    
        public static void main(String[] args) {
            // A lambda IS an implementation of that one method.
            // Full form: parameters -> body
            Calculator add = (a, b) -> a + b;        // (a,b) -> a+b
            Calculator multiply = (a, b) -> a * b;   // body is a single expression
            System.out.println("add.operate(2, 3)      = " + add.operate(2, 3));
            System.out.println("multiply.operate(2, 3) = " + multiply.operate(2, 3));
    
            // The compiler INFERS the parameter types from the interface,
            // so you write (a, b) not (int a, int b).
            Calculator subtract = (a, b) -> {        // braces = a block body
                int result = a - b;                  // multiple statements need {}
                return result;                       // ...and an explicit return
            };
            System.out.println("subtract.operate(7, 4) = " + subtract.operate(7, 4));
    
            // A built-in interface with the SAME shape: BiFunction<T,U,R>.
            BiFunction<Integer, Integer, Integer> power = (base, exp) -> {
                int r = 1;
                for (int i = 0; i < exp; i++) r *= base;
                return r;
            };
            System.out.println("power.apply(2, 10)     = " + power.apply(2, 10));
        }
    }
    Output
    add.operate(2, 3)      = 5
    multiply.operate(2, 3) = 6
    subtract.operate(7, 4) = 3
    power.apply(2, 10)     = 1024
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    2️⃣ The java.util.function Toolkit

    You rarely need to write your own functional interface. Java ships with a small set in java.util.function that covers almost every shape of "a bit of behaviour". Learn these five and you can read most modern Java:

    InterfaceShapeMethodUse it for
    Function<T,R>T -> RapplyTransform a value
    Consumer<T>T -> voidacceptDo something (e.g. print)
    Supplier<T>() -> TgetProduce a value
    Predicate<T>T -> booleantestA yes/no test (filtering)
    BiFunction<T,U,R>(T, U) -> RapplyCombine two inputs

    Notice the method name changes per interface: you call .apply() on a Function, .test() on a Predicate, .accept() on a Consumer, and .get() on a Supplier. The example below uses all five.

    Function, Consumer, Supplier, Predicate, BiFunction
    import java.util.List;
    import java.util.function.Function;
    import java.util.function.Consumer;
    import java.util.function.Supplier;
    import java.util.function.Predicate;
    import java.util.function.BiFunction;
    
    public class Main {
        public static void main(String[] args) {
            // Function<T,R>:  T  ->  R   (transform one value into another)
            Function<String, Integer> length = s -> s.length();
            System.out.println("Function  length(\"hello\") = " + length.apply("hello"));
    
            // Consumer<T>:    T  ->  void (do something, return nothing)
            Consumer<String> shout = s -> System.out.println("Consumer  -> " + s.toUpperCase());
            shout.accept("ready");
    
            // Supplier<T>:    ()  ->  T   (produce a value from nothing)
            Supplier<String> greeter = () -> "Supplier -> Hello!";
            System.out.println(greeter.get());
    
            // Predicate<T>:   T  ->  boolean (a yes/no test)
            Predicate<Integer> isEven = n -> n % 2 == 0;
            System.out.println("Predicate isEven(4) = " + isEven.test(4));
            System.out.println("Predicate isEven(7) = " + isEven.test(7));
    
            // BiFunction<T,U,R>: (T, U) -> R  (two inputs, one result)
            BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
            System.out.println("BiFunction add(3, 4) = " + add.apply(3, 4));
    
            // forEach takes a Consumer — pass a lambda straight in.
            System.out.println("forEach with a Consumer:");
            List.of("Apple", "Banana", "Cherry")
                .forEach(item -> System.out.println("  - " + item));
        }
    }
    Output
    Function  length("hello") = 5
    Consumer  -> READY
    Supplier -> Hello!
    Predicate isEven(4) = true
    Predicate isEven(7) = false
    BiFunction add(3, 4) = 7
    forEach with a Consumer:
      - Apple
      - Banana
      - Cherry
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    🎯 Your Turn #1 — Write Two Lambdas

    Fill in the two blanks so the Predicate and the Function behave correctly. Run it and check your output against the expected lines in the comments.

    Your Turn #1: lambdas for functional interfaces
    import java.util.function.Predicate;
    import java.util.function.Function;
    
    public class Main {
        public static void main(String[] args) {
            // 🎯 YOUR TURN — fill in the blanks marked with ___
    
            // 1) A Predicate<Integer> that is true when a number is positive (> 0)
            Predicate<Integer> isPositive = n -> ___;   // 👉 the test: n is greater than 0
    
            // 2) A Function<String, Integer> that returns the text's length
            Function<String, Integer> wordLength = ___;  // 👉 lambda: s -> s.length()
    
            System.out.println("isPositive(5)  = " + isPositive.test(5));
            System.out.println("isPositive(-2) = " + isPositive.test(-2));
            System.out.println("wordLength(\"lambda\") = " + wordLength.apply("lambda"));
    
            // ✅ Expected output:
            // isPositive(5)  = true
            // isPositive(-2) = false
            // wordLength("lambda") = 6
        }
    }
    Output
    // Run it and compare with the Expected output in the comments.
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    3️⃣ Method References & Capturing Variables

    When a lambda does nothing but call one existing method, you can replace it with a method reference using ::. It's shorter and often clearer. There are four kinds:

    KindReferenceEquivalent lambda
    Static methodMath::absx -> Math.abs(x)
    Instance on a classString::toUpperCases -> s.toUpperCase()
    Instance on an objectSystem.out::printlnx -> System.out.println(x)
    ConstructorArrayList::new() -> new ArrayList<>()

    Lambdas can also capture local variables from the surrounding method — but only ones that are effectively final: assigned exactly once and never changed afterwards. You don't have to write the final keyword; the variable just has to behave as if it were final.

    Four kinds of method reference + capture
    import java.util.ArrayList;
    import java.util.List;
    import java.util.function.Function;
    import java.util.function.Supplier;
    
    public class Main {
        static String shout(String s) { return s.toUpperCase() + "!"; }  // static method
    
        public static void main(String[] args) {
            List<String> names = List.of("ada", "alan", "grace");
    
            // 1) Class::staticMethod  — a reference to a static method.
            names.stream().map(Main::shout).forEach(System.out::println);
            //                  ^^^^^^^^^^                  ^^^^^^^^^^^^^^^^^^
            //                  static ref                  obj::instanceMethod ref
    
            // 2) Class::instanceMethod — called ON each stream element.
            //    String::toUpperCase means "x -> x.toUpperCase()".
            List<String> upper = names.stream().map(String::toUpperCase).toList();
            System.out.println("Class::instanceMethod -> " + upper);
    
            // 3) obj::instanceMethod — bound to one specific object.
            String prefix = "Hi ";                 // captured: effectively final
            Function<String, String> greet = prefix::concat;   // x -> prefix.concat(x)
            System.out.println("obj::instanceMethod   -> " + greet.apply("Ada"));
    
            // 4) Class::new — a reference to a constructor.
            Supplier<List<String>> maker = ArrayList::new;     // () -> new ArrayList<>()
            List<String> fresh = maker.get();
            fresh.add("built by Class::new");
            System.out.println("Class::new            -> " + fresh);
        }
    }
    Output
    ADA!
    ALAN!
    GRACE!
    Class::instanceMethod -> [ADA, ALAN, GRACE]
    obj::instanceMethod   -> Hi Ada
    Class::new            -> [built by Class::new]
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    🎯 Your Turn #2 — Method References

    Replace the lambdas with the two method references described in the comments. Method references read more cleanly than the equivalent x -> ... lambda.

    Your Turn #2: use :: instead of a lambda
    import java.util.List;
    
    public class Main {
        public static void main(String[] args) {
            // 🎯 YOUR TURN — fill in the blanks marked with ___
    
            List<String> fruits = List.of("apple", "banana", "cherry");
    
            // 1) Print each fruit using a METHOD REFERENCE to System.out.println,
            //    not a lambda. Shape: System.out::println
            fruits.forEach(___);            // 👉 obj::instanceMethod — System.out::println
    
            // 2) Map each fruit to UPPER CASE with a Class::instanceMethod reference.
            List<String> shouted = fruits.stream()
                                         .map(___)   // 👉 String::toUpperCase
                                         .toList();
            System.out.println(shouted);
    
            // ✅ Expected output:
            // apple
            // banana
            // cherry
            // [APPLE, BANANA, CHERRY]
        }
    }
    Output
    // Run it and compare with the Expected output in the comments.
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    4️⃣ Lambdas vs Anonymous Classes

    Before lambdas, you implemented a single-method interface with an anonymous class — many lines of ceremony for one line of logic. A lambda collapses all of that:

    Anonymous class (verbose)

    Runnable r = new Runnable() {
        @Override
        public void run() {
            System.out.println("hi");
        }
    };

    Lambda (concise)

    Runnable r = () -> System.out.println("hi");

    They are not identical, though. The differences that matter:

    • Number of methods: a lambda works only for a single-method interface; an anonymous class can implement many methods or extend a class.
    • The this trap: inside an anonymous class, this means the anonymous object. Inside a lambda, this means the enclosing class instance. This catches people out constantly.
    • Under the hood: a lambda uses invokedynamic and creates no extra .class file, whereas each anonymous class generates its own.

    Rule of thumb: reach for a lambda for single-method behaviour; fall back to an anonymous class only when you need fields, multiple methods, or its own this.

    Capturing + a stream pipeline of lambdas
    import java.util.Comparator;
    import java.util.List;
    import java.util.concurrent.atomic.AtomicInteger;
    
    public class Main {
        record Product(String name, int price, double rating) {}
    
        public static void main(String[] args) {
            // A plain int can't be mutated inside a lambda — it must stay
            // effectively final. For a counter, use AtomicInteger.
            AtomicInteger seen = new AtomicInteger(0);
    
            List<Product> products = List.of(
                new Product("Laptop", 999, 4.5),
                new Product("Phone", 699, 4.8),
                new Product("Tablet", 399, 4.2),
                new Product("Watch", 299, 4.6),
                new Product("Headphones", 149, 4.9)
            );
    
            // budget is CAPTURED by the filter lambda (effectively final).
            int budget = 500;
    
            // A pipeline of lambdas + method references, reading top to bottom.
            List<String> picks = products.stream()
                .peek(p -> seen.incrementAndGet())              // Consumer: count items seen
                .filter(p -> p.price() < budget)                // Predicate: captures budget
                .sorted(Comparator.comparingDouble(Product::rating).reversed())
                .map(p -> p.name() + " (" + p.rating() + ")")   // Function: Product -> String
                .toList();
    
            System.out.println("Products seen: " + seen.get());
            System.out.println("Affordable, best first: " + picks);
        }
    }
    Output
    Products seen: 5
    Affordable, best first: [Headphones (4.9), Watch (4.6), Tablet (4.2)]
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    🧩 Mini-Challenge — Capture & Filter

    No fill-in-the-blanks this time — just a comment outline. Build a Predicate that captures a threshold and use it to filter a stream. The expected output is in the comments so you can self-check.

    Mini-Challenge: filter with a captured threshold
    import java.util.List;
    import java.util.function.Predicate;
    
    public class Main {
        public static void main(String[] args) {
            // 🎯 MINI-CHALLENGE: filter a list with a captured threshold
            // 1. Make an int "minLen" = 5 (it must stay effectively final — assign once)
            // 2. Make a Predicate<String> "longEnough" that is true when a word's
            //    length is >= minLen  (the lambda CAPTURES minLen)
            // 3. Stream List.of("hi", "hello", "world", "ok", "lambda"),
            //    filter with longEnough, and print the survivors with .toList()
            //
            // ✅ Expected output: [hello, world, lambda]
    
            // your code here
        }
    }
    Output
    // Expected output: [hello, world, lambda]
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    Common Errors

    • Mutating a captured variable: int count = 0; list.forEach(x -> count++); won't compile — error: "local variables referenced from a lambda expression must be final or effectively final". Use AtomicInteger counter = new AtomicInteger(); and counter.incrementAndGet(), or a one-element array int[] count = {0};.
    • Expecting lambda this to behave like an anonymous class: inside a lambda, this refers to the enclosing object, not the lambda. Code ported from an anonymous class that relied on this meaning "this handler" will silently point somewhere else. If you truly need the inner this, keep the anonymous class.
    • Overusing lambdas: a lambda longer than ~3 lines, or one you copy-paste, belongs in a named method. .map(p -> { /* 12 lines */ }) is unreadable — extract it and use a method reference like .map(this::scoreProduct) instead.
    • Forgetting it's a single-method interface: assigning a lambda to a type that isn't a functional interface gives "incompatible types: ... is not a functional interface". The target must have exactly one abstract method.
    • Block body without a return: Function<Integer,Integer> f = x -> { x * 2; }; fails — once you use braces you must write return x * 2;. Drop the braces to auto-return: x -> x * 2.

    Pro Tips

    💡 Prefer method references when a lambda just forwards to a method: Object::toString beats x -> x.toString() for readability.

    💡 Compose predicates for readable filters: isAdult.and(isActive) and isEven.negate() read like English and stay reusable.

    💡 Name your behaviour by extracting a long lambda into a method — your stream pipeline then reads as a list of verbs.

    📋 Quick Reference

    ConceptSyntaxNotes
    Lambda (expression)(a, b) -> a + bAuto-returns the expression
    Lambda (block)x -> { return x*2; }Braces need explicit return
    Functional interface@FunctionalInterfaceExactly one abstract method
    Static refMath::absx -> Math.abs(x)
    Class instance refString::toUpperCases -> s.toUpperCase()
    Object refSystem.out::printlnBound to one object
    Constructor refArrayList::new() -> new ArrayList<>()
    Capturemust be effectively finalAssign once; never reassign

    ❓ Frequently Asked Questions

    What is a lambda expression in Java?

    A lambda is a short, anonymous block of code you can pass around like a value. It implements a functional interface — an interface with exactly one abstract method — using the syntax parameters -> body, for example (a, b) -> a + b. Introduced in Java 8, lambdas replace verbose anonymous inner classes when you just need to pass behaviour to a method.

    What is a functional interface and what does @FunctionalInterface do?

    A functional interface is any interface with exactly one abstract method (a SAM — Single Abstract Method). That single method is what a lambda implements. The @FunctionalInterface annotation is optional documentation: it makes the compiler reject the interface if you accidentally add a second abstract method, so it guards against breaking lambda compatibility.

    What is the difference between Function, Consumer, Supplier, and Predicate?

    They are the core interfaces in java.util.function, each a different shape. Function<T,R> takes a T and returns an R (a transform). Consumer<T> takes a T and returns nothing (a side effect, like printing). Supplier<T> takes no input and returns a T (a factory). Predicate<T> takes a T and returns a boolean (a test). BiFunction<T,U,R> is like Function but takes two inputs.

    What are method references and the four kinds?

    A method reference (::) is shorthand for a lambda that only calls one existing method. The four kinds are: static (Math::abs, i.e. x -> Math.abs(x)), bound instance on a specific object (System.out::println), unbound instance on the class (String::toUpperCase, i.e. s -> s.toUpperCase()), and constructor (ArrayList::new, i.e. () -> new ArrayList<>()). Prefer them when a lambda just delegates.

    Why must captured variables be effectively final?

    A lambda may outlive the method that created it and even run on another thread, so Java captures a copy of each local variable rather than sharing it. To keep the lambda and the surrounding code consistent, captured locals must be effectively final — assigned exactly once and never reassigned. If you need a mutable counter, wrap it in an AtomicInteger or a one-element array.

    What is the difference between a lambda and an anonymous class?

    A lambda can only implement a single-method interface and is more concise, while an anonymous class can extend classes, implement multi-method interfaces, and hold fields. The biggest trap is this: inside an anonymous class, this refers to the anonymous object itself; inside a lambda, this refers to the enclosing class instance. Lambdas also avoid creating a new .class file per use.

    🎉 Lesson Complete!

    You can now write lambdas, recognise the five core functional interfaces, swap in method references, capture variables safely, and explain how a lambda differs from an anonymous class — including the this trap.

    Next up: Optionals — combine these lambda skills with Optional to eliminate null pointer exceptions for good.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service