使用连续的“要么/可能”时减少嵌套

Jiv*_*van 8 monads haskell flatten either maybe

这可能是一个非常基本的 Haskell 问题,但让我们假设以下函数签名

-- helper functions
getWeatherInfo :: Day -> IO (Either WeatherException WeatherInfo)
craftQuery :: WeatherInfo -> Either QueryException ModelQuery
makePrediction :: ModelQuery -> IO (Either ModelException ModelResult)
Run Code Online (Sandbox Code Playgroud)

将上述所有内容链接到一个predict day函数中的天真方法可能是:

predict :: Day -> IO (Maybe Prediction)
predict day = do
    weather <- getWeatherInfo day
    pure $ case weather of
        Left ex -> do
            log "could not get weather: " <> msg ex
            Nothing
        Right wi -> do
            let query = craftQuery wi
            case query of
                Left ex -> do
                    log "could not craft query: " <> msg ex
                    Nothing
                Right mq -> do
                    prediction <- makePrediction mq
                    case prediction of
                        Left ex -> do
                            log "could not make prediction: " <> msg ex
                            Nothing
                        Right p ->
                            Just p
Run Code Online (Sandbox Code Playgroud)

在更多命令式语言中,可以执行以下操作:

def getWeatherInfo(day) -> Union[WeatherInfo, WeatherError]:
    pass

def craftQuery(weather) -> Union[ModelQuery, QueryError]:
    pass

def makePrediction(query) -> Union[ModelResult, ModelError]:
    pass

def predict(day) -> Optional[ModelResult]:
    weather = getWeatherInfo(day)
    if isinstance((err := weather), WeatherError):
        log(f"could not get weather: {err.msg}")
        return None

    query = craftQuery weather
    if isinstance((err := query), QueryError):
        log(f"could not craft query: {err.msg}")
        return None

    prediction = makePrediction query
    if isinstance((err := prediction), ModelError):
        log(f"could not make prediction: {err.msg}")
        return None

    return prediction
Run Code Online (Sandbox Code Playgroud)

这在很多方面都可以说是不那么类型安全和笨重的,但也可以说,更平坦。我可以看到主要的区别在于,在 Python 中我们可以(是否应该是一个不同的故事)使用 make 多个早期return语句在任何阶段停止流程。但这在 Haskell 中是不可用的(无论如何,这看起来非常不习惯,并且首先会破坏使用该语言的全部目的)。

然而,在处理连续Either/Maybe一个接一个链接的相同逻辑时,是否有可能在 Haskell 中实现相同类型的“平坦度” ?

- 按照重复的建议进行编辑:

我可以看到另一个问题是如何相关的,但这只是(相关)——它没有回答这里公开的问题,即如何展平 3 级嵌套案例。此外,这个问题(这里)以比另一个问题更通用的方式暴露了这个问题,这是非常特定于用例的。与另一个问题相比,我想回答这个问题(此处)对社区中的其他读者有益。

我明白对于经验丰富的 Haskeller 来说,“只使用任何一个”听起来是一个完全有效的答案,这似乎是多么明显,但这里的重点是,这个问题是从一个不是经验丰富的 Haskeller 的人的角度提出的,也是阅读过的人的角度一遍又一遍地认为 Monad 转换器有其局限性,也许 Free monad 或 Polysemy 或其他替代方案是最好的,等等。我想这对整个社区来说是有用的,可以在这方面用不同的替代方案回答这个特定问题,所以刚开始面对更复杂的代码库时,Haskeller 新手会发现自己“迷失在翻译中”的情况会少一些。

lef*_*out 11

要“反向推断”monad 转换器是此处的正确工具,请考虑不需要 IO 的情况(例如,因为天气信息来自已在内存中的静态数据库):

getWeatherInfo' :: Day -> Either WeatherException WeatherInfo
craftQuery :: WeatherInfo -> Either QueryException ModelQuery
makePrediction' :: ModelQuery -> Either ModelException ModelResult
Run Code Online (Sandbox Code Playgroud)

你的例子现在看起来像

predict' :: Day -> Maybe Prediction
predict' day =
    let weather = getWeatherInfo' day
    in case weather of
        Left ex ->
            Nothing
        Right wi -> do
            let query = craftQuery wi
            in case query of
                Left ex ->
                    Nothing
                Right mq ->
                    let prediction = makePrediction' mq
                    in case prediction of
                        Left ex ->
                            Nothing
                        Right p ->
                            Just p
Run Code Online (Sandbox Code Playgroud)

几乎任何 Haskell 教程都使用Maybemonad的事实来解释如何将其展平:

predict' :: Day -> Maybe Prediction
predict' day = do
    let weather = getWeatherInfo' day
    weather' <- case weather of
      Left ex -> Nothing
      Right wi -> Just wi
    let query = craftQuery weather'
    query' <- case query of
      Left ex -> Nothing
      Right mq -> Just mq
    let prediction = makePrediction' query'
    prediction' <- case prediction of
      Left ex -> Nothing
      Right p -> Just p
    return prediction'
Run Code Online (Sandbox Code Playgroud)

这是一个有点尴尬总是绑定variableNamelet提取之前variableName'从单子。在这里它实际上是不必要的(您可以将getWeatherInfo' day自己放在case语句中),但请注意,更普遍的情况可能是这种情况:

predict' :: Day -> Maybe Prediction
predict' day = do
    weather <- pure (getWeatherInfo' day)
    weather' <- case weather of
      Left ex -> Nothing
      Right wi -> Just wi
    query <- pure (craftQuery weather')
    query' <- case query of
      Left ex -> Nothing
      Right mq -> Just mq
    prediction <- pure (makePrediction' query')
    prediction' <- case prediction of
      Left ex -> Nothing
      Right p -> Just p
    return prediction'
Run Code Online (Sandbox Code Playgroud)

关键是,你绑定的东西weather本身可能在Maybemonad 中。

避免本质上重复的变量名称的一种方法是使用 lambda-case 扩展名,这允许您 eta-reduce 其中一个。此外,JustNothing值仅是一个具体的例子pure,并empty与您得到这个代码:

{-# LANGUAGE LambdaCase #-}

import Control.Applicative

predict' :: Day -> Maybe Prediction
predict' day = do
    weather <- pure (getWeatherInfo' day) >>= \case
      Left ex -> empty
      Right wi -> pure wi
    query <- case craftQuery weather of
      Left ex -> empty
      Right mq -> pure mq
    prediction <- pure (makePrediction' query) >>= \case
      Left ex -> empty
      Right p -> pure p
    return prediction
Run Code Online (Sandbox Code Playgroud)

很好,但是你不能简单地在Maybemonad 中工作,因为你也有IOmonad 的效果。换句话说,你不想Maybe单子,而是将其短路财产上的顶部IO单子。因此,您转换IOmonad。您仍然可以普通的旧的非转换 IO 操作提升MaybeT堆栈中,并且仍然使用pureempty用于可能的情况,从而获得与没有 IO 时几乎相同的代码:

predict :: Day -> MaybeT IO Prediction
predict day = do
    weather <- liftIO (getWeatherInfo day) >>= \case
      Left ex -> empty
      Right wi -> pure wi
    query <- case craftQuery weather of
      Left ex -> empty
      Right mq -> pure mq
    prediction <- liftIO (makePrediction query) >>= \case
      Left ex -> empty
      Right p -> pure p
    return prediction
Run Code Online (Sandbox Code Playgroud)

最后,您现在可以更进一步,还可以使用转换器层以更好的方式处理日志。可以用WriterT. 与登录 IO 相比的优势在于日志不仅会在某个地方结束,而且您的函数的调用者会知道日志已创建,并且可以决定是将其放入文件中还是直接在终端上显示,或者干脆丢弃它。

但是由于您似乎总是只记录Nothing案例,因此更好的选择是根本不使用Maybe变压器,而是使用变压器Except,因为这似乎是您的想法:

import Control.Monad.Trans.Except

predict :: Day -> ExceptT String IO Prediction
predict day = do
    weather <- liftIO (getWeatherInfo day) >>= \case
      Left ex -> throwE $ "could not get weather: " <> msg ex
      Right wi -> pure wi
    query <- case craftQuery weather of
      Left ex -> throwE $ "could not craft query: " <> msg ex
      Right mq -> pure mq
    prediction <- liftIO (makePrediction query) >>= \case
      Left ex -> throwE $ "could not make prediction: " <> msg ex
      Right p -> pure p
    return prediction
Run Code Online (Sandbox Code Playgroud)

事实上,可能你的原语一开始就应该在那个 monad 中,然后它变得更加简洁:

getWeatherInfo :: Day -> ExceptT WeatherException IO WeatherInfo
makePrediction :: ModelQuery -> ExceptT ModelException IO WeatherInfo

predict day = do
    weather <- withExcept (("could not get weather: "<>) . msg)
       $ getWeatherInfo day
    query <- withExcept (("could not craft query: "<>) . msg)
        $ except (craftQuery weather)
    prediction <- withExcept (("could not make prediction: "<>) . msg)
        $ makePrediction query
    return prediction
Run Code Online (Sandbox Code Playgroud)

最后 - 最后请注意,您实际上并不需要绑定中间变量,因为您始终只是在下一个操作中传递它们。即,您有一个Kleisli 箭头的组合链:

predict = withExcept (("could not get weather: "<>) . msg)
                   . getWeatherInfo
      >=> withExcept (("could not craft query: "<>) . msg)
                   . except . craftQuery
      >=> withExcept (("could not make prediction: "<>) . msg)
                   . makePrediction
Run Code Online (Sandbox Code Playgroud)