Если вы когда-нибудь читали агитации, призывающие к изучению Haskell, наверняка вас убеждали, что в нём ну очень удобно обрабатывать ошибки, ведь там есть Монада Either.

Однако, чем дальше вы изучаете Haskell (если, конечно, изучаете), тем больше понимаете, что Either неудобен примерно всегда, и использовать его вы не станете. А именно потому, что он не допускает побочных эффектов (например, IO).

Хотя, например, в Rust, такой проблемы нет, однако и там сообщество пришло к выводу, что тамошний Result из коробки тоже непригоден к использованию.

Далее, скорее всего, вы узнаете про ExceptT/MonadError, который и побочные эффекты позволяет, и ошибки умеет бросать. Но и у него есть проблемы, целых две.

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

  2. Ну, а в дополнение к тому, что, используя MonadError/ExceptT, нельзя хоть сколько нибудь удобным способом кидать несколько разных ошибок, так их ещё и нельзя по-настоящему ловить. Вы можете обработать, но не поймать. Потому что catchError имеет сигнатуру m a -> (e -> m a) -> m a. Она означает, что если даже вы вызовете эту функцию и действительно отловите ошибку (не бросте её заново), то типы ничего об этом не скажут. Где-то выше по стеку вы всё ещё не сможете быть уверены, что ошибка поймана.

Первая проблема, конечно, из коробки решена исключениями: бросай что хочешь, сколько хочешь. Но их очевидный недостаток в отсутствии явности. Однако, для явности у наc есть checked exceptions. Они описаны здесь. С первого взгляда здесь всё хорошо.

  1. Можно бросать ошибки.

  2. Можно бросать разные ошибки, несвязанные друг с другом

  3. Можно ловить их, не оставляя от них следа.

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 не сказано!

  1. Вывод типов работает очень плохо.

    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
  2. Однако второе (и я всё ещё дико негодую, что WellTyped об этом не предупреждают!) может взорвать вам рантайм.

    foo :: CanThrow Foo => IO ()
    foo = do
      forkIO do
        throwChecked Foo
      throwChecked Foo

    Так как у вас в скоупе присутствует CanThrow, вы, естественно, можете вызывать throwChecked. Но на самом деле вы бросили исключение в соседнем треде, а хендлер для него не обозначили. И компилятор вас не заставил!

Однако, если вы будете достаточно внимательны, то checked exceptions может быть вашим вариантом из-за того, что их можно довольно быстро и безболезненно встроить.

И не стоит отчаиваться, ведь есть вариант ещё лучше, пусть и не получится его встроить так же легко, как checked exceptions. Это эффекты. Что такое эффекты, для чего они нужны и как их использовать, вы можете почитать в документации к любой из реализаций. Вот несколько:

(Это далеко не весь список)

Для примера здесь я буду использовать effectful. Так как сам автор effectful не то что бы подозревает, что его библиотека решает нашу проблему, то мы будем использовать эффект Error немного иначе, чем это задумывалось. Эффект Error в общем-то имеет интерфейс, состоящий из двух функций:

  1. throwError :: forall e es a. (HasCallStack, Error e :> es)
    => e -> Eff es a

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

Конец.

Комментарии (19)


  1. CrazyOpossum
    09.01.2023 18:56
    +1

    В целом, да. Добавлю только, что проблемы с исключениями - чисто архитектурные и присутствуют во многих языках, где вообще встаёт вопрос "возвращать или ловить?". В своём коде всегда ловлю вызовы внешних библиотек, которые и так обычно в чистом IO a работают, и непосредственно на верхнем уровне - в main или forkFinally, на случай, если где-то всё же упустил. Все внутренние вызовы всегда вокруг ExceptT/MonadError построены.


  1. ph14nix
    09.01.2023 23:21
    +3

    Хорошая, вдумчивая статья (не всю пока прочитал — в процессе). Давно не читал на Хабре чего-то, что мне бы понравилось. Спасибо!


  1. Des333
    09.01.2023 23:45
    +4

    Хорошая статья, спасибо!
    Жаль, что на Хабре про Haskell пишут крайне редко.

    Автору -- респект и пожелание писать ещё!


  1. lain8dono
    10.01.2023 02:05
    +11

    Хотя, например, в Rust, такой проблемы нет, однако и там сообщество пришло к выводу, что тамошний Result из коробки тоже непригоден к использованию.

    Не понимаю, о чём вы, но кликбейт неплохой. Если с чем и есть некоторые проблемы, так это со стандартным трейтом Error. Но никто не говорит, что надо его использовать везде.

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

    Опять же в 95% случаев ошибка это просто информация о некорректных входных данных (в довольно широком смысле). И всё, что вы можете сделать в этом случае - внятно сообщить об этом пользователю.

    Кстати, полагаю, сейчас центральным местом в обработке ошибок становится даже не сам Result, а трейт Try. При этом Result всё ещё остаётся подходящим для подавляющего числа случаев. Для некоторых других случаев есть Option и ControlFlow.


    1. goosedb Автор
      10.01.2023 07:28
      +3

      Проблема Result в том, что когда они используется, как есть (без box Error, failure, anyhow), то он не композится с другими Result. Именно поэтому и придумали failure и anyhow. Однако их проблема уже в другом. Как только ты забоксил ошибку в трейт Error (или обернул в anyhow::Error), ты понятия не имеешь, что за ошибка лежит внутри. Два стула: либо не композится, как MonadError/ExceptT либо неизвестность, как с голыми исключениями.


      1. nlinker
        10.01.2023 09:05

        Если не ошибаюсь, anyhow::Result используется только в приложениях и конечном киентском коде, поэтому его и композить нет необходимости. И не очень понятно, как предлагается композить два чёрных ящика, кроме как создать ещё один ящик и положить эти два туда. Вот этот момент мне не совсем понятен.

        Добавлю, чтобы не писать лишний комментарий: хорошая информативная статья ????


        1. 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


          1. mayorovp
            10.01.2023 10:48
            +2

            А зачем вообще композить в "чёрный" anyhow::Error, если информация об ошибке всё ещё нужна?


            #[derive(Error, Debug)]
            pub enum AppError {
                Foo(#[from] FooError),
                Bar(#[from] BarError),
            }


            1. goosedb Автор
              10.01.2023 12:28
              +1

              А Вы точно читали этот текст дальше наброса на раст?


      1. mayorovp
        10.01.2023 09:08
        +1

        Он без проблем композится с другими Result если настроено отношение From.


      1. 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 слишком устарел для того, чтоб упоминать его.


  1. 0xd34df00d
    10.01.2023 03:32
    +2

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


    И единственный его минус, по моему мнению, что надо чтобы эффекты, запущенные в новом треде были статически известны, чтобы компилятор мог проверить, что HasNoError не приводит к TypeError.

    Можно equational constraint добавить. Для подавляющего большинства случаев этого хватит.


    Вы можете обработать, но не поймать. Потому что catchError имеет сигнатуру m a -> (e -> m a) -> m a. Она означает, что если даже вы вызовете эту функцию и действительно отловите ошибку (не бросте её заново), то типы ничего об этом не скажут. Где-то выше по стеку вы всё ещё не сможете быть уверены, что ошибка поймана.

    Я, кстати, не вижу фундаментальных причин, по которым HasCatch из capability не мог бы иметь такого метода. Надо поковырять.


    1. 0xd34df00d
      10.01.2023 07:43
      +1

      Я, кстати, не вижу фундаментальных причин, по которым HasCatch из capability не мог бы иметь такого метода. Надо поковырять.

      А, ну потому, что констрейнтами тяжело манипулировать даже с этим, так что констрейнты тоже надо иметь первоклассные, как в идрисе, например.


    1. 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 ()
          |       ^^^


      1. 0xd34df00d
        10.01.2023 08:18

        А куда ты его добавишь-то?

        Можно для конкретики пример, когда оно там не работает транзитивно?


        Функциональные зависимости всё портят. Хотя в этом случае я даже не особо понимаю, как оно там считается

        Там с текущим API это не сделаешь, да.


        1. goosedb Автор
          10.01.2023 08:37
          +1

          Можно для конкретики пример, когда оно там не работает транзитивно?

          Так ето ты скопипасти, да проверь, что оно неюзабельно, куда ни добавляй)


        1. goosedb Автор
          10.01.2023 20:59

          Пробовал?


          1. 0xd34df00d
            10.01.2023 21:04

            Не, это ж надо сесть и собраться и мозги включить, а у меня пока ещё утро.


    1. ph14nix
      11.01.2023 03:33
      +1

      Для зависимых типов нужна будет проверка тотальности. И с ней будет трудно писать программы. Именно прикладные программы. Идрис ставил себе целью совместить эти две вещи. Посмотрите, что получилось. Может быть, вам он нужен, а не Хаскель.