【问题标题】:menhir - associate AST nodes with token locations in source filemenhir - 将 AST 节点与源文件中的令牌位置相关联
【发布时间】:2017-07-11 02:04:41
【问题描述】:

我正在使用 Menhir 来解析 DSL。我的解析器使用精心设计的嵌套类型集合构建 AST。在稍后的类型检查和其他为用户生成的错误报告中,我想参考它发生的源文件位置。这些不是解析错误,是解析完成后产生的。

一个天真的解决方案是为所有 AST 类型配备额外的位置信息,但这会使使用它们(例如构造或匹配)变得不必要的笨拙。这样做的既定做法是什么?

【问题讨论】:

  • 为什么额外的位置信息会影响匹配的 AST 节点?是的,即使对于动态构造的节点,您也会想要一些调试信息
  • 而不是:match x with Node x 我将不得不这样做match x with Node (pos,x)
  • 在您的构造函数中使用记录,这样您就可以在字段子集上进行模式匹配
  • 承载AST节点的所有位置根本不是原生的。 OCaml 编译器可以做到这一点。你应该看看它的parser/parsetree.mli
  • 我在考虑某种单子方法...

标签: error-handling ocaml abstract-syntax-tree menhir


【解决方案1】:

我不知道这是否是最佳实践,但我喜欢在 Frama-C 系统的抽象语法树中采用的方法;见https://github.com/Frama-C/Frama-C-snapshot/blob/master/src/kernel_services/ast_data/cil_types.mli

这种方法使用相互嵌套的记录和代数类型的“层”。记录包含源位置等元信息,以及您可以匹配的代数“节点”。

例如,这里是表达式的一部分表示:

type ...

and exp = { 
  eid: int; (** unique identifier *)
  enode: exp_node; (** the expression itself *)
  eloc: location; (** location of the expression. *)
}

and exp_node =
  | Const      of constant              (** Constant *)
  | Lval       of lval                  (** Lvalue *)
  | UnOp       of unop * exp * typ
  | BinOp      of binop * exp * exp * typ
...

因此,给定一个exp 类型的变量e,您可以使用e.eloc 访问其源位置,并在e.enode 中对其抽象语法树进行模式匹配。

如此简单,语法上的“顶级”匹配非常简单:

let rec is_const_expr e =
  match e.enode with
  | Const _ -> true
  | Lval _ -> false
  | UnOp (_op, e', _typ) -> is_const_expr e'
  | BinOp (_op, l, r, _typ) -> is_const_expr l && is_const_expr r

要在表达式中进行更深入的匹配,您必须遍历每个级别的记录。这会增加一些语法混乱,但不会太多,因为您只能对您感兴趣的一个记录字段进行模式匹配:

let optimize_double_negation e =
  match e.enode with
  | UnOp (Neg, { enode = UnOp (Neg, e', _) }, _) -> e'
  | _ -> e

为了比较,在没有元数据的纯 AST 上,这将是这样的:

let optimize_double_negation e =
  match e.enode with
  | UnOp (Neg, UnOp (Neg, e', _), _) -> e'
  | _ -> e

我发现 Frama-C 的方法在实践中效果很好。

【讨论】:

    【解决方案2】:

    您需要以某种方式将位置信息附加到您的节点。通常的解决方案是将您的 AST 节点编码为记录,例如,

    type node = 
      | Typedef of typdef
      | Typeexp of typeexp
      | Literal of string
      | Constant of int
      | ...
    
    type annotated_node = { node : node; loc : loc}
    

    由于您使用的是记录,因此您仍然可以进行模式匹配,而无需过多的语法开销,例如,

     match node with
     | {node=Typedef t} -> pp_typedef t
     | ...
    

    根据您的表示,您可以选择单独包装您的类型的每个分支、包装整个类型或递归,就像 @Isabelle Newbie 在 Frama-C 示例中那样。

    一种类似但更通用的方法是扩展节点,而不是使用位置,而只是使用唯一标识符,并使用最终地图将任意数据添加到节点。这种方法的好处是您可以在实际外部化节点属性时使用任意数据扩展您的节点。缺点是您实际上无法保证属性的整体性,因为有限映射不是整体。因此,很难保持一个不变量,例如,所有节点都有一个位置。

    由于每个堆分配的对象都已经有一个隐式的唯一标识符,即地址,因此可以将数据附加到堆分配的对象上,而无需实际将其包装在另一种类型中。例如,我们仍然可以按原样使用node 类型并使用有限映射将任意信息附加到它们,只要每个节点都是堆对象,即节点定义不包含常量构造函数(在如果有,您可以通过添加一个虚假的单位值来解决它,例如,| End 可以表示为| End of unit

    当然,我所说的地址并不是指一个对象的物理地址或虚拟地址。 OCaml 使用移动 GC,因此 OCaml 对象的实际地址可能会在程序执行期间发生变化。此外,一般来说,地址不是唯一的,因为一旦对象被释放,它的地址就可以被完全不同的实体获取。

    幸运的是,在最新版本的 OCaml 中添加了蜉蝣后,它不再是问题。此外,ephemeron 可以很好地与 GC 配合使用,因此如果一个节点不再可访问,其属性(如文件位置)将由 GC 收集。所以,让我们用一个具体的例子来说明这一点。假设我们有两个节点c1c2

    let c1 = Literal "hello"
    let c2 = Constant 42
    

    现在我们可以创建从节点到位置的位置映射(我们将后者表示为字符串)

    module Locations = Ephemeron.K1.Make(struct
       type t = node
       let hash = Hashtbl.hash (* or your own hash if you have one *)
       let equal = (=) (* or a specilized equal operator *)
    end)
    

    Locations 模块提供了一个典型的命令式哈希表的接口。所以让我们使用它。在解析器中,每当您创建一个新节点时,您都​​应该在全局 locations 值中注册其位置,例如,

    let locations = Locations.create 1337
    
    
    (* somewhere in the semantics actions, where c1 and c2 are created *)
    
    Locations.add c1 "hello.ml:12:32"
    Locations.add c2 "hello.ml:13:56"
    

    然后,您可以提取位置:

    # Locations.find locs c1;;
    - : string = "hello.ml:12:32"
    

    如您所见,虽然该解决方案在某种意义上很好,但它不涉及节点数据类型,因此您的其余代码可以轻松地对其进行模式匹配,但它仍然有点脏,因为它需要全局可变状态,所以很难维护。此外,由于我们使用对象地址作为键,因此每个新创建的对象,即使它是从原始对象逻辑派生的,也将具有不同的标识。例如,假设您有一个函数,可以规范化所有文字:

    let normalize = function
      | Literal str -> Literal (normalize_literal str)
      | node -> node 
    

    它将从原始节点创建一个新的Literal 节点,因此所有位置信息都将丢失。这意味着,每次从另一个节点派生一个节点时,您都​​需要更新位置信息。

    ephemera 的另一个问题是它们无法在编组或序列化中存活下来。即,如果您将 AST 存储在文件中的某个位置,然后恢复它,所有节点将失去其身份,location 表将变为空。

    谈到您在 cmets.xml 中提到的“一元方法”。虽然 monad 很神奇,但它们仍然不能神奇地解决所有问题。它们不是灵丹妙药 :) 为了将某些东西附加到节点上,我们仍然需要使用额外的属性来扩展它——直接的位置信息或我们可以间接附加属性的身份。不过,monad 对后者很有用,因为我们可以使用 state monad 来封装我们的 id 生成器,而不是对最后分配的标识符进行全局引用。并且为了完整起见,您可以使用 UUID 并获取不仅在程序运行中唯一,而且在某种意义上也是普遍唯一的标识符,而不是使用 state monad 或全局引用来生成唯一标识符世界上没有其他具有相同标识符的对象,无论您多久运行一次程序(在理智的世界中)。虽然看起来生成 UUID 并没有使用任何状态,但在底层它仍然使用命令式随机数生成器,所以它有点作弊,但仍然可以看作是纯函数式的,因为它不包含 observable效果。

    【讨论】:

      猜你喜欢
      • 2014-02-14
      • 1970-01-01
      • 2017-12-23
      • 2019-07-10
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2016-04-05
      相关资源
      最近更新 更多