我在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中是否有某种方法可以实现这一点。
非常感谢任何指向示例代码或文章的指针。
答案 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
此处我故意滥用所有地方的注释:The
是ScopedSymbol
类型的构造函数,Name
是Proxy
类型,具有更好的名称和构造函数。这使我们能够写出如下细节:
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)
我需要在之前的解决方案中解决一些小问题:
我会在Idris #freenode频道中查看这些小问题,看看这两点是否可行。