BNF Grammars and Syntax Rules
A recursive descent parser models every BNF grammar rule as a subroutine, so the structure of the program follows the structure of the grammar.
Predicting Which Subroutine Runs First
Before a program can do anything with a piece of text, something has to read that text and recognize its structure. That work belongs to a parser. This article introduces one popular method for writing a parser by hand, called recursive descent parsing. It is not the only way to parse, and it is not the most efficient one. What makes it useful is that it is the method best suited for writing compilers by hand, instead of generating the parser with a parser generator program.
What do you think happens?
A recursive descent parser is given the input (4) under a small grammar whose top rule is expression. Which subroutine is called first?
Reveal answer
The subroutine for the top rule, expression(), is called first.
Parsing starts by calling the subroutine for the top rule. That subroutine then hands the work to term(), and the chain of calls continues from there.
The answer follows from the central idea of the method: the structure of the program mirrors the structure of the grammar. The rest of this article shows how that mirroring works, what the grammar must satisfy for it to work, and how the parser behaves while it runs.
Each Rule Becomes a Subroutine
The central idea of recursive descent parsing is a direct correspondence. In a recursive descent parser, every rule of the BNF grammar becomes a subroutine. If the grammar has a rule for an expression, the parser has a subroutine that handles expressions. If the grammar has a rule for a term that sits inside an expression, the parser has a separate subroutine for terms. The structure of the program therefore mirrors the structure of the grammar. Reading the grammar tells you what subroutines to write, and reading the subroutines tells you which grammar rules they follow.
To make this concrete, consider a small illustrative grammar that is invented for this article. An expression is a term. A term is either a single digit-based number, or an opening parenthesis followed by an expression and a closing parenthesis. Each of these three rules gets its own subroutine, and the subroutines call one another in the same pattern that the rules refer to one another. The rule for expression refers to term, so expression() calls term(). The rule for term refers to expression inside its parenthesized alternative, so term() calls expression(). The rule for number is a separate subroutine that term() calls when it chooses the number alternative.
expression()follows the rule expression is a term, so it hands its work toterm()term()follows the two alternatives of the term rule and chooses one of them- The call to
expression()insideterm()follows the rule that an expression goes inside the parentheses number()follows the rule for a digit-based number
The sketch above is only a shape to study, not a finished parser. Its purpose is to show that each grammar rule has a matching subroutine and that the calls between subroutines match the references between rules.
The Lookahead Property a Grammar Needs
Not every BNF grammar can be parsed by recursive descent. The grammar must have a specific property: while the parser is in the middle of parsing a phrase, it must be possible to tell which syntactic category comes next by looking only at the next item in the input. The parser does not get to guess, backtrack through alternatives, or read far ahead to decide. It has to be able to choose the right subroutine from one item of lookahead.
In the illustrative grammar, this property holds. When term() is running, the parser only needs to see whether the next character is an opening parenthesis. If it is, the phrase must be a parenthesized expression. If it is not, the phrase must be a number. The next input item alone determines which rule applies, so term() can make its decision immediately. The flowchart below shows this single branching decision.
Tracing the Parse of (4)
The clearest way to see the mechanism is to follow a parse of a short nested phrase. Take the input (4) under the illustrative grammar. Parsing starts by calling expression(). That subroutine hands the work to term(). The term() subroutine looks at the next item, which is an opening parenthesis, so it chooses the parenthesized alternative. It consumes the parenthesis and calls expression() again, because the grammar says an expression goes inside the parentheses.
The second expression() call again calls term(), which now sees the digit 4 and chooses the number alternative, calling number(). Once the digit is consumed, control returns up through the chain of calls, and the closing parenthesis is consumed by the outer term() call. The call stack diagram below shows which subroutine is active at each step.
Tracing the calls for the input (4)
Trace which subroutine is active at each step while parsing (4) under the illustrative grammar.
- 1Step 1: start at the top rule
The parser calls
expression(). That subroutine callsterm(). - 2Step 2: choose the parenthesized alternative
term()sees an opening parenthesis as the next item, so it consumes the parenthesis and callsexpression(). - 3Step 3: choose the number alternative
The inner
expression()callsterm(). Thisterm()sees the digit 4, so it chooses the number alternative and callsnumber(). - 4Step 4: consume the digit and return
number()consumes the digit 4. Control returns to the innerterm(), then to the innerexpression(), then to the outerterm(). - 5Step 5: consume the closing parenthesis
The outer
term()consumes the closing parenthesis, and the parse of the nested phrase is complete.
The nested phrase is parsed by a chain of calls that goes down through the rules and then returns back up. Each return hands control to the subroutine that was waiting for that sub-phrase.
Why skipBlanks Comes First
The BNF rules say nothing about spaces between items, yet a person writing input usually wants to put spaces between items wherever they like. A recursive descent parser handles this with a convention. Before it looks ahead to see what is coming up next in the input, it calls TextIO.skipBlanks(). That call skips past any whitespace, such as spaces and tabs. It stops when the next character is either a non-blank character or the end-of-line character.
Because the lookahead decision in term() depends on seeing the next character, skipping blanks first is what makes that decision reliable. If the input is written as ( 4 ), the spaces would otherwise stand between the parser and the character it needs to examine. Calling skipBlanks() before each lookahead lets the same grammar accept the input with or without spaces, without the grammar rules themselves having to mention spaces.
Mistakes When Writing the Parser by Hand
Hand-written recursive descent parsers are easy to get subtly wrong, because the rules are simple to state but the order of operations matters. The mistakes below all involve the lookahead and the input position.
Common mistake Consuming the next item before checking which alternative applies
What it looks liketerm() reads the next character and removes it from the input, then checks whether that character was an opening parenthesis.
Why it failsOnce the item is consumed, the decision is made on a position that has already moved. If the character was a digit, it is gone before
number()can use it, so the number alternative receives the wrong input.Do this insteadLook at the next item first, decide which alternative applies, and only then consume it inside the chosen alternative.
Common mistake Looking ahead without calling skipBlanks() first
What it looks likeFor the input ( 4 ), the parser checks the next character right after the opening parenthesis, and that character is a space.
Why it failsThe space is not the item the grammar cares about. The lookahead sees whitespace instead of the next real token, so the choice of alternative can be wrong even though the input is valid.
Do this insteadCall TextIO.skipBlanks() before each lookahead so that the next non-blank item is the one examined.
Common mistake Choosing an alternative when the next item does not reveal it
What it looks likeA grammar has two alternatives that both begin with the same kind of item, and the parser must pick one by reading further ahead.
Why it failsThe method requires that the next input item alone reveal the next syntactic category. When it does not, the one-item choice cannot be made reliably.
Do this insteadCheck the grammar for the one-item property before writing the parser. If it fails, the grammar needs to be changed or a different parsing method is needed.
Practice Tracing Nested Input
Under the illustrative grammar, the input is (( 7 )). List, in order, every subroutine call the parser makes, including the nested ones, until the parse is complete.
Hints
- Start with
expression(), which callsterm(). - Each time
term()sees an opening parenthesis as the next item, it chooses the parenthesized alternative and callsexpression()again. - When the digit is reached,
term()chooses the number alternative and callsnumber().
Key Takeaways
- A recursive descent parser models every BNF grammar rule as a subroutine, so the structure of the program follows the structure of the grammar.
- The grammar must let the parser choose the next syntactic category by looking only at the next input item. Not every grammar has this property.
- Control moves between subroutines as they call one another, so nested grammar rules appear as nested subroutine calls that return back up when a sub-phrase finishes.
- The parser calls
TextIO.skipBlanks()before looking ahead, so that whitespace does not hide the next real item from the lookahead decision. - When writing the parser by hand, look at the next item before consuming it, and skip blanks before each lookahead.