使用 Haskell 类型 类 时如何优雅地避免 "Ambiguous type variable"

How to elegantly avoid "Ambiguous type variable" when using Haskell type classes

我想编写一个处理持久化实体的简单框架。 这个想法是拥有一个实体类型 class 并提供像

这样的通用持久性操作
storeEntity    :: (Entity a) => a -> IO () 
retrieveEntity :: (Entity a) => Integer -> IO a
publishEntity  :: (Entity a) => a -> IO () 

实际数据类型是该实体类型的实例 class。

即使持久性操作是通用的并且不需要有关具体数据类型的任何信息您必须在调用站点提供类型注释以使 GHC 满意,例如:

main = do
    let user1 = User 1 "Thomas" "Meier" "tm@meier.com"
    storeEntity user1
    user2 <- retrieveEntity 1 :: IO User -- how to avoid this type annotation?
    publishEntity user2

有什么方法可以避免这种调用站点注释吗?

我知道如果编译器可以从使用的上下文中推断出实际类型,我就不需要这些注释。因此,例如以下代码可以正常工作:

main = do
    let user1 = User 1 "Thomas" "Meier" "tm@meier.com"
    storeEntity user1
    user2 <- retrieveEntity 1
    if user1 == user2
        then publishEntity user2
        else fail "retrieve of data failed"

但我希望能够像这样链接多态操作:

main = do
    let user1 = User 1 "Heinz" "Meier" "hm@meier.com"
    storeEntity user1
    -- unfortunately the next line does not compile
    retrieveEntity 1 >>= publishEntity

    -- but with a type annotation it works:
    (retrieveEntity 1 :: IO User) >>= publishEntity

但是这里有一个类型注释破坏了多态性的优雅...

为了完整起见,我包含了完整的源代码:

{-# LANGUAGE DeriveGeneric, DeriveAnyClass #-}
module Example where
import GHC.Generics
import Data.Aeson

-- | Entity type class
class (ToJSON e, FromJSON e, Eq e, Show e) => Entity e where 
    getId :: e -> Integer

-- | a user entity    
data User = User {
      userId    :: Integer
    , firstName :: String
    , lastName  :: String
    , email     :: String
} deriving (Show, Eq, Generic, ToJSON, FromJSON)

instance Entity User where
    getId = userId 


-- | load persistent entity of type a and identified by id
retrieveEntity :: (Entity a) => Integer -> IO a
retrieveEntity id = do
    -- compute file path based on id
    let jsonFileName = getPath id
    -- parse entity from JSON file
    eitherEntity <- eitherDecodeFileStrict jsonFileName
    case eitherEntity of
        Left msg -> fail msg
        Right e  -> return e

-- | store persistent entity of type a to a json file
storeEntity :: (Entity a) => a -> IO ()
storeEntity entity = do
    -- compute file path based on entity id
    let jsonFileName = getPath (getId entity)
    -- serialize entity as JSON and write to file
    encodeFile jsonFileName entity

-- | compute path of data file based on id
getPath :: Integer -> String
getPath id = ".stack-work/" ++ show id ++ ".json"

publishEntity :: (Entity a) => a -> IO ()   
publishEntity = print

main = do
    let user1 = User 1 "Thomas" "Meier" "tm@meier.com"
    storeEntity user1
    user2 <- retrieveEntity 1 :: IO User
    print user2

您可以将 storeEntityretrieveEntity 的类型联系在一起,方法是向您的实体标识符 Integer 添加类型级标签。我认为您的 API 设计还有一个不严重的小缺陷,但无论如何我都会修复它。即:Users 不应存储其标识符。取而代之的是为已识别的事物使用一个顶级类型包装器。这使您可以一劳永逸地编写代码来修改标识符——例如一个函数,它接受一个还没有 ID 的实体(你甚至如何用你对 User 的定义来表示它?)并为其分配一个新的 ID —— 无需返回并修改你的 Entity class 及其所有实现。同样分别存储名字和姓氏的是 wrong。所以:

import Data.Tagged

data User = User
    { name :: String
    , email :: String
    } deriving (Eq, Ord, Read, Show)

type Identifier a = Tagged a Integer
data Identified a = Identified
    { ident :: Identifier a
    , val :: a
    } deriving (Eq, Ord, Read, Show)

这里我的Identified User对应你的User,我的User在你的版本中没有对应的。 Entity class 可能如下所示:

class Entity a where
    store :: Identified a -> IO ()
    retrieve :: Identifier a -> IO a
    publish :: a -> IO () -- or maybe Identified a -> IO ()?

instance Entity User -- stub

作为上述 "write it once and for all" 原则的一个示例,您可能会发现 retrieve 将实体 returns 与其标识符实际关联起来很方便。现在可以对所有实体统一执行此操作:

retrieveIDd :: Entity a => Identifier a -> IO (Identified a)
retrieveIDd id = Identified id <$> retrieve id

现在我们可以编写一个将其存储类型和检索操作联系在一起的操作:

storeRetrievePublish :: Entity a => Identified a -> IO ()
storeRetrievePublish e = do
    store e
    e' <- retrieve (ident e)
    publish e'

此处 ident e 具有足够丰富的类型信息,我们知道 e' 必须是 a,即使我们没有明确的类型签名。 (storeRetrievePublish 上的签名也是可选的;这里给出的是 GHC 推断的签名。)收尾:

main :: IO ()
main = storeRetrievePublish (Identified 1 (User "Thomas Meier" "tm@meier.com"))

如果您不想明确定义 storeRetrievePublish,您可以这样做:

main :: IO ()
main = do
    let user = Identified 1 (User "Thomas Meier" "tm@meier.com")
    store user
    user' <- retrieve (ident user)
    publish user'

...但是您不能进一步展开定义:如果您将 ident user 减少到 1,您将失去用于 [ 的标识符上的类型标签之间的联系=34=] 和 retrieve,然后回到你的模棱两可的类型情况。