我正在尝试在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
。这用于构造类型依赖图,以便检测导致无限类型的循环。一旦我们确定类型图中没有循环,就可以安全地对它们应用递归函数而不会进入无限循环。
下一步是确定每种类型的大小,为结构字段分配偏移量,并用指针算法替换ExprField
s。通过这样做,我们可以确定表达式的类型,并从类型图中消除ExprSizeof
,ExprField
,TypeDef
和TypeOf
s,因此我们的类型和表达式已经发展,现在看起来更像是这样:
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'
另一方面,保持单一类型可以解决常量折叠问题,但会阻止类型检查器强制执行静态不变量。
此外,在第一次传递期间,我们将什么放入未知字段(如字段偏移,数组大小和表达式类型)?我们可以使用undefined
或error "*hole*"
插入漏洞,但这感觉就像是等待发生的灾难(比如NULL
指针甚至无法检查)。我们可以将未知字段更改为Maybe
,并使用Nothing
插入漏洞(就像我们可以检查的NULL
指针一样),但这会很烦人在后续的传递中,必须不断地从总是Maybe
s的Just
中提取值。
答案 0 :(得分:17)
希望有更多经验的人会有更优雅,经过实战考验并准备好的答案,但这是我的目标。
你可以用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
(除语法外,两者完全相同)。
我们已为Type
和Expr
添加了一个类型变量。这是一个所谓的幻像类型变量:没有存储类型为p
的实际数据,它仅用于强制类型系统中的不变量。
我们已经声明了虚拟类型来表示转换前后的阶段:阶段0和阶段1。 (在一个具有多个阶段的更复杂的系统中,我们可能会使用类型级别的数字来表示它们。)
GADT让我们在数据结构中存储类型级不变量。这里我们有两个。首先,递归位置必须与包含它们的结构具有相同的相位。例如,查看TypePointer :: Id -> Type p -> Type p
,您将Type p
传递给TypePointer
构造函数并获得Type p
作为结果,那些p
必须相同类型。 (如果我们想要允许不同的类型,我们可以使用p
和q
。)
第二,我们强制执行一些构造函数只能在第一阶段使用的事实。大多数构造函数在幻像类型变量p
中都是多态的,但其中一些要求它是P0
。这意味着这些构造函数只能用于构造Type P0
或Expr P0
类型的值,而不能用于任何其他阶段。
GADT在两个方向上工作。第一个是如果你有一个返回Type P1
的函数,并尝试使用其中一个返回Type P0
的构造函数来构造它,你将得到一个类型错误。这就是所谓的"正确的构造":它是静态不可能构造一个无效的结构(前提是你可以编码类型系统中的所有相关不变量)。另一方面,如果您的值为Type P1
,则可以确定其构造正确:TypeOf
和TypeDef
构造函数无法使用(实际上,如果你尝试对它们进行模式匹配,编译器会抱怨),并且任何递归位置也必须是阶段P1
。基本上,当您构建GADT时,您存储了满足类型约束的证据,并且当您对其进行模式匹配时,您将检索该证据并可以利用它。
这很容易。不幸的是,除了允许哪些构造函数之外,我们在两种类型之间存在一些差异:一些构造函数参数在各阶段之间是不同的,而一些仅在转换后阶段存在。我们可以再次使用GADT对其进行编码,但它并不是低成本和优雅的。一种解决方案是复制所有不同的构造函数,并为P0
和P1
分别使用一个构造函数。但重复并不好。我们可以尝试做得更精细:
-- 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
)之间不同的事实。虽然这使我们完全类型安全,但感觉有点特别,我们仍然需要始终将内容包装进出MaybeP
和EitherP
。我不知道在这方面是否有更好的解决方案。但完整的类型安全是这样的:我们可以写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
相对于先前版本,Expr
和Type
的唯一更改是构造函数需要添加Phase p
约束,例如ExprInt :: Phase p => MaybeType p -> Int -> Expr p
。
如果已知p
或Type
中Expr
的类型,您可以静态地知道MaybeP
是()
还是EitherP
给定类型以及p
所属的类型,并且可以直接将它们用作该类型而无需显式展开。如果maybeP
未知,您可以使用eitherP
课程中的Phase
和Proxy
来了解它们是什么。 (p
参数是必要的,因为否则编译器将无法确定您的意思是哪个阶段。)这类似于GADT版本,如果已知MaybeP
,则为可以确定EitherP
和()
包含的内容,否则您必须模式匹配两种可能性。这个解决方案在“缺失”方面并不完善。参数变为Expr
而不是完全消失。
构建Type
和p
s似乎在两个版本之间大致相似:如果您构建的值具有任何阶段特定的值,那么它必须指定该阶段在它的类型。当您想在asdf :: MaybeP p a -> MaybeP p a
asdf NothingP = NothingP
asdf (JustP a) = JustP a
中编写函数多态而仍然处理特定于阶段的部分时,似乎会遇到麻烦。使用GADT,这很简单:
asdf _ = NothingP
请注意,如果我只编写TypeFamilies
编译器会抱怨,因为输出的类型不能保证与输入相同。通过模式匹配,我们可以确定输入的类型并返回相同类型的结果。
使用maybeP
版本时,这要困难得多。只需使用Maybe
和结果maybeP
,就无法向编译器证明有关类型的任何内容。您可以通过eitherP
和Maybe
返回Either
和maybe
,而不是让either
和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
和MaybeP
1}}也使类型相等:
EitherP
(请注意,我们需要asdf :: Phase p => MaybeP p a -> MaybeP p a
asdf a = maybeP phase () id a
,并注意这些版本基本上是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
和MaybeP p a
的GADT版本的CPS转换版本。)
然后我们可以写:
p
但这仍然不够,因为GHC说:
a
也许你可以在某个地方用类型签名来解决这个问题,但是在这一点上它似乎比它的价值更令人烦恼。所以等待来自其他人的进一步信息,我将建议使用GADT版本,更简单,更强大,以一点语法噪音为代价。
再次更新:这里的问题是因为Proxy p
是一个类型函数,而且没有其他信息可供使用,GHC无法知道phase
和p
应该是什么是。如果我传入a
并使用{{1}}而不是{{1}}来解决{{1}},但{{1}}仍然未知。
答案 1 :(得分:4)
这个问题没有理想的解决方案,因为每个问题都有不同的优点和缺点。
我个人会使用单一数据类型“树”,并为需要区分的事物添加单独的数据构造函数。即:
data Type
= ...
| TypeArray Id Type Expr
| TypeResolvedArray Id Type Int
| ...
这样做的好处是你可以在同一棵树上多次运行相同的阶段,但是理由比这更深入:假设您正在实现一个生成更多AST的语法元素(类似于{ {1}}或C ++模板类型的交易,它可以依赖于像include
这样的常量exprs,因此您无法在第一次迭代中对其进行求值)。使用统一数据类型方法,您只需在现有树中插入新AST,不仅可以直接在该树上运行与之前相同的阶段,而且可以免费获得缓存,即如果新AST引用了使用TypeArray
或其他内容的数组,您不必再次确定sizeof(typeof(myarr))
的常量大小,因为它的类型已经是您之前解析阶段的myarr
。
当您经过所有编译阶段时,您可以使用不同的表示形式,是时候解释代码(或其他东西);那么你肯定的是,不再需要AST更改,更简化的表示可能是一个好主意。
顺便说一下,对于数组大小,您应该使用Data.Word.Word
而不是Data.Int.Int
。在C中使用TypeResolvedArray
来索引数组这是一个常见的错误,而C指针实际上是无符号的。请不要用你的语言犯这个错误,除非你真的想支持负数大小的数组。