【问题标题】:How can I handle operations over many different types in my DSL?如何在我的 DSL 中处理许多不同类型的操作?
【发布时间】:2014-03-18 21:16:58
【问题描述】:

假设 Haskell 用于为特定领域的语言实现解释器。 DSL 有大量的类型,表示为数据构造函数,以及大量的二进制表达式。一个天真的第一次尝试是一个类型类 BinaryOps 在 DSL 中封装 MyType 上的所有二进制操作:

data MyType = A String
            | B Integer
            | C Bool
            | D Double
         {- | E .. Z -}

class BinaryOps a where
    f :: a -> a -> a
    g :: a -> a -> a
    h :: a -> a -> a
    j :: a -> a -> a
    {- many more binary ops -}

instance BinaryOps MyType where
    f (A s1) (A s2) = {- Haskell expression on s1 and s2 -}
    f (A s1) (B s2) = {- ... -}
    f (B s1) (D s2) = {- ... -}
    f _ _ = error "f does not support argument types"

    g (D s1) (A s2) = {- Haskell expression on s1 and s2 -}
    g (D s1) (C s2) = {- ... -}
    g _ _ = error "g does not support argument types"

    h (B s1) (B s2) = {- Haskell expression on s1 and s2 -}
    h (B s1) (C s2) = {- ... -}
    h (B s1) (D s2) = {- ... -}
    h (C s1) (B s2) = {- ... -}
    h (D s1) (C s2) = {- ... -}
    h (D s1) (D s2) = {- ... -}
    h _ _ = error "h does not support argument types"

DSL 将有许多二进制表达式和许多内置类型。上面的解决方案不会很好地扩展:类定义会很大,并且“不受支持”的 DSL 类型的错误类型组合的数量会增加(error 调用)。

有没有更优雅的方式来使用类型类来解释 DSL 中的二进制表达式?或者确实,有没有像 GADT 那样提供更具可扩展性的解决方案?

【问题讨论】:

  • 您是否有理由不将操作也建模为 ADT?对表达式表示的构造进行类型限制并对其进行解释可能比在解释函数中检查参数的有效性要容易得多...

标签: haskell dsl typeclass


【解决方案1】:

我不明白你为什么首先使用类型类。类型类比仅仅拥有普通功能有什么好处?

只需将二元运算符定义为 Haskell 二元运算符,它们只是普通函数:

f :: MyType -> MyType -> MyType
f = ...

由于您所有的 DSL 类型都在 MyType 中,因此没有理由使用类型类。

打包和拆包

当然,这仍然不能解决您的error 问题。我过去采用的一种方法是使用类型类来定义将原始类型“打包”和“提取”到 DSL 中的方法:

class Pack a where
  pack :: a -> MyType

class Extract a where
  extract :: MyType -> a

String 的实例如下所示:

instance Pack String where pack = A
instance Extract String where
  extract (A str) = str
  extract _       = error "Type error: expected string!"

Extract 类可以处理不兼容类型的错误处理。

这让您可以将功能统一“提升”到您的 DSL 中:

-- Lifts binary Haskell functions into your DSL
lift :: (Extract a, Extract b, Pack c) => (a -> b -> c)
          -> MyType -> MyType -> MyType
lift f a b = pack $ f (extract a) (extract b)

如果您将MyType 设为PackExtract 的实例,这将适用于纯Haskell 函数 了解您的DSL 的函数。也就是说,有意识的函数只会得到某种MyType,并且必须手动处理它,如果他们的MyType参数不是他们所期望的,则调用error

因此,对于您可以直接用 Haskell 编写的函数,这解决了您的 error 问题,但对于那些依赖于 MyType 的函数却不是真的。

错误处理

使用pack 也很好,因为切换到比error 更好的错误处理机制非常简单。您只需切换extract 的类型(或者甚至pack,如果合适的话)。也许你可以使用:

class Extract a where
  extract :: MyType -> Either MyError a

然后以Left (TypeError expected got) 失败,这会让你写出漂亮的错误消息。

这还可以让您轻松地将多个原始函数组合成MyType 级别的“案例”。基本思想是我们将多个可提升函数组合成一个MyType -> MyType -> MyType,在内部我们只使用第一个不会给我们错误的函数。这也可以给我们一些漂亮的语法:)。

以下是相关代码:

type MyFun = MyType -> MyType -> Either MyError MyType

(|:) :: (Extract a, Extract b, Pack c) => MyFun -> (a -> b -> c) -> MyFun
(f |: option) a b = case f a b of
  Right res -> return res
  Left err  -> (lift option) a b 

match :: MyFun
match _ _ = Left EmptyFunction

test = match |: (\ a b -> a ++ b :: String)
             |: (\ a b -> a || b)

不幸的是,我不得不添加一个:: String 类型签名,否则它会模棱两可。如果我使用+,也会发生同样的情况,因为它不知道要依赖什么样的数字。

现在test 是一个可以在两个As 或两个Bs 上正常工作的函数,否则会出错:

*Main> test (A "foo") (A "foo")
Right (A "foofoo")
*Main> test (C True) (C False)
Right (C True)
*Main> test (A "foo") (C False)
Left TypeError

另请注意,这对于不同类型的参数非常有效,例如可以组合 AB 值的案例。

这意味着您现在可以方便地将fgh 等函数重铸为 Haskell 中的顶级名称。以下是您如何定义f

f :: MyFun
f = match |: \ s1 s2 -> {- something with strings -}
          |: \ s i   -> {- something with a string and an int -}
          |: \ i d   -> {- something with an int and a double -}
          |: {- ...and so on... -}

您有时必须使用类型签名来注释某些值,因为没有足够的信息来使类型推断正常工作。仅当您使用来自类型类的操作(即+)或使用具有更一般类型的操作(如++ 用于字符串)时才会出现这种情况(++ 可以在任何列表上工作)。 p>

您还必须更新 lift 才能正确处理错误。这涉及将其更改为返回 Either 并添加必要的管道。我的版本是这样的:

lift :: (Extract a, Extract b, Pack c) => (a -> b -> c) -> MyFun
lift f a b = fmap pack $ f <$> extract a <*> extract b

新类型

这主要通过让|: 为您构造检查错误来解决您的error 问题。这种方法的主要缺点是,如果您希望您的 DSL 具有多个具有相同 基础 Haskell 类型的类型,例如:

data MyType = A Double
            | B Double
            {- ... -}

您可以使用newtypeDouble 创建一个包装器来解决此问题。像这样的:

newtype BDouble = B Double

instance Pack Double where pack = A

instance Pack BDouble where pack = B

-- same for Extract

【讨论】:

  • 巧妙地使用 match|: 进行基于类型的调度。
  • 感谢您回复如此深思熟虑的答复,一个很好的答复。
  • @tikhon-jelvis 鉴于您的第二版 Extract 类定义包含错误处理,lift 的新定义是什么?我假设lift 的返回类型也必须更改为Either MyError MyType 对吗?另外,您是否建议将 test 函数定义为我的 DSL 中的所有二进制操作?此外,如果我想test 接受不同类型的参数,我将不得不在 |: ?
  • @RobStewart:我们的想法是让每个函数(即fgh)本身都是具有MyFun 类型的顶级函数。由于现在所有的函数都是相同的类型,如果你愿意,你也可以将它们放入像 Map 这样的数据结构中。我将在我有更好格式的答案本身中添加更多细节。
  • @RobStewart:呵呵,谢谢。我用packunpack 表示我自己的语言,这真的很方便。但我从未使用过 match 的东西——我想出了这个答案。
【解决方案2】:

您可以使用 GADT 更好地编码 dsl 的语义。

{-# LANGUAGE GADTs, TypeSynonymInstances, FlexibleInstances #-} 

data MyType a where 
  A :: String -> MyType String
  B :: Integer -> MyType Integer 
  C :: Bool -> MyType Bool
  D :: Double -> MyType Double 

出现了为函数分配类型的问题。以f 为例。我无法想象一个函数的多态性足以接受两个字符串,一个字符串和一个整数,或者一个整数和一个双精度,但不是一个字符串和一个双精度。您没有包含语义,所以我不知道它的作用。所以当你想做这样的事情时:

class BinaryOps r where 
  add :: r Integer -> r Integer -> r Integer

甚至

class BinaryOps r where 
  add :: Num a => r a -> r a-> r a

你不能,因为f 太多态了。我能想到的最好的:

class BinaryOps r where 
  f :: FArg a b c => r a -> r b -> r c  

class FArg a b c 
instance FArg String String a  -- a should be the actual output type
instance FArg String Integer a
instance FArg Integer Double a

instance BinaryOps MyType where 
    f (A s1) (A s2) = undefined 
    f (A s1) (B s2) = undefined 
    f (B s1) (D s2) = undefined 

这不是一个很好的解决方案,因为FArg 没有说明参数,如果将实例添加到FArg,则用户必须查找类的定义,例如FArg Double Double Double您将能够调用f (D 0) (D 0) 并获得运行时模式匹配错误。我的建议是将函数更改为更合理的类型;将函数编写为单态并在您的 dsl 中实现隐式或显式转换;包括一些实际功能的定义,以便更容易解决这个问题。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2014-08-20
    • 2016-08-23
    • 2014-05-21
    • 1970-01-01
    • 2014-12-26
    • 2016-01-15
    • 1970-01-01
    相关资源
    最近更新 更多