写一个灵活的 "string fetcher"

Writing a flexible "string fetcher"

我正在尝试编写一个允许从任意源(例如,内存列表、文件、数据库...等等)获取字符串的模块,到目前为止我有:

type StringFetcher m = String -> m String

listStringFetcher :: [(String, String)] -> StringFetcher Maybe
listStringFetcher list key = fst <$> (listToMaybe $ filter ((key ==) . fst) list)

fileStringFetcher :: FilePath -> StringFetcher (MaybeT IO)
fileStringFetcher fp key = undefined

然后我想当我在我的应用程序中使用它时,我可以拥有如下功能:

usage :: (MonadIO m) => StringFetcher m -> m ()
usage fetch = (fetch "usage") >>= (liftIO . putStrLn)

但后来我有点卡住了,当我尝试 运行 usage (listStringFetcher [("usage", "asdf")]) 时,我得到一个 "No instance for (MonadIO Maybe) arising from a use of usage" 错误。我不太确定应该如何访问 StringFetcher 的字符串 "inside"。所以我觉得这种方法可能不可行。有没有更合理的方式来做这样的事情?

编辑:为了更清楚地说明我要实现的目标,这是我的应用程序中的实际功能:

usage :: [Command] -> ExceptT String IO String
usage c =  pure . ununlines $ [
  "usage: xyz <command>",
  "Commands:",
  unlines $ fmap (\x ->"\t" ++ name (x :: Command) ++ ": " ++ description (x :: Command)) c
  ]

我不想像这样硬编码字符串 "usage: xyz <command>""Commands:"。我想做的是向函数添加另一个参数,其工作是使用键获取这些字符串。但我希望可以将 "String fetcher" 与不同的实现(可能涉及也可能不涉及 IO)互换。

我知道它不会直接回答您的问题,但可以作为指南。您的函数示例的具体实例是:

usage ::  StringFetcher IO -> IO ()
usage fetch = (fetch "usage") >>= ((liftIO . putStrLn) :: String -> IO ())

这会导致错误:

error:
    • Couldn't match type ‘Maybe String’ with ‘IO String’
      Expected type: StringFetcher IO
        Actual type: StringFetcher Maybe
    • In the first argument of ‘usage’, namely
        ‘(listStringFetcher [("usage", "asdf")])’
      In the expression: usage (listStringFetcher [("usage", "asdf")])
      In an equation for ‘it’:
          it = usage (listStringFetcher [("usage", "asdf")])

意思是您当前对 listStringFetcher 的定义永远不会与 liftIO 一起使用,因为它的类型是 well,IO,而不是 Maybe。

要么使用其他函数,而不是 liftIO,要么更改其他函数的定义

总结

这个回答比我预期的要长,所以我认为总结一下是必要的。对于这样一个笼统的问题,并且表达的意图比较模糊,解决方案的范围相当广泛。我鼓励不要使代码复杂化超过绝对必要,而是关注手头解决方案的可读性。使用像 Reader 这样的现有原语也使得代码对于其他阅读它的人来说不那么令人惊讶。如果这还不够,最后的解决方案在任何情况下都提供了最大的灵活性。


详情

您必须了解您的 StringFetcher 只是一个可能提供您想要的值的计算,但如果不实际评估该计算,您将无法获得该值。

您直接遇到的问题是,根据所使用的 StringFetcher 的类型,评估的上下文将取决于传递的值,这意味着,原则上,您不能在所有情况下都使用每个 StringFetcher。如果您假设像这样部分应用的提取器,这将更容易可视化:

command :: forall m. StringFetcher m -> m String
command fetch = fetch "command"

您在这里还没有真正做任何工作,您只是在进一步推进工作。您剩下的 m String 需要 m 才能被提取。然后您需要将 m 作为签名的一部分,有点像:

usage :: forall m. MonadIO m => StringFetcher m -> m ()
usage fetch = do
    cmdStr <- command fetch
    liftIO . putStrLn $ cmdStr

另一种方法是避免将提取器作为参数传递,而是使其成为上下文的一部分。如果您知道如何使用 ReaderT,这应该是显而易见的,但无论如何,对于其他任何人...

假设我们的字符串存储在Map String String中。然后我们可以利用 Reader 显式依赖这些字符串:

import qualified Data.Map as Map
type Strings = Map.Map String String 

usage :: Reader Strings ()
usage = do
    cmdStr <- fromMaybe "no description" . Map.lookup "command" <$> ask
    -- do whatever

由于我们不能在这个特定的函数中做太多“任何事情”,我们需要 ReaderT 来稍微增加它:

usage :: ReaderT Strings IO ()
usage = do
    cmdStr <- fromMaybe "no description" . Map.lookup "command" <$> ask
    liftIO . putStrLn $ cmdStr

急!我们现在对 Strings 和 IO 都有明确的依赖性,并且可以根据需要从外部提供字符串。这就是魔法发生的地方:

main :: IO ()
main = do
    let strings = Map.fromList [("command", "this is a command")]
    runReaderT usage strings

对于您的情况,您可能只想使用 askForCommand 创建自己的类型,而不是 Reader Strings,这样可以稍微缩短此代码并使其更明确。

事实是,如果您假设某处将有一个提取程序的 IO 变体,那么跳过所有这些箍是有点愚蠢的。如果是这种情况,您可能只是假设它总是需要 IO,而在预定义字符串列表的情况下,它不会触及它。这将大大简化实施,而不会造成任何实际损失。

如果您 坚持 使用基于 Fetcher 本身的 Fetcher 更改(此处重点)代码的实际类型(例如 usage 使用纯 fetcher 是pure,而 usage 使用 IO fetcher 是 IO1),你需要一个 Transformer Monad Class:

usage :: forall m. => (MonadStringFetcher m, MonadIO) -> m ()

如果 usage 无论如何都在使用 IO(也按照我之前所说的),那么这个例子确实非常愚蠢,但如果没有,则不必。

同样,MonadReader class 可以作为实施它的良好模板。这种方法与“效果”非常相似(如在 Idris 中所见),并允许您直接从预期功能构建上下文类型。这是编写此类内容的最灵活方式,但也是最耗时的方式,一旦开始添加更多内容,它就会变得非常复杂。


1 这讨论了从 Fetcher 继承的 usage 的要求,而不是像我使用的 putStrLn 那样的内部结构。您可能会想到这里有两个不同的 IO