Java design pattern deep dive
Interpreter Pattern in Java: 100 interview questions with professional answers.
Learn how the Interpreter pattern turns a small grammar into a class hierarchy of expressions, how to build arithmetic and boolean evaluators the GoF way, why Spring Expression Language and regex engines are interpreters in production, and when to abandon a hand-rolled DSL for ANTLR, a rules engine, or an embedded scripting language.
What makes a good Interpreter answer?
Interviewers want to see that you understand Interpreter as a grammar-to-class mapping with a hard scaling ceiling, not just "recursive classes that evaluate themselves." A strong answer names the four GoF roles, shows the recursion mirroring the grammar, and knows when to stop hand-rolling and reach for a real parser.
| Approach | Use when | Watch out for |
|---|---|---|
| Hand-rolled Interpreter classes | The grammar is genuinely small (a handful of operators), stable, and you want the AST fully under your control as plain Java types. | Class count grows one-for-one with grammar rules; precedence and error recovery must be hand-coded. |
| Regular expressions | The language to recognize is regular (no nested/recursive structure), such as validating an input format. | Cannot express nested or recursive grammars; complex regexes become unreadable and slow. |
| Real parser generator (ANTLR) | The grammar has many rules, precedence levels, or is expected to evolve, and you want generated lexer/parser plus tooling. | Adds a build-time code-generation step and a learning curve for the grammar file syntax. |
| Embedded scripting engine (Java Scripting API, GraalVM JS) or rules engine (Drools) | You need a genuinely general-purpose language, or a mature rules authoring/audit story, and don't want to own a grammar at all. | Larger attack surface if scripts come from untrusted input; heavier runtime footprint than a tiny hand-rolled interpreter. |
Topics
Interview questions and answers
Each answer gives the implementation direction, the trade-off to mention, and the production concern that makes the answer stronger.
1. Explain the Interpreter design pattern in Java, its GoF intent, and the class of problems it solves for evaluating sentences in a small custom language.
The Interpreter pattern represents a grammar for a simple language as a class hierarchy, then defines an interpret() operation so instances of that hierarchy can evaluate ("interpret") sentences in the language. Each grammar rule becomes one class; a sentence in the language becomes a tree of objects built from those classes, and evaluating the sentence means walking the tree and calling interpret() recursively.
It solves the recurring problem of needing to evaluate the same kind of small, repeated expression over and over — arithmetic formulas, boolean eligibility rules, search query mini-languages — without hand-writing a bespoke parser and evaluator every time from scratch.
2. Describe the four canonical roles in the Interpreter pattern (AbstractExpression, TerminalExpression, NonterminalExpression, Context) and how they map onto a formal grammar.
AbstractExpression declares the shared interpret(Context) operation every node in the tree must support. TerminalExpression implements it for grammar terminals — the leaves, such as a literal number or a variable name — which usually just read a value directly or look one up in the Context. NonterminalExpression implements it for every recursive production rule, such as an addition or an AND expression, holding references to one or more child AbstractExpression instances and combining their interpreted results.
interface Expression {
int interpret(Context context);
}
Context carries global information the whole evaluation needs, most commonly variable bindings, so terminals don't need to be constructed with every possible value baked in.
3. Why does interpret() recursion mirror the grammar's own recursive production rules? Explain with a grammar example.
A grammar rule such as expr ::= expr '+' expr | expr '-' expr | NUMBER | VARIABLE is itself recursive: an expr can be built from smaller exprs. The Interpreter pattern encodes each alternative as a class, and a nonterminal class's interpret() simply calls interpret() on its own child expr fields — which is exactly what the grammar rule says an expr is made of.
class AddExpression implements Expression {
private final Expression left, right;
AddExpression(Expression left, Expression right) { this.left = left; this.right = right; }
@Override
public int interpret(Context context) {
return left.interpret(context) + right.interpret(context); // mirrors expr + expr
}
}
Because the code structure and the grammar structure are the same shape, adding a rule to the grammar on paper corresponds directly to adding one class in Java.
4. Design the AbstractExpression interface for a simple arithmetic language supporting numbers, variables, +, and -.
The interface only needs one method, interpret(Context), returning the numeric result. Keeping it minimal is deliberate: every expression type, terminal or nonterminal, must implement exactly this contract, so the interface should not leak details specific to any one rule.
interface ArithmeticExpression {
int interpret(Context context);
}
class Context {
private final Map<String, Integer> variables = new HashMap<>();
void assign(String name, int value) { variables.put(name, value); }
int lookup(String name) {
Integer value = variables.get(name);
if (value == null) throw new IllegalStateException("Undefined variable: " + name);
return value;
}
}
5. Implement a NumberExpression terminal class for the arithmetic interpreter above.
NumberExpression is the simplest terminal: it holds a literal value at construction time and returns it unchanged, ignoring the Context entirely since a literal number needs no variable lookup.
class NumberExpression implements ArithmeticExpression {
private final int value;
NumberExpression(int value) { this.value = value; }
@Override
public int interpret(Context context) {
return value; // terminal: no children, no Context lookup needed
}
}
Because it holds only a final field and performs no lookups, NumberExpression instances are naturally immutable and safe to share across threads or cache.
6. Implement a VariableExpression terminal class that reads a variable's value from Context.
Unlike NumberExpression, VariableExpression is still a terminal (it has no child expressions) but it does depend on Context, since the actual value is not known until evaluation time.
class VariableExpression implements ArithmeticExpression {
private final String name;
VariableExpression(String name) { this.name = name; }
@Override
public int interpret(Context context) {
return context.lookup(name); // terminal, but Context-dependent
}
}
7. Implement an AddExpression nonterminal class composing two child expressions.
AddExpression holds two child ArithmeticExpression references — either of which could themselves be a NumberExpression, a VariableExpression, or another nonterminal — and combines their interpreted results with +.
class AddExpression implements ArithmeticExpression {
private final ArithmeticExpression left, right;
AddExpression(ArithmeticExpression left, ArithmeticExpression right) {
this.left = left; this.right = right;
}
@Override
public int interpret(Context context) {
return left.interpret(context) + right.interpret(context);
}
}
The recursive call means this same class handles both "2 + 3" and arbitrarily deep expressions like "(a + 5) + (b - c)" without any special-casing.
8. Implement a SubtractExpression nonterminal class, and show how the composite tree evaluates "x - 3 + y".
class SubtractExpression implements ArithmeticExpression {
private final ArithmeticExpression left, right;
SubtractExpression(ArithmeticExpression left, ArithmeticExpression right) {
this.left = left; this.right = right;
}
@Override
public int interpret(Context context) {
return left.interpret(context) - right.interpret(context);
}
}
// "x - 3 + y" parsed left-to-right as ((x - 3) + y):
ArithmeticExpression tree = new AddExpression(
new SubtractExpression(new VariableExpression("x"), new NumberExpression(3)),
new VariableExpression("y"));
Context context = new Context();
context.assign("x", 10);
context.assign("y", 4);
System.out.println(tree.interpret(context)); // (10 - 3) + 4 = 11
Notice the tree shape itself encodes evaluation order: the outer node is the last operation applied, and its children are evaluated first, recursively.
9. Design the Context class for the arithmetic interpreter, and explain what state it should (and should not) hold.
Context should hold exactly the global, cross-cutting state an evaluation needs to look up — variable bindings, function tables, or configuration flags — and nothing about the shape of any particular expression tree. It should not hold intermediate results from mid-evaluation, since those belong on the call stack via interpret()'s return values, and it should not be mutated by expression nodes during a read-only evaluation unless that mutation is an intentional part of the language (like an assignment statement).
class Context {
private final Map<String, Integer> variables;
Context(Map<String, Integer> variables) { this.variables = Map.copyOf(variables); } // defensive, immutable
int lookup(String name) { return variables.get(name); }
}
10. Walk through building and evaluating the full expression tree for "(a + 5) - b" end to end.
ArithmeticExpression tree = new SubtractExpression(
new AddExpression(new VariableExpression("a"), new NumberExpression(5)),
new VariableExpression("b"));
Context context = new Context();
context.assign("a", 20);
context.assign("b", 8);
int result = tree.interpret(context);
// Evaluation order:
// AddExpression.interpret -> a.interpret() = 20, 5.interpret() = 5 -> 25
// SubtractExpression.interpret -> 25 - b.interpret() = 25 - 8 = 17
System.out.println(result); // 17
The outermost node is SubtractExpression, matching the outermost operator in the original text; the tree's shape is the parenthesization made explicit as object references instead of characters.
11. Explain how an interpreter's abstract syntax tree (AST) relates to the Composite pattern, and why every Interpreter implementation is also, structurally, a Composite.
Composite lets clients treat individual objects and compositions of objects uniformly through a shared interface. An Interpreter's AST is precisely that: AbstractExpression plays the Composite's "Component" role, TerminalExpression is the Composite's "Leaf," and NonterminalExpression is the Composite's "Composite," holding children of the same abstract type and delegating to them.
This is why any code that walks the tree, such as interpret() itself, does not need to know whether it holds a leaf or a branch — it just calls the shared method, exactly as Composite intends. The distinguishing feature that makes it "Interpreter" rather than plain Composite is that the tree specifically represents a grammar, and the shared operation specifically means "evaluate this sentence."
12. What is the difference between a terminal and a nonterminal expression in grammar terms, and how does that map onto class design?
In formal grammar terms, a terminal is a symbol that appears literally in the language and cannot be broken down further (a number, an identifier, a keyword); a nonterminal is a symbol defined in terms of other symbols via a production rule, and can always be expanded. In class design, this maps directly: a TerminalExpression subclass has no child Expression fields and returns a value derived only from its own state or a Context lookup, while a NonterminalExpression subclass always holds one or more child Expression references and its interpret() necessarily recurses into them.
13. Implement the classic GoF Boolean expression interpreter supporting AND, OR, NOT over named variables.
This is the textbook GoF example. Each boolean operator gets its own nonterminal class, and named variables are resolved through Context, exactly mirroring the arithmetic example but with boolean instead of int.
interface BooleanExpression {
boolean interpret(BooleanContext context);
}
interface BooleanContext {
boolean lookup(String name);
}
14. Implement a VariableExpression for the boolean interpreter that looks up a named boolean from Context.
class BooleanVariableExpression implements BooleanExpression {
private final String name;
BooleanVariableExpression(String name) { this.name = name; }
@Override
public boolean interpret(BooleanContext context) {
return context.lookup(name);
}
}
class MapBooleanContext implements BooleanContext {
private final Map<String, Boolean> values;
MapBooleanContext(Map<String, Boolean> values) { this.values = values; }
@Override
public boolean lookup(String name) {
return values.getOrDefault(name, false);
}
}
15. Implement AndExpression and OrExpression nonterminal classes for the boolean interpreter.
class AndExpression implements BooleanExpression {
private final BooleanExpression left, right;
AndExpression(BooleanExpression left, BooleanExpression right) {
this.left = left; this.right = right;
}
@Override
public boolean interpret(BooleanContext context) {
return left.interpret(context) && right.interpret(context);
}
}
class OrExpression implements BooleanExpression {
private final BooleanExpression left, right;
OrExpression(BooleanExpression left, BooleanExpression right) {
this.left = left; this.right = right;
}
@Override
public boolean interpret(BooleanContext context) {
return left.interpret(context) || right.interpret(context);
}
}
Using Java's own && and || operators inside interpret() gives short-circuit behavior for free, matching the semantics most languages expect.
16. Implement a NotExpression unary nonterminal, and explain how unary vs binary nonterminals differ structurally.
A binary nonterminal such as AndExpression holds two child references; a unary nonterminal such as NotExpression holds exactly one. Structurally both are still "nonterminal" because both recurse into at least one child, but the arity of the constructor and the shape of interpret()'s body differ.
class NotExpression implements BooleanExpression {
private final BooleanExpression operand;
NotExpression(BooleanExpression operand) { this.operand = operand; }
@Override
public boolean interpret(BooleanContext context) {
return !operand.interpret(context);
}
}
17. Show how to parse a simple boolean expression string like "A AND (B OR NOT C)" into the expression tree above.
Even for a "hand-rolled" interpreter, you still need some parsing step to turn text into the tree; a small recursive-descent parser over tokens is typically enough for a two-or-three-operator grammar like this one.
BooleanExpression tree = new AndExpression(
new BooleanVariableExpression("A"),
new OrExpression(
new BooleanVariableExpression("B"),
new NotExpression(new BooleanVariableExpression("C"))));
BooleanContext context = new MapBooleanContext(Map.of("A", true, "B", false, "C", false));
System.out.println(tree.interpret(context)); // true AND (false OR true) = true
Writing the parser by hand is exactly where a small grammar starts costing real effort; see the class-explosion and ANTLR questions later for where this stops being worthwhile.
18. Design a tiny discount-eligibility rules engine DSL using Interpreter (e.g. "orderTotal > 100 AND customerTier = GOLD").
Reuse the same boolean building blocks (AndExpression, OrExpression, NotExpression) but add comparison terminals that read business facts from a richer Context instead of plain booleans, giving product and business teams a small, composable rule language for eligibility logic.
interface RuleExpression {
boolean interpret(RuleContext facts);
}
class RuleContext {
private final Map<String, Object> facts;
RuleContext(Map<String, Object> facts) { this.facts = facts; }
Object get(String key) { return facts.get(key); }
}
19. Implement a GreaterThanExpression and EqualsExpression for the rules engine, and explain how to handle mixed types (numbers vs strings) in Context.
class GreaterThanExpression implements RuleExpression {
private final String field; private final double threshold;
GreaterThanExpression(String field, double threshold) { this.field = field; this.threshold = threshold; }
@Override
public boolean interpret(RuleContext facts) {
Object value = facts.get(field);
if (!(value instanceof Number number)) {
throw new IllegalStateException("Field '" + field + "' is not numeric: " + value);
}
return number.doubleValue() > threshold;
}
}
class EqualsExpression implements RuleExpression {
private final String field; private final Object expected;
EqualsExpression(String field, Object expected) { this.field = field; this.expected = expected; }
@Override
public boolean interpret(RuleContext facts) {
return Objects.equals(facts.get(field), expected);
}
}
Type mismatches (a rule expecting a number but finding a string fact) should fail loudly with a clear message rather than silently coercing, since a silently-wrong eligibility rule is worse than a rule that fails fast in tests.
20. How would you let business analysts author new discount rules without redeploying code, using the rules-engine interpreter above?
Store rules as text (or a simple structured format such as JSON) in a database or config service rather than compiled Java, parse them into the RuleExpression tree at load time or on a scheduled refresh, and cache the parsed tree for reuse. Analysts edit the rule text through an admin UI; the application never needs a new deployment to pick up a changed threshold or a newly composed AND/OR condition.
21. Design a Context class for the rules engine that holds order/customer facts, and discuss thread-safety when the same rule tree is evaluated across concurrent requests.
Build a fresh, immutable RuleContext per incoming request from that request's order and customer data, and never mutate an existing Context instance shared across requests. Because the expression tree itself (built once from parsed rule text) holds no per-request state, many concurrent threads can safely call interpret() on the very same tree, each passing its own Context, with zero contention.
RuleContext perRequestFacts = new RuleContext(Map.of(
"orderTotal", order.total(), "customerTier", customer.tier()));
boolean eligible = sharedCompiledRule.interpret(perRequestFacts); // tree is shared and stateless; facts are per-call
22. How do you add short-circuit evaluation (like Java's && and ||) to AndExpression/OrExpression so unnecessary child evaluations are skipped?
Simply write interpret() using Java's own && and || operators rather than evaluating both children eagerly into local variables first; Java's operator short-circuiting then does the work for you automatically.
// Wrong: evaluates right unconditionally, defeating short-circuiting
boolean l = left.interpret(context);
boolean r = right.interpret(context);
return l && r;
// Right: right.interpret(context) only runs if left is true
return left.interpret(context) && right.interpret(context);
23. Compare implementing business rules with an Interpreter-based DSL versus hardcoding if/else chains in Java, and when each is preferable.
Hardcoded if/else chains are faster to write initially and fully type-checked by the compiler, but every rule change requires a code change, a review, and a deployment. An Interpreter-based DSL trades a small upfront investment (terminal/nonterminal classes plus a parser) for the ability to change rule content — thresholds, added conditions, new combinations — without touching Java code at all.
Prefer if/else when rules are few, rarely change, and are owned by engineers. Prefer the Interpreter DSL when rules are numerous, change frequently, or need to be owned by a non-engineering team.
24. Walk through unit-testing the discount rules engine: what should be tested at the terminal level vs the full-tree level?
At the terminal level, test each comparison class in isolation with a hand-built RuleContext: does GreaterThanExpression return true/false correctly at, above, and below the threshold, and does it fail clearly on a type mismatch? At the full-tree level, build a realistic composed tree (AND of several comparisons) and assert the overall eligibility outcome for representative order/customer combinations, including edge cases like a missing fact.
@Test
void goldTierAboveThresholdIsEligible() {
RuleExpression rule = new AndExpression(
new GreaterThanExpression("orderTotal", 100),
new EqualsExpression("customerTier", "GOLD"));
RuleContext facts = new RuleContext(Map.of("orderTotal", 150.0, "customerTier", "GOLD"));
assertTrue(rule.interpret(facts));
}
25. Why doesn't the Interpreter pattern scale well to complex grammars? Explain the "one class per rule" class explosion problem.
Because each grammar production maps to exactly one class, a grammar with twenty operators, several precedence levels, and error-recovery rules needs dozens of small classes, each holding only a sliver of behavior. Beyond a handful of rules, navigating, testing, and mentally tracing this many tiny classes becomes harder than the parsing problem it was meant to simplify — the "one class per rule" mapping that made the pattern elegant for three or four rules turns into unmanageable sprawl for thirty.
26. At what point should a team abandon a hand-rolled Interpreter and adopt a real parser generator like ANTLR? What are the warning signs?
Warning signs include: the grammar has grown multiple precedence levels that the hand-written tree-building code keeps getting wrong; new operators require touching several existing files instead of adding one class; error messages for malformed input are inconsistent or missing; and engineers new to the codebase struggle to find where a given operator "lives" among many similarly-named classes.
Once any two of these show up together, the maintenance cost of the hand-rolled classes has likely exceeded the cost of learning ANTLR's grammar file syntax and generated-code workflow.
27. Compare hand-writing expression classes versus generating a parser and AST visitor from an ANTLR grammar file.
Hand-written classes give full control over every line, no build-time code generation step, and are easy to read for a tiny grammar — at the cost of hand-written lexing/parsing logic that must correctly handle precedence, whitespace, and error cases. ANTLR generates a lexer, parser, and parse-tree walker from a declarative .g4 grammar file, handling precedence and error recovery for you, at the cost of an added build step, generated code to understand, and a grammar-file syntax to learn.
| Aspect | Hand-rolled Interpreter | ANTLR-generated |
|---|---|---|
| Precedence handling | Manual, easy to get wrong | Declared in grammar rules, handled automatically |
| Error messages | You write them yourself | Reasonable defaults, customizable listeners |
| Adding an operator | New class(es) plus parser changes | One new grammar rule, regenerate |
28. What does ANTLR generate from a .g4 grammar file, and how does its generated parse tree differ from a GoF Interpreter AST?
From a .g4 grammar file, ANTLR generates a lexer (tokenizer), a parser, and a parse-tree class hierarchy along with a base Visitor or Listener you implement to walk it. ANTLR's parse tree is typically closer to a literal, verbose reflection of the grammar's rules (including punctuation tokens), whereas a hand-built GoF Interpreter AST is usually a leaner, purpose-built tree with only the nodes that matter for evaluation — you often write a small pass to convert ANTLR's parse tree into your own simplified AST before evaluating it.
29. Explain the difference between a parser combinator library and the GoF Interpreter pattern for building a small-language evaluator.
A parser combinator library (such as those inspired by Haskell's Parsec, available in various Java flavors) lets you build a parser by composing small parser functions with combinators like "sequence," "choice," and "many," directly in Java code, rather than declaring a separate grammar file or writing one class per grammar rule. It focuses purely on parsing text into a result, and you typically still design your own AST types to hold that result — Interpreter dictates that the AST types themselves each know how to evaluate (interpret) themselves, while a parser combinator approach is agnostic about how the resulting tree gets evaluated.
30. Discuss the trade-off between writing a hand-rolled recursive-descent parser producing Interpreter-style nodes versus using a parser generator, in terms of maintainability and onboarding new engineers.
A hand-rolled recursive-descent parser is approachable for a new engineer already comfortable with Java, since it is "just code" with no generated-code step or unfamiliar grammar-file syntax to learn — but its correctness for precedence and error cases depends entirely on the original author getting subtle recursive-descent techniques (like precedence climbing) right, and that knowledge can be hard to transfer.
A parser generator concentrates grammar knowledge in one declarative file that is, in principle, easier to review for correctness, but it does require the team to learn the generator's own conventions and tooling, and it adds a build dependency that must be kept in sync with the grammar file.
31. How would you evolve a hand-rolled Interpreter-based DSL that has grown from 3 operators to 20 operators and multiple precedence levels? What refactoring signals tell you it's time to move to ANTLR?
Track how many files a typical "add one operator" change touches over time; if it started at one new class and has grown to touching the parser, the tree-builder, and a visitor each time, the grammar has outgrown ad hoc hand-rolling. Also watch for growing precedence-climbing logic in the parser becoming duplicated or inconsistent across operator groups — that duplication is exactly the kind of bookkeeping a parser generator centralizes and gets right once.
32. Explain how operator precedence and associativity are normally expressed in a formal grammar, and how a hand-rolled Interpreter implementation can get precedence wrong if the tree is built naively.
Precedence and associativity are normally expressed by structuring the grammar into layered rules — for example a term rule for *// nested inside an expr rule for +/- — so that higher-precedence operators bind tighter simply because they live at a deeper grammar level. If a hand-rolled parser naively builds the tree left-to-right without this layering, "2 + 3 * 4" can be parsed as (2 + 3) * 4 = 20 instead of the mathematically correct 2 + (3 * 4) = 14, because addition greedily consumed operands meant for multiplication.
33. What is left recursion in a grammar and why does it matter when hand-writing a recursive-descent parser that feeds an Interpreter tree?
A grammar rule is left-recursive when a nonterminal's own first symbol, directly or indirectly, refers back to itself, such as expr ::= expr '+' term | term. A naive recursive-descent parser translates that rule into a function that calls itself immediately before consuming any input, which recurses infinitely and overflows the call stack before ever reaching a base case.
The standard fix is to rewrite the rule to be right-recursive or iterative (using a loop to build up left-associative operators instead of recursive calls), which is exactly what precedence-climbing parsers do; parser generators like ANTLR can also handle certain forms of left recursion automatically.
34. Describe a real production migration from a hand-rolled Interpreter DSL to ANTLR, including what had to change in calling code.
A reporting service's filter language started with five hand-rolled expression classes and grew to twenty-five over two years, with precedence bugs recurring every time a new comparison operator was added. The team wrote an ANTLR grammar covering the same language, generated a parser, and wrote a visitor that built the exact same internal FilterExpression tree type the rest of the application already depended on.
Because the internal tree type and its interpret()-equivalent evaluation logic were kept unchanged, only the parsing entry point (the function that turned filter text into a FilterExpression) needed to be swapped; every downstream caller of the resulting tree required no changes at all.
35. How does Spring Expression Language (SpEL) exemplify the Interpreter pattern in a real, production Java framework?
SpEL parses expression strings like "#user.age > 18" into an internal AST of node types (SpEL's own SpelNode hierarchy), each of which implements a shared evaluation contract analogous to interpret(), walking its children and combining results against an evaluation context that supplies variables, root objects, and registered functions — precisely the AbstractExpression/TerminalExpression/NonterminalExpression/Context shape the GoF pattern describes, at production scale.
ExpressionParser parser = new SpelExpressionParser();
Expression expr = parser.parseExpression("#user.age > 18 and #user.country == 'US'");
StandardEvaluationContext context = new StandardEvaluationContext();
context.setVariable("user", currentUser);
boolean eligible = (Boolean) expr.getValue(context);
36. Walk through evaluating a SpEL expression like "#user.age > 18 and #user.country == 'US'" and relate its internal AST nodes to GoF Interpreter roles.
SpEL first parses the string into an AST: an OpAnd node (nonterminal) with two children, each an OpGT/OpEQ comparison node (also nonterminal), each of which in turn has property-access nodes for #user.age and #user.country (terminals, in the sense that they resolve directly against the evaluation context) and literal nodes for 18 and 'US' (pure terminals).
Calling expr.getValue(context) triggers the same recursive walk a hand-written interpret() tree would: OpAnd asks its two comparison children to evaluate themselves, each comparison asks its property-access and literal children to resolve, and results combine back up the tree exactly like the arithmetic example earlier.
37. Explain how java.util.regex (or any regex engine) is itself a form of interpreter over the regular-expression grammar.
A regular expression is itself a sentence in the small formal grammar of regular expressions (literals, character classes, alternation |, repetition */+, grouping). Pattern.compile() parses that regex string into an internal representation — conceptually a tree of nodes for concatenation, alternation, and repetition — and Matcher then "interprets" that structure against an input string, deciding match or no-match by walking the same tree-like structure the regex describes.
38. Compare compiling a Pattern once and reusing it via Matcher versus re-parsing a regex string on every call, and relate this to compiling versus interpreting an expression tree.
Pattern.compile(regex) does the expensive parsing-into-internal-structure step once; each subsequent pattern.matcher(input) call reuses that already-built structure and only does the (much cheaper) matching work. Calling Pattern.matches(regex, input) repeatedly in a loop instead re-parses the same regex string on every call, which is the regex-engine equivalent of re-parsing an Interpreter expression string on every evaluation instead of caching the parsed tree.
// Slow: re-parses the regex every iteration
for (String line : lines) {
if (Pattern.matches("^[A-Z][a-z]+$", line)) { /* ... */ }
}
// Fast: parse once, match many times
Pattern namePattern = Pattern.compile("^[A-Z][a-z]+$");
for (String line : lines) {
if (namePattern.matcher(line).matches()) { /* ... */ }
}
39. Design a simple template engine that evaluates "{{variable}}" placeholders in a string using an Interpreter-style approach.
Model the template as a sequence of expressions: literal text segments are terminals that return themselves unchanged, and {{name}} placeholders are terminals that look the variable up in a template Context. A top-level TemplateExpression (nonterminal) holds the ordered list of segments and concatenates each one's interpreted result.
interface TemplateNode { String interpret(Map<String, Object> vars); }
class LiteralNode implements TemplateNode {
private final String text;
LiteralNode(String text) { this.text = text; }
public String interpret(Map<String, Object> vars) { return text; }
}
class PlaceholderNode implements TemplateNode {
private final String name;
PlaceholderNode(String name) { this.name = name; }
public String interpret(Map<String, Object> vars) {
return String.valueOf(vars.getOrDefault(name, ""));
}
}
40. Extend the template-placeholder interpreter above to support a simple conditional block like "{{#if premium}}...{{/if}}".
Add a nonterminal ConditionalNode that holds a variable name to test plus a list of child TemplateNodes to render only when that variable is truthy in the current Context — the same nonterminal-composes-children shape as AndExpression, just producing a rendered string instead of a boolean.
class ConditionalNode implements TemplateNode {
private final String conditionVar;
private final List<TemplateNode> children;
ConditionalNode(String conditionVar, List<TemplateNode> children) {
this.conditionVar = conditionVar; this.children = children;
}
public String interpret(Map<String, Object> vars) {
if (!Boolean.TRUE.equals(vars.get(conditionVar))) return "";
StringBuilder result = new StringBuilder();
for (TemplateNode child : children) result.append(child.interpret(vars));
return result.toString();
}
}
41. What security risks does an embedded expression/template interpreter introduce (e.g. SpEL injection), and how do you mitigate them?
If untrusted input (user-supplied text, a query parameter) is ever concatenated into an expression string that gets parsed and evaluated with full privileges, an attacker can craft an expression that calls arbitrary methods, reads environment variables, or in the worst documented cases achieves remote code execution — this is the same category of bug as SQL injection, but for expression languages, and has caused real CVEs against SpEL usage.
// Dangerous: attacker-controlled string evaluated with full method-invocation power
String userSuppliedFilter = request.getParameter("filter"); // untrusted!
Expression expr = parser.parseExpression(userSuppliedFilter);
expr.getValue(new StandardEvaluationContext(data)); // full method access
42. Compare SpEL's SimpleEvaluationContext versus StandardEvaluationContext, and explain why the choice matters for security when interpreting untrusted expressions.
StandardEvaluationContext permits the full range of SpEL features: arbitrary method invocation, constructor calls, bean references, and type lookups. SimpleEvaluationContext is deliberately restricted to a safer subset — property/field read and (optionally) write access, without arbitrary method invocation or type/constructor access — making it the appropriate choice whenever the expression text itself could originate from an untrusted or semi-trusted source.
EvaluationContext safeContext = SimpleEvaluationContext.forReadOnlyDataBinding().build();
expr.getValue(safeContext, data); // cannot invoke arbitrary methods
43. How would you sandbox a custom DSL interpreter so that user-supplied expressions cannot call arbitrary Java methods or access the filesystem?
The strongest sandbox is architectural: design the grammar so it has no "call any method" or "reference any class" production rule in the first place — only a fixed, whitelisted set of terminal and nonterminal expressions the interpreter itself defines, with no escape hatch to general Java reflection. Because a hand-rolled Interpreter's tree is built entirely from your own known classes (unlike a general-purpose embedded scripting engine), this containment is a natural side effect of the pattern rather than something bolted on afterward.
44. Explain how JSON Schema or XPath evaluation engines can also be understood as interpreters over their respective grammars.
An XPath expression like "//book[@price > 30]" is parsed into a tree of location-step and predicate nodes, then evaluated ("interpreted") against a document's node tree, walking axes and predicates recursively — structurally the same shape as a GoF Interpreter AST evaluated against a document-shaped Context. A JSON Schema document similarly describes validation rules (type constraints, required properties, nested sub-schemas) that a validator engine recursively walks and applies against an input JSON value, again matching the terminal/nonterminal/Context shape, just with "produce a list of validation errors" in place of "produce a boolean or number."
45. Compare the Interpreter pattern and the Visitor pattern, and explain why the two are so often used together in practice.
Interpreter's classic form puts the evaluation logic directly inside each expression class as its interpret() method — the operation and the node type are welded together. Visitor instead defines the AST node classes with only an accept(Visitor) method, and moves each operation (evaluate, pretty-print, type-check) into a separate Visitor class that implements one visit method per node type.
They pair naturally because Interpreter is excellent at describing the tree's shape (build the AST once, matching the grammar), while Visitor is excellent at adding new operations over that shape later without touching the node classes again — many real systems build the AST Interpreter-style and then evaluate it with one or more Visitors rather than baking evaluation into the nodes themselves.
46. Show how you would refactor the arithmetic expression interpreter from question 4 to separate evaluation logic into a Visitor instead of an interpret() method on each node.
interface ArithmeticNode { <R> R accept(ArithmeticVisitor<R> visitor); }
interface ArithmeticVisitor<R> {
R visitNumber(NumberNode node);
R visitVariable(VariableNode node);
R visitAdd(AddNode node);
}
class AddNode implements ArithmeticNode {
final ArithmeticNode left, right;
AddNode(ArithmeticNode left, ArithmeticNode right) { this.left = left; this.right = right; }
public <R> R accept(ArithmeticVisitor<R> visitor) { return visitor.visitAdd(this); }
}
class EvaluatingVisitor implements ArithmeticVisitor<Integer> {
private final Context context;
EvaluatingVisitor(Context context) { this.context = context; }
public Integer visitAdd(AddNode node) {
return node.left.accept(this) + node.right.accept(this);
}
// visitNumber, visitVariable ...
}
Now a second operation, such as a PrettyPrintVisitor, can be added without modifying AddNode, NumberNode, or any other node class at all.
47. Compare the Interpreter pattern and the Composite pattern, and explain the sense in which every Interpreter AST is also a Composite structure.
Composite is a purely structural pattern: it describes how to build tree structures of uniform-interface parts and wholes. Interpreter is a behavioral pattern layered on top of that same structural idea, adding the specific meaning that the tree represents a grammar and the shared operation is "interpret a sentence in that grammar." Every Interpreter AST satisfies Composite's structure (terminals are leaves, nonterminals are composites), but not every Composite structure is an Interpreter — a filesystem tree of files and folders is a Composite with no grammar or interpret() semantics at all.
48. Compare the Interpreter pattern and the Strategy pattern; both encapsulate an "algorithm," so what structurally distinguishes them?
Strategy encapsulates one interchangeable algorithm behind a single interface, chosen and swapped as a whole unit at runtime — there is exactly one strategy object active for a given concern, and it does not compose recursively with other strategies. Interpreter instead builds a tree of many small objects, each representing a tiny fragment of a grammar, whose combined recursive evaluation produces the overall result; the "algorithm" here is not one swappable object but the emergent behavior of the whole composed tree.
49. Compare the Interpreter pattern and the Command pattern for representing user actions as objects.
Command wraps a single request or action (with its receiver and parameters) as an object with an execute() method, primarily to support queuing, logging, and undo of discrete actions. Interpreter wraps grammar rules as objects with an interpret() method, primarily to support recursively evaluating composed sentences in a language. A macro-recording feature that replays a sequence of Commands is not the same as an Interpreter unless those commands are themselves composed according to a grammar (for example, a scripting language whose statements happen to be built from Command-like action objects) — the two patterns can appear together but solve different problems.
50. Compare the Interpreter pattern and the Chain of Responsibility pattern when both are candidates for processing a sequence of rules.
Chain of Responsibility passes a request along a linear chain of handlers, each deciding whether to handle it or pass it further down the same chain — it is fundamentally sequential and typically stops at the first handler that acts. Interpreter's expression tree is not a linear chain but a recursive hierarchy where nonterminals actively combine the results of multiple children (an AND of several conditions, for instance), rather than simply passing a single request along until someone claims it. Choose Chain of Responsibility when exactly one handler should "win"; choose Interpreter when the rules must be logically composed together into one combined result.
51. When would you choose the Builder pattern to assemble an Interpreter's expression tree programmatically, rather than parsing text into it?
When the tree is being constructed directly from application code or a UI (a visual rule-builder, for instance) rather than from a text string a human typed, skipping text parsing entirely and using a fluent Builder to assemble the tree is both simpler and safer, since there is no string-parsing step that can fail on malformed syntax.
RuleExpression rule = RuleBuilder.field("orderTotal").greaterThan(100)
.and(RuleBuilder.field("customerTier").equalTo("GOLD"))
.build();
52. Compare the Interpreter pattern to simply writing a switch/instanceof-based recursive evaluator over a sealed interface hierarchy in modern Java.
Classic GoF Interpreter puts interpret() as a method on every node class (each node knows how to evaluate itself). A modern alternative defines the node types as a sealed interface hierarchy with no behavior at all, and writes one central recursive function that pattern-matches on the node's runtime type via a switch expression. Both approaches produce the same recursive-tree-walk behavior; the difference is purely where the "what does this node mean" logic lives — spread across node classes (classic Interpreter) versus centralized in one function (modern pattern-matching style), which is really the same trade-off Visitor makes versus Interpreter.
53. How do Java 17+ sealed interfaces and pattern matching for switch change how you would implement an Interpreter-style AST today compared to classic GoF-era Java?
A sealed interface lets the compiler know the complete, closed set of node subtypes, so a switch expression pattern-matching over that hierarchy can be checked for exhaustiveness at compile time — if you add a new node type and forget to handle it in an evaluator's switch, the compiler now errors instead of silently falling through, which classic GoF-era Java (interfaces open to arbitrary implementers, no exhaustiveness checking) could never guarantee.
sealed interface Expr permits NumberExpr, VariableExpr, AddExpr {}
record NumberExpr(int value) implements Expr {}
record VariableExpr(String name) implements Expr {}
record AddExpr(Expr left, Expr right) implements Expr {}
static int eval(Expr expr, Context ctx) {
return switch (expr) {
case NumberExpr n -> n.value();
case VariableExpr v -> ctx.lookup(v.name());
case AddExpr a -> eval(a.left(), ctx) + eval(a.right(), ctx);
};
}
54. Rewrite the boolean expression interpreter from question 13 using a sealed interface and a switch expression instead of separate interpret() overrides per class.
sealed interface BoolExpr permits Var, And, Or, Not {}
record Var(String name) implements BoolExpr {}
record And(BoolExpr left, BoolExpr right) implements BoolExpr {}
record Or(BoolExpr left, BoolExpr right) implements BoolExpr {}
record Not(BoolExpr operand) implements BoolExpr {}
static boolean eval(BoolExpr expr, Map<String, Boolean> vars) {
return switch (expr) {
case Var v -> vars.getOrDefault(v.name(), false);
case And a -> eval(a.left(), vars) && eval(a.right(), vars);
case Or o -> eval(o.left(), vars) || eval(o.right(), vars);
case Not n -> !eval(n.operand(), vars);
};
}
The grammar's shape is now expressed as compact record types with no behavior, and all evaluation logic lives in one exhaustively-checked function.
55. Compare an Interpreter-based AST evaluator versus a Visitor-based evaluator when the AST node hierarchy is expected to grow new node types frequently versus new operations frequently (the expression problem).
This is the "expression problem": classic Interpreter (behavior on the node) makes adding a new node type trivial — write one new class implementing the shared interface — but adding a new operation (say, pretty-printing) requires touching every existing node class to add the new method. Visitor inverts this: adding a new operation is trivial — write one new Visitor implementation — but adding a new node type requires touching every existing Visitor implementation to handle it.
Pick Interpreter-style (behavior on the node) when new node types are expected to arrive often and operations are stable; pick Visitor when new operations are expected to arrive often and the set of node types is stable.
56. Discuss whether Interpreter is really a distinct structural pattern from Composite plus polymorphic dispatch, and how interviewers expect you to draw the line.
Structurally, yes, Interpreter is "just" a Composite tree with polymorphic dispatch — the GoF authors themselves note the strong relationship. What makes it worth naming separately is intent and vocabulary: Interpreter specifically signals "this tree represents a grammar, and traversal means evaluating a sentence in that language," which communicates design intent to other engineers far more precisely than saying "it's a tree with an operation on it."
A strong interview answer acknowledges the structural overlap explicitly rather than pretending the two patterns are unrelated, while still being able to say when naming it "Interpreter" specifically adds useful meaning (grammar-shaped domains) versus when "Composite" is the more honest name (no grammar, just parts-and-wholes).
57. What performance concerns arise from interpreting the same expression tree repeatedly (e.g. inside a hot loop), and how would you address them?
Each call to interpret() re-walks the entire tree and redoes any work that does not depend on the specific call's Context, which is wasteful if the same expression is evaluated thousands of times per second with only small pieces of the input changing. Address it by caching the parsed tree itself (never re-parse text on every call), by hoisting any genuinely constant subexpressions so they are computed once, and, for very hot paths, by compiling the tree into a more direct executable form instead of tree-walking it every time.
58. Compare interpreting an expression tree on every evaluation versus compiling it once into JVM bytecode using a library like ASM.
Tree-walking interpretation re-dispatches through virtual method calls and re-traverses object references on every evaluation, which the JIT can optimize reasonably well but never eliminates entirely. Compiling the same expression into actual JVM bytecode with a library such as ASM turns the tree into a single generated method the JIT can inline and optimize as aggressively as any other Java method, trading an upfront (and nontrivial) code-generation cost for much faster repeated execution — a trade-off worth making only when the same expression is evaluated an extremely large number of times, since class generation and loading themselves are not free.
59. How would you cache a parsed expression tree so that repeated evaluations of the same expression string skip re-parsing?
Key a cache (a plain ConcurrentHashMap is often sufficient) by the raw expression string, and store the already-built, immutable expression tree as the value; look up the cache before parsing, and only parse-and-store on a cache miss. Because the trees built by these patterns are typically stateless once constructed, the same cached tree instance can safely be reused and evaluated concurrently by many threads, each supplying its own Context.
private final Map<String, RuleExpression> parsedRuleCache = new ConcurrentHashMap<>();
RuleExpression getOrParse(String ruleText) {
return parsedRuleCache.computeIfAbsent(ruleText, this::parse);
}
60. Explain how just-in-time (JIT) compilation in the JVM interacts with a heavily-used interpret() call path, and why megamorphic call sites can hurt performance.
A call site that repeatedly invokes interpret() on the same concrete type (monomorphic) is easy for the JIT to inline and optimize aggressively. But because a real expression tree mixes many different concrete node types at the same call site (a NumberExpression here, an AddExpression there, called through the same abstract interpret() call), the call site quickly becomes "megamorphic," which prevents effective inlining and forces a slower virtual dispatch on every call — one reason compiling to a single flat method (see the ASM question) can outperform tree-walking for hot paths, since it collapses many virtual dispatches into ordinary sequential bytecode.
61. Discuss memory allocation concerns when building large expression trees for short-lived evaluations, and how object pooling or flyweight terminals can help.
Building a fresh tree of many small objects for every single evaluation, especially in a hot loop, produces significant short-lived garbage-collector pressure. If the same terminal values recur often (the same variable name, the same numeric literal), sharing immutable terminal instances rather than allocating a new one each time reduces allocation without changing behavior, since terminals like NumberExpression and VariableExpression hold no mutable state and are safe to share freely.
62. How would you benchmark an Interpreter-based evaluator against a compiled alternative using JMH, and what pitfalls would you watch for in the benchmark itself?
Use JMH's @Benchmark methods with adequate warm-up iterations so the JIT has time to optimize both the interpreted and compiled paths before measurements are taken, and use Blackhole.consume() on the result so the JIT cannot eliminate the "unused" computation as dead code.
@Benchmark
public void treeWalkingInterpret(Blackhole bh) { bh.consume(sharedTree.interpret(context)); }
@Benchmark
public void compiledEvaluator(Blackhole bh) { bh.consume(compiledExpr.evaluate(context)); }
Common pitfalls: measuring without warm-up (biases toward the interpreter, since compilation's overhead front-loads differently), reusing mutable state across iterations in a way that hides allocation cost, and benchmarking on too few distinct expression shapes to be representative of production traffic.
63. Explain the Flyweight pattern's relevance to Interpreter when many identical TerminalExpression instances (e.g. the same variable name) are created repeatedly.
Flyweight shares immutable, intrinsic state across many logical instances instead of allocating a new object for each occurrence. Because a VariableExpression("x") or NumberExpression(5) terminal is fully described by its immutable constructor argument and holds no per-occurrence mutable state, an interning cache (a simple Map from value to instance) can hand back the same shared terminal object for every occurrence of the same literal or variable name across many parsed trees — GoF explicitly calls this out as a natural pairing for Interpreter's terminal expressions.
class VariableExpressionPool {
private static final Map<String, VariableExpression> POOL = new ConcurrentHashMap<>();
static VariableExpression of(String name) {
return POOL.computeIfAbsent(name, VariableExpression::new);
}
}
64. Describe a real scenario where switching from tree-walking interpretation to a simple bytecode/stack-machine compilation step measurably improved throughput.
A pricing engine evaluated a shared discount-rule tree millions of times per hour across incoming orders. Profiling showed a meaningful fraction of CPU time going to virtual dispatch overhead in the megamorphic interpret() call site rather than the actual comparisons. The team added a compile step that walked each rule tree once and emitted a flat list of simple stack-machine instructions (push constant, push field, compare, and, or); evaluating that instruction list with a plain loop and an array-backed stack, instead of recursive virtual calls, measurably cut per-evaluation latency under load, at the cost of a one-time compile pass per distinct rule.
65. Discuss how to unit test each expression type (terminal and nonterminal) in isolation, separately from integration tests over full expression trees.
Test each terminal class directly with a hand-built Context, asserting the exact value it returns for known input — this is fast and pinpoints exactly which node type is wrong if it fails. Test each nonterminal class using simple stub or mock child expressions (not real subtrees) so the nonterminal's own combining logic is verified in isolation from whatever its children happen to compute.
@Test
void addExpressionSumsChildResults() {
ArithmeticExpression stubLeft = ctx -> 4;
ArithmeticExpression stubRight = ctx -> 7;
assertEquals(11, new AddExpression(stubLeft, stubRight).interpret(new Context()));
}
66. Design a test suite for the arithmetic interpreter from question 4 covering terminals, nonterminals, and full end-to-end expressions.
Layer the suite in three tiers: unit tests per terminal (NumberExpression returns its literal; VariableExpression reads and throws on missing keys), unit tests per nonterminal using stub children (as in the previous question), and a smaller set of full-tree integration tests that parse or hand-build a realistic multi-operator expression and assert the final numeric result against a hand-computed expected value, including at least one deeply-nested tree to catch recursion issues.
67. What thread-safety concerns arise when multiple threads share and evaluate expressions against the same Context instance concurrently?
If Context is mutable and shared across concurrent evaluations — for example, if variable bindings are written into a shared map mid-evaluation rather than passed in fully-formed — one thread's in-progress evaluation can read another thread's partially-written or unrelated bindings, producing nondeterministic, hard-to-reproduce results. This is a classic shared-mutable-state race condition, not unique to Interpreter, but easy to introduce accidentally because Context often looks like harmless "just a map."
68. How would you make a Context safe for concurrent read-only evaluation while still allowing per-request variable bindings?
Construct a brand-new Context instance per request from that request's own data, backed by an unmodifiable or defensively-copied map, so no two concurrent evaluations ever share a mutable reference. The expression tree itself, if it holds no per-request state, can and should be shared and reused across all these concurrent per-request Context instances.
class Context {
private final Map<String, Integer> variables;
Context(Map<String, Integer> variables) { this.variables = Map.copyOf(variables); } // immutable snapshot
}
69. Explain how to extend the arithmetic grammar with a new operator (e.g. multiplication) without modifying any existing expression classes, in line with the Open/Closed Principle.
Because every existing expression class only depends on the shared ArithmeticExpression interface, adding MultiplyExpression as a brand-new class implementing that same interface requires touching zero existing classes — NumberExpression, VariableExpression, AddExpression, and SubtractExpression are all unaffected.
class MultiplyExpression implements ArithmeticExpression {
private final ArithmeticExpression left, right;
MultiplyExpression(ArithmeticExpression left, ArithmeticExpression right) {
this.left = left; this.right = right;
}
@Override
public int interpret(Context context) {
return left.interpret(context) * right.interpret(context);
}
}
This is exactly the sense in which Interpreter honors the Open/Closed Principle for adding new grammar rules: open for extension via new classes, closed for modification of existing ones.
70. What has to change across the whole codebase (parser, tree-building code, and possibly a Visitor) when you add a brand-new nonterminal expression type, and why does this reveal Interpreter's OCP limits?
The new expression class itself needs no changes to existing sibling classes, but the parser or tree-builder that turns text into the tree must be taught the new operator's token and precedence, and if the codebase also uses a Visitor for a second operation (pretty-printing, type-checking), every existing Visitor implementation must add a new visit method for the new type. So while the node hierarchy itself is open for extension, the surrounding parsing and any Visitor-based operations are not — this is the practical, whole-system version of the "expression problem" from question 55, and a candid interview answer should name both sides rather than only claiming a clean OCP win.
71. Implement a simple postfix (Reverse Polish Notation) calculator using the Interpreter pattern, parsing tokens like "3 4 + 2 *".
RPN maps naturally onto Interpreter: scan tokens left to right, pushing a NumberExpression for each numeric token onto a stack, and whenever an operator token appears, pop the two most recently pushed expressions as children of the matching nonterminal (AddExpression, MultiplyExpression, ...) and push that composed nonterminal back onto the stack. After the last token, exactly one expression remains on the stack — the full tree.
ArithmeticExpression parseRpn(String rpn) {
Deque<ArithmeticExpression> stack = new ArrayDeque<>();
for (String token : rpn.split("\\s+")) {
switch (token) {
case "+" -> { var r = stack.pop(); var l = stack.pop(); stack.push(new AddExpression(l, r)); }
case "*" -> { var r = stack.pop(); var l = stack.pop(); stack.push(new MultiplyExpression(l, r)); }
default -> stack.push(new NumberExpression(Integer.parseInt(token)));
}
}
return stack.pop();
}
72. Walk through evaluating "5 1 2 + 4 * + 3 -" with the RPN interpreter above, showing the operand stack at each step.
// Tokens: 5 1 2 + 4 * + 3 -
// Stack after each token (top on the right):
// 5 -> [5]
// 1 -> [5, 1]
// 2 -> [5, 1, 2]
// + -> pop 2,1 -> push Add(1,2) -> [5, Add(1,2)]
// 4 -> [5, Add(1,2), 4]
// * -> pop 4,Add(1,2) -> push Multiply(Add(1,2),4) -> [5, Multiply(Add(1,2),4)]
// + -> pop both -> push Add(5, Multiply(Add(1,2),4)) -> [Add(5, Multiply(Add(1,2),4))]
// 3 -> [Add(...), 3]
// - -> pop both -> push Subtract(Add(...), 3) -> [Subtract(Add(5, Multiply(Add(1,2),4)), 3)]
// interpret(): 5 + (1 + 2) * 4 - 3 = 5 + 12 - 3 = 14
The final stack contains exactly one node, the same tree the corresponding infix expression "5 + (1 + 2) * 4 - 3" would build, just constructed without needing precedence-aware parsing at all — RPN's token order already encodes the tree shape.
73. Compare building an RPN evaluator using an explicit Deque<Double> stack machine versus building a full Interpreter class hierarchy; when is the stack machine simpler?
If you only ever need the final numeric result and never need to inspect, cache, pretty-print, or re-evaluate the expression's structure, a direct stack machine that evaluates numbers immediately (popping two operands and pushing the numeric result of applying the operator, with no intermediate expression objects at all) is simpler and faster than building an Interpreter class hierarchy first.
double evalRpnDirect(String rpn) {
Deque<Double> stack = new ArrayDeque<>();
for (String token : rpn.split("\\s+")) {
switch (token) {
case "+" -> { double r = stack.pop(), l = stack.pop(); stack.push(l + r); }
case "*" -> { double r = stack.pop(), l = stack.pop(); stack.push(l * r); }
default -> stack.push(Double.parseDouble(token));
}
}
return stack.pop();
}
Build the full Interpreter hierarchy instead when you need the tree itself as a reusable, inspectable object — for caching, pretty-printing, or evaluating the same parsed tree against many different Contexts.
74. Describe a bug where a mutable, shared Context field got corrupted because two concurrent evaluations of the same expression tree wrote to it simultaneously.
A team cached one shared Context instance per rule set (to avoid rebuilding it) and had a nonterminal expression write an "intermediate accumulator" field onto that shared Context during evaluation, intending it purely as scratch space. Under load, two threads evaluating the same rule tree concurrently against the same shared Context interleaved their writes to that accumulator field, so each thread sometimes read the other thread's partial intermediate value, producing intermittently wrong eligibility results that only appeared under concurrent traffic and were extremely difficult to reproduce in a single-threaded test.
75. How would you fix the shared-Context corruption bug from question 74 without giving up a single shared, cached expression tree?
Keep the expensive-to-build expression tree shared and cached (it holds no per-evaluation state, so that part was never the problem), but stop using Context as scratch space: any intermediate accumulator value should be a local variable inside the interpret() call stack (a parameter or return value), never a field written onto a shared object. If genuinely per-evaluation mutable state is unavoidable, construct a small, fresh, non-shared holder object for it on every call instead of writing onto the shared Context.
76. Describe a StackOverflowError bug caused by deeply nested or deeply recursive expressions, and how you would redesign the interpreter to avoid it.
A log-filtering DSL let users chain conditions with repeated OR, and an automated tool generated a filter with several thousand chained OR terms, producing an expression tree several thousand nodes deep along its right-hand spine. Because OrExpression.interpret() recurses into its right child, evaluating that single generated filter threw a StackOverflowError, taking down the request thread, even though the expression was semantically simple.
The fix was twofold: cap the maximum nesting depth accepted at parse time with a clear error message, and, separately, redesign the evaluator to flatten a long chain of the same associative operator (many chained ORs) into one nonterminal holding a List of children evaluated in a loop, rather than a deeply nested binary chain.
77. How would you convert a deeply recursive interpret() implementation into an iterative, explicit-stack-based evaluator to avoid stack overflow on pathological input?
Replace the JVM's own call stack with an explicit, heap-allocated stack (an ArrayDeque, which can grow far larger than the default thread stack) and process nodes in a loop rather than through recursive method calls, manually tracking each node's evaluation state (not-yet-visited children versus ready-to-combine) instead of relying on the call stack to do it implicitly.
// Sketch: post-order traversal using an explicit stack instead of recursion
Deque<ArithmeticExpression> toVisit = new ArrayDeque<>();
Deque<Integer> results = new ArrayDeque<>();
// push nodes, tracking child-visited state, pop and combine using 'results' instead of return values
This trades simpler, more readable recursive code for resilience against pathologically deep or adversarially-generated trees.
78. Describe a bug where operator precedence was handled incorrectly because nonterminal expressions were composed in the wrong tree shape during parsing.
A homegrown expression parser built the tree for "orderTotal > 50 AND customerTier = GOLD OR isVip = true" strictly left to right with no precedence awareness, producing ((A AND B) OR C) when the business intent (and the usual convention that AND binds tighter than OR) actually required (A AND (B OR C)) — silently approving discounts for VIP customers regardless of order total, which was the opposite of the intended rule.
The bug was invisible in code review because every individual class (AndExpression, OrExpression) was correct in isolation; the defect was purely in how the parser chose to nest them.
79. How would you validate that a hand-built expression tree actually respects the grammar's intended operator precedence before shipping it?
Write parser-level tests that assert on the tree's actual shape, not just its evaluated result, since two different tree shapes can coincidentally evaluate to the same answer for one specific set of inputs while disagreeing for others.
@Test
void andBindsTighterThanOr() {
RuleExpression tree = parse("A AND B OR C");
assertInstanceOf(OrExpression.class, tree);
OrExpression or = (OrExpression) tree;
assertInstanceOf(AndExpression.class, or.left()); // confirms (A AND B) OR C, not A AND (B OR C)
}
Pair this with a table of precedence-sensitive test expressions covering every pair of operators the grammar supports, so a future change to the parser cannot silently regress precedence for a combination nobody thought to test.
80. What common mistakes do developers make when a TerminalExpression accidentally holds mutable state instead of being effectively immutable?
A common mistake is giving a terminal a setter, or caching a resolved value in an instance field after first evaluation, intending it as an optimization — but because terminal instances are often shared or cached across many evaluations and threads (see the Flyweight question), a mutable field turns an object meant to be freely shareable into one that silently carries stale or cross-request state, reintroducing the same class of bug as the shared-Context corruption case.
81. Explain how to add proper error handling to an interpreter so that an undefined variable reference produces a clear diagnostic instead of a NullPointerException.
Have Context.lookup() explicitly check for a missing key and throw a descriptive, purpose-built exception naming the missing variable, rather than letting a null silently propagate into arithmetic or comparison logic and later surface as a confusing NullPointerException several calls removed from the actual cause.
int lookup(String name) {
if (!variables.containsKey(name)) {
throw new UndefinedVariableException("Variable '" + name + "' is not defined in this Context");
}
return variables.get(name);
}
82. How would you add line/column position information to expression nodes so parse or evaluation errors can be reported with useful context to the DSL author?
Have the parser attach the source token's line and column (captured during lexing) to each expression node as it is constructed, then include that position whenever an exception is thrown during parsing or evaluation, so a rule author sees exactly where in their rule text the problem occurred rather than a bare, unlocated error message.
record SourcePosition(int line, int column) {}
class GreaterThanExpression implements RuleExpression {
private final String field; private final double threshold; private final SourcePosition position;
// ... constructor omitted
public boolean interpret(RuleContext facts) {
if (!(facts.get(field) instanceof Number number)) {
throw new RuleEvaluationException(
"Field '" + field + "' is not numeric (at line " + position.line() + ")");
}
return number.doubleValue() > threshold;
}
}
83. Discuss how to support short-circuiting side-effecting expressions safely (e.g. a function-call expression with side effects) inside a boolean AND/OR tree.
If a terminal or nonterminal expression can have an observable side effect (logging, a metered external call, a counter increment), short-circuit evaluation means that side effect may or may not run depending on the other operand's value — the same subtlety Java's own &&/|| carry. Document this explicitly at the grammar level, and where predictable side effects genuinely matter, either forbid side-effecting expressions from appearing as operands of AND/OR entirely, or provide a clearly-named non-short-circuiting variant (an "eager AND") for the rare cases that need to always run both operands.
84. What is the risk of allowing an Interpreter-based DSL to call into arbitrary application methods, and how do you constrain the DSL's capabilities intentionally?
If the grammar includes a generic "call this method by name with these arguments" production, the DSL effectively becomes as powerful, and as dangerous, as reflection itself — any caller who can author or influence rule text gains an unbounded capability surface, which is especially risky if rule text ever originates from outside a trusted engineering team. Constrain the DSL by only ever offering a small, explicit, whitelisted set of nonterminal operations the interpreter itself defines (as in question 43), never a generic method-invocation escape hatch, so the language's full capability set is enumerable simply by reading the expression class hierarchy.
85. Describe how you would add a caching/memoization layer over interpret() for subexpressions whose value cannot change within a single evaluation pass.
If a subexpression is referenced from multiple places in the same tree (a shared sub-tree, or the same field looked up by several different comparisons) and its value cannot change mid-evaluation, wrap that subexpression so its first interpret() result within a given evaluation is cached and returned directly on subsequent calls during that same pass, keyed per-evaluation rather than globally so different Contexts still get correct, independent results.
class MemoizingExpression implements ArithmeticExpression {
private final ArithmeticExpression delegate;
MemoizingExpression(ArithmeticExpression delegate) { this.delegate = delegate; }
@Override
public int interpret(Context context) {
// cache keyed by context identity, cleared per top-level evaluation call
return context.memoize(this, () -> delegate.interpret(context));
}
}
86. How would you support user-defined variables and functions in a small DSL interpreter (e.g. "let x = 5 in x + 1")?
Add a LetExpression nonterminal that evaluates its bound value expression, produces a new, extended Context with that name bound to the result (never mutating the original Context, to avoid leaking the binding outside its scope), and evaluates its body expression against that extended context — this is exactly how lexical scoping is implemented in real interpreters, GoF-style or otherwise.
class LetExpression implements ArithmeticExpression {
private final String name; private final ArithmeticExpression valueExpr, bodyExpr;
LetExpression(String name, ArithmeticExpression valueExpr, ArithmeticExpression bodyExpr) {
this.name = name; this.valueExpr = valueExpr; this.bodyExpr = bodyExpr;
}
public int interpret(Context context) {
Context extended = context.withBinding(name, valueExpr.interpret(context));
return bodyExpr.interpret(extended); // 'name' is scoped only to bodyExpr
}
}
87. Explain how to pretty-print or serialize an expression tree back into its original DSL syntax, and why this is useful for debugging.
Add a second operation alongside interpret() — either another method on each class, or (preferably, per the Visitor discussion) a separate PrettyPrintVisitor — that recursively renders each node back into readable text, wrapping nonterminal children in parentheses as needed to preserve the original grouping.
class PrettyPrintVisitor implements ArithmeticVisitor<String> {
public String visitAdd(AddNode node) {
return "(" + node.left.accept(this) + " + " + node.right.accept(this) + ")";
}
// ...
}
This is invaluable for debugging because it lets you log the exact tree shape a parser produced for a given rule, confirming precedence and grouping decisions without stepping through a debugger.
88. How would you implement a type-checking pass over an expression tree before evaluation, to catch a boolean-in-arithmetic-context error early?
Add a static analysis pass, run once after parsing and before any evaluation, that recursively infers or checks each node's declared result type (numeric, boolean, string) and verifies that every nonterminal's children have types compatible with what that operator expects — an AddExpression whose child happens to be a boolean-producing comparison should be rejected at this stage with a clear error, rather than discovered only when a confusing runtime ClassCastException occurs deep inside an actual evaluation.
89. Discuss versioning a DSL's grammar over time: how do you evolve the language without breaking previously-saved rule definitions?
Tag every saved rule definition with the grammar version it was authored against, keep the parser (and, if precedence or semantics genuinely changed, the interpreter classes) for older versions available rather than deleting them, and route each saved rule through the parser matching its own tag. Purely additive grammar changes, such as introducing a new operator, are safe without versioning at all; only changes that alter the meaning or precedence of existing syntax require this kind of explicit versioning discipline.
90. How would you log or trace interpreter evaluation for observability, without leaking sensitive data that might appear in Context variables?
Log the shape of the evaluation (which rule ran, which branch of an AND/OR was taken, the final boolean or numeric outcome, and timing) rather than the raw variable values themselves, since Context often carries customer PII or other sensitive fields that should never land in application logs. Where variable values genuinely need to appear for debugging, redact or hash known-sensitive field names centrally in the logging layer rather than trusting every expression class to remember to do so individually.
91. Explain how the Interpreter pattern relates to how many rules engines (e.g. Drools) represent and evaluate conditions internally.
Production rules engines like Drools compile authored rules (often expressed in a dedicated rule language or decision tables) into an internal representation of conditions and actions that gets matched against working-memory facts, conceptually similar to an Interpreter AST evaluated against a Context of facts — the key difference is that engines like Drools additionally use sophisticated pattern-matching algorithms (such as the Rete algorithm) to evaluate many rules against many facts efficiently and incrementally, something a naive hand-rolled tree-walking Interpreter does not attempt on its own. Recognizing Interpreter as the conceptual seed of these production rules engines, while knowing they add substantial matching-efficiency machinery on top, is a strong interview signal.
92. Compare embedding a full scripting engine (Java Scripting API / GraalVM JavaScript) into your application versus hand-rolling an Interpreter-based DSL.
An embedded scripting engine gives you a complete, well-tested general-purpose language for free — variables, functions, control flow, a large standard library — at the cost of a heavier runtime dependency, a much larger and harder-to-audit capability surface, and less control over exactly what operations are possible. A hand-rolled Interpreter-based DSL gives you a deliberately tiny, fully-auditable language with an explicit, enumerable set of operations, at the cost of writing and maintaining that grammar yourself and re-solving problems (like error messages and precedence) the scripting engine would have handled for you.
93. What are the security implications of exposing an embedded scripting engine to end users versus a deliberately restricted, hand-rolled Interpreter grammar?
A general-purpose scripting engine exposed to untrusted input carries a large and often incompletely-documented capability surface — file access, network access, arbitrary loops that can hang a thread — and sandboxing it correctly (restricting classes, methods, and resource usage) is itself a substantial and error-prone undertaking. A hand-rolled Interpreter grammar with no method-invocation or I/O production rules at all is, by construction, incapable of most of those attacks, simply because the capability was never built into the language in the first place — for genuinely untrusted or public-facing DSL input, this containment-by-omission is usually the safer default over trying to sandbox a general engine.
94. How would you design an interpreter so that adding a new Context variable type (e.g. dates, in addition to numbers and strings) doesn't require touching every existing expression class?
Keep the value type behind a single, generic Object-typed (or a small sealed value-type hierarchy) accessor on Context, and have each comparison or operator expression perform its own type check and cast only for the types it actually cares about, rather than having Context expose type-specific getters that every expression class would need to know about. Adding support for dates then means adding a new comparison expression class (such as DateAfterExpression) that knows how to handle the new type, without modifying GreaterThanExpression, EqualsExpression, or any other existing class.
95. Discuss how you would document a small DSL's grammar (e.g. using EBNF) so other engineers can extend the Interpreter implementation correctly.
Write the grammar down formally using EBNF (Extended Backus-Naur Form) alongside the code, even for a small hand-rolled language, since EBNF unambiguously captures precedence, associativity, and the exact set of valid productions in a way prose explanations tend to miss or contradict over time.
expr ::= term (('+' | '-') term)*
term ::= factor (('*' | '/') factor)*
factor ::= NUMBER | VARIABLE | '(' expr ')'
Keep this grammar file next to the expression classes and treat it as the source of truth; any change to precedence or supported syntax should update the EBNF first, then the classes, so the two never silently drift apart.
96. How would you handle a division-by-zero or similarly invalid runtime operation inside a NonterminalExpression's interpret() method?
Detect the invalid condition explicitly inside the operator's own interpret() method and throw a clear, purpose-built exception naming the operation and the offending operands, rather than letting Java's own ArithmeticException (for integer division) or a silent Infinity/NaN (for floating-point division) propagate with no DSL-level context attached.
class DivideExpression implements ArithmeticExpression {
private final ArithmeticExpression left, right;
DivideExpression(ArithmeticExpression left, ArithmeticExpression right) {
this.left = left; this.right = right;
}
public int interpret(Context context) {
int divisor = right.interpret(context);
if (divisor == 0) {
throw new RuleEvaluationException("Division by zero in expression");
}
return left.interpret(context) / divisor;
}
}
97. Explain how the Interpreter pattern's Context differs conceptually from a Memento, since both can hold state related to the object being processed.
Memento captures and externally stores a snapshot of an object's internal state at one point in time, specifically so that state can later be restored, without violating that object's encapsulation. Interpreter's Context instead holds forward-looking input data (variable bindings, facts) that expressions consult during evaluation — it is not a snapshot of anything's prior internal state, is not meant to be "restored" onto some object, and typically flows forward through a single evaluation rather than being archived for later rollback. The two patterns solve unrelated problems that simply happen to both involve an object called "state."
98. Walk through refactoring a growing if/else-based rule evaluator in a real order-management service into an Interpreter-based rule tree, and the tests you would write before doing so.
First, write characterization tests against the existing if/else evaluator's current outputs for a representative set of real order/customer combinations, so any refactor can be checked against a known-good baseline. Second, design the expression grammar and classes (terminals for facts and literals, nonterminals for the logical operators the if/else chain implicitly used). Third, translate each existing if/else branch into an equivalent tree, running both the old evaluator and the new tree side by side against the same characterization inputs until they agree exactly. Finally, once agreement is confirmed, remove the old if/else evaluator and keep only the tree-based one going forward.
99. Summarize the top interview-ready guidance for when to reach for Interpreter versus when to reach for ANTLR, an embedded scripting engine, or a plain hardcoded evaluator.
Reach for a plain hardcoded evaluator (if/else or switch) when the rules are few and rarely change. Reach for GoF Interpreter when you have a small, stable grammar (roughly a handful of operators) that benefits from being expressed as data or text rather than compiled code, and you want full control with no external dependency. Reach for ANTLR once the grammar grows past that small handful of rules, gains multiple precedence levels, or needs robust error recovery. Reach for an embedded scripting engine or a mature rules engine when you genuinely need a general-purpose language or sophisticated multi-rule matching, and are willing to accept the larger capability surface and runtime footprint that comes with it.
100. Design a complete small DSL interpreter for a feature-flag targeting rule (e.g. "country IN (US,CA) AND rolloutPercentage <= 20"), combining terminals, nonterminals, Context, and a note on production hardening.
Define a TargetingExpression interface with interpret(TargetingContext), terminals for field comparisons (InExpression for set membership, LessThanOrEqualExpression for the rollout percentage check, both reading facts from Context), and reuse AndExpression/OrExpression for combining them, exactly following the same shape used throughout this guide.
interface TargetingExpression { boolean interpret(TargetingContext context); }
class InExpression implements TargetingExpression {
private final String field; private final Set<String> allowed;
InExpression(String field, Set<String> allowed) { this.field = field; this.allowed = allowed; }
public boolean interpret(TargetingContext context) {
return allowed.contains(context.get(field));
}
}
TargetingExpression rule = new AndExpression(
new InExpression("country", Set.of("US", "CA")),
new LessThanOrEqualExpression("rolloutPercentage", 20));
For production hardening: parse and cache targeting rules once per flag (not per request), build a fresh immutable Context per evaluated user, restrict the grammar to this fixed set of comparison operators with no method-invocation escape hatch, add clear diagnostics for undefined fields, and log which branch of each rule matched (not raw user attribute values) for auditability.
Post a Comment
Add