Implement the Interpreter Pattern for DSLs in C
The Interpreter design pattern defines a grammatical representation for a domain-specific language (DSL) and provides an interpreter to evaluate sentences within that language. This article demonstrates how to implement this pattern in C# to parse and evaluate custom expressions. By breaking down language rules into terminal and non-terminal expression classes arranged in an Abstract Syntax Tree (AST), you can build lightweight, extensible domain-specific evaluation engines.
Core Components of the Pattern
The pattern relies on four primary components:
- Context: Holds global state, variables, or environment information needed during interpretation.
- Abstract Expression (
IExpression): Declares theInterpretoperation common to all nodes in the syntax tree. - Terminal Expression: Implements the interpretation for primitive elements (e.g., numbers, variable identifiers) that terminate an AST branch.
- Non-Terminal Expression: Implements interpretation for composite grammar rules (e.g., addition, logical AND) and recursively evaluates child expressions.
1. Define the Context and Expression Interface
The context contains runtime variables that expressions can query, and the interface enforces the interpretation contract.
using System.Collections.Generic;
public class Context
{
private readonly Dictionary<string, int> _variables = new();
public void SetVariable(string name, int value) => _variables[name] = value;
public int GetVariable(string name)
{
if (_variables.TryGetValue(name, out var value))
{
return value;
}
throw new KeyNotFoundException($"Variable '{name}' not defined.");
}
}
public interface IExpression
{
int Interpret(Context context);
}2. Implement Terminal Expressions
Terminal expressions do not contain sub-expressions. They return immediate values or resolve variables from the context.
// Evaluates literal integer values
public class NumberExpression : IExpression
{
private readonly int _value;
public NumberExpression(int value)
{
_value = value;
}
public int Interpret(Context context) => _value;
}
// Evaluates variable references using the context
public class VariableExpression : IExpression
{
private readonly string _name;
public VariableExpression(string name)
{
_name = name;
}
public int Interpret(Context context) => context.GetVariable(_name);
}3. Implement Non-Terminal Expressions
Non-terminal expressions represent grammar operations. They hold
references to child IExpression instances and combine their
results.
// Addition Operation
public class AddExpression : IExpression
{
private readonly IExpression _left;
private readonly IExpression _right;
public AddExpression(IExpression left, IExpression right)
{
_left = left;
_right = right;
}
public int Interpret(Context context) => _left.Interpret(context) + _right.Interpret(context);
}
// Multiplication Operation
public class MultiplyExpression : IExpression
{
private readonly IExpression _left;
private readonly IExpression _right;
public MultiplyExpression(IExpression left, IExpression right)
{
_left = left;
_right = right;
}
public int Interpret(Context context) => _left.Interpret(context) * _right.Interpret(context);
}4. Construct and Interpret the Syntax Tree
To evaluate a domain expression such as (x + 5) * y,
manually assemble the AST nodes or construct them using a simple
parser.
using System;
class Program
{
static void Main()
{
// Setup context with dynamic values
var context = new Context();
context.SetVariable("x", 10);
context.SetVariable("y", 2);
// Represent DSL syntax: (x + 5) * y
IExpression expression = new MultiplyExpression(
new AddExpression(
new VariableExpression("x"),
new NumberExpression(5)
),
new VariableExpression("y")
);
// Interpret the AST
int result = expression.Interpret(context);
Console.WriteLine($"Result: {result}"); // Output: 30
}
}When to Use This Pattern
- Simple Grammar: The Interpreter pattern works best when the grammar of your DSL is relatively simple and stable.
- Extensibility: Adding new operations (such as
SubtractExpressionorDivideExpression) is straightforward and requires creating a new class without modifying existing AST nodes. - Trade-off: For complex grammars with many production rules, maintaining dozens of expression classes can become unwieldy. In those scenarios, dedicated parser generators like ANTLR or parser combinators are preferred.