Language Runtimes · Dynamic Typing
Shape Identity, Interning, and Structural Equality
Why two layouts with the same property names and offsets may still need to be distinct, when an engine may safely merge them, and how the identity comparison in a guard depends on that decision.
A guard compares two pointers. That is the whole reason the layout descriptor exists, and it is why the identity of a descriptor is not an implementation detail but the central design decision. If two descriptors that describe the same layout are distinct objects, then a site that sees both is polymorphic even though it has no reason to be, and every guard against one fails on the other.
Structural equality is the obvious criterion for merging: two descriptors with the same property names, the same offsets, the same attributes, and the same prototype should be one descriptor. Deciding that equality is cheap is the difficulty. Comparing two descriptors structurally means comparing every entry, which is O(n) in the property count, and doing it on every descriptor creation would reintroduce the cost the descriptor model was built to avoid.
The transition tree is already an interning table
The transition tree described in Hidden Classes and the Transition Tree Behind Property Writes solves most of this problem without a general equality test. A descriptor is identified by the path that reaches it from the root, so two objects built by the same sequence of property additions share one descriptor by construction. No comparison is needed, because the second object’s additions walk existing edges.
That mechanism has a gap. The tree identifies a descriptor by its path, and two paths that produce the same physical layout need not be the same path. Adding x then y reaches {x@0, y@8}; adding y then x reaches {y@0, x@8}, so those two do not converge and correctly should not, because the offsets differ. The gap appears when a layout is reachable by two sequences whose entries end up in the same order and at the same offsets anyway, for instance when one path passed through a property that was later removed without demoting the object, or when two different constructors install the same prefix of properties before diverging in code that installs none. Both objects then hold the same names at the same offsets and still carry different descriptors, and a site that sees both pays two guards for one layout.
The same gap appears with transitions that are logically the same but reached through different intermediate states. Adding a, then b, then deleting b, then adding b again leaves an object whose delete almost certainly demoted it to a dictionary, so the layout is gone rather than duplicated. Where an engine does keep deletion inside the fast representation, the re-added property can end up at an offset different from the one a plain a-then-b object carries, and the two descriptors differ for a reason the program did not intend.
Why merging is not always safe
Merging two structurally equal descriptors is safe only if everything that distinguishes them is either equal or irrelevant to compiled code.
The prototype pointer must be equal. A descriptor records which object is the prototype, and two objects with the same own properties but different prototypes behave differently on an inherited access. Merging them would make a guard pass for an object whose chain differs, which is a correctness bug of exactly the kind the cell machinery is meant to prevent.
The instance size must be equal, which follows from the offsets but is worth checking because some engines pad.
The dictionary-mode flag must be equal. A dictionary-backed object with the same property names as a fast object has the same logical layout and a completely different physical one.
The element kind of an array must be equal, since an array’s elements are part of what its mapped-backing store describes and two arrays with different kinds are not interchangeable however similar their named properties.
The internal type tag must be equal. A Date and a plain object with the same properties are not interchangeable, and compiled code that dispatches on the internal tag would be wrong if they shared a descriptor.
Given all five, structural equality is a valid criterion, and the merge saves a descriptor and, more importantly, saves a site from becoming polymorphic.
The cost of interning
If a runtime decides to intern descriptors by structural equality, it needs a hash table keyed by the structural content. Computing the hash requires walking the property entries, which is O(n), and looking up requires comparing on collision, which is also O(n) in the worst case. For a descriptor with ten properties that is twenty operations at creation, against the zero operations of the transition tree.
The hash can be made incremental. A descriptor created by extending another can compute its hash from the parent’s hash and the new entry’s hash, which is O(1). That is the standard trick, and it makes the hash side cheap. The comparison side remains O(n), but it only runs on a hash hit, which for a well-distributed hash is rare for genuinely different descriptors.
Even with incremental hashing, interning adds a global table that every descriptor creation must consult. The table is shared across the whole isolate, so it needs either a lock or a per-thread table with periodic merging. Engines that intern descriptors usually do it only for descriptors that are created in a context where sharing is likely, such as those produced by the same constructor on repeated calls, and skip it for one-off shapes.
The alternative to interning is to accept duplication and let the inline caches handle it. A site that sees two structurally equal but distinct descriptors becomes polymorphic with two cases that do the same load from the same offset. The code is correct and the cost is one extra compare. For a site that is hit rarely, that is cheaper than the interning machinery. For a site in a hot loop, it is not.
Identity versus a stable key
The guard does not actually require pointer identity. It requires a value that is stable and that distinguishes layouts that behave differently. Pointer identity is the cheapest such value, which is why engines use it, but a small integer id assigned at descriptor creation works equally well and has an advantage: it can be compared as a 32-bit immediate, and it can be used as a key in tables without indirection.
The disadvantage is that the id space is finite. Recycling ids would let an old guard pass for a new descriptor, so an id-based scheme must either never recycle, which bounds the number of descriptors a process can create over its lifetime, or invalidate every guard holding an id before the id is reused. A descriptor supply that can be exhausted is a resource a long-running process can consume, and the response available to a runtime is to stop creating descriptors and put objects with unusual property histories into dictionary mode.
Pointer identity avoids the exhaustion problem because a descriptor that is no longer referenced can be collected and its address reused. But address reuse breaks the guard in exactly the same way as id recycling: a cached pointer that now points at a different descriptor would pass the comparison and produce a wrong offset. This is why a compiled guard cannot hold a raw pointer to a descriptor without keeping that descriptor alive. Compiled code either holds a reference that the collector traces, or the cache holds a weak reference that is cleared when the descriptor dies.
Which engines do which
V8 creates hidden classes through the transition tree, so two objects that reach the same hidden class by the same path share it; the tree is shared across all objects in an isolate, and the common construction patterns converge on shared maps because they walk the same edges. Whether a given engine additionally merges structurally equal layouts reached by different paths is a separate decision, and engines that identify a layout primarily by its path in the tree have little reason to do it.
Prototype-based systems face the opposite pressure. Where objects are produced by copying an existing object and adding slots, rather than by invoking one constructor with a fixed sequence of assignments, the set of reachable shapes is open-ended and the number of distinct paths that produce equivalent shapes is larger, so canonicalising by content buys more. A path-based tree is cheapest precisely when construction is conventional enough that many objects walk the same few edges.
The choice correlates with how objects are built. In a language where construction is dominated by class instantiation with a fixed sequence of field assignments, the transition tree captures nearly all the sharing and structural merging adds little. In a language where objects are built by copying and extending prototypes, the tree’s paths are more varied and content-keyed merging pays.
The consequence for guards
The practical rule is that a guard’s comparison is against a descriptor identity, and the identity is guaranteed to be shared for objects built the same way, not for objects that merely look the same. That guarantee is weaker than structural equality and it is what the runtime can afford. The guard itself, and the conditions it checks beyond identity, are set out in Monomorphic Inline Caches and the Guard That Makes Them Work.
The consequence is visible when two libraries build equivalent records differently. The objects are interchangeable at the language level, but they have different descriptors, and a function that handles both becomes polymorphic. The fix is at the boundary: convert one representation into the other once, rather than passing both through the hot path. That conversion is exactly a write of every field in a fixed order, which is the operation that creates a single shared descriptor, and it is the reason normalizing data at a module boundary is worth doing in a dynamic language even when the types are not checked.
The limitation of identity-based guards is that they are sensitive to history in a way structural checks are not. A program that builds the same logical object by two paths pays for it in dispatch, and there is no runtime mechanism that will notice the two are the same. The runtime has traded the cost of structural comparison, paid on every descriptor creation, for the cost of polymorphism, paid on every access at a site that sees both. Which trade is better depends on the ratio of creations to accesses, and for most programs creations are rare and accesses are frequent, which is why the trade is made in the direction it is.
References
- An Efficient Implementation of SELF, a Dynamically-Typed Object-Oriented Language Based on Prototypes — Craig Chambers, David Ungar, and Elgin Lee, OOPSLA 1989
- Fast properties in V8 — V8 project
- Efficient Implementation of the Smalltalk-80 System — L. Peter Deutsch and Allan M. Schiffman, POPL 1984