Passing Functions as Data

intermediate30 min

The repetition problem

Suppose you need to filter a list of sensor readings several different ways.

static List<Integer> above100(List<Integer> readings) {
    List<Integer> result = new ArrayList<>();
    for (int r : readings) {
        if (r > 100) result.add(r);
    }
    return result;
}

static List<Integer> evenOnly(List<Integer> readings) {
    List<Integer> result = new ArrayList<>();
    for (int r : readings) {
        if (r % 2 == 0) result.add(r);
    }
    return result;
}

These methods are identical except for one line. Everything else — creating the list, looping, adding, returning — is duplicated.

You already know how to pass a value into a method to avoid repetition. What you need here is to pass the test itself.

Behaviour as a parameter

Java has no standalone function type. What it has is interfaces, and an object implementing a one-method interface serves the same purpose.

interface ReadingTest {
    boolean matches(int reading);
}

Now the loop is written once, and the differing line becomes a parameter:

static List<Integer> filter(List<Integer> readings, ReadingTest test) {
    List<Integer> result = new ArrayList<>();
    for (int r : readings) {
        if (test.matches(r)) {      // the caller decides what this means
            result.add(r);
        }
    }
    return result;
}

Calling it means supplying an implementation:

ReadingTest above100 = new ReadingTest() {
    @Override
    public boolean matches(int reading) {
        return reading > 100;
    }
};

List<Integer> high = filter(readings, above100);

That block is an anonymous class — a class with no name, defined and instantiated in one expression. It is verbose, and Lambda Expressions exist precisely to shorten it. But this is what is really happening underneath, and seeing it once makes lambdas much less mysterious.

Why an interface is required

In some languages you can pass a function directly. In Java, the thing you pass is always an object. The interface gives that object a type, and runtime polymorphism picks the right matches implementation when filter calls it.

So “passing a function” in Java is really “passing an object with one interesting method”. Every lambda you write is compiled into something along these lines.

What this buys you

The filter method now works with tests that did not exist when it was written:

List<Integer> even = filter(readings, new ReadingTest() {
    @Override public boolean matches(int r) { return r % 2 == 0; }
});

List<Integer> inRange = filter(readings, new ReadingTest() {
    @Override public boolean matches(int r) { return r >= 50 && r <= 150; }
});

One loop, any number of behaviours. The pattern generalises beyond filtering — anywhere you find near-identical methods differing by a line or two, the difference can usually become a parameter.

Functional interfaces

An interface with exactly one abstract method is called a functional interface, and it is the shape Java uses for this everywhere.

@FunctionalInterface
interface ReadingTest {
    boolean matches(int reading);
}

The annotation is optional but worth adding. It makes the compiler reject the interface if someone later adds a second abstract method — which would silently break every lambda written against it.

Java already ships the common shapes, so you rarely define your own. Those are covered in Supplier and the Functional Interfaces.

Deferring work

There is a second use, distinct from removing duplication: describing work now and running it later.

interface Action {
    void run();
}

class ActionQueue {
    private final Queue<Action> pending = new ArrayDeque<>();

    void schedule(Action action) {
        pending.offer(action);           // stored, not executed
    }

    void runAll() {
        while (!pending.isEmpty()) {
            pending.poll().run();        // executed now
        }
    }
}

The action is created at one point in the program and executed at another. That separation is the foundation of event handlers, callbacks, and the Command Pattern — and it is how FRC’s command-based framework schedules robot behaviour.

Two distinct motivations

  • Removing duplication — the surrounding code is fixed, one step varies. Pass the varying step in.
  • Deferring execution — you know what should happen but not yet when. Store the behaviour and run it later.

Both use the same mechanism. Recognising which one you need helps you name things sensibly.

Returning behaviour

A method can also hand back an implementation, which lets you build behaviour from parameters:

static ReadingTest greaterThan(int threshold) {
    return new ReadingTest() {
        @Override
        public boolean matches(int reading) {
            return reading > threshold;      // captures threshold
        }
    };
}

List<Integer> high = filter(readings, greaterThan(100));
List<Integer> veryHigh = filter(readings, greaterThan(500));

The returned object remembers threshold — a captured variable. Note the restriction: a captured local variable must be effectively final, meaning you cannot reassign it after capture. Java enforces this at compile time.

Common mistakes

  • Adding a second abstract method to a functional interface, breaking every lambda using it. @FunctionalInterface prevents this.
  • Reassigning a captured local variable, which is a compile error.
  • Calling the behaviour when you meant to store it. schedule(action) stores; action.run() executes.
  • Over-applying the pattern. If a method has exactly one caller and one behaviour, parameterising it adds indirection for no benefit.

Practice

Use anonymous classes throughout — lambdas come in the next lesson.

  1. Define a ReadingTest interface and a filter method, then filter a list three different ways.
  2. Define a Transformer interface with int apply(int value) and write a map method that applies it to every element of a list.
  3. Write a greaterThan(int) method that returns a ReadingTest, and use it twice with different thresholds.
  4. Define an Action interface and an ActionQueue that stores actions and runs them in order.
  5. Write a sort helper that takes a list and a comparison interface you define yourself, so the caller decides the ordering.
Hints
  1. As written above.
  2. Same loop shape; call apply and add the result.
  3. The returned anonymous class captures the parameter.
  4. Store in an ArrayDeque; runAll polls and calls run().
  5. Your interface needs a method returning a negative number, zero, or a positive number. Then any simple sort works using it for comparisons. This is exactly how Comparator is built.

Next