skip to content

Why does a resolver collect a scope's declarations before it resolves the references inside that scope?

level: middleimportance: should knowfreq 48%

answer

  1. one left-to-right walk is not enough
  2. definitions that call each other
  3. populate the table, then resolve
  4. collect declarations, then walk statements
  5. position comparison, not a missing entry

basics

~20 s

So that forward references resolve. Collecting every declaration of a scope into its symbol table first means a reference can name something declared later in the same scope — mutually recursive definitions being the case a single left-to-right walk cannot handle at all.

solid answer

~50 s

Processing a scope in one left-to-right walk binds a reference only to what has already been seen, so two definitions that call each other are unresolvable: whichever comes first names something not yet in the table. The usual fix is two sub-passes over the same scope. The first walks the scope's declarations and inserts an entry for each, reporting a collision if the name is already present in **that** table. The second walks the statements and resolves every reference against the now-complete chain. Note that this is a per-scope-kind decision: scopes whose declarations are deliberately order-independent — top-level definitions, a script's named steps — get the two-pass treatment, while local block scopes usually also require a reference to appear **after** its declaration, which the resolver enforces by comparing the reference's position with the position recorded in the entry.

code

pseudocode · 18 lines
pseudocode
check_scope(block, parent_scope):
    scope = new scope(parent = parent_scope)

    for each declaration d in block:            // pass 1: collect
        if scope.table contains d.name:
            report error "duplicate declaration: " + d.name
        else:
            scope.table[d.name] = entry(kind = d.kind, position = d.position)

    for each statement s in block:              // pass 2: resolve
        for each reference r in s:
            r.binding = resolve(r.name, scope)
            if scope.requires_declare_before_use
               and r.binding is in scope.table
               and r.binding.position > r.position:
                report error "used before it is declared: " + r.name
        for each nested block b in s:
            check_scope(b, scope)

go deeper

for a junior

Recall that a reference is sometimes written above the declaration it names, and that the tool reads declarations first so definitions calling each other still work.

for a middle

Explain the two sub-passes over one scope — collect entries, then resolve references — and why mutual recursion is unresolvable without them.

for a senior

Show the diagnostic pay-off: a collected entry turns an unhelpful unknown-name error into a used-before-declared error that names both positions, and catches same-scope duplicates the parser accepted.

for a principal

Weigh order-independence against predictability: which scope kinds get it, what it costs when a declaration's type is implied rather than written, and how cycles among those are rejected rather than chased.

## The failure a single walk produces Imagine resolving a scope in one pass, left to right, inserting each declaration as you reach it and resolving each reference against whatever is in the table at that moment. That works for the common case of declare-then-use, and it fails immediately for two definitions that refer to each other: ```pseudocode step validate(item): if item.needs_expansion: return expand(item) // names a step declared below return item step expand(item): return validate(unpack(item)) // names the step declared above ``` Whichever definition is written first, its body names something the table does not yet hold. No ordering of the text fixes this — the cycle is in the program, not in the layout. The same problem appears in gentler form whenever a script's author writes the entry point at the top and the helpers underneath, which is how most people prefer to read code. ## Split the scope into collect and resolve The standard structure is two sub-passes over the same scope: 1. **Collect.** Walk only the declarations of the scope. Insert an entry for each into that scope's table, recording kind, declaration position, and the declared type if the syntax states one. If the name is already present in *this* table, report a duplicate declaration — the parser accepted both because the shape is legal, so this sub-pass is the first thing in the pipeline that can notice. 2. **Resolve.** Walk the statements. For each reference, run the outward scope-chain lookup and record the binding on the tree. Descend into a nested block by pushing a new table and repeating both sub-passes for it. The cost is a second traversal of one scope's nodes; the benefit is that resolution no longer depends on the order in which a human chose to write the definitions. ## Order-independent versus declare-before-use It would be an overstatement to say every scope works this way. Languages differ, and the useful way to hold the distinction is per scope kind: | Scope kind | Typical rule | Why | |---|---|---| | Top-level or named-step scope | Order-independent: collect all, then resolve | Mutual recursion must work; authors reorder definitions freely | | Block or loop body | Declare-before-use as well as collected | A reference above the declaration would read a slot that has no value yet | | Parameter list | Collected before the body is resolved | The body must see every parameter regardless of position | Where declare-before-use applies, the resolver does **not** abandon collection. It still collects, because collecting is what lets it produce the good diagnostic: a reference to a name that exists further down the same block can be reported as *used before it is declared*, pointing at both positions, instead of the misleading *no such name* that a single walk would emit. The check is a comparison of two recorded positions, not the absence of an entry. ## What the collected table buys the rest of the pipeline A complete table per scope is also what makes the type checks tractable. Checking the body of one definition against another requires the other's signature; if signatures were only discovered by reaching them, the checker would inherit the same ordering problem one level up. Collecting declarations first gives every later pass a stable question to ask — *what is this name declared as?* — that is answerable at any point in the tree. There is one honest wrinkle. Collection can record a **declared** type immediately only when the syntax states one. Where a declaration's type is meant to be worked out from its initialiser, the entry is created during collection with its type still unknown and filled in later, and a genuine cycle among such declarations (each one's type depending on the other's) is a case the language has to reject explicitly rather than loop on. That is why order-independence is usually granted generously to definitions whose signatures are written out, and more cautiously to declarations whose types are implied. ## How to say this in an interview The compact version is three sentences: a reference may legitimately precede its declaration, so the resolver populates a scope's table before it resolves the scope's references; that makes mutual recursion work and makes definition order a matter of taste rather than of correctness; and where a language still demands declare-before-use, the collected entry is what turns a confusing *unknown name* into a precise *used before declared* pointing at both lines.

  • If the table is populated first, how can a language still reject a use that appears above its declaration?
    By comparing positions rather than by failing the lookup. The entry records where the declaration is, so a reference that resolves to an entry declared later in the same scope is rejected with both positions in the message. The name is known; the ordering rule is what fails.
  • What does the collect sub-pass catch that the parser cannot?
    Two declarations of one name in one scope. The text is shaped legally, so the parser accepts it; the collision is only visible when the second entry is inserted into a table that already holds the name. The same is true of a declaration that shadows nothing but collides with a built-in name.
  • Why not collect every declaration in the whole program up front, in one table?
    Because one table cannot represent nesting, and nesting is the point: the same name may be declared in several scopes with different meanings. Collection is per scope, performed as each scope is entered, so the chain still decides which entry a reference sees.

saying these in an interview costs you the question

  • says a forward reference is always a syntax error
  • claims every scope must be declare-before-use
  • thinks collecting first makes duplicate declarations legal
  • believes mutual recursion is fixed by reordering the source
  • says one global table can replace per-scope collection