Если вы когда-нибудь читали агитации, призывающие к изучению 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 имеется, но призываю поиграться и поизучать, возможно, некоторые подвоные камни, которые я не нашёл.
Конец.
Комментарии (19)
ph14nix
09.01.2023 23:21+3Хорошая, вдумчивая статья (не всю пока прочитал — в процессе). Давно не читал на Хабре чего-то, что мне бы понравилось. Спасибо!
Des333
09.01.2023 23:45+4Хорошая статья, спасибо!
Жаль, что на Хабре про Haskell пишут крайне редко.
Автору -- респект и пожелание писать ещё!
lain8dono
10.01.2023 02:05+11Хотя, например, в Rust, такой проблемы нет, однако и там сообщество пришло к выводу, что тамошний
Result
из коробки тоже непригоден к использованию.Не понимаю, о чём вы, но кликбейт неплохой. Если с чем и есть некоторые проблемы, так это со стандартным трейтом Error. Но никто не говорит, что надо его использовать везде.
Систему с раскруткой стека и паниками мы не называем исключениями и (кроме весьма специфичных случаев) не ловим и не обрабатываем. Если приложение паникует и умирает, то это не ошибка, а баг в коде. По этому поводу есть консенсус в сообществе.
Опять же в 95% случаев ошибка это просто информация о некорректных входных данных (в довольно широком смысле). И всё, что вы можете сделать в этом случае - внятно сообщить об этом пользователю.
Кстати, полагаю, сейчас центральным местом в обработке ошибок становится даже не сам Result, а трейт Try. При этом Result всё ещё остаётся подходящим для подавляющего числа случаев. Для некоторых других случаев есть Option и ControlFlow.
goosedb Автор
10.01.2023 07:28+3Проблема Result в том, что когда они используется, как есть (без box Error, failure, anyhow), то он не композится с другими Result. Именно поэтому и придумали failure и anyhow. Однако их проблема уже в другом. Как только ты забоксил ошибку в трейт Error (или обернул в anyhow::Error), ты понятия не имеешь, что за ошибка лежит внутри. Два стула: либо не композится, как MonadError/ExceptT либо неизвестность, как с голыми исключениями.
nlinker
10.01.2023 09:05Если не ошибаюсь,
anyhow::Result
используется только в приложениях и конечном киентском коде, поэтому его и композить нет необходимости. И не очень понятно, как предлагается композить два чёрных ящика, кроме как создать ещё один ящик и положить эти два туда. Вот этот момент мне не совсем понятен.Добавлю, чтобы не писать лишний комментарий: хорошая информативная статья ????
goosedb Автор
10.01.2023 09:49Наверное, я как-то криво выразился. Не нужно композить два чёрных ящика (box Error, с anyhow::Error). Нужно композить два белых (конкретные Result<FooError, _>, Result<BarError, _>) в один чёрный. И как только вы это сделали, объединили Result<FooError, T> с Result<BarError, T> в Result<anyhow::Error, T>, вы потеряли вообще всю информацию, какие конкретно ошибки могут там быть. Это всё равно, что хаскелевый SomeException
lain8dono
10.01.2023 10:35+4Оператор ? это по большей части сахар для следующей конструкции (версия для Result):
match some_expr { Ok(ok_value) => ok_value, Err(err_value) => return From::from(err_value), }
Обратите внимание на вызов From::from. Это вызов преобразования из одного типа ошибки в другой. Так что для того, чтоб композитить это всё, нужно только реализовать From для своего типа ошибки. thiserror делает этот процесс простым и приятным. При этом не теряя явности.
А для случая, когда нужно кастовать один Result в другой Result есть такие варианты:
fn foo<T>(result: Result<T, A>) -> Result<T, B> { Ok(result?) } fn bar<T>(result: Result<T, A>) -> Result<T, B> { result.map_err(From::from) } impl From<A> for B { fn from(a: A) -> B { .. } } // не существует реализации impl<T, A, B: From<A>> From<Result<T, A>> for Result<T, B> // причина тому конфликт с impl<T> From<T> for T
В случае anyhow можно посмотреть внутрь при помощи downcast, в случае thiserror будет работать стандартный match. Крейт failure слишком устарел для того, чтоб упоминать его.
0xd34df00d
10.01.2023 03:32+2Именно поэтому хаскелю нужны зависимые типы, тогда все эти вещи выражать проще будет. И сообщения об ошибках станут лучше.
И единственный его минус, по моему мнению, что надо чтобы эффекты, запущенные в новом треде были статически известны, чтобы компилятор мог проверить, что HasNoError не приводит к TypeError.
Можно equational constraint добавить. Для подавляющего большинства случаев этого хватит.
Вы можете обработать, но не поймать. Потому что
catchError
имеет сигнатуруm a -> (e -> m a) -> m a
. Она означает, что если даже вы вызовете эту функцию и действительно отловите ошибку (не бросте её заново), то типы ничего об этом не скажут. Где-то выше по стеку вы всё ещё не сможете быть уверены, что ошибка поймана.Я, кстати, не вижу фундаментальных причин, по которым
HasCatch
изcapability
не мог бы иметь такого метода. Надо поковырять.0xd34df00d
10.01.2023 07:43+1Я, кстати, не вижу фундаментальных причин, по которым HasCatch из capability не мог бы иметь такого метода. Надо поковырять.
А, ну потому, что констрейнтами тяжело манипулировать даже с этим, так что констрейнты тоже надо иметь первоклассные, как в идрисе, например.
goosedb Автор
10.01.2023 07:52+1Можно equational constraint добавить
А куда ты его добавишь-то?
Я, кстати, не вижу фундаментальных причин, по которым
HasCatch
изcapability
Функциональные зависимости всё портят. Хотя в этом случае я даже не особо понимаю, как оно там считается
data Foo = Foo deriving (Show, Exception) data Bar = Bar deriving (Show, Exception) throwCap :: forall a m b. Cap.HasThrow a a m => a -> m b throwCap = Cap.throw @a type CanThrow a = Cap.HasThrow a a foo :: (CanThrow Bar m, CanThrow Foo m) => m () foo = do () <- throwCap Foo throwCap Bar catchCap :: (Unlift.MonadUnliftIO m, Exception e) => Cap.MonadCatch e m a -> (e -> m a) -> m a catchCap (Cap.MonadCatch m) = Unlift.catch m baz :: CanThrow Bar m => m () baz = foo `catchCap` \Foo -> pure ()
• Couldn't match type ‘Foo’ with ‘Bar’ arising from a functional dependency between: constraint ‘HasThrow Bar Bar (MonadCatch Foo m)’ arising from a use of ‘foo’ instance ‘HasThrow tag e (MonadCatch e m1)’ at <no location info> • In the first argument of ‘catchCap’, namely ‘foo’ In the expression: foo `catchCap` \ Foo -> pure () In an equation for ‘baz’: baz = foo `catchCap` \ Foo -> pure () | xxx | baz = foo `catchCap` \Foo -> pure () | ^^^
0xd34df00d
10.01.2023 08:18А куда ты его добавишь-то?
Можно для конкретики пример, когда оно там не работает транзитивно?
Функциональные зависимости всё портят. Хотя в этом случае я даже не особо понимаю, как оно там считается
Там с текущим API это не сделаешь, да.
goosedb Автор
10.01.2023 08:37+1Можно для конкретики пример, когда оно там не работает транзитивно?
Так ето ты скопипасти, да проверь, что оно неюзабельно, куда ни добавляй)
goosedb Автор
10.01.2023 20:59Пробовал?
0xd34df00d
10.01.2023 21:04Не, это ж надо сесть и собраться и мозги включить, а у меня пока ещё утро.
ph14nix
11.01.2023 03:33+1Для зависимых типов нужна будет проверка тотальности. И с ней будет трудно писать программы. Именно прикладные программы. Идрис ставил себе целью совместить эти две вещи. Посмотрите, что получилось. Может быть, вам он нужен, а не Хаскель.
CrazyOpossum
В целом, да. Добавлю только, что проблемы с исключениями - чисто архитектурные и присутствуют во многих языках, где вообще встаёт вопрос "возвращать или ловить?". В своём коде всегда ловлю вызовы внешних библиотек, которые и так обычно в чистом
IO a
работают, и непосредственно на верхнем уровне - в main или forkFinally, на случай, если где-то всё же упустил. Все внутренние вызовы всегда вокруг ExceptT/MonadError построены.