Нет, в Haskell неудобно обрабатывать ошибки (если не пользоваться эффектами)
Если вы когда-нибудь читали агитации, призывающие к изучению Haskell, наверняка вас убеждали, что в нём ну очень удобно обрабатывать ошибки, ведь там есть Монада Either.
Однако, чем дальше вы изучаете Haskell (если, конечно, изучаете), тем больше понимаете, что Either неудобен примерно всегда, и использовать его вы не станете. А именно потому, что он не допускает побочных эффектов (например, IO
).
Хотя, например, в Rust, такой проблемы нет, однако и там сообщество пришло к выводу, что тамошний Result
из коробки тоже непригоден к использованию.
Далее, скорее всего, вы узнаете про ExceptT/MonadError
, который и побочные эффекты позволяет, и ошибки умеет бросать. Но и у него есть проблемы, целых две.
Если вы разрабатываете достаточно сложную систему, то ваш код скорее всего будет бросать больше, чем одну ошибку. Тут и начинаются танцы. Вы либо используете сумму (под конкретный случай, или
Either
или самописная сумма):data Foo = Foo -- 1st exception data Bar = Bar -- 2nd exception data FooOrBar = ... -- sum of 1st and 2nd exceptions foo :: MonadError FooOrBar m => m ()
Либо делаете ещё более страшные вещи:
Если используете
ExceptT
:foo :: ExceptT Foo (ExceptT Bar IO) () foo = do throwError Foo -- неявный лифтинг работать не будет из-за FunctionalDependency lift $ throwError Bar
Если используете
MonadError
, то из-заFunctionalDependency
в принципе нельзя написать(MonadError Foo m, MonadError Bar m) => m ()
. Так как эту поблему нашёл не я, и она давно известна, то решение уже имеется, это библиотекаcapability
.foo :: (HasThrow "foo" Foo m, HasThrow "bar" Bar m) => m () foo = do throw @"foo" Foo throw @"bar" Bar
Но, во-первых, это какой-то позор со стороны интерфейса. Во-вторых, это не решает проблемы проблемы номер 2.
Ну, а в дополнение к тому, что, используя
MonadError/ExceptT
, нельзя хоть сколько нибудь удобным способом кидать несколько разных ошибок, так их ещё и нельзя по-настоящему ловить. Вы можете обработать, но не поймать. Потому чтоcatchError
имеет сигнатуруm a -> (e -> m a) -> m a
. Она означает, что если даже вы вызовете эту функцию и действительно отловите ошибку (не бросте её заново), то типы ничего об этом не скажут. Где-то выше по стеку вы всё ещё не сможете быть уверены, что ошибка поймана.
Первая проблема, конечно, из коробки решена исключениями: бросай что хочешь, сколько хочешь. Но их очевидный недостаток в отсутствии явности. Однако, для явности наc есть checked exceptions. Они описаны здесь. С первого взгляда здесь всё хорошо.
Можно бросать ошибки.
Можно бросать разные ошибки, несвязанные друг с другом
Можно ловить их, не оставляя от них следа.
foo :: (CanThrow Foo, CanThrow Bar) => IO ()
foo = do
throwChecked Foo
throwChecked Bar
bar :: CanThrow Foo => IO ()
bar = foo
-- ^^^
-- Could not deduce (CanThrow Bar) arising from a use of ‘foo’
baz :: CanThrow Foo => IO ()
baz = foo `catchChecked` \Bar -> pure ()
-- ^^^
-- Ok
Однако, из-за того, что CanThrow
по факту, ни к чему не привязан, то опять же есть 2 проблемы. Одна из них фатальна. И что самое обидное: ни об одной из них в посте WellTyped не сказано!
Вывод типов работает очень плохо.
foo :: IO () foo = do void qux `catchChecked` \Foo -> pure () where qux = for [1..10] \_ -> throwChecked Foo -- ^^^ -- No instance for (CanThrow Foo) -- arising from a use of ‘throwChecked’ -- • In the expression: throwChecked Foo -- In the second argument of ‘for’, namely ‘\ _ -> throwChecked Foo’ -- In a stmt of a 'do' block: for [1 .. 10] \ _ -> throwChecked Foo
Но это решается сигнатурами и в принципе несмертельно, потому что только делает код несколько вербознее и ничего больше.
foo :: IO () foo = do void qux `catchChecked` \Foo -> pure () where qux :: CanThrow Foo => IO [()] qux = do for [1..10] \_ -> throwChecked Foo
Однако второе (и я всё ещё дико негодую, что WellTyped об этом не предупреждают!) может взорвать вам рантайм.
foo :: CanThrow Foo => IO () foo = do forkIO do throwChecked Foo throwChecked Foo
Так как у вас в скоупе присутствует
CanThrow
, вы, естественно, можете вызыватьthrowChecked
. Но на самом деле вы бросили исключение в соседнем треде, а хендлер для него не обозначили. И компилятор вас не заставил!
Однако, если вы будете достаточно внимательны, то checked exceptions может быть вашим вариантом из-за того, что их можно довольно быстро и безболезненно встроить.
И не стоит отчаиваться, ведь есть вариант ещё лучше, пусть и не получится его встроить так же легко, как checked exceptions. Это эффекты. Что такое эффекты, для чего они нужны и как их использовать, вы можете почитать в документации к любой из реализаций. Вот несколько:
(Это далеко не весь список)
Для примера здесь я буду использовать effectful
. Так как сам автор effectful
не то что бы подозревает, что его библиотека решает нашу проблему, то мы будем использовать эффект Error немного иначе, чем это задумывалось. Эффект Error в общем-то имеет интерфейс, состоящий из двух функций:
throwError :: forall e es a. (HasCallStack, Error e :> es)
=> e -> Eff es acatchError :: forall e es a. Error e :> es
=> Eff es a -> (CallStack -> e -> Eff es a) -> Eff es a
Но так как catchError
не стирает ошибку, а только реагирует на неё, то мы ей пользоваться не будем. Напишем свою:
-- Для простоты я убрал обработку CallStack
catchError :: Eff (Error e ': es) a -> (e -> Eff es a) -> Eff es a
catchError eff handler = runErrorNoCallStack eff >>= \case
Left e -> handler e
Right r -> pure r
Теперь можем пользоваться.
Это всё ещё так же хорошо работает с несколькими ошибками, как и checked exception:
foo :: (Error Foo :> es, Error Bar :> es) => Eff es ()
foo = do
Eff.throwError Foo
Eff.throwError Bar
Типы выводятся:
foo :: Eff es ()
foo = qux `catchError` \Foo -> pure ()
where
qux = for_ [1..10] \_ -> Eff.throwError Foo
С ошибками в соседнем треде всё ещё не так просто. Но есть шаманское решение, которое позволит сделать форки более безопасными, хоть и более вербозными. Для начала определим свой собсвенный хитрый fork:
type family HasNoError es :: Constraint where
HasNoError (Error e ': es) = TypeError ('Text "You can't fork action that throws error")
HasNoError (e ': es) = HasNoError es
HasNoError '[] = ()
data Fork :: Effect where
Fork :: m () -> Fork m ThreadId
type instance DispatchOf Fork = 'Dynamic
fork :: forall es' es. (Fork :> es', HasNoError es', Subset es' es) => Eff es' () -> Eff es ThreadId
fork = inject . send @_ @es' . Fork
И единственный его минус, по моему мнению, что надо чтобы эффекты, запущенные в новом треде были статически известны, чтобы компилятор мог проверить, что HasNoError
не приводит к TypeError
.
foo :: Error Foo :> es => Eff es ()
foo = for_ [1..10] \_ -> Eff.throwError Foo
baz :: (Fork :> es, Error Foo :> es) => Eff es ()
baz = do
threadId <- fork foo
Eff.throwError Foo
-- ^^^
-- 1) • Could not deduce (Eff.Subset es'0 es)
-- arising from a use of ‘fork’
-- 2) Could not deduce (Error Foo :> es'0) arising from a use of ‘foo’
-- from the context: (Fork :> es, Error Foo :> es)
-- Что я и говорил, нужно указать список эффектов явно.
-- Конечно, ошибки компиляции оставляют жеать лучшего, но как
-- сделать их читаемее, я пока не придумал
-- Попытка номер 2
baz :: (Fork :> es, Error Foo :> es) => Eff es ()
baz = do
threadId <- fork @'[Fork] foo
Eff.throwError Foo
-- ^^^
-- • There is no handler for 'Error Foo' in the context
-- • In the second argument of ‘fork’, namely ‘foo’
-- In a stmt of a 'do' block: threadId <- fork @'[Fork] foo
-- In the expression:
-- do threadId <- fork @'[Fork] foo
-- throwError Foo
-- |
--xxx | threadId <- fork @'[Fork] foo
-- | ^^^
-- Обмануть компилятор не вышло, я указал, что foo должен уметь только Fork,
-- но в сигнатуре foo явно указан Error Foo.
-- Попытка номер 3
baz :: (Fork :> es, Error Foo :> es) => Eff es ()
baz = do
threadId <- fork @'[Fork, Error Foo] foo
Eff.throwError Foo
-- ^^^
-- • You can't fork action that throws error
-- • In a stmt of a 'do' block:
-- threadId <- fork @'[Fork, Error Foo] foo
-- In the expression:
-- do threadId <- fork @'[Fork, Error Foo] foo
-- throwError Foo
-- In an equation for ‘baz’:
-- baz
-- = do threadId <- fork @'[Fork, Error Foo] foo
-- throwError Foo
-- |
--xxx | threadId <- fork @'[Fork, Error Foo] foo
-- | ^^^^
-- А теперь мы уже сами себя спасли. Констрейнт HasNoError точно
-- не выполняется, о чём и сообщает компилятор.
-- Попытка номер 4
baz :: (Fork :> es, Error Foo :> es) => Eff es ()
baz = do
threadId <- fork @'[Fork] (foo `catchError` \Foo -> pure ())
Eff.throwError Foo
-- ^^^
-- Ok!
Пробуем запустить более интересный пример:
foo :: Error Foo :> es => Eff es ()
foo = for_ [1..10] \_ -> Eff.throwError Foo
baz :: (IOE :> es, Fork :> es, Error Bar :> es) => Eff es ()
baz = do
threadId <- fork @'[Fork, IOE]
( foo `catchError` \Foo -> liftIO do
t <- myThreadId
putStrLn $ "Catch Foo. From another thread (" <> show t <> ")"
)
liftIO do
threadDelay 100000
print threadId
throwError Bar
qux :: (IOE :> es, Fork :> es) => Eff es ()
qux = baz `catchError` \Bar -> liftIO do
t <- myThreadId
putStrLn $ "Catch Bar. From main thread (" <> show t <> ")"
runFork :: Concurrent :> es => Eff (Fork ': es) a -> Eff es a
runFork = interpret \env -> \case
Fork m -> localUnlift env (ConcUnlift Ephemeral Unlimited) \unlift -> forkIO (unlift m)
-- >>> runEff $ runConcurrent $ runFork qux
-- Catch Foo. From another thread (ThreadId 247)
-- ThreadId 247
-- Catch Bar. From main thread (ThreadId 246)
Стоит признаться, что я не пробовал всё это дело на хоть сколько нибудь большом проекте.
Я просто, своего рода, экспериментатор)))).
Не призываю никого запихивать это к себе на прод, если у вас таковой на Haskell имеется, но призываю поиграться и поизучать, возможно, некоторые подвоные камни, которые я не нашёл.
Конец.