A hash map is keyed by mutable objects another team owns — how do you keep lookups correct?
answer
- the fix belongs at the key, not the caller
- what on this object never changes?
- copy the identifying fields at insert
- count the cost per insert and per probe
- a convention is not an enforcement
basics
~20 sKey by something that cannot change: a stable identifier the object already carries, or an immutable snapshot key holding copies of the identifying fields, built at insert. Asking callers not to mutate is not a defense.
solid answer
~50 sI stop putting the foreign object in the table at all. The cheapest fix is to key by a stable identifier it already carries — a booking id, an assigned ordinal — which is immutable by nature and decouples the map from everything else on the object. If no such identifier exists, I build a small immutable snapshot key at insert time, copying the identifying fields; that costs one allocation and a field copy per insert and per lookup probe, which is negligible unless the key is large or the map sits on a very hot path. Getting the owning team to freeze the type is the better long-term fix but not one I can land today. What I will not do is write down a "please don't mutate keys" rule: it is unenforceable across call sites, invisible in review, and it fails the moment a reference is shared.
go deeper
Know the safe default before the tradeoffs: only use something that cannot change as a key. If the object you have can change, key by its stable identifier instead.
Explain each defense mechanically — why a stable identifier removes the problem, what a snapshot key copies, and why the copy has to happen on the lookup path too, not only at insert.
Rank the options by whether they depend on human discipline, quantify the per-insert and per-probe cost of copying, insist that nested mutable state inside a snapshot reopens the hole, and say how you would verify the diagnosis before changing code.
Own the call across teams: push the shared type toward frozen identifying state because it fixes every consumer at once, ship a local snapshot key meanwhile, and be willing to defend the allocation on a hot path as the price of removing a silent-corruption class.
## The situation A scheduling service keeps a map from booking objects to computed availability, and the booking type comes from a shared model library another team owns. Its fields — resource, start, end — are writable, and somewhere in the pipeline a normalization step rounds start times to the minute in place. Entries stored before that step are stranded in the bucket their old values chose, lookups miss, and the map slowly fills with entries whose keys compare equal to one another. You cannot change the type this week. So the question is which defense to take, and what each one costs. ## Option 1 — key by a stable identifier (usually the right answer) If the object already carries an identifier that is assigned once and never rewritten — a booking id, a sequence number, a natural composite that is genuinely fixed — key the map by *that* and hold the object as the value, or on the side. The map then depends on one field that no normalization pass will touch. Cost: essentially zero. Hashing a small identifier is cheaper than hashing several fields, buckets get shorter, and the map becomes independent of every future field the owning team adds. The reason to look for this option first is that it removes the whole class of problem rather than containing it. Watch for one trap: the identifier must be **stable and unique for the identity you mean**. If two logically distinct bookings can share it, or if it is assigned late (null or zero until persisted), you have replaced a stranding bug with a collision-of-meaning bug. ## Option 2 — an immutable snapshot key (defensive copy at insert) Build a small key type of your own holding copies of exactly the fields that define identity, construct it at insert, and construct one again for every lookup probe. Its state is set once and never rewritten, so its hash and its equality comparison agree forever, whatever the source object does afterwards. Cost, honestly stated: - **One small allocation plus a field copy per insert** — and the same per lookup probe, which is the part people forget when they estimate. - **Hashing the copied fields**, which you were paying anyway. - **Memory** proportional to the number of live entries times the snapshot size. For a handful of scalar or already-immutable fields this is noise next to the hashing and bucket walk you already do. It stops being noise when the identifying state is large — long text, nested collections — in which case fall back to option 1, or key on a digest of the content if you can accept the (astronomically small, but non-zero) collision semantics that implies. The correctness detail that decides whether the snapshot actually works: it must copy **every field that participates in either the hash or the equality comparison**, and any nested reference must be copied or replaced with an immutable value. A shallow snapshot holding a mutable child re-opens exactly the hole you set out to close — the child mutates, the child's contribution to the hash changes, and the snapshot is stranded like the original was. ## Option 3 — the remove-mutate-reinsert protocol Correct in principle: take the entry out, mutate, put it back, and the table re-places it with the new hash. It costs nothing in memory and requires no new type. It is also the weakest defense in practice, and you should say so. It depends on every call site remembering, forever, including the ones written next year by someone who does not know the map exists. It fails the moment a reference is shared, because the mutation can happen through a path that has no idea it is holding a key. It does nothing about entries already stranded before the rule was introduced. And it is invisible in review — the dangerous code is a plain field write that looks like every other field write. Use it only as a stopgap while a real fix lands, and only when the mutation sites are few and local. ## Option 4 — get the type frozen The real fix: identifying fields are set at construction and never rewritten, so the type cannot become a stale key at all. It removes the problem for every consumer of the shared library, not just yours, which is the argument to make when you file it. It also usually enables hash caching as a follow-on optimization, since a frozen key's hash can be computed once. It is not a defense you can deploy today, so file it and ship option 1 or 2 now. Both compose fine with the eventual freeze — a snapshot key over an immutable source is redundant, and deleting redundant code later is a good problem to have. ## What a strong answer sounds like Name the failure precisely (entries stranded in the bucket their old values chose, lookups missing, equal-comparing duplicates accumulating), rank the defenses by whether they depend on human discipline, quantify the copy cost rather than hand-waving it, and be explicit that documentation is not a control. Then say how you would verify: assert that the map's size matches the count of distinct identities, or reconcile lookups against a linear scan in a canary build, so the silent version of this bug becomes a loud one.
- What does the remove-mutate-reinsert protocol buy you, and where does it fail?It is correct when followed: removal takes the entry out under the old hash, and re-insertion files it under the new one. It fails because it relies on every call site remembering forever, it is invisible in review since the dangerous line is an ordinary field write, it breaks the moment a reference is shared, and it does nothing about already-stranded entries.
- How much does a snapshot key really cost per insert?One small allocation plus copying the identifying fields, plus hashing them — and you pay the same on every lookup probe, which people routinely forget to count. For a few scalar fields that is noise against the bucket walk. For large text or nested structures it is not, and you should key on a stable identifier instead.
- You have adopted snapshot keys — what else must you check?That the snapshot copies every field feeding the hash or the equality comparison, and that nested references are copied or replaced with immutable values. A shallow snapshot holding a mutable child strands exactly like the original object did. Also confirm the probe path builds the same snapshot, or lookups will never match.
- How would you prove this was the cause before changing anything?Compare the map's size against the count of distinct identities — a gap means equal-comparing duplicates accumulated. Or, in a canary build, fall back to a linear scan when a lookup misses and log the hits: an entry the scan finds but the hash lookup does not is a stranded key, near-conclusively.
It is the difference between asking every borrower to please not re-title the books, and stamping a permanent catalogue number on the spine when the book arrives.
saying these in an interview costs you the question
- Documents a rule that callers must not mutate keys
- Calls a shallow copy of the object sufficient
- Builds the defensive copy only on the lookup path
- Claims locking or synchronization prevents stale buckets
- Expects the map to self-correct on the next resize
- Cannot state the per-insert cost of a snapshot key