Lambda Expression

1. What Changed in Java 8?

Before Java 8, Java was a purely object-oriented language. Every piece of behavior — sorting a list, filtering a collection, handling a button click — had to be wrapped inside a class. If you wanted to pass a comparator to Collections.sort(), you created an anonymous inner class with five lines of boilerplate just to express one line of logic. Java 8 changed that.

Released in March 2014, Java 8 was the most significant update to the language since generics arrived in Java 5. It introduced lambda expressions, which brought functional programming capabilities to Java for the first time. This was not just a syntax convenience — it was a paradigm shift. Developers could now treat behavior as data: pass functions as arguments, return them from methods, and store them in variables.

Why this matters:

  • Eliminates boilerplate — An anonymous inner class with 5-7 lines becomes a single expression
  • Enables functional programming — Pass behavior (not just data) as method arguments
  • Powers the Stream API — filter, map, reduce, collect all depend on lambdas
  • Improves readability — Code reads closer to what it does rather than how it is wired up
  • Encourages immutability — Functional style promotes working with values rather than mutating state

The three pillars of Java 8’s functional programming support work together:

Feature Purpose Example
Lambda Expressions Define inline anonymous functions (a, b) -> a + b
Functional Interfaces Provide the type system for lambdas Predicate<T>, Function<T,R>
Method References Shorthand for lambdas that call existing methods String::toUpperCase

Here is the before-and-after that summarizes the entire shift:

import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

public class Java8ParadigmShift {
    public static void main(String[] args) {

        // ===== BEFORE Java 8: Anonymous inner class =====
        List names = Arrays.asList("Charlie", "Alice", "Bob");
        Collections.sort(names, new Comparator() {
            @Override
            public int compare(String a, String b) {
                return a.compareTo(b);
            }
        });
        System.out.println("Before Java 8: " + names);
        // Output: Before Java 8: [Alice, Bob, Charlie]

        // ===== Java 8: Lambda expression =====
        List names2 = Arrays.asList("Charlie", "Alice", "Bob");
        Collections.sort(names2, (a, b) -> a.compareTo(b));
        System.out.println("Lambda: " + names2);
        // Output: Lambda: [Alice, Bob, Charlie]

        // ===== Java 8: Method reference (even shorter) =====
        List names3 = Arrays.asList("Charlie", "Alice", "Bob");
        names3.sort(String::compareTo);
        System.out.println("Method ref: " + names3);
        // Output: Method ref: [Alice, Bob, Charlie]

        // ===== Java 8: List.sort() replaces Collections.sort() =====
        List names4 = Arrays.asList("Charlie", "Alice", "Bob");
        names4.sort(Comparator.naturalOrder());
        System.out.println("Comparator factory: " + names4);
        // Output: Comparator factory: [Alice, Bob, Charlie]
    }
}

Seven lines of anonymous class code reduced to a single expression. That is the power of Java 8.

2. Lambda Syntax Deep Dive

A lambda expression has three parts separated by the arrow operator ->:

(parameters) -> expression        // Single expression, implicit return
(parameters) -> { statements; }   // Block body, explicit return needed

The left side defines the input. The right side defines the output or action. The compiler infers parameter types, return type, and the functional interface from the context where the lambda is used. This is called target typing.

2.1 All Syntax Variations

Variation Syntax Example
No parameters () -> expression () -> System.out.println("Hello")
Single parameter (no parens) param -> expression name -> name.toUpperCase()
Single parameter (with parens) (param) -> expression (name) -> name.toUpperCase()
Multiple parameters (p1, p2) -> expression (a, b) -> a + b
Expression body (implicit return) (params) -> expression (x) -> x * x
Block body (explicit return) (params) -> { return expr; } (x) -> { return x * x; }
Block body (void) (params) -> { statements; } (msg) -> { System.out.println(msg); }
Explicit parameter types (Type p1, Type p2) -> expr (String a, String b) -> a.compareTo(b)
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;

public class LambdaSyntaxVariations {
    public static void main(String[] args) {

        // 1. No parameters
        Runnable greet = () -> System.out.println("Hello, Java 8!");
        greet.run();
        // Output: Hello, Java 8!

        // 2. Single parameter - parentheses optional
        Function upper = name -> name.toUpperCase();
        System.out.println(upper.apply("lambda"));
        // Output: LAMBDA

        // 3. Single parameter - with parentheses (same thing)
        Function upper2 = (name) -> name.toUpperCase();
        System.out.println(upper2.apply("expressions"));
        // Output: EXPRESSIONS

        // 4. Multiple parameters - parentheses required
        BiFunction add = (a, b) -> a + b;
        System.out.println("Sum: " + add.apply(10, 20));
        // Output: Sum: 30

        // 5. Expression body - implicit return
        Function square = x -> x * x;
        System.out.println("Square of 7: " + square.apply(7));
        // Output: Square of 7: 49

        // 6. Block body - explicit return required
        Function classify = x -> {
            if (x > 0) return "positive";
            else if (x < 0) return "negative";
            else return "zero";
        };
        System.out.println(classify.apply(-5));
        // Output: negative

        // 7. Block body - void (no return)
        Consumer logger = msg -> {
            String timestamp = java.time.LocalTime.now().toString();
            System.out.println("[" + timestamp + "] " + msg);
        };
        logger.accept("Application started");
        // Output: [14:30:22.123456] Application started

        // 8. Explicit parameter types
        BiFunction concat = (String a, String b) -> a + " " + b;
        System.out.println(concat.apply("Hello", "World"));
        // Output: Hello World
    }
}

2.2 Type Inference and Target Typing

The Java compiler determines the type of a lambda from the context in which it appears. This is called target typing. The same lambda expression can match different functional interfaces depending on where it is used:

import java.util.concurrent.Callable;
import java.util.function.Supplier;

public class TargetTyping {
    public static void main(String[] args) throws Exception {

        // Same lambda, different target types
        Supplier supplier = () -> "Hello from Supplier";
        Callable callable = () -> "Hello from Callable";

        System.out.println(supplier.get());
        // Output: Hello from Supplier

        System.out.println(callable.call());
        // Output: Hello from Callable

        // The compiler infers parameter types from context
        // No need to write (Integer a, Integer b) -> ...
        java.util.Comparator ascending = (a, b) -> a - b;
        java.util.Comparator descending = (a, b) -> b - a;

        java.util.List nums = new java.util.ArrayList<>(
            java.util.Arrays.asList(3, 1, 4, 1, 5)
        );
        nums.sort(ascending);
        System.out.println("Ascending: " + nums);
        // Output: Ascending: [1, 1, 3, 4, 5]

        nums.sort(descending);
        System.out.println("Descending: " + nums);
        // Output: Descending: [5, 4, 3, 1, 1]
    }
}

2.3 Effectively Final Variables

Lambdas can reference local variables from the enclosing scope, but those variables must be effectively final — meaning they are never reassigned after initialization. You do not need the final keyword explicitly, but the compiler enforces that the value does not change.

import java.util.function.Function;

public class EffectivelyFinal {
    public static void main(String[] args) {

        // This works - 'prefix' is effectively final
        String prefix = "Hello";
        Function greeter = name -> prefix + ", " + name + "!";
        System.out.println(greeter.apply("Alice"));
        // Output: Hello, Alice!

        // This would NOT compile:
        // String greeting = "Hi";
        // greeting = "Hello";  // reassignment makes it NOT effectively final
        // Function broken = name -> greeting + name;
        // Error: Variable used in lambda should be final or effectively final

        // Arrays and objects CAN be modified (the reference is final, not the contents)
        int[] counter = {0};
        Runnable increment = () -> counter[0]++;
        increment.run();
        increment.run();
        increment.run();
        System.out.println("Counter: " + counter[0]);
        // Output: Counter: 3
    }
}

3. Functional Interfaces

A functional interface is an interface with exactly one abstract method. This is also called a SAM (Single Abstract Method) interface. Every lambda expression in Java corresponds to a functional interface — the lambda provides the implementation of that single abstract method.

The @FunctionalInterface annotation is optional but strongly recommended. It tells the compiler to enforce the single-abstract-method rule. If someone accidentally adds a second abstract method, the compiler will produce an error immediately.

// Custom functional interface
@FunctionalInterface
public interface Transformer {
    T transform(T input);

    // Default methods are allowed - they are not abstract
    default Transformer andThen(Transformer after) {
        return input -> after.transform(this.transform(input));
    }

    // Static methods are allowed
    static  Transformer identity() {
        return input -> input;
    }

    // Methods from Object are allowed (toString, equals, hashCode)
    // They do NOT count as abstract methods
    String toString();
}

Rules for functional interfaces:

  • Exactly one abstract method
  • Can have any number of default methods
  • Can have any number of static methods
  • Can override methods from Object (toString, equals, hashCode) — these do not count
  • @FunctionalInterface is optional but recommended for compile-time safety

3.1 Java 8 Built-in Functional Interfaces

Java 8 added the java.util.function package with 43 functional interfaces. The seven most important ones cover the vast majority of use cases:

Interface Method Input Output Use Case
Predicate<T> test(T) T boolean Filtering, matching, validating
Function<T, R> apply(T) T R Transforming, mapping, converting
Consumer<T> accept(T) T void Side effects: print, log, save
Supplier<T> get() none T Factory, lazy evaluation, defaults
BiFunction<T, U, R> apply(T, U) T, U R Two-input transformation
UnaryOperator<T> apply(T) T T (same type) Modify and return same type
BinaryOperator<T> apply(T, T) T, T T (same type) Combining two values of same type

4. Each Built-in Interface in Detail

4.1 Predicate — Test a Condition

Predicate<T> takes an input and returns boolean. Think of it as a yes/no question about the input. It also provides composition methods: and(), or(), and negate() for combining predicates.

import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class PredicateExamples {
    public static void main(String[] args) {

        // Basic predicate: is the number even?
        Predicate isEven = n -> n % 2 == 0;
        System.out.println("Is 4 even? " + isEven.test(4));
        // Output: Is 4 even? true
        System.out.println("Is 7 even? " + isEven.test(7));
        // Output: Is 7 even? false

        // Composing predicates with and(), or(), negate()
        Predicate isPositive = n -> n > 0;
        Predicate isEvenAndPositive = isEven.and(isPositive);
        Predicate isEvenOrPositive = isEven.or(isPositive);
        Predicate isOdd = isEven.negate();

        System.out.println("Is -4 even AND positive? " + isEvenAndPositive.test(-4));
        // Output: Is -4 even AND positive? false
        System.out.println("Is -4 even OR positive? " + isEvenOrPositive.test(-4));
        // Output: Is -4 even OR positive? true
        System.out.println("Is 7 odd? " + isOdd.test(7));
        // Output: Is 7 odd? true

        // Practical: filtering a list with composed predicates
        List numbers = Arrays.asList(-5, -2, 0, 3, 4, 7, 10, -8);
        List evenAndPositive = numbers.stream()
                .filter(isEven.and(isPositive))
                .collect(Collectors.toList());
        System.out.println("Even and positive: " + evenAndPositive);
        // Output: Even and positive: [4, 10]

        // String predicate: validation
        Predicate isNotEmpty = s -> s != null && !s.isEmpty();
        Predicate isEmail = s -> s.contains("@") && s.contains(".");
        Predicate isValidEmail = isNotEmpty.and(isEmail);

        System.out.println("Valid email? " + isValidEmail.test("user@example.com"));
        // Output: Valid email? true
        System.out.println("Valid email? " + isValidEmail.test("not-an-email"));
        // Output: Valid email? false

        // Predicate.isEqual() - static factory method
        Predicate isAdmin = Predicate.isEqual("ADMIN");
        System.out.println("Is ADMIN? " + isAdmin.test("ADMIN"));
        // Output: Is ADMIN? true
        System.out.println("Is USER? " + isAdmin.test("USER"));
        // Output: Is USER? false
    }
}

4.2 Function — Transform Input to Output

Function<T, R> takes one input of type T and produces an output of type R. It is the most general-purpose functional interface. The composition methods andThen() and compose() let you chain transformations into a pipeline.

import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;

public class FunctionExamples {
    public static void main(String[] args) {

        // Basic function: string to its length
        Function length = String::length;
        System.out.println("Length of 'Lambda': " + length.apply("Lambda"));
        // Output: Length of 'Lambda': 6

        // andThen: apply this function, THEN apply the next
        Function trim = String::trim;
        Function toUpper = String::toUpperCase;
        Function cleanAndUpperCase = trim.andThen(toUpper);

        System.out.println(cleanAndUpperCase.apply("  hello world  "));
        // Output: HELLO WORLD

        // compose: apply the OTHER function first, THEN this one
        // compose is the reverse order of andThen
        Function doubleIt = x -> x * 2;
        Function addTen = x -> x + 10;

        // doubleIt.andThen(addTen) means: double first, then add 10
        System.out.println("Double then add 10: " + doubleIt.andThen(addTen).apply(5));
        // Output: Double then add 10: 20   (5*2=10, 10+10=20)

        // doubleIt.compose(addTen) means: add 10 first, then double
        System.out.println("Add 10 then double: " + doubleIt.compose(addTen).apply(5));
        // Output: Add 10 then double: 30   (5+10=15, 15*2=30)

        // Function.identity() - returns input unchanged
        Function identity = Function.identity();
        System.out.println(identity.apply("unchanged"));
        // Output: unchanged

        // Practical: build a transformation pipeline
        Function pipeline = ((Function) String::trim)
                .andThen(String::toLowerCase)
                .andThen(s -> s.replaceAll("\\s+", "-"))
                .andThen(s -> s.replaceAll("[^a-z0-9-]", ""));

        System.out.println(pipeline.apply("  Hello World! Java 8  "));
        // Output: hello-world-java-8

        // Using Function with streams
        List names = Arrays.asList("alice", "bob", "charlie");
        Function capitalize = s -> s.substring(0, 1).toUpperCase() + s.substring(1);
        List capitalized = names.stream()
                .map(capitalize)
                .collect(Collectors.toList());
        System.out.println(capitalized);
        // Output: [Alice, Bob, Charlie]
    }
}

4.3 Consumer — Perform an Action

Consumer<T> takes an input and returns nothing (void). It represents a side effect — printing, logging, saving to a database, sending a notification. The andThen() method chains multiple consumers.

import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;

public class ConsumerExamples {
    public static void main(String[] args) {

        // Basic consumer: print a value
        Consumer printer = System.out::println;
        printer.accept("Hello from Consumer!");
        // Output: Hello from Consumer!

        // Chaining consumers with andThen
        Consumer log = msg -> System.out.println("[LOG] " + msg);
        Consumer save = msg -> System.out.println("[SAVE] Persisting: " + msg);
        Consumer logAndSave = log.andThen(save);

        logAndSave.accept("User signed up");
        // Output: [LOG] User signed up
        // Output: [SAVE] Persisting: User signed up

        // Practical: process a list of users
        List users = Arrays.asList("Alice", "Bob", "Charlie");

        Consumer welcome = name -> System.out.println("Welcome, " + name + "!");
        Consumer assignRole = name -> System.out.println("Assigning default role to " + name);
        Consumer onboardUser = welcome.andThen(assignRole);

        System.out.println("--- Onboarding users ---");
        users.forEach(onboardUser);
        // Output:
        // --- Onboarding users ---
        // Welcome, Alice!
        // Assigning default role to Alice
        // Welcome, Bob!
        // Assigning default role to Bob
        // Welcome, Charlie!
        // Assigning default role to Charlie

        // BiConsumer: two inputs
        java.util.Map scores = new java.util.HashMap<>();
        scores.put("Alice", 95);
        scores.put("Bob", 87);
        scores.put("Charlie", 92);

        scores.forEach((name, score) ->
            System.out.println(name + " scored " + score)
        );
        // Output: Alice scored 95
        // Output: Bob scored 87
        // Output: Charlie scored 92
    }
}

4.4 Supplier — Provide a Value

Supplier<T> takes no input and returns a value. It is a factory or generator. Common uses include providing default values, lazy initialization, and deferred computation.

import java.util.Random;
import java.util.function.Supplier;
import java.util.stream.Stream;

public class SupplierExamples {
    public static void main(String[] args) {

        // Basic supplier: return a constant
        Supplier greeting = () -> "Hello, World!";
        System.out.println(greeting.get());
        // Output: Hello, World!

        // Supplier as a factory
        Supplier sbFactory = StringBuilder::new;
        StringBuilder sb1 = sbFactory.get();
        StringBuilder sb2 = sbFactory.get();
        sb1.append("First");
        sb2.append("Second");
        System.out.println(sb1 + ", " + sb2);
        // Output: First, Second

        // Lazy evaluation with Supplier
        Supplier randomValue = () -> Math.random();
        // Nothing computed yet - only computed when get() is called
        System.out.println("Random 1: " + randomValue.get());
        System.out.println("Random 2: " + randomValue.get());
        // Output: Random 1: 0.7234... (varies)
        // Output: Random 2: 0.1456... (varies)

        // Generate infinite stream from Supplier
        Supplier diceRoll = () -> new Random().nextInt(6) + 1;
        System.out.print("Five dice rolls: ");
        Stream.generate(diceRoll)
              .limit(5)
              .forEach(n -> System.out.print(n + " "));
        System.out.println();
        // Output: Five dice rolls: 3 1 5 2 6 (varies)

        // Practical: providing default values
        String configValue = null;
        Supplier defaultConfig = () -> "localhost:8080";
        String result = configValue != null ? configValue : defaultConfig.get();
        System.out.println("Config: " + result);
        // Output: Config: localhost:8080
    }
}

4.5 BiFunction — Two Inputs, One Output

BiFunction<T, U, R> takes two inputs and produces one output. It is useful when a transformation needs two pieces of data. It has andThen() but no compose() (since compose would need a function that returns two values, which Java does not support).

import java.util.function.BiFunction;

public class BiFunctionExamples {
    public static void main(String[] args) {

        // Basic: combine two values
        BiFunction fullName =
            (first, last) -> first + " " + last;
        System.out.println(fullName.apply("John", "Doe"));
        // Output: John Doe

        // Math operation
        BiFunction power =
            (base, exponent) -> Math.pow(base, exponent);
        System.out.println("2^10 = " + power.apply(2, 10));
        // Output: 2^10 = 1024.0

        // andThen: transform the result
        BiFunction greet =
            (greeting, name) -> greeting + ", " + name;
        String result = greet.andThen(String::toUpperCase).apply("hello", "world");
        System.out.println(result);
        // Output: HELLO, WORLD

        // Practical: calculate discounted price
        BiFunction priceWithDiscount =
            (price, discountPercent) -> {
                double discounted = price * (1 - discountPercent / 100);
                return String.format("$%.2f (%.0f%% off $%.2f)",
                    discounted, discountPercent, price);
            };

        System.out.println(priceWithDiscount.apply(100.0, 25.0));
        // Output: $75.00 (25% off $100.00)
        System.out.println(priceWithDiscount.apply(49.99, 10.0));
        // Output: $44.99 (10% off $49.99)
    }
}

4.6 UnaryOperator — Same Type In, Same Type Out

UnaryOperator<T> extends Function<T, T>. It takes a value and returns a value of the same type. Use it when you are modifying a value without changing its type — formatting a string, incrementing a number, transforming a list element in place.

import java.util.Arrays;
import java.util.List;
import java.util.function.UnaryOperator;

public class UnaryOperatorExamples {
    public static void main(String[] args) {

        // Basic: increment a number
        UnaryOperator increment = x -> x + 1;
        System.out.println(increment.apply(10));
        // Output: 11

        // String transformation
        UnaryOperator addExclamation = s -> s + "!";
        UnaryOperator toUpper = String::toUpperCase;
        UnaryOperator shout = toUpper.andThen(addExclamation)::apply;

        System.out.println(shout.apply("hello"));
        // Output: HELLO!

        // Practical: List.replaceAll() uses UnaryOperator
        List names = Arrays.asList("alice", "bob", "charlie");
        names.replaceAll(String::toUpperCase);
        System.out.println(names);
        // Output: [ALICE, BOB, CHARLIE]

        // UnaryOperator.identity() - returns input unchanged
        UnaryOperator noChange = UnaryOperator.identity();
        System.out.println(noChange.apply("same"));
        // Output: same

        // Chaining: build text formatter
        UnaryOperator trim = String::trim;
        UnaryOperator lower = String::toLowerCase;
        UnaryOperator format = trim.andThen(lower)::apply;

        System.out.println(format.apply("  HELLO WORLD  "));
        // Output: hello world
    }
}

4.7 BinaryOperator — Combine Two Values of Same Type

BinaryOperator<T> extends BiFunction<T, T, T>. It takes two inputs of the same type and returns a result of that same type. It is the ideal type for reduction operations like summing, finding max/min, or merging.

import java.util.Arrays;
import java.util.List;
import java.util.function.BinaryOperator;

public class BinaryOperatorExamples {
    public static void main(String[] args) {

        // Basic: add two integers
        BinaryOperator add = (a, b) -> a + b;
        System.out.println("Sum: " + add.apply(10, 20));
        // Output: Sum: 30

        // Concatenate strings
        BinaryOperator concat = (a, b) -> a + " " + b;
        System.out.println(concat.apply("Hello", "World"));
        // Output: Hello World

        // BinaryOperator.maxBy() and minBy() - factory methods
        BinaryOperator max = BinaryOperator.maxBy(Integer::compareTo);
        BinaryOperator min = BinaryOperator.minBy(Integer::compareTo);
        System.out.println("Max of 10, 20: " + max.apply(10, 20));
        // Output: Max of 10, 20: 20
        System.out.println("Min of 10, 20: " + min.apply(10, 20));
        // Output: Min of 10, 20: 10

        // Practical: reduce a list
        List numbers = Arrays.asList(1, 2, 3, 4, 5);
        BinaryOperator sum = Integer::sum;
        int total = numbers.stream().reduce(0, sum);
        System.out.println("Total: " + total);
        // Output: Total: 15

        // Practical: find longest string
        List words = Arrays.asList("java", "lambda", "expression", "functional");
        BinaryOperator longer = BinaryOperator.maxBy(
            java.util.Comparator.comparingInt(String::length)
        );
        String longest = words.stream().reduce(longer).orElse("");
        System.out.println("Longest word: " + longest);
        // Output: Longest word: expression
    }
}

5. Lambda with Collections

Java 8 added new methods directly to the Collection framework interfaces that accept lambdas. These let you work with collections without streams, using simple method calls on the collection itself.

5.1 Iterable.forEach()

The forEach() method was added to Iterable (the parent of all collections). It takes a Consumer and executes it for each element.

import java.util.Arrays;
import java.util.List;
import java.util.HashMap;
import java.util.Map;

public class ForEachExamples {
    public static void main(String[] args) {

        // List.forEach()
        List languages = Arrays.asList("Java", "Python", "JavaScript");
        languages.forEach(lang -> System.out.println("I know " + lang));
        // Output: I know Java
        // Output: I know Python
        // Output: I know JavaScript

        // Method reference shorthand
        languages.forEach(System.out::println);
        // Output: Java
        // Output: Python
        // Output: JavaScript

        // Map.forEach() - takes BiConsumer
        Map ages = new HashMap<>();
        ages.put("Alice", 30);
        ages.put("Bob", 25);
        ages.put("Charlie", 35);

        ages.forEach((name, age) ->
            System.out.println(name + " is " + age + " years old")
        );
        // Output: Alice is 30 years old
        // Output: Bob is 25 years old
        // Output: Charlie is 35 years old
    }
}

5.2 Collection.removeIf(), List.replaceAll(), List.sort()

These methods modify the collection in place using lambda predicates and functions.

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;

public class CollectionLambdaMethods {
    public static void main(String[] args) {

        // ===== removeIf(Predicate) - remove elements that match =====
        List numbers = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10));
        numbers.removeIf(n -> n % 2 == 0);  // Remove even numbers
        System.out.println("Odds only: " + numbers);
        // Output: Odds only: [1, 3, 5, 7, 9]

        // Remove empty strings
        List items = new ArrayList<>(Arrays.asList("apple", "", "banana", "", "cherry"));
        items.removeIf(String::isEmpty);
        System.out.println("Non-empty: " + items);
        // Output: Non-empty: [apple, banana, cherry]

        // ===== replaceAll(UnaryOperator) - transform every element =====
        List names = new ArrayList<>(Arrays.asList("alice", "bob", "charlie"));
        names.replaceAll(String::toUpperCase);
        System.out.println("Uppercased: " + names);
        // Output: Uppercased: [ALICE, BOB, CHARLIE]

        List prices = new ArrayList<>(Arrays.asList(10.0, 20.0, 30.0));
        prices.replaceAll(p -> p * 1.1);  // 10% price increase
        System.out.println("After 10% increase: " + prices);
        // Output: After 10% increase: [11.0, 22.0, 33.0]

        // ===== sort(Comparator) - sort with lambda =====
        List fruits = new ArrayList<>(Arrays.asList("banana", "apple", "cherry", "date"));
        fruits.sort((a, b) -> a.compareTo(b));
        System.out.println("Alphabetical: " + fruits);
        // Output: Alphabetical: [apple, banana, cherry, date]

        // Sort by length, then alphabetically
        fruits.sort(Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()));
        System.out.println("By length: " + fruits);
        // Output: By length: [date, apple, banana, cherry]

        // Reverse sort
        fruits.sort(Comparator.reverseOrder());
        System.out.println("Reverse: " + fruits);
        // Output: Reverse: [date, cherry, banana, apple]
    }
}

5.3 Map Methods: computeIfAbsent, merge, getOrDefault

Java 8 added several powerful lambda-accepting methods to Map that simplify common patterns.

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class MapLambdaMethods {
    public static void main(String[] args) {

        // ===== computeIfAbsent(key, mappingFunction) =====
        // If the key is absent, compute and insert the value
        Map> groups = new HashMap<>();

        // BEFORE Java 8:
        // if (!groups.containsKey("fruits")) {
        //     groups.put("fruits", new ArrayList<>());
        // }
        // groups.get("fruits").add("apple");

        // AFTER Java 8:
        groups.computeIfAbsent("fruits", k -> new ArrayList<>()).add("apple");
        groups.computeIfAbsent("fruits", k -> new ArrayList<>()).add("banana");
        groups.computeIfAbsent("vegetables", k -> new ArrayList<>()).add("carrot");
        System.out.println(groups);
        // Output: {fruits=[apple, banana], vegetables=[carrot]}

        // ===== merge(key, value, remappingFunction) =====
        // Combine old and new values
        Map wordCount = new HashMap<>();
        String[] words = {"hello", "world", "hello", "java", "hello", "world"};

        for (String word : words) {
            wordCount.merge(word, 1, Integer::sum);
        }
        System.out.println("Word counts: " + wordCount);
        // Output: Word counts: {java=1, world=2, hello=3}

        // ===== getOrDefault(key, defaultValue) =====
        Map config = new HashMap<>();
        config.put("host", "localhost");
        config.put("port", "8080");

        String host = config.getOrDefault("host", "127.0.0.1");
        String timeout = config.getOrDefault("timeout", "30");
        System.out.println("Host: " + host + ", Timeout: " + timeout);
        // Output: Host: localhost, Timeout: 30

        // ===== replaceAll(BiFunction) =====
        Map scores = new HashMap<>();
        scores.put("Alice", 85);
        scores.put("Bob", 92);
        scores.put("Charlie", 78);

        // Add 5 bonus points to everyone
        scores.replaceAll((name, score) -> score + 5);
        System.out.println("After bonus: " + scores);
        // Output: After bonus: {Alice=90, Bob=97, Charlie=83}

        // ===== compute(key, BiFunction) =====
        Map inventory = new HashMap<>();
        inventory.put("apples", 10);
        inventory.compute("apples", (key, val) -> val == null ? 1 : val + 5);
        inventory.compute("oranges", (key, val) -> val == null ? 1 : val + 5);
        System.out.println("Inventory: " + inventory);
        // Output: Inventory: {oranges=1, apples=15}
    }
}

6. Lambda Scope and Variable Capture

Lambdas have access to variables from the enclosing scope. Understanding exactly what they can and cannot access is critical for writing correct lambda code.

6.1 What Lambdas Can Access

Variable Type Can Access? Can Modify? Notes
Local variables Yes No Must be effectively final
Method parameters Yes No Must be effectively final
Instance fields (this.field) Yes Yes Captured via this reference
Static fields Yes Yes Accessed directly
import java.util.function.IntSupplier;
import java.util.function.Supplier;

public class LambdaScope {

    private int instanceCounter = 0;
    private static int staticCounter = 0;

    public void demonstrateScope() {
        int localVar = 10;  // effectively final - never reassigned

        // 1. Accessing local variable (must be effectively final)
        Supplier getLocal = () -> localVar;
        System.out.println("Local: " + getLocal.get());
        // Output: Local: 10

        // This would NOT compile:
        // localVar = 20;  // Makes localVar not effectively final
        // Supplier broken = () -> localVar;

        // 2. Accessing and MODIFYING instance fields - allowed
        Runnable incrementInstance = () -> instanceCounter++;
        incrementInstance.run();
        incrementInstance.run();
        System.out.println("Instance counter: " + instanceCounter);
        // Output: Instance counter: 2

        // 3. Accessing and MODIFYING static fields - allowed
        Runnable incrementStatic = () -> staticCounter++;
        incrementStatic.run();
        incrementStatic.run();
        incrementStatic.run();
        System.out.println("Static counter: " + staticCounter);
        // Output: Static counter: 3
    }

    public static void main(String[] args) {
        new LambdaScope().demonstrateScope();
    }
}

6.2 The ‘this’ Keyword in Lambdas

A critical difference between lambdas and anonymous classes is what this refers to. In a lambda, this refers to the enclosing class. In an anonymous class, this refers to the anonymous class itself.

public class ThisInLambda {

    private String name = "Enclosing class";

    public void demonstrate() {

        // Lambda: 'this' refers to ThisInLambda instance
        Runnable lambdaRunnable = () -> {
            System.out.println("Lambda this: " + this.name);
            System.out.println("Lambda this class: " + this.getClass().getSimpleName());
        };

        // Anonymous class: 'this' refers to the anonymous class instance
        Runnable anonRunnable = new Runnable() {
            @Override
            public void run() {
                // 'this' here refers to the anonymous Runnable
                System.out.println("Anon this class: " + this.getClass().getSimpleName());
                // To access the enclosing class, use EnclosingClass.this
                System.out.println("Anon enclosing: " + ThisInLambda.this.name);
            }
        };

        lambdaRunnable.run();
        // Output: Lambda this: Enclosing class
        // Output: Lambda this class: ThisInLambda

        anonRunnable.run();
        // Output: Anon this class: ThisInLambda$1
        // Output: Anon enclosing: Enclosing class
    }

    public static void main(String[] args) {
        new ThisInLambda().demonstrate();
    }
}

6.3 Workaround for the Effectively Final Restriction

If you need to accumulate state inside a lambda, you cannot use a plain int or String variable. Instead, use a mutable container like an array, AtomicInteger, or a custom holder object.

import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public class EffectivelyFinalWorkarounds {
    public static void main(String[] args) {
        List names = Arrays.asList("Alice", "Bob", "Charlie");

        // Approach 1: Single-element array
        int[] count1 = {0};
        names.forEach(name -> count1[0]++);
        System.out.println("Array counter: " + count1[0]);
        // Output: Array counter: 3

        // Approach 2: AtomicInteger (preferred, thread-safe)
        AtomicInteger count2 = new AtomicInteger(0);
        names.forEach(name -> count2.incrementAndGet());
        System.out.println("Atomic counter: " + count2.get());
        // Output: Atomic counter: 3

        // Approach 3: Use streams instead (cleanest solution)
        long count3 = names.stream().count();
        System.out.println("Stream count: " + count3);
        // Output: Stream count: 3

        // Approach 4: StringBuilder for string accumulation
        StringBuilder sb = new StringBuilder();
        names.forEach(name -> sb.append(name).append(", "));
        System.out.println("Accumulated: " + sb.toString());
        // Output: Accumulated: Alice, Bob, Charlie,
    }
}

7. Lambda vs Anonymous Classes

Lambdas and anonymous classes both create implementations of interfaces on the fly. However, they differ in important ways beyond just syntax.

7.1 Comparison Table

Feature Anonymous Class Lambda Expression
Syntax Verbose (class + method) Concise (arrow notation)
Can implement Any interface (including multi-method) or extend a class Only functional interfaces (single abstract method)
this keyword Refers to the anonymous class instance Refers to the enclosing class instance
Has own state Yes (can have fields) No (stateless, captures from enclosing scope)
Compiled to Separate .class file (Outer$1.class) invokedynamic bytecode (no extra class file)
Performance New class loaded per instantiation Lightweight, JVM can optimize via method handles
Serializable? Yes (if implements Serializable) Only with explicit cast
Shadowing Can shadow enclosing scope variables Cannot shadow (shares enclosing scope)

7.2 Migration Examples

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class AnonClassToLambda {
    public static void main(String[] args) {

        // ===== Example 1: Runnable =====
        // Anonymous class
        Runnable oldWay = new Runnable() {
            @Override
            public void run() {
                System.out.println("Running the old way");
            }
        };
        // Lambda
        Runnable newWay = () -> System.out.println("Running the new way");

        oldWay.run();  // Output: Running the old way
        newWay.run();  // Output: Running the new way

        // ===== Example 2: Comparator =====
        List names = new ArrayList<>();
        names.add("Charlie");
        names.add("Alice");
        names.add("Bob");

        // Anonymous class
        Collections.sort(names, new java.util.Comparator() {
            @Override
            public int compare(String a, String b) {
                return a.length() - b.length();
            }
        });
        System.out.println("Anonymous: " + names);
        // Output: Anonymous: [Bob, Alice, Charlie]

        // Lambda
        Collections.sort(names, (a, b) -> a.length() - b.length());
        System.out.println("Lambda: " + names);
        // Output: Lambda: [Bob, Alice, Charlie]

        // Method reference + Comparator factory (cleanest)
        names.sort(java.util.Comparator.comparingInt(String::length));
        System.out.println("Factory: " + names);
        // Output: Factory: [Bob, Alice, Charlie]

        // ===== Example 3: ActionListener (event handling) =====
        // Anonymous class (Swing)
        // button.addActionListener(new ActionListener() {
        //     @Override
        //     public void actionPerformed(ActionEvent e) {
        //         System.out.println("Button clicked!");
        //     }
        // });

        // Lambda
        // button.addActionListener(e -> System.out.println("Button clicked!"));

        // ===== Example 4: Thread =====
        // Anonymous class
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("Thread via anonymous class: " + Thread.currentThread().getName());
            }
        });
        // Lambda
        Thread t2 = new Thread(() ->
            System.out.println("Thread via lambda: " + Thread.currentThread().getName())
        );

        t1.start();
        t2.start();
        // Output: Thread via anonymous class: Thread-0
        // Output: Thread via lambda: Thread-1
    }
}

7.3 When You Still Need Anonymous Classes

Lambdas cannot replace anonymous classes in every situation. Use an anonymous class when:

  • The interface has more than one abstract method (not a functional interface)
  • You need to extend a class (not implement an interface)
  • You need the anonymous instance to have its own state (fields)
  • You need this to refer to the anonymous instance itself

8. Common Patterns with Lambdas

8.1 Callbacks

Lambdas make the callback pattern clean and readable. Instead of creating a named class for each callback, define the behavior inline.

import java.util.function.Consumer;

public class CallbackPattern {

    // Method that accepts a callback
    static void fetchData(String url, Consumer onSuccess, Consumer onError) {
        try {
            // Simulate fetching data
            if (url.startsWith("http")) {
                String data = "{\"status\": \"ok\", \"source\": \"" + url + "\"}";
                onSuccess.accept(data);
            } else {
                throw new IllegalArgumentException("Invalid URL: " + url);
            }
        } catch (Exception e) {
            onError.accept(e.getMessage());
        }
    }

    public static void main(String[] args) {

        // Pass lambdas as callbacks
        fetchData("http://api.example.com",
            data -> System.out.println("Success: " + data),
            error -> System.out.println("Error: " + error)
        );
        // Output: Success: {"status": "ok", "source": "http://api.example.com"}

        fetchData("invalid-url",
            data -> System.out.println("Success: " + data),
            error -> System.out.println("Error: " + error)
        );
        // Output: Error: Invalid URL: invalid-url
    }
}

8.2 Strategy Pattern

The Strategy pattern defines a family of algorithms and lets you swap them at runtime. Before Java 8, each strategy was a separate class. With lambdas, you define strategies inline.

import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class StrategyPattern {

    // The "strategy" is just a Predicate
    static  List filter(List items, Predicate strategy) {
        return items.stream().filter(strategy).collect(Collectors.toList());
    }

    public static void main(String[] args) {
        List numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // Different strategies passed as lambdas
        List evens = filter(numbers, n -> n % 2 == 0);
        List odds = filter(numbers, n -> n % 2 != 0);
        List greaterThan5 = filter(numbers, n -> n > 5);

        System.out.println("Evens: " + evens);
        // Output: Evens: [2, 4, 6, 8, 10]
        System.out.println("Odds: " + odds);
        // Output: Odds: [1, 3, 5, 7, 9]
        System.out.println("Greater than 5: " + greaterThan5);
        // Output: Greater than 5: [6, 7, 8, 9, 10]

        // Compose strategies
        Predicate evenAndGreaterThan5 = ((Predicate) n -> n % 2 == 0).and(n -> n > 5);
        System.out.println("Even AND > 5: " + filter(numbers, evenAndGreaterThan5));
        // Output: Even AND > 5: [6, 8, 10]

        // Store strategies in variables for reuse
        Predicate isShort = s -> s.length() <= 4;
        Predicate startsWithJ = s -> s.startsWith("J");

        List langs = Arrays.asList("Java", "Python", "JavaScript", "Go", "C", "Rust");
        System.out.println("Short: " + filter(langs, isShort));
        // Output: Short: [Java, Go, C, Rust]
        System.out.println("Starts with J: " + filter(langs, startsWithJ));
        // Output: Starts with J: [Java, JavaScript]
    }
}

8.3 Lazy Evaluation

Lambdas enable lazy evaluation — the computation is not performed until the result is actually needed. This is useful for expensive operations where the result might not always be required.

import java.util.function.Supplier;

public class LazyEvaluation {

    static String expensiveOperation() {
        System.out.println("  [Computing expensive result...]");
        // Simulate expensive computation
        return "expensive result";
    }

    // Eager: always computes the default, even if not needed
    static String getValueEager(String value, String defaultValue) {
        return value != null ? value : defaultValue;
    }

    // Lazy: only computes default if value is null
    static String getValueLazy(String value, Supplier defaultSupplier) {
        return value != null ? value : defaultSupplier.get();
    }

    public static void main(String[] args) {
        String cached = "cached result";

        // Eager: expensiveOperation() is ALWAYS called
        System.out.println("--- Eager (value exists) ---");
        String eager = getValueEager(cached, expensiveOperation());
        System.out.println("Result: " + eager);
        // Output:
        // --- Eager (value exists) ---
        //   [Computing expensive result...]
        // Result: cached result

        // Lazy: expensiveOperation() is NOT called when value exists
        System.out.println("--- Lazy (value exists) ---");
        String lazy = getValueLazy(cached, () -> expensiveOperation());
        System.out.println("Result: " + lazy);
        // Output:
        // --- Lazy (value exists) ---
        // Result: cached result

        // Lazy: expensiveOperation() IS called when value is null
        System.out.println("--- Lazy (value is null) ---");
        String lazy2 = getValueLazy(null, () -> expensiveOperation());
        System.out.println("Result: " + lazy2);
        // Output:
        // --- Lazy (value is null) ---
        //   [Computing expensive result...]
        // Result: expensive result
    }
}

8.4 Custom Functional Interfaces

While the built-in interfaces cover most cases, sometimes you need a custom one. Common scenarios: checked exceptions, three or more parameters, or domain-specific naming.

import java.io.IOException;
import java.util.function.Function;

public class CustomFunctionalInterfaces {

    // Custom interface for operations that throw checked exceptions
    @FunctionalInterface
    interface ThrowingFunction {
        R apply(T input) throws Exception;

        // Convert to standard Function with exception wrapping
        static  Function unchecked(ThrowingFunction f) {
            return input -> {
                try {
                    return f.apply(input);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            };
        }
    }

    // Custom interface with three parameters
    @FunctionalInterface
    interface TriFunction {
        R apply(A a, B b, C c);
    }

    // Domain-specific interface
    @FunctionalInterface
    interface Validator {
        ValidationResult validate(T item);
    }

    record ValidationResult(boolean valid, String message) {
        static ValidationResult ok() { return new ValidationResult(true, "Valid"); }
        static ValidationResult error(String msg) { return new ValidationResult(false, msg); }
    }

    public static void main(String[] args) {

        // ThrowingFunction: handle checked exceptions in lambdas
        ThrowingFunction parse = Integer::parseInt;
        Function safeParse = ThrowingFunction.unchecked(parse);
        System.out.println("Parsed: " + safeParse.apply("42"));
        // Output: Parsed: 42

        // TriFunction: three inputs
        TriFunction formatUser =
            (first, last, age) -> first + " " + last + " (age " + age + ")";
        System.out.println(formatUser.apply("John", "Doe", 30));
        // Output: John Doe (age 30)

        // Domain validator
        Validator emailValidator = email -> {
            if (email == null || email.isEmpty()) {
                return ValidationResult.error("Email cannot be empty");
            }
            if (!email.contains("@")) {
                return ValidationResult.error("Email must contain @");
            }
            return ValidationResult.ok();
        };

        System.out.println(emailValidator.validate("user@example.com"));
        // Output: ValidationResult[valid=true, message=Valid]
        System.out.println(emailValidator.validate("bad-email"));
        // Output: ValidationResult[valid=false, message=Email must contain @]
    }
}

8.5 Event Listeners and Handlers

Lambdas dramatically simplify GUI and event-driven code where you previously needed verbose anonymous classes for every event handler.

import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

public class EventListenerPattern {

    // Simple event system using lambdas
    static class EventEmitter {
        private final List> listeners = new ArrayList<>();

        void on(Consumer listener) {
            listeners.add(listener);
        }

        void emit(T event) {
            listeners.forEach(listener -> listener.accept(event));
        }
    }

    public static void main(String[] args) {

        // Create an emitter for String events
        EventEmitter userEvents = new EventEmitter<>();

        // Register listeners with lambdas
        userEvents.on(event -> System.out.println("[Logger] " + event));
        userEvents.on(event -> System.out.println("[Analytics] Tracking: " + event));
        userEvents.on(event -> {
            if (event.contains("signup")) {
                System.out.println("[Email] Sending welcome email");
            }
        });

        // Emit events
        userEvents.emit("user.signup");
        // Output: [Logger] user.signup
        // Output: [Analytics] Tracking: user.signup
        // Output: [Email] Sending welcome email

        System.out.println();
        userEvents.emit("user.login");
        // Output: [Logger] user.login
        // Output: [Analytics] Tracking: user.login
    }
}

9. Best Practices

Writing good lambda code requires discipline. These best practices come from real-world Java 8+ production codebases.

9.1 Keep Lambdas Short

If a lambda exceeds 3-4 lines, extract it into a named method and use a method reference. Long lambdas defeat the readability purpose.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class KeepLambdasShort {

    // BAD: Lambda is too long -- hard to read inline
    static List processNamesBad(List names) {
        return names.stream()
                .filter(name -> {
                    if (name == null || name.isEmpty()) return false;
                    if (name.length() < 2) return false;
                    if (!Character.isUpperCase(name.charAt(0))) return false;
                    return true;
                })
                .collect(Collectors.toList());
    }

    // GOOD: Extract to a named method, use method reference
    static boolean isValidName(String name) {
        if (name == null || name.isEmpty()) return false;
        if (name.length() < 2) return false;
        if (!Character.isUpperCase(name.charAt(0))) return false;
        return true;
    }

    static List processNamesGood(List names) {
        return names.stream()
                .filter(KeepLambdasShort::isValidName)
                .collect(Collectors.toList());
    }

    public static void main(String[] args) {
        List names = Arrays.asList("Alice", "", "b", "Charlie", null, "David");
        System.out.println(processNamesGood(names));
        // Output: [Alice, Charlie, David]
    }
}

9.2 Use Method References When Possible

If your lambda simply calls an existing method, use a method reference instead. It is shorter and communicates intent more clearly.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class MethodReferencePreference {
    public static void main(String[] args) {
        List names = Arrays.asList("Alice", "Bob", "Charlie");

        // Lambda (ok, but wordy)
        names.forEach(name -> System.out.println(name));
        // Method reference (better)
        names.forEach(System.out::println);

        // Lambda
        List upper1 = names.stream()
                .map(s -> s.toUpperCase())
                .collect(Collectors.toList());
        // Method reference (better)
        List upper2 = names.stream()
                .map(String::toUpperCase)
                .collect(Collectors.toList());

        // Lambda
        List lengths1 = names.stream()
                .map(s -> s.length())
                .collect(Collectors.toList());
        // Method reference (better)
        List lengths2 = names.stream()
                .map(String::length)
                .collect(Collectors.toList());

        System.out.println(upper2);
        // Output: [ALICE, BOB, CHARLIE]
        System.out.println(lengths2);
        // Output: [5, 3, 7]
    }
}

9.3 Avoid Side Effects

Lambdas used in streams and functional-style code should be pure functions — they should produce a result based on input without modifying external state. Side effects in lambdas make code harder to reason about and can cause bugs in parallel streams.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class AvoidSideEffects {
    public static void main(String[] args) {
        List names = Arrays.asList("Alice", "Bob", "Charlie");

        // BAD: Side effect inside a lambda (modifying external list)
        List resultsBad = new java.util.ArrayList<>();
        names.stream()
                .filter(n -> n.length() > 3)
                .forEach(n -> resultsBad.add(n));  // Side effect!
        System.out.println("Bad approach: " + resultsBad);
        // Output: Bad approach: [Alice, Charlie]

        // GOOD: Pure functional style (collect result)
        List resultsGood = names.stream()
                .filter(n -> n.length() > 3)
                .collect(Collectors.toList());
        System.out.println("Good approach: " + resultsGood);
        // Output: Good approach: [Alice, Charlie]

        // BAD: Side effect modifying counter (NOT thread-safe with parallel streams)
        int[] count = {0};
        names.parallelStream().forEach(n -> count[0]++);  // Race condition!

        // GOOD: Use stream operations
        long goodCount = names.stream().count();
        System.out.println("Count: " + goodCount);
        // Output: Count: 3
    }
}

9.4 Use Meaningful Parameter Names

Single-letter parameter names are fine for simple lambdas, but use descriptive names when the context is not obvious.

import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

public class MeaningfulParamNames {
    public static void main(String[] args) {

        // OK for simple cases - single letter is fine
        List numbers = Arrays.asList(1, 2, 3, 4, 5);
        numbers.stream().filter(n -> n > 3).forEach(System.out::println);

        // BAD: unclear what a, b, c represent
        // (a, b) -> a.getLastName().compareTo(b.getLastName())

        // GOOD: descriptive names make the comparison obvious
        // (employee1, employee2) -> employee1.getLastName().compareTo(employee2.getLastName())

        // Practical example with meaningful names
        List emails = Arrays.asList(
            "alice@company.com", "bob@gmail.com",
            "charlie@company.com", "diana@yahoo.com"
        );

        // BAD: what does 'e' represent?
        List bad = emails.stream().filter(e -> e.endsWith("@company.com"))
                .collect(Collectors.toList());

        // GOOD: clear meaning
        List companyEmails = emails.stream()
                .filter(email -> email.endsWith("@company.com"))
                .collect(Collectors.toList());

        System.out.println("Company emails: " + companyEmails);
        // Output: Company emails: [alice@company.com, charlie@company.com]
    }
}

9.5 Best Practices Summary

Practice Do Don’t
Length Keep lambdas to 1-3 lines Write 10+ line lambdas inline
Method references String::toUpperCase s -> s.toUpperCase()
Side effects .collect(Collectors.toList()) .forEach(list::add)
Parameter names employee -> employee.getSalary() e -> e.getSalary() in complex contexts
Type declarations Let the compiler infer types (String s) -> s.length() when unnecessary
Parentheses x -> x * 2 for single param (x) -> x * 2 (unnecessary parens)
Return statement x -> x + 1 x -> { return x + 1; } for single expressions
Exception handling Use custom functional interfaces for checked exceptions Wrap lambdas in try/catch making them unreadable
Reusable logic Store as named Predicate/Function variables Duplicate same lambda across multiple places

10. Complete Practical Example

Let us build an Employee Analytics System that uses lambdas throughout — every concept covered in this tutorial appears in this example. This is the kind of code you would write in real production systems when processing business data.

import java.util.*;
import java.util.function.*;
import java.util.stream.Collectors;

public class EmployeeAnalytics {

    // Simple Employee record
    static class Employee {
        String name;
        String department;
        double salary;
        int yearsOfService;

        Employee(String name, String department, double salary, int yearsOfService) {
            this.name = name;
            this.department = department;
            this.salary = salary;
            this.yearsOfService = yearsOfService;
        }

        @Override
        public String toString() {
            return String.format("%s (%s, $%.0f, %dy)", name, department, salary, yearsOfService);
        }
    }

    // Custom functional interface for business rules
    @FunctionalInterface
    interface SalaryAdjuster {
        double adjust(Employee employee);
    }

    public static void main(String[] args) {

        // ===== Setup: Create employee list =====
        List employees = new ArrayList<>(Arrays.asList(
            new Employee("Alice",   "Engineering", 95000,  5),
            new Employee("Bob",     "Engineering", 88000,  3),
            new Employee("Charlie", "Marketing",   72000,  7),
            new Employee("Diana",   "Engineering", 105000, 8),
            new Employee("Eve",     "Marketing",   68000,  2),
            new Employee("Frank",   "Sales",       78000,  4),
            new Employee("Grace",   "Sales",       82000,  6),
            new Employee("Hank",    "Marketing",   91000, 10)
        ));

        // ===== 1. Predicate: Filter employees =====
        Predicate isSenior = emp -> emp.yearsOfService >= 5;
        Predicate isHighEarner = emp -> emp.salary > 85000;
        Predicate isEngineering = emp -> emp.department.equals("Engineering");

        // Composed predicate: senior engineers earning > 85k
        Predicate seniorHighEarningEngineers =
            isSenior.and(isHighEarner).and(isEngineering);

        List elite = employees.stream()
                .filter(seniorHighEarningEngineers)
                .collect(Collectors.toList());
        System.out.println("=== Senior high-earning engineers ===");
        elite.forEach(System.out::println);
        // Output: Alice (Engineering, $95000, 5y)
        // Output: Diana (Engineering, $105000, 8y)

        // ===== 2. Function: Transform employees =====
        Function toSummary = emp ->
            emp.name + " - " + emp.department;

        List summaries = employees.stream()
                .map(toSummary)
                .collect(Collectors.toList());
        System.out.println("\n=== Employee summaries ===");
        summaries.forEach(System.out::println);
        // Output: Alice - Engineering
        // Output: Bob - Engineering
        // Output: Charlie - Marketing
        // ... (all 8)

        // ===== 3. Consumer: Process employees =====
        Consumer printBonus = emp -> {
            double bonus = emp.salary * 0.10;
            System.out.printf("  %s gets $%.0f bonus%n", emp.name, bonus);
        };

        System.out.println("\n=== Year-end bonuses (senior employees) ===");
        employees.stream()
                .filter(isSenior)
                .forEach(printBonus);
        // Output:   Alice gets $9500 bonus
        // Output:   Charlie gets $7200 bonus
        // Output:   Diana gets $10500 bonus
        // Output:   Grace gets $8200 bonus
        // Output:   Hank gets $9100 bonus

        // ===== 4. Supplier: Factory and defaults =====
        Supplier defaultEmployee = () ->
            new Employee("New Hire", "Unassigned", 50000, 0);

        Employee newHire = defaultEmployee.get();
        System.out.println("\n=== Default employee ===");
        System.out.println(newHire);
        // Output: New Hire (Unassigned, $50000, 0y)

        // ===== 5. BinaryOperator: Reduce =====
        BinaryOperator sum = Double::sum;
        double totalSalary = employees.stream()
                .map(emp -> emp.salary)
                .reduce(0.0, sum);
        System.out.printf("%n=== Total salary: $%.0f ===%n", totalSalary);
        // Output: === Total salary: $679000 ===

        // ===== 6. Comparator with lambdas =====
        employees.sort(Comparator.comparingDouble((Employee emp) -> emp.salary).reversed());
        System.out.println("\n=== Employees by salary (highest first) ===");
        employees.forEach(emp -> System.out.printf("  %-10s $%.0f%n", emp.name, emp.salary));
        // Output:   Diana      $105000
        // Output:   Alice      $95000
        // Output:   Hank       $91000
        // Output:   Bob        $88000
        // Output:   Grace      $82000
        // Output:   Frank      $78000
        // Output:   Charlie    $72000
        // Output:   Eve        $68000

        // ===== 7. Map operations with lambdas =====
        Map> byDepartment = new HashMap<>();
        employees.forEach(emp ->
            byDepartment.computeIfAbsent(emp.department, k -> new ArrayList<>()).add(emp)
        );

        System.out.println("\n=== Employees by department ===");
        byDepartment.forEach((dept, emps) -> {
            System.out.println(dept + ":");
            emps.forEach(emp -> System.out.println("  - " + emp.name));
        });
        // Output: Engineering:
        // Output:   - Diana
        // Output:   - Alice
        // Output:   - Bob
        // Output: Marketing:
        // Output:   - Hank
        // Output:   - Charlie
        // Output:   - Eve
        // Output: Sales:
        // Output:   - Grace
        // Output:   - Frank

        // ===== 8. Department salary totals using merge =====
        Map deptSalaries = new HashMap<>();
        employees.forEach(emp ->
            deptSalaries.merge(emp.department, emp.salary, Double::sum)
        );

        System.out.println("\n=== Department salary totals ===");
        deptSalaries.forEach((dept, total) ->
            System.out.printf("  %-15s $%.0f%n", dept, total)
        );
        // Output:   Engineering     $288000
        // Output:   Marketing       $231000
        // Output:   Sales           $160000

        // ===== 9. Custom functional interface: salary adjustments =====
        SalaryAdjuster standardRaise = emp -> emp.salary * 1.05;
        SalaryAdjuster seniorRaise = emp -> emp.yearsOfService >= 5
            ? emp.salary * 1.10
            : emp.salary * 1.03;
        SalaryAdjuster departmentRaise = emp -> {
            switch (emp.department) {
                case "Engineering": return emp.salary * 1.08;
                case "Sales":      return emp.salary * 1.06;
                default:           return emp.salary * 1.04;
            }
        };

        System.out.println("\n=== Salary adjustments (department-based) ===");
        employees.forEach(emp -> {
            double newSalary = departmentRaise.adjust(emp);
            System.out.printf("  %-10s $%.0f -> $%.0f%n", emp.name, emp.salary, newSalary);
        });
        // Output:   Diana      $105000 -> $113400
        // Output:   Alice      $95000 -> $102600
        // Output:   Bob        $88000 -> $95040
        // Output:   Hank       $91000 -> $94640
        // Output:   Charlie    $72000 -> $74880
        // Output:   Eve        $68000 -> $70720
        // Output:   Grace      $82000 -> $86920
        // Output:   Frank      $78000 -> $82680

        // ===== 10. removeIf and replaceAll =====
        List departments = new ArrayList<>(
            Arrays.asList("Engineering", "Marketing", "Sales", "Legal", "HR")
        );
        departments.removeIf(dept ->
            !byDepartment.containsKey(dept)  // Remove departments with no employees
        );
        departments.replaceAll(dept ->
            dept + " (" + byDepartment.get(dept).size() + " people)"
        );
        System.out.println("\n=== Active departments ===");
        departments.forEach(System.out::println);
        // Output: Engineering (3 people)
        // Output: Marketing (3 people)
        // Output: Sales (2 people)
    }
}

Concepts Demonstrated

# Concept Where Used
1 Predicate (test, and, or, negate) Filtering employees by seniority, salary, department
2 Function (apply, andThen) Transforming employees to summary strings
3 Consumer (accept, andThen) Printing bonus calculations
4 Supplier (get) Factory for default employee
5 BinaryOperator (reduce) Summing total salary
6 Comparator with lambdas Sorting by salary descending
7 Map.computeIfAbsent() Grouping employees by department
8 Map.merge() Department salary totals
9 Custom functional interface SalaryAdjuster with different strategies
10 removeIf() and replaceAll() Filtering and transforming department list
11 forEach() on List and Map Iterating and printing throughout
12 Method references System.out::println, Double::sum
13 Strategy pattern Multiple SalaryAdjuster strategies
14 Effectively final Lambda captures in forEach blocks

Quick Reference

Topic Key Point
Lambda syntax (params) -> expression or (params) -> { statements; }
Type inference Compiler infers param types from context (target typing)
Effectively final Local variables captured by lambdas must never be reassigned
Predicate<T> T -> boolean. Composable with and(), or(), negate()
Function<T,R> T -> R. Chain with andThen(), compose()
Consumer<T> T -> void. Side effects. Chain with andThen()
Supplier<T> () -> T. Factories, lazy defaults
BiFunction<T,U,R> (T, U) -> R. Two inputs, one output
UnaryOperator<T> T -> T. Same-type transformation. Used by replaceAll()
BinaryOperator<T> (T, T) -> T. Used by reduce(), merge()
this in lambdas Refers to the enclosing class (not the lambda itself)
Lambda vs anonymous class Lambda: invokedynamic, no class file, shares enclosing scope
forEach() Iterable method. Takes Consumer. Use for simple iteration
removeIf() Collection method. Takes Predicate. Removes matching elements
replaceAll() List method. Takes UnaryOperator. Transforms every element
computeIfAbsent() Map method. Initializes missing keys with lambda factory
merge() Map method. Combines old and new values with BiFunction
Best practice Keep short, use method references, avoid side effects, name params



Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *