解决不明确的类型变量

Resolving an ambiguous type variable

我有这两个功能:

load :: Asset a => Reference -> IO (Maybe  a)
send :: Asset a => a -> IO ()

资产 class 看起来像这样:

class (Typeable a,ToJSON a, FromJSON a) => Asset a where
  ref :: a -> Reference
  ...

第一个从磁盘读取资产,第二个将 JSON 表示传输到 WebSocket。单独使用它们可以正常工作,但是当我将它们组合在一起时,编译器无法推断出 a 应该是什么具体类型。 (Could not deduce (Asset a0) arising from a use of 'load')

这是有道理的,我没有给出具体的类型,loadsend都是多态的。编译器必须以某种方式决定使用哪个版本的 send(以及 toJSON 的扩展版本)。

我可以在 运行 时确定 a 的具体类型是什么。此信息实际上在磁盘上的数据和 Reference 类型中都进行了编码,但我在编译时不确定,因为类型检查器正在 运行.

有没有办法在 运行 时传递正确的类型并且仍然让类型检查器满意?


附加信息

参考定义

data Reference = Ref {
    assetType:: String
  , assetIndex :: Int
  } deriving (Eq, Ord, Show, Generic)

引用是通过如下解析来自 WebSocket 的请求派生的,其中 Parser 来自 Parsec 库。

reference :: Parser Reference
reference = do 
  t <-    string "User" 
       <|> string "Port" 
       <|> string "Model"
       <|> ...
  char '-'
  i <- int
  return Ref {assetType = t, assetIndex =i}

如果我向 Reference 添加类型参数,我只是将我的问题推回到解析器中。我仍然需要将一个我在编译时不知道的字符串转换为一个类型才能使它工作。

当然,Reference 存储类型。

data Reference a where
    UserRef :: Int -> Reference User
    PortRef :: Int -> Reference Port
    ModelRef :: Int -> Reference Model

load :: Asset a => Reference a -> IO (Maybe a)
send :: Asset a => a -> IO ()

如有必要,您仍然可以通过存在性装箱来恢复原始 Reference 类型的优点。

data SomeAsset f where SomeAsset :: Asset a => f a -> SomeAsset f

reference :: Parser (SomeAsset Reference)
reference = asum
    [ string "User" *> go UserRef
    , string "Port" *> go PortRef
    , string "Model" *> go ModelRef
    ]
    where
    go :: Asset a => (Int -> Parser (Reference a)) -> Parser (SomeAsset Reference)
    go constructor = constructor <$ char '-' <*> int

loadAndSend :: SomeAsset Reference -> IO ()
loadAndSend (SomeAsset reference) = load reference >>= traverse_ send

您不能创建一个函数,根据字符串中的内容将字符串数据转换为不同类型的值。那简直是不可能的。您需要重新排列,以便您的 return 类型不依赖于字符串内容。

您为 loadAsset a => Reference -> IO (Maybe a) 输入的类型为 "pick any a (where Asset a) you like and give me a Reference, and I'll give you back an IO action that produces Maybe a"。调用者选择他们希望引用加载的类型;文件的内容不影响加载的类型。但是您不希望它由调用者选择,您希望它由存储在磁盘上的内容选择,因此类型签名根本不表达您实际想要的操作。那才是你真正的问题;如果 loadsend 单独正确并且组合在一起,则组合 loadsend 时不明确的类型变量将很容易解决(使用类型签名或 TypeApplications)他们是唯一的问题。

基本上你不能只拥有 load return 多态类型,因为如果它存在,那么调用者将(必须)决定它是什么类型 returns。有两种或多或少等效的方法可以避免这种情况:return 存在性包装器,或使用等级 2 类型并添加多态处理函数(延续)作为参数。

使用存在包装器(需要 GADTs 扩展),它看起来像这样:

data SomeAsset
  where Some :: Asset a => a -> SomeAsset

load :: Reference -> IO (Maybe SomeAsset)

注意 load 不再是多态的。你得到一个 SomeAsset ,它(就类型检查器而言)可以包含任何具有 Asset 实例的类型。 load 可以在内部使用它想要的任何逻辑拆分成多个分支,并在不同分支上得出不同类型资产的值;如果每个分支都以 SomeAsset 构造函数包装资产值结束,所有分支都将 return 相同类型。

对于send它,你可以使用类似的东西(忽略我没有处理Nothing):

loadAndSend :: Reference -> IO ()
loadAndSend ref
  = do Just someAsset <- load ref
       case someAsset
         of SomeAsset asset -> send asset

SomeAsset 包装器保证 Asset 保持其包装值,因此您可以解包它们并在结果上调用任何 Asset 多态函数。但是,您永远不能以任何其他方式对依赖于特定类型的值做任何事情1,这就是为什么您必须将它包裹起来并case匹配它每时每刻;如果 case 表达式产生的类型取决于包含的类型(例如 case someAsset of SomeAsset a -> a),编译器将不会接受您的代码。

另一种方法是使用 RankNTypes 并给 load 一个这样的类型:

load :: (forall a. Asset a => a -> r) -> Reference -> IO (Maybe r)

这里 load 根本没有 return 代表加载资产的值。相反,它所做的是将多态函数作为参数;该函数适用于任何 Asset 和 return 类型 r(由 load 的调用者选择),因此 load 可以在内部分支但是它想要在不同的分支机构构建不同类型的资产。不同的资产类型都可以传递给handler,所以handler可以在每个分支中调用。

我的偏好通常是使用 SomeAsset 方法,但随后也使用 RankNTypes 并定义一个辅助函数,例如:

withSomeAsset :: (forall a. Asset a => a -> r) -> (SomeAsset -> r)
withSomeAsset f (SomeAsset a) = f a

这避免了将您的代码重组为连续传递样式,但在您需要使用 SomeAsset:

的任何地方都消除了 heave case 语法
loadAndSend :: Reference -> IO ()
loadAndSend ref
  = do Just asset <- load ref
       withSomeAsset send asset

甚至添加:

sendSome = withSomeAsset send

Daniel Wagner 建议将类型参数添加到 Reference,OP 对此表示反对,声明只是将相同的问题转移到构造引用时。如果参考文献包含表示它们所指资产类型的数据,那么我 强烈 建议采纳 Daniel 的建议,并使用此答案中描述的概念来解决参考构建中的问题等级。 Reference 具有类型参数可防止在您知道类型的情况下混淆对错误类型资产的引用。

并且如果您对相同类型的引用和资产进行大量处理,那么在您的主力代码中使用类型参数可以很容易地发现混淆它们的错误即使您通常存在于代码外层的类型。


1 从技术上讲,您的 Asset 意味着 Typeable,因此您可以针对特定类型测试它,然后 return 那些。

在查看了 and 的答案后,我最终结合使用了这两者解决了我的问题,希望它能对其他人有所帮助。

首先,根据 Daniel Wagner 的回答,我向 Reference 添加了一个 phantom 类型:

data Reference a = Ref {
    assetType:: String
  , assetIndex :: Int
  } deriving (Eq, Ord, Show, Generic)

我选择不使用 GADT 构造函数并将字符串引用保留给 assetType,因为我经常通过网络发送引用 and/or 从传入文本中解析它们。我觉得有太多代码点需要通用参考。对于这些情况,我用 Void:

填充幻像类型
{-# LANGUAGE EmptyDataDecls #-}
data Void
-- make this reference Generic
voidRef :: Reference a -> Reference Void
castRef :: a -> Reference b -> Reference a
--        ^^^ Note this can be undefined used only for its type

有了这个 load 类型签名变成 load :: Asset a => Reference a -> IO (Maybe a) 所以资产总是匹配参考的类型。 (耶类型安全!)

这仍然没有解决如何加载通用引用的问题。对于这些情况,我使用 Ben 回答的后半部分编写了一些新代码。通过将资产包装在 SomeAsset 中,我可以 return 一个让类型检查器满意的类型。

{-# LANGUAGE GADTs #-}

import Data.Aeson (encode)

loadGenericAsset :: Reference Void -> IO SomeAsset
loadGenericAsset ref =
  case assetType ref of
    "User" -> Some <$> load (castRef (undefined :: User) ref)
    "Port" -> Some <$> load (castRef (undefined :: Port) ref)
     [etc...]

send :: SomeAsset -> IO ()
send (Some a) = writeToUser (encode a)

data SomeAsset where 
  Some :: Asset a => a -> SomeAsset