Обработка ошибок в трубах

Предыстория

У меня есть несколько файлов данных, каждый из которых содержит список записей данных (по одному на строку). Подобно CSV, но достаточно отличается, чтобы я предпочел написать свой собственный анализатор, а не использовать библиотеку CSV. Для этого вопроса я буду использовать упрощенный файл данных, содержащий только одно число в строке:

1
2
3
error
4

Как видите, возможно, что файл содержит искаженные данные, и в этом случае весь файл следует считать искаженным.

Тип обработки данных, который я хочу выполнить, можно выразить в виде карт и складок. Итак, я подумал, что это хорошая возможность научиться пользоваться библиотекой pipes.

{-# LANGUAGE NoMonomorphismRestriction #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE FlexibleContexts #-}

import           Control.Monad.Except
import           Pipes ((>->))
import qualified Pipes as P
import qualified Pipes.Prelude as P
import qualified Pipes.Safe as P
import qualified System.IO as IO

Сначала я создаю производителя строк в текстовом файле. Это очень похоже на пример в документации Pipes.Safe.

getLines = do
    P.bracket (IO.openFile "data.txt" IO.ReadMode) IO.hClose P.fromHandle

Затем мне нужна функция для анализа каждой из этих строк. Как я упоминал ранее, это может привести к сбою, что я представлю с помощью Either.

type ErrMsg = String

parseNumber :: String -> Either ErrMsg Integer
parseNumber s = case reads s of
                  [(n, "")] -> Right n
                  _         -> Left $ "Parse Error: \"" ++ s ++ "\""

Для простоты в качестве первого шага я хочу собрать все записи данных в список записей. Самый простой подход - пропустить все строки через синтаксический анализатор и просто собрать все это в список.

readNumbers1 :: IO [Either ErrMsg Integer]
readNumbers1 = P.runSafeT $ P.toListM $
    getLines >-> P.map parseNumber

К сожалению, это создает список любой из записей. Однако, если файл содержит одну неправильную запись, то весь файл следует считать неправильным. Что мне действительно нужно, так это список записей. Конечно, я могу просто использовать sequence для транспонирования списка любого из них.

readNumbers2 :: IO (Either ErrMsg [Integer])
readNumbers2 = sequence <$> readNumbers1

Но при этом будет прочитан весь файл, даже если первая строка уже искажена. Эти файлы могут быть большими, и у меня их много, поэтому было бы лучше, если бы чтение остановилось при первой ошибке.

Вопрос

Мой вопрос - как этого добиться. Как отменить синтаксический анализ первой некорректной записи?

Что я получил до сих пор

Моей первой мыслью было использовать экземпляр монады Either ErrMsg и P.mapM вместо P.map. Поскольку мы читаем из файла, у нас уже есть IO и SafeT в нашем стеке монад, поэтому, думаю, мне понадобится ExceptT, чтобы добавить обработку ошибок в этот стек монад. Это тот момент, в котором я застрял. Я пробовал много разных комбинаций и всегда получал крики от проверяющего типа. Следующее - самое близкое, что я могу сделать для компиляции.

readNumbers3 = P.runSafeT $ runExceptT $ P.toListM $
    getLines >-> P.mapM (ExceptT . return . parseNumber)

Введенный тип readNumbers3 читает

*Main> :t readNumbers3
readNumbers3
  :: (MonadIO m, P.MonadSafe (ExceptT ErrMsg (P.SafeT m)),
      P.MonadMask m, P.Base (ExceptT ErrMsg (P.SafeT m)) ~ IO) =>
     m (Either ErrMsg [Integer])

что похоже на то, что я хочу:

readNumbers3 :: IO (Either ErrMsg [Integer])

Однако как только я пытаюсь выполнить это действие, я получаю следующее сообщение об ошибке в ghci:

*Main> readNumbers3

<interactive>:7:1:
    Couldn't match expected type ‘IO’
                with actual type ‘P.Base (ExceptT ErrMsg (P.SafeT m0))’
    The type variable ‘m0’ is ambiguous
    In the first argument of ‘print’, namely ‘it’
    In a stmt of an interactive GHCi command: print it

Если я попытаюсь применить следующую подпись типа:

readNumbers3 :: IO (Either ErrMsg [Integer])

Затем я получаю следующее сообщение об ошибке:

error.hs:108:5:
    Couldn't match expected type ‘IO’
                with actual type ‘P.Base (ExceptT ErrMsg (P.SafeT IO))’
    In the first argument of ‘(>->)’, namely ‘getLines’
    In the second argument of ‘($)’, namely
      ‘getLines >-> P.mapM (ExceptT . return . parseNumber)’
    In the second argument of ‘($)’, namely
      ‘P.toListM $ getLines >-> P.mapM (ExceptT . return . parseNumber)’
Failed, modules loaded: none.

В стороне

Еще одна мотивация для переноса обработки ошибок в базовую монаду канала заключается в том, что это значительно упростило бы дальнейшую обработку данных, если бы мне не пришлось жонглировать любым из них в моих картах и ​​свертках.


person Lemming    schedule 05.05.2016    source источник
comment
Вот начало ответа: stackoverflow.com/a/11417819/866915   -  person ErikR    schedule 05.05.2016


Ответы (1)


Вот поэтапный подход к решению проблемы.

Следуя предложению Tekmo в этом SO-ответе, мы стремимся работать в следующей монаде:

ExceptT String (Pipe a b m) r

Начнем с импорта и определения parseNumber:

import           Control.Monad.Except
import           Pipes ((>->))
import qualified Pipes as P
import qualified Pipes.Prelude as P

parseNumber :: String -> Either String Integer
parseNumber s = case reads s of
                  [(n, "")] -> Right n
                  _         -> Left $ "Parse Error: \"" ++ s ++ "\""

Вот простой производитель строк в монаде ввода-вывода, которую мы будем использовать в качестве входных данных:

p1 :: P.Producer String IO ()
p1 = P.stdinLn >-> P.takeWhile (/= "quit")

Чтобы поднять его до монады ExceptT, мы просто используем lift:

p2 :: ExceptT String (P.Producer String IO) ()
p2 = lift p1

Вот сегмент конвейера, который преобразует строки в целые числа в монаде ExceptT:

p4 :: ExceptT String (P.Pipe String Integer IO) a
p4 = forever $ 
       do s <- lift P.await
          case parseNumber s of
            Left e  -> throwError e
            Right n -> lift $ P.yield n

Вероятно, можно было бы написать более комбинаторно, но я оставил его очень явным для ясности.

Затем мы соединяем p2 и p4 вместе. Результат также находится в монаде ExceptT.

-- join together p2 and p4
p7 :: ExceptT String (P.Producer Integer IO) ()
p7 = ExceptT $ runExceptT p2 >-> runExceptT p4

Ответ Tekmo SO предлагает создать для этого нового оператора.

Наконец, мы можем использовать toListM' для запуска этого конвейера. (Я включил сюда определение toListM', потому что его нет в моей установленной версии Pipes.Prelude)

p8 :: IO ([Integer], Either String ())
p8 = toListM' $ runExceptT p7

toListM' :: Monad m => P.Producer a m r -> m ([a], r)
toListM' = P.fold' step begin done
  where
    step x a = x . (a:)
    begin = id
    done x = x []

Примеры того, как работает p8:

ghci> p8
4
5
6
quit
([4,5,6],Right ())

ghci> p8
5
asd
([5],Left "Parse Error: \"asd\"")

Обновить

Вы можете упростить код, обобщив parseNumber следующим образом:

parseNumber' :: (MonadError [Char] m) => String -> m Integer
parseNumber' s = case reads s of
                   [(n, "")] -> return n
                   _         -> throwError $ "Parse Error: \"" ++ s ++ "\""

Тогда p4 можно записать:

p4' :: ExceptT String (P.Pipe String Integer IO) a
p4' = forever $ lift P.await >>= parseNumber' >>= lift . P.yield
person ErikR    schedule 05.05.2016
comment
Спасибо за подробный ответ. Это решило мою проблему. Я обобщил p4' как mapE для любой произвольной функции в MonadError e. Определение следующего оператора может сделать синтаксис еще короче: infixl 6 ^>?> p1 ^>?> p2 = lift p1 >?> p2 - person Lemming; 05.05.2016