usharik.dev

← All articles

Recursive descent parsing, revisited in Java 21

Published · Alex Usharovski

First published on Habr (in Russian) on 24 February 2020; this is a rewritten and updated version.

I was still at school when a book on C and C++ ended with a listing called "recursive calculator". It took me many evenings to understand how a few short functions could evaluate 2 + 3 * (4 - 5). In 2020 I wrote that explanation up for Habr. This is the same idea six years later, with what I would now tell a student first, and with modern Java.

The full program is one file: Calc.java. Run it with java Calc.java; Java 21 needs no separate compilation step.

Start with a grammar

Before code, describe what an expression looks like. Three rules are enough for the four operations and brackets:

expression -> term   (('+' | '-') term)*
term       -> factor (('*' | '/') factor)*
factor     -> NUMBER | '(' expression ')'

Read * as "repeat zero or more times". An expression is a sum of terms, a term is a product of factors, a factor is a number or a whole expression in brackets. The last rule is where the recursion lives: a bracket sends us back to the first rule.

Precedence falls out of the layering. * binds tighter than + because multiplication is handled one level deeper: by the time expression sees a +, term has already consumed 3 * (4 - 5) as one piece.

The trap in the 2020 version

My original article wrote the rules recursively:

E -> T + E | T - E | T

It looks equivalent, but it is not. Implemented literally, 10 - 4 - 3 becomes 10 - (4 - 3) = 9, because the recursion groups from the right. Subtraction and division are left-associative and must group from the left: (10 - 4) - 3 = 3.

The 2020 code got the right answer anyway, because it turned the recursion into a loop and accumulated the result from left to right. The grammar and the code disagreed, and nothing in the text said so. The rules above use repetition (*) instead of right recursion, so the grammar now says what the loop does.

Tokens first

The old article skipped tokenization and started from an array of strings. A tokenizer is short, and it is where errors such as 1 # 2 are best reported, so here it is. Tokens are records behind a sealed interface:

sealed interface Token permits Num, Name, Sym {}
record Num(double value) implements Token {}
record Name(String text) implements Token {}
record Sym(char c) implements Token {}

The tokenizer walks the string once: digits and dots become a Num, letters and digits a Name, any of +-*/^(), a Sym, spaces are skipped, anything else is an error with its position.

Build a tree, then evaluate it

In 2020 the parser computed the result while parsing. That is fine for a calculator, but a tree is more useful: you can print it, simplify it, evaluate it for many values of x, or compile it. Records make the tree almost free:

sealed interface Expr permits Number, Var, Unary, Binary, Call {}
record Number(double value) implements Expr {}
record Var(String name) implements Expr {}
record Unary(char op, Expr operand) implements Expr {}
record Binary(char op, Expr left, Expr right) implements Expr {}
record Call(String function, List<Expr> args) implements Expr {}

The grammar, extended

Now add what a real calculator needs: unary minus, powers, variables and functions such as max(1, x * 2, sqrt(16)).

expression -> term (('+' | '-') term)*
term       -> unary (('*' | '/') unary)*
unary      -> '-' unary | power
power      -> primary ('^' unary)?
primary    -> NUMBER | NAME | NAME '(' args ')' | '(' expression ')'

Two decisions are hidden here.

Each rule becomes one method. The two loops are the left-associative rules:

private Expr expression() {
    Expr left = term();
    while (peek('+') || peek('-')) {
        char op = next();
        left = new Binary(op, left, term());
    }
    return left;
}

private Expr term() {
    Expr left = unary();
    while (peek('*') || peek('/')) {
        char op = next();
        left = new Binary(op, left, unary());
    }
    return left;
}

private Expr unary() {
    if (peek('-')) {
        next();
        return new Unary('-', unary());
    }
    return power();
}

private Expr power() {
    Expr base = primary();
    if (peek('^')) {
        next();
        return new Binary('^', base, unary()); // right-associative: 2^3^2 = 2^9
    }
    return base;
}

primary is where Java 21 pattern matching pays off. One switch over the sealed token type decides between a number, a function call, a variable and a bracket, and the compiler checks that no case is missing:

private Expr primary() {
    if (pos >= tokens.size()) throw error("unexpected end of expression");
    Token t = tokens.get(pos++);
    return switch (t) {
        case Num n -> new Number(n.value());
        case Name n when peek('(') -> call(n.text());
        case Name n -> new Var(n.text());
        case Sym s when s.c() == '(' -> {
            Expr inner = expression();
            expect(')');
            yield inner;
        }
        case Sym s -> throw error("unexpected '" + s.c() + "'");
    };
}

Evaluation is another switch, this time over the tree:

static double eval(Expr e, Map<String, Double> vars) {
    return switch (e) {
        case Number n -> n.value();
        case Var v -> { /* look the name up in vars */ }
        case Unary u -> -eval(u.operand(), vars);
        case Binary b -> { /* +, -, *, /, ^ on the two sides */ }
        case Call c -> { /* sqrt, sin, max */ }
    };
}

Check it

The main method of the file prints this:

2 + 3 * (4 - 5) + 6 - 7      = -2.0   ok
2 + 2 * (3 + 4 * (5 - 6))    = 0.0    ok
10 - 4 - 3                   = 3.0    ok
-2 ^ 2                       = -4.0   ok
2 ^ 3 ^ 2                    = 512.0  ok
2 * -x                       = -6.0   ok
max(1, x * 2, sqrt(16))      = 6.0    ok
-(1 + 2) * 3                 = -9.0   ok
2 +                          -> unexpected end of expression at token 2
(1 + 2                       -> ')' expected at token 4
2 3                          -> unexpected Num[value=3.0] at token 1
1 # 2                        -> Unexpected character '#' at 2

The tree for 2 + 3 * x, printed by the records themselves:

Binary[op=+, left=Number[value=2.0], right=Binary[op=*, left=Number[value=3.0], right=Var[name=x]]]

What to take away

A good exercise: add comparisons with a new top rule, comparison -> expression (('<' | '==') expression)?, and start parsing from it; then add a ternary a ? b : c on top of that. If the grammar is right, the code follows almost mechanically.

Source files