At the end of the previous lesson two loose ends were left. The first: you wrote MaterialFilter and NoticeRule, two functional interfaces of your own that look suspiciously like something that ought to come as standard. And so it does: the JDK ships a complete catalogue of general-purpose functional interfaces in the java.util.function package, ready to use, with methods to combine them with each other. The second loose end: when a lambda merely calls a method that already exists — m -> m.getTitle() — even that line is too much ceremony; there is a way to write simply Material::getTitle.

This lesson closes the circle of programming with behaviour in Java. You will learn what a functional interface formally is and what @FunctionalInterface checks; you will go through the whole java.util.function catalogue knowing when to use each piece; you will compose functions, predicates and comparators to build complex criteria out of simple parts; and you will master the four forms of method reference. By the end, sorting the BiblioTech catalogue by type and then by title, in reverse order, will fit on one readable line.

Contents

  1. Formal definition of a functional interface
  2. The @FunctionalInterface annotation: what exactly it checks
  3. The java.util.function package: the full catalogue
  4. The primitive variants and autoboxing
  5. The main interfaces applied to BiblioTech
  6. Composing functions: andThen and compose
  7. Composing predicates: and, or, negate
  8. Composing comparators: comparing, thenComparing, reversed
  9. Method references: the four forms
  10. When the reference is more readable than the lambda
  11. Receiving behaviour as a parameter: LoanManager
  12. Common Mistakes and Tips
  13. Exercises

  1. Formal definition of a functional interface

A functional interface is an interface declaring exactly one abstract method.

It is also called a SAM (Single Abstract Method). It is the only kind of interface that can be implemented with a lambda or a method reference, because only with one abstract method can the compiler know, unambiguously, what your code is implementing.

The important word is abstract: there are three kinds of member that do not count towards the tally.

@FunctionalInterface
public interface MaterialFilter {

    // 1. THE abstract method: it counts
    boolean accepts(Material material);

    // 2. default: does NOT count
    default MaterialFilter negated() {
        return m -> !this.accepts(m);
    }

    // 3. static: does NOT count
    static MaterialFilter all() {
        return m -> true;
    }

    // 4. private: does NOT count (Java 9)
    private boolean never(Material m) {
        return false;
    }
}

This interface is still functional: it has one abstract method, accepts. The default, static and private ones are implementations, not obligations.

And there is a fourth exception, less well known and very important in practice: redeclared public methods of Object do not count either.

@FunctionalInterface
public interface MaterialComparator {
    int compare(Material a, Material b);

    // Redeclaring equals does NOT break functionality:
    // every class already inherits it from Object
    @Override boolean equals(Object o);
}

It is exactly the case of java.util.Comparator, which declares compare and equals, and is still functional. If you did not know this, seeing it in the JDK source would be bewildering.

// These ARE functional
Runnable            // run()
Comparator<T>       // compare(T,T)  [+ equals, which does not count]
MaterialFilter      // accepts(Material)
RateRule            // rateFor(Material,int)

// These are NOT
Lendable            // 4 abstract methods
Notifiable          // 2 abstract methods

  1. The @FunctionalInterface annotation: what exactly it checks

@FunctionalInterface is an optional but strongly recommended annotation. It does not change the code's behaviour: it enables a compile-time check.

@FunctionalInterface
public interface MaterialFilter {
    boolean accepts(Material material);
    boolean rejects(Material material);   // a second abstract method
}
error: Unexpected @FunctionalInterface annotation
  MaterialFilter is not a functional interface
  multiple non-overriding abstract methods found in interface MaterialFilter

What it checks exactly:

It checks It does not check
That it is an interface (not a class or enum) That it is actually used with lambdas
That it has exactly one abstract method That the method's name is nice
That it does not have zero abstract methods Anything at run time

And why you should always use it on your single-method interfaces, even though it is optional:

  1. It documents the intent. Whoever reads it knows it is meant for lambdas.
  2. It protects the contract. Without the annotation, a colleague can add a second abstract method and the error will show up in the twenty places where you used lambdas, with confusing messages. With it, the error shows up in the interface, with the exact reason.
  3. It costs one line.

Mind the asymmetry: an interface without the annotation but with a single abstract method does accept lambdas. The annotation enables nothing, it only verifies. Runnable and Comparator worked with lambdas from day one of Java 8.

  1. The java.util.function package: the full catalogue

Java 8 introduced java.util.function with more than forty general-purpose functional interfaces. There is no need to memorise them: it is enough to understand the six families and their naming logic.

Interface Abstract method Receives Returns What it is for
Function<T,R> R apply(T t) 1 value 1 value Transforming one value into another
BiFunction<T,U,R> R apply(T t, U u) 2 values 1 value Transforming two values into one
Consumer<T> void accept(T t) 1 value nothing Consuming: printing, storing, notifying
BiConsumer<T,U> void accept(T t, U u) 2 values nothing Consuming two values
Supplier<T> T get() nothing 1 value Producing a value on demand
Predicate<T> boolean test(T t) 1 value boolean Deciding: filtering, validating
BiPredicate<T,U> boolean test(T t, U u) 2 values boolean Deciding about two values
UnaryOperator<T> T apply(T t) 1 value of type T value of type T Transforming without changing type
BinaryOperator<T> T apply(T a, T b) 2 values of type T value of type T Combining two into one of the same type

The naming system is completely regular, and understanding it is worth more than memorising the table:

  • Bi in front = receives two arguments (BiFunction, BiConsumer, BiPredicate).
  • Operator = a special case of Function where input and output are of the same type. UnaryOperator<String> is literally a Function<String,String> with a shorter name.
  • The method's verb gives away the family: apply transforms, accept consumes, get produces, test decides.

About the angle brackets: Function<Material, String> means "a function that receives a Material and returns a String". Here you are only using existing generic types; writing your own is lesson 10-01.

flowchart LR
    A["Supplier&lt;T&gt;<br/>nothing -> T"] --> B["Function&lt;T,R&gt;<br/>T -> R"]
    B --> C["Predicate&lt;T&gt;<br/>T -> boolean"]
    B --> D["Consumer&lt;T&gt;<br/>T -> nothing"]

  1. The primitive variants and autoboxing

Alongside the nine generic interfaces, the package includes dozens of primitive variants: IntPredicate, ToDoubleFunction<T>, IntSupplier, DoubleConsumer, IntUnaryOperator, ToIntBiFunction<T,U>...

Why do they exist? Because of autoboxing, which you studied in 01-04.

Generic types do not accept primitives: there is no Function<int, int>. You would have to write Function<Integer, Integer>, and then every call involves two hidden conversions:

// WITH autoboxing: Integer -> int -> calculation -> int -> Integer
Function<Integer, Integer> twice = n -> n * 2;
Integer result = twice.apply(21);
//   1. unboxing of 21 (Integer) to int
//   2. calculation
//   3. boxing of 42 (int) to Integer -> an object is created
// WITHOUT autoboxing: pure int
IntUnaryOperator fastTwice = n -> n * 2;
int fast = fastTwice.applyAsInt(21);        // zero objects created

In a loop of a million iterations, the first version creates a million Integer objects that the collector will have to clean up. The second creates none.

The naming rules, also regular:

Prefix Meaning Example
Int, Long, Double at the start The argument is primitive IntPredicate: boolean test(int)
To + type The result is primitive ToDoubleFunction<T>: double applyAsDouble(T)
Type + To + type Both are primitive IntToDoubleFunction: double applyAsDouble(int)

Applied to BiblioTech:

// A material's fine at 20 days: receives an object, returns a primitive double
ToDoubleFunction<Material> fineAt20 = m -> m.calculateFine(20);
double total = fineAt20.applyAsDouble(dvd);      // without creating any Double

// Checking whether a term is valid: receives an int, returns a boolean
IntPredicate validTerm = days -> days > 0 && days <= 30;
System.out.println(validTerm.test(15));          // true

Practical tip: in ordinary business code, use the generic versions; they are more readable. Turn to the primitive ones when working with large volumes or in hot loops. And know that the Streams API uses the primitive ones heavily (IntStream, mapToDouble); you will see that in 10-04.

  1. The main interfaces applied to BiblioTech

Let us now replace the interfaces of your own from 04-05 with the standard ones, and see the four families in action.

Predicate<T>: deciding. It directly replaces MaterialFilter.

import java.util.function.Predicate;

Predicate<Material> available = m -> m.isAvailable();
Predicate<Material> isBook    = m -> m instanceof Book;
Predicate<Material> shortTerm = m -> m.getLoanDays() <= 7;

System.out.println(available.test(effectiveJava));   // true
System.out.println(isBook.test(refactoringDvd));     // false

Function<T,R>: transforming.

import java.util.function.Function;

Function<Material, String> toTitle    = m -> m.getTitle();
Function<Material, String> toLabel    = m -> m.getType() + " / " + m.getReference();
Function<Employee, String> toInitials = e -> e.getInitials();

System.out.println(toLabel.apply(effectiveJava));    // Book / 978-0000000001
System.out.println(toInitials.apply(marta));         // M.R.

Consumer<T>: consuming without returning anything.

import java.util.function.Consumer;

Consumer<Material> print = m -> System.out.println("  " + m.describe());
Consumer<Material> lend  = m -> m.lend();

for (Material m : catalog) {
    print.accept(m);
}

Supplier<T>: producing on demand.

import java.util.function.Supplier;

Supplier<Employee> defaultEmployee = () -> new Employee("Unassigned", "EMP-000");
Supplier<String>   timestamp       = () -> "day " + System.currentTimeMillis() / 86_400_000;

Employee e = (assigned != null) ? assigned : defaultEmployee.get();

The point of Supplier is lazy evaluation: the object is not created until get() is called. If creating the default value were expensive, with a Supplier you only pay that cost when it is genuinely needed.

BiFunction<T,U,R> and BinaryOperator<T>.

import java.util.function.BiFunction;
import java.util.function.BinaryOperator;

// Two inputs of different types, one output of a third type
BiFunction<Material, Integer, Double> fineAt = (m, days) -> m.calculateFine(days);
System.out.printf("%.2f EUR%n", fineAt.apply(refactoringDvd, 20));   // 8.50 EUR

// Two inputs and one output, all of the same type
BinaryOperator<Material> theMoreExpensive = (a, b) ->
    a.calculateFine(20) >= b.calculateFine(20) ? a : b;
System.out.println(theMoreExpensive.apply(effectiveJava, refactoringDvd).getTitle());

UnaryOperator<T>: transforming without changing type.

import java.util.function.UnaryOperator;

UnaryOperator<String> normalise = t -> t.trim().toUpperCase();
System.out.println(normalise.apply("  effective java  "));   // EFFECTIVE JAVA

  1. Composing functions: andThen and compose

Here is where things get truly powerful. The java.util.function interfaces come with default methods that combine two functions into a third.

Function offers two, and the difference is only in the order:

Method Meaning Execution order
f.andThen(g) First f, then g g(f(x))
f.compose(g) First g, then f f(g(x))
Function<Material, String> toTitle  = m -> m.getTitle();
Function<String, String>   toUpper  = t -> t.toUpperCase();
Function<String, String>   inQuotes = t -> "\"" + t + "\"";

// andThen: reads left to right, like a pipeline
Function<Material, String> label = toTitle.andThen(toUpper).andThen(inQuotes);
System.out.println(label.apply(effectiveJava));         // "EFFECTIVE JAVA"

// compose: reads right to left
Function<Material, String> same = inQuotes.compose(toUpper).compose(toTitle);
System.out.println(same.apply(effectiveJava));          // "EFFECTIVE JAVA"
flowchart LR
    A["Material"] -->|"toTitle"| B["Effective Java"]
    B -->|"toUpper"| C["EFFECTIVE JAVA"]
    C -->|"inQuotes"| D["\"EFFECTIVE JAVA\""]

Use andThen almost always. It reads in the order things happen, which is how the reader thinks. compose exists out of fidelity to mathematical notation (f ∘ g), and in code it usually confuses.

Consumer also has andThen, with the difference that both consumers receive the original value (neither returns anything to chain):

Consumer<Material> logIt = m -> System.out.println("LOG: " + m.getReference());
Consumer<Material> show  = m -> System.out.println("  " + m.describe());

Consumer<Material> both = logIt.andThen(show);
both.accept(effectiveJava);
LOG: 978-0000000001
  Book "Effective Java" (ref. 978-0000000001) - Joshua Bloch, 2018

  1. Composing predicates: and, or, negate

Predicate offers three combination methods corresponding to the logical operators of 01-05:

Method Equivalent to Description
p.and(q) p && q Meets both. Short-circuits: if p is false, q is not evaluated
p.or(q) p || q Meets either. Short-circuits too
p.negate() !p Does not meet
Predicate.not(p) !p Same as negate(), in static form (Java 11)
Predicate.isEqual(x) x.equals(...) Equality predicate, static
Predicate<Material> available = m -> m.isAvailable();
Predicate<Material> isBook    = m -> m instanceof Book;
Predicate<Material> expensive = m -> m.calculateFine(20) > 5.0;

// Combinations, each on one line
Predicate<Material> availableBook     = isBook.and(available);
Predicate<Material> onLoan            = available.negate();
Predicate<Material> expensiveOrOnLoan = expensive.or(onLoan);
Predicate<Material> cheapFreeBook     = isBook.and(expensive.negate()).and(available);

Applied to the BiblioTech catalogue, with the method count(Predicate<Material>):

public int count(Predicate<Material> criterion) {
    int n = 0;
    for (Material m : materials) {
        if (criterion.test(m)) { n++; }
    }
    return n;
}
System.out.println("Available books:      " + catalog.count(availableBook));
System.out.println("On loan:              " + catalog.count(onLoan));
System.out.println("Cheap free books:     " + catalog.count(cheapFreeBook));
Available books:      2
On loan:              1
Cheap free books:     2

The underlying advantage: the three base predicates (isBook, available, expensive) are written once and from them come as many combinations as you like, each with its own name and testable separately. It is the same leap that functions make over copied code, applied to conditions.

A detail about Predicate.not versus negate:

Predicate<Material> notAvailable1 = available.negate();                  // instance method
Predicate<Material> notAvailable2 = Predicate.not(available);            // static, Java 11
Predicate<Material> notAvailable3 = Predicate.not(Material::isAvailable);   // reference

The third form is the most common in modern code, and it uses a method reference: that arrives in section 9.

  1. Composing comparators: comparing, thenComparing, reversed

This is the application that will most improve your BiblioTech code. Remember the composite comparator from 04-05:

// Before: block body with an intermediate condition
Comparator<Material> byTypeAndTitle = (a, b) -> {
    int byType = a.getType().compareTo(b.getType());
    if (byType != 0) {
        return byType;
    }
    return a.getTitle().compareToIgnoreCase(b.getTitle());
};

Comparator offers a set of methods that reduces it to one line:

Method What it does
Comparator.comparing(f) Creates a comparator that compares by the key f extracts
Comparator.comparingInt(f) The same, with an int key (no autoboxing). Also comparingDouble, comparingLong
c.thenComparing(f) Tie-breaker: if c gives equality, compare by f
c.reversed() Reverses the order
Comparator.naturalOrder() The type's natural order (that of compareTo)
Comparator.reverseOrder() Natural order reversed
c.nullsFirst(c2) / nullsLast(c2) Places the nulls at the beginning or at the end
import java.util.Comparator;

// One line, and it reads like a sentence
Comparator<Material> byTypeAndTitle =
    Comparator.comparing((Material m) -> m.getType())
              .thenComparing(m -> m.getTitle());

// Highest fine first
Comparator<Material> byFineDesc =
    Comparator.comparingDouble((Material m) -> m.calculateFine(20)).reversed();

// Three levels: by term ascending, then by type, then by title
Comparator<Material> full =
    Comparator.comparingInt((Material m) -> m.getLoanDays())
              .thenComparing(m -> m.getType())
              .thenComparing(m -> m.getTitle());

Arrays.sort(catalog, full);

Applied to the catalogue:

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

Arrays.sort(data, byTypeAndTitle);
for (Material m : data) {
    System.out.printf("  %-10s %s%n", m.getType(), m.getTitle());
}
  Book       Design Patterns
  Book       Effective Java
  Book       Refactoring
  DVD        Refactoring Live
  Magazine   Java Magazine

Two important warnings.

First, the position of reversed() matters. c1.thenComparing(c2).reversed() reverses the whole composite criterion; c1.reversed().thenComparing(c2) reverses only the first one. They are not the same.

Second, the type annotation in the first comparing. Notice that it is written (Material m) -> ... instead of m -> .... The reason is that the compiler infers the lambda's type from the target type, and in Comparator.comparing(...) chained with .thenComparing(...) the inference does not always get there. The alternative — and the cleanest one — is to declare the variable's type or use a method reference:

// With the variable's type declared, the lambda no longer needs annotating
Comparator<Material> c1 = Comparator.comparing(Material::getType)
                                    .thenComparing(Material::getTitle);

That Material::getType is exactly what comes next.

  1. Method references: the four forms

When a lambda does nothing more than call an existing method, the method reference expresses the same thing without noise. The syntax is Target::methodName, without parentheses and without arguments.

Form Syntax Equivalent lambda Example
1. Static method Class::method x -> Class.method(x) Integer::parseInt
2. Instance method of a specific object object::method x -> object.method(x) receipt::print
3. Instance method of an arbitrary object of the type Class::method x -> x.method() Material::getTitle
4. Constructor Class::new x -> new Class(x) Book::new

Forms 1 and 3 are written the same way (Class::method) and the compiler tells them apart according to whether the method is static or an instance one. It is the main source of confusion, so let us go through them one by one.

Form 1: static method. The lambda's argument is passed to the method.

Function<String, Integer> toInt1 = s -> Integer.parseInt(s);   // lambda
Function<String, Integer> toInt2 = Integer::parseInt;          // reference

System.out.println(toInt2.apply("42") + 1);                    // 43

// Another example from the domain
BiFunction<Double, Double, Double> min = Math::min;
System.out.println(min.apply(8.50, 20.0));                     // 8.5

Form 2: instance method of a specific object. The object is already fixed; the lambda's argument goes to the method.

ConsoleReceipt receipt = new ConsoleReceipt();

Consumer<Loan> print1 = l -> receipt.printReceipt(l);          // lambda
Consumer<Loan> print2 = receipt::printReceipt;                 // reference

print2.accept(loan);

A detail worth knowing: the reference captures the object at the moment it is created. If you later reassign receipt to another object, print2 will keep using the original. It is consistent with the capture rule of 04-05.

Form 3: instance method of an arbitrary object of the type. It is the most used and the hardest at first. Here the lambda's argument becomes the receiver of the call.

Function<Material, String> toTitle1 = m -> m.getTitle();       // lambda
Function<Material, String> toTitle2 = Material::getTitle;      // reference
//                                    ^^^^^^^^ the parameter becomes 'm'

Predicate<Material> free1 = m -> m.isAvailable();
Predicate<Material> free2 = Material::isAvailable;

Comparator<String> alphabetical = String::compareToIgnoreCase;
// equivalent to: (a, b) -> a.compareToIgnoreCase(b)
//   the FIRST argument is the receiver, the rest are the parameters

That last line explains the whole mechanism: in Class::method over an instance method, the first argument of the functional interface is the object the call is made on, and the others are the method's parameters.

Form 4: constructor.

Supplier<Employee> newAnonymous = () -> new Employee("Unassigned", "EMP-000");

// With a one-argument constructor
Function<String, StringBuilder> toBuffer = StringBuilder::new;

// With a multi-argument constructor, using a functional interface of your own
@FunctionalInterface
interface BookCreator {
    Book create(String title, String author, String isbn, int year);
}

BookCreator creator1 = (t, a, i, y) -> new Book(t, a, i, y);   // lambda
BookCreator creator2 = Book::new;                              // reference

Book newBook = creator2.create("Refactoring", "Martin Fowler", "978-0000000003", 1999);
System.out.println(newBook.describe());

Book::new automatically picks the constructor whose signature fits the functional interface's method. Since Book has two constructors (one with four parameters and one with five), the BookCreator interface with four parameters determines which one is used.

Now, the comparators of section 8 with references:

Comparator<Material> byTitle     = Comparator.comparing(Material::getTitle);
Comparator<Material> byTerm      = Comparator.comparingInt(Material::getLoanDays);
Comparator<Material> byTypeTitle = Comparator.comparing(Material::getType)
                                             .thenComparing(Material::getTitle);
Comparator<Material> byTypeDesc  = byTypeTitle.reversed();

That is the line promised at the start of the lesson: sort by type, break ties by title and reverse the lot, in text that reads like an English sentence.

  1. When the reference is more readable than the lambda

Not always. These criteria will save you code-review arguments.

Use it when the lambda only delegates:

m -> m.getTitle()                  →  Material::getTitle           // better
s -> Integer.parseInt(s)           →  Integer::parseInt            // better
l -> receipt.printReceipt(l)       →  receipt::printReceipt        // better
(a, b) -> a.compareTo(b)           →  String::compareTo            // better

Do not force it when there is logic, however small:

// There is an operation: the lambda is mandatory and also clearer
m -> m.calculateFine(20)
m -> m.getTitle().toUpperCase()
m -> !m.isAvailable()

Beware of losing parameter names. Compare:

// Reference: concise, but it does not say what is being compared
Arrays.sort(catalog, Comparator.comparing(Material::getTitle));

// Lambda: explicitly says there are two materials and how they relate
Arrays.sort(catalog, (a, b) -> a.getTitle().compareToIgnoreCase(b.getTitle()));

Here the reference wins, because comparing(Material::getTitle) reads as "comparing by the title". But in cases with several arguments of the same type, the lambda with explicit names can be clearer.

Be consistent within a single expression. Mixing styles in a chain makes it hard to follow:

// BAD: a mixture of styles
Comparator.comparing(Material::getType).thenComparing(m -> m.getTitle())

// GOOD
Comparator.comparing(Material::getType).thenComparing(Material::getTitle)

  1. Receiving behaviour as a parameter: LoanManager

Let us close with the complete application: a class that receives behaviour from outside in order to decide who to notify, what text to send and how to send it, without knowing any of the three things.

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.Loan;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;

/**
 * Coordinates the sending of loan notices.
 *
 * <p>It does not decide who is notified (a Predicate says so), nor what text
 * is sent (a Function says so), nor through which channel it is sent (a Consumer
 * says so). It only orchestrates.</p>
 */
public class LoanManager {

    private final Loan[] loans;              // provisional array: collections in module 5

    public LoanManager(Loan[] loans) {
        this.loans = loans.clone();
    }

    /**
     * Sends one notice per loan meeting the criterion.
     *
     * @param criterion decides WHICH loans are notified
     * @param composer  decides WHAT text is sent
     * @param channel   decides HOW it is sent
     * @return number of notices sent
     */
    public int sendNotices(Predicate<Loan> criterion,
                           Function<Loan, String> composer,
                           Consumer<String> channel) {
        int sent = 0;
        for (Loan l : loans) {
            if (criterion.test(l)) {
                channel.accept(composer.apply(l));
                sent++;
            }
        }
        return sent;
    }

    /** Counts the loans meeting a criterion. */
    public int count(Predicate<Loan> criterion) {
        int n = 0;
        for (Loan l : loans) {
            if (criterion.test(l)) { n++; }
        }
        return n;
    }
}

And its usage, composing criteria out of simple parts:

// --- Base criteria, each with a name explaining its intent ---
Predicate<Loan> overdue  = Loan::isOverdue;
Predicate<Loan> returned = Loan::isReturned;
Predicate<Loan> highFine = l -> l.calculateFine() > 5.0;

// --- Composite criteria ---
Predicate<Loan> overduePending = overdue.and(returned.negate());
Predicate<Loan> urgent         = overduePending.and(highFine);

// --- Composers ---
Function<Loan, String> brief = l -> String.format(
        "[%s] %s - %d days late, %.2f EUR",
        l.getReference(), l.getMaterialTitle(),
        l.calculateDaysLate(), l.calculateFine());

Function<Loan, String> indented = brief.andThen(t -> "  " + t);

// --- Channels ---
Consumer<String> toConsole  = System.out::println;                 // reference, form 2
Consumer<String> withPrefix = t -> System.out.println("NOTICE> " + t);
Consumer<String> both       = toConsole.andThen(withPrefix);       // both at once

// --- Usage ---
LoanManager manager = new LoanManager(loans);

System.out.println("=== All overdue and pending ===");
int n1 = manager.sendNotices(overduePending, indented, toConsole);

System.out.println("=== Only the urgent ones, through two channels ===");
int n2 = manager.sendNotices(urgent, brief, both);

System.out.printf("Sent: %d normal, %d urgent%n", n1, n2);
System.out.println("Overdue and pending: " + manager.count(overduePending));
=== All overdue and pending ===
  [LN-0001] Effective Java - 5 days late, 1.25 EUR
  [LN-0002] Refactoring Live - 17 days late, 8.50 EUR
  [LN-0003] Java Magazine - 13 days late, 1.30 EUR
=== Only the urgent ones, through two channels ===
[LN-0002] Refactoring Live - 17 days late, 8.50 EUR
NOTICE> [LN-0002] Refactoring Live - 17 days late, 8.50 EUR
Sent: 3 normal, 1 urgent

Look back at what LoanManager does not know: it does not know what an urgent loan is, it does not know what text is sent, it does not know whether the channel is the console, an email or a file. It only knows how to walk and coordinate. Changing any of the three decisions does not touch its code, and testing it in module 11 will be trivial: you pass it a Consumer that accumulates the texts into an array and check the result, without capturing System.out.

Reminder: all of this becomes even more compact with the Streams API, where filter(criterion).map(composer).forEach(channel) replaces the whole loop. Streams is lesson 10-04; the previous step, the one you have just taken, is understanding that behaviour is passed as data.

Common Mistakes and Tips

Miscounting the abstract methods. The default, static, private and redeclared public Object methods do not count. That is why Comparator, which declares compare and equals, is still functional.

Putting parentheses in a method reference. Material::getTitle() does not compile. The reference does not invoke: it describes which method to invoke later.

Confusing form 1 with form 3. Class::method means "call the static one" if the method is static, and "call this method on the first argument" if it is an instance method. If you write Material::calculateFine expecting form 3, remember that calculateFine(int) takes a parameter, so the functional interface would need two arguments: the material and the days.

Method reference with fixed arguments. There is no way to write "a reference to calculateFine with 20". For that you need a lambda: m -> m.calculateFine(20).

Swapping andThen and compose. f.andThen(g) runs f first. f.compose(g) runs g first. When in doubt, use andThen.

reversed() in the wrong place. It reverses everything accumulated up to that point in the chain, not only the last criterion.

Inference failures in Comparator.comparing. If the chain does not compile, declare the variable's type (Comparator<Material> c = ...) or use a method reference. It almost always solves the problem.

Invisible autoboxing. Function<Integer,Integer> in a loop of millions of iterations creates millions of objects. Use IntUnaryOperator or ToDoubleFunction<T> when performance matters.

Tip: name your composite predicates. overdue.and(returned.negate()) is correct but opaque. Assign it to a variable called overduePending and the business code reads itself.

Tip: reuse the JDK interfaces. Before writing your own functional interface, check whether Predicate, Function, Consumer or Supplier will do. You get the composition methods for free and any Java programmer understands your signature instantly. Write your own only when the domain name adds clarity (RateRule says more than BiFunction<Material,Integer,Double>) or when the signature fits no standard one.

Exercises

Exercise 1: a catalogue of composite criteria

Starting from the Catalog with find(Predicate<Material>) and count(Predicate<Material>), define five base predicates as public static final constants (available, is a book, short term, high fine at 20 days, title starting with a given letter) and build with them, using and, or and negate, at least four named composite criteria. Show the results over the BiblioTech catalogue.

Exercise 2: the four method references

Write a program using one reference of each of the four forms over the BiblioTech domain:

  1. Static: converting a string "20" into an int for the elapsed days.
  2. Of a specific object: printing a receipt with ConsoleReceipt.
  3. Of an arbitrary object: extracting a material's title.
  4. Of a constructor: creating an Employee from a name and an identifier.

Write next to each one its equivalent lambda in a comment.

Exercise 3: a fully parameterised report

Write a method static void report(Material[] materials, Predicate<Material> filter, Comparator<Material> order, Function<Material,String> format, Consumer<String> output) that filters, sorts and displays. Call it three times with different combinations: (a) books only, by title, brief format, to the console; (b) all of them, by fine descending, format with the fine, to the console with a prefix; (c) the short-term ones, by type and title, full format, accumulating into a StringBuilder printed at the end.

Solutions

Solution 1

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.*;
import java.util.function.Predicate;

public class CatalogCriteria {

    // ---------- Base predicates: simple, reusable parts ----------

    public static final Predicate<Material> AVAILABLE  = Material::isAvailable;
    public static final Predicate<Material> IS_BOOK    = m -> m instanceof Book;
    public static final Predicate<Material> SHORT_TERM = m -> m.getLoanDays() <= 7;
    public static final Predicate<Material> HIGH_FINE  = m -> m.calculateFine(20) > 5.0;

    /** Predicate factory: returns a different one depending on the letter. */
    public static Predicate<Material> startsWith(char letter) {
        return m -> !m.getTitle().isEmpty()
                 && Character.toUpperCase(m.getTitle().charAt(0))
                    == Character.toUpperCase(letter);
    }

    // ---------- Composite criteria: they read like sentences ----------

    public static final Predicate<Material> AVAILABLE_BOOK =
            IS_BOOK.and(AVAILABLE);

    public static final Predicate<Material> ON_LOAN =
            AVAILABLE.negate();

    public static final Predicate<Material> URGENT =
            HIGH_FINE.or(SHORT_TERM.and(AVAILABLE.negate()));

    public static final Predicate<Material> CHEAP_FREE_BOOK =
            IS_BOOK.and(HIGH_FINE.negate()).and(AVAILABLE);

    public static void main(String[] args) {

        Material[] data = {
            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)
        };
        data[0].lend();          // Effective Java goes on loan
        data[4].lend();          // and so does the DVD

        Catalog catalog = new Catalog(data);

        System.out.println("Available books:         " + catalog.count(AVAILABLE_BOOK));
        System.out.println("On loan:                 " + catalog.count(ON_LOAN));
        System.out.println("Urgent:                  " + catalog.count(URGENT));
        System.out.println("Cheap and free books:    " + catalog.count(CHEAP_FREE_BOOK));
        System.out.println("Starting with 'R':       " + catalog.count(startsWith('R')));

        System.out.println("\nAvailable books starting with 'D':");
        for (Material m : catalog.find(AVAILABLE_BOOK.and(startsWith('D')))) {
            System.out.println("  " + m.describe());
        }
    }
}
Available books:         2
On loan:                 2
Urgent:                  1
Cheap and free books:    2
Starting with 'R':       2

Available books starting with 'D':
  Book "Design Patterns" (ref. 978-0000000002) - Erich Gamma, 1994

Three design details. First, AVAILABLE uses a method reference to an arbitrary object (Material::isAvailable), form 3: the material passed to test will be the receiver of the call. Second, startsWith(char) is not a constant but a predicate factory: a method returning behaviour, something that only makes sense now that functions are values. Third, the composite criteria read like business phrases (CHEAP_FREE_BOOK) and each can be tested separately in JUnit without touching the catalogue.

Solution 2

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.domain.*;
import com.nexussoftware.bibliotech.presentation.ConsoleReceipt;
import java.util.function.*;

public class ReferencesDemo {

    /** A functional interface of our own for the two-argument constructor. */
    @FunctionalInterface
    interface EmployeeCreator {
        Employee create(String name, String identifier);
    }

    public static void main(String[] args) {

        // ---- FORM 1: STATIC method ----
        // Equivalent lambda: s -> Integer.parseInt(s)
        Function<String, Integer> toInt = Integer::parseInt;
        int days = toInt.apply("20");
        System.out.println("1. Elapsed days read: " + days);

        // ---- FORM 4: CONSTRUCTOR (used first because the employee is needed) ----
        // Equivalent lambda: (n, id) -> new Employee(n, id)
        EmployeeCreator createEmployee = Employee::new;
        Employee marta = createEmployee.create("Marta Ruiz", "EMP-001");
        System.out.println("4. Employee created: " + marta.getName()
                           + " (" + marta.getInitials() + ")");

        Book effectiveJava = new Book("Effective Java", "Joshua Bloch",
                                      "978-0000000001", 2018);
        Loan loan          = new Loan(effectiveJava, marta, 100);

        // ---- FORM 3: instance method of an ARBITRARY OBJECT of the type ----
        // Equivalent lambda: m -> m.getTitle()
        // The argument passed to apply will be the RECEIVER of getTitle()
        Function<Material, String> toTitle = Material::getTitle;
        System.out.println("3. Title extracted: " + toTitle.apply(effectiveJava));

        // ---- FORM 2: instance method of a SPECIFIC OBJECT ----
        // Equivalent lambda: l -> receipt.printReceipt(l)
        // The 'receipt' object is captured; the argument goes to the method
        ConsoleReceipt receipt = new ConsoleReceipt();
        Consumer<Loan> print = receipt::printReceipt;

        loan.registerReturn(days);
        System.out.println("2. Receipt printed via a reference to a specific object:");
        print.accept(loan);
    }
}
1. Elapsed days read: 20
4. Employee created: Marta Ruiz (M.R.)
3. Title extracted: Effective Java
2. Receipt printed via a reference to a specific object:
========================================
   RETURN RECEIPT - BIBLIOTECH
  Reference:           LN-0001
  Material:            Effective Java
  Employee:            Marta Ruiz
  Days late:           5 days
  Fine:                1.25 EUR
  Severity:            MINOR
========================================

The key to telling form 2 from form 3 lies in what is to the left of ::: if it is a variable (receipt), the object is fixed and the argument goes to the method; if it is a type name (Material), the argument becomes the object the call is made on.

Solution 3

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.*;
import java.util.Arrays;
import java.util.Comparator;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;

public class ParameterisedReport {

    /**
     * Filters, sorts, formats and delivers. The four decisions are parameters:
     * this method knows none of them.
     */
    public static void report(Material[] materials,
                              Predicate<Material> filter,
                              Comparator<Material> order,
                              Function<Material, String> format,
                              Consumer<String> output) {

        // 1. Filter (no collections: maximum-size array and a final trim)
        Material[] selection = new Material[materials.length];
        int n = 0;
        for (Material m : materials) {
            if (filter.test(m)) { selection[n++] = m; }
        }
        selection = Arrays.copyOf(selection, n);

        // 2. Sort
        Arrays.sort(selection, order);

        // 3. Format and deliver
        for (Material m : selection) {
            output.accept(format.apply(m));
        }
    }

    public static void main(String[] args) {

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

        // ---------- (a) Books only, by title, brief format, to the console ----------
        System.out.println("=== (a) Books by title ===");
        report(catalog,
               m -> m instanceof Book,
               Comparator.comparing(Material::getTitle),
               Material::getTitle,                        // reference, form 3
               System.out::println);                      // reference, form 2

        // ---------- (b) All of them, by fine descending, with a prefix ----------
        System.out.println("=== (b) All by fine descending ===");
        report(catalog,
               m -> true,
               Comparator.comparingDouble((Material m) -> m.calculateFine(20)).reversed(),
               m -> String.format("%-24s %6.2f EUR", m.getTitle(), m.calculateFine(20)),
               t -> System.out.println("  > " + t));

        // ---------- (c) Short term, by type and title, accumulating ----------
        System.out.println("=== (c) Short term, accumulated ===");
        StringBuilder accumulator = new StringBuilder();

        report(catalog,
               m -> m.getLoanDays() <= 7,
               Comparator.comparing(Material::getType)
                         .thenComparing(Material::getTitle),
               m -> String.format("%s | %s | term %d days | rate %.2f EUR%n",
                                  m.getType(), m.getTitle(),
                                  m.getLoanDays(), m.getDailyRate()),
               accumulator::append);                      // reference, form 2

        System.out.print(accumulator);
    }
}
=== (a) Books by title ===
Design Patterns
Effective Java
Refactoring
=== (b) All by fine descending ===
  > Refactoring Live           8.50 EUR
  > Java Magazine              1.30 EUR
  > Effective Java             1.25 EUR
  > Design Patterns            1.25 EUR
  > Refactoring                1.25 EUR
=== (c) Short term, accumulated ===
DVD | Refactoring Live | term 3 days | rate 0.50 EUR
Magazine | Java Magazine | term 7 days | rate 0.10 EUR

The remarkable thing about case (c): the output does not go to the console but to a StringBuilder, and the change cost one method reference (accumulator::append). The report method has not changed and could not tell the difference. That is exactly the property that will make module 11's tests trivial: you swap the Consumer for one that accumulates and check the text, without capturing System.out.

And notice the type annotation in (b): (Material m) -> m.calculateFine(20) carries it because comparingDouble followed by .reversed() does not always infer the type from the argument. In (a) and (c) it is not needed, because the method references (Material::getTitle) carry the type with them.

Conclusion

You now have the complete vocabulary of programming with behaviour in Java. You know that a functional interface is one declaring exactly one abstract method, and that default, static, private and redeclared public Object methods do not count towards that tally — which is why Comparator, declaring compare and equals, is still functional. You always use @FunctionalInterface on your single-method interfaces, knowing that it enables nothing but verifies, and that its value lies in making the error appear in the interface rather than in the twenty places using it with lambdas.

You know the java.util.function catalogue and, better than the list, its logic: Function transforms, Consumer consumes, Supplier produces, Predicate decides; the Bi prefix adds a second argument and Operator is the Function whose input and output are of the same type. And you understand why the dozens of primitive variants exist: to avoid autoboxing, which in a loop of a million iterations means a million objects for the collector to clean up.

You have mastered composition, which is where these pieces stop being curiosities and become design: andThen and compose for chaining functions; and, or and negate for building complex business criteria out of simple, named predicates; and Comparator.comparing().thenComparing().reversed(), which turned that six-line composite comparator with an intermediate condition into one line that reads like a sentence. And you have mastered the four forms of method reference with the criterion for telling them apart: what is to the left of :: — a variable or a type name — determines whether the object is fixed or whether the argument becomes the receiver.

Above all, you have written code that receives behaviour as a parameter: a LoanManager that does not know what an urgent loan is, nor what text is sent, nor through which channel, and which nevertheless does exactly what it is asked on every call. Changing any of the three decisions does not touch its code, and testing it will be trivial. Remember that all of this becomes even more compact with the Streams API, where filter().map().forEach() replaces the whole loop: that is lesson 10-04, and you will reach it with the concept already understood.

One last matter remains outstanding in the module, and it has been dragging on since module 3. classifySeverity still returns the strings "MINOR" and "SEVERE", compared with equals throughout the project; Loan.Incident stores its severity as a String it validates by hand in the constructor; and nothing stops someone writing "severe" in lower case, or "CATASTROPHIC", and the compiler will not say a word until the failure turns up in production. Besides, you have written equals, hashCode and toString by hand in every data class, the same twenty lines three times over. In lesson 04-07, Enums and Records, you close the module with the two tools that solve both things: enums, which turn those strings into a closed set of values with type safety, exhaustiveness in the switch and even behaviour of their own per constant; and records, which generate on their own the constructor, the accessors and the three Object methods that cost you so much effort to write in 03-09.

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