From a7340bea155afde880a637238af70721a64aea5a Mon Sep 17 00:00:00 2001 From: Reed von Redwitz Date: Wed, 22 Jul 2026 07:46:20 +0200 Subject: [PATCH] feat(base-foundation): add Memoizer.computeWith for ad-hoc per-call suppliers --- .../memoizer/AbstractConcurrentMemoizer.java | 8 ++- .../foundation/memoizer/AbstractMemoizer.java | 9 ++- .../base/foundation/memoizer/Memoizer.java | 17 ++++++ .../foundation/memoizer/MemoizerTests.java | 61 +++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractConcurrentMemoizer.java b/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractConcurrentMemoizer.java index bfbd457..ded138a 100644 --- a/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractConcurrentMemoizer.java +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractConcurrentMemoizer.java @@ -81,6 +81,12 @@ protected AbstractConcurrentMemoizer( @Override public final R compute(final T input) { + return computeWith(input, () -> this.function.apply(input)); + } + + @Override + public final R computeWith(final T input, final Supplier supplier) { + Objects.requireNonNull(supplier, "supplier must not be null"); final var key = input == null ? NULL_KEY : input; // fast path — no lock needed on a cache hit @@ -96,7 +102,7 @@ public final R compute(final T input) { if (cached != null) { return unwrap(cached); } - final R result = this.function.apply(input); + final R result = supplier.get(); writeCache(key, wrap(result)); return result; } finally { diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractMemoizer.java b/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractMemoizer.java index 92c698c..9b8d396 100644 --- a/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractMemoizer.java +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/AbstractMemoizer.java @@ -21,6 +21,7 @@ */ import java.util.Map; +import java.util.Objects; import java.util.function.Function; import java.util.function.Supplier; @@ -46,12 +47,18 @@ protected AbstractMemoizer(final Function function, final Supplier this.function.apply(input)); + } + + @Override + public final R computeWith(final T input, final Supplier supplier) { + Objects.requireNonNull(supplier, "supplier must not be null"); final var key = input == null ? NULL_KEY : input; final var cached = readCache(key); if (cached != null) { return unwrap(cached); } - final R result = this.function.apply(input); + final R result = supplier.get(); writeCache(key, wrap(result)); return result; } diff --git a/base-foundation/src/main/java/build/base/foundation/memoizer/Memoizer.java b/base-foundation/src/main/java/build/base/foundation/memoizer/Memoizer.java index d41afeb..742dd1e 100644 --- a/base-foundation/src/main/java/build/base/foundation/memoizer/Memoizer.java +++ b/base-foundation/src/main/java/build/base/foundation/memoizer/Memoizer.java @@ -64,6 +64,23 @@ public interface Memoizer { */ R compute(T input); + /** + * Returns the memoized result for {@code input}, computing it via {@code supplier} on the + * first call for a given input. Unlike {@link #compute(Object)}, the computation is not fixed + * to the memoizer's underlying {@link Function} — this allows callers who don't have (or don't + * want to construct) a {@code Function} up front to still share the same cache, keyed by + * {@code input}. + *

+ * {@code supplier} is only invoked on a cache miss; on a hit, the cached result is returned and + * {@code supplier} is never called. If two callers race on the same {@code input} with different + * suppliers, only one supplier's result is memoized — the other is discarded. + * + * @param input the input; may be {@code null} + * @param supplier supplies the result on a cache miss; must not be {@code null} + * @return the memoized result; may be {@code null} + */ + R computeWith(T input, Supplier supplier); + /** * Clears all memoized results, forcing subsequent calls to recompute. */ diff --git a/base-foundation/src/test/java/build/base/foundation/memoizer/MemoizerTests.java b/base-foundation/src/test/java/build/base/foundation/memoizer/MemoizerTests.java index 8359f07..246f7cd 100644 --- a/base-foundation/src/test/java/build/base/foundation/memoizer/MemoizerTests.java +++ b/base-foundation/src/test/java/build/base/foundation/memoizer/MemoizerTests.java @@ -132,6 +132,67 @@ void shouldReportCorrectSize(final Function, Memoizer< assertThat(memoizer.size()).isEqualTo(2); } + // ------------------------------------------------------------------------- + // computeWith + // ------------------------------------------------------------------------- + + @ParameterizedTest + @MethodSource("allFactories") + void shouldComputeWithSupplierOnFirstCall(final Function, Memoizer> factory) { + final var memoizer = factory.apply(s -> { throw new AssertionError("function should not be invoked"); }); + assertThat(memoizer.computeWith("hello", () -> 5)).isEqualTo(5); + } + + @ParameterizedTest + @MethodSource("allFactories") + void shouldIgnoreSupplierOnCacheHit(final Function, Memoizer> factory) { + final var memoizer = factory.apply(String::length); + memoizer.compute("hello"); + + assertThat(memoizer.computeWith("hello", () -> { throw new AssertionError("supplier should not be invoked"); })) + .isEqualTo(5); + } + + @ParameterizedTest + @MethodSource("allFactories") + void shouldShareCacheBetweenComputeAndComputeWith(final Function, Memoizer> factory) { + final var memoizer = factory.apply(String::length); + memoizer.computeWith("hello", () -> 5); + + assertThat(memoizer.contains("hello")).isTrue(); + assertThat(memoizer.compute("hello")).isEqualTo(5); + } + + @ParameterizedTest + @MethodSource("allFactories") + void shouldRejectNullSupplier(final Function, Memoizer> factory) { + final var memoizer = factory.apply(String::length); + assertThatThrownBy(() -> memoizer.computeWith("hello", null)) + .isInstanceOf(NullPointerException.class); + } + + @ParameterizedTest + @MethodSource("concurrentFactories") + void shouldComputeWithAtMostOnceUnderConcurrentLoad( + final Function, Memoizer> factory) throws InterruptedException { + final var count = new AtomicInteger(); + final var memoizer = factory.apply(s -> { throw new AssertionError("function should not be invoked"); }); + + final var threads = new Thread[20]; + for (int i = 0; i < threads.length; i++) { + threads[i] = new Thread(() -> { + for (int j = 0; j < 50; j++) { + memoizer.computeWith("concurrent", () -> { count.incrementAndGet(); sleep10ms(); return 10; }); + } + }); + } + for (final var t : threads) t.start(); + for (final var t : threads) t.join(); + + assertThat(count).hasValue(1); + assertThat(memoizer.size()).isEqualTo(1); + } + // ------------------------------------------------------------------------- // Construction // -------------------------------------------------------------------------