Concepts / BNF Grammars and Syntax Rules

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.

  • Programming

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
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.

java
Notes on the code
  • expression() follows the rule expression is a term, so it hands its work to term()
  • term() follows the two alternatives of the term rule and chooses one of them
  • The call to expression() inside term() 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

Definition

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.

term() startsskipBlanks()skip whitespace firstnext item is (?parenthesizedalternativeconsume (, callexpression(), consume )return to callernumber alternativecall number()yesno
Given the next input item, term() branches to exactly one alternative rule. The lookahead item alone decides the branch, with no guessing or backtracking.

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.

expression()top rule, calls term()term()sees (, picks parenthesizedalternativeexpression()inside parentheses, callsterm()term()sees 4, picks numberalternativenumber()consumes the digit 4callscalls after consuming (callscalls
Each indented level is a subroutine that is still active. Control moves down as rules call one another, and returns back up after a sub-phrase is finished.
Worked example

Tracing the calls for the input (4)

Trace which subroutine is active at each step while parsing (4) under the illustrative grammar.

  1. 1
    Step 1: start at the top rule

    The parser calls expression(). That subroutine calls term().

  2. 2
    Step 2: choose the parenthesized alternative

    term() sees an opening parenthesis as the next item, so it consumes the parenthesis and calls expression().

  3. 3
    Step 3: choose the number alternative

    The inner expression() calls term(). This term() sees the digit 4, so it chooses the number alternative and calls number().

  4. 4
    Step 4: consume the digit and return

    number() consumes the digit 4. Control returns to the inner term(), then to the inner expression(), then to the outer term().

  5. 5
    Step 5: consume the closing parenthesis

    The outer term() consumes the closing parenthesis, and the parse of the nested phrase is complete.

Result

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.

space4spacespace4)space)
After the opening parenthesis has been consumed, the remaining input starts with spaces. skipBlanks() removes the spaces so that the next real item, the digit 4, is the one the lookahead examines.

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 like

    term() reads the next character and removes it from the input, then checks whether that character was an opening parenthesis.

    Why it fails

    Once 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 instead

    Look 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 like

    For the input ( 4 ), the parser checks the next character right after the opening parenthesis, and that character is a space.

    Why it fails

    The 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 instead

    Call 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 like

    A 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 fails

    The 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 instead

    Check 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

MEDIUM

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 calls term().
  • Each time term() sees an opening parenthesis as the next item, it chooses the parenthesized alternative and calls expression() again.
  • When the digit is reached, term() chooses the number alternative and calls number().

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.