skip to content

A registry keyed by object identity stops finding an object once it is wrapped for interception, so what broke?

level: seniorimportance: should knowfreq 38%

answer

  1. the stand-in is a second object
  2. contract preserved, identity not
  3. identity keys miss after wrapping
  4. forwarded equality becomes asymmetric
  5. key on an identifier, not a reference

basics

~20 s

The stand-in is a second object, not the target. Identity comparison, identity-keyed lookups and run-time type tests answer about the object the caller is holding, which is now the wrapper rather than the thing it represents.

solid answer

~30 s

Wrapping preserves the **contract**, not the **identity**. Code handed a stand-in holds a reference to a different object that forwards to the original, so a reference comparison reports `not the same`, a container keyed by identity misses, and a run-time type test answers about the stand-in. Equality and hashing survive only as far as the forwarding goes: if the stand-in forwards them but the target compares against itself, the relation becomes asymmetric, which quietly corrupts hash-based containers. The rule that survives wrapping is to compare and key on a **stable identifier the object carries as data**, never on the reference itself.

code

pseudocode · 11 lines
pseudocode
registry = identityKeyedMap()
target = Account()
registry.put(target, metadata)

wrapped = AuditStandIn(target)
registry.get(wrapped)              // miss: a different object is the key
registry.get(target)               // hit

byIdentifier = map()
byIdentifier.put(target.identifier(), metadata)
byIdentifier.get(wrapped.identifier())   // hit: the call forwards to the target

go deeper

for a junior

Recall that a wrapper is a different object from the one it wraps, so anything that asks which object am I holding gets a different answer after wrapping.

for a middle

Explain which operations are answered by the reference rather than by the contract, and why forwarded equality can stop being symmetric.

for a senior

Find it in a live system from a miss rather than an error, and replace identity-based keys and type tests with a stable identifier obtained through a member.

for a principal

Set the convention that identity is not part of any published contract, so teams do not build lookups that a later interception layer silently breaks.

## Wrapping preserves the contract, not the identity The whole point of a stand-in is that it is indistinguishable **through the contract**: same members, same results, plus whatever behaviour was added. Nothing in that promise says it is indistinguishable as an *object*. It is a second object with its own place in memory, its own reference, and its own type, holding the original inside it. Every surprise in this area is that one sentence applied somewhere the code assumed otherwise. ## What changes the moment a reference is wrapped - **Reference comparison** answers about the object you are holding. A caller holding the stand-in and a caller holding the target will disagree about whether they have the same thing. - **A container keyed by identity** stores under the reference used at insertion time. Insert with the raw object and look up with the wrapped one, or the reverse, and you get a miss rather than an error. - **A run-time type test** answers about the stand-in. Whether it still matches the target's concrete type depends on how the stand-in was built, which is a separate subject; the lesson here is that you may not depend on the answer. - **Equality and hashing** depend on forwarding. If the stand-in forwards them to the target, values agree; if it does not, the stand-in falls back to comparing references, and two references to the same target through two stand-ins look unequal. - **Stack traces** grow frames for every layer the call passed through. | operation | answers about | safe under wrapping? | |---|---|---| | reference comparison | the object held | no | | lookup in an identity-keyed container | the reference used as key | no | | run-time type test against a concrete type | the object held | no | | lookup by an identifier the object exposes | the target, via forwarding | yes | | a member call declared by the contract | the target, via forwarding | yes | ## The asymmetry trap The half-fix is the dangerous one. A conscientious stand-in forwards value equality to the target, so asking the stand-in whether it equals the target says yes. Ask the target the same question about the stand-in and, if the target compares by reference or by concrete type, it says no. A relation that answers differently in each direction is not an equality relation, and containers that group, deduplicate or bucket by equality are entitled to assume it is. The result is not a crash but a container that holds what looks like the same element twice, or fails to find an element it contains. Diagnosing it from the symptom is hard precisely because each individual comparison looks defensible. ## The reference that escapes A related leak runs the other way. If a member of the target returns the object it is executing in, the caller now holds a raw reference and every later call through it bypasses the added behaviour permanently. Members that hand back the running object are common in chained, builder-shaped interfaces, which makes them a standing hazard wherever wrapping is used. A careful stand-in checks each returned value and substitutes itself when the target handed back itself, and code that is going to be wrapped is easier to live with if it returns a fresh value instead. ## Reading the thickened stack trace The extra frames are noise when you are reading a failure, and evidence when you are diagnosing coverage. Their presence on a path proves the call really went through the interception layers; their absence on a failing path is a strong hint that the call bypassed them. They also cost something real: a deeper stack on every call, and traces that need filtering before they are readable. Treating them as a defect in the target is a misreading, but treating the interception layers as free is also wrong. ## Rules that survive 1. **Compare and key on a stable identifier the object carries as data**, obtained through a member so that forwarding applies. An identifier is what stayed the same; the reference is exactly what wrapping changed. 2. **Do not key caches or registries on identity across any boundary where wrapping can happen**, which in a wired system is most boundaries. 3. **Do not branch on a run-time type test** to recover the concrete type of something you were handed through a contract. 4. **If you must compare, normalise first**: unwrap to the target on both sides, or compare both sides through the same layer, so the comparison is asked once in one direction.

  • A stack trace now shows several unfamiliar frames between the caller and the target's body. What are they?
    The interception layers the call passed through on the way in. They are noise for reading the failure and a deeper stack on every call, but they are also evidence: present on a covered path, absent on one that bypassed the stand-in, which makes them a useful first check when the added behaviour seems to be missing.
  • A member returns the object it is running in, and the caller keeps that reference. What happens next?
    That reference points at the raw target, so every later call through it skips the added behaviour for good. A stand-in should substitute itself when the target hands back itself, and code written to be wrapped is safer returning a fresh value than returning the running object.
  • What identity rule keeps code correct under wrapping?
    Compare and key on a stable identifier the object exposes as data, not on the reference. Reference comparison answers the question which object am I holding, and wrapping is precisely the operation that changes the answer without changing anything the contract promises.

saying these in an interview costs you the question

  • Assumes the stand-in and the target are the same object because the contract matches.
  • Uses a run-time type test to recover the real type through a wrapper.
  • Keys a cache on object identity across a boundary where wrapping happens.
  • Forwards equality in one direction and expects the relation to stay symmetric.
  • Reads the extra stack frames as evidence of a bug in the target.