【问题标题】:Evolving data structure不断发展的数据结构
【发布时间】:2012-05-25 21:29:13
【问题描述】:

我正在尝试在 Haskell 中为类 C 语言编写编译器。编译器通过转换 AST 来进行。第一遍解析输入以创建 AST,将符号表与符号表打结,以允许在定义符号之前定位符号,而无需前向引用。

AST 包含关于类型和表达式的信息,它们之间可以存在联系;例如sizeof(T) 是一个依赖于类型 T 的表达式,T[e] 是一个依赖于常量表达式 e 的数组类型。

类型和表达式由 Haskell 数据类型表示,如下所示:

data Type = TypeInt Id
          | TypePointer Id Type -- target type
          | TypeArray Id Type Expr -- elt type, elt count
          | TypeStruct Id [(String, Type)] -- [(field name, field type)]
          | TypeOf Id Expr
          | TypeDef Id Type

data Expr = ExprInt Int -- literal int
          | ExprVar Var -- variable
          | ExprSizeof Type
          | ExprUnop Unop Expr
          | ExprBinop Binop Expr Expr
          | ExprField Bool Expr String -- Bool gives true=>s.field, false=>p->field

其中Unop 包括地址(&)和取消引用(*)等运算符,Binop 包括加号(+)和时间(*)等运算符...

请注意,每种类型都分配有唯一的Id。这用于构造类型依赖图,以检测导致无限类型的循环。一旦我们确定类型图中没有循环,就可以安全地对它们应用递归函数,而不会陷入无限循环。

下一步是确定每种类型的大小,为结构字段分配偏移量,并用指针算法替换ExprFields。这样做,我们可以确定表达式的类型,并从类型图中消除ExprSizeofs、ExprFields、TypeDefs和TypeOfs,所以我们的类型和表达式已经进化了,现在看更像这样:

data Type' = TypeInt Id
           | TypePointer Id Type'
           | TypeArray Id Type' Int -- constant expression has been eval'd
           | TypeStruct Id [(String, Int, Type')] -- field offset has been determined

data Expr' = ExprInt Type' Int
           | ExprVar Type' Var
           | ExprUnop Type' Unop Expr'
           | ExprBinop Type' Binop Expr' Expr'

请注意,我们删除了一些数据构造函数,并稍微更改了其他一些构造函数。特别是,Type' 不再包含任何Expr',并且每个Expr' 都确定了它的Type'

所以,最后的问题是:创建两个几乎相同的数据类型集,还是尝试将它们统一为一个数据类型更好?

保留两种不同的数据类型可以明确表明某些构造函数不能再出现。但是,执行常量折叠以评估常量表达式的函数将具有以下类型:

foldConstants :: Expr -> Either String Expr'

但这意味着我们不能在以后使用Expr's 执行常量折叠(想象一下操作Expr' 的某个传递,并希望折叠任何出现的常量表达式)。我们需要另一个实现:

foldConstants' :: Expr' -> Either String Expr'

另一方面,保持单一类型可以解决常量折叠问题,但会阻止类型检查器强制执行静态不变量。

此外,在第一轮中,我们在未知字段(如字段偏移量、数组大小和表达式类型)中添加了什么?我们可以用undefinederror "*hole*" 堵住漏洞,但这感觉就像一场等待发生的灾难(比如NULL 指针,您甚至无法检查)。我们可以将未知字段更改为Maybes,并用Nothing 堵住漏洞(就像我们可以检查的NULL 指针),但是在随后的传递中必须这样做会很烦人不断从Maybes 中提取值,这些值总是Justs。

【问题讨论】:

  • 我也有同样的问题。拥有一个统一的结构听起来是个好主意,但是,即使你这样做了,你真的可以在两个阶段重用相同的功能吗?在我看来,微小的结构变化会导致更大的逻辑飞跃。
  • 是的,尽管如果在这些类型上运行的每个函数都在一个 monad 中运行,它可以让您访问上下文并能够生成错误,那么应该可以将旧结构转换为新结构即时的。

标签: haskell


【解决方案1】:

希望有更多经验的人能有一个更完善、久经考验和现成的答案,但这是我的尝试。

您可以通过 GADT 以相对较低的成本吃掉您的馅饼:

{-# LANGUAGE GADTs #-}

data P0 -- phase zero
data P1 -- phase one

data Type p where
     TypeInt     :: Id -> Type p
     TypePointer :: Id -> Type p -> Type p             -- target type
     TypeArray   :: Id -> Type p -> Expr p -> Type p   -- elt type, elt count
     TypeStruct  :: Id -> [(String, Type p)] -> Type p -- [(field name, field type)]
     TypeOf      :: Id -> Expr P0 -> Type P0
     TypeDef     :: Id -> Type P0 -> Type P0

data Expr p where
     ExprInt     :: Int -> Expr p                        -- literal int
     ExprVar     :: Var -> Expr p                        -- variable
     ExprSizeof  :: Type P0 -> Expr P0
     ExprUnop    :: Unop -> Expr p -> Expr p
     ExprBinop   :: Binop -> Expr p -> Expr p -> Expr p
     ExprField   :: Bool -> Expr P0 -> String -> Expr P0 -- Bool gives true=>s.field, false=>p->field

我们改变的地方是:

  • 数据类型现在使用 GADT 语法。这意味着构造函数是使用它们的类型签名声明的。 data Foo = Bar Int Char 变为 data Foo where Bar :: Int -> Char -> Foo(除了语法,两者完全等价)。

  • 我们为TypeExpr 添加了一个类型变量。这是一个所谓的幻像类型变量:没有存储p 类型的实际数据,它仅用于在类型系统中强制执行不变量。

  • 我们已经声明了虚拟类型来表示转换之前和之后的阶段:阶段零和阶段一。 (在具有多个阶段的更复杂的系统中,我们可能会使用类型级别的数字来表示它们。)

  • GADT 让我们可以在数据结构中存储类型级别的不变量。这里我们有两个。首先是递归位置必须与包含它们的结构处于同一阶段。例如,查看TypePointer :: Id -> Type p -> Type p,您将Type p 传递给TypePointer 构造函数并得到Type p 作为结果,并且那些ps 必须是相同的类型。 (如果我们想允许不同的类型,我们可以使用pq。)

  • 第二个是我们强制执行某些构造函数只能在第一阶段使用的事实。大多数构造函数在幻象类型变量p 中是多态的,但其中一些要求它是P0。这意味着这些构造函数只能用于构造Type P0Expr P0 类型的值,不能用于任何其他阶段。

GADT 有两个方向。第一个是如果你有一个返回Type P1的函数,并尝试使用返回Type P0的构造函数来构造它,你会得到一个类型错误。这就是所谓的“通过构造正确”:构造无效结构在静态上是不可能的(前提是您可以对类型系统中的所有相关不变量进行编码)。它的另一面是,如果你有一个 Type P1 的值,你可以确定它是正确构造的:TypeOfTypeDef 构造函数不能被使用(事实上,编译器会抱怨如果您尝试对它们进行模式匹配),并且任何递归位置也必须是相位P1。本质上,当您构建 GADT 时,您存储满足类型约束的证据,当您对其进行模式匹配时,您检索该证据并可以利用它。

这是最简单的部分。不幸的是,除了允许构造函数之外,我们在这两种类型之间还有一些差异:一些构造函数参数在各个阶段之间是不同的,而有些只存在于转换后阶段。我们可以再次使用 GADT 对其进行编码,但它不是低成本和优雅的。一种解决方案是复制所有不同的构造函数,并为P0P1 各设置一个。但重复并不好。我们可以尝试做的更细粒度:

-- a couple of helper types
-- here I take advantage of the fact that of the things only present in one phase,
-- they're always present in P1 and not P0, and not vice versa
data MaybeP p a where
     NothingP :: MaybeP P0 a
     JustP    :: a -> MaybeP P1 a

data EitherP p a b where
     LeftP  :: a -> EitherP P0 a b
     RightP :: b -> EitherP P1 a b

data Type p where
     TypeInt     :: Id -> Type p
     TypePointer :: Id -> Type p -> Type p
     TypeArray   :: Id -> Type p -> EitherP p (Expr p) Int -> Type p
     TypeStruct  :: Id -> [(String, MaybeP p Int, Type p)] -> Type p
     TypeOf      :: Id -> Expr P0 -> Type P0
     TypeDef     :: Id -> Type P0 -> Type P0

-- for brevity
type MaybeType p = MaybeP p (Type p)

data Expr p where
     ExprInt     :: MaybeType p -> Int -> Expr p
     ExprVar     :: MaybeType p -> Var -> Expr p
     ExprSizeof  :: Type P0 -> Expr P0
     ExprUnop    :: MaybeType p -> Unop -> Expr p -> Expr p
     ExprBinop   :: MaybeType p -> Binop -> Expr p -> Expr p -> Expr p
     ExprField   :: Bool -> Expr P0 -> String -> Expr P0

在这里,我们通过一些辅助类型强制执行这样一个事实,即一些构造函数参数只能出现在第一阶段 (MaybeP) 并且有些在两个阶段之间是不同的 (EitherP)。虽然这使我们完全是类型安全的,但感觉有点特别,我们仍然必须一直在MaybePs 和EitherPs 中进行封装。我不知道在这方面是否有更好的解决方案。不过,完全的类型安全是很重要的:我们可以写 fromJustP :: MaybeP P1 a -> a 并确保它是完全安全的。

更新:另一种方法是使用TypeFamilies

data Proxy a = Proxy

class Phase p where
    type MaybeP  p a
    type EitherP p a b
    maybeP  :: Proxy p -> MaybeP p a -> Maybe a
    eitherP :: Proxy p -> EitherP p a b -> Either a b
    phase   :: Proxy p
    phase = Proxy

instance Phase P0 where
    type MaybeP  P0 a   = ()
    type EitherP P0 a b = a
    maybeP  _ _ = Nothing
    eitherP _ a = Left a

instance Phase P1 where
    type MaybeP  P1 a   = a
    type EitherP P1 a b = b
    maybeP  _ a = Just  a
    eitherP _ a = Right a

ExprType 相对于之前版本的唯一变化是构造函数需要添加一个 Phase p 约束,例如ExprInt :: Phase p => MaybeType p -> Int -> Expr p.

这里如果知道TypeExprp 的类型,则可以静态知道MaybePs 是() 还是给定类型,以及@987654363 是哪种类型@s 是,并且可以直接将它们用作该类型而无需显式展开。当p 未知时,您可以使用Phase 类中的maybePeitherP 来找出它们是什么。 (Proxy 参数是必需的,否则编译器将无法判断您指的是哪个阶段。)这类似于 GADT 版本,如果知道p,您可以确定@ 987654370@ 和 EitherP 包含,否则您必须对两种可能性进行模式匹配。该解决方案在“缺失”参数变为() 而不是完全消失方面也不完美。

构造Exprs 和Types 在两个版本之间似乎也大致相似:如果您正在构造的值具有特定于它的任何阶段,那么它必须在其类型中指定该阶段。当您想在p 中编写多态函数但仍要处理特定于阶段的部分时,问题似乎来了。使用 GADT,这很简单:

asdf :: MaybeP p a -> MaybeP p a
asdf NothingP  = NothingP
asdf (JustP a) = JustP a

请注意,如果我只写了asdf _ = NothingP,编译器会抱怨,因为不能保证输出的类型与输入的类型相同。通过模式匹配,我们可以判断输入是什么类型,并返回相同类型的结果。

不过,对于 TypeFamilies 版本,这要困难得多。仅使用 maybeP 和生成的 Maybe 您无法向编译器证明任何关于类型的内容。您可以通过以下方式获得一部分,而不是让maybePeitherP 返回MaybeEither,使它们成为像maybeeither 这样的解构函数,这也使得类型相等可用:

maybeP  :: Proxy p -> (p ~ P0 => r) -> (p ~ P1 => a -> r) -> MaybeP p a -> r
eitherP :: Proxy p -> (p ~ P0 => a -> r) -> (p ~ P1 => b -> r) -> EitherP p a b -> r

(请注意,为此我们需要Rank2Types,还要注意这些本质上是MaybePEitherP 的GADT 版本的CPS 转换版本。)

那么我们可以这样写:

asdf :: Phase p => MaybeP p a -> MaybeP p a
asdf a = maybeP phase () id a

但这还不够,因为 GHC 说:

data.hs:116:29:
 Could not deduce (MaybeP p a ~ MaybeP p0 a0)
 from the context (Phase p)
   bound by the type signature for
              asdf :: Phase p => MaybeP p a -> MaybeP p a
   at data.hs:116:1-29
 NB: `MaybeP' is a type function, and may not be injective
 In the fourth argument of `maybeP', namely `a'
 In the expression: maybeP phase () id a
 In an equation for `asdf': asdf a = maybeP phase () id a

也许你可以在某个地方使用类型签名来解决这个问题,但在这一点上,它似乎比它的价值更麻烦。因此,在等待其他人提供更多信息之前,我将推荐使用 GADT 版本,它更简单、更强大,但会产生一些语法噪音。

再次更新:这里的问题是因为MaybeP p a是一个类型函数并且没有其他信息可以通过,GHC无法知道pa应该是什么。如果我传入Proxy p 并使用它而不是phase 来解决p,但a 仍然未知。

【讨论】:

  • 如何创建一个可以专门化类型的类型族类,并创建类的P0P1 实例? MaybePs 将被定义为 P0 中的单元类型,以及我们在 P1 中真正想要的类型。 EitherPs 将定义我们在每次传递中想要的类型。这样,我们就不必一直包装和解包数据。
  • 这是我几乎提到的另一个选项。但是不得不绕过()s 对我来说似乎更难看。更大的问题是,如果你有一个 Expr p 并且不知道 p 是什么,那么你就无法知道类型族解析到什么并且不能使用这些值。您可以通过将所有函数放入类型类并为P0P1 创建单独的实例来解决它,但这似乎很糟糕。使用 GADT,您至少可以通过模式匹配处理所有可能的情况(并且可能在此过程中发现一些类型信息)。
  • 我想了更多,并充实了TypeFamilies 变体并更新了答案。总结是,您可以通过TypeFamilies 以比我想象的更好的方式走得更远(并且与 GADTs 版本有许多有趣的相似之处),但它最终似乎仍然不足。
  • 值得一提的是,MaybePEitherP 是个糟糕的名字……对于抽象表示,很好。但是对于实际的项目,这些字段的含义会有一些概念,类型应该以此命名。因此,这里看起来很多样板抽象在实际项目中看起来只是告诉人们您项目的阶段以及每个阶段如何代表参考或其他。
【解决方案2】:

这个问题没有理想的解决方案,因为每个都有不同的优缺点。

我个人会使用单一数据类型“树”并为需要区分的事物添加单独的数据构造函数。即:

data Type
  = ...
  | TypeArray Id Type Expr
  | TypeResolvedArray Id Type Int
  | ...

正如您所说,这样做的好处是您可以在同一棵树上多次运行同一阶段,但推理比这更深:假设您正在实现一个生成更多 AST 的语法元素(类似于 @ 987654324@ 或 C++ 模板类型的交易,它可以依赖于常量表达式,例如您的 TypeArray,因此您无法在第一次迭代中评估它)。使用统一数据类型方法,您只需在现有树中插入新的 AST,不仅可以直接在该树上运行与以前相同的阶段,而且您将免费获得缓存,即如果新的 AST 引用通过使用sizeof(typeof(myarr)) 之类的数组,您不必再次确定myarr 的常量大小,因为它的类型已经是您之前解析阶段的TypeResolvedArray

当您完成所有编译阶段时,您可以使用不同的表示,并且是时候解释代码(或其他东西)了;那么您就可以确定不需要更多的 AST 更改,并且更简化的表示可能是一个好主意。

顺便说一下,对于数组大小,您应该使用Data.Word.Word 而不是Data.Int.Int。在 C 中使用ints 来索引数组是很常见的错误,而 C 指针实际上是无符号的。也请不要在你的语言中犯这个错误,除非你真的想支持负大小的数组。

【讨论】:

  • 我不知道你是在批评 C 还是 C 程序员。但是在 C 中使用 unsigned 在大多数情况下被认为是有害的,除非您正在执行位操作和/或需要明确定义的溢出行为(例如散列)。根本问题是,如果 unsigned 变为负数,您不会收到错误,您会得到一个大的正数,您无法将其与任何其他(合法的)大正数区分开来。 (例如,很容易意外地使向后循环无限。)使用ints,您至少可以断言。我不知道其他语言是否可以做得更好。
  • (但也许这正是您所指的。在那种情况下,对不起;我不知道。)
  • C 中的“数组”由无符号整数索引,就是这样。因此,如果您执行a[-1]-1 将被强制转换为无符号整数,并且您会得到一个段错误。这就是为什么在处理数组时在任何地方使用有符号整数都没有意义(数组位置偏移除外),并且在定义一种新语言以将无符号整数用于数组时,您必须特别小心。
  • 我说错了;数组中的索引当然可以签名,因为您需要能够进行任意指针运算;它是数组的 size 永远不会是负数。
猜你喜欢
  • 2011-09-19
  • 2012-07-24
  • 1970-01-01
  • 2020-04-12
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-04-05
  • 1970-01-01
相关资源
最近更新 更多