Complete guide to designing and implementing a lexical analyzer including architecture, algorithms, buffering strategies, and practical implementation.
Introduction
Designing a lexical analyzer involves translating formal token specifications (regular expressions) into an efficient program that can quickly scan source code and produce a stream of tokens. This involves decisions about architecture, data structures, input handling, and error recovery.
Architecture of a Lexical Analyzer
┌────────────────────────────────────────────────┐
│ Lexical Analyzer │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ Input │ │ Scanner │ │ Token │ │
│ │ Buffer │→ │ Engine │→ │ Generator │ │
│ └──────────┘ └──────────┘ └──────────────┘ │
│ ↑ ↑ │ │
│ Source File Transition Token │
│ Table Stream │
└────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│ Symbol │ │ Parser │
│ Table │ │ │
└──────────┘ └──────────────┘
Design Approaches
1. Hand-Coded Scanner
A manually written scanner using direct coded automata:
Token getNextToken() {
char c = getChar();
// Skip whitespace
while (c == ' ' || c == '\t' || c == '\n') {
if (c == '\n') lineNumber++;
c = getChar();
}
// Identifiers and keywords
if (isalpha(c) || c == '_') {
char buffer[MAX_ID_LEN];
int i = 0;
while (isalnum(c) || c == '_') {
buffer[i++] = c;
c = getChar();
}
buffer[i] = '\0';
ungetChar(c); // Put back lookahead
// Check if keyword
TokenType kw = lookupKeyword(buffer);
if (kw != NONE) return makeToken(kw);
return makeToken(IDENTIFIER, buffer);
}
// Numbers
if (isdigit(c)) {
int value = 0;
while (isdigit(c)) {
value = value * 10 + (c - '0');
c = getChar();
}
if (c == '.') {
// Handle floating point
// ...
}
ungetChar(c);
return makeToken(INTEGER, value);
}
// Operators and delimiters
switch (c) {
case '+': return makeToken(PLUS);
case '-': return makeToken(MINUS);
case '*': return makeToken(STAR);
case '=':
c = getChar();
if (c == '=') return makeToken(EQUAL);
ungetChar(c);
return makeToken(ASSIGN);
case '<':
c = getChar();
if (c == '=') return makeToken(LTEQ);
ungetChar(c);
return makeToken(LT);
// ... more cases
}
}
2. Table-Driven Scanner
Uses a precomputed transition table:
// Transition table indexed by [state][character_class]
int delta[NUM_STATES][NUM_CHAR_CLASSES];
int accepting[NUM_STATES]; // Token type for accepting states
TokenType token_type[NUM_STATES];
Token scan() {
int state = START_STATE;
int lastAccept = -1;
int lastAcceptPos = -1;
char *start = forward;
while (state != ERROR_STATE) {
char c = *forward++;
int charClass = classify(c);
state = delta[state][charClass];
if (accepting[state]) {
lastAccept = state;
lastAcceptPos = forward - input;
}
}
if (lastAccept >= 0) {
forward = input + lastAcceptPos;
return makeToken(token_type[lastAccept], start, forward);
}
// Error: no token matched
reportError(start);
}
3. Generator-Based (Lex/Flex)
Using a tool like Lex or Flex:
%{
#include "tokens.h"
%}
%%
[ \t\n]+ { /* skip whitespace */ }
"//"[^\n]* { /* skip comments */ }
"if" { return IF; }
"else" { return ELSE; }
"while" { return WHILE; }
"int" { return INT; }
"float" { return FLOAT; }
[A-Za-z_][A-Za-z0-9_]* { yylval.str = strdup(yytext); return ID; }
[0-9]+ { yylval.ival = atoi(yytext); return NUMBER; }
[0-9]+\.[0-9]+ { yylval.fval = atof(yytext); return FNUMBER; }
"+" { return PLUS; }
"-" { return MINUS; }
"*" { return TIMES; }
"/" { return DIVIDE; }
"=" { return ASSIGN; }
"==" { return EQ; }
"!=" { return NEQ; }
";" { return SEMI; }
. { printf("Unknown char: %c\n", *yytext); }
%%
Double Buffer Scheme
| Buffer 1 (4096) | Buffer 2 (4096) |
|---|
| i f ( x > 0 ) ... EOF | { y = x * 2 ; ... EOF |
Algorithm:
char nextChar() {
if (forward is at EOF sentinel) {
if (forward is at end of first buffer) {
reload second buffer;
forward = beginning of second buffer;
} else {
// true end of input
return EOF;
}
}
return *forward++;
}
Symbol Table Interaction
The lexical analyzer creates symbol table entries for identifiers:
typedef struct SymbolEntry {
char *name;
TokenType token;
int lineNumber;
// Type info filled by semantic analysis
// Address filled by code generation
} SymbolEntry;
Token scanIdentifier() {
char lexeme[MAX_ID];
// ... read the identifier lexeme ...
SymbolEntry *entry = lookup(symbolTable, lexeme);
if (entry == NULL) {
entry = insert(symbolTable, lexeme);
entry->lineNumber = currentLine;
}
return makeToken(IDENTIFIER, entry);
}
Handling Special Cases
Multi-line Strings
# Python triple-quoted strings
text = """
This is a
multi-line string
"""
# Scanner must track state across lines
/* level 1 /* level 2 */ still level 1 */
# Requires counter or stack (beyond regular language)
Context-Sensitive Tokens
// In C: is >> a right-shift or two template closing brackets?
vector<vector<int>> v; // Two > characters
x = a >> 2; // Right-shift operator
| Technique | Benefit |
|---|
| Table-driven automaton | Predictable O(1) per character |
| Direct-coded scanner | Faster due to fewer table lookups |
| Buffer sentinels | Eliminate boundary checks |
| Perfect hash for keywords | O(1) keyword lookup |
| Character classification table | Faster than multiple comparisons |
Interview Questions
- What are the three main approaches to building a lexical analyzer?
Hand-coded (directly implementing the automaton in code), table-driven (using precomputed transition tables), and generator-based (using tools like Lex/Flex). Hand-coded offers best performance, table-driven offers clean separation, generators offer easiest development.
- Why is double buffering used in lexical analyzers?
To avoid frequent disk I/O (reading one character at a time is too slow) while handling the case where a lexeme spans the buffer boundary. Two buffers alternate: while one is being processed, the other can be refilled.
- How does a lexical analyzer handle the longest match rule?
It continues scanning even after finding a match, recording the last accepting state and position. When no more transitions are possible, it backtracks to the last accepting position and returns that token.
- What are the advantages of using a scanner generator over hand-coding?
Easier development, proven correctness (based on automata theory), easier maintenance (modify regex patterns), and automatic optimization. However, hand-coded scanners are often faster and more flexible for complex cases.
- How does the lexical analyzer interact with the symbol table?
When an identifier is recognized, the scanner looks it up in the symbol table. If not found, it creates a new entry. The token returned contains a pointer to the symbol table entry, which later phases use for type information, scope, and code generation.