A mapping layer reads each field by name at run time — what work does that add over a compiled field read?
answer
- the compiler already did this work
- a name, not an offset
- resolve, check, pack, call
- values travel in a uniform container
- the callee is a value, not a target
basics
~20 sEach read first resolves a name to a member, then passes an access check, then moves the value through a generic representation and an indirect call. A compiled read did the resolving once, at build time, and does none of the rest.
solid answer
~50 sA compiled field read is an offset and a move: the compiler already decided which declaration the name meant, whether the code was permitted to touch it, and what shape the value has. A read through a member found by name re-asks all of that at run time. It searches the type's description for a matching member, including inherited ones; it decides whether the caller may reach it; it moves the value through one uniform representation, so values that are not reference-shaped get wrapped and an argument list is materialised as a container; and it calls through a generic entry point whose target is a value, not a fixed callee, so the optimizer has nothing to inline. The first cost scales with how many members the type has, the rest with how many times you call.
code
pseudocode · 8 lines// compiled read: the name was resolved when this was compiled
value = source.amount
// read by name: every step is repeated per object
member = lookupMember(typeOf(source), "amount") // search the member description
checkAccess(member, callingCode) // permitted to reach it?
boxed = member.read(source) // value handed back generically
value = unwrap(boxed) // shape re-established by handgo deeper
Remember that reading a member by name at run time is not free: a name has to be turned into a member before any value moves.
Be able to name the phases in order — resolve, check, pack, call — and say which of them a compiler performs once at build time instead.
Show it in a profile: say which phase you expect to dominate at startup versus in steady state, and attack that one rather than the slogan.
Frame it as a budget question: which paths in the system may pay per-access resolution at all, and what the shared layer owes every team that crosses that boundary.
## The two reads side by side A field read written in ordinary code and a field read performed by looking a member up by name at run time produce the same value, and that is nearly all they have in common. The difference is **when the questions were answered**. When a read is compiled, the compiler has already settled every question the access raises: which declaration the name refers to, whether this code is permitted to reach it, where the value sits in the object's storage, and what shape the value has. What survives into the emitted code is an offset computation and a move. Those questions were answered **once, at build time**, for every execution that will ever run. ## What the reflective read does instead A read through a member discovered at run time re-asks the same questions on **every read**: 1. **Resolve.** The name — a string, or something derived from one — is matched against the type's own description of itself. The search covers declared members and, depending on the model, inherited ones, so its cost grows with how wide the type is, not with how often you call. 2. **Check.** Something must decide whether the calling code is permitted to reach the member that was found. Designs differ in when this is paid: some record the decision on the resolved member, others make it on every access. 3. **Pack.** The generic path has one signature for every member, so values travel through a **uniform representation**. Values that are not reference-shaped are wrapped to fit it, and an argument list is usually materialised as a container object per call. 4. **Call.** The access happens through a generic entry point. The member being read is a *value* the code holds, not a target the compiler wrote down. ## Where the time actually lands | Phase | Work performed | Scales with | |---|---|---| | Resolve | Matching a name against the type's member description | Members the type declares and inherits | | Check | Deciding whether the caller may reach the member | Calls, or once per resolution, by design | | Pack | Wrapping values, allocating the argument container | Calls times arguments | | Call | Indirect entry with no statically known target | Calls | This table is why "reflection is slow" is an unhelpful sentence in an interview. There is no single multiplier. There are four distinct costs with different shapes, and which one dominates a given profile tells you what to do about it. ## Why the optimizer usually cannot rescue it - **Inlining needs a known callee.** Here the callee is chosen from a value at run time, so the compiler that emitted the call site could not name a single target to inline. - **The name is not folded away.** A name that arrives as data cannot be turned into a fixed offset by the compiler, which is precisely what makes the mechanism useful and what makes it cost. - **Static shape is lost at the boundary.** Once a value enters the uniform representation it is handled generically, and the code on the other side re-establishes facts a compiled path proved statically. - **Wrapper allocation often survives.** Escape analysis can sometimes remove a wrapper that never leaves a method, but a shared generic path makes that harder, so the allocation frequently stays. ## What this looks like in a startup profile The frame where engineers meet this is a layer that maps objects to and from some external representation, field by field, discovering the field set from the type itself. At first use it touches every field of every type it has been asked about: - **Resolve dominates early**, because it runs per field per object until something caches it, and startup is exactly the window where nothing has been cached yet. - **Packing dominates later**, once the same handful of types are being mapped over and over, because packing is per call and cannot be amortised by knowing the type. - The profile therefore has two distinct humps, and attacking the wrong one wastes the work. ## The honest summary The cost is **structural, not incidental**. Work that a compiler performs once for the lifetime of a program has been moved to run time, where it is performed once per access. Everything people do about it — holding resolved members, narrowing the generic boundary, reducing how many fields are touched at all — is an attempt to move some part of that work back toward "once".
- Does the cost fall if the same member is read a million times?Not by itself. Without something holding the resolved member, every read repeats the search and the access decision; the only saving is incidental, from warm data structures and predictable branches. Making the resolution a once-per-type cost requires deliberately keeping what was resolved.
- Why does moving values through a uniform representation cost more than passing them directly?Values that are not reference-shaped must be wrapped to fit one common slot, the argument list is usually allocated as a container per call, and the receiving side unwraps and re-checks what a direct call proved at compile time. That is allocation plus checks the compiled path never emits.
- Which part of the cost grows with the size of the type rather than the number of calls?The resolution step. Matching a name against a type's description, and enumerating that description when the layer discovers which fields exist, both scale with how many members the type declares and inherits. Packing and the indirect call scale with call count instead.
A compiled read is walking to a shelf position you already memorised. A reflective read is handing over a title, waiting for the catalogue to be searched, and receiving the book inside a crate you then have to open.
saying these in an interview costs you the question
- Says the whole cost is allocation, ignoring lookup and the indirect call
- Treats it as one fixed multiplier rather than several distinct phases
- Assumes the lookup is free once the type is already loaded
- Expects the optimizer to inline the call the way it inlines a direct one
- Believes the second read of the same field is automatically cheaper