Если вы когда-нибудь читали агитации, призывающие к изучению 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 имеется, но призываю поиграться и поизучать, возможно, некоторые подвоные камни, которые я не нашёл.
Конец.
