我正在使用Menhir来解析DSL。我的解析器使用精心设计的嵌套类型集合构建 AST。在以后的类型检查和为用户生成的错误报告中的其他传递中,我想参考发生错误的源文件位置。这些不是解析错误,它们是在解析完成后生成的。
一个天真的解决方案是为所有 AST 类型配备额外的位置信息,但这会使使用它们(例如构建或匹配)变得不必要笨拙。这样做的既定做法是什么?
我不知道这是否是最佳实践,但我喜欢 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的方法在实践中效果很好。
您需要以某种方式将位置信息附加到节点。通常的解决方案是将 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
| ...
根据您的表示形式,您可以选择单独包装类型的每个分支、包装整个类型或递归包装,如新手在 Frama-C 示例中@Isabelle。
一种类似但更通用的方法是不使用位置扩展节点,而仅使用唯一标识符,并使用最终映射向节点添加任意数据。此方法的好处是,在实际外部化节点属性时,可以使用任意数据扩展节点。缺点是你实际上不能保证属性的整体性,因为有限映射不是总的。因此,很难保留一个不变的,例如,所有节点都有一个位置。
由于每个堆分配对象都已经有一个隐式的唯一标识符,即地址,因此可以将数据附加到堆分配的对象,而无需实际将其包装在另一种类型中。例如,我们仍然可以按原样使用node
类型,并使用有限映射将任意信息附加到它们,只要每个节点都是堆对象,即节点定义不包含常量构造函数(如果有,您可以通过添加虚假的单位值来解决它, 例如,| End
可以表示为| End of unit
。
当然,通过说地址,我并不是字面上的意思是对象的物理或虚拟地址。OCaml 使用移动 GC,因此在程序执行期间,OCaml 对象的实际地址可能会更改。此外,地址通常不是唯一的,因为一旦对象被释放,它的地址就可以被完全不同的实体抓取。
幸运的是,在将蜉蝣添加到最新版本的OCaml之后,它不再是问题。此外,星历将很好地与 GC 配合使用,因此如果节点不再可访问,其属性(如文件位置)将由 GC 收集。因此,让我们用一个具体的例子来解决这个问题。假设我们有两个节点c1
和c2
:
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
节点,因此所有位置信息都将丢失。这意味着,每次从一个节点派生另一个节点时,您都需要更新位置信息。
蜉蝣的另一个问题是它们无法在封送处理或序列化中幸存下来。 也就是说,如果您将 AST 存储在文件中的某个位置,然后恢复它,则所有节点都将丢失其标识,并且location
表将变为空。
说到您在评论中提到的"一元方法"。虽然monads是神奇的,但它们仍然不能神奇地解决所有问题。它们不是灵丹妙药:)为了将某些内容附加到节点,我们仍然需要使用额外的属性对其进行扩展 - 直接的位置信息或可以通过其间接附加属性的身份。monad 对后者很有用,因为我们可以使用状态 monad 来封装我们的 id 生成器,而不是对最后一个分配的标识符进行全局引用。为了完整起见,您可以使用 UUID 并获取不仅在程序运行中唯一,而且普遍唯一的标识符,而不是使用状态 monad 或全局引用来生成唯一标识符, 无论您多久运行一次程序(在理智的世界中)。虽然看起来生成UUID不使用任何状态,但在引擎盖下它仍然使用命令式随机数生成器,所以它有点作弊,但仍然可以被视为纯功能,因为它不包含可观察的效果。