Self-Encapsulation — Optimization Drills¶
Category: Object & State Patterns — when the accessor is free, when it isn't, and how to exploit the seam for laziness and caching without paying twice.
Apple M2 Pro, single thread.
Optimization 1: Trust the Inliner (Don't Hand-Inline)¶
Premature "optimization"¶
Done to avoid a "method call." But it destroys the seam.
Optimized — keep the accessor¶
Benchmark (JMH, post-warmup)¶
No cost. HotSpot inlines the trivial getter; the machine code is identical. You kept the seam for free.
Optimization 2: Lazy Initialization Behind the Accessor¶
Eager — pays even when unused¶
class Report {
private final List<Row> rows = buildRows(); // always built
List<Row> getRows() { return rows; }
}
If most Reports never read rows, the build is wasted.
Optimized — lazy seam¶
class Report {
private List<Row> rows;
List<Row> getRows() {
if (rows == null) rows = buildRows(); // built on first access only
return rows;
}
}
Self-encapsulation made this a one-method change; callers of getRows() are untouched. See Lazy Initialization.
Tradeoff¶
- Saves the build when unused.
- Getter now writes → needs synchronization if shared across threads (Optimization 6).
Optimization 3: Memoize a Computed "Field"¶
Slow — recomputes every read¶
double getTotal() {
return items.stream().mapToDouble(Item::amount).sum(); // O(n) per call
}
boolean isLarge() { return getTotal() > 1000; }
String summary() { return "Total: " + getTotal(); } // recomputes again
Optimized — cache behind the same accessor¶
private Double cachedTotal;
double getTotal() {
if (cachedTotal == null)
cachedTotal = items.stream().mapToDouble(Item::amount).sum();
return cachedTotal;
}
// Invalidate on mutation:
void add(Item i) { items.add(i); cachedTotal = null; }
The seam lets you slip a cache in without touching isLarge or summary. Generalizes to Memoization & Caching.
Caveat¶
Only safe if you invalidate (cachedTotal = null) on every mutation — see find-bug Bug 8.
Optimization 4: Python — Hoist Property Reads Out of Hot Loops¶
Slow — property fetched every iteration¶
class Body:
@property
def mass(self) -> float:
return self._mass
def kinetic(bodies):
return sum(0.5 * b.mass * b.v ** 2 for b in bodies) # mass via descriptor each time
Optimized — read the property once per object¶
def kinetic(bodies):
total = 0.0
for b in bodies:
m = b.mass # one descriptor call
v = b.v
total += 0.5 * m * v ** 2
return total
Benchmark (timeit, 1e7 iterations)¶
A Python @property is a real descriptor call (~5× a bare attribute). Inside a hot numeric loop, hoist it into a local. Unlike Java/Go, the Python accessor is not inlined away.
Optimization 5: Use cached_property for Lazy Self-Encapsulated Fields¶
Recompute every time¶
class Document:
def __init__(self, raw): self._raw = raw
@property
def tokens(self):
return self._raw.split() # re-splits on every access
Optimized — compute once, then bare-attribute speed¶
from functools import cached_property
class Document:
def __init__(self, raw): self._raw = raw
@cached_property
def tokens(self):
return self._raw.split() # first access stores into __dict__
After first access, cached_property writes the result into the instance __dict__, so later reads are bare-attribute fast (~25 ns), not descriptor-speed. Only valid when _raw never changes.
Optimization 6: Thread-Safe Lazy Without Lock Contention¶
Correct but contended¶
synchronized List<Row> getRows() { // every read locks
if (rows == null) rows = buildRows();
return rows;
}
Every reader pays lock overhead even after initialization.
Optimized — double-checked locking¶
private volatile List<Row> rows;
List<Row> getRows() {
List<Row> r = rows;
if (r == null) {
synchronized (this) {
r = rows;
if (r == null) rows = r = buildRows();
}
}
return r;
}
Locks only on the (rare) initialization path; warm reads are a single volatile load. In Go, sync.Once gives the same profile with cleaner code.
Benchmark (Java, 8 threads, warm)¶
synchronized getter ~ 18 ns/op (lock per read)
double-checked + volatile ~ 0.8 ns/op (volatile load only)
Optimization 7: Avoid Megamorphic Accessors in Hot Loops¶
Slow — accessor overridden by many subclasses¶
abstract class Shape { abstract double getArea(); } // 6 subclasses override
double total(List<Shape> shapes) {
double s = 0;
for (Shape sh : shapes) s += sh.getArea(); // megamorphic → vtable dispatch
return s;
}
With ≥3 distinct implementations seen, HotSpot stops inlining getArea(); each call is a dispatch and the arithmetic can't be fused.
Optimized — when the loop is genuinely hot¶
// Partition by type so each loop is monomorphic, or
// precompute areas into a double[] once and sum the array.
double[] areas = shapes.stream().mapToDouble(Shape::getArea).toArray();
double s = 0; for (double a : areas) s += a; // monomorphic primitive loop
Benchmark¶
This is the cost of polymorphism, not self-encapsulation — you bought an override seam and paid in inlinability. Optimize only with a profiler in hand.
Optimization 8: Don't Self-Encapsulate Pure Data¶
Over-engineered¶
public final class Point {
private final double x, y;
public double getX() { return x; } // never computed, never overridden
public double getY() { return y; }
}
For an immutable value object with no invariant and no future computation, the accessors are pure noise.
Optimized¶
Reserve self-encapsulation for fields whose representation may change.
Optimization Tips¶
How to find self-encapsulation costs¶
- Confirm inlining. Java:
-XX:+PrintInlining. Go:go build -gcflags=-m. If a trivial getter inlined, its cost is zero. - Profile Python property hotspots with
py-spy/cProfile; descriptor calls show up by name. - Watch for megamorphic sites — a polymorphic accessor in a tight loop is the rare real cost.
- Benchmark before "optimizing" away the seam — you usually lose flexibility for no speed.
Checklist¶
- Keep trivial getters — they inline to free.
- Move expensive build behind a lazy accessor.
- Memoize a computed field behind its accessor (and invalidate on mutation).
- In Python, hoist property reads out of hot loops; use
cached_propertyfor lazy fields. - Use double-checked locking /
sync.Oncefor thread-safe lazy seams. - Precompute or partition to dodge megamorphic accessors in hot loops.
- Use records/dataclasses for pure data instead of accessors.
Anti-optimizations¶
- ❌ Hand-inlining the getter to "save a call" — destroys the seam, saves nothing (JIT already inlines it).
- ❌ Caching a computed field whose inputs mutate — stale data.
- ❌
synchronizedon every read when double-checked locking suffices. - ❌ Eager getters in Python/Go — noise, and Python properties aren't even free.
Summary¶
Self-encapsulation is free on the hot path (the getter inlines to a raw field load) and is the enabler for the real optimizations — lazy initialization and memoization slipped behind the accessor with zero caller changes. The genuine costs are narrow: Python descriptor overhead in tight loops, megamorphic/interface dispatch, and unsynchronized lazy seams. Keep the seam; optimize what sits behind it.
Self-Encapsulation roadmap complete. All 8 files: junior · middle · senior · professional · interview · tasks · find-bug · optimize.
In this topic