Symbol Tables in Compilers: Structure, Scope, and Semantic Analysis
A symbol table in a compiler records information about program identifiers (e.g., variables, functions, types) so later phases can do name resolution, type checking, and scope management. In most compilers, the symbol table is built during parsing and/or a dedicated analysis pass, and it is updated as the compiler enters and exits scopes.
Conceptually, each entry in a symbol table stores at least:
- the identifier’s name (lexeme),
- a link/representation to its declaration,
- semantic attributes such as type, storage location (or an IR handle), and source location.
A standard approach is to organize symbols by scope using a stack of tables: when the compiler enters a new block, it pushes a new scope; when it leaves, it pops the scope so that shadowed names are naturally handled.
This supports semantic analysis because the compiler can “look up” what a name means at a specific program point, then verify that usage is consistent with the declaration.
Important: Symbol tables enable semantic analysis to be mostly local: once you can resolve each identifier to its declaration, many checks (undeclared names, wrong argument counts, type mismatches, misuse of variables vs functions) become straightforward queries against stored attributes.
Compilers: Symbol Tables, Scopes, and Semantic Analysis (Intro)
Key terminology and invariants
- identifier: e.g.,
x,add. - scope: e.g., function scope, block scope.
- declaration: e.g.,
int x;,int f(int a){...}. - semantic attribute: e.g., type, parameter list, whether initialized.
A good symbol table design preserves these invariants while compiling:
- Uniqueness per scope: no two declarations of the same name in the same scope (or a well-defined rule for duplicates).
- Shadowing correctness: inner scopes can redeclare names that temporarily hide outer ones.
- Fast lookup: lookup at point should find the nearest (most recent) enclosing declaration.
- Type consistency availability: type (and other attributes) for each declaration must be stored so semantic rules can query it.
- Correct lifetime: when a scope ends, its declarations should stop being visible (hence scope stack pop).
Typical structure of a symbol table
A practical symbol table is usually implemented as one of these (often combined):
-
Hash map per scope
- Each scope owns a dictionary:
name -> entry. - Lookup: search from innermost to outermost scope.
- Each scope owns a dictionary:
-
Stack of scopes (linked symbol tables)
- Maintain
currentScope. - Each scope has a parent pointer to the next outer scope.
- Lookup walks parent pointers until it finds the identifier or reaches global scope.
- Maintain
-
Tree/forest for nested scopes
- Each scope node references child scopes.
- Useful if you need to re-analyze or traverse for later tooling (e.g., IDE support).
A symbol table entry often includes:
kind: variable vs function vs type vs parametertype: static type information (e.g.,int,float, function signature)params(for functions): parameter types and countoffset/address/IR value: backend or intermediate representation linkagedefined?: distinction between declaration-only vs definition (for languages that have both)sourceSpan: file/line/column for error reporting
[CalloutBlock]{type="tip" title="Pro Tip" content="When designing entries, include enough semantic attributes so type checking can be a set of attribute queries—avoid recomputing type by re-parsing the declaration text."}
Constructing and maintaining a symbol table during compilation
- 1Step 1
Create an empty symbol table for the global scope and make it the current scope.
- 2Step 2
When encountering a declaration, create an entry and insert it into the current scope’s table.
- 3Step 3
If an identifier already exists in the current scope, report a redeclaration error or apply the language’s rule.
- 4Step 4
Upon entering a block (e.g.,
{ ... }) or function body, push a new scope table whose parent is the previous current scope. - 5Step 5
For each identifier usage, perform lookup starting at the innermost scope and moving outward until a matching entry is found.
- 6Step 6
Use resolved entries to verify rules like declared-before-use, correct type for operations, and correct function call arity.
- 7Step 7
When leaving a block, pop the current scope so its declarations are no longer visible.
- 8Step 8
After traversal, run additional validations (e.g., unused symbols, return statement coverage, definite assignment) if your compiler requires them.
Mermaid: scope stack and lookup order
This “nearest enclosing declaration” behavior is the key to correct shadowing.
Example: Constructing and maintaining the symbol table
Consider the following toy language with block scopes and type annotations inferred/checked from declarations:
1int x = 1; 2int f(int a) { 3 int y = x + a; 4 { 5 int x = 2; 6 y = y + x; 7 } 8 return y; 9}
Step A: Global scope after processing top-level declarations
At the start:
- current scope = Global
Insert:
x : int(initialized with1)f : functionwith parameter list(int a)and return typeint(if declared or inferred from body)
Symbol tables (conceptual):
| Scope | Entries (name → attributes) |
|---|---|
| Global | x → {kind:var, type:int, init:true}; f → {kind:function, params:[int], ret:int} |
Step B: Enter function scope for f
When compiling f’s body:
- push Function scope for
f - insert function parameter
a
Now:
- current scope =
f(function scope)
Entries:
| Scope | Entries |
|---|---|
Function f | a → {kind:param, type:int} |
Step C: Inside the first inner statement int y = x + a;
At this point:
xis referenced, so lookup proceeds:- Function scope: no
x - Global scope: finds
x
- Function scope: no
ais referenced, so lookup:- Function scope: finds
a
- Function scope: finds
Insert y into the function scope (since y is declared in the function body block, not inside the later nested block):
| Scope | Entries |
|---|---|
Function f | a → int; y → int (defined with expr: x + a) |
Semantic analysis examples enabled by symbol table attributes:
x + arequires both operands to be numeric/compatible → verify both areint.y’s type becomesint(from declared type or inferred from initializer).
Step D: Enter the nested block { ... }
Push a new inner block scope:
- current scope = inner block scope
Now parse int x = 2;:
- declare a new
xin the inner scope - this shadows global
xwithin the block
| Scope | Entries |
|---|---|
| Inner block | x → int (init:2) |
Step E: Inside y = y + x;
Lookups:
y:- inner block scope: not found
- function scope: found
y
x:- inner block scope: found
x(shadowing active)
- inner block scope: found
Semantic checks:
- assignment
y = ...requires RHS type compatible with LHS type - RHS
y + xmust be a valid numeric addition
Because the symbol table resolves names to the correct declarations, semantic analysis uses:
y(function-scopey)x(inner-blockx)
Step F: Exit nested block
When leaving { ... }:
- pop inner block scope
xfrom inner block no longer exists/visible- subsequent statements see the outer
xagain (global or function-scoped, depending on language)
Step G: return y;
Lookup y:
- inner scopes popped, so found in function scope
- verify return type is compatible with
int
Name resolution order (innermost to outermost)
For each identifier usage, the compiler checks scopes from the top of the scope stack outward until it finds a matching entry.
Common edge cases and design choices
Warning: name lookup vs. type computation
A symbol table resolves which declaration an identifier refers to. Type checking may require additional rules (e.g., implicit conversions). Keep these concerns separated: resolve via lookup first, then check types via stored attributes.
Knowledge Check
In a compiler, what is the primary role of a symbol table during semantic analysis?
Explore Related Topics
Translating Arithmetic Expressions into Three-Address Code (TAC): From Syntax Tree to TAC
Passes in a Compiler: Definition and How Single- vs Multi-Pass Compilers Work
Lexical Analyzer Output in Compiler Design
In compiler design, the lexical analyzer’s sole output is a stream of tokens derived from the source code character stream.
- It scans characters left‑to‑right, grouping them into lexemes that match language patterns.
- Each lexeme is classified into a token category (e.g., ID, NUM, PLUS) possibly with attributes.
- The token stream is handed to the parser, which builds the parse tree or AST.
- Machine code, intermediate code, and parse trees are produced in later compilation phases, not by the lexer.