【问题标题】:Modular Program Design - Combining Monad Transformers in Monad Agnostic functions模块化程序设计 - 在 Monad 不可知函数中组合 Monad Transformers
【发布时间】:2026-01-07 10:30:01
【问题描述】:

我正在尝试进行模块化程序设计,我再次请求您的帮助。

作为这些后续帖子Monad Transformers vs passing ParametersLarge Scale Design in Haskell 的后续工作,我正在尝试构建两个独立的模块,它们使用 Monad Transformers 但公开与 Monad 无关的函数,然后将其中每个模块中的一个与 Monad 无关的函数组合起来模块到一个新的 Monad-agnostic 函数中。

我一直无法运行组合功能,例如如何在下面的示例中使用runReaderT 调用mainProgram?。

次要问题是:有没有更好的方法来实现相同的模块化设计目标?


该示例有两个模拟模块(但可以编译),一个执行日志记录,一个读取用户输入并对其进行操作。组合函数读取用户输入,记录并打印。

{-# LANGUAGE FlexibleContexts #-}

module *2 where

import Control.Monad.Reader

----
---- From Log Module - Writes the passed message in the log
---- 

data LogConfig = LC { logFile :: FilePath }

doLog :: (MonadIO m, MonadReader LogConfig m) => String -> m ()
doLog _ = undefined


----
---- From UserProcessing Module - Reads the user Input and changes it to the configured case
----

data  MessageCase = LowerCase | UpperCase deriving (Show, Read)

getUserInput :: (MonadReader MessageCase m, MonadIO m) => m String
getUserInput = undefined

----
---- Main program that combines the two
----                  

mainProgram :: (MonadReader MessageCase m, MonadReader LogConfig m, MonadIO m) => m ()
mainProgram = do input <- getUserInput
                 doLog input
                 liftIO $ putStrLn $ "Entry logged: " ++ input

【问题讨论】:

    标签: haskell monad-transformers


    【解决方案1】:

    有一种方法可以编写程序的完全模块化版本。解决问题所需的方法是将阅读器配置捆绑到一个数据结构中,然后定义类型类来描述特定功能对该数据结构所需的部分接口。例如:

    class LogConfiguration c where
      logFile :: c -> FilePath
    
    doLog :: (MonadIO m, LogConfiguration c, MonadReader c m) => String -> m ()
    doLog = do
      file <- asks logFile
      -- ...
    
    class MessageCaseConfiguration c where
      isLowerCase :: c -> Bool
    
    getUserInput :: (MonadIO m, MessageCaseConfiguration c, MonadReader c m) => m String
    getUserInput = do
      lc <- asks isLowerCase
      -- ...
    
    data LogConfig = LC { logConfigFile :: FilePath }
    data MessageCase = LowerCase | UpperCase
    
    data Configuration = Configuration { logging :: LogConfig, casing :: MessageCase }
    
    instance LogConfiguration Configuration where
      logFile = logConfigFile . logging
    
    instance MessageCaseConfiguration Configuration where
      isLowerCase c = case casing c of
        LowerCase -> True
        UpperCase -> False
    
    mainProgram :: (MonadIO m, MessageCaseConfiguration c, LogConfiguration c, MonadReader c m) => m ()
    mainProgram = do
      input <- getUserInput
      doLog input
      liftIO . putStrLn $ "Entry logged: " ++ input
    

    现在您可以在ReaderT monad 中使用Configuration 调用mainProgram,它会按您的预期工作。

    【讨论】:

    • 令人印象深刻!谢谢你。所以“诀窍”是组合 Monad Transformers,而是将所有数据结构组合成一个(在Configuration 中)并依靠类型类来公开“API”。出于好奇,这是设计 Haskell 应用程序的常见模式吗?
    • 不,对于像这样的小配置问题,需要这种通用且松耦合的设计并不常见;然而,当更大的系统需要这种级别的模块化时,这就是使用的模式。例如,请参阅 Snap Web 框架以及如何配置 Snaplets。
    • 好的。非常感谢。多年来,我从事大型 Java 应用程序的工作,对使用“标准”模式编写模块以促进其维护和提高其可重用性变得敏感。
    • 我接受了 shang 的回答——他是第一个回答并涵盖了我问题的第一部分的人——但我希望我也能接受你的回答,它提供了一个通用的机制。再次感谢您。
    【解决方案2】:

    您的mainProgram 签名有问题,因为MonadReader 类型类包含函数依赖MonadReader r m | m -&gt; r。这实质上意味着单个具体类型不能有多个不同类型的MonadReader 实例。因此,当您说 m 类型同时具有 MonadReader MessageCaseMonadReader LogConfig 实例时,它违反了依赖声明。

    最简单的解决方案是将mainProgram 更改为非泛型类型:

    mainProgram :: ReaderT MessageCase (ReaderT LogConfig IO) ()
    mainProgram = do input <- getUserInput
                     lift $ doLog input
                     liftIO $ putStrLn $ "Entry logged: " ++ input
    

    这也需要为doLog 显式lift

    现在您可以通过分别运行每个ReaderT 来运行mainProgram,如下所示:

    main :: IO ()
    main = do
        let messageCase = undefined :: MessageCase
            logConfig   = undefined :: LogConfig
        runReaderT (runReaderT mainProgram messageCase) logConfig
    

    如果您想要一个使用两个不同 MonadReader 实例的通用函数,您需要在签名中明确说明一个阅读器是另一个阅读器之上的单子转换器。

    mainProgram :: (MonadTrans mt, MonadReader MessageCase (mt m), MonadReader LogConfig m, MonadIO (mt m), MonadIO m) => mt m ()
    mainProgram = do input <- getUserInput
                     lift $ doLog input
                     liftIO $ putStrLn $ "Entry logged: " ++ input
    

    但是,不幸的是,函数不再是完全通用的,因为两个读取器出现在 monad 堆栈中的顺序是锁定的。也许有一种更简洁的方法来实现这一点,但我无法在不牺牲(甚至更多)通用性的情况下从头顶想出一个。

    【讨论】:

    • 谢谢;关于为什么mainProgram 不是“可运行”的非常清楚的解释。这就是我所担心的:您必须要么放弃通用性,要么将一个 Monad Transformer 封装在另一个和 lift 中。我确信这两种解决方案都能满足我 99% 的需求,但很糟糕的是,通过简单地组合其他通用模块来构建 n 级通用模块并非易事。
    • 我接受了您的回答,因为您是第一个回复的人,您解释了为什么我的示例无法运行,您涵盖了常见情况,并且您的回答在很大程度上得到了社区的支持。再次感谢您,但我希望我也能接受 dflemstr 的回复,因为他通过提供通用机制回答了问题的第二部分。