Skip to content
Go back

Interpreter 模式最佳实践:让 C# 表达式解析代码干净且可维护

把 Interpreter 模式跑通不难——定义一个 IExpression 接口,写几个终结符和非终结符表达式类,对着上下文求值就行了。但文法一旦超过玩具示例,事情就开始崩:解析逻辑渗进表达式类里,表达式树 debug 不了,报错信息指不到任何有用的位置。

这篇文章针对这些维护性雷区,梳理了 Interpreter 模式在 C# 下的 7 条实践。不管你是在建规则引擎、配置 DSL 还是数学表达式求值器,这些做法能让代码保持干净和可预测。

先把文法写清楚

Interpreter 模式跟文法是一一对应的。文法里的每条规则,都是你代码里的一个类。这种映射既是模式最大的优势,也是最常见的陷阱——文法有歧义,表达式类就跟着歧义,而 debug 歧义的表达式树极其痛苦。

动手写任何代码之前,先写一份形式化文法。哪怕是一小段 BNF 风格的规范,也能迫使你提前想清楚运算符优先级、结合性和边界情况:

expression  ::= term (('+' | '-') term)*
term        ::= factor (('*' | '/') factor)*
factor      ::= NUMBER | '(' expression ')'

这份文法没有歧义。乘法比加法绑定更紧,括号覆盖优先级,每条规则恰好映射到一个类。

跳过了这一步、让文法在代码中隐式演化的后果:表达式类身兼多职,优先级规则随解析顺序漂移,整个代码库没人能讲清楚到底在解释什么语言。先写文法,保持短小。如果一条规则一句话解释不清,就继续拆。

把解析和求值彻底分开

Interpreter 模式在 C# 下影响最大的一条实践:在解析求值之间画一条硬线。解析负责把原始输入(字符串、token、配置文件)变成表达式树,求值负责遍历这棵树产出结果。这两件事混在一起,代码就难测、难扩、难调试。

混在一起的典型写法:

// 坏写法:解析和求值搅在一起
public class MixedEvaluator
{
    public double Evaluate(string input)
    {
        var tokens = input.Split(' ');
        var left = double.Parse(tokens[0]);
        var op = tokens[1];
        var right = double.Parse(tokens[2]);

        return op switch
        {
            "+" => left + right,
            "-" => left - right,
            _ => throw new InvalidOperationException(
                $"Unknown operator: {op}")
        };
    }
}

"3 + 5" 能跑,但一遇到嵌套表达式、运算符优先级或需要复用解析结果就崩。拆开之后是这样:

public interface IExpression
{
    double Interpret();
}

public sealed class NumberExpression : IExpression
{
    private readonly double _value;
    public NumberExpression(double value) => _value = value;
    public double Interpret() => _value;
}

public sealed class AddExpression : IExpression
{
    private readonly IExpression _left;
    private readonly IExpression _right;

    public AddExpression(IExpression left, IExpression right)
    {
        _left = left
            ?? throw new ArgumentNullException(nameof(left));
        _right = right
            ?? throw new ArgumentNullException(nameof(right));
    }

    public double Interpret()
        => _left.Interpret() + _right.Interpret();
}

// 解析器单独负责构建表达式树
public sealed class ExpressionParser
{
    public IExpression Parse(string input)
    {
        var tokens = input.Split(' ');
        var left = new NumberExpression(double.Parse(tokens[0]));
        var right = new NumberExpression(double.Parse(tokens[2]));

        return tokens[1] switch
        {
            "+" => new AddExpression(left, right),
            _ => throw new FormatException(
                $"Unsupported operator: {tokens[1]}")
        };
    }
}

拆开后,解析可以独立测试,求值可以独立测试。可以换解析器而不用动表达式类,可以缓存或序列化表达式树。两部分各自演进,模式才真正有了扩展性。

用 Composite 模式组织表达式树

Interpreter 模式的表达式树天然是递归的——一个 SubtractExpression 包含两个子表达式,每个子表达式又可能是另一个复合表达式或终结符值。这正是 Composite 模式管的事。

做法很直接:定义清晰的组件接口 IExpression,为终结符表达式创建叶子节点,为非终结符表达式创建复合节点:

public interface IExpression
{
    double Interpret(
        IReadOnlyDictionary<string, double> context);
}

// 终结符表达式——叶子节点
public sealed class VariableExpression : IExpression
{
    private readonly string _name;

    public VariableExpression(string name)
    {
        _name = name
            ?? throw new ArgumentNullException(nameof(name));
    }

    public double Interpret(
        IReadOnlyDictionary<string, double> context)
    {
        if (!context.TryGetValue(_name, out var value))
        {
            throw new KeyNotFoundException(
                $"Variable '{_name}' is not defined.");
        }
        return value;
    }
}

// 非终结符表达式——复合节点
public sealed class MultiplyExpression : IExpression
{
    private readonly IExpression _left;
    private readonly IExpression _right;

    public MultiplyExpression(IExpression left, IExpression right)
    {
        _left = left
            ?? throw new ArgumentNullException(nameof(left));
        _right = right
            ?? throw new ArgumentNullException(nameof(right));
    }

    public double Interpret(
        IReadOnlyDictionary<string, double> context)
        => _left.Interpret(context) * _right.Interpret(context);
}

基于 Composite 的做法带来几个好处:表达式树可遍历、可打印、可变换;新增表达式类型不需要改已有类;每个节点的 Interpret 只关心自己的语义,复杂度均匀分布,不会集中在一个单体求值器里。

缓存已解析的表达式

解析通常是 Interpreter 模式管道里最贵的一步——切词、建树、校验结构,耗时远超遍历树产出结果。如果同样表达式用不同上下文反复求值(规则引擎、公式求值器),缓存解析好的表达式树就是最直接的性能收益。

把解析和求值拆开后,缓存就自然了:

public sealed class CachingInterpreter
{
    private readonly ConcurrentDictionary<string, IExpression>
        _cache = new();
    private readonly ExpressionParser _parser;

    public CachingInterpreter(ExpressionParser parser)
    {
        _parser = parser
            ?? throw new ArgumentNullException(nameof(parser));
    }

    public double Evaluate(
        string expression,
        IReadOnlyDictionary<string, double> context)
    {
        var tree = _cache.GetOrAdd(
            expression,
            key => _parser.Parse(key));
        return tree.Interpret(context);
    }
}

ConcurrentDictionary 处理了缓存的线程安全问题。相同表达式字符串的每次调用都复用已有树,只跑求值阶段。这之所以可行,是因为设计良好的 Interpreter 模式中表达式树是无状态的——可变状态全在 context 字典里,不在树节点上。

如果文法产出的树很大、内存是个顾虑,可以用 Flyweight 模式减少重复分配。像 NumberExpression(0)NumberExpression(1) 这样的公共子表达式可以跨树共享,不用每次解析都新建。

让错误信息指向问题根源

Interpreter 模式处理的是用户或外部提供的输入,这种输入一定会出问题。变量未定义、运算符收到不兼容的类型、除以零——问题不是会不会出错,而是出错了能不能报清楚

实践是:在离问题最近的地方抛出具体、描述性的异常,带上足够上下文让调用方能修:

public sealed class DivideExpression : IExpression
{
    private readonly IExpression _left;
    private readonly IExpression _right;

    public DivideExpression(IExpression left, IExpression right)
    {
        _left = left
            ?? throw new ArgumentNullException(nameof(left));
        _right = right
            ?? throw new ArgumentNullException(nameof(right));
    }

    public double Interpret(
        IReadOnlyDictionary<string, double> context)
    {
        var divisor = _right.Interpret(context);
        if (divisor == 0)
        {
            throw new DivideByZeroException(
                "Division by zero in expression. " +
                "The right operand evaluated to 0.");
        }
        return _left.Interpret(context) / divisor;
    }
}

// 自定义异常,带位置信息
public class InterpreterException : Exception
{
    public int Position { get; }

    public InterpreterException(string message, int position)
        : base(message)
    {
        Position = position;
    }
}

自定义的 InterpreterException 带位置信息,调用方能精确知道原始输入中哪里出了问题。这对处理多行 DSL 或复杂公式的场景至关重要——没有位置追踪,用户看到”undefined variable”,根本不知道是 50 个 token 里哪个变量炸的。

解析阶段的错误要在解析时就拦下来,不要等到求值。检查括号是否匹配、是否有意料之外的 token、是否缺操作数——这些问题在解析阶段拒绝掉,远比求值到一半在表达式树深处抛 NullReferenceException 强。

为文法规则写测试

Interpreter 模式”一条规则一个类”的结构让它天然好测。每个表达式类都是小而聚焦的单元,只有一个 Interpret 方法。测试策略也很自然:终结符表达式单独测,非终结符表达式用已知子表达式注入测,解析器用已知输入输出对测。

先是终结符——没有依赖,最直接:

public class NumberExpressionTests
{
    [Theory]
    [InlineData(0)]
    [InlineData(42.5)]
    [InlineData(-7)]
    public void Interpret_ReturnsStoredValue(double expected)
    {
        var expression = new NumberExpression(expected);
        var context = new Dictionary<string, double>();
        var result = expression.Interpret(context);
        Assert.Equal(expected, result);
    }
}

public class VariableExpressionTests
{
    [Fact]
    public void Interpret_VariableUndefined_Throws()
    {
        var expression = new VariableExpression("y");
        var context = new Dictionary<string, double>();
        Assert.Throws<KeyNotFoundException>(
            () => expression.Interpret(context));
    }
}

非终结符表达式,注入已知的子表达式而不是从字符串解析,把表达式逻辑和解析器行为隔离开:

public class MultiplyExpressionTests
{
    [Fact]
    public void Interpret_MultipliesBothOperands()
    {
        var left = new NumberExpression(6);
        var right = new NumberExpression(7);
        var multiply = new MultiplyExpression(left, right);
        var context = new Dictionary<string, double>();
        var result = multiply.Interpret(context);
        Assert.Equal(42, result);
    }

    [Fact]
    public void Interpret_HandlesNestedExpressions()
    {
        // (2 + 3) * 4 = 20
        var add = new AddExpression(
            new NumberExpression(2),
            new NumberExpression(3));
        var multiply = new MultiplyExpression(
            add,
            new NumberExpression(4));
        var context = new Dictionary<string, double>();
        var result = multiply.Interpret(context);
        Assert.Equal(20, result);
    }
}

最后把解析器当作集成层来测——喂字符串输入,验证产出的表达式树得到的输出是否符合预期。这一步会抓住各个表达式单元测试可能漏掉的文法 bug,比如运算符优先级或结合性错误。

什么时候该换解析器生成器

Interpreter 模式适合小而稳定的文法。四个运算符加括号和变量的数学求值器用着舒服,十几个关键字的配置 DSL 也管得住。但每种文法都有一个复杂度阈值,超过之后 Interpreter 模式就不再是合适的工具。

这些信号说明你的实现已经超出模式的承载能力:

到了这个阈值,考虑 ANTLRPidgin(一个 C# 的轻量解析器组合子库)。ANTLR 从文法文件生成完整词法分析和语法分析器,产出可遍历的解析树。Pidgin 让你在 C# 代码里用小构件组合解析器——介于 Interpreter 模式和完整解析器生成器之间的中间地带。

过渡不需要一步到位。你可以保留 Interpreter 模式的表达式类继续做求值,只把解析层换成生成的解析器。已有的求值逻辑和测试套件不动,同时获得专用工具的语法处理能力。

把项目结构也组织好

最后一条实践:想清楚你的类住在项目的哪里。常见错误是把所有表达式类、解析器、上下文、缓存全倒进一个 namespace。五个类还好,二十个就崩了。

按职责组织:

Interpreter/
├── Expressions/
│   ├── IExpression.cs
│   ├── NumberExpression.cs
│   ├── VariableExpression.cs
│   ├── AddExpression.cs
│   ├── SubtractExpression.cs
│   ├── MultiplyExpression.cs
│   └── DivideExpression.cs
├── Parsing/
│   ├── Token.cs
│   ├── Tokenizer.cs
│   └── ExpressionParser.cs
├── Context/
│   └── InterpreterContext.cs
├── Caching/
│   └── CachingInterpreter.cs
└── Errors/
    └── InterpreterException.cs

每个文件夹对应一个独立关注点。Expressions 只管求值,Parsing 管文本到树的转换,Context 管运行时状态,Caching 管复用优化,Errors 管结构化反馈。新同事加入团队时,文件夹名就告诉他该看哪里、往哪放。

这种结构也方便用访问修饰符强制边界。把具体表达式类标 internal,只让解析器来创建它们。对外暴露 IExpression 和解析器作为公开 API,防止外部代码绕过解析器直接手工拼表达式树进而跳过校验。Interpreter 模式保持自包含——一个干净模块,窄公开面。

小结

这 7 条实践贯穿一个共同的主题:把文法在写代码之前形式化定义,把解析和求值彻底拆开,用 Composite 原则组织表达式树,把校验推到输入进入系统的边界(解析层),出错时带上位置信息。

Interpreter 模式在文法小、稳定、直接映射到类层级时最强。先从解决你问题的最简文法开始——几个终结符表达式、两三个运算符、一个直接的递归下降解析器。随着需求增长再加变量支持、缓存和更丰富的错误处理。当文法超出模式的承载,把解析层迁移到专用工具,同时保留久经考验的表达式类不动。目标不是最大化抽象,而是一个可预测、可独立测试、团队里每个开发者都容易扩展的解释器。

如果你关注 AI 助手、开发工具和软件工程实践,可以关注 Aide Hub。这里会继续分享能落地的工具教程、技术观察和项目经验。

参考


Tags


Previous

Vertical Slice Architecture 生产落地指南:.NET 项目结构的最佳布局

Next

用 Agent Skill 固化架构规范:从纠正 AI 到自己动手建一个 Scaffold 技能