Mastering Java Lambda Expressions and Functional Interfaces

Part 11 of 14 in Functional Java Unleashed

Before Java 8, passing code as data meant writing anonymous inner classes — five to ten lines of boilerplate just to express a simple condition. Lambda expressions collapse that down to a single expression. Functional interfaces give the JVM a type for “a function with this signature,” and together they unlock behavior parameterization: write your algorithm once, plug in the logic it needs.

This post walks through three focused examples. Each one isolates a different facet of lambdas in Java: the syntax simplification, reusable behavior via functional interfaces, and composing operations into pipelines.

Lambda syntax — what you save

Implementing a one-method interface used to require an anonymous class with @Override, type declarations, and braces. A lambda is the same thing without the ceremony. The JVM infers the parameter types from the target functional interface:

Both the anonymous class and the lambda produce identical output. The lambda version replaces five lines of boilerplate with one expression. With single-parameter lambdas, even the parentheses around the parameter are optional — name -> "Hi, " + name is enough.

The four functional interfaces you’ll use most

Java ships java.util.function with built-in interfaces for the common shapes. Here’s what each one does:

Predicate takes a value and returns boolean. Use it for filtering:

var engineers = employees.stream()
    .filter(e -> e.department().equals("Engineering"))
    .toList();

This returned 3 employees: Alice (95K),Carol(95K), Carol (105K), and Frank ($110K).

Function<T, R> takes a value and returns a transformed value. Use it for mapping:

var names = employees.stream()
    .map(Employee::name)
    .toList();
// [Alice, Bob, Carol, Dave, Eve, Frank]

Employee::name is a method reference — syntactic sugar for e -> e.name() when the lambda body calls exactly one existing method with matching signature. Formatted salaries showed $95,000 through $110,000.

Consumer takes a value and returns nothing. Use it for side effects:

employees.forEach(e ->
    System.out.printf("%s earns %,.2f/month%n", e.name(), e.salary() / 12.0));

Alice’s 95Ksalaryproduced95K salary produced `7,916.67` per month. The lambda runs once per element.

Supplier takes nothing and returns a value. Use it for lazy production:

Supplier<List<Employee>> highEarners = () ->
    employees.stream().filter(e -> e.salary() > 90000).toList();
var selected = highEarners.get();

This produced 4 high earners (salary strictly above 90K):Alice(90K): Alice (95K), Carol (105K),Eve(105K), Eve (91K), and Frank ($110K). The code doesn’t execute until .get() is called.

Behavior parameterization — one method, many behaviors

The real power of lambdas shows up when you write a generic method that accepts behavior as a parameter, then plug in different logic at each call site:

Before (one method per criterion):

List<Employee> filterBySalary(List<Employee> employees, int min) { ... }
List<Employee> filterByDept(List<Employee> employees, String dept) { ... }

Each method repeats the same for loop. Add a new criterion and you add a new method.

After (one method, arbitrary behavior):

List<Employee> filter(List<Employee> employees, Predicate<Employee> pred) {
    var result = new ArrayList<Employee>();
    for (var e : employees)
        if (pred.test(e)) result.add(e);
    return result;
}

The same method produces different results depending on the lambda passed in: e -> e.salary() >= 90000 gave 4 results, e -> e.department().equals("Engineering") gave 3, and the compound e -> e.salary() > 80000 && !e.department().equals("Finance") also gave 4 (Alice 95K,Bob95K, Bob 82K, Carol 105K,Frank105K, Frank 110K).

Anonymous classes and lambdas are fully interchangeable here — the last test in the output confirmed both approaches returned 3 results for the same condition. A reusable Predicate<Employee> variable can be stored once and applied across different data sets without rewriting logic.

Composing operations — filter, map, sorted

Lambdas shine when you chain stream operations. Each step is a lambda (or method reference) that receives the output of the previous step:

Step by step:

  1. filter(e -> department == "Engineering") → 3 engineers (Alice, Carol, Frank)
  2. filter(e -> salary >= 90K) → same 3 (all three earn above $90K)
  3. map(Employee::name)[Alice, Bob, Carol, Dave, Eve, Frank]
  4. sorted(salary).reversed().limit(3) → Frank (110K),Carol(110K), Carol (105K), Alice ($95K)

A composed pipeline combining all operations produced the same three senior engineers in descending salary order. No temporary collections, no nested loops — each lambda is a standalone step in a pipeline. The Collections.reverseOrder() call on the mapped strings gave alphabetical descending output (Frank, Carol, Alice).

Takeaway

A functional interface is a contract: “I expect one method with this signature.” A lambda is the inline implementation of that contract. With built-in interfaces like Predicate, Function, Consumer, and Supplier, you write the algorithm once (the stream pipeline) and plug in arbitrary logic through lambdas. The behavior parameterization pattern — generic method, specific lambda at call site — eliminates boilerplate while keeping each operation focused on a single concern.