Skip to content
academia.sh

Lesson 06 / 15

Functional Interfaces

Single abstract method is a compile-time promise: a two-method interface can never be a lambda target, and the marker only catches a definition that breaks this promise early. The interface's second promise — behavior — gets checked nowhere: the type promise gets compiled in all four members of the family, while the purity and no-side-effects promise can break silently in Predicate and Function.

Contents

The previous topic’s last lesson moved the ordering rule to an externally supplied object: a comparator was a single-method type the caller attached to the collection. The library called it, it never made the decision. This lesson looks at that object itself — a type with a single abstract method, a functional interface — measures what it tells the compiler, and which of its promises no one checks.

Every line of the standard library is a promise, and one of three sources gives that promise: the interface itself, the chosen implementation, or a rule the caller follows. Functional interfaces are this trio’s narrowest example — they carry only one abstract method — and it is exactly this narrowness that lets them be the target of lambda expressions. Does the compiler hold this narrowness, or does it only sit in documentation? The interface carries a second promise too: how the method is supposed to behave. Where does that promise sit, who checks it?

Single Abstract Method Is a Compile-Time Promise

  • FJ1 — Four container forms get tried: a single-method and a two-method interface, each both marked and unmarked with @FunctionalInterface. The source texts get written within the lesson.
  • FJ2 — Whether an interface’s definition compiles gets measured separately from whether a class using that interface as a lambda target compiles; the two do not get mixed.
  • FJ3 — The compiler’s error message text does not get printed; only whether it compiles gets reported, because what gets measured is not the message’s content, it is whether the gate is open.
// Compile.java - shared core: does the given source text compile
import java.io.PrintWriter;
import java.io.Writer;
import java.nio.file.*;
import java.util.spi.ToolProvider;

class Compile {
    static boolean compiles(String source, String className) throws Exception {
        Path dir = Files.createTempDirectory("scale");
        Path file = dir.resolve(className + ".java");
        Files.writeString(file, source);
        PrintWriter silent = new PrintWriter(Writer.nullWriter());
        return ToolProvider.findFirst("javac").orElseThrow()
                .run(silent, silent, "-d", dir.toString(), file.toString()) == 0;
    }
}
// Marker.java - what the @FunctionalInterface marker catches
public class Marker {
    static final String SINGLE = "interface Operation { int apply(int x); }";
    static final String SINGLE_MARKED = "@FunctionalInterface\ninterface Operation { int apply(int x); }";
    static final String TWO = "interface Operation { int apply(int x); int revert(int x); }";
    static final String TWO_MARKED = "@FunctionalInterface\ninterface Operation { int apply(int x); int revert(int x); }";

    static final String[][] ATTEMPTS = {
        {"single method, unmarked", SINGLE},
        {"single method, marked", SINGLE_MARKED},
        {"two methods, unmarked", TWO},
        {"two methods, marked", TWO_MARKED},
    };

    public static void main(String[] args) throws Exception {
        System.out.printf("%-24s %-20s %s%n", "form", "definition compiles", "can be a lambda target");
        for (String[] d : ATTEMPTS) {
            String label = d[0], body = d[1];
            boolean definition = Compile.compiles(body, "Operation");
            boolean lambda = definition && Compile.compiles(
                    body + "\nclass Use { Operation i = x -> x; }", "Operation");
            System.out.printf("%-24s %-20s %s%n", label,
                    definition ? "compiled" : "did not compile",
                    !definition ? "-" : (lambda ? "compiled" : "did not compile"));
        }
    }
}
form                     definition compiles  can be a lambda target
single method, unmarked  compiled             compiled
single method, marked    compiled             compiled
two methods, unmarked    compiled             did not compile
two methods, marked      did not compile      -

Only one of the four rows falls at the definition stage: the two-method, marked interface never compiles at all, because the marker tells the compiler “this interface should have exactly one abstract method,” and the compiler checks that. The other three rows’ definitions compile — an unmarked two-method interface is valid as an ordinary interface too; Java does not forbid an interface from having more than one abstract method.

The real distinction is in the second column. Both single-method interfaces — marked or not — can be a lambda target. The two-method, unmarked interface, even though its definition passes, cannot be a lambda target: the expression x -> x does not say which method it corresponds to, and the compiler cannot resolve it. What the marker does and does not do splits apart right here.

The Marker Adds No Guarantee, It Catches Early

The table confirms a single conclusion: an interface being able to be a lambda target is a structural property — carrying exactly one abstract method — and this property exists or does not, independent of the marker. The two-method, unmarked interface compiles as a definition too, but it was never functional to begin with; no one just said so, until someone tries to use it with a lambda. The error at that moment comes up far from the line where the interface got defined, separately at every point it gets used.

The marker closes this distance. The two-method, marked interface falls at compile time — without waiting for use, at the definition itself. The promise the marker gives is not “this interface is functional,” it is “tell me here if this interface stops being functional.” The interface was already giving the guarantee through its own structure; the marker only moves the moment that guarantee breaks from the use site to the definition site. Types like Runnable, Comparator, Predicate in the standard library carry this marker, but even if you removed the marker, they would still be single-abstract-method interfaces and would still be lambda targets — the marker does not make them functional, it only catches their breakage early.

The Object-Oriented Java course counted four things the compiler rejects: access modifiers, final, checked exceptions, type parameters. The single-abstract-method rule is not a fifth protection added to this list, it is the same protection carried into the standard library — here too, the compiler refuses a structure and reports it at the definition line. But this protection’s scope is narrow: it looks only at the interface’s shape, at how many methods there are. It never looks at what the promise behind the shape is — and this distinction shows up in the measurement below.

The shape itself carries less than it might seem to. In the Object-Oriented Java course, a class implementing an interface got built by name: the interface’s name appears in the implements line. A lambda expression never writes this name at all; the compiler looks not at the target’s name, only at its shape.

// Structural.java - can the same lambda be the target of two interfaces unaware of each other
public class Structural {
    static final String TWO_INTERFACES = """
        interface Multiplier { int apply(int x); }
        interface Converter { int apply(int x); }
        class Use {
            Multiplier m = x -> x * 3;
            Converter c = x -> x * 3;
        }
        """;

    public static void main(String[] args) throws Exception {
        System.out.println("two separate interfaces unaware of each other, same-bodied lambda: "
                + (Compile.compiles(TWO_INTERFACES, "Multiplier") ? "compiled" : "did not compile"));
    }
}
two separate interfaces unaware of each other, same-bodied lambda: compiled

Multiplier and Converter do not know each other at all; there is no inheritance between them, no shared supertype — both only carry one abstract method of the same shape. The same lambda expression compiles for both. This differs from the behavior of a class implementing an interface: there, the name is binding; here, the shape is enough. The promise the compiler checks is exactly this narrow: not name, not lineage, only parameter count, parameter types, and return type.

Default, Static, and Object Methods Do Not Count

The single-abstract-method rule does not say “single method,” it says “single abstract method.” An interface can carry methods with a default body (default) or static methods; these do not count as abstract and do not enter the count — because the caller does not have to implement them, the library has already written them.

  • FJ4 — Two containers get tried: one adds a default and a static method next to an abstract method; the other redeclares two of Object’s own methods (equals, toString) as abstract next to an abstract method. Both are marked with @FunctionalInterface.
  • FJ5 — For both containers, whether the definition compiles and whether it can be a lambda target get measured separately.
// DoesNotCount.java - default, static, and Object methods do not enter the abstract count
public class DoesNotCount {
    static final String MULTI_MEMBER = """
        @FunctionalInterface
        interface Operation {
            int apply(int x);
            default int doubled(int x) { return apply(x) * 2; }
            static Operation identity() { return x -> x; }
        }
        """;

    static final String OBJECT_SIGNED = """
        @FunctionalInterface
        interface Operation {
            int apply(int x);
            boolean equals(Object o);
            String toString();
        }
        """;

    public static void main(String[] args) throws Exception {
        System.out.println("one abstract + two default/static methods : "
                + (Compile.compiles(MULTI_MEMBER, "Operation") ? "compiled" : "did not compile"));
        System.out.println("one abstract + two of Object's methods    : "
                + (Compile.compiles(OBJECT_SIGNED, "Operation") ? "compiled" : "did not compile"));

        boolean lambdaMultiMember = Compile.compiles(
                MULTI_MEMBER + "\nclass K { Operation i = x -> x; }", "Operation");
        boolean lambdaObjectSigned = Compile.compiles(
                OBJECT_SIGNED + "\nclass K { Operation i = x -> x; }", "Operation");
        System.out.println("first can be a lambda target               : "
                + (lambdaMultiMember ? "compiled" : "did not compile"));
        System.out.println("second can be a lambda target              : "
                + (lambdaObjectSigned ? "compiled" : "did not compile"));
    }
}
one abstract + two default/static methods : compiled
one abstract + two of Object's methods    : compiled
first can be a lambda target               : compiled
second can be a lambda target              : compiled

All four lines compile. The first container gives an expected result: doubled and identity are not abstract, they have bodies, they do not enter the count — the interface is still single-abstract-method, and the marker raises no objection. The second container is less intuitive: even though equals and toString get redeclared as abstract, the compiler does not count them, because both match a public method of the Object class, and every implementation already provides these methods indirectly by inheriting from Object. The rule does not see three abstract declarations, it sees that two of the three declarations already have a counterpart in Object, and counts only apply.

This measurement shows the single-abstract-method rule is a slightly narrower rule than it sounds: what gets counted is not “the count of abstract declarations in the interface,” it is “the count of methods every implementation would have to rewrite.” Object’s methods already exist on every class, so they do not enter the rewriting obligation. A familiar example from the previous topic gets clarified here: Comparator<T> carries around a dozen default and static methods (reversed, thenComparing, naturalOrder) and is still functional, because the only place the caller has to rewrite stays compare. Member count and abstract method count are not the same thing.

The Interface’s Behavior Promise Is Checked Nowhere

A functional interface carries two separate promises. The first is the signature — how many parameters, of what type, what it returns — and this promise gets held at compile time, as measured in the tables above. The second is behavior: Predicate expects “give the same result for the same input,” Function expects “create no side effect.” Which layer does this second promise sit in?

  • FJ6 — Four members of the standard family get taken as examples: Predicate, Function, Supplier, Consumer. The rest of the family follows the same pattern.
  • FJ7 — A purity violation gets made visible with a call counter, a side-effect violation with an accumulator; both are helper state written only for this measurement.
// Meaning.java - the interface's type promise and behavior promise are not checked in the same place
import java.util.function.*;

public class Meaning {
    static int callCount = 0;

    public static void main(String[] args) {
        Predicate<Integer> p = x -> x > 0;
        Function<Integer, Integer> f = x -> x * 2;
        Supplier<Integer> s = () -> 42;
        Consumer<Integer> c = x -> { };
        System.out.println("all four type promises matched, all four compiled");
        System.out.println();

        Predicate<Integer> unstable = x -> { callCount++; return callCount == 1; };
        System.out.printf("same input, first call   : %s%n", unstable.test(5));
        System.out.printf("same input, second call  : %s%n", unstable.test(5));

        StringBuilder log = new StringBuilder();
        Function<Integer, Integer> impure = x -> { log.append(x).append(','); return x + 1; };
        impure.apply(1);
        impure.apply(2);
        System.out.printf("did it accumulate a side effect : %s%n", !log.isEmpty());
    }
}
all four type promises matched, all four compiled

same input, first call   : true
same input, second call  : false
did it accumulate a side effect : true

The first line confirms once more where the type promise sits: all four variables matched their interface’s signature, the compiler checked this, and all four compiled. The remaining three lines test the second promise. A Predicate named unstable gives two separate results on two separate calls with the same input (5) — first true, then false — and the compiler never got involved, and runtime never raised an exception either. A Function named impure writes to a StringBuilder outside its own scope every time it gets called; this too got blocked neither while compiling nor while running.

The consequence is silent: Predicate.test does not throw an exception because it fails to “give the same result for the same input,” it runs a misbehaving implementation as it is, unchanged. The family’s type promise got confirmed in the first line above: all four members matched their own signature, all four compiled. The behavior promise, though, is not distributed evenly across the family. For Predicate and Function, purity and no-side-effects are real documented promises, and both just got broken, both stayed silent — the compiler did not intervene, runtime raised no exception. For Supplier, the documentation is even looser: there is no requirement at all about producing the same or a different value on each call, so there is not even a promise to break there. Consumer, on the other hand, is built the opposite way — its purpose is a side effect to begin with, the documentation does not count that as a defect, does not treat it as a promise. The one general rule that fits the table: the compiler keeps the type promise complete in all four members of the family; the behavior promise is not uniform across the family, and even where it holds, no layer checks it.

This repeats an observation built in the previous lesson (Comparison and Sorting), but on a different surface: there, a non-transitive comparator was measured producing a silently wrong order on a small input. Here, the same silence shows up on a surface much smaller and much more frequently used than sorting — a one-line lambda expression. However narrow a functional interface is, that narrowness does not turn into a behavior guarantee.

Summary

  • An interface being able to be a lambda target is a structural condition: exactly one abstract method. This condition gets held at compile time, independent of the marker.
  • The @FunctionalInterface marker adds no guarantee; it only catches this structural condition’s violation early, by moving it from the use site to the definition site.
  • A two-method, unmarked interface compiles too, but cannot be a lambda target — not being functional holds without the marker too, it just gets noticed later.
  • Functional interfaces carry a second promise: behavior (purity, no side effects). This promise gets checked neither by the compiler nor at runtime; its violation is silent, it raises no exception.
  • The standard family’s type promise sits with the compiler in all four members; the behavior promise is not uniform across the family — it can break in Predicate and Function and stays silent when it does, and for Supplier such a promise does not even exist in the documentation.

Next Step

This lesson looked at the functional interface itself: where single abstract method gets held, where the second promise does not. When a lambda expression gets written, at that type’s site it does one more thing: it captures the names around it. The next lesson measures what gets captured when it is written at that site — is capturing a local variable bound to the same constraint as capturing a field, and do the two produce the same result?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close