Skip to main content

12.3 Lambda Expressions and Functional Interfaces

What Is a Lambda Expression?

Introduced in Java 8, lambda expressions are anonymous functions — they let you treat code as data, passing behavior as a method argument. They dramatically reduce boilerplate and make Java feel modern.

// Before lambdas: anonymous inner class
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Running!");
}
};

// With lambda: same thing, one line
Runnable r = () -> System.out.println("Running!");

1. Lambda Syntax

// () -> expression           — no params, expression body
// (x) -> expression — one param, expression body
// x -> expression — one param, parens optional
// (x, y) -> expression — two params
// (x, y) -> { statements; } — block body with statements

// Examples
Runnable r = () -> System.out.println("Hello");
Supplier<String> s = () -> "Hello World";
Consumer<String> c = name -> System.out.println("Hi " + name);
Function<Integer, Integer> f = x -> x * 2;
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;

// Block body for multiple statements
Function<String, String> process = input -> {
String trimmed = input.trim();
return trimmed.toUpperCase();
};

2. Functional Interfaces

A functional interface has exactly one abstract method. This is the type a lambda is assigned to. The @FunctionalInterface annotation enforces this.

@FunctionalInterface
interface Greeter {
String greet(String name); // one abstract method
// default and static methods are allowed
}

Greeter formal = name -> "Dear " + name + ",";
Greeter casual = name -> "Hey, " + name + "!";
Greeter shouting = name -> ("Hello " + name).toUpperCase();

System.out.println(formal.greet("Alice")); // Dear Alice,
System.out.println(casual.greet("Bob")); // Hey, Bob!
System.out.println(shouting.greet("Carol")); // HELLO CAROL

3. Built-in Functional Interfaces (java.util.function)

Java provides a rich set of ready-to-use functional interfaces:

Predicate<T> — test a condition, returns boolean

import java.util.function.Predicate;
import java.util.List;

Predicate<String> isLong = s -> s.length() > 5;
Predicate<Integer> isEven = n -> n % 2 == 0;
Predicate<String> notEmpty = s -> !s.isEmpty();

System.out.println(isLong.test("Hello")); // false
System.out.println(isLong.test("Hello World")); // true
System.out.println(isEven.test(4)); // true

// Combining predicates
Predicate<String> isLongAndNotEmpty = isLong.and(notEmpty);
Predicate<String> isLongOrShort = isLong.or(s -> s.length() < 3);
Predicate<String> isShort = isLong.negate();

List<String> words = List.of("hi", "hello", "Java", "programming", "code");
words.stream()
.filter(isLong)
.forEach(System.out::println); // hello, programming

// BiPredicate for two arguments
java.util.function.BiPredicate<String, Integer> longerThan = (s, n) -> s.length() > n;
System.out.println(longerThan.test("hello", 3)); // true

Function<T, R> — transform T into R

import java.util.function.Function;

Function<String, Integer> strToLen = s -> s.length();
Function<Integer, String> intToStr = n -> "Number: " + n;
Function<String, String> toUpper = String::toUpperCase;

System.out.println(strToLen.apply("hello")); // 5
System.out.println(intToStr.apply(42)); // Number: 42

// Composing functions
Function<String, String> trim = String::trim;
Function<String, Integer> trimAndLen = trim.andThen(strToLen);
// or: trim.andThen(strToLen) is equivalent to: s -> strToLen.apply(trim.apply(s))

System.out.println(trimAndLen.apply(" hello ")); // 5

// compose() applies the argument function FIRST
Function<Integer, Integer> timesTwo = x -> x * 2;
Function<Integer, Integer> plusThree = x -> x + 3;

Function<Integer, Integer> timesTwoThenPlusThree = plusThree.compose(timesTwo); // timesTwo first
Function<Integer, Integer> plusThreeThenTimesTwo = timesTwo.andThen(plusThree); // plusThree second? no:
// andThen: apply timesTwo, then plusThree
// compose: apply timesTwo (the argument) first, then plusThree (the caller)

System.out.println(timesTwoThenPlusThree.apply(5)); // (5*2)+3 = 13
System.out.println(plusThreeThenTimesTwo.apply(5)); // (5*2)+3 = 13 (same here)
// Actually: timesTwo.andThen(plusThree): first *2, then +3 → (5*2)+3=13
// plusThree.compose(timesTwo): first *2, then +3 → (5*2)+3=13

Consumer<T> — consume T, no return value

import java.util.function.Consumer;

Consumer<String> print = System.out::println;
Consumer<String> log = s -> System.out.println("[LOG] " + s);

print.accept("Hello"); // Hello
log.accept("Error occurred"); // [LOG] Error occurred

// andThen: chain consumers
Consumer<String> printAndLog = print.andThen(log);
printAndLog.accept("Test");
// Hello
// [LOG] Test

// BiConsumer for two arguments
java.util.function.BiConsumer<String, Integer> printScore = (name, score) ->
System.out.printf("%-10s: %d%n", name, score);
printScore.accept("Alice", 95);

Supplier<T> — produce T, no input

import java.util.function.Supplier;

Supplier<String> hello = () -> "Hello World";
Supplier<Integer> randomNum = () -> (int) (Math.random() * 100);
Supplier<java.util.List<String>> emptyList = java.util.ArrayList::new;

System.out.println(hello.get()); // Hello World
System.out.println(randomNum.get()); // e.g., 42
System.out.println(emptyList.get()); // []

// Lazy initialization pattern
class HeavyObject {
HeavyObject() { System.out.println("Expensive construction!"); }
}

Supplier<HeavyObject> lazy = HeavyObject::new;
// Construction is deferred until .get() is called
HeavyObject obj = lazy.get(); // "Expensive construction!" printed here

4. Primitive Functional Interfaces

To avoid boxing overhead, Java provides specialized interfaces for primitives:

import java.util.function.*;

// IntPredicate, LongPredicate, DoublePredicate
IntPredicate isPositive = n -> n > 0;
System.out.println(isPositive.test(5)); // true

// IntFunction<R>, IntUnaryOperator, IntBinaryOperator
IntUnaryOperator square = n -> n * n;
IntBinaryOperator multiply = (a, b) -> a * b;

System.out.println(square.applyAsInt(7)); // 49
System.out.println(multiply.applyAsInt(3, 4)); // 12

// IntSupplier, IntConsumer
IntSupplier counter = new IntSupplier() {
int count = 0;
public int getAsInt() { return count++; }
};

IntConsumer printInt = n -> System.out.print(n + " ");
for (int i = 0; i < 5; i++) printInt.accept(counter.getAsInt()); // 0 1 2 3 4

5. Lambda Variable Capture

Lambdas can capture variables from their enclosing scope:

// Capture of effectively final local variable
String prefix = "Hello, "; // effectively final (never reassigned)
Function<String, String> greeter = name -> prefix + name;
System.out.println(greeter.apply("Alice")); // Hello, Alice

// prefix = "Hi, "; // uncommenting this would cause a compile error

// Instance fields and static fields can be captured and mutated
class Counter {
private int count = 0;

public Runnable incrementer() {
return () -> count++; // captures 'this', not a local variable
}
}

Counter c = new Counter();
Runnable inc = c.incrementer();
inc.run(); inc.run(); inc.run();
// c.count is now 3

6. Practical Example: Sorting with Lambdas

import java.util.*;
import java.util.function.*;

record Employee(String name, String department, double salary) {}

public class LambdaSortingDemo {
public static void main(String[] args) {
List<Employee> employees = new ArrayList<>(List.of(
new Employee("Alice", "Engineering", 95000),
new Employee("Bob", "Marketing", 72000),
new Employee("Charlie", "Engineering", 88000),
new Employee("Diana", "HR", 65000),
new Employee("Eve", "Marketing", 78000)
));

// Sort by salary descending
employees.sort((e1, e2) -> Double.compare(e2.salary(), e1.salary()));
employees.forEach(e -> System.out.printf("%-10s $%.0f%n", e.name(), e.salary()));

System.out.println("---");

// Sort by department, then by name within same department
employees.sort(Comparator.comparing(Employee::department)
.thenComparing(Employee::name));
employees.forEach(e -> System.out.printf("%-12s %-10s%n", e.department(), e.name()));

// Filter using Predicate
Predicate<Employee> isHighEarner = e -> e.salary() > 80000;
Predicate<Employee> isEngineer = e -> e.department().equals("Engineering");

System.out.println("\nHigh earners in Engineering:");
employees.stream()
.filter(isHighEarner.and(isEngineer))
.map(Employee::name)
.forEach(System.out::println);

// Transform using Function
Function<Employee, String> summary = e ->
String.format("%s (%s) - $%.0f", e.name(), e.department(), e.salary());

System.out.println("\nEmployee summaries:");
employees.stream()
.map(summary)
.forEach(System.out::println);
}
}

Pro Tips

Lambda best practices:

  1. Keep lambdas short— if a lambda is more than 2-3 lines, extract it to a named method and use a method reference.

  2. Avoid side effects in stream lambdas — lambdas should ideally be pure functions (no modifying external state).

  3. Use method references when the lambda just calls an existing method:

    .map(s -> s.toUpperCase())  // lambda
    .map(String::toUpperCase) // method reference (prefer this)
  4. Prefer specific functional interfaces: Use IntFunction, IntPredicate, etc. over Function<Integer, ...> to avoid boxing/unboxing overhead in performance-critical code.

  5. Don't use lambdas everywhere— regular loops and methods are sometimes clearer. Use lambdas when they genuinely reduce noise and increase clarity.