InterviewKata
Design patterns · 23 of 26 · Objects talking

Interpreter

Interpreter represents each rule of a small language as a class, so any sentence becomes a tree of objects that can evaluate itself. This page gives Pedal's marketing team a tiny promo-rule language, with a parser, clear errors and a live editor.

1. The pain

Pedal's marketing team runs promotions: "weekend rides over 20 minutes get £1 off", "late-night rides are free for members". Every rule was a small function a developer wrote, reviewed and deployed: def promo_17(ride): return ride.weekend and ride.minutes > 20.

Marketing wanted to try a new rule every week and waited days each time. The rules themselves were always the same handful of ideas combined with "and", "or" and "not": weekend, member, minutes, hour of day. A tiny language for exactly those would let marketing write the rules, with the code checking them.

2. The idea in one picture

A recipe card is written in a small language: "if the dough is sticky and the kitchen is warm, chill for 20 minutes". The cook doesn't need a new cook for each recipe; they know how to read the few kinds of instruction and combine them. New recipes are just new cards.

Interpreter represents each rule of a small language as a class, builds a tree of those objects for each sentence, and evaluates the tree. Here the grammar has five kinds of expression: Flag (weekend, member), Compare (minutes > 20), Not, And, Or. Each node evaluates itself, asking its children first. A small parser turns marketing's text into the tree.

The pattern is about the tree and its evaluation; the parser is a separate job (this page writes a short recursive-descent one). It suits small, stable languages. For anything bigger, use a parser generator or an existing rules engine, and keep the language closed: no loops, no access to anything but the ride.

3. The roles

RoleIts jobIn Pedal
Abstract expressionThe interface every node implements.Expr.evaluate(ride)
Terminal expressionA leaf: a basic fact about the ride.Flag(weekend), Compare(minutes > 20)
Non-terminal expressionCombines child expressions.And, Or, Not
ContextThe data expressions are evaluated against.The ride: weekend, member, minutes, hour
Client (and parser)Builds the tree from text and evaluates it.Parser(text).parse(), then tree.evaluate(ride)

4. Before and after

Promo rules both ways (Python sketch; the full program is in section 5).

Without the pattern

def promo_17(ride):
    return ride.weekend and ride.minutes > 20

def promo_18(ride):
    return ride.member or (
        ride.hour >= 22 and ride.minutes < 30)

# promo_19: ticket raised, waiting
#           for the next deploy

With it

rule = Parser("member or (hour >= 22 "
              "and minutes < 30)").parse()
# Or(Flag(member),
#    And(Compare(hour >= 22),
#        Compare(minutes < 30)))

rule.evaluate(ride)   # True or False
# promo_19: typed into the admin screen
The next changeWithout the patternWith it
Marketing wants a new ruleA developer writes, reviews and deploys a functionMarketing types it; the parser checks it
A typo in a ruleCaught in code review, if at allA clear error: "unknown name 'speed'"
Add "station" as something rules can testNothing to change for old rules, but no new rules without codeOne new terminal expression

Rules become data that non-developers can write, inside a language small enough to check completely.

5. The code

Five expression classes and a short recursive-descent parser. Three rules are parsed, printed as trees and evaluated against three rides; two broken rules are rejected with clear errors.

from __future__ import annotations
import re
from abc import ABC, abstractmethod

Ride = dict[str, int | bool]
NUMBERS, FLAGS = {"minutes", "hour"}, {"weekend", "member"}


# ---- The grammar as classes: each kind of expression knows how to evaluate itself. ----
class Expr(ABC):
    @abstractmethod
    def evaluate(self, ride: Ride) -> bool: ...

    @abstractmethod
    def describe(self) -> str: ...


class Flag(Expr):
    def __init__(self, name: str) -> None: self.name = name
    def evaluate(self, ride: Ride) -> bool: return bool(ride[self.name])
    def describe(self) -> str: return f"Flag({self.name})"


class Compare(Expr):
    OPS = {">": lambda a, b: a > b, ">=": lambda a, b: a >= b, "<": lambda a, b: a < b, "<=": lambda a, b: a <= b, "==": lambda a, b: a == b}

    def __init__(self, name: str, op: str, value: int) -> None: self.name, self.op, self.value = name, op, value
    def evaluate(self, ride: Ride) -> bool: return bool(Compare.OPS[self.op](int(ride[self.name]), self.value))
    def describe(self) -> str: return f"Compare({self.name} {self.op} {self.value})"


class Not(Expr):
    def __init__(self, inner: Expr) -> None: self.inner = inner
    def evaluate(self, ride: Ride) -> bool: return not self.inner.evaluate(ride)
    def describe(self) -> str: return f"Not({self.inner.describe()})"


class And(Expr):
    def __init__(self, left: Expr, right: Expr) -> None: self.left, self.right = left, right
    def evaluate(self, ride: Ride) -> bool: return self.left.evaluate(ride) and self.right.evaluate(ride)
    def describe(self) -> str: return f"And({self.left.describe()}, {self.right.describe()})"


class Or(Expr):
    def __init__(self, left: Expr, right: Expr) -> None: self.left, self.right = left, right
    def evaluate(self, ride: Ride) -> bool: return self.left.evaluate(ride) or self.right.evaluate(ride)
    def describe(self) -> str: return f"Or({self.left.describe()}, {self.right.describe()})"


# ---- A small recursive-descent parser turns text into that tree. ----
class RuleError(Exception):
    pass


class Parser:
    def __init__(self, text: str) -> None:
        self.tokens = re.findall(r">=|<=|==|[()<>]|\w+", text)
        self.pos = 0

    def peek(self) -> str | None:
        return self.tokens[self.pos] if self.pos < len(self.tokens) else None

    def take(self) -> str:
        tok = self.peek()
        if tok is None:
            raise RuleError("expected a condition, found the end of the rule")
        self.pos += 1
        return tok

    def parse(self) -> Expr:
        expr = self.rule()
        if self.peek() is not None:
            raise RuleError(f"unexpected '{self.peek()}'")
        return expr

    def rule(self) -> Expr:                     # rule := term ("or" term)*
        expr = self.term()
        while self.peek() == "or":
            self.take()
            expr = Or(expr, self.term())
        return expr

    def term(self) -> Expr:                     # term := factor ("and" factor)*
        expr = self.factor()
        while self.peek() == "and":
            self.take()
            expr = And(expr, self.factor())
        return expr

    def factor(self) -> Expr:                   # factor := "not" factor | "(" rule ")" | flag | name op number
        tok = self.take()
        if tok == "not":
            return Not(self.factor())
        if tok == "(":
            expr = self.rule()
            if self.take() != ")":
                raise RuleError("expected ')'")
            return expr
        if tok in FLAGS:
            return Flag(tok)
        if tok in NUMBERS:
            op = self.take()
            if op not in Compare.OPS:
                raise RuleError(f"expected a comparison after '{tok}'")
            num = self.peek()
            if num is None or not num.isdigit():
                raise RuleError(f"expected a number after '{op}'")
            self.take()
            return Compare(tok, op, int(num))
        raise RuleError(f"unknown name '{tok}'")


if __name__ == "__main__":
    rides: dict[str, Ride] = {
        "A": {"weekend": True, "member": True, "minutes": 25, "hour": 14},
        "B": {"weekend": True, "member": False, "minutes": 15, "hour": 23},
        "C": {"weekend": False, "member": False, "minutes": 40, "hour": 22},
    }
    for text in ["weekend and minutes > 20", "member or (hour >= 22 and minutes < 30)", "not member and weekend", "weekend and", "speed > 3"]:
        print(f"rule: {text}")
        try:
            tree = Parser(text).parse()
        except RuleError as e:
            print(f"  error: {e}")
            continue
        print(f"  tree: {tree.describe()}")
        hits = [name for name, ride in rides.items() if tree.evaluate(ride)]
        print(f"  applies to: {', '.join(hits) or 'none'}")
type Ride = Record<string, number | boolean>;
const NUMBERS = new Set(["minutes", "hour"]), FLAGS = new Set(["weekend", "member"]);

// ---- The grammar as classes: each kind of expression knows how to evaluate itself. ----
interface Expr { evaluate(ride: Ride): boolean; describe(): string; }

class Flag implements Expr {
  constructor(private readonly name: string) {}
  evaluate(ride: Ride) { return Boolean(ride[this.name]); }
  describe() { return `Flag(${this.name})`; }
}

const OPS: Record<string, (a: number, b: number) => boolean> = { ">": (a, b) => a > b, ">=": (a, b) => a >= b, "<": (a, b) => a < b, "<=": (a, b) => a <= b, "==": (a, b) => a === b };

class Compare implements Expr {
  constructor(private readonly name: string, private readonly op: string, private readonly value: number) {}
  evaluate(ride: Ride) { return OPS[this.op](Number(ride[this.name]), this.value); }
  describe() { return `Compare(${this.name} ${this.op} ${this.value})`; }
}

class Not implements Expr {
  constructor(private readonly inner: Expr) {}
  evaluate(ride: Ride) { return !this.inner.evaluate(ride); }
  describe() { return `Not(${this.inner.describe()})`; }
}

class And implements Expr {
  constructor(private readonly left: Expr, private readonly right: Expr) {}
  evaluate(ride: Ride) { return this.left.evaluate(ride) && this.right.evaluate(ride); }
  describe() { return `And(${this.left.describe()}, ${this.right.describe()})`; }
}

class Or implements Expr {
  constructor(private readonly left: Expr, private readonly right: Expr) {}
  evaluate(ride: Ride) { return this.left.evaluate(ride) || this.right.evaluate(ride); }
  describe() { return `Or(${this.left.describe()}, ${this.right.describe()})`; }
}

// ---- A small recursive-descent parser turns text into that tree. ----
class RuleError extends Error {}

class Parser {
  private readonly tokens: string[];
  private pos = 0;
  constructor(text: string) { this.tokens = text.match(/>=|<=|==|[()<>]|\w+/g) ?? []; }

  private peek(): string | null { return this.pos < this.tokens.length ? this.tokens[this.pos] : null; }
  private take(): string {
    const tok = this.peek();
    if (tok === null) throw new RuleError("expected a condition, found the end of the rule");
    this.pos++;
    return tok;
  }

  parse(): Expr {
    const expr = this.rule();
    if (this.peek() !== null) throw new RuleError(`unexpected '${this.peek()}'`);
    return expr;
  }

  private rule(): Expr {   // rule := term ("or" term)*
    let expr = this.term();
    while (this.peek() === "or") { this.take(); expr = new Or(expr, this.term()); }
    return expr;
  }

  private term(): Expr {   // term := factor ("and" factor)*
    let expr = this.factor();
    while (this.peek() === "and") { this.take(); expr = new And(expr, this.factor()); }
    return expr;
  }

  private factor(): Expr {   // factor := "not" factor | "(" rule ")" | flag | name op number
    const tok = this.take();
    if (tok === "not") return new Not(this.factor());
    if (tok === "(") {
      const expr = this.rule();
      if (this.take() !== ")") throw new RuleError("expected ')'");
      return expr;
    }
    if (FLAGS.has(tok)) return new Flag(tok);
    if (NUMBERS.has(tok)) {
      const op = this.take();
      if (!(op in OPS)) throw new RuleError(`expected a comparison after '${tok}'`);
      const num = this.peek();
      if (num === null || !/^\d+$/.test(num)) throw new RuleError(`expected a number after '${op}'`);
      this.take();
      return new Compare(tok, op, Number(num));
    }
    throw new RuleError(`unknown name '${tok}'`);
  }
}

const rides: Record<string, Ride> = {
  A: { weekend: true, member: true, minutes: 25, hour: 14 },
  B: { weekend: true, member: false, minutes: 15, hour: 23 },
  C: { weekend: false, member: false, minutes: 40, hour: 22 },
};
for (const text of ["weekend and minutes > 20", "member or (hour >= 22 and minutes < 30)", "not member and weekend", "weekend and", "speed > 3"]) {
  console.log(`rule: ${text}`);
  let tree: Expr;
  try {
    tree = new Parser(text).parse();
  } catch (e) {
    if (!(e instanceof RuleError)) throw e;
    console.log(`  error: ${e.message}`);
    continue;
  }
  console.log(`  tree: ${tree.describe()}`);
  const hits = Object.keys(rides).filter((name) => tree.evaluate(rides[name]));
  console.log(`  applies to: ${hits.join(", ") || "none"}`);
}
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiPredicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Main {
    static final Set<String> NUMBERS = Set.of("minutes", "hour"), FLAGS = Set.of("weekend", "member");
    static final Map<String, BiPredicate<Integer, Integer>> OPS = Map.of(">", (a, b) -> a > b, ">=", (a, b) -> a >= b, "<", (a, b) -> a < b, "<=", (a, b) -> a <= b, "==", (a, b) -> a.equals(b));

    // ---- The grammar as classes: each kind of expression knows how to evaluate itself. ----
    interface Expr { boolean evaluate(Map<String, Object> ride); String describe(); }

    record Flag(String name) implements Expr {
        public boolean evaluate(Map<String, Object> ride) { return (Boolean) ride.get(name); }
        public String describe() { return "Flag(" + name + ")"; }
    }

    record Compare(String name, String op, int value) implements Expr {
        public boolean evaluate(Map<String, Object> ride) { return OPS.get(op).test((Integer) ride.get(name), value); }
        public String describe() { return "Compare(" + name + " " + op + " " + value + ")"; }
    }

    record Not(Expr inner) implements Expr {
        public boolean evaluate(Map<String, Object> ride) { return !inner.evaluate(ride); }
        public String describe() { return "Not(" + inner.describe() + ")"; }
    }

    record And(Expr left, Expr right) implements Expr {
        public boolean evaluate(Map<String, Object> ride) { return left.evaluate(ride) && right.evaluate(ride); }
        public String describe() { return "And(" + left.describe() + ", " + right.describe() + ")"; }
    }

    record Or(Expr left, Expr right) implements Expr {
        public boolean evaluate(Map<String, Object> ride) { return left.evaluate(ride) || right.evaluate(ride); }
        public String describe() { return "Or(" + left.describe() + ", " + right.describe() + ")"; }
    }

    // ---- A small recursive-descent parser turns text into that tree. ----
    static class RuleError extends RuntimeException {
        private static final long serialVersionUID = 1L;
        RuleError(String message) { super(message); }
    }

    static class Parser {
        private final List<String> tokens = new ArrayList<>();
        private int pos;

        Parser(String text) {
            Matcher m = Pattern.compile(">=|<=|==|[()<>]|\\w+").matcher(text);
            while (m.find()) tokens.add(m.group());
        }

        private String peek() { return pos < tokens.size() ? tokens.get(pos) : null; }
        private String take() {
            String tok = peek();
            if (tok == null) throw new RuleError("expected a condition, found the end of the rule");
            pos++;
            return tok;
        }

        Expr parse() {
            Expr expr = rule();
            if (peek() != null) throw new RuleError("unexpected '" + peek() + "'");
            return expr;
        }

        private Expr rule() {   // rule := term ("or" term)*
            Expr expr = term();
            while ("or".equals(peek())) { take(); expr = new Or(expr, term()); }
            return expr;
        }

        private Expr term() {   // term := factor ("and" factor)*
            Expr expr = factor();
            while ("and".equals(peek())) { take(); expr = new And(expr, factor()); }
            return expr;
        }

        private Expr factor() {   // factor := "not" factor | "(" rule ")" | flag | name op number
            String tok = take();
            if (tok.equals("not")) return new Not(factor());
            if (tok.equals("(")) {
                Expr expr = rule();
                if (!take().equals(")")) throw new RuleError("expected ')'");
                return expr;
            }
            if (FLAGS.contains(tok)) return new Flag(tok);
            if (NUMBERS.contains(tok)) {
                String op = take();
                if (!OPS.containsKey(op)) throw new RuleError("expected a comparison after '" + tok + "'");
                String num = peek();
                if (num == null || !num.matches("\\d+")) throw new RuleError("expected a number after '" + op + "'");
                take();
                return new Compare(tok, op, Integer.parseInt(num));
            }
            throw new RuleError("unknown name '" + tok + "'");
        }
    }

    static Map<String, Object> ride(boolean weekend, boolean member, int minutes, int hour) {
        return Map.of("weekend", weekend, "member", member, "minutes", minutes, "hour", hour);
    }

    public static void main(String[] args) {
        Map<String, Map<String, Object>> rides = new LinkedHashMap<>();
        rides.put("A", ride(true, true, 25, 14));
        rides.put("B", ride(true, false, 15, 23));
        rides.put("C", ride(false, false, 40, 22));
        for (String text : List.of("weekend and minutes > 20", "member or (hour >= 22 and minutes < 30)", "not member and weekend", "weekend and", "speed > 3")) {
            System.out.println("rule: " + text);
            Expr tree;
            try {
                tree = new Parser(text).parse();
            } catch (RuleError e) {
                System.out.println("  error: " + e.getMessage());
                continue;
            }
            System.out.println("  tree: " + tree.describe());
            List<String> hits = new ArrayList<>();
            for (Map.Entry<String, Map<String, Object>> r : rides.entrySet()) if (tree.evaluate(r.getValue())) hits.add(r.getKey());
            System.out.println("  applies to: " + (hits.isEmpty() ? "none" : String.join(", ", hits)));
        }
    }
}

Output (the same in all three languages)

rule: weekend and minutes > 20
  tree: And(Flag(weekend), Compare(minutes > 20))
  applies to: A
rule: member or (hour >= 22 and minutes < 30)
  tree: Or(Flag(member), And(Compare(hour >= 22), Compare(minutes < 30)))
  applies to: A, B
rule: not member and weekend
  tree: And(Not(Flag(member)), Flag(weekend))
  applies to: B
rule: weekend and
  error: expected a condition, found the end of the rule
rule: speed > 3
  error: unknown name 'speed'

6. Try it

Write a promo rule

Names: weekend, member, minutes, hour. Words: and, or, not, brackets, and > >= < <= == with whole numbers.


      

7. When not to use it

What you get

  • Rules as data: written, stored and changed without a deploy.
  • Safe by construction: the language can only test what it names.
  • Easy to extend the grammar: a new expression is a new class.
  • Inspectable: the tree can be printed, tested and explained.

What you pay

  • A parser to maintain: and good error messages are work.
  • Grows badly: big grammars need real parser tools.
  • Performance: walking a tree per ride is slower than compiled code (fine for promotions).
  • A language is a product: people will want variables, dates, functions…
If…Use instead
The rules are few and change rarelyPlain code
The rules need a full languageAn embedded scripting language or rules engine, sandboxed
Rules are simple key/value thresholdsA configuration table
You need to run many operations over the treeInterpreter plus 21 Visitor (print, evaluate, optimise)

8. How it goes wrong

Letting the language grow without a planEach "just one more feature" makes the parser harder. Write the grammar down and change it deliberately.
Using eval() on user textEvaluating marketing's rule as Python would let it do anything. A closed grammar only allows what you defined.
Unclear errors"Syntax error" helps nobody. Say what was expected and where, as the parser here does.
Re-parsing on every rideParse once when the rule is saved; evaluate the stored tree for each ride.

9. Remember it and say it

Memory hook: "A recipe card"A tiny language of a few instruction kinds; any card is a tree you can follow.
How this hook works: what “A recipe card” means

What the slogan means, phrase by phrase

“A recipe card”
A rule written in text.
“a few instruction kinds”
Flag, Compare, And, Or, Not.
“a tree you can follow”
Each node evaluates its children.

Why this picture fits: Cooks read new recipes without being retrained, because recipes reuse the same few kinds of instruction.

The story → the concept

The kitchenThe code
The recipe card"member or (hour >= 22 and minutes < 30)"
Kinds of instructionThe expression classes
Reading the cardThe parser building a tree
Following ittree.evaluate(ride)

Watch it on an example

  1. The parser reads the rule and builds Or(member, And(hour ≥ 22, minutes < 30)).
  2. For ride B, Or asks member: no; then asks And.
  3. And asks hour ≥ 22 (23: yes) and minutes < 30 (15: yes): the promotion applies.

The trap it saves you from

Writing and deploying a new function for every rule that's just a new combination of the same conditions.

It still works when…

Yes: search query syntax, spreadsheet formulas, feature-flag targeting rules and SQL WHERE clauses all use small interpreted languages.

In an interview or code review

10. Check yourself

What does each class represent in Interpreter?

Flags, comparisons, and, or, not.

Which nodes are terminal here?

They test the ride directly.

How does an And node evaluate?

Non-terminals combine children.

Why not run marketing's text with eval()?

A closed grammar is safe by construction.

When should you parse a rule?

Then evaluate the stored tree.

11. Go deeper

ResourceWhy
21 VisitorSeveral operations over the same expression tree.
10 CompositeAn expression tree is a composite.
Python astPython's own syntax trees, built by a parser.
Wikipedia: Interpreter patternStructure and examples.