在Haskell中键入抽象语法和DSL设计

时间:2015-11-26 00:09:57

标签: haskell dsl language-design dependent-type

我在Haskell中设计DSL,我希望有一个赋值操作。这样的事情(下面的代码仅用于在有限的上下文中解释我的问题,我没有检查类型的Stmt类型):

 data Stmt = forall a . Assign String (Exp a) -- Assignment operation
           | forall a. Decl String a          -- Variable declaration 
 data Exp t where
    EBool   :: Bool -> Exp Bool
    EInt    :: Int  -> Exp Int
    EAdd    :: Exp Int -> Exp Int -> Exp Int
    ENot    :: Exp Bool -> Exp Bool

在前面的代码中,我能够使用GADT对表达式强制执行类型约束。我的问题是如何强制执行赋值的左侧:1)定义,即必须在使用之前声明变量2)右侧必须具有相同类型的左侧变量?

我知道在完全依赖类型的语言中,我可以定义由某种类型的输入上下文索引的语句,即定义的变量列表及其类型。我相信这会解决我的问题。但是,我想知道在Haskell中是否有某种方法可以实现这一点。

非常感谢任何指向示例代码或文章的指针。

3 个答案:

答案 0 :(得分:5)

鉴于我的工作侧重于范围和类型安全的相关问题在类型级编码,我偶然发现了这个古老的问题,并且认为我会试一试。

我认为这篇文章提供的答案与原始规范非常接近。一旦你有正确的设置,整个事情就会出乎意料地短暂。

首先,我将从一个示例程序开始,让您了解最终结果:

program :: Program
program = Program
  $  Declare (Var :: Name "foo") (Of :: Type Int)
  :> Assign  (The (Var :: Name "foo")) (EInt 1)
  :> Declare (Var :: Name "bar") (Of :: Type Bool)
  :> increment (The (Var :: Name "foo"))
  :> Assign  (The (Var :: Name "bar")) (ENot $ EBool True)
  :> Done

作用域

为了确保我们只能为之前声明过的变量赋值,我们需要一个范围概念。

GHC.TypeLits为我们提供了类型级别的字符串(称为Symbol),因此如果需要,我们可以很好地使用字符串作为变量名。因为我们想要确保类型安全,所以每个变量声明都带有一个类型注释,我们将它与变量名一起存储。因此,我们的范围类型为:[(Symbol, *)]

我们可以使用类型族来测试给定的Symbol是否在范围内并返回其关联类型(如果是这种情况):

type family HasSymbol (g :: [(Symbol,*)]) (s :: Symbol) :: Maybe * where
  HasSymbol '[]            s = 'Nothing
  HasSymbol ('(s, a) ': g) s = 'Just a
  HasSymbol ('(t, a) ': g) s = HasSymbol g s

根据这个定义,我们可以定义变量的概念:范围a中的g类型的变量是符号s,以便HasSymbol g s返回'Just a }。这是ScopedSymbol数据类型通过使用存在量化来存储s所代表的内容。

data ScopedSymbol (g :: [(Symbol,*)]) (a :: *) = forall s.
  (HasSymbol g s ~ 'Just a) => The (Name s)

data Name (s :: Symbol) = Var

此处我故意滥用所有地方的注释:TheScopedSymbol类型的构造函数,NameProxy类型,具有更好的名称和构造函数。这使我们能够写出如下细节:

example :: ScopedSymbol ('("foo", Int) ': '("bar", Bool) ': '[]) Bool
example = The (Var :: Name "bar")

现在我们在该范围内有范围和良好类型变量的概念,我们可以开始考虑Statement应该具有的效果。鉴于可以在Statement中声明新变量,我们需要找到一种在范围内传播此信息的方法。关键的后见之明是有两个索引:输入输出范围。

Declare新变量及其类型,将使用变量名称对和相应类型扩展当前范围。

另一方面,

Assign不会修改范围。它们只是将ScopedSymbol与相应类型的表达式相关联。

data Statement (g :: [(Symbol, *)]) (h :: [(Symbol,*)]) where
  Declare :: Name s -> Type a -> Statement g ('(s, a) ': g)
  Assign  :: ScopedSymbol g a -> Exp g a -> Statement g g

data Type (a :: *) = Of

我们再次引入了一种代理类型,以获得更好的用户级语法。

example' :: Statement '[] ('("foo", Int) ': '[])
example' = Declare (Var :: Name "foo") (Of :: Type Int)

example'' :: Statement ('("foo", Int) ': '[]) ('("foo", Int) ': '[])
example'' = Assign (The (Var :: Name "foo")) (EInt 1)

Statement可以通过定义以下类型对齐序列的GADT以保持范围的方式链接:

infixr 5 :>
data Statements (g :: [(Symbol, *)]) (h :: [(Symbol,*)]) where
  Done :: Statements g g
  (:>) :: Statement g h -> Statements h i -> Statements g i

表达式

表达式与原始定义大多没有变化,除了它们现在是作用域的,并且新的构造函数EVar允许我们取消引用先前声明的变量(使用ScopedSymbol),为我们提供相应类型的表达式

data Exp (g :: [(Symbol,*)]) (t :: *) where
  EVar    :: ScopedSymbol g a -> Exp g a
  EBool   :: Bool -> Exp g Bool
  EInt    :: Int  -> Exp g Int
  EAdd    :: Exp g Int -> Exp g Int -> Exp g Int
  ENot    :: Exp g Bool -> Exp g Bool

程序

Program只是从空范围开始的一系列语句。我们再一次使用存在量化来隐藏我们最终的范围。

data Program = forall h. Program (Statements '[] h)

显然可以在Haskell中编写子程序并在程序中使用它们。在这个例子中,我有一个非常简单的increment,可以这样定义:

increment :: ScopedSymbol g Int -> Statement g g
increment v = Assign v (EAdd (EVar v) (EInt 1))

我已将整个代码段与正确的LANGUAGE pragma以及此处列出的示例in a self-contained gist一起上传。但我没有在那里提出任何意见。

答案 1 :(得分:1)

你应该知道你的目标是非常崇高的。我不认为你会把你的变量完全视为字符串。我做一些比较烦人的事情,但更实用。为您的DSL定义一个monad,我将调用M

newtype M a = ...

data Exp a where
    ... as before ...

data Var a  -- a typed variable

assign :: Var a -> Exp a -> M ()
declare :: String -> a -> M (Var a)

我不确定你为什么要Exp a进行作业,而只是a进行宣言,但我在这里转载。 String中的declare仅适用于化妆品,如果您需要代码生成或错误报告或其他内容 - 变量的标识应该与该名称无关。所以它通常用作

myFunc = do
    foobar <- declare "foobar" 42

这是烦人的冗余位。 Haskell并没有很好地解决这个问题(虽然取决于你用DSL做什么,你可能根本不需要字符串。)

至于实施,可能像

data Stmt = forall a. Assign (Var a) (Exp a)
          | forall a. Declare (Var a) a

data Var a = Var String Integer  -- string is auxiliary from before, integer
                                 -- stores real identity.

对于M,我们需要一个独特的名称供应和一个要输出的语句列表。

newtype M a = M { runM :: WriterT [Stmt] (StateT Integer Identity a) }
    deriving (Functor, Applicative, Monad)

然后操作通常相当简单。

assign v a = M $ tell [Assign v a]

declare name a = M $ do
    ident <- lift get
    lift . put $! ident + 1
    let var = Var name ident
    tell [Declare var a]
    return var

我使用相当类似的设计制作了一个相当大的DSL用于使用另一种语言生成代码,并且它可以很好地扩展。我发现留在地面附近是一个好主意,只是在不使用太多奇特的类型级魔法功能的情况下进行实体建模,并接受轻微的语言烦恼。这样,Haskell的主要优势 - 它的抽象能力 - 仍可用于DSL中的代码。

一个缺点是需要在do块内定义所有内容,随着代码量的增长,这可能会妨碍良好的组织。我会窃取declare以显示解决方法:

declare :: String -> M a -> M a

一样使用
foo = declare "foo" $ do
    -- actual function body

那么你的M可以作为其状态的一个组件,从名称到变量的缓存,并且第一次使用具有特定名称的声明时,你将其呈现并放置它在变量中(这将需要比[Stmt]更复杂的monoid而不是Writer的目标。以后你只需要查找变量。不幸的是,它确实对名字的唯一性有着相当松软的依赖;明确的命名空间模型可以帮助解决这个问题,但绝不能完全消除它。

答案 2 :(得分:1)

在看了@Cactus的所有代码和@luqui的Haskell建议后,我设法得到了一个接近我想要的解决方案。完整的代码可从以下要点获得:

https://gist.github.com/rodrigogribeiro/33356c62e36bff54831d

我需要在之前的解决方案中解决一些小问题:

  1. 我还不知道(如果)Idris支持整数文字重载,那么构建我的DSL会非常有用。
  2. 我试图在DSL语法中为程序变量定义一个前缀运算符,但它并没有像我喜欢的那样工作。我有一个解决方案(在前一个要点中)使用关键字--- use --- for variable access。
  3. 我会在Idris #freenode频道中查看这些小问题,看看这两点是否可行。