用 C# 开发一个解释器语言——基于《Crafting Interpreters》的实战系列(五)表达式求值

用 C# 开发一个解释器语言——基于《Crafting Interpreters》的实战系列(三)表达式求值

表达式求值

在之前的文章中,我们已经实现了词法分析器(Scanner)和表达式解析器(Parser),成功将源代码字符串转换成了抽象语法树(AST)。但这只是第一步——我们得到了一颗"结构化的树",但它还不会"动"。

表达式求值(Expression Evaluation)就是让这颗树"活"起来,真正计算出表达式的结果。这是解释器的核心功能之一。

什么是表达式求值?

表达式求值的过程可以简单理解为:

源代码 "1 + 2 * 3"
    ↓ Scanner (词法分析)
Token 序列 [NUMBER(1), PLUS, NUMBER(2), STAR, NUMBER(3)]
    ↓ Parser (语法分析)
AST 结构 Binary(+, Literal(1), Binary(*, Literal(2), Literal(3)))
    ↓ Interpreter (求值) ← 我们这次要实现的部分
最终结果 7

解释器会遍历 AST,根据每个节点的类型执行相应的操作,最终计算出结果。

访问者模式(Visitor Pattern)

在实现表达式求值之前,我们需要理解一个重要的设计模式——访问者模式

为什么需要访问者模式?

AST 中的节点类型有很多种(Binary、Grouping、Literal、Unary),我们需要针对每种节点执行不同的求值逻辑。如果使用传统的继承方式,会带来两个问题:

  1. 添加新操作困难:如果以后想添加"类型检查"、"代码优化"等新功能,需要修改每个节点类
  2. 逻辑分散:相关逻辑分散在不同的类中,难以维护

访问者模式解决了这些问题:将数据结构(AST 节点)与操作(求值逻辑)分离

访问者模式的核心思想

// 每个节点类都实现 Accept 方法
public abstract class Expr
{
    public abstract T Accept<T>(Visitor<T> visitor);
}

public class Binary : Expr
{
    public override T Accept<T>(Visitor<T> visitor)
    {
        return visitor.VisitBinaryExpr(this);  // 双重分发
    }
}

// 访问者接口定义了针对每种节点的操作
public interface Visitor<T>
{
    T VisitBinaryExpr(Binary expr);
    T VisitGroupingExpr(Grouping expr);
    T VisitLiteralExpr(Literal expr);
    T VisitUnaryExpr(Unary expr);
}

// 解释器实现了访问者接口
public class Interpreter : Expr.Visitor<object>
{
    public object VisitBinaryExpr(Binary expr) { /* 求值逻辑 */ }
    public object VisitGroupingExpr(Grouping expr) { /* 求值逻辑 */ }
    public object VisitLiteralExpr(Literal expr) { /* 求值逻辑 */ }
    public object VisitUnaryExpr(Unary expr) { /* 求值逻辑 */ }
}

关键点

  • 每个节点调用 Accept(visitor),将自己"传给"访问者
  • 访问者调用对应的 VisitXxxExpr(node) 方法,执行具体操作
  • 这就是双重分发(Double Dispatch)

运行时错误处理

在求值过程中,可能会发生运行时错误(比如对字符串使用减法运算符)。我们需要定义专门的异常类型:

/// <summary>
/// 运行时错误异常
/// 在表达式求值过程中抛出
/// </summary>
public class RuntimeError : Exception
{
    public Token Token { get; }

    public RuntimeError(Token token, string message) : base(message)
    {
        Token = token;
    }
}

与语法错误不同,运行时错误需要携带发生错误的 Token,以便显示错误位置。

我们在 ErrorReporter 中添加运行时错误处理方法:

public static void RuntimeError(RuntimeError error)
{
    Console.Error.WriteLine($"{error.Message}\n[line {error.Token.Line}]");
    HadError = true;
}

解释器实现

1. 解释器基本结构

/// <summary>
/// 表达式解释器
/// 实现访问者模式,遍历 AST 并计算表达式值
/// </summary>
public class Interpreter : Expr.Visitor<object>
{
    /// <summary>
    /// 解释表达式的入口方法
    /// </summary>
    public object Interpret(Expr expression)
    {
        try
        {
            return expression.Accept(this);
        }
        catch (RuntimeError error)
        {
            ErrorReporter.RuntimeError(error);
            return null;
        }
    }
}

2. 字面量求值

最简单的节点类型,直接返回字面量的值:

public object VisitLiteralExpr(Expr.Literal expr)
{
    return expr.Value;
}

3. 分组表达式求值

括号表达式的求值,只需递归求值内部表达式:

public object VisitGroupingExpr(Expr.Grouping expr)
{
    return Evaluate(expr.Expression);
}

private object Evaluate(Expr expr)
{
    return expr.Accept(this);
}

4. 一元表达式求值

一元运算符有两种:

  • ! (逻辑非):对操作数求布尔值后取反
  • - (负号):对数值取负
public object VisitUnaryExpr(Expr.Unary expr)
{
    object right = Evaluate(expr.Right);

    switch (expr.Operator.Type)
    {
        case TokenType.BANG:
            return !IsTruthy(right);
        case TokenType.MINUS:
            CheckNumberOperand(expr.Operator, right);
            return -(double)right;
    }

    return null;
}

类型检查是关键:

private void CheckNumberOperand(Token op, object operand)
{
    if (operand is double) return;
    throw new RuntimeError(op, "Operand must be a number.");
}

如果操作数不是数字,抛出运行时错误。

5. 二元表达式求值

这是最复杂的部分,需要处理多种运算:

5.1 算术运算

case TokenType.MINUS:
    CheckNumberOperands(expr.Operator, left, right);
    return (double)left - (double)right;

case TokenType.SLASH:
    CheckNumberOperands(expr.Operator, left, right);
    return (double)left / (double)right;

case TokenType.STAR:
    CheckNumberOperands(expr.Operator, left, right);
    return (double)left * (double)right;

5.2 加法运算(支持数字和字符串)

case TokenType.PLUS:
    if (left is double leftNum && right is double rightNum)
    {
        return leftNum + rightNum;
    }
    if (left is string leftStr || right is string rightStr)
    {
        return Stringify(left) + Stringify(right);
    }
    throw new RuntimeError(expr.Operator,
        "Operands must be two numbers or two strings.");

5.3 比较运算

case TokenType.GREATER:
    CheckNumberOperands(expr.Operator, left, right);
    return (double)left > (double)right;

case TokenType.GREATER_EQUAL:
    CheckNumberOperands(expr.Operator, left, right);
    return (double)left >= (double)right;
// ... LESS, LESS_EQUAL 类似

5.4 相等性比较

case TokenType.BANG_EQUAL:
    return !IsEqual(left, right);

case TokenType.EQUAL_EQUAL:
    return IsEqual(left, right);

IsEqual 方法处理各种类型的比较:

private bool IsEqual(object a, object b)
{
    if (a == null && b == null) return true;
    if (a == null) return false;
    return a.Equals(b);
}

6. 真值判断

Lox 语言中,只有 falsenil 是假值,其他都是真值:

private bool IsTruthy(object value)
{
    if (value == null) return false;
    if (value is bool b) return b;
    return true;
}

这意味着 0""(空字符串)都是真值,与 JavaScript 不同。

7. 结果字符串化

为了正确显示结果,我们需要将 C# 对象转换为 Lox 风格的字符串:

private string Stringify(object value)
{
    if (value == null) return "nil";

    if (value is double d)
    {
        // 处理小数部分为整数的情况
        if (d == (int)d)
        {
            return d.ToString("F0");
        }
        return d.ToString();
    }

    return value.ToString();
}

这样 123.0 会显示为 123123.5 显示为 123.5

测试示例

我们编写一个测试方法,验证各种表达式的求值:

static void TestInterpreter()
{
    string[] testCases = {
        "1 + 2",                    // 简单加法: 3
        "1 + 2 * 3",                // 运算优先级: 7
        "(1 + 2) * 3",              // 括号: 9
        "-123",                     // 负数: -123
        "!true",                    // 逻辑非: false
        "5 > 3",                    // 大于: true
        "5 < 3",                    // 小于: false
        "1 == 1",                   // 相等: true
        "1 == 2",                   // 不等: false
        "\"hello\" + \" world\"",   // 字符串连接: "hello world"
        "nil == nil"                // nil 比较: true
    };

    foreach (string source in testCases)
    {
        Scanner scanner = new Scanner(source);
        List<Token> tokens = scanner.ScanTokens();
        Parser parser = new Parser(tokens);
        Expr expression = parser.Parse();
        Interpreter interpreter = new Interpreter();
        object result = interpreter.Interpret(expression);
        Console.WriteLine($"{source} => {StringifyResult(result)}");
    }
}

运行结果:

表达式: 1 + 2
结果: 3

表达式: 1 + 2 * 3
结果: 7

表达式: (1 + 2) * 3
结果: 9

表达式: -123
结果: -123

表达式: !true
结果: False

表达式: 5 > 3
结果: True

表达式: 5 < 3
结果: False

表达式: 1 == 1
结果: True

表达式: 1 == 2
结果: False

表达式: "hello" + " world"
结果: hello world

表达式: nil == nil
结果: True

核心知识点总结

1. 访问者模式的优势

  • 分离关注点:AST 结构定义与操作逻辑解耦
  • 易于扩展:添加新操作只需新建访问者类,无需修改节点类
  • 代码集中:相关逻辑集中在一个类中,便于维护

2. 动态类型处理

C# 是静态类型语言,但解释器需要处理动态类型:

// 使用 is 模式匹配检查类型
if (left is double leftNum && right is double rightNum)

// 使用 as 或显式转换进行类型转换
return -(double)right;

3. 错误处理策略

  • 语法错误:在 Parser 中检测,使用 ParseError 异常
  • 运行时错误:在 Interpreter 中检测,使用 RuntimeError 异常
  • 类型检查:在运算前检查操作数类型,不符合则抛出错误

4. 递归求值

表达式求值本质上是递归过程

// 求值二元表达式
object left = Evaluate(expr.Left);   // 递归求值左操作数
object right = Evaluate(expr.Right); // 递归求值右操作数
return 运算结果;

项目进度

  • 词法分析器(Scanner)- 将源码拆解为 Token
  • 抽象语法树(Expr)- 定义表达式节点结构
  • 表达式解析器(Parser)- 将 Token 序列转换为 AST
  • 表达式求值(Interpreter)- 遍历 AST 并计算结果 ← 本次完成
  • 语句解析(Statements)- var、if、while 等语句
  • 语句执行(Statement Execution)- 执行语句
  • 环境管理(Environment)- 变量作用域

下一步计划

现在我们已经实现了完整的表达式求值功能,可以计算各种算术、比较和逻辑表达式的值。下一步我们将实现语句解析和执行,包括:

  • 变量声明语句(var x = 1;
  • 打印语句(print x;
  • 条件语句(if ... else
  • 循环语句(while

这样我们的解释器就能执行完整的程序,而不仅仅是单个表达式了!

参考资料


posted @ 2026-01-28 14:00  daviyoung  阅读(6)  评论(0)    收藏  举报