functional-reactive
Made in the European Union

Memoizing expensive computations

Wrap a pure function in Memoizer.memoize and the next call with the same arguments returns instantly. Works for Supplier, Function, BiFunction and TriFunction β€” including legacy method references.

May 17, 2026

Memoization caches the output of a function keyed by its input. Call it once with (2, 3) and the body runs; call it again with (2, 3) and the cached value comes back. Idea is simple; the wins are substantial whenever the function is pure and the inputs repeat.

Memoizer.memoize exists in four overloads β€” one for each arity from zero to three.

A first taste

import com.svenruppert.functional.memoizer.Memoizer;
import java.util.function.Function;

Function<Integer, Integer> square = x -> {
    System.out.println("computing " + x);
    return x * x;
};

Function<Integer, Integer> cached = Memoizer.memoize(square);

cached.apply(5);  // prints "computing 5", returns 25
cached.apply(5);  // returns 25 (no print β€” served from cache)
cached.apply(6);  // prints "computing 6", returns 36

square is unchanged; cached is a new function with a private ConcurrentHashMap that maps inputs to outputs.

Memoizing recursive functions

For a recursion to be memoized properly, the recursive call has to go through the memoized reference, not the original lambda:

public class Fib {
    public static Function<Integer, Long> fib;

    static {
        fib = Memoizer.memoize(n ->
            n < 2 ? (long) n : fib.apply(n - 1) + fib.apply(n - 2));
    }
}

Fib.fib.apply(50);   // instant β€” would otherwise be ~12 GB of stack frames

Without memoization, the naΓ―ve Fibonacci hits 12 billion calls for n = 50. With it, each n is computed once.

Two and three arguments

BiFunction and TriFunction memoize via internal currying β€” which means you get partial caching for free:

BiFunction<Integer, Integer, Integer> multiply = Memoizer.memoize((x, y) -> x * y);

multiply.apply(3, 4);   // computed
multiply.apply(3, 4);   // cached
multiply.apply(3, 5);   // inner cache hit on x=3, only y=5 row computed
multiply.apply(7, 4);   // entirely new

Internally Memoizer turns the BiFunction into a Function<Integer, Function<Integer, Integer>>, memoizes both levels, and turns it back into a BiFunction. The same trick scales to TriFunction for three arguments.

Bridging legacy code

A method reference on an instance is just a function value β€” so any legacy method becomes memoizable without touching its declaration:

public class ReportService {
    public Report build(int year, int quarter) { /* expensive */ }
}

ReportService svc = new ReportService();

BiFunction<Integer, Integer, Report> raw    = svc::build;
BiFunction<Integer, Integer, Report> cached = Memoizer.memoize(raw);

cached.apply(2026, 1);
cached.apply(2026, 1);   // served from cache; svc.build never called twice

The original build method is unmodified. Callers who want caching opt in by going through cached; others continue to call svc.build directly.

A Supplier overload

For zero-arg “compute once, reuse forever” cases:

Supplier<HeavyThing> lazy = Memoizer.memoize(() -> buildHeavyThing());

lazy.get();   // builds
lazy.get();   // returns the same instance, no rebuild

This is essentially a thread-safe lazy initialization in one line.

When memoization bites

Three categories of bugs that always trace back to a non-pure function being wrapped:

  1. Side effects silently disappear β€” logging, audit writes, counters fire on the first call only.
  2. Time-dependent results freeze β€” Memoizer.memoize(() -> LocalDateTime.now()) will gleefully return the same timestamp forever.
  3. External state isn’t part of the key β€” if your function reads a config flag, that flag is not in the cache key, so the function’s output can disagree with the world around it.

The fix is always the same: make the function pure, or don’t memoize it.

// ❌ Reads from external state β€” output depends on more than the input
Function<Integer, Integer> rate = id -> id * configService.getMultiplier();

// βœ… The multiplier is now part of the input β€” safe to memoize
BiFunction<Integer, Integer, Integer> rate = (id, multiplier) -> id * multiplier;

Cache lifecycle

Each Memoizer.memoize(...) call returns a function with its own ConcurrentHashMap. The cache lives as long as the returned reference; let it go out of scope and the GC reclaims it.

There is no built-in eviction, size limit, or TTL. If you need any of those, wrap a real cache library (Caffeine, Guava) and expose it as a Function<K, V> to the rest of your code.

Recap

  • Memoizer.memoize(...) exists for Supplier, Function, BiFunction, TriFunction.
  • BiFunction and TriFunction get partial caching via internal currying.
  • For recursion, the recursive call must go through the memoized reference.
  • Only memoize pure functions. Side effects, time-dependence and external state are silent killers.
  • Each memoized function has its own per-call cache β€” no global state, no eviction.

Next