Rust 实现一个表达式 Parser(9) Visitor 实现

4,874 阅读5分钟

目前我们已经能够得到期望的 AST

接下来就需要基于 AST 来实现我们的具体需求

本文将基于访问者模式来实现 AST 的遍历

源码: src/traversal/visitor.rs

访问者模式

在全文开始之前, 先来介绍访问者模式(Visitor Mode)

访问者模式是一种能够将数据模型与逻辑分离的设计模式, 通常在面对需要持续维护大量不同类型的数据, 且针对不同类型的数据有不同的操作逻辑时, 访问者模式会非常好用, 下面来举个来自refactoring的例子

设想有一位保险推销员, 他需要向不同类型的建筑推销不同类型的保险, 比如

  • 向住宅推销医疗保险
  • 向银行推销偷窃保险
  • 向咖啡店推销洪涝保险

在这个例子中, 不同的住宅类型就是上面提到的 大量不同类型的数据, 而保险推销员就是 访问者, 访问者需要根据访问到的数据的类型执行不同的操作, 以下用 TypeScript 写一段的简单的示意代码

// 赋予对象访问的能力
interface Visitor {
  visit_residential(r: Residential);
  visit_bank(b: Bank);
  visit_cafes(c: Cafes);
}

// 赋予对象被 Visitor 被访问的能力
interface Visitable {
  accept(v: Visitor);
}

class Salesman implements Visitor {
  visit_residential(r: Residential);
  visit_bank(b: Bank);
  visit_cafes(c: Cafes);
}

class Residential implements Visitable {
  accept(v: Visitor) {
    v.visit_residential(this);
  }
}

class Bank implements Visitable {
  accept(v: Visitor) {
    v.visit_bank(this);
  }
}

class Cafes implements Visitable {
  accept(v: Visitor) {
    v.visit_cafes(this)
  }
}

如上就实现了简单的访问者模式, 主要是为了将逻辑和数据分离, 以上例子中, 数据指的是 Residential, Bank, Cafes 三个类, 逻辑指的是 Visitor 接口中定义的 visit_xxx 方法的具体实现

有什么好处呢, Q&A 再来详谈

Why

为什么遍历个树形结构要用这么啰嗦的设计模式呢

先来看节点定义

pub enum Node {
    Literal {
        kind: SyntaxKind,
        value: i32,
        raw: String,
    },

    Expr {
        kind: SyntaxKind,
        left: Box<Node>,
        op: SyntaxKind,
        right: Box<Node>,
    },
}

我们需要 Literal 中的 value 字段, 也需要 Expr 中的 left, op, right 字段, 其中, 在遇到 leftright 时我们应该尝试递归解析, 因为 left 有可能也是一个 Expr

这意味着我们针对 LiteralExpr 会有着不同的访问逻辑, 我们无法像遍历普通二叉树一样直接一个递归函数就写完, 再者说 tiny-expr-parser 中只需要两种节点类型, 而大多数的情况下, AST 需要几十种节点类型, 根本无法直接固定访问逻辑, 因此访问者模式在这种情境下就非常非常的合适

How

回到 Rust, 我们正在尝试对当前已有的 AST 实现访问者模式, 我们可以使用 Trait 来约束具体实现, 如下

pub trait Visitor {
    fn visit_num(&mut self, node: &Node);
    fn visit_expr(&mut self, node: &Node);
}

改进一下

以上定义好了 LiteralExpr 对应的访问方法, 可存在点点问题

试想我们的访问者实现这个 Visitor Trait 时自定义访问逻辑, 需要针对传入参数 node 的的类型做判断, 再定义访问逻辑, 大致如下

struct V;

impl Visitor for V {
    fn visit_num(&mut self, node: &Node) {
        match node {
            Literal { kind, value, raw } => {
                // ...
            },
            Expr { kind, left, op, right } => {
                // ...
            },
        }
    }
    fn visit_expr(&mut self, node: &Node) {
        match node {
            Literal { kind, value, raw } => {
                // ...
            },
            Expr { kind, left, op, right } => {
                // ...
            },
        }
    }
}

这样的代码很丑很冗余, 我们应该将访问的逻辑单独抽离出来, 就像这样

pub trait Visitor {
    fn visit(&mut self, node: &Node) {
        match node {
            // 解构出需要的字段, 直接传入对应的访问函数
            Node::Literal {
                value, raw, ..
            } => self.visit_num(*value, raw),
            Node::Expr {
                left, op, right, ..
            } => self.visit_expr(left, op.into_str(), right),
        }
    }

    fn visit_num(&mut self, value: i32, raw: &str);
    fn visit_expr(&mut self, left: &Node, op: &str, right: &Node);
}

我们在 Visitor Trait 中定义 visit 方法并进行默认实现, 这样 visit_xxx 方法只需要接受相应的参数并进行处理即可, 逻辑上会简洁不少

再改进一下

理论上访问者模式貌似是没有返回值的, 但是考虑到后面需要实现求值, 给予返回值会更加方便一些, 使用泛型简单重构一下, 如下

pub trait Visitor<T> {
    fn visit(&mut self, node: &Node) -> T {
        match node {
            Node::Literal {
                value, raw, ..
            } => self.visit_num(*value, raw),
            Node::Expr {
                left, op, right, ..
            } => self.visit_expr(left, op.into_str(), right),
        }
    }

    fn visit_num(&mut self, value: i32, raw: &str) -> T;
    fn visit_expr(&mut self, left: &Node, op: &str, right: &Node) -> T;
}

这样我们就定义好了我们需要的 Visitor Trait, 接下来只需要专注于实现需要的 visitor 即可

Q&A

Q: 访问者模式的优势?

A: 我们的相关需求其实完全可以将逻辑直接耦合到数据中实现, 比如给 Node 实现一个 get_expr 之类的方法, 返回相应的数据, 但是这样会导致数据和逻辑耦合, 因为 Node 是数据的生产者, 消费者如何消费数据是消费者的事情, 理应由消费者来实现这个逻辑, 而且全部耦合在一起可能会不好维护

读者可能会觉得 "啊怎么好的没学到, 光学别人过度设计, 这些代码可能你这辈子都不会再改动", 好骂! 实际上有一个比较重要的原因在于 tiny-expr-parser 需要实现格式化和求值的需求, 有两个不同的消费者, 需要两套不同的访问逻辑, 如果将这个逻辑耦合在 Node 的定义中, 会非常恶心, 写着恶心, 我想着都恶心, 所以就单独抽离了

实际上很多时候在访问者模式的应用场景下是除了访问者模式其他没得选, 比如硬件适配软件升级, 我们不可能在升级时对硬件的逻辑进行更改, 软件通过 visitor 来访问不同的硬件执行相关的逻辑, 硬件只能通过实现封装好的 accept 方法去进行适配

Q: 在这里实现访问者模式到底做了什么?

A: 按照我个人的理解, 我认为访问者模式对于 AST 的遍历来说是实现了逻辑层面的线索化, 通常对树形结构的线索化分为前中后序线索化三种, 这里我们对 AST 使用 DFS 的遍历思路实现访问者模式, 实际上是达到了中序线索化的效果, 只不过通常线索化会在节点中添加一个指针域去指向后继节点, 而这里我们将后继隐含在了 visit_expr 的具体实现中, 因此称为逻辑层面的线索化