Sharing and memoization
Two distinct concerns sit under this heading: within a single make
call (does each consumer of T see the same value?) and across make
calls (does the same registry, called twice, return the same value?).
The library distinguishes them clearly.
Within a make call: shared by default
The resolver caches the value of every entry it invokes for the duration of
one make call. Every consumer of the same type gets the same instance —
matching the behavior most DI users expect.
import registry.*
class Counter:
override def toString = s"Counter#${System.identityHashCode(this)}"
case class Pair(a: Counter, b: Counter)
val r =
fun[Pair] +:
fun(() => new Counter)
val p = r.make[Pair]
// p: Pair = Pair(a = Counter#1435601619, b = Counter#1435601619)
p.a eq p.b // true — one Counter for both fields
// res0: Boolean = true
The cache lives only for the duration of one make call. A second call
gets a fresh cache and a fresh Counter:
val p2 = r.make[Pair]
// p2: Pair = Pair(a = Counter#230343938, b = Counter#230343938)
p2.a eq p.a // false — different make calls
// res1: Boolean = false
Opting out: fresh
If a type is genuinely meant to differ per consumer (UUIDs, timestamps,
fresh request IDs), mark the entry .fresh to bypass the per-make cache:
val freshPerConsumer =
fun[Pair] +:
fun(() => new Counter()).fresh
val fp = freshPerConsumer.make[Pair]
// fp: Pair = Pair(a = Counter#1627451839, b = Counter#1241150)
fp.a eq fp.b // false — each consumer triggers a fresh invoke
// res2: Boolean = false
You can also opt out at the registry level with r.fresh[A], which sets
the flag on every entry whose output is a subtype of A.
Across make calls: memoize
r.memoize[A] makes every entry whose output is a subtype of A
survive across make calls. The cache is backed by an
AtomicReference that travels with the entry through any subsequent
combinator.
val pooled = r.memoize[Counter]
val q1 = pooled.make[Pair]
// q1: Pair = Pair(a = Counter#1181648979, b = Counter#1181648979)
val q2 = pooled.make[Pair]
// q2: Pair = Pair(a = Counter#1181648979, b = Counter#1181648979)
q1.a eq q2.a // true — Counter pinned across make calls
// res3: Boolean = true
r.memoizeAll is the shortcut for memoizing every entry. entry.memoize
sets it on a single entry inline. Each call to memoize returns a new
registry with a fresh cache; the original is unaffected.
For effectful types (F[A]), memoize caches the effect value, not
the result of running it. Whether re-running yields the same A is F’s
concern (use IO.memoize inside the entry to share the result).
Clearing memoized state with reset()
The cache backing memoize[A] lives in the entry’s closure — r.memoize[A]
returns a registry whose entries hold the cache, and that cache survives
the registry value as long as the registry is reachable. If the registry
is shared across independent test runs (e.g. a lazy val used by every
property in a file), every run after the first observes the first run’s
cached value.
r.reset() mutates each entry in place, clearing every memoize / const
cache without rebuilding the registry. The registry value is returned
unchanged so it can chain. Call it at the start of each test that should
get fresh memoized values:
val cleared = pooled.reset()
Entries with no mutable state (the default plain entries) are unaffected —
reset() is a no-op on them.
How the two interact
| You want | Use |
|---|---|
One value per make (default) |
nothing — it’s already shared |
Fresh value per consumer in a make |
entry.fresh or r.fresh[A] |
Same value across all make calls |
r.memoize[A] or entry.memoize |
Fresh per consumer AND per make |
entry.fresh (no cross-make cache stays) |
| Clear cached / pinned state between test runs | r.reset() |
The shared flag (scalacheck)
There’s a third, orthogonal concept that lives in registry-scalacheck:
sampling-time pinning.
When you have a Gen[A] consumed in two positions of a generated tree,
the resolver’s per-make cache shares the Gen instance — but Gen
samples non-deterministically per flatMap step, so each position still
draws an independent value. If you want the two positions to observe the
same sampled value, set the shared flag on the entry:
import registry.scalacheck.*
import org.scalacheck.Gen
case class Bundle(a: Int, b: Int)
val r = gen[Bundle] +: gen(Gen.choose(0, 1_000_000)).share
// r.makeGen[Bundle] now samples the Int once and pins it for both fields.
Mechanism: when any entry has shared = true, makeGen[T] takes a
different build path that samples each shared Gen[A] once at the outer
level and prepends a value-style entry producing Gen.const(sampled)
before resolving the rest. LIFO selection makes the pinned entry win.
Details in modules/scalacheck.md.
share pins one sample per makeGen call. Its stronger sibling, const,
also pins across separate makeGen calls (sample once, hold forever).
Markers (Memoize[T] / Share[T] / Const[T])
These are internal value types backing the per-type marker factories you
see in the ScalaCheck integration (memoize[T] / share[T] / const[T]).
At the core level you don’t construct them directly — the methods on
Registry and TypedEntry cover everything core can express on its own.