我的语言的 AST 类型设计记住令牌位置

Ale*_*cco 4 haskell abstract-syntax-tree

我为一种简单的编程语言编写了一个解析器和评估器。这是 AST 类型的简化版本:

data Value = IntV Int | FloatV Float | BoolV Bool
data Expr = IfE Value [Expr] | VarDefE String Value
type Program = [Expr]
Run Code Online (Sandbox Code Playgroud)

我希望错误消息告诉发生错误的源代码的行和列。例如,如果If表达式中的值不是布尔值,我希望求值器显示一个错误,指出"expected boolean at line x, column y",xy引用值的位置。

所以,我需要做的是重新定义之前的类型,以便它们可以存储不同事物的相关位置。一种选择是为表达式的每个构造函数添加一个位置,如下所示:

type Location = (Int, Int)

data Expr = IfE Value [Expr] Location | VarDef String Value Location
Run Code Online (Sandbox Code Playgroud)

这显然不是最优的,因为我必须将这些Location字段添加到每个可能的表达式中,例如,如果一个值包含其他值,我也需要为该值添加位置:

{-
this would turn into FunctionCall String [Value] [Location], 
with one location for each value in the function call
-}
data Value = ... | FunctionCall String [Value]
Run Code Online (Sandbox Code Playgroud)

我想出了另一个解决方案,它允许我为所有内容添加位置:

data Located a = Located Location a
type LocatedExpr = Located Expr
type LocatedValue = Located Value

data Value = IntV Int | FloatV Float | BoolV Bool | FunctionCall String [LocatedValue]
data Expr = IfE LocatedValue [LocatedExpr] | VarDef String LocatedValue
data Program = [LocatedExpr]
Run Code Online (Sandbox Code Playgroud)

不过我不太喜欢这个。首先,它混淆了评估器的定义,并且模式匹配每次都有一个额外的层。另外,我不认为说函数调用将定位值作为参数是完全正确的。函数调用应该将值作为参数,位置应该是不干扰评估器的元数据。

我需要帮助重新定义我的类型,以便解决方案尽可能干净。也许有我不知道的语言扩展或设计模式可能会有所帮助。

Jon*_*rdy 7

很多方法可以注释 AST!这是所谓的AST 类型问题的一半,另一半是您如何管理在编译过程中发生变化的 AST。问题并没有完全“解决”:所有解决方案都有权衡,选择哪一个取决于您的预期用例。我会在最后介绍一些你可能想研究的内容。

无论您选择哪种方法来组织实际数据类型,如果它使模式匹配变得丑陋或笨拙,自然的解决方案是PatternSynonyms.

考虑你的第一个例子:

{-# Language PatternSynonyms #-}

type Location = (Int, Int)

data Expr
  = LocatedIf     Value [Expr] Location
  | LocatedVarDef String Value Location

-- Unidirectional pattern synonyms which ignore the location:

pattern If :: Value -> [Expr] -> Expr
pattern If val exprs <- LocatedIf val exprs _loc

pattern VarDef :: String -> Value -> Expr
pattern VarDef name expr <- LocatedVarDef name expr _loc

-- Inform GHC that matching ‘If’ and ‘VarDef’ is just as good
-- as matching ‘LocatedIf’ and ‘LocatedVarDef’.

{-# Complete If, VarDef #-}
Run Code Online (Sandbox Code Playgroud)

这对于您的目的来说可能已经足够整洁了。但这里还有一些我觉得有用的提示。

将注解放在首位:直接向 AST 添加注解类型时,我通常更喜欢将其作为每个构造函数的第一个参数,以便可以方便地部分应用。

data LocatedExpr
  = LocatedIf     Location Value [Expr]
  | LocatedVarDef Location String Value
Run Code Online (Sandbox Code Playgroud)

如果注释是一个位置,那么这也使得在编写某些类型的解析器时更方便地获取,就像AnnotatedIf <$> (getSourceLocation <* ifKeyword) <*> value <*> many expr在解析器组合器库中一样。

参数化你的注解:我经常将注解类型变成类型参数,这样 GHC 可以为我派生一些有用的类:

{-# Language
    DeriveFoldable,
    DeriveFunctor,
    DeriveTraversable #-}

data AnnotatedExpr a
  = AnnotatedIf     a Value [Expr]
  | AnnotatedVarDef a String Value
  deriving (Functor, Foldable, Traversable)

type LocatedExpr = AnnotatedExpr Location

-- Get the annotation of an expression.
-- (Total as long as every constructor is annotated.)
exprAnnotation :: AnnotatedExpr a -> a
exprAnnotation = head

-- Update annotations purely.
mapAnnotations
  :: (a -> b)
  -> AnnotatedExpr a -> AnnotatedExpr b
mapAnnotations = fmap

-- traverse, foldMap, &c.
Run Code Online (Sandbox Code Playgroud)

如果您想要“不干扰”,请使用多态:您可以通过对其进行多态来强制评估器无法检查注释类型。模式同义词仍然可以让您方便地匹配这些表达式:

pattern If :: Value -> [AnnotatedExpr a] -> AnnotatedExpr a
pattern If val exprs <- AnnotatedIf _anno val exprs

-- …

eval :: AnnotatedExpr a -> Value
eval expr = case expr of
  If     val exprs -> -- …
  VarDef name expr -> -- …
Run Code Online (Sandbox Code Playgroud)

未注释的术语不是你的敌人:没有源位置的术语不利于错误报告,但我认为为了方便构造带有单元()注释的未注释术语,使模式同义词双向仍然是一个好主意。(或等效的东西,如果您使用 egMaybe Location作为注释类型。)

原因是这对于编写单元测试来说非常方便,在那里你想检查输出,但想使用Eq而不是模式匹配,并且不想在不关心的测试中比较所有的源位置跟他们。使用派生类,void :: (Functor f) => f a -> f ()删除 AST 上的所有注释。

import Control.Monad (void)

type BareExpr = AnnotatedExpr ()

-- One way to define bidirectional synonyms, so e.g.
-- ‘If’ can be used as either a pattern or a constructor.

pattern If :: Value -> [BareExpr] -> BareExpr
pattern If val exprs = AnnotatedIf () val exprs

-- …

stripAnnotations :: AnnotatedExpr a -> BareExpr
stripAnnotations = void
Run Code Online (Sandbox Code Playgroud)

等效地,您可以使用GADTs/ExistentialQuantification来表示data AnyExpr where { AnyExpr :: AnnotatedExpr a -> AnyExpr }/ data AnyExpr = forall a. AnyExpr (AnnotatedExpr a);这样,注释的信息量与 完全一样(),但您不需要fmap遍历整个树void来剥离它,只需应用AnyExpr构造函数来隐藏类型。


最后,这里简要介绍一些 AST 类型解决方案。

  • 标签(例如唯一 ID)注释每个 AST 节点,然后将所有元数据(如源位置、类型等)与AST分开存储

    import Data.IntMap (IntMap)
    
    -- More sophisticated/stronglier-typed tags are possible.
    newtype Tag = Tag Int
    
    newtype TagMap a = TagMap (IntMap a)
    
    data Expr
      = If     !Tag Value [Expr]
      | VarDef !Tag String Expr
    
    type Span = (Location, Location)
    type SourceMap = TagMap Span
    type CommentMap = TagMap (Span, String)
    parse
      :: String             -- Input
      -> Either ParseError
        ( Expr              -- Parsed expression
        , SourceMap         -- Source locations of tags
        , CommentMap        -- Sideband for comments
        -- …
        )
    
    Run Code Online (Sandbox Code Playgroud)

    优点是你可以很容易地在任何地方混合任意新类型的注释,而不会影响 AST 本身,并且避免仅仅为了更改注释而重写 AST。您可以将树和注释表视为一种数据库,其中标签是关联它们的“外键”。不利的一面是,你必须要小心,当你保持这些标签重写AST。

    我不知道这种方法是否有一个既定的名称;我认为它只是“标记”或“标记的 AST”。

  • recursion-schemes和/或Data Types à la Carte PDF:将带注释的表达式树的“递归”部分与“注释”部分分开,并用于Fix将它们重新连接在一起,使用Compose(或Cofree)在中间添加注释。

    data ExprF e
      = IfF     Value [e]
      | VarDefF String e
      -- …
      deriving (Foldable, Functor, Traversable, …)
    
    -- Unannotated: Expr ~ ExprF (ExprF (ExprF (…)))
    type Expr = Fix ExprF
    
    -- With a location at each recursive step:
    --
    -- LocatedExpr ~ Located (ExprF (Located (ExprF (…))))
    type LocatedExpr = Fix (Compose Located ExprF)
    
    data Located a = Located Location a
      deriving (Foldable, Functor, Traversable, …)
    -- or: type Located = (,) Location
    
    Run Code Online (Sandbox Code Playgroud)

    一个明显的优势是你得到了一堆很好的遍历东西,比如catafree-ish,这样你就可以避免一遍又一遍地在你的 AST 上编写手动遍历。一个缺点是它增加了一些模式混乱来清理,就像“点菜”方法一样,但它们确实提供了很大的灵活性。

  • Trees That Grow PDF仅对源位置来说是多余的,但在严肃的编译器中它非常有用。如果您希望有多个注释类型(例如推断类型或其他分析结果)或随时间变化的 AST,那么您可以为“编译阶段”添加一个类型参数(解析、重命名、类型检查、脱糖等) .) 并根据该索引选择字段类型或启用和禁用构造函数。

    一个非常不幸的缺点是你经常不得不重写树,即使在没有改变的地方,因为一切都取决于“阶段”。我用另一种方法是添加一种类型的参数为每种类型的相位或注释,可以独立地改变,例如data Expr annotation termVarName typeVarName,以及与抽象的过度typepattern同义词。这使您可以独立更新索引,并且仍然使用Functor和这样的类Bitraversable

  • @AlejandroDeCicco:这是我的一个错误;我正在考虑使用存在主义:给定“data Any where { Any :: AnnoExpr a -&gt; Any }”,“void e”和“Any e”都将“隐藏”术语的注释,但前者*重建* a去注释的树,而后者只是给出现有树的未注释视图。您可以直接在“Fix (Compose located ExprF)”上进行匹配,例如“Fix (Compose (Located loc (VarDefF xe)))”,但大多数情况下您使用诸如“foldFix”(=“cata”)之类的函数。[Win for Recursion Schemes](http://newartisans.com/2018/04/win-for-recursion-schemes/)有一些示例代码。 (2认同)