如何使用 Monad Transformers 组合不同的(纯的和不纯的)monad?

How to use Monad Transformers to combine different (both pure and impure) monads?

我正在编写我的第一个 Haskell 应用程序,我很难理解 Monad 转换器的使用。

示例代码:

-- Creates a new user in the system and encrypts their password
userSignup :: Connection -> User -> IO ()
userSignup conn user = do
    -- Get the encrypted password for the user
    encrypted <- encryptPassword $ password user.     -- encryptPassword :: Text -> IO (Maybe Text)
    -- Updates the password to the encrypted password
    -- if encryption was successful
    let newUser = encrypted >>= (\x -> Just user { password = x })
    -- Inserts the user using helper function and gets the result
    result <- insertUser (createUser conn) newUser
    return result
    where
        insertUser :: (User -> IO ()) -> (Maybe User) -> IO ()
        insertUser insertable inuser = case inuser of
            Just u -> insertable u -- Insert if encryption was successful
            Nothing -> putStrLn "Failed to create user" -- Printing to get IO () in failure case

问题:

编辑: 更新答案以匹配您更新的问题。

需要说明的是,您实际上并没有在您的代码示例中使用 任何 monad 转换器。您只是将一个 monad 嵌套在另一个 monad 中。有关使用真正的 monad 转换器的示例 MonadT,请参阅我对第二个问题的回答。

对于你的第一个问题, 正如@David Young 评论的那样,你可以使用 return ():

showSuccess :: Bool -> IO ()
showSuccess success =
   if success then putStrLn "I am great!"
              else return ()    -- fail silently

更一般地说,如果一个函数 returns IO a 对于某种类型 a,那么你总是可以 return 一个 "pure" 值,它没有通过使用 return 函数关联的 IO 操作。 (这就是 return 的用途!)在函数 returning IO () 的情况下,类型 () 的唯一值是值 (),所以你唯一的选择是 return ()。对于 IO a 某些其他类型 a,您将需要 return 某些类型 a 的值。如果您想要 选项 到 return 一个值或不想要,您需要制作类型 IO (Maybe a) 或使用 MaybeT 转换器,因为下面。

对于你的第二个问题,你基本上是在问如何在 Maybe monad 中巧妙地表达嵌套计算:

let newUser = encrypted >>= (\x -> Just user { password = x })

在外部 IO monad 中。

一般来说,嵌套 monad 中的大量计算编写起来很痛苦,并且会导致难看、不清楚的代码。这就是发明 monad 转换器的原因。它们允许您从多个 monad 中借用设施并将它们捆绑在一个 single monad 中。然后,所有 bind (>>=) 和 return 操作,以及所有 do-syntax 都可以引用相同的单个 monad 中的操作,因此您不会在 "IO mode" 和 "Maybe mode"当你阅读和编写代码时。

重写您的代码以使用转换器涉及从 transformers 包中导入 MaybeT 转换器并定义您自己的 monad。你可以随意命名它,虽然你可能会输入很多,所以我通常使用简短的名称,比如 M.

import Control.Monad
import Control.Monad.Trans
import Control.Monad.Trans.Maybe

-- M is the IO monad supplemented with Maybe functionality
type M = MaybeT IO
nothing :: M a
nothing = mzero  -- nicer name for failing in M monad

然后,您可以按如下方式重写您的 userSignUp 函数:

userSignUp :: Connection -> User -> M ()
userSignUp conn user = do
  encrypted <- encryptPassword (password user)     -- encrypted  :: String
  let newUser = user { password = encrypted }      -- newUser    :: User
  insertUser <- createUser conn                    -- insertUser :: User -> M ()
  insertUser newUser

我在评论中添加了一些类型注释。请注意,新的 M monad 负责确保每个由 <- 运算符绑定的变量都已经过 Nothing 检查。如果任何步骤 returns Nothing,处理将被中止。如果步骤 returns Just xx 将自动展开。您通常不必处理(甚至看不到)NothingJust

您的其他函数也必须存在于 M monad 中,它们可以 return 一个值(成功)或指示失败,如下所示:

encryptPassword :: String -> M String
encryptPassword pwd = do
  epwd <- liftIO $ do putStrLn "Dear System Operator,"
                      putStrLn $ "Plaintext password was " ++ pwd
                      putStr $ "Please manually calculate encrypted version: "
                      getLine
  if epwd == "I don't know" then nothing   -- return failure
    else return epwd                       -- return success

请注意,他们可以使用 liftIO 将操作提升到底层 IO monad,因此所有 IO 操作都可用。否则,他们可以 return 纯值(通过 return)或使用 nothing(我对 mzero 的别名)在 MaybeT 层中发出故障信号。

现在唯一剩下的就是为 "run" 您的自定义 monad 提供一个工具(包括将其从 M a 转换为 IO a,因此您实际上可以 运行 它来自 main)。对于这个 monad,定义很简单,但最好定义一个函数,以防 M monad 更复杂:

runM :: M a -> IO (Maybe a)
runM = runMaybeT

下面包含一个带有存根代码的完整工作示例。

对于你的第三个问题, 使它成为 "more functional" 不一定会使它更容易理解,但我的想法是利用像 [= 这样的 monad 运算符61=] 或像 <*> 这样的应用运算符来模拟 monadic 上下文中的函数形式。以下将是我的 userSignUp 的 monad 转换器版本的等效 "more functional" 形式。不清楚这个比上面的命令式"do-notation"版本更容易理解,而且肯定更难写。

moreFunctionalUserSignUp :: Connection -> User -> M ()
moreFunctionalUserSignUp conn user
  = join $ createUser conn
           <*> (setPassword user <$> encryptPassword (password user))
  where
    setPassword u p = u { password = p }

你可以想象这大致等同于纯函数计算:

  createUser conn (setPassword user (encryptPassword (password user)))

但加入了正确的运算符,使其作为一元计算进行类型检查。 (为什么需要join?别问了。)

完整的 MaybeT 示例

import Control.Monad
import Control.Monad.Trans
import Control.Monad.Trans.Maybe

-- M is the IO monad supplemented with Maybe functionality
type M = MaybeT IO
nothing :: M a
nothing = mzero  -- nicer name for failing in M monad

runM :: M a -> IO (Maybe a)
runM = runMaybeT

data User = User { username :: String, password :: String } deriving (Show)
data Connection = Connection

userSignUp :: Connection -> User -> M ()
userSignUp conn user = do
  encrypted <- encryptPassword (password user)     -- encrypted  :: String
  let newUser = user { password = encrypted }      -- newUser    :: User
  insertUser <- createUser conn                    -- insertUser :: User -> M ()
  insertUser newUser

encryptPassword :: String -> M String
encryptPassword pwd = do
  epwd <- liftIO $ do putStrLn "Dear System Operator,"
                      putStrLn $ "Plaintext password was " ++ pwd
                      putStr $ "Please manually calculate encrypted version: "
                      getLine
  if epwd == "I don't know" then nothing   -- return failure
    else return epwd                       -- return success

createUser :: Connection -> M (User -> M ())
createUser conn = do
  -- some fake storage
  return (\user -> liftIO $ putStrLn $ "stored user record " ++ show user)

main :: IO ()
main = do username  <- putStr "Username: " >> getLine
          password  <- putStr "Password: " >> getLine

          let user = User username password
          result <- runM (userSignUp Connection user)

          case result of
            Nothing -> putStrLn "Something failed -- with MaybeT, we can't tell what."
            Just () -> putStrLn "Success!"