Everything so far has been a link in one chain. Now we forge the whole thing and get a working scanner
out the other end. The pipeline is exactly the sequence of pages you have read:
regular definitions for each token →
A scanner does not run one automaton per token type. Instead lex takes the whole list of
patterns —
Here is the subtlety maximal-munch forces on us. The DFA might march happily forward, pass through an accepting state, and only later hit a dead end — by which point it has read characters past the last good token. So the driver must remember the last accepting state it saw and, on getting stuck, back up the input pointer to that point. The loop is:
The "roll back" step is the whole reason a scanner needs to peek ahead and retreat. Consider a
language with both < and <= as tokens: reading < then
= reaches the le accept, but reading < then a letter must give
back that letter and emit lt. Without the remembered lastPos, the scanner
would either stop too early (missing <=) or consume the letter it should have left for
the next token.
There are two ways to turn the DFA into a program, a classic engineering trade-off:
| Table-driven | Direct-coded | |
|---|---|---|
| Form | a 2-D array next[state][char] read by one generic loop | the DFA compiled into control flow — a labelled block per state, goto/switch between them |
| Made by | lex, flex | re2c, hand-writing |
| Size | compact table, tiny code | larger code, no table lookup |
| Speed | a memory load per character (cache misses on big tables) | faster — branches predict well, values stay in registers |
Both encode the same DFA and the same longest-match driver; they differ only in whether the transition function lives in a data array or in the instruction stream. Table-driven scanners are the textbook default because a generator can emit them trivially; direct-coded scanners win on raw throughput, which is why performance-critical tools generate them.
The driver reads and occasionally backs up over characters, so it needs the recent input still
in memory — you cannot un-read from a stream. The classic solution is the two-buffer
scheme: two halves of a buffer, each lexemeBegin (start of the current lexeme) and forward (the
scanning head) — and backing up is just moving forward back toward
lexemeBegin. The naïve version tests, on every character, both "have I run off the
end of this half?" and "is this the character I want?" — two comparisons per byte.
The sentinel optimisation halves that. Place a special
Below is the real thing in miniature: a hand-built DFA table over a tiny token set — identifiers
+ — driven by the
exact longest-match-with-backtracking loop above. Feed it ab12+7 and watch it bite off one
token at a time.
Notice ab12 comes out as one id token, not id then
num: state 1 keeps accepting through the digits, so maximal munch swallows all four
characters. Whitespace is recognised (state 4) but its token is dropped before output. And each token
ends by rolling i back to lastPos — the remembered last-accepting position —
exactly the backtracking the driver spec called for.
Production compilers almost universally hand-write their scanners rather than generate them,
and for good reasons. Speed: the lexer touches every byte of source, so it is a hot
loop, and a hand-tuned direct-coded scanner (careful character-class tables, sentinels, no per-token
allocation) beats a generic table interpreter. Error messages: a hand-written lexer can
emit "unterminated string literal starting here" with a caret under the exact column, recover
gracefully, and handle the thousand real-world quirks (digraphs, trigraphs, Unicode identifiers, raw
strings, >> in nested templates) that a pure regex spec makes awkward. Tools like
re2c sit in between — you write patterns in comments and it generates a fast
direct-coded scanner you can still read and tweak. The lesson: lex is a wonderful
way to prototype and to teach the theory, but the theory's real value is that it tells you
exactly what your hand-written loop must do — longest match, rule priority, backtrack, buffer.
The driver's inner loop lives or dies by two conditions being checked in the right order with the right operator: "am I still inside the buffer?" and "does the DFA have a move on this character?" Write it as
and the logical && short-circuits: if pos is past the end, the
second half is never evaluated, so you never index past the buffer. Swap in the bitwise
& by mistake and both operands are always evaluated — the scanner reads
src[pos] when pos is out of bounds (an undefined character, a
wrong class, or a crash in a language with real arrays). Worse, & forces both sides to
integers, so a truthy/falsy transition test silently becomes a numeric AND and the loop condition turns
to nonsense.
The related bug is forgetting to back up at all: if the driver emits a token at the
point it gets stuck rather than at the last accepting position, then
<= vs <, or 3.14 vs 3., silently
mis-tokenise. Always emit from lastPos (the remembered win), never from wherever the DFA
happened to die.