Low-level designDeep dive8 min

Interpreter

Give a small language a class per grammar rule, and represent each sentence as a tree of those classes that evaluates itself. Users can then write rules as text, and the program interprets them without a code change.

Watch it evolve1 / 9
The problem

Watering rules written as code

Irrigation has a method per garden bed. Each one combines sensor readings with && and ||.

80
Start
Stage 1
Stage 2
Final

1 · The situation

Watering rules for a garden

A community garden waters its beds automatically. Sensors report soil moisture in percent, temperature in degrees Celsius, and rain in the last hour in millimeters. Each bed has a rule: the lawn is watered when the soil is below 30% and it hasn't rained; the greenhouse when the soil is below 40% or it's over 30 °C.

The first version writes each rule as a method of Irrigation, combining readings with the language's own and and or. Python writes and, or and not; the others &&, || and !.

The first versionJava
import java.util.Map;

record Readings(String day, Map<String, Integer> values) {
    int get(String sensor) {
        return values.get(sensor);
    }
}

class Irrigation {
    boolean lawn(Readings r) {
        return r.get("soil") < 30 && !(r.get("rain") > 0);
    }

    boolean greenhouse(Readings r) {
        return r.get("soil") < 40 || r.get("temp") > 30;
    }
}

public class Main {
    static String decide(boolean water) {
        return water ? "water" : "skip";
    }

    public static void main(String[] args) {
        Irrigation irrigation = new Irrigation();
        Readings[] days = {
            new Readings("Monday", Map.of("soil", 25, "temp", 22, "rain", 0)),
            new Readings("Tuesday", Map.of("soil", 35, "temp", 33, "rain", 4)),
        };
        for (Readings r : days) {
            System.out.println(r.day() + ": lawn " + decide(irrigation.lawn(r))
                    + ", greenhouse " + decide(irrigation.greenhouse(r)));
        }
    }
}
«record»Readings− day: String− values: Map<String, Integer>~ get(String): intIrrigation~ lawn(Readings): boolean~ greenhouse(Readings): boolean

The class diagram of this version, drawn from its code: one class holds every rule as code.

Two mornings of readings:

Monday: lawn water, greenhouse water
Tuesday: lawn skip, greenhouse water

2 · The change

The gardener wants to write the rules

The head gardener adjusts the rules every season, and is adding an herb bed. Each change today is a request to a developer, a code change and a release:

The rules are expressions in codeJava
class Irrigation {
    boolean lawn(Readings r) {
        return r.get("soil") < 30 && !(r.get("rain") > 0);
    }

    boolean greenhouse(Readings r) {
        return r.get("soil") < 40 || r.get("temp") > 30;
    }
}

The rules are short and follow a pattern, which means they can be a small language instead of code. Written out, its grammar is three lines:

Grammar rule

Means

Example

rule := term (or term)*

Any of the terms holds

soil < 40 or temp > 30

term := factor (and factor)*

All of the factors hold

soil < 30 and not rain > 0

factor := not factor, or sensor < n, or sensor > n

A negation, or one comparison

rain > 0

With rules as text, the gardener edits a line and the controller picks it up. The program has to understand the language: read a line, and decide whether it holds for today's readings.

3 · The refactor

A class per grammar rule

Start from the first version. Turn each kind of expression into a class that can evaluate itself, and write a parser that builds them from text:

Rule declares holds(readings). Below and Above compare one sensor with a number. Not, And and Or hold other rules and combine their answers. In Java they are records; in Python, frozen dataclasses.

Java
interface Rule {
    boolean holds(Readings r);
}

record And(Rule left, Rule right) implements Rule {
    public boolean holds(Readings r) {
        return left.holds(r) && right.holds(r);
    }
}
«create»«create»«create»«create»«create»«record»Readings− day: String− values: Map<String, Integer>~ get(String): int«interface»Rule+ holds(Readings): boolean«record»Below− sensor: String− limit: int+ holds(Readings): boolean«record»Above− sensor: String− limit: int+ holds(Readings): boolean«record»Not− inner: Rule+ holds(Readings): boolean«record»And− left: Rule− right: Rule+ holds(Readings): boolean«record»Or− left: Rule− right: Rule+ holds(Readings): booleanRuleParser− tokens: String[]− pos: int~ parse(String): Rule− next(String): boolean− or(): Rule− and(): Rule− factor(): Rule«record»Zone− name: String− rule: Rule

The interpreted rules give the same answers as the coded ones, and the herb bed joins without a code change:

Monday: lawn water, greenhouse water, herbs skip
Tuesday: lawn skip, greenhouse water, herbs water
Rules as methods
2
Rules as text
0

Classes edited to add the herb bed. Before: Irrigation, and a release. After: none; the rule is text.

4 · The pattern

Interpreter: a sentence as a tree that evaluates itself

Interpreter represents a language's grammar as classes, one per rule, and a sentence as a tree of their instances, the abstract syntax tree. Each node knows how to evaluate itself: terminal expressions, like a comparison, answer directly; nonterminal expressions, like and, combine the answers of their children. Building the tree, parsing, is a separate job.

andsoil < 30notrain > 0

The lawn rule as a tree: the leaves compare readings, and the inner nodes combine them.

«create»«create»«create»«create»«create»«record»Readings− day: String− values: Map<String, Integer>~ get(String): int«interface»Rule+ holds(Readings): boolean«record»Below− sensor: String− limit: int+ holds(Readings): boolean«record»Above− sensor: String− limit: int+ holds(Readings): boolean«record»Not− inner: Rule+ holds(Readings): boolean«record»And− left: Rule− right: Rule+ holds(Readings): boolean«record»Or− left: Rule− right: Rule+ holds(Readings): booleanRuleParser− tokens: String[]− pos: int~ parse(String): Rule− next(String): boolean− or(): Rule− and(): Rule− factor(): Rule«record»Zone− name: String− rule: Rule

The roles in the garden's code: Rule is the abstract expression, Below and Above terminals, Not, And and Or nonterminals, and RuleParser builds the trees.

Role

In the garden

Its job

Abstract expression

Rule

Declares holds(), the interpretation

Terminal expression

Below, Above

Evaluates directly against the readings

Nonterminal expression

Not, And, Or

Holds sub-expressions and combines their results

Context

Readings

The input the sentence is evaluated against

Client

RuleParser and main

Builds the tree and asks it for an answer

The tree is a Composite: And holds rules and answers by asking them. Interpreter adds the grammar, which decides which nodes exist and how text maps to them.

5 · What it costs

Fine for small languages only

  • A class per grammar rule. Six classes for three lines of grammar is fine. A language with dozens of rules becomes hundreds of classes, and a parser generator or an existing language serves better.
  • Errors move to run time. A rule with a typo, such as sol < 30, now fails when it's parsed or evaluated, not when the program is compiled. The parser must report errors clearly; this page's parser doesn't check its input, to stay short.
  • Speed. Walking a tree of objects is slower than compiled code. For a few rules each morning it doesn't matter; for millions of evaluations, compile the tree to something faster.
  • A language is a commitment. Users will write rules you didn't anticipate, and every change to the language must keep their rules working.

Situation

Interpreter?

A small, stable language that users or configuration write

Yes

A few fixed rules that rarely change

No: code is clearer and checked

A large or growing language

A parser generator, or embed an existing language

Rules built in code, never from text

The same classes without a parser: a Specification-style API

6 · Getting it right

Parse once, evaluate many times

  • Separate parsing from evaluation. Parse each rule once, when it's loaded, and keep the tree; evaluate it on every reading.
  • Validate at parse time. Unknown sensors, missing numbers and stray words should be rejected with the line and the word, so the gardener can fix them.
  • Keep nodes immutable. A tree that never changes can be shared and evaluated from several threads.
  • Add operations with a Visitor. Printing a rule back as text, or simplifying it, are new operations over a fixed set of node types: exactly what Visitor is for.

Language

Nodes and parsing

Java

Records implementing an interface; a sealed interface lets a switch over the nodes be checked

C++

An abstract class with nodes owning children through std::unique_ptr; or std::variant nodes

JavaScript

Classes with holds(); a regular expression or simple split for tokens

Python

Frozen dataclasses; for richer languages, the standard ast module shows the same idea for Python itself

7 · Look-alikes

Trees of objects with a purpose

Interpreter shares its structure with other patterns. What differs is what the tree means:

Pattern

The tree is

Built from

Interpreter

A sentence in a language, evaluated

Text, by a parser

Composite

A part-whole structure, such as bundles

Code that assembles it

Specification

A business rule combined with and, or, not

Code, as method calls

Command

Not a tree: one request

Code that creates it

Without the parser, the rule classes here are what is often called the Specification pattern: rules as objects you combine in code, new And(new Below("soil", 30), ...). The parser is what turns them into a language a person can write.

8 · In practice

Small languages everywhere

Small interpreted languages are common: search query syntax, spreadsheet formulas, filter rules in mail clients, feature-flag targeting rules, and alerting conditions in monitoring tools. Each is a grammar, a parser, and a tree that is evaluated against some input.

The practice checks have no Interpreter detector. They read RuleParser as a simple factory, which it is: it decides which rule classes to create, so callers never name them. All three versions read as clean.

Check yourself

4 questions

1. The gardener wants a "between" comparison: soil between 20 40. What changes?
2. Why does and bind tighter than or in this parser?
3. A rule says sol < 30, a typo. When is it caught?
4. When is an interpreter the wrong choice?

Takeaways

Remember this

  • Interpreter gives each grammar rule a class, and represents a sentence as a tree of them.
  • Terminals evaluate directly; nonterminals combine their children: the tree is a Composite.
  • Parsing builds the tree from text and is separate from the pattern; parse once, evaluate many times.
  • Rules as text let users change behavior without a release, at the cost of run-time errors.
  • Use it for small, stable languages; larger ones need a parser generator or an existing language.