The previous lesson ended with an uncomfortable observation: every comparator in the BiblioTech catalogue has one line of useful logic wrapped in five of ceremony. And that ceremony adds nothing, because all of it is information the compiler already has: it knows which interface you expect (the parameter of Arrays.sort says so), it knows which method has to be implemented (Comparator has only one) and it knows the type of the two arguments.

Lambda expressions, introduced in Java 8, let you write only what carries information: the parameters and the body. They were the biggest change to the language's syntax since its creation and they completely transformed the way Java is written: sorting collections, defining callbacks, launching concurrent tasks and — above all — processing data with the Streams API. This lesson teaches you what they really are (not what almost everybody believes), all of their syntax, their capture rules, the critical difference in this compared with anonymous classes, and how to use them in BiblioTech to change a class's behaviour without touching its code.

Contents

  1. From anonymous class to lambda, step by step
  2. Full syntax and shorthand forms
  3. What a lambda really is
  4. Type inference and the target type
  5. Capturing effectively final variables
  6. Why a lambda cannot modify a local variable
  7. this inside a lambda: the contrast with 04-04
  8. Where lambdas are used today
  9. A BiblioTech functional interface of your own
  10. Parameterising behaviour: MaterialFilter
  11. Readability: when a lambda should be a method
  12. Common Mistakes and Tips
  13. Exercises

  1. From anonymous class to lambda, step by step

We start from the comparator by title of 04-04. We are going to remove ceremony in five steps, checking at each one what information is lost: none.

Step 0. The complete anonymous class.

Arrays.sort(catalog, new Comparator<Material>() {
    @Override
    public int compare(Material a, Material b) {
        return a.getTitle().compareToIgnoreCase(b.getTitle());
    }
});

Step 1. Out with new Comparator<Material>(). The compiler already knows that Arrays.sort(T[], Comparator<? super T>) expects a Comparator<Material>: the array's type tells it so. Repeating it is redundant.

Arrays.sort(catalog,
    public int compare(Material a, Material b) {
        return a.getTitle().compareToIgnoreCase(b.getTitle());
    }
);

Step 2. Out with @Override, public and the method name. Comparator has a single abstract method: compare. There is no possible ambiguity about which one you are implementing, so naming it adds nothing.

Arrays.sort(catalog,
    (Material a, Material b) {
        return a.getTitle().compareToIgnoreCase(b.getTitle());
    }
);

Step 3. The arrow -> appears. Java needs a separator between the parameters and the body. That separator is the lambda operator.

Arrays.sort(catalog, (Material a, Material b) -> {
    return a.getTitle().compareToIgnoreCase(b.getTitle());
});

This is already a valid lambda and it compiles.

Step 4. Out with the parameter types. The compiler knows that compare receives two Materials. Writing it is optional.

Arrays.sort(catalog, (a, b) -> {
    return a.getTitle().compareToIgnoreCase(b.getTitle());
});

Step 5. Out with the braces and the return. If the body is a single expression, its value is returned automatically.

Arrays.sort(catalog, (a, b) -> a.getTitle().compareToIgnoreCase(b.getTitle()));

From six lines to one. And the final line contains exactly the information that was not deducible: the two parameters and the operation.

Step What is removed Why it can be
1 new Comparator<Material>() The type is imposed by the method's parameter
2 @Override public int compare The interface has a single abstract method
3 — -> is added as a separator
4 Parameter types They are inferred from the method signature
5 { } and return Single-expression body
flowchart TD
    A["new Comparator&lt;Material&gt;() { @Override public int compare(Material a, Material b) { ... } }"] --> B["The type is imposed by Arrays.sort: out with new Comparator"]
    B --> C["The interface has a single abstract method: out with the name and the modifiers"]
    C --> D["The arrow is added as a separator: (Material a, Material b) -> { ... }"]
    D --> E["The types are inferred: (a, b) -> { ... }"]
    E --> F["Single-expression body: (a, b) -> a.getTitle().compareToIgnoreCase(b.getTitle())"]

  1. Full syntax and shorthand forms

The general form is:

(parameters) -> body

And it accepts several abbreviations depending on the number of parameters and the shape of the body:

Form Example When it can be used
No parameters () -> System.out.println("Hello") Whenever the method receives nothing. The parentheses are mandatory
One parameter with type (Material m) -> m.getTitle() Always
One parameter without type (m) -> m.getTitle() When the type can be inferred
One parameter without parentheses m -> m.getTitle() Only with one parameter and no declared type
Several parameters with type (Material a, Material b) -> ... Always
Several parameters without type (a, b) -> ... When they can be inferred. All or nothing
Expression body (a, b) -> a.getTitle().compareTo(b.getTitle()) Single-expression body; returns its value
Block body (a, b) -> { ... return x; } Several statements; return is mandatory if there is a return value
Block without a return m -> { System.out.println(m); } When the method returns void

Equivalent examples of the same lambda, from most explicit to most concise:

// 1. Everything explicit
Comparator<Material> c1 = (Material a, Material b) -> {
    return a.getTitle().compareToIgnoreCase(b.getTitle());
};

// 2. Without types
Comparator<Material> c2 = (a, b) -> {
    return a.getTitle().compareToIgnoreCase(b.getTitle());
};

// 3. Expression body (the usual form)
Comparator<Material> c3 = (a, b) -> a.getTitle().compareToIgnoreCase(b.getTitle());

And the rules to memorise, with their associated errors:

// With ONE parameter the parentheses are optional... if you do not declare the type
m -> m.getTitle()                // valid
(m) -> m.getTitle()              // valid
(Material m) -> m.getTitle()     // valid
// Material m -> m.getTitle()    // NOT VALID: with a type, the parentheses are mandatory

// With no parameters, ALWAYS the parentheses
() -> System.out.println("hi")   // valid
// -> System.out.println("hi")   // NOT VALID

// The types are ALL or NOTHING
(Material a, Material b) -> ...  // valid
(a, b) -> ...                    // valid
// (Material a, b) -> ...        // NOT VALID: a mixture

// Block body: return mandatory if there is a value
(a, b) -> { return a.getTitle().compareTo(b.getTitle()); }   // valid
// (a, b) -> { a.getTitle().compareTo(b.getTitle()); }       // DOES NOT COMPILE: return missing

There is one more variant, available since Java 11: var in the parameters, useful when you want to annotate them.

Comparator<Material> c4 = (var a, var b) -> a.getTitle().compareTo(b.getTitle());

It is all or nothing too, and its only real reason to exist is to allow writing (@NotNull var a, var b) -> .... In day-to-day work it is rarely used.

  1. What a lambda really is

Here it is worth being precise, because there is a very widespread misunderstanding.

A lambda is NOT a function pointer. A lambda IS an instance of a functional interface.

A functional interface is an interface with exactly one abstract method. Comparator is one (compare), Runnable is one (run), Lendable is not (it has four).

When you write:

Comparator<Material> byTitle = (a, b) -> a.getTitle().compareTo(b.getTitle());

what you get is an object of a type implementing Comparator<Material>, whose compare method runs that body. You can check it:

Comparator<Material> byTitle = (a, b) -> a.getTitle().compareTo(b.getTitle());

System.out.println(byTitle instanceof Comparator);      // true
System.out.println(byTitle.getClass().getName());       // App$$Lambda$14/0x...
System.out.println(byTitle.compare(book1, book2));      // invoked like any method

The practical consequences of it being an object are important:

  • You can store it in a variable, in a field or in an array.
  • You can pass it as an argument and return it from a method.
  • You must invoke its method by name: byTitle.compare(a, b), not byTitle(a, b).
  • It only works with functional interfaces. If the interface has two abstract methods, no lambda is possible: an anonymous class is needed (04-04).

Now, there is a technical difference from anonymous classes worth knowing. The compiler does not generate a .class file per lambda: it emits an invokedynamic instruction that creates the implementation at run time. That is why the class name you see above is so odd, and why thousands of lambdas do not make startup more expensive the way thousands of anonymous classes did.

The catalogue of functional interfaces shipped with the JDK (Function, Predicate, Consumer, Supplier...), the @FunctionalInterface annotation and method references are the full content of lesson 04-06. Here you will work with Comparator and with functional interfaces of your own.

  1. Type inference and the target type

If you do not write the types in the lambda, where do they come from? From the target type: the type the context expects at that point.

// Context 1: assignment to a declared variable
Comparator<Material> c = (a, b) -> a.getTitle().compareTo(b.getTitle());
//  target type: Comparator<Material>  ->  a and b are Material

// Context 2: method argument
Arrays.sort(catalog, (a, b) -> a.getTitle().compareTo(b.getTitle()));
//  target type: Comparator<? super Material>  ->  a and b are Material

// Context 3: return value
public Comparator<Material> titleCriterion() {
    return (a, b) -> a.getTitle().compareTo(b.getTitle());
}

And here comes the surprising consequence: the same lambda can mean different things depending on where you put it.

public interface TitleValidator {
    boolean validate(String title);
}

public interface TextFilter {
    boolean accepts(String text);
}
TitleValidator v = t -> t.length() > 3;   // implements TitleValidator.validate
TextFilter     f = t -> t.length() > 3;   // implements TextFilter.accepts

System.out.println(v.validate("Java"));   // true
System.out.println(f.accepts("Java"));    // true
System.out.println(v.getClass() == f.getClass());   // false: they are different types

The lambda's text is identical and the objects are of completely different types. This explains two things:

A lambda has no type of its own. There is no such thing as "the type of t -> t.length() > 3". Its type is determined by the context. That is why this does not compile:

// var f = t -> t.length() > 3;      // DOES NOT COMPILE: no target type to infer from
// Object o = t -> t.length() > 3;   // DOES NOT COMPILE: Object is not a functional interface

An ambiguous lambda does not compile either. If a method is overloaded with two functional interfaces of compatible signature, the compiler cannot choose:

public void process(TitleValidator v) { }
public void process(TextFilter f)     { }

// process(t -> t.length() > 3);                 // DOES NOT COMPILE: ambiguous reference
process((TitleValidator) t -> t.length() > 3);   // disambiguated with a cast

  1. Capturing effectively final variables

A lambda can read:

  • Its own parameters.
  • The fields of the enclosing class (even mutable ones).
  • The method's local variables, if they are final or effectively final.
public Material[] expensiveMaterials(Material[] materials, double threshold, int days) {

    // 'threshold' and 'days' are parameters that are not reassigned: effectively final
    Comparator<Material> byFine =
        (a, b) -> Double.compare(b.calculateFine(days), a.calculateFine(days));

    Material[] copy = Arrays.copyOf(materials, materials.length);
    Arrays.sort(copy, byFine);

    int count = 0;
    for (Material m : copy) {
        if (m.calculateFine(days) >= threshold) { count++; }
    }
    return Arrays.copyOf(copy, count);
}

It is exactly the same rule as in local classes (04-03) and anonymous ones (04-04), and for the same reason: local variables live on the stack and die with the method; the lambda object lives on the heap and can outlive it. Java copies the value inside the object.

And the crucial distinction still holds: the restriction applies to the variable, not to the object.

Employee marta = new Employee("Marta Ruiz", "EMP-001");
StringBuilder log = new StringBuilder();

Runnable task = () -> {
    marta.registerLoan();               // ALLOWED: the object is modified
    log.append("loan registered");      // ALLOWED: the object is modified
    // marta = new Employee(...);       // FORBIDDEN: the variable is reassigned
};

  1. Why a lambda cannot modify a local variable

This is the mistake everybody makes in their first week:

public int countOverdue(Material[] materials, int days) {

    int overdue = 0;

    MaterialConsumer task = m -> {
        if (m.calculateDaysLate(days) > 0) {
            overdue++;         // COMPILATION ERROR
        }
    };
    // ...
}
error: local variables referenced from a lambda expression must be final
       or effectively final

The cause, already familiar, is capture by value: the lambda stores a copy. If you could modify it, there would be two uncoordinated values with the same name. Java forbids it at compile time.

But there is a second reason, specific to lambdas and deeper: a lambda may run on another thread. If two threads incremented the same stack variable — something that is physically impossible anyway, because each thread has its own stack — the result would be undefined. The restriction cuts off at the root a whole category of concurrency errors you will study in module 8.

The three correct alternatives, from best to worst:

// A. A field of the class (lives on the heap, is legitimately mutable)
public class OverdueCounter {
    private int overdue;                    // field, not a local variable
    public void process(Material[] materials, int days) {
        MaterialConsumer task = m -> {
            if (m.calculateDaysLate(days) > 0) { overdue++; }      // ALLOWED
        };
        for (Material m : materials) { task.accept(m); }
    }
    public int getOverdue() { return overdue; }
}

// B. An ordinary loop, no lambda: almost always the most readable
int overdue = 0;
for (Material m : materials) {
    if (m.calculateDaysLate(days) > 0) { overdue++; }
}

// C. A one-element array (old trick: avoid it)
int[] overdue = {0};
MaterialConsumer task = m -> { if (m.calculateDaysLate(days) > 0) { overdue[0]++; } };

Option B deserves a comment: accumulating into a variable is precisely what a loop does well. Do not force a lambda where a for is clearer. And for counting and summing over collections there will be a far better way: the Streams API, which is lesson 10-04.

  1. this inside a lambda: the contrast with 04-04

This section is the most important one in the lesson for anyone who already knows anonymous classes, because the behaviour is exactly the opposite.

Inside a lambda, this is the instance of the class containing it. A lambda does not introduce a new this scope.

The lambda is said to have lexical scope: this, variable names and method names mean inside it the same as they would mean just outside.

package com.nexussoftware.bibliotech.service;

public class Auditor {

    private final String source = "Auditor";

    public void compare() {

        // --- ANONYMOUS CLASS ---
        Runnable withAnonymous = new Runnable() {
            @Override
            public void run() {
                System.out.println("ANONYMOUS -> this.getClass() = "
                                   + this.getClass().getSimpleName());
                System.out.println("ANONYMOUS -> outer source    = "
                                   + Auditor.this.source);
            }
        };

        // --- LAMBDA ---
        Runnable withLambda = () -> {
            System.out.println("LAMBDA    -> this.getClass() = "
                               + this.getClass().getSimpleName());
            System.out.println("LAMBDA    -> source          = " + source);
            // System.out.println(Auditor.this.source);   // valid, but redundant
        };

        withAnonymous.run();
        withLambda.run();
    }

    public static void main(String[] args) {
        new Auditor().compare();
    }
}
ANONYMOUS -> this.getClass() = Auditor$1
ANONYMOUS -> outer source    = Auditor
LAMBDA    -> this.getClass() = Auditor
LAMBDA    -> source          = Auditor

The comparison table you must remember:

Aspect Anonymous class Lambda
this The anonymous instance The instance of the enclosing class
this.getClass() Outer$1 Outer
Accessing the outer field Outer.this.field field directly
Can it have its own fields? Yes No
Can it shadow names from the outer class? Yes (new scope) No (lexical scope)
Does it generate a .class? Yes, one per anonymous class No: invokedynamic
Can it implement non-functional interfaces? Yes No

That "cannot shadow" has a concrete consequence: inside a lambda you cannot declare a variable with the same name as one from the enclosing method.

public void example() {
    String title = "Effective Java";

    Runnable r1 = () -> {
        // String title = "Other";      // DOES NOT COMPILE: 'title' is already defined
        System.out.println(title);
    };

    Runnable r2 = new Runnable() {
        @Override public void run() {
            String title = "Other";      // IT DOES COMPILE: new scope, it shadows it
            System.out.println(title);   // prints "Other"
        }
    };
}

It is the same reason as the change in this: the lambda does not create a new scope, it is code living in the same lexical scope. And this is the main source of bugs when migrating old code from anonymous classes to lambdas: if the anonymous class used this expecting to refer to itself, the equivalent lambda will do something different without giving any compilation error.

  1. Where lambdas are used today

Three scenarios you can already use with what you know, plus one that comes later.

Comparators. The case you have seen:

Arrays.sort(catalog, (a, b) -> a.getTitle().compareToIgnoreCase(b.getTitle()));
Arrays.sort(catalog, (a, b) -> Integer.compare(a.getLoanDays(), b.getLoanDays()));
Arrays.sort(catalog, (a, b) -> Double.compare(b.calculateFine(20), a.calculateFine(20)));

Three criteria, three lines. Compare it with the fifteen lines per criterion of 04-04.

Runnable: a task with no arguments and no result.

Runnable reminder = () -> System.out.println("Review the overdue loans");
reminder.run();

Here you only run it directly. Its real use — passing it to a thread so it runs in parallel — is module 8.

Your own callbacks. The ReturnListener of 04-04 has a single abstract method, so it is a functional interface and accepts a lambda:

// Before (04-04): 6 lines of anonymous class
manager.setListener(new ReturnListener() {
    @Override
    public void onReturn(Loan loan, double fine) {
        System.out.printf("RECEIPT %s: %.2f EUR%n", loan.getReference(), fine);
    }
});

// Now: 1 line
manager.setListener((loan, fine) ->
    System.out.printf("RECEIPT %s: %.2f EUR%n", loan.getReference(), fine));

And where lambdas really shine: the Streams API. Operations such as filtering, transforming and grouping entire collections in a single chained expression. That is lesson 10-04, and we will not use it before then.

  1. A BiblioTech functional interface of your own

Defining your own functional interfaces is what turns lambdas into a design tool. Let us start with a configurable rate rule.

Today, a material's rate is fixed in its class: Book charges €0.25/day, always. But Nexus Software wants to run campaigns: half rate in August, double rate for materials in high demand, zero rate for interns. Adding an if per campaign inside Material would be exactly the design smell you learned to avoid in 03-06.

The solution: make the rule a parameter.

package com.nexussoftware.bibliotech.domain;

/**
 * Rule for computing the daily rate applicable to a material.
 *
 * <p>It is a functional interface: it has a single abstract method, so
 * any lambda of the form (Material, int) -> double implements it.</p>
 */
@FunctionalInterface
public interface RateRule {

    /**
     * @param material material the calculation applies to
     * @param daysLate accumulated days late
     * @return euros per day late that must be applied
     */
    double rateFor(Material material, int daysLate);
}

The @FunctionalInterface annotation makes the compiler verify that there is exactly one abstract method; if somebody adds a second one, the error appears here and not in the twenty places using lambdas. Its full meaning is 04-06.

Now a service that uses it:

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.Material;
import com.nexussoftware.bibliotech.domain.RateRule;

/** Computes fines applying a configurable rate rule. */
public class FineCalculator {

    public static final double MAX_FINE = 20.0;

    private final RateRule rule;

    /** The rule is injected: the calculator does not know which one it is. */
    public FineCalculator(RateRule rule) {
        this.rule = (rule != null)
                    ? rule
                    : (m, days) -> m.getDailyRate();   // default rule, as a lambda
    }

    public double calculate(Material material, int elapsedDays) {
        int    daysLate = material.calculateDaysLate(elapsedDays);
        double rate     = rule.rateFor(material, daysLate);
        return Math.min(daysLate * rate, MAX_FINE);
    }
}

And the campaigns, each on one line:

Material dvd = new Dvd("Refactoring Live", "DVD-0007", 95);

// 1. Standard rule: each material's own rate
FineCalculator standard = new FineCalculator((m, days) -> m.getDailyRate());

// 2. August campaign: half rate
FineCalculator august = new FineCalculator((m, days) -> m.getDailyRate() / 2);

// 3. Progressive rate: doubles from the eighth day late onwards
FineCalculator progressive = new FineCalculator((m, days) ->
    days > 7 ? m.getDailyRate() * 2 : m.getDailyRate());

// 4. Amnesty: no fines
FineCalculator amnesty = new FineCalculator((m, days) -> 0.0);

System.out.printf("Standard:    %.2f EUR%n", standard.calculate(dvd, 20));
System.out.printf("August:      %.2f EUR%n", august.calculate(dvd, 20));
System.out.printf("Progressive: %.2f EUR%n", progressive.calculate(dvd, 20));
System.out.printf("Amnesty:     %.2f EUR%n", amnesty.calculate(dvd, 20));
Standard:    8.50 EUR
August:      4.25 EUR
Progressive: 17.00 EUR
Amnesty:     0.00 EUR

What has happened deserves a pause: four different fine policies, and neither Material nor Dvd nor FineCalculator has changed a single line. The variable behaviour is passed as if it were data. This has a name — the Strategy pattern — and you will formalise it in 12-02; here you have written it almost without noticing.

  1. Parameterising behaviour: MaterialFilter

A second example, with the classic problem of "give me the materials that meet X".

Without lambdas, each new criterion forces you to add a method to the catalogue: findByTitle, findAvailable, findByType, findWithFineGreaterThan... The class grows without end and each new criterion modifies it.

package com.nexussoftware.bibliotech.domain;

/** Material selection criterion. Functional interface. */
@FunctionalInterface
public interface MaterialFilter {

    /** @return true if the material meets the criterion. */
    boolean accepts(Material material);
}
package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.MaterialFilter;
import com.nexussoftware.bibliotech.domain.Material;
import java.util.Arrays;

public class Catalog {

    private final Material[] materials;

    public Catalog(Material[] materials) {
        this.materials = Arrays.copyOf(materials, materials.length);
    }

    /**
     * A single search method, valid for ANY criterion.
     * The behaviour arrives as a parameter.
     */
    public Material[] find(MaterialFilter filter) {
        Material[] result = new Material[materials.length];
        int n = 0;
        for (Material m : materials) {
            if (filter.accepts(m)) {
                result[n++] = m;
            }
        }
        return Arrays.copyOf(result, n);      // module 5 will do this much better
    }

    /** Counts how many meet the criterion, without building the array. */
    public int count(MaterialFilter filter) {
        int n = 0;
        for (Material m : materials) {
            if (filter.accepts(m)) { n++; }
        }
        return n;
    }
}

And now, unlimited criteria without touching Catalog:

Catalog catalog = new Catalog(new Material[] {
    new Book("Effective Java",  "Joshua Bloch",  "978-0000000001", 2018),
    new Book("Design Patterns", "Erich Gamma",   "978-0000000002", 1994),
    new Book("Refactoring",     "Martin Fowler", "978-0000000003", 1999),
    new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly"),
    new Dvd("Refactoring Live", "DVD-0007", 95)
});

System.out.println("Available: "
    + catalog.count(m -> m.isAvailable()));

System.out.println("Books only: "
    + catalog.count(m -> m instanceof Book));

System.out.println("Short term (<= 7 days): "
    + catalog.count(m -> m.getLoanDays() <= 7));

System.out.println("With 'Refactor' in the title:");
for (Material m : catalog.find(m -> m.getTitle().contains("Refactor"))) {
    System.out.println("  " + m.describe());
}

// A filter stored in a variable and reused
MaterialFilter expensive = m -> m.calculateFine(20) > 5.0;
System.out.println("High fine after 20 days: " + catalog.count(expensive));
Available: 5
Books only: 3
Short term (<= 7 days): 2
With 'Refactor' in the title:
  Book "Refactoring" (ref. 978-0000000003) - Martin Fowler, 1999
  DVD "Refactoring Live" (ref. DVD-0007) - 95 min
High fine after 20 days: 1

This is the change of mindset in the module: behaviour is data. Before you passed numbers and strings; now you pass pieces of logic. The JDK already ships an interface identical to MaterialFilter — it is called Predicate — and you will see it in 04-06, together with the way to combine filters (and, or, negate).

  1. Readability: when a lambda should be a method

Lambdas are concise, and misapplied conciseness produces unreadable code. Three practical criteria:

The three-line rule. If the lambda's body goes beyond three lines, extract it into a named method.

// BAD: business logic buried in a long lambda
Material[] critical = catalog.find(m -> {
    int daysLate = m.calculateDaysLate(20);
    if (daysLate == 0) { return false; }
    double fine = m.calculateFine(20);
    boolean expensive = fine >= Material.MAX_FINE * 0.5;
    boolean sensitiveType = m.getLoanDays() <= 3;
    return expensive || (sensitiveType && daysLate > 5);
});

// GOOD: the condition has a name and can be tested separately
Material[] critical = catalog.find(m -> isCritical(m, 20));

private static boolean isCritical(Material m, int days) {
    int daysLate = m.calculateDaysLate(days);
    if (daysLate == 0) { return false; }
    boolean expensive     = m.calculateFine(days) >= Material.MAX_FINE * 0.5;
    boolean sensitiveType = m.getLoanDays() <= 3;
    return expensive || (sensitiveType && daysLate > 5);
}

The good version gains three things: the condition has a name (isCritical explains the intent better than six lines of booleans), it is reusable in other criteria, and it is testable in isolation with JUnit in module 11.

If it repeats, give it a name. A lambda used in three places should be a constant:

public static final MaterialFilter AVAILABLE  = m -> m.isAvailable();
public static final MaterialFilter BOOKS_ONLY = m -> m instanceof Book;
public static final Comparator<Material> BY_TITLE =
    (a, b) -> a.getTitle().compareToIgnoreCase(b.getTitle());

Name the parameters well. (a, b) is fine for a generic comparator; m is fine for a material in an obvious context. But in a two-line lambda, (loan, fine) reads infinitely better than (l, x). Conciseness does not justify cryptic names.

Common Mistakes and Tips

Believing a lambda is a free-standing function. It is an object implementing a functional interface. That is why it is invoked by the method's name (filter.accepts(m), not filter(m)) and why you cannot assign it to var or to Object.

Trying a lambda on a non-functional interface. Lendable l = () -> true; does not compile: Lendable has four abstract methods. Only interfaces with exactly one.

Forgetting the return in a block body. If you open braces and the method returns something, the return is mandatory. (a, b) -> { a.compareTo(b); } does not compile.

Putting a semicolon after an expression body. m -> m.getTitle(); inside a method call is an error. With an expression body there is no internal ;; with a block body, each statement carries its own.

Modifying a captured local variable. Forbidden. Use a field or a loop. And do not resort to the one-element array unless there is no alternative.

Getting this wrong. In a lambda, this is the enclosing class; in an anonymous class, the anonymous one. It is the number one error when converting old code, and it gives no compilation error: it simply does something else.

Mixing types in the parameters. (Material a, b) -> ... does not compile. Either all with types or none.

Kilometre-long lambdas. A twenty-line lambda inside a method call is worse than the anonymous class it replaces. Extract a method.

Tip: always annotate @FunctionalInterface on your single-method interfaces. It documents the intent and prevents a colleague from adding a second abstract method and breaking every lambda in the project.

Tip: learn to read inference errors. When a lambda does not compile, the message usually points at the target type, not at the lambda. If you get lost, temporarily write out the parameter types: the error becomes much clearer.

Tip: keep reused lambdas as static final constants. A single instance is created, the name documents the criterion and error traces are more readable.

Exercises

Exercise 1: rewriting the comparators of 04-04

Take the showSorted method from exercise 1 of lesson 04-04 and rewrite it using lambdas instead of anonymous classes. Count the lines before and after. Add a fourth criterion, "type", which sorts by type and breaks ties by title.

Exercise 2: NoticeRule, a functional interface of your own

Create a functional interface NoticeRule with the method String compose(Loan loan, double fine). Create a class NoticeService with a method send(Loan, double, NoticeRule) that prints the composed text. Pass it three different lambdas: a formal notice, a brief one and one that only writes something if the fine exceeds €5. Explain what advantage this has over three methods sendFormal, sendBrief and sendIfHigh.

Exercise 3: capture and this

Write a class Recorder with a field private int notices and a method process(Material[] materials, int days) that:

  1. Declares a local variable int localCounter = 0 and tries to increment it inside a lambda. Check the error and comment on it.
  2. Increments the field notices from the lambda instead.
  3. Prints this.getClass().getSimpleName() inside the lambda and explains the result by comparing it with what an anonymous class would return.

Solutions

Solution 1

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.*;
import java.util.Arrays;
import java.util.Comparator;

public class CatalogSorter {

    public static void showSorted(Material[] materials, String criterion, int days) {

        Material[] copy = Arrays.copyOf(materials, materials.length);

        Comparator<Material> comparator = switch (criterion) {

            case "title" -> (a, b) -> a.getTitle().compareToIgnoreCase(b.getTitle());

            case "term"  -> (a, b) -> Integer.compare(a.getLoanDays(),
                                                      b.getLoanDays());

            case "fine"  -> (a, b) -> Double.compare(b.calculateFine(days),
                                                     a.calculateFine(days));

            // Composite criterion: block body, because there is an intermediate condition
            case "type"  -> (a, b) -> {
                int byType = a.getType().compareTo(b.getType());
                if (byType != 0) {
                    return byType;
                }
                return a.getTitle().compareToIgnoreCase(b.getTitle());
            };

            default      -> (a, b) -> 0;
        };

        Arrays.sort(copy, comparator);

        System.out.println("--- Sorted by " + criterion + " ---");
        for (Material m : copy) {
            System.out.printf("  %-10s %-24s term %2d  fine %5.2f EUR%n",
                              m.getType(), m.getTitle(),
                              m.getLoanDays(), m.calculateFine(days));
        }
    }
}
--- Sorted by type ---
  Book       Design Patterns          term 15  fine  1.25 EUR
  Book       Effective Java           term 15  fine  1.25 EUR
  DVD        Refactoring Live         term  3  fine  8.50 EUR
  Magazine   Java Magazine            term  7  fine  1.30 EUR

The count: the switch with anonymous classes took 30 lines for four criteria; with lambdas it takes 14 including the composite criterion, which is the only one needing a block body. And the gain is not only in size: the sorting criterion now reads on a single line next to its case, without the eye having to jump over @Override public int compare(...) four times.

Note the two styles coexisting: the first three use an expression body (no braces, no return) and the fourth uses a block body because it has an intermediate condition. In 04-06 you will see that even that case can be written on one line with Comparator.comparing(...).thenComparing(...).

Solution 2

package com.nexussoftware.bibliotech.domain;

/** Composes the text of a return notice. Functional interface. */
@FunctionalInterface
public interface NoticeRule {

    /**
     * @return text to send, or null / an empty string if no notice applies
     */
    String compose(Loan loan, double fine);
}
package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.Loan;
import com.nexussoftware.bibliotech.domain.NoticeRule;

/** Sends notices applying whichever composition rule it is given. */
public class NoticeService {

    private int sent;

    public void send(Loan loan, double fine, NoticeRule rule) {
        String text = rule.compose(loan, fine);
        if (text == null || text.isBlank()) {
            System.out.println("(no notice for " + loan.getReference() + ")");
            return;
        }
        sent++;                     // field: the lambda does not touch it, the service does
        System.out.println(text);
    }

    public int getSent() { return sent; }
}
NoticeService service = new NoticeService();

// 1. Formal notice
NoticeRule formal = (l, fine) -> String.format(
        "Dear %s,%n  The material \"%s\" (ref. %s) is %d days late.%n"
      + "  Amount due: %.2f EUR.%n  Yours sincerely, BiblioTech - Nexus Software.",
        l.getEmployeeName(), l.getMaterialTitle(), l.getReference(),
        l.calculateDaysLate(), fine);

// 2. Brief notice
NoticeRule brief = (l, fine) ->
        String.format("[%s] %s: %.2f EUR", l.getReference(), l.getMaterialTitle(), fine);

// 3. Only if the fine is high: returns null and the service sends nothing
NoticeRule highOnly = (l, fine) -> fine > 5.0
        ? String.format("URGENT %s: %.2f EUR outstanding", l.getReference(), fine)
        : null;

Employee marta = new Employee("Marta Ruiz", "EMP-001");
Loan l1 = new Loan(
        new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018), marta, 100, 20);
Loan l2 = new Loan(
        new Dvd("Refactoring Live", "DVD-0007", 95), marta, 100, 20);

service.send(l1, 1.25, brief);
service.send(l1, 1.25, highOnly);
service.send(l2, 8.50, highOnly);
service.send(l2, 8.50, formal);

System.out.println("Notices sent: " + service.getSent());
[LN-0001] Effective Java: 1.25 EUR
(no notice for LN-0001)
URGENT LN-0002: 8.50 EUR outstanding
Dear Marta Ruiz,
  The material "Refactoring Live" (ref. LN-0002) is 17 days late.
  Amount due: 8.50 EUR.
  Yours sincerely, BiblioTech - Nexus Software.
Notices sent: 3

Advantages over three methods sendFormal, sendBrief and sendIfHigh:

Aspect Three methods One method + NoticeRule
Adding a new format Modify NoticeService Write a lambda where it is used
Sending logic (counter, empty check) Duplicated three times Written once
Composition Impossible to combine formats One rule can wrap another
Testing Three methods to test Sending is tested once and the rules separately
External configuration Impossible The rule can be chosen at run time

It is the open/closed principle: the class stays open to extension (new rules) and closed to modification (its code does not change). You will formalise it in 12-02.

Solution 3

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.MaterialFilter;
import com.nexussoftware.bibliotech.domain.Material;

public class Recorder {

    private int notices;                      // FIELD: lives on the heap, is mutable

    public void process(Material[] materials, int days) {

        int localCounter = 0;                 // LOCAL VARIABLE: lives on the stack

        MaterialFilter isOverdue = m -> {
            // localCounter++;                // (1) DOES NOT COMPILE:
            //   "local variables referenced from a lambda expression
            //    must be final or effectively final"
            //
            // The lambda stores a COPY of the value. If it could modify it,
            // there would be two different values with the same name: the method's
            // and the lambda object's. Besides, the lambda could run on
            // another thread, which has its own stack (module 8).

            if (m.calculateDaysLate(days) > 0) {
                notices++;                    // (2) IT DOES COMPILE: it is a field of the class
                return true;
            }
            return false;
        };

        for (Material m : materials) {
            isOverdue.accepts(m);
        }

        // (3) this inside the lambda
        Runnable report = () -> System.out.printf(
                "%s has recorded %d notices. this = %s%n",
                getClass().getSimpleName(), notices,
                this.getClass().getSimpleName());
        report.run();

        System.out.println("localCounter is still " + localCounter);
    }

    public int getNotices() { return notices; }

    public static void main(String[] args) {
        Material[] catalog = {
            new Book("Effective Java",   "Joshua Bloch",  "978-0000000001", 2018),
            new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly"),
            new Dvd("Refactoring Live", "DVD-0007", 95)
        };
        new Recorder().process(catalog, 20);
    }
}
Recorder has recorded 3 notices. this = Recorder
localCounter is still 0

The three answers:

  1. localCounter++ does not compile. The lambda captures the value by copy because the object may outlive the method, and modifying the copy would produce two uncoordinated values. The compiler stops it before the problem exists.
  2. notices++ does compile. notices is an instance field: it lives on the heap next to the Recorder object, and the lambda reaches it through this, which it does capture. Mutability belongs to the object, not to the captured variable. (With several threads this would stop being safe without synchronisation: module 8.)
  3. this.getClass() returns Recorder. The lambda does not create a this scope of its own: its this is the enclosing method's. With an anonymous class, the same line would have printed Recorder$1. And notice that inside the lambda getClass() without a prefix works just like this.getClass(), something that in an anonymous class would have required Recorder.this.getClass().

Conclusion

You have made the full journey from anonymous class to lambda, removing ceremony step by step and checking that no information is lost at any point: the type is imposed by the context, the method name is unique, the parameter types are inferred and the return is superfluous when the body is an expression. From six lines to one, and the remaining line contains exactly what was not deducible.

You have mastered the full syntax with all its forms: parentheses mandatory when there are no parameters or when the types are declared, optional with a single untyped parameter; types all or nothing; expression body without braces or return versus block body with a mandatory return. And you know what a lambda really is: not a function pointer but an instance of a functional interface — an interface with exactly one abstract method — a fully-fledged object you can store, pass and return, and whose method has to be invoked by name.

You understand inference by target type and its most striking consequence: the same lambda written word for word can produce objects of completely different types depending on where you put it, and that is why there is no such thing as "the type of a lambda" and why it cannot be assigned to var or Object. You know the rule for capturing effectively final variables, its twofold reason — copying by value and the possibility of running on another thread — and the correct alternatives when you genuinely need to accumulate: a field, or simply a loop. And you are perfectly clear about the difference that costs the most when migrating old code: in a lambda, this is the enclosing class, exactly the opposite of an anonymous class, and that change produces no compilation error whatsoever.

Above all, you have changed your mindset: behaviour is data. FineCalculator applies four different rate policies without Material or itself changing a line, and Catalog answers any imaginable search criterion with a single find(MaterialFilter) method. That is what lambdas really contribute: classes open to extension and closed to modification.

But you have written two functional interfaces of your own — MaterialFilter and NoticeRule — that look suspiciously like something that ought to be standard. And it is: the JDK ships a complete catalogue of functional interfaces in the java.util.function package, with Predicate for filtering, Function for transforming, Consumer for consuming and Supplier for producing, plus ways of combining them (and, or, negate, andThen) that turn two criteria into one. And there is more: when a lambda merely calls a method that already exists — m -> m.getTitle() — even that line is too much ceremony. In lesson 04-06, Functional Interfaces and Method References, you will see the whole catalogue, the @FunctionalInterface annotation and exactly what it checks, the composition of functions and comparators, and the four forms of method reference that reduce m -> m.getTitle() to Material::getTitle.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved