- PVSM.RU - https://www.pvsm.ru -
Если вы когда-нибудь читали агитации, призывающие к изучению 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 ()
. Так как эту поблему нашёл не я, и она давно известна, то решение [1] уже имеется, это библиотека 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. Они описаны здесь [2]. С первого взгляда здесь всё хорошо.
Можно бросать ошибки.
Можно бросать разные ошибки, несвязанные друг с другом
Можно ловить их, не оставляя от них следа.
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 [3]
polysemy [4]
cleff [5]
freer-simple [6]
...
(Это далеко не весь список)
Для примера здесь я буду использовать effectful
. Так как сам автор effectful
не то что бы подозревает, что его библиотека решает нашу проблему, то мы будем использовать эффект Error немного иначе, чем это задумывалось. Эффект Error [7] в общем-то имеет интерфейс, состоящий из двух функций:
throwError :: forall e es a. (HasCallStack, Error e :> es)
=> e -> Eff es a
catchError :: 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 имеется, но призываю поиграться и поизучать, возможно, некоторые подвоные камни, которые я не нашёл.
Конец.
Автор: Берестов Данил
Источник [8]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/haskell/381929
Ссылки в тексте:
[1] решение: https://hackage.haskell.org/package/capability-0.5.0.1/docs/Capability-Error.html
[2] здесь: https://well-typed.com/blog/2015/07/checked-exceptions/
[3] effectful: https://hackage.haskell.org/package/effectful-core
[4] polysemy: https://hackage.haskell.org/package/polysemy
[5] cleff: https://hackage.haskell.org/package/cleff
[6] freer-simple: https://hackage.haskell.org/package/freer-simple
[7] Error: https://hackage.haskell.org/package/effectful-core-2.2.1.0/docs/Effectful-Error-Static.html
[8] Источник: https://habr.com/ru/post/709622/?utm_source=habrahabr&utm_medium=rss&utm_campaign=709622
Нажмите здесь для печати.