menhir - 将AST节点与源文件中的令牌位置相关联

kro*_*dil 5 error-handling ocaml abstract-syntax-tree menhir

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

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

Isa*_*bie 5

我不知道这是否是最佳实践,但我喜欢 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
...
Run Code Online (Sandbox Code Playgroud)

因此,给定一个e类型的变量exp,您可以使用 访问其源位置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
Run Code Online (Sandbox Code Playgroud)

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

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

为了进行比较,在没有元数据的纯 AST 上,这将类似于:

let optimize_double_negation e =
  match e.enode with
  | UnOp (Neg, UnOp (Neg, e', _), _) -> e'
  | _ -> e
Run Code Online (Sandbox Code Playgroud)

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


ivg*_*ivg 4

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

type node = 
  | Typedef of typdef
  | Typeexp of typeexp
  | Literal of string
  | Constant of int
  | ...

type annotated_node = { node : node; loc : loc}
Run Code Online (Sandbox Code Playgroud)

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

 match node with
 | {node=Typedef t} -> pp_typedef t
 | ...
Run Code Online (Sandbox Code Playgroud)

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

一种类似但更通用的方法是不使用位置来扩展节点,而仅使用唯一标识符来扩展节点,并使用最终映射将任意数据添加到节点。这种方法的好处是,您可以在技术上外部化节点属性时使用任意数据扩展节点。缺点是您无法保证属性的完整性,因为有限映射本质上是非完整性的。因此,保留一个不变量(例如,所有节点都有一个位置)就更困难了。

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

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

幸运的是,在最新版本的 OCaml 中添加了蜉蝣之后,这不再是问题。此外,星历将与 GC 很好地配合,因此,如果节点不再可达,则 GC 将收集其属性(如文件位置)。那么,让我们用一个具体的例子来解释这一点。假设我们有两个节点c1c2

let c1 = Literal "hello"
let c2 = Constant 42
Run Code Online (Sandbox Code Playgroud)

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

module Locations = Ephemeron.K1.Make(struct
   type t = node
   let hash = Hashtbl.hash (* or your own hash if you have one *)
   let equal = (==) (* aka the physical equality *)
end)
Run Code Online (Sandbox Code Playgroud)

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

let locations = Locations.create 1337

(* let's assume semantics actions produced different constants `c1` and `c2` which are structurally equal, e.g., *)

# let c1 = Constant 42 and c2 = Constant 42 

(* with each constant we can associate its location *)

Locations.add c1 "hello.ml:12:32"
Locations.add c2 "hello.ml:13:56"
Run Code Online (Sandbox Code Playgroud)

之后,您可以提取位置:

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

# Locations.find locs c2;;
- : string = "hello.ml:13:56"
Run Code Online (Sandbox Code Playgroud)

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

let normalize = function
  | Literal str -> Literal (normalize_literal str)
  | node -> node 
Run Code Online (Sandbox Code Playgroud)

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

蜉蝣的另一个问题是它们无法在编组或序列化后幸存下来。也就是说,如果您将 AST 存储在文件中的某个位置,然后恢复它,所有节点都将丢失其身份,并且表location将变空。

关于您在评论中提到的“一元方法”。尽管单子很神奇,但它们仍然不能神奇地解决所有问题。它们不是灵丹妙药:)要将某些内容附加到节点,我们仍然需要使用额外的属性来扩展它 - 可以是直接的位置信息,也可以是我们可以通过其间接附加属性的身份。monad 对于后者可能很有用,因为我们可以使用状态 monad 来封装我们的 id 生成器,而不是对最后分配的标识符进行全局引用。为了完整起见,您可以使用 UUID 并获取在程序运行中不唯一但也是普遍唯一的标识符,而不是使用状态 monad 或全局引用来生成唯一标识符,从某种意义上说,不存在世界上的其他对象具有相同的标识符,无论您运行程序的频率如何(在理智的世界中)。虽然看起来生成 UUID 不使用任何状态,但实际上它仍然使用命令式随机数生成器,因此它有点作弊,但仍然可以视为纯粹的功能,因为它不包含可观察到的效果。