【问题标题】:Type design for the AST of my language remembering token locations我的语言的 AST 的类型设计记住令牌位置
【发布时间】:2021-04-18 01:02:37
【问题描述】:

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

data Value = IntV Int | FloatV Float | BoolV Bool
data Expr = IfE Value [Expr] | VarDefE String Value
type Program = [Expr]

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

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

type Location = (Int, Int)

data Expr = IfE Value [Expr] Location | VarDef String Value Location

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

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

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

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]

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

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

【问题讨论】:

    标签: haskell abstract-syntax-tree


    【解决方案1】:

    许多 方法来注释 AST!这是所谓的AST typing problem 的一半,另一半是您如何管理在编译过程中发生变化的 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 #-}
    

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

    先放注解:直接给AST添加注解类型时,我往往更喜欢把它作为每个构造函数的第一个参数,这样可以方便地部分应用。

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

    如果注解是一个位置,那么这也使得在编写某些类型的解析器时更方便获取,沿着解析器组合库中的AnnotatedIf &lt;$&gt; (getSourceLocation &lt;* ifKeyword) &lt;*&gt; value &lt;*&gt; 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.
    

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

    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 -> -- …
    

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

    原因是这对于编写单元测试非常方便,你想检查输出,但想使用Eq而不是模式匹配,并且不想比较所有源位置与他们无关的测试。使用派生类,void :: (Functor f) =&gt; f a -&gt; 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
    

    同样,您可以使用GADTs / ExistentialQuantification 表示data AnyExpr where { AnyExpr :: AnnotatedExpr a -&gt; AnyExpr } / data AnyExpr = forall a. AnyExpr (AnnotatedExpr a);这样,注释的信息与() 完全相同,但您不需要使用void 在整个树上使用fmap 来剥离它,只需应用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
          -- …
          )
      

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

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

    • recursion-schemes 和/或Data Types à la CartePDF:将带注释的表达式树的“递归”部分与“注释”部分分开,并使用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
      

      一个明显的优势是您可以免费获得一堆不错的遍历内容,例如cata,因此您可以避免一遍又一遍地编写手动遍历您的 AST。缺点是它增加了一些模式混乱来清理,就像“点菜”方法一样,但它们确实提供了很大的灵活性。

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

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

    【讨论】:

    • 非常感谢您提供如此详细的答案。我想我会尝试使用多态注释和模式同义词。我很好奇你对 RankNTypes 所说的话:那如何避免使用 fmapvoid 剥离注释的树?另外,我发现recursion-schemes 方法非常有趣,但我不明白如何定义一个使用FixCompose 定义的类型的函数
    • @AlejandroDeCicco:那是我的错误;我正在考虑使用存在主义:给定data Any where { Any :: AnnoExpr a -&gt; Any }void eAny e 都会“隐藏”一个术语的注释,但前者重建一个去注释的树,而后者只是给出一个现有树的未注释视图。您可以直接在Fix (Compose Located ExprF) 上匹配,例如Fix (Compose (Located loc (VarDefF x e))),但大多数情况下您使用foldFix(=cata) 之类的函数。 Win for Recursion Schemes 有一些示例代码。
    • 如果你读到这篇文章,我一直在努力让Any 构造函数工作。我可以定义它,但我不知道如何使用它。在AnnoExpr 上调用Any 会让我失去所有关于它的信息。你知道我怎么用吗?
    • @AlejandroDeCicco:该类型的重点是防止访问注解,就像在注解类型中使函数多态一样。如果这不是你想要的,那么我想这不适合这种情况,如果我误导了你,对不起。不过,您应该仍然能够匹配表达式的结构。
    猜你喜欢
    • 2012-06-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-02-05
    • 1970-01-01
    • 2011-07-15
    • 2013-06-30
    • 2011-06-27
    相关资源
    最近更新 更多