skip to content

An association is navigable in both directions: an order knows its customer and the customer knows their orders. What does maintaining that two-way link actually cost, and why is the same shape far harder in some languages than in others?

level: seniorimportance: should knowfreq 46%

answer

  1. Second direction = derived index, not a second fact
  2. One owning end, one mutator, mappedBy/inverse_of
  3. Tracing GC collects islands; ARC does not
  4. shared_ptr both ways leaks, weak_ptr back
  5. Rust: Rc+Weak or arena ids = relational model

basics

~20 s

Two costs: an invariant (both ends must always agree, so one mutator owns both writes) and a reclamation cost that depends on the runtime. Java, C# and Go collect the cycle for free; Swift and C++ shared_ptr leak unless one end is weak; Rust rejects the shape and pushes you to Weak or index tables.

solid answer

~50 s

The second direction is derived data - a denormalised index over the first - so it needs one owning mutator, and it needs the runtime to tolerate a cycle. - **Java, C#, Go**: tracing collectors reclaim unreachable cycles, so the runtime cost is zero and only the invariant remains. That is why JPA and Entity Framework make you nominate one owning side and declare the other inverse (`mappedBy`, `[InverseProperty]`). - **Swift**: ARC has no cycle collector, so two strong ends leak deterministically; you must mark one `weak` or `unowned`, which forces navigability and ownership into the source. - **C++**: `shared_ptr` both ways leaks identically; the idiom is `shared_ptr` down and `weak_ptr` back, trading a leak for a lock-and-check on every traversal. - **Rust**: two owning links are impossible. You choose `Rc<RefCell<T>>` plus `Weak` and accept runtime borrow panics, or index tables (`Vec` plus ids) - at which point your object graph has become a relational model.

code

swift · 4 lines
swift
class Customer { var orders: [Order] = [] }        // strong: owns
class Order { unowned let customer: Customer }     // must NOT be strong

// two strong ends here would never deinit: ARC has no cycle collector

go deeper

for a junior

Know that both ends must be kept in sync and that a single method should update both, rather than two independent setters.

for a middle

Add the runtime dimension: which memory models tolerate the cycle, and why persistence mappings insist on an owning side.

for a senior

Diagnose it - identify the owning end, explain ARC and shared_ptr leaks versus tracing collection, and name the long-lived-listener leak that survives even under tracing GC.

for a principal

Question whether the back-edge should exist at all: it is denormalisation, and in Rust-style ownership models the same pressure turns object graphs into indexed tables. Decide deliberately between traversal and query.

## What bidirectional navigability means Navigability is a property of each association *end*: it says that end can be reached from the other. A one-way association gives you one pointer; making both ends navigable gives you two, and the second is not free information - it is a second copy of the same fact. Everything that follows comes from that: one fact, two storage locations, in a runtime that may or may not tolerate the resulting cycle. ## Cost one: the invariant If `order.customer == c` then `c.orders` must contain that order, always. Two public setters cannot maintain that; the standard fix is to nominate one **owning end** and route every mutation through a single method on it (`customer.addOrder(o)` sets both sides), leaving the other end read-only to the outside world. Persistence frameworks formalise exactly this, because a database row has only one foreign key: JPA marks the inverse side `mappedBy` and simply ignores writes to it, Entity Framework pairs navigation properties with `[InverseProperty]` or fixes them up in the change tracker, Rails pairs `belongs_to` with `has_many` plus `inverse_of`. Skip that and you get the classic bug where an in-memory collection disagrees with the foreign key until you reload. ## Cost two: reclamation, and this is where languages part company **Tracing collectors** - the JVM, the CLR, Go, JavaScript engines - start from roots and mark what is reachable. An island of mutually-referencing objects with no path from a root is unreachable and is collected as a unit. So on those runtimes a bidirectional association costs nothing at reclamation time. The only lifetime hazard is a *long-lived* back-reference: a listener registered on a global bus, or a cache keyed by the parent, keeps the entire island alive. That is what weak references, `WeakHashMap`, and .NET's `ConditionalWeakTable` exist for. **Reference counting without a collector** - Swift's ARC - cannot see the island at all. Each object's count stays at one because the other end holds it, so `deinit` never runs and the memory is gone for the process's lifetime. Swift therefore forces the modelling decision into the code: the owning end holds a strong reference, the other end is `weak` (optional, nils out when the target dies) or `unowned` (non-optional, traps if you touch it after the target dies). A Swift codebase's reference strengths *are* its aggregation diagram. **C++** with `shared_ptr` behaves like ARC and leaks the same way. The idiom is `shared_ptr` in the ownership direction and `weak_ptr` back, which every traversal must `lock()` and check - a real ergonomic tax, but one that makes the dangling case explicit rather than undefined. **CPython** sits in between: it refcounts, so most objects die promptly, but it also runs a generational cycle detector, so the two-way link is eventually collected - non-deterministically, which matters if the objects hold non-memory resources. (Since PEP 442 in Python 3.4, objects with `__del__` are no longer uncollectable, so the old "cycle with a finaliser leaks" folklore is out of date.) **Rust** refuses the shape outright. Ownership is unique, so two owning edges cannot both exist, and a borrow cannot outlive its owner. The two answers Rust programmers actually reach for are: `Rc<RefCell<T>>` for the owning direction plus `Weak` for the back-edge - which moves borrow checking to runtime and can panic on an aliased `borrow_mut` - or, much more commonly for anything graph-shaped, an **arena**: store every node in a `Vec` and let both directions be integer ids. That second option is worth naming for what it is: the object model has been replaced by a relational one, with tables and keys, precisely because the language will not let identity-based cycles be expressed cheaply. ## What to conclude Ask first whether the second direction earns itself. It is derived, so every traversal you add is a cache you must invalidate; a query ("find orders where customer = c") often replaces it entirely, and that is why many domain models keep only the child-to-parent edge. If you do keep both, name the owning end explicitly, funnel mutation through it, and then check what your runtime charges: nothing on the JVM, the CLR and Go beyond discipline; a mandatory `weak`/`unowned` choice in Swift and with `shared_ptr` in C++; and in Rust, a decision between runtime-checked interior mutability and turning the graph into indexed tables.

  • If a tracing collector handles cycles, when can a back-reference still cause a leak on the JVM or CLR?
    When the back-reference is held by something long-lived: a static registry, an event bus the child registered with, a cache keyed by the parent, or a thread-local. Then the island is reachable from a root and never collected. The fixes are explicit deregistration or weak-reference containers such as WeakHashMap or ConditionalWeakTable.
  • Why do Rust graph libraries hand out integer node ids instead of references?
    Because unique ownership plus lifetimes make a genuinely cyclic reference graph either impossible or expensive: Rc/RefCell moves aliasing checks to runtime and risks panics, and Weak upgrades add branching. An arena keeps every node in one owner, makes ids Copy, and turns traversal into indexing. The trade is that ids are not checked - a stale id silently addresses a different node unless you add generations.

saying these in an interview costs you the question

  • Calling the two directions two independent facts, so both can be set separately - that is how the ends drift apart.
  • Assuming every runtime collects cycles; Swift's ARC and C++ shared_ptr do not, and the leak is silent.
  • Saying weak references are a performance optimisation, when in ARC they are the only way to express a non-owning end.
  • Adding the inverse collection by default 'for convenience' without asking whether a query would do, then loading a customer's entire order history to answer one question.

context