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.
Watering rules written as code
Irrigation has a method per garden bed. Each one combines sensor readings with && and ||.
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 !.
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)));
}
}
}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:
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 |
|
term := factor (and factor)* | All of the factors hold |
|
factor := not factor, or sensor < n, or sensor > n | A negation, or one comparison |
|
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.
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);
}
}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
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.
The lawn rule as a tree: the leaves compare readings, and the inner nodes combine them.
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 |
| Declares |
Terminal expression |
| Evaluates directly against the readings |
Nonterminal expression |
| Holds sub-expressions and combines their results |
Context |
| The input the sentence is evaluated against |
Client |
| 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 |
C++ | An abstract class with nodes owning children through |
JavaScript | Classes with |
Python | Frozen dataclasses; for richer languages, the standard |
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
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.