haskell 如何在同一个do表达式中使用两个不同的monad?

ddrv8njm  于 12个月前  发布在  其他
关注(0)|答案(2)|浏览(134)

为了练习我的Haskell,我决定写一个小的JSON解析器。在主文件中,我调用解析器的不同部分,打印结果,这样我就有更多的调试信息,然后把解析后的JSON写回一个文件:

{-# LANGUAGE OverloadedStrings #-}

module Main where

import qualified Lexer as L
import qualified Parser as P
import qualified Printer as PR
import qualified Data.Text.Lazy.IO as TIO

main :: IO ()
main = do
    input  <- TIO.readFile "input.json"
    case L.tokenize input of
        Nothing -> putStrLn "Syntax error!"
        Just tokens -> do
            print tokens
            case P.parse tokens of
                Nothing -> putStrLn "Parse error!"
                Just parsedValue -> do
                    print parsedValue
                    TIO.writeFile "output.json" $ PR.toText parsedValue

字符串
不幸的是,我得到了这个丑陋的嵌套代码,我在其中使用了多个do表达式。在我的理解中,使用monad和do表示法的主要原因之一是避免这种代码嵌套。例如,我可以使用Maybe monad来评估不同的解析步骤。(lexing,解析)而不需要单独检查每一步的成功。遗憾的是,在这个例子中这是不可能的,因为我需要使用诸如print和writeFile之类的函数,需要IO monad的函数与需要Maybe monad的函数交替使用。
我如何重构这段代码以减少嵌套并包含更少的do表达式?或者更一般地说,我如何编写包含对不同monad函数的调用的干净代码?是否有可能在同一个do表达式中“混合”两个monad,有点像这样?

main :: IO ()
main = do
    input  <- TIO.readFile "input.json"
    tokens <- L.tokenize input
    print tokens
    parsedValue <- P.parse tokens
    print parsedValue
    TIO.writeFile "output.json" $ PR.toText parsedValue

nmpmafwu

nmpmafwu1#

首先,关于do符号的良好直觉!在本例中,您希望将Either String monad与IO monad合并组合在一起。结果将是一个新的monad,其中您将得到一个平坦的do-块。(注意,你不需要Maybe,因为Maybe不允许你记录错误信息。)Either StringIO的组合单子被称为ExceptT String IO,其中,ExceptTtransformers包中定义的以下类型(任何GHC安装都应附带此包)。

newtype ExceptT e m a = ExceptT (m (Either e a))
instance Monad m => Monad (ExceptT e m) -- and other instances

字符串
你会想要和一个函数一起使用,

orError :: Functor f => e -> f (Maybe a) -> ExceptT e f a
orError err x = ExceptT $ maybe (Left err) Right <$> x


它用一个给定的错误消息来注解Maybe的“无信息”故障,还有一个函数,

printingError :: ExceptT String IO () -> IO ()
printingError x = do
  result <- runExceptT x
  case result of
    Left err -> putStrLn err
    Right _  -> pure ()


它“处理”ExceptT String效果,只留下IO。您还需要该函数(在transformers中定义)

lift :: IO a -> ExceptT e IO a


以将现有的IO操作放入这个新的monad中。

main :: IO ()
main = printingError $ do
    input  <- lift $ TIO.readFile "input.json"
    tokens <- orError "Syntax error!" $ L.tokenize input
    lift $ print tokens
    parsedValue <- orError "Parse error!" $ P.parse tokens
    lift $ print parsedValue
    lift $ TIO.writeFile "output.json" $ PR.toText parsedValue


另一种解决方案是使用一个函数,如

orError :: String -> Maybe a -> IO a
orError err Nothing    = ioError $ userError err -- in System.IO.Error
orError err (Just ret) = pure ret

-- in which case
main :: IO ()
main = do
    input  <- TIO.readFile "input.json"
    tokens <- orError "Syntax error!" =<< L.tokenize input
    print tokens
    parsedValue <- orError "Parse error!" =<< P.parse tokens
    print parsedValue
    TIO.writeFile "output.json" $ PR.toText parsedValue


这利用了IO已经具有内置异常机制的事实。(我认为这是一个问题),这是一个行动可能产生的错误是不明确的类型了,因此没有实施结构良好的错误处理。(例如,请注意,我不再被迫使用像printingError这样的函数来处理我的异常。异常只是冒泡,越过main,并由运行时系统处理。)
(NB:我没有测试过这个答案中的任何内容。如果有错误,请原谅。)

vddsk6oq

vddsk6oq2#

这实际上是你偶然发现的一个非常大的问题,在某种程度上,它仍然是一个活跃的研究领域。
问题很简单,如果我有一个Functor f和一个Functor g,我可以得到一个Functor (Compose f g)

data Compose f g a = Compose { runCompose :: f (g a) }

instance (Functor f, Functor g) => Functor (Compose f g) where
    fmap f (Compose x) = Compose $ fmap (fmap f) x

字符串
也就是说,两个Functor的组合是一个Functor
同样,如果我有一个Applicative f和一个Applicative g,那么它们的组合也是一个Applicative

instance (Applicative f, Applicative g) => Applicative (Compose f g) where
    pure = Compose . pure . pure
    Compose ff <*> Compose xx = Compose (fmap (<*>) ff <*> xx)


然而,如果我有一个Monad f和一个Monad g,那么我们并不清楚如何生成一个Monad (Compose f g)。事实上,一般来说,两个单子的组合不是一个单子。我们当然可以尝试。

-- Does NOT compile!
instance (Monad f, Monad g) => Monad (Compose f g) where
    return = pure
    Compose x >>= f = Compose $ ??? $ fmap (fmap f) x


但是我们不能填充???。无论我们如何尝试,一旦我们应用f,我们将得到f (g (Compose f g b))(直到同构,f (g (f (g b))),我们需要Compose f g b(equiv,f (g b)))。
每个monad都可以join,也就是说,每个monad都可以将其自身的两层扁平化为一层

join :: Monad m => m (m a) -> m a


但是我们有一个f (g (f (g b))),想要一个f (g b)。如果 * 只有 * 我们可以交换中间的gf。我们可以

f (g (f (g b))) -> f (f (g (g b))) -> f (g b)
                ^                  ^
                Some new           |
                operation          Joins


从根本上说,找出哪些monad可以组合就是识别哪些fg可以进行中间操作。

forall b. g (f b) -> f (g b)


如果gIOf是一个纯单子(读作:不做IO),那么这个签名是

forall b. IO (f b) -> f (IO b)


如果没有unsafePerformIO,这是不可能的,我也不会写一个依赖于unsafePerformIOMonad示例。所以Compose f IO一般不是Monad(尽管Compose IO g经常是一个)。
这个问题有几种不同的解决方案。最简单的是monad transformers,由transformers包实现。transformers稍微改变了框架。我们不是写“monad”,而是为monad写一种工厂,称为“monad transformer”。在没有外部包的vanilla Haskell中,我们只说Either e :: * -> *Monad

data Either e a = Left e | Right a


transformers下,我们将其 Package 在额外的间接层中。

data ExceptT e m a = ExceptT (m (Either e a))


然后我们说,而Either e :: * -> *是一个MonadExceptT e :: (* -> *) -> * -> *是一个 monad Transformer。也就是说,给定一个monad m,我们可以应用ExceptT e来得到一个monad ExceptT e m,这个monad是在minside 上合成一个Either的结果。它是一个从monad到monad的Map。
我们可以使用与Compose相同的技术为ExceptT e m获取FunctorApplicative示例。

instance Functor m => Functor (ExceptT e m) where
    -- Ordinary Functor composition
    fmap f (ExceptT x) = ExceptT $ fmap (fmap f) x

instance Applicative m => Applicative (ExceptT e m) where
    -- Ordinary Applicative composition
    pure = ExceptT . pure . Right
    ExceptT ff <*> ExceptT xx = ExceptT (fmap (<*>) ff <*> xx)


现在,当我们为我们的(尝试的)Monad示例写同样的东西时,我们说

instance Monad m => Monad (ExceptT e m) where
    return = pure
    ExceptT x >>= f = ExceptT $ ??? $ fmap (fmap f) x


这个未知的???的类型是m (Either e (ExceptT e m b)) -> m (Either e b),我们实际上可以这样写。

instance Monad m => Monad (ExceptT e m) where
    return = pure
    ExceptT x >>= f = ExceptT $ go $ fmap (fmap f) x
        where go value = value >>= \value' ->
                         case value' of
                           Left e -> pure (Left e)
                           Right (ExceptT ma) -> ma


如果你想知道我是怎么想到go函数的,我建议你自己尝试一下,你想要一个m (Either e (ExceptT e m b)) -> m (Either e b)类型的函数,所以只要开始尝试写它并遵循类型错误。
所以现在我们有一种方法,给定一个任意的单子m,得到一个单子,它是m * 加上 * Either的效果,而ExceptT是存在于transformers中的一个真实的类型。
如果你想将Either e a转换为ExceptT e m a(也就是说,在不使用m的情况下添加m的效果),那就是except。如果你想将m转换为ExceptT e m a,那就是lift。所以你建议的main函数看起来像这样

-- Not exact code, just an approximation to give you an idea.
mainExcept :: ExceptT String IO ()
mainExcept = do
    input  <- lift $ TIO.readFile "input.json"
    tokens <- except $ L.tokenize input
    lift $ print tokens
    parsedValue <- except $ P.parse tokens
    lift $ print parsedValue
    lift $ TIO.writeFile "output.json" $ PR.toText parsedValue


现在,你可能已经注意到了,这有点冗长了。没有人想一直在lift . lift . except上运行他们所有的代码。这就是最前沿的地方:有几种不同的方法可以减少噪音。

  • mtl(monad Transformer库)定义了一堆类型类,它们“神奇地”插入lift调用以使类型对齐。基本上,我们利用Haskell非常强大的类型推断和类型类解析系统来为我们插入样板。
  • polysemy使用了一个名为 free monads 的范畴论结构,本质上是一个名为Sem的“超级monad”,它能够以一种非常抽象的方式表示效果。

整本书都可以写关于如何使这符合人体工程学,但它都归结为你在这里提出的基本问题:“我有两个单子,我想把它们挤在一起,得到另一个单子”

相关问题