Идиоматический способ обработки вложенного ввода-вывода в Haskell

Я изучаю Haskell и пишу короткую парсинг script как упражнение. Большинство моих script состоят из чистых функций, но у меня есть два вложенных компонента IO:

  • Прочитайте список файлов с пути.
  • Прочитайте содержимое каждого файла, который, в свою очередь, будет использоваться для большей части остальной части программы.

У меня есть работы, но вложенные IO и слои fmap "чувствуют" неуклюжими, например, я должен либо избегать вложенного ввода-вывода (как-то), либо более умело использовать обозначения, чтобы избежать всех fmaps. Мне интересно, если я слишком усложняю ситуацию, делаю это неправильно и т.д. Вот какой-то соответствующий код:

getPaths :: FilePath -> IO [String]
getPaths folder = do
    allFiles <- listDirectory folder
    let txtFiles = filter (isInfixOf ".txt") allFiles
        paths = map ((folder ++ "/") ++) txtFiles
    return paths

getConfig :: FilePath -> IO [String]
getConfig path = do
    config <- readFile path
    return $ lines config

main = do
    paths = getPaths "./configs"
    let flatConfigs = map getConfigs paths
        blockConfigs = map (fmap chunk) flatConfigs
    -- Parse and do stuff with config data.
    return

В итоге я столкнулся с IO [IO String] с помощью listDirectory в качестве входного для readFile. Не неуправляемый, но если я использую обозначение, чтобы развернуть [IO String] для отправки какой-либо функции парсера, я все равно в конечном итоге либо использую вложенный fmap, либо загрязняю мои якобы чистые функции с помощью IO-осведомленности (fmap и т.д.). Последнее кажется хуже, поэтому я делаю первое. Пример:

type Block = [String]
getTrunkBlocks :: [Block] -> [Block]
getTrunkBlocks = filter (liftM2 (&&) isInterface isMatchingInt)
    where isMatchingInt line = isJust $ find predicate line
          predicate = isInfixOf "switchport mode trunk"

main = do
    paths <- getPaths "./configs"
    let flatConfigs = map getConfig paths
        blockConfigs = map (fmap chunk) flatConfigs
        trunks = fmap (fmap getTrunkBlocks) blockConfigs
    return $ "Trunk count: " ++ show (length trunks)

fmap, fmap, fmap... Я чувствую, что я непреднамеренно сделал это более сложным, чем это необходимо, и не могу представить, насколько это было бы запутанно, если бы я имел более глубокое вложение IO.

Предложения?

Спасибо заранее.

Ответ 1

Я думаю, вам нужно что-то подобное для вашего main:

main = do
    paths <- getPaths "./configs"
    flatConfigs <- traverse getConfig paths
    let blockConfigs = fmap chunk flatConfigs
    -- Parse and do stuff with config data.
    return ()

Сравнить

fmap :: Functor f => (a -> b) -> f a -> f b

и

traverse :: (Applicative f, Traversable t) => (a -> f b) -> t a -> f (t b)

Они очень похожи, но traverse позволяет использовать такие эффекты, как IO.

Вот типы, которые еще раз специализировались для сравнения:

fmap     :: (a -> b)    -> [a] -> [b]
traverse :: (a -> IO b) -> [a] -> IO [b]

(traverse также известен как mapM)

Ответ 2

Ваша идея "вложенности" на самом деле довольно хорошее представление о том, что такое монады. Монады можно рассматривать как Функторы с двумя дополнительными операциями, возвращать с типом a -> m a и присоединяться к типу m (m a) -> m a. Затем мы можем выполнять функции типа a -> m b:

fmap :: (a -> m b) -> m a -> m (m b)
f =<< v = join (fmap f v) :: (a -> m b) -> m a -> m b

Итак, мы хотим использовать соединение здесь, но в настоящее время m [m a], поэтому наши комбинаторы монады не помогут напрямую. Позволяет искать m [m a] -> m (m [a]) с помощью hoogle, и наш первый результат выглядит многообещающим. Это sequence:: [m a] -> m [a].
Если мы посмотрим на связанную функцию, мы также найдем traverse :: (a -> IO b) -> [a] -> IO [b], которая аналогично sequence (fmap f v).

Вооружившись этим знанием, мы можем просто написать:

readConfigFiles path = traverse getConfig =<< getPaths path