I once wrote a tiny arithmetic evaluator for a pricing config field, something like ābase + surge * 1.5ā, and the naive version was a growing switch statement over token types. Adding a new operator meant touching that same method again, every time. Interpreterās whole pitch is: stop doing that, give every grammar rule its own class.
The problem
You need to evaluate expressions built from a small grammar, numbers, variables, plus, minus, times, divide, and you want adding a new operator to mean adding a class, not editing an existing one.
Without the pattern
The obvious thing is a single evaluate(String expr, Context ctx) method: tokenize the string, then walk the tokens with an if/else chain or a switch on token type, recursing (or juggling an explicit stack) to get precedence right. That holds together for + and -. Add * and / and the switch needs precedence-aware branching so 2 + 3 * 4 doesnāt collapse to 20. Add variables and every branch needs a fallthrough to a lookup table. Add a unary minus later and youāre threading yet another case through a function that already has five others it canāt afford to break.
flowchart TD
A[Raw expression string] --> B[tokenize]
B --> C{switch on token type}
C -->|number| D[parse int]
C -->|variable| E[look up in variable map]
C -->|plus| F[check precedence, recurse left and right]
C -->|minus| G[check precedence, recurse left and right]
C -->|times| H[check precedence, recurse left and right]
C -->|divide| I{divisor zero}
I -->|yes| J[throw ArithmeticException]
I -->|no| K[recurse left and right]
D --> L[combine and return int]
E --> L
F --> L
G --> L
H --> L
K --> L
L -.new operator means editing C.-> C
Every one of NumberExpression, VariableExpression, AddExpression, SubtractExpression, MultiplyExpression, DivideExpression below started life as a branch in that switch, all six living in one method that got a little more tangled each time the grammar grew by one rule. Adding a modulo operator to the switch version means reopening that method and threading a new case through logic that already has four other cases it canāt afford to break. Adding it here means writing ModuloExpression and never touching the other five classes.
With the pattern
Context wraps a Map<String, Integer> for variables, setVariable(), getVariable(), hasVariable(). AbstractExpression is the one-method contract, interpret(Context) returns an int. NumberExpression and VariableExpression are the terminal nodes, a NumberExpression just returns its stored int, a VariableExpression looks itself up in the Context via getVariable(). AddExpression, SubtractExpression, MultiplyExpression, DivideExpression are the non-terminal nodes, each holding a leftExpression and rightExpression, and interpret() recursively calls interpret() on both sides before combining them. DivideExpression is the only one that has to think about failure, it throws ArithmeticException on a zero divisor before doing the division. Composing an expression is just nesting constructors: (x + y) * (10 - 5) becomes new MultiplyExpression(new AddExpression(varX, varY), new SubtractExpression(num10, num5)). Thereās no parser here, the tree is built by hand, a real implementation would need a tokenizer in front of this to go from a raw string to that tree.
classDiagram
class Context {
-Map~String,Integer~ variables
+setVariable(String, int)
+getVariable(String) int
+hasVariable(String) boolean
}
class AbstractExpression {
<<interface>>
+interpret(Context) int
}
class NumberExpression {
-int number
+interpret(Context) int
}
class VariableExpression {
-String variableName
+interpret(Context) int
}
class AddExpression {
-AbstractExpression leftExpression
-AbstractExpression rightExpression
+interpret(Context) int
}
class SubtractExpression
class MultiplyExpression
class DivideExpression
AbstractExpression <|.. NumberExpression
AbstractExpression <|.. VariableExpression
AbstractExpression <|.. AddExpression
AbstractExpression <|.. SubtractExpression
AbstractExpression <|.. MultiplyExpression
AbstractExpression <|.. DivideExpression
AddExpression o--> AbstractExpression : left/right
SubtractExpression o--> AbstractExpression : left/right
MultiplyExpression o--> AbstractExpression : left/right
DivideExpression o--> AbstractExpression : left/right
AbstractExpression ..> Context : interprets against
What it costs you
Six operators is nothing, but a real config language grows comparisons, boolean and/or, maybe a ternary, and each one of those is another class with its own interpret() override, the class count tracks the grammar size one-to-one and that stops being cute somewhere past a couple dozen rules. Itās also just slow compared to what a real parser/compiler pipeline gets you, thereās no compilation step here, (x + y) * (10 - 5) gets walked node by node, with a virtual dispatch at every level, on every single evaluation, forever, thereās nothing cached or precomputed about it. This is the right tool for a small, stable, well-defined grammar, a pricing rule field, a config expression, not for building anything youād call a production-grade language. Past that line you want a real parser generator and something closer to a bytecode interpreter, not more expression classes.
When to reach for it
Small, stable grammars: config languages, rule engines, places where you evaluate expressions far more often than you change the grammar. Itās a different tool from Strategy (interchangeable algorithms, no tree) and from Composite (structural part-whole, no evaluation semantics attached), Interpreter is specifically about building and walking a tree that represents a language.
The takeaway
Donāt reach for this past a handful of operators, each new grammar rule is a new class, and a deep expression tree means a deep call stack, thatās a real limit, not a theoretical one. Past a certain grammar size you want a parser generator, not more expression classes.
Read the full source on GitHub.
Comments