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:
- Side effects silently disappear β logging, audit writes, counters fire on the first call only.
- Time-dependent results freeze β
Memoizer.memoize(() -> LocalDateTime.now())will gleefully return the same timestamp forever. - 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 forSupplier,Function,BiFunction,TriFunction.BiFunctionandTriFunctionget 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
MemoizerAPI reference.- Composing functions β currying is what makes multi-arg memoization work.