首页 > 解决方案 > Haskell 不使用类型类的更具体的实例

问题描述

在过去的几天里,我一直在弄清楚我正在尝试做的事情在 Haskell 中是否真的可行。

这是一些上下文:我正在尝试编写一种小标记语言(类似于 ReST),其中语法已经通过指令启用了自定义扩展。对于实现新指令的用户,他们应该能够在文档数据类型中添加新的语义结构。例如,如果想要添加一个用于显示数学的指令,他们可能希望MathBlock String在 ast 中有一个构造函数。

DirectiveBlock String显然数据类型是不可扩展的,并且有一个包含指令名称的通用构造函数的解决方案(这里"math",论据)。

使用类型族,我制作了类似的原型:

{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TypeSynonymInstances #-}
{-# LANGUAGE FlexibleInstances #-}

-- Arguments for custom directives.
data family Args :: * -> *

data DocumentBlock
    = Paragraph String
    | forall a. Block (Args a)

果然,如果有人想为数学显示定义一个新的指令,他们可以这样做:

data Math
-- The expected arguments for the math directive.
data instance Args Math = MathArgs String

doc :: [DocumentBlock]
doc =
    [ Paragraph "some text"
    , Block (MathArgs "x_{n+1} = x_{n} + 3")
    ]

到目前为止一切顺利,我们只能构建指令块接收正确参数的文档。

当一个用户想要将文档的内部表示转换为一些自定义输出(例如,字符串)时,就会出现问题。用户需要为所有指令提供默认输出,因为会有很多指令,其中一些无法转换为目标。此外,用户可能希望为某些指令提供更具体的输出

class StringWriter a where
    write :: Args a -> String

-- User defined generic conversion for all directives.
instance StringWriter a where
   write _ = "Directive"

-- Custom way of showing the math directive.
instance StringWriter Math where
    write (MathArgs raw) = "Math(" ++ raw ++ ")"

-- Then to display a DocumentBlock
writeBlock :: DocumentBlock -> String
writeBlock (Paragraph t) = "Paragraph(" ++ t ++ ")"
writeBlock (Block args)  = write args

main :: IO ()
main = putStrLn $ writeBlock (Block (MathArgs "a + b"))

在此示例中,输出 isBlock和 not Math(a+b),因此始终选择 StringWriter 的通用实例。即使玩{-# OVERLAPPABLE #-},也没有成功。

我在 Haskell 中描述的那种行为是否可能?


当尝试在Block定义中包含通用 Writer 时,它也无法编译。

-- ...

class Writer a o where
    write :: Args a -> o

data DocumentBlock
    = Paragraph String
    | forall a o. Writer a o => Block (Args a)

instance {-# OVERLAPPABLE #-} Writer a String where
   write _ = "Directive"

instance {-# OVERLAPS #-} Writer Math String where
    write (MathArgs raw) = "Math(" ++ raw ++ ")"

-- ...

标签: haskellinstancetypeclassextensibility

解决方案


您的代码无法编译,因为Block something有 type DocumentBlock,而write需要一个Args a参数,并且这两种类型是不同的。你的意思是writeBlock相反吗?我会假设是这样。

您可能想要尝试的是在您的存在类型中添加一个约束,例如:

data DocumentBlock
    = Paragraph String
    | forall a. StringWriter a => Block (Args a)
             -- ^^^^^^^^^^^^^^ --

这具有以下效果。在操作上,每次Block something使用时,都会记住实例(指针隐含地存储在Args a值中)。这将是一个指向包罗万象的实例或特定实例的指针,以最合适的为准。

当构造函数稍后进行模式匹配时,可以使用该实例。完整的工作代码:

{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TypeSynonymInstances #-}
{-# LANGUAGE FlexibleInstances #-}

-- Arguments for custom directives.
data family Args :: * -> *

data DocumentBlock
    = Paragraph String
    | forall a. StringWriter a => Block (Args a)

data Math
-- The expected arguments for the math directive.
data instance Args Math = MathArgs String

doc :: [DocumentBlock]
doc =
    [ Paragraph "some text"
    , Block (MathArgs "x_{n+1} = x_{n} + 3")
    ]

class StringWriter a where
    write :: Args a -> String

-- User defined generic conversion for all directives.
instance {-# OVERLAPPABLE #-} StringWriter a where
   write _ = "Directive"

-- Custom way of showing the math directive.
instance StringWriter Math where
    write (MathArgs raw) = "Math(" ++ raw ++ ")"

-- Then to display a DocumentBlock
writeBlock :: DocumentBlock -> String
writeBlock (Paragraph t) = "Paragraph(" ++ t ++ ")"
writeBlock (Block args)  = write args

main :: IO ()
main = putStrLn $ writeBlock (Block (MathArgs "a + b"))

这打印Math(a + b)

最后一点:要使其工作,所有相关实例在Block使用时都在范围内是至关重要的。否则,GHC 可能会选择错误的实例,导致一些意外的输出。这是主要限制,使重叠实例通常有点脆弱。只要没有孤立实例,这应该可以工作。

另请注意,如果使用其他存在类型,用户可能(有意或无意地)导致 GHC 选择错误的实例。例如,如果我们使用

data SomeArgs = forall a. SomeArgs (Args a)

toGenericInstance :: DocumentBlock -> DocumentBlock
toGenericInstance (Block a) = case SomeArgs a of
   SomeArgs a' -> Block a'  -- this will always pick the generic instance
toGenericInstance db = db

然后,writeBlock (toGenericInstance (Block (MathArgs "a + b"))) 将产生Directive代替。


推荐阅读