过于冗长的Haskell函数简化

3
我编写了一个查询API货币汇率的函数。它可以正常工作,但代码太长、难以阅读。我认为有人能够帮助我简化它,特别是因为存在许多重复的模式和操作符,比如反复使用 pure
编辑:我没有意识到将任何东西绑定到pure是完全无用的!
... <&> (=<<) (something >>= pure) ...

我刚开始学习Haskell,所以不知道有哪些可用的聪明操作符/函数/镜头。

顺便说一下,我知道do-notation的存在。

forex :: (String, String) -> IO (Maybe (Scientific, UnixTime))
forex cp = (get ("https://www.freeforexapi.com/api/live?pairs=" ++ uncurry (++) cp) <&> decode . flip (^.) responseBody <&> (=<<) (parseMaybe (.: "rates") >>= pure) :: IO (Maybe (Map Key (Map Key Scientific)))) <&> (=<<) (Data.Map.lookup (fromString (uncurry (++) cp)) >>= pure) <&> (=<<) ((pure . toList) >>= pure) <&> (=<<) (pure . map snd >>= pure) <&> fmap (\y -> (head y, UnixTime ((CTime . fromRight 0 . floatingOrInteger) (y !! 1)) 0))

接收到的JSON如下:
{"rates":{"EURUSD":{"rate":1.087583,"timestamp":1649600523}},"code":200}

事先感谢您。


每当你看到像这样的重复模式时,通常可以(而且应该)将其提取到自己的函数中,然后可以重复使用。例如,pat f = (=<<) (f >>= pure)。然后,不必每次都编写它,只需使用 pat 即可。 - David Young
将您的解析分为两个阶段,一个在aeson的解析器单子中,另一个在Maybe中。考虑合并它们,使所有操作都在aeson的单子中完成;例如,do { rates <- o .: "rates"; current <- rates .: fromString (c ++ p); rate <- current .: "rate"; timestamp <- current .: "timestamp"; pure (rate, UnixTime (CTime timestamp) 0) } :: Parser (Scientific, UnixTime) 可以让您省略很多与 MaybefmapEither 相关的繁琐操作;它还可以更可预测地处理格式不正确或格式出乎意料的JSON数据,而不像您的 toListhead(!!) 那样。 - Daniel Wagner
1个回答

13

哇,这段代码有点太长了。我们来一步步看,最终会得到以下的代码片段,我觉得这个更加自然易读,但仍然能够执行完全相同的计算:

forex (c, p) = extractFirstTime c p
    <$> get ("https://www.freeforexapi.com/api/live?pairs=" ++ c ++ p)

extractFirstTime c p response = firstTime
    <$> parseAndLookUp c p (response ^. responseBody)

parseAndLookUp c p body =
    decode body >>=
    parseMaybe (.: "rates") >>=
    Data.Map.lookup (fromString (c ++ p))

firstTime = case Data.Map.elems m of
    k:t:_ -> (k, UnixTime ((CTime . fromRight 0 . floatingOrInteger) t) 0)

让我们看看如何做到这一点。
  1. To start, I think it's easier to see and edit if there are strategically chosen line breaks.

    forex cp =
        (get ("https://www.freeforexapi.com/api/live?pairs=" ++ uncurry (++) cp)
            <&> decode . flip (^.) responseBody
            <&> (=<<) (parseMaybe (.: "rates") >>= pure)
            :: IO (Maybe (Map Key (Map Key Scientific)))
        )
        <&> (=<<) (Data.Map.lookup (fromString (uncurry (++) cp)) >>= pure)
        <&> (=<<) ((pure . toList) >>= pure)
        <&> (=<<) (pure . map snd >>= pure)
        <&> fmap (\y -> (head y, UnixTime ((CTime . fromRight 0 . floatingOrInteger) (y !! 1)) 0))
    
  2. One of the monad laws is m >>= pure = m, so let's delete >>= pure everywhere. (One each on lines 4, 7, 8, and 9.)

    forex cp =
        (get ("https://www.freeforexapi.com/api/live?pairs=" ++ uncurry (++) cp)
            <&> decode . flip (^.) responseBody
            <&> (=<<) (parseMaybe (.: "rates"))
            :: IO (Maybe (Map Key (Map Key Scientific)))
        )
        <&> (=<<) Data.Map.lookup (fromString (uncurry (++) cp))
        <&> (=<<) (pure . toList)
        <&> (=<<) (pure . map snd)
        <&> fmap (\y -> (head y, UnixTime ((CTime . fromRight 0 . floatingOrInteger) (y !! 1)) 0))
    
  3. Another monad law is m >>= pure . f = fmap f m. Let's simplify with that law where possible. (One each on lines 8 and 9.)

    forex cp =
        (get ("https://www.freeforexapi.com/api/live?pairs=" ++ uncurry (++) cp)
            <&> decode . flip (^.) responseBody
            <&> (=<<) (parseMaybe (.: "rates"))
            :: IO (Maybe (Map Key (Map Key Scientific)))
        )
        <&> (=<<) Data.Map.lookup (fromString (uncurry (++) cp))
        <&> fmap toList
        <&> fmap (map snd)
        <&> fmap (\y -> (head y, UnixTime ((CTime . fromRight 0 . floatingOrInteger) (y !! 1)) 0))
    
  4. The uses of uncurry are happening because we're not pattern-matching on cp. Let's fix that up. (Lines 1, 2, and 7.)

    forex (c, p) =
        (get ("https://www.freeforexapi.com/api/live?pairs=" ++ c ++ p)
            <&> decode . flip (^.) responseBody
            <&> (=<<) (parseMaybe (.: "rates"))
            :: IO (Maybe (Map Key (Map Key Scientific)))
        )
        <&> (=<<) Data.Map.lookup (fromString (c ++ p))
        <&> fmap toList
        <&> fmap (map snd)
        <&> fmap (\y -> (head y, UnixTime ((CTime . fromRight 0 . floatingOrInteger) (y !! 1)) 0))
    
  5. My mental type-checker is going nuts. Let's split this calculation into three different kinds of things: one that works in IO, one that works in Maybe, and one that is pure. First let's split the IO from everything else.

    forex (c, p) = extractFirstTime c p
        <$> get ("https://www.freeforexapi.com/api/live?pairs=" ++ c ++ p)
    
    extractFirstTime c p response = response
        & decode . flip (^.) responseBody
        & (=<<) (parseMaybe (.: "rates"))
        & (=<<) Data.Map.lookup (fromString (c ++ p))
        & fmap toList
        & fmap (map snd)
        & fmap (\y -> (head y, UnixTime ((CTime . fromRight 0 . floatingOrInteger) (y !! 1)) 0))
    
  6. Now let's split out the Maybe parts.

    forex (c, p) = extractFirstTime c p
        <$> get ("https://www.freeforexapi.com/api/live?pairs=" ++ c ++ p)
    
    extractFirstTime c p response = parseAndLookUp c p (response ^. responseBody)
        & fmap toList
        & fmap (map snd)
        & fmap (\y -> (head y, UnixTime ((CTime . fromRight 0 . floatingOrInteger) (y !! 1)) 0))
    
    parseAndLookUp c p body =
        decode body >>=
        parseMaybe (.: "rates") >>=
        Data.Map.lookup (fromString (c ++ p))
    
  7. And let's split out the pure parts. One of the functor laws is fmap f . fmap g = fmap (f . g), so we can merge the three fmaps in extractFirstTime. At that point, the two arguments to (&) that remain are short enough that we can inline the definition of (&). I'll also use the name (<$>) instead of fmap; I think it reads a bit clearer.

    forex (c, p) = extractFirstTime c p
        <$> get ("https://www.freeforexapi.com/api/live?pairs=" ++ c ++ p)
    
    extractFirstTime c p response = firstTime
        <$> parseAndLookUp c p (response ^. responseBody)
    
    parseAndLookUp c p body =
        decode body >>=
        parseMaybe (.: "rates") >>=
        Data.Map.lookup (fromString (c ++ p))
    
    firstTime m = m
        & toList
        & map snd
        & (\y -> (head y, UnixTime ((CTime . fromRight 0 . floatingOrInteger) (y !! 1)) 0))
    
  8. Data.Map has a name for map snd . toList, namely, elems. Instead of using head and !!, let's use pattern matching to pick out the elements we want. (All changes are in firstTime.)

    forex (c, p) = extractFirstTime c p
        <$> get ("https://www.freeforexapi.com/api/live?pairs=" ++ c ++ p)
    
    extractFirstTime c p response = firstTime
        <$> parseAndLookUp c p (response ^. responseBody)
    
    parseAndLookUp c p body =
        decode body >>=
        parseMaybe (.: "rates") >>=
        Data.Map.lookup (fromString (c ++ p))
    
    firstTime = case Data.Map.elems m of
        k:t:_ -> (k, UnixTime ((CTime . fromRight 0 . floatingOrInteger) t) 0)
    

可能还有其他可以做的美化工作(例如添加类型签名,我有几个想法可以改变/改进代码的行为),但我认为到这个时候,你已经有了一个相当合理的阅读和理解的东西。一路上,使事物可读性强,副作用是消除了你发现令人不安的重复代码片段,所以这是一个小奖励;但如果它们仍然存在,尝试将它们作为额外步骤来解决是非常自然的。


哇,谢谢你的非常好的回答! 现在唯一让我有点烦恼的是处理嵌套单子类型的方式, 在这种情况下是 IO (Maybe ...),难道没有任何运算符可以消除使用此类类型时双重绑定的需要吗? - Fabus1184
此外,我是否应该从 IO(可能...) 切换到 Maybe(IO...)? - Fabus1184
1
@Fabus1184看看这个关于避免缩进的问题的 问答,了解如何使用Monad转化器来避免双重绑定。但是我不认为在这里特别习惯。您不应该从“IO(Maybe ...)”切换到“Maybe(IO ...)”,因为后者承诺知道它是否“Just”或“Nothing”而无需执行任何“IO”,而这是您不可能实现的承诺。 - Daniel Wagner

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接