使用连续 Either/Maybe 时减少嵌套

Reduce nestedness when using successive Either/Maybe

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

-- helper functions
getWeatherInfo :: Day -> IO (Either WeatherException WeatherInfo)
craftQuery :: WeatherInfo -> Either QueryException ModelQuery
makePrediction :: ModelQuery -> IO (Either ModelException ModelResult)

将上述所有内容链接成一个 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

在命令式语言中,可以这样做:

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

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

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

-- EDIT following the duplicate suggestion:

I can see how the other question is related, but it's only that (related) — it doesn't answer the question exposed here which is how to flatten a 3-level nested case. Furthermore this question (here) exposes the problem in a much more generic manner than the other one, which is very use-case-specific. I guess answering this question (here) would be beneficial to other readers from the community, compared to the other one.

I understand how obvious it seems to be for seasoned Haskellers that "just use EitherT" sounds like a perfectly valid answer, but the point here is that this question is asked from the perspective of someone who is not a seasoned Haskeller, and also who's read over and again that Monad transformers have their limitations, and maybe Free monad or Polysemy or other alternatives would be best, etc. I guess this would be useful for the community at large to have this specific question answered with different alternatives in that regard, so the newbie Haskeller can find himself slightly less "lost in translation" when starting to be confronted with more complex codebases.

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

getWeatherInfo' :: Day -> Either WeatherException WeatherInfo
craftQuery :: WeatherInfo -> Either QueryException ModelQuery
makePrediction' :: ModelQuery -> Either ModelException ModelResult

你的例子现在看起来像

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

几乎任何 Haskell 教程都解释了如何将其展平,使用 Maybe 是一个 monad 的事实:

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'

在从 monad 中提取 variableName' 之前总是将 variableNamelet 绑定有点尴尬。这里其实是没有必要的(可以直接把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'

重点是,您要绑定到 weather 的东西本身可能在 Maybe monad 中。

避免本质上重复的变量名的一种方法是使用 lambda-case 扩展名,这使您可以通过 eta-reduce 去掉其中一个。此外,JustNothing 值只是 pureempty 的一个具体示例,您可以使用它们获得此代码:

{-# 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

不错,但是您不能在 中仅使用 Maybe monad,因为您还具有 IO monad 的效果。换句话说,您不希望 Maybe 成为 monad,而是将其短路 属性 放在 IO 之上单子。因此,您 转换 IO monad。您仍然可以 lift 普通旧的未转换的 IO 操作进入 MaybeT 堆栈,并且仍然使用 pureempty 作为可能,从而获得几乎相同的代码没有 IO:

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

最后,您现在可以更进一步,还可以使用转换器层以更好的方式处理您的日志记录。可以用 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

的确,也许你的基元一开始就应该在那个 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

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

的组合链
predict = withExcept (("could not get weather: "<>) . msg)
                   . getWeatherInfo
      >=> withExcept (("could not craft query: "<>) . msg)
                   . except . craftQuery
      >=> withExcept (("could not make prediction: "<>) . msg)
                   . makePrediction