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

Код в статье приводится на scala, однако рассматриваемый подход может быть реализован на многих других языках (c++ с помощью макросов, java с помощью JetBrains MPS и т.д.). Наиболее близким аналогом рассматриваемого подхода является способ обработки ошибок в haskell.

При проектировании функции способ работы с ошибками внутри неё зависит от предполагаемого способа использования в случае ошибки. Можно выделить два основных способа:
  • Ошибка ведет к некорректному состоянию программы и мы должны максимально быстро и подробно сообщить о ней пользователю. Системы с таким способом обработки ошибок обладают свойством fail-fast (FF).
  • Ошибка является стандартной ситуацией и будет обработана в автоматическом режиме без участия пользователя. Подобные системы являются fault-tolerant (FT)

Далее я буду использовать два сокращения: функция рассчитанная на то, что внешний код будет работать с ней в режиме FT — FT-функция и аналогично FF-функция.

Зачем вообще выделять несколько способов работы с ошибками, а не использовать какой-то один для всех случаев? Дело в том, что функции рассчитанные на FF применяемые в режиме FT, как правило, будут приводить к слишком медленной работе всей системы. В то время как функции рассчитанные на FT, используемые в режиме FF не будут предоставлять достаточно информации для быстрого поиска ошибки пользователем.

Множество примеров несоответствия целей и способов использования порождает стандартная функция из JDK Integer.parseInt, которая явно проектировалась из расчета на FF. Например если вы в цикле читаете строки из потока и обрабатываете только числа, а остальные пропускаете. В этом случае try { Integer.parseInt(...) } catch {...} может замедлить ваш код в несколько раз. Более подробное объяснение и тесты можно посмотреть здесь nadeausoftware.com/articles/2009/08/java_tip_how_parse_integers_quickly.

FT-функции должны возвращать информацию об ошибке в наиболее сжатом виде, чтобы не расходовать напрасно ресурсы. Чаще всего это либо значение типа boolean, либо код ошибки типа int. При этом, если случилась какая-то фатальная ошибка, то информацию о ней пользователю или в лог должна передавать уже вышестоящая FF-функция. FT-функции должны быть минимального размера, чтобы по коду ошибки можно было понять в каком месте эта ошибка произошла.

Если вы пишете обычное бизнес- или веб-приложение, т.е. такое, где основную роль играет внешний пользователь (а таких приложений в настоящий момент подавляющее большинство), а не систему управления марсоходом, то 99% вашего кода будет FF. Т.е. от пользователя будут требовать ввода корректных данных, а если же какие-то некорректные всё-таки проскочили, то падать с ошибкой и требовать от разработчика добавить больше проверок. Весь код, обрабатывающий ошибки, будет занимать < 1% машинного времени, даже если его объем велик, если только вы не будете использовать FF-функции из FT-кода. Поэтому в дальнейшем я хотел бы более подробно рассмотреть способы проектирования FF функций и уменьшения количества кода, необходимого для удобной обработки ошибок.

Т.к. при FF-подходе наша задача максимально быстро свернуть всю текущую деятельность и оповестить пользователя об ошибке, то необходимо сделать сообщение об ошибке максимально информативным и при этом неважно какие ресурсы для этого потребуются, т.к. узким местом всё равно будет пользователь либо программист, который эту ошибку устранит вообще. Т.е. в данном случае ошибка является внештатной ситуацией и система не рассчитана на постоянный цикл обработки такой ошибки с максимальной пропускной способностью.

Здесь нужно остановиться на требованиях, которые мы можем предъявлять к способу обработки ошибок:
  • Ошибка должна быть описана достаточно подробно, чтобы пользователь понимал причину её возникновения. Например: деление на ноль.
  • Пользователь должен понимать, где искать эту причину. Т.е. не только, что это деление на ноль, но и в какое поле он этот ноль ввел. В какой форме было это поле и на какой вкладке нашего многостраничного приложения была эта форма.
  • Описание ошибки для пользователя не должно содержать всякого информационного мусора, типа трассировки стека вызовов. По крайней мере без клика по кнопке «подробнее».
  • Должна быть доступна информация об ошибке, по которой программист сможет легко найти место её возникновения, т.е. скорее всего это как раз стек вызовов.

Таким образом наш объект, описывающий ошибку, должен содержать две принципиальные части: информация для пользователя и информация для программиста.

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

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

Реализация данного функционала может быть представлена функцией
def convertFiles(files: List[File]): Unit

Т.к. полная реализация может занимать несколько сотен, а то и тысяч строк, в зависимости от сложности форматов, то код стоит разбить на ряд вложенных функций с минимальной зоной ответственности и максимальной чистотой (я про pure functions). Допустим у нас есть следующие функции.
def convertFiles(files: List[File]): Unit
def convertFile(in: File): Unit
def convertStream(is: InputStream, os: OutputStream): Unit
def convertCity(el: InCity): OutCity
def convertPersons(pl: List[InPerson]): List[OutPerson]
def convertManager(el: InManager): OutManager
def convertProgrammer(el: InProgrammer): OutProgrammer
def convertPosition(el: InPosition): OutPosition      
def isOutdated(f: PosType): Boolean

Дерево вызовов может выглядеть так:
def convertFiles(files: List[File]): Unit
  def convertFile(in: File): Unit
    def convertStream(is: InputStream, os: OutputStream): Unit
      def convertCity(c: InCity): OutCity
      def convertPersons(pl: List[InPerson]): List[OutPerson]
        def convertManager(el: InManager): OutManager
          def convertPosition(el: InPosition): OutPosition      
            def isOutdated(f: PosType): Boolean          
        def convertProgrammer(el: InProgrammer): OutProgrammer
          def convertPosition(el: InPosition): OutPosition      
            def isOutdated(f: PosType): Boolean

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

Один из способов, особенно распространенный в java-сообществе, подразумевает использование исключений для обработки ошибок. Допустим isOutdated возвращает false. В этом случае convertPosition не может продолжать работу и выкидывает исключение. Код, вызывающий convertFiles перехватывает его и уведомляет пользователя о произошедшем, а также пишет стек вызовов в лог (если вдруг должность не устарела, то программист быстро найдет источник ошибки).

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

Первый — передавать во все внутренние функции контекст (т.е. некий объект, содержащий человека, файл и пр.). При этом усложнятся сигнатуры функций, способ вызова, понизиться читабельность программы в целом, сильно увеличится связанность функций между собой. Иными словами: не надо так.

Второй. convertPosition кидает исключение. convertManager и convertProgrammer содержат код:
def convertManager(el: InManager): OutManager = {
  ...
  try {
    ...
    val position = convertPosition(el.position)
    ...    
  } catch {
    case e: PositionException => throw new PersonException(s"ошибка при обработке менеджера ${p.name}", p, e)
  }
  ...
}

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

В чем минусы такого подхода? Во-первых нам приходится кидать много исключений. И хотя ресурсов особо на такое дело не жалко, но здесь уже чувствуется некий излишек в расходах. Во вторых синтаксис таков, что при усложнении примера можно закопаться в try...catch. Например может быть пример, когда на следующем шаге мы передаем в функцию данные, полученные на предыдущем. Тогда уже мы имеем набор вложенных try...catch. Обработка человека могла бы выглядеть так:
case class InPerson(name: String, tel: String, addr: Address, age: Age)
case class OutPerson(id: Int, name: String, homeAdder: Address, workAddr: Address, distance: Double)

def convertPerson(p: InPerson): OutPerson = {
  try {
    val name = convertName(p.name) //например меняем кодировку и возникает исключение, если встречается символ с кодом 0
    val homeAddr = convertAddr(p.addr)
    val age = convertAge(p.age)
    try {
      val (id, wAddr) = db.query("select id, work_addr from persons where tel = ?", p.tel)
      val workAddr = addrFromStr(wAddr)
      val distance = calcDistance(homeAddr, workAddr)
      if (distance == 0)
        throw new PersonException(s"похоже, что человек ${p.name} живет на работе", p, e)
      OutPerson(id, name, homeAddr, workAddr, distance)  
    } catch {
      case e: NotFoundException => throw new PersonException(s"в базе нет данных о человеке с номером телефона ${p.tel}", p, e)
    }    
  } catch {
    case e @ _: AddressException | _: AgeException => throw new PersonException(s"ошибка при обработке человека ${p.name}", p, e)
  }  
}

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

Я считаю, что исключения в своем текущем виде должны использоваться только с двумя целями:
1. Чтобы покинуть текущий фрейм стека. И для этого, по хорошему, должна быть более легковесная альтернатива, но её сейчас нет.
2. В ситуациях, встречающихся в количестве 1 на миллион строк кода, когда нет никакой больше возможности сообщить об ошибке. Например в стандартной библиотеке scala есть класс Option с методом get, который возвращает значение только если у нас в переменной потомок класса Option — класс Some. Если же там класс None, то возникает исключение. В данном случае нет возможности заменить результат get на Option[X], т.к. это сделает вызов get просто бессмысленным и нам остается только выкинуть исключение. Однако во всем остальном коде в аналогичной ситуации мы может заменить наш результат X на Option[X] и обойтись без исключений.

Так что же предлагает нам стандартная библиотека scala, библиотека scalaz и haskell. В интернете есть множество статей на эту тему, да и сама тема не так уж нова. Поэтому пересказывать эти статьи, как мне кажется, не стоит. Суть их сводится к использованию имеющихся монад.

Например статья про возможности стандартной библиотеки
tersesystems.com/2012/12/27/error-handling-in-scala
предлагает нам использовать Option, Either и Try. Но всё это работает на достаточно простых примерах. Если же нам при переписывании convertPerson потребуется использовать условие посередине, наподобие if (distance == 0) ..., то такая ситуация потребует разбиения одного общего for на два, что сразу же создаст кода не меньше чем при использовании try...catch. А если же наша функция, возвращающая Either или Try, будет вызываться из цикла foreach, map или fold, то единственный способ прервать цикл, это использовать scala.util.control.Breaks и переменную на уровне выше, если нужно передать наверх какое-то значение.

Scalaz, со своими \/, -\/ и \/- в сущности почти никак не меняет ситуацию
typelevel.org/blog/2014/02/21/error-handling.html
Ну или я чего-то про неё не понимаю и буду рад услышать об этом в комментариях.

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

В haskell используется монада Error, которая очень похожа на Try и \/, -\/, \/-.
book.realworldhaskell.org/read/error-handling.html
Но там не возникают многие из проблем, которые возникают в scala, из-за другого устройства самого кода и стандартной библиотеки. Поэтому там этот подход вполне себе может считаться рабочим, хотя и обладает также рядом недостатков.

Мой велосипед


Я попытался взять лучшее из обоих подходов (исключения и монады) и устранить, по возможности, недостатки. Результат можно посмотреть здесь
github.com/cs0ip/habr-error-handling
Для дальнейшего прочтения необходимо заглянуть в код, чтобы мне не пришлось приводить всё его описание здесь. Данный код лучше воспринимать не как готовую библиотеку, хотя я и использую его в своей разработке, а скорее как реализацию идеи, которую можно развить. В именах классов используются сокращения во-первых для краткости, во-вторых для уменьшения возможности пересечения с уже имеющимися именами, которые как правило сокращения не используют (по крайней мере у меня).

Я использовал 4 сущности. Res — класс, похожий на Try. Его потомки Ok и Err, соответствующие корректному результату и ошибке. А также класс Exit, служащий для прерывания циклов и передачи значений вверх по стеку. Exit похож на scala.util.control.Breaks. Ко всему этому были добавлены методы, делающие жизнь сносной при использовании кода, не поддерживающего Res. Так, например, Res можно создавать из Option, Either, Try и даже Boolean, что делает возможным вставку в for операторов if, способных прерывать выполнение и не относящимся к предыдущему выражению. Кроме того есть возможность легко обрабатывать код, кидающий исключения с помощью функции safe, без необходимости оборачивать его в Try.

Предположим, что все функции, написанные нами теперь поддерживают Res, тогда convertPerson можно переписать в следующем виде:
def convertPerson(p: InPerson): Res[OutPerson] = {
  val res = for {
    name <- convertName(p.name)
    
    homeAddr <- convertAddr(p.addr)
    
    age <- convertAge(p.age)
    
    (id, wAddr) <- Res.safe(db.query("select id, work_addr from persons where tel = ?", p.tel)) mapErr { e => e.ex.get match {
      case _: NotFoundException => e.replace(s"в базе нет данных о человеке с номером телефона ${p.tel}")
    }}
    
    workAddr <- addrFromStr(wAddr)
    
    distance <- calcDistance(homeAddr, workAddr)
    
    _ <- Res(distance != 0, s"похоже, что человек ${p.name} живет на работе")
  } yield OutPerson(id, name, homeAddr, workAddr, distance)   
  
  res.mapErr(e => e.push(s"ошибка при обработке человека ${p.name}"))  
}

Err хранит в себе значение типа Option[Throwable], что во-первых позволяет отслеживать стек вызовов, а во-вторых позволяет отказаться от создания исключения вообще, где нужно повысить производительность. Кроме того Err хранит список сообщений об ошибке и позволяет легко заменять последнее сообщение и добавлять новое. Также надо отметить, что Err полностью персистентен, что исключает возможность случайной модификации данных.

Пример использования Exit может выглядеть так:
def convertPersons(pl: List[InPerson]): Res[List[OutPerson]] = exitRes[List[OutPerson]]{ex =>
  Ok(pl.foldLeft(Nil){case (z, p) => 
    (p match {
      case p: InManager => convertManager(p)
      case p: InProgrammer => convertProgrammer(p)
    }) match {
      case Ok(out) => out :: z
      case e: Err => ex.go(e)
    }
  })
}  

exitRes[X] ожидает получить значение типа Res[X]. К сожалению при использовании exitRes (и exit) всегда нужно указывать тип получаемого значения, т.к. компилятор не может его вывести. Теоретически вывод типа можно сделать с помощью макросов, но пока они нестабильны, я не хочу их использовать.

Также хотелось отметить ещё пару моментов. Если вы пишете библиотеку для общественного пользования, то возможно вы хотите указывать для каждой ошибки свой тип, как это можно сделать с помощью исключений (InputException, NullPointerException и т.д.). Err позволяет как указывать на тип ошибки с помощью содержащегося в нем Throwable, так и с помощью каждого элемента из списка сообщений lst. Т.е. вы можете создавать своих наследников Err.Er. Кроме того хотелось бы отметить что благодаря implicit convertion из String в Err.Er строки можно использовать везде, где сигнатура предполагает Err.Er.

Если же вы пишете своё приложение, а не публичное api, то чаще всего в fail-fast режиме вам не важен тип ошибки, а важно лишь сообщение о ней для пользователя и местоположение для возможности исправить. В этом случае в примерах выше можно опустить все проверки типа «case _: NotFoundException» и код станет ещё более компактным.

Java и другие языки


В большинстве языков вполне можно реализовать некую монадическую сущность, наподобие описанного Res. Проблема возникает из-за того, что вся сила Res раскрывается в основном внутри оператора for для scala или do для haskell. Т.е. необходимо реализовать подобный оператор. В принципе, с помощью указанных в начале статьи инструментов, мне кажется это возможным. Описание самого for...yield можно найти здесь docs.scala-lang.org/tutorials/FAQ/yield.html

Плюсы / минусы


Плюсы данного подхода по сравнению с чистой обработкой исключений:
  • Лучший контроль над ошибками;
  • возможность редактировать и дополнять сообщения в вышестоящих функциях;
  • меньший расход ресурсов на исключениях: для указания позиции используется только одно исключение, а для подъема по стеку — легковесное исключение без стека вызовов;
  • иногда значительное уменьшение количества кода;

Плюсы по сравнению со способами, использующими монады по ссылкам (Try, \/, ...):
  • Данных подход работает там, где указанные в статьях методы требуют доработки и попросту неприменимы.

Минусы по сравнению с чистой обработкой исключений:
  • Значительная часть кода должна располагаться в for, что делает его вид несколько непривычным, хотя к этому можно привыкнуть.
  • Может вызывать затруднения при проектировании сверху-вниз. Т.е. когда для нижестоящих по стеку вызовов функций описывается только сигнатура без реализации, а основные усилия прикладываются к данной функции без необходимости отвлекаться на детали. Проблема может возникать из-за того, что некоторые функции не должны возвращать ошибки вообще и соответственно не должны возвращать Res. Как следствие, не всегда сразу ясна сигнатура функции.


Итого


Что хотелось бы сказать в заключении. Данный подход всё ещё обладает минусами, которые, возможно, не удастся исправить без поддержки со стороны языка. Когда-нибудь в далеком и прекрасном будущем хотелось бы увидеть язык с поддержкой легковесных конструкций для передачи значений вверх по стеку. С возможностью отслеживать текущую строчку кода и при этом не тратить ресурсы на получение стека вызовов до самого того момента, когда его нужно будет вывести в лог. А самое главное, хотелось бы иметь возможность описывать корректный результат и ошибку независимо, всегда явно и при обработке не тонуть в конструкциях типа try...catch, но также не модифицировать код под использование монад. Я верю в то, что это возможно, и даже имею некоторые мысли на счет того как, но как всегда на реализацию подобных вещей нет времени. Однако радует то, что любые идеи обычно приходят в головы разных людей независимо и параллельно, поэтому можно надеяться, что когда-нибудь обработка ошибок выйдет на новый уровень.

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


  1. Optik
    19.07.2015 10:07

    Не очень понятен профит относительно Try и \/.


    1. cs0ip Автор
      19.07.2015 10:26

      Try и \/ без помощи со стороны в некоторых ситуациях просто не работают. Ситуация с циклами и if, который должен приводить к выходу из функции. Кроме того там не хватает сервисных функций для облегчения интеграции с кодом, не поддерживающим их. Кроме того порой требуют больше кода при преобразовании значений и не позволяют без допиливания уточнять и заменять сообщения об ошибках. Т.е. идея описанного здесь Res — это доведения до более-менее удобного состояния этих самых Try и \/.


      1. Optik
        19.07.2015 10:42
        +1

        Try и \/ без помощи со стороны в некоторых ситуациях просто не работают. Можно пример для наглядности?


        1. cs0ip Автор
          19.07.2015 11:10

          Пример с fold в статье. «Пример использования Exit может выглядеть так...». fold как и прочие циклические функции из стандартной библиотеки scala нельзя прервать просто средствами самого Try или \/. Для этих целей у меня используется Exit, который удобно интегрирован с Res.

          Пример с if. Скажем у вас есть такая функция, использующая Try:

          case class F1(x: Int, y: Int)
          case class In(f1: F1, f2: Int)
          case class Out(z: Int, f2: Int)
          def convF1(x: F1): Try[Int] = ???
          def convF2(x: Int): Try[Int] = ???
          def fn(x: In): Try[Out] = {
            if (x.f1.x < 0 && x.f1.y < 0)
              return Failure(new Exception("x < 0 && y < 0"))
            for {
              z <- convF1(x.f1); if (z > 0)
              f2 <- convF2(x.f2)
            } yield Out(z, f2)
          } 
          


          Пример с Res:

          case class F1(x: Int, y: Int)
          case class In(f1: F1, f2: Int)
          case class Out(z: Int, f2: Int)
          def convF1(x: F1): Res[Int] = ???
          def convF2(x: Int): Res[Int] = ???
          def fn(x: In): Res[Out] = for {
            _ <- Res(x.f1.x >= 0 || x.f1.y >= 0), "x < 0 && y < 0") //работает как assert; позволяет указать сообщение об ошибке
            z <- convF1(x.f1); 
            _ <- Res(z > 0, "z должен быть больше 0")
            f2 <- convF2(x.f2)
          } yield Out(z, f2) 
          


          Т.е. во-первых мы можем делать больше действий в пределах одного for, во-вторых можем указывать свои сообщения об ошибках.


          1. Optik
            19.07.2015 11:25

            case class F1(x: Int, y: Int) {
            require(x >= 0 && y >= 0, «x and y must be positive»)
            }
            В конкретном примере, имхо, лучше так.


            1. cs0ip Автор
              19.07.2015 11:33

              Это не совсем верно будет. В данном примере сам класс F1 может хранить любые значения и в других частях программы они допустимы. Но вот в этой функции преобразования есть свои локальные ограничения.


              1. Optik
                19.07.2015 11:40

                Согласен. Другой вариант:

                scala> Try(res3)
                res7: scala.util.Try[F1] = Success(F1(-4,3))

                scala> res7.filter(x => x.x >=0 && x.y >= 0)
                res8: scala.util.Try[F1] = Failure(java.util.NoSuchElementException: Predicate does not hold for F1(-4,3))

                При необходимости можно сделать recover и задать собственное исключение.


                1. cs0ip Автор
                  19.07.2015 11:46

                  Ну опять же: указать своё сообщение прямо на месте нельзя; необходимо создавать целое новое исключение, что во-вервых длиннее, во-вторых без доп. телодвижений теряется часть стека вызовов, а в-третьих требует больше памяти и процессорного времени. Кроме того, работа с исключениями всегда характерна тем, что их можно пропустить там, где не ожидаешь.


                  1. Optik
                    19.07.2015 12:14

                    Для упрощения разве что сделать вспомогательный метод на фильтр, где можно задать своё сообщение. Исключение все равно бросается в контексте, почему он теряется? И опять же исключение в Try — fast fail. Не вижу оверхеда.

                    p.s. в тему обсуждения _http://fsharpforfunandprofit.com/posts/recipe-part2/


                    1. cs0ip Автор
                      19.07.2015 12:24

                      В том и суть, что для упрощения надо сделать метод, и даже не один. Кроме того в Res к верхнему уровню мы имеем одно исключение чисто для стека вызовов и список сообщений об ошибках, которые могут задаваться не только строками, но и произвольными типами. При использовании Try к верхнему уровню мы имеем в лучшем случае список исключений каждое со своим стеком или одно с нерелевантным стеком.
                      Ну и на счет того, что всё кидается в контексте. А что будет если случайно контекст забыть. В случае с Try во многих случаях компилятор ничего не скажет, в случае с Res это будет явная ошибка компиляции. Поэтому я считаю важным отделить работу с ошибками, которые можно обработать, от исключений, которые никто, по хорошему, ловить не должен.


                    1. cs0ip Автор
                      19.07.2015 12:30

                      На счет статьи. Трудно её понять без знания F#. Но в закладки добавил, чтобы хоть с основными принципами по разбираться.


                      1. Optik
                        19.07.2015 14:57

                        В случае fail fast (тем более с собственными исключениями), имхо, стэктрейс не столь важен. Конечно, если это повсеместный подход в проекте. По идее исключения нужны только для обозначения ситуаций, когда контекст покинул область определения функции и это надо обрабатывать в рамках самой функции. Саму же логику строить «прямолинейно», словно всё хорошо. И лишь в конце кейса сделать выбор что сделать с результатом: просто вернуть корректный ответ или вернуть ответ-ошибку, если такая имела место.


                        1. cs0ip Автор
                          19.07.2015 15:10

                          Спасибо за краткое объяснение. А может быть действительно переведете как-нибудь статью, и может даже добавите пару примеров на других языках?


                          1. Optik
                            19.07.2015 17:35

                            Не могу обещать. Труд большой, а с литературным изложением у меня проблемы. Статья больше обзор идеи, чем готовый рецепт. Незнание F# не помеха.

                            В довесок можно еще две статьи (с дебатами в комментариях) уже на скале привести:
                            _http://underscore.io/blog/posts/2015/02/13/error-handling-without-throwing-your-hands-up.html
                            _http://underscore.io/blog/posts/2015/02/23/designing-fail-fast-error-handling.html


                    1. EvilsInterrupt
                      19.07.2015 12:53

                      /Offtop:
                      >>p.s. в тему обсуждения _http://fsharpforfunandprofit.com/posts/recipe-part2/
                      Сожет быть переведете? ;)


  1. ImLiar
    19.07.2015 13:32
    +1

    Как-то велосипедисто. И меня смущает, что `\/` и прочие монады пытаются применяться в том же императивном стиле и контексте. Вы когда правильно код монадами пишите с FF-стретегией, никакого cyclomatic complexity с десятками вложенных методов/for-yield'ов не должно быть. Разбивается всё на мелкие чистые composable-функции и помчали.

    У себя в проекте накидал implicit обертку над `Future[\/]` с `EitherT` под капотом. Очень удобно. `map/flatMap`, `ensure` есть, все из коробки работает. Что не так — упали в левую ветку с ошибкой, и всё, дальше код не выполнится.

    Если хочется собирать кучу ошибок, то есть стейт монады. Как-то так.

    Исключения в бизнес-логике — дурной тон, особенно в ФП. Вы таким образом поток выполнения прерываете ненормальным путем.


    1. cs0ip Автор
      19.07.2015 14:09

      А как вы справляетесь с циклами? Для каждого цикла создаете рекурсивную функцию с @tailcall оптимизацией?

      >Если хочется собирать кучу ошибок, то есть стейт монады. Как-то так.
      Можете привести какой-нибудь пример использования? Я о них мало знаю.

      >Исключения в бизнес-логике — дурной тон, особенно в ФП. Вы таким образом поток выполнения прерываете ненормальным путем.
      Я с этим согласен. Поэтому у себя их использую локально и ограниченно, только для транспортных целей. В этом случае обычно видно, что ничего критического не прерывается.


      1. ImLiar
        19.07.2015 15:33

        Да, для цикла рекурсии более чем достаточно. break это уже какой-то goto, только легализованный.


        1. cs0ip Автор
          19.07.2015 15:49

          Ясно. Я тоже по началу пытался рекурсию для циклов использовать. Проблема в том, что порой список параметров раздувает на пару строк, что совсем не есть удобно. Кроме того, надо понимать, что @tailcall рекурсия разворачивается в обычный while. Т.е. большинство проблем while вылезут также и в этой рекурсии.

          Кстати, на счет goto. Это как говорить, что атомная бомба абсолютное зло. Но на деле она зло, только когда взрывается рядом с тобой. Я это к тому, что ряд сценариев использования goto действительно приводит к спагетиобразному коду, в котором не разберешься, но такие частные случаи как break и continue вряд ли можно в этом обвинить. И есть ряд вполне безопасных сценариев для их использования.


  1. alan008
    19.07.2015 13:38

    А в Java в блоке catch можно перевозбудить исходное исключение без создания нового? Мы в Delphi пользуемся вложенными try catch ( try except в терминах Delphi), при этом в блоке catch просто припысываем доп. информацию в текст исключения наподобие Exception.Message = 'Ошибка при обработке файла: ' + Exception.Message и перевозбуждаем это же самое исключение на уровень выше. Т.е. у нас исключение будет одно, но с навешенными к нему приписками.


    1. cs0ip Автор
      19.07.2015 14:01

      Перевозбудить можно, а вот изменить что-то нельзя. Поэтому и приходится создавать новое.


      1. SirEdvin
        19.07.2015 14:26

        Не очень понимаю, почему нельзя сделать как-то так:

        Ошибка с возможностью перезаписи сообщения
        public class Test {
            public static class MyException extends Exception{
                private String message;
        
                public MyException(String message) {
                    super();
                    this.message = message;
                }
        
                public void setMessage(String message){
                    this.message = message;
                }
        
                public String getMessage(){
                    return message;
                }
            }
        
            public static void main(String[] args) {
                try {
                    A();
                } catch (MyException e) {
                    System.out.println(e.getMessage());
                }
            }
        
            public static void A() throws MyException {
                try {
                    B();
                } catch (MyException e) {
                    System.out.println(e.getMessage());
                    e.setMessage("Test2");
                    throw e;
                }
            }
        
            public static void B() throws MyException {
                throw new MyException("Test");
            }
        }
        


        1. cs0ip Автор
          19.07.2015 14:48

          По идее так сделать можно. Здесь скорее вопрос идеологии, состоящий в том, что изменяемые данные за пределами какой-то очень локальной области кода сами по себе уже являются злом. Затрудняют отладку, увеличивают вероятность ошибки. Здесь же предлагается передавать их через всю программу и модифицировать во многих местах, что является, так сказать, дурным тоном. Ну и кроме того, не совсем красиво выглядит тот факт, что поля, которые уже были в исключении и отвечали за обработку сообщения, так никуда и не делись. И теперь у нас есть несколько полей, отвечающих за одно и тоже, но часть из них ничего не делает.


  1. AnthonyBY
    19.07.2015 19:32

    Писал когда-то обработчик ошибок на Objective-C с использованием патерна Chain of responsibility.


  1. MaximChistov
    19.07.2015 20:38

    1. SirEdvin
      19.07.2015 20:57

      Вот можно как-то так сделать:

      optional.orElseThrow(NullPointerException::new);
      

      в нужном месте.


      1. MaximChistov
        19.07.2015 20:59

        а тогда дальше уже не Optional будет, а голое значение, которое опять заворачивать для продолжения цепочки(а они длинные бывают, это я для примера укротил)


        1. SirEdvin
          19.07.2015 21:07

          Тут в любом случае будет некий костыль, так как идея Optional заключалась в том, что бы избавится от npe, а не удобно ими управлять.
          Есть еще вариант с условием:

          if (!optional.isPresent()){
              throw new NullPointerException();
          }
          


          Сомневаюсь, что есть что-то другое. Разве что переписывать Option или дополнять его.


          1. MaximChistov
            19.07.2015 21:13

            идеально было бы что-то вроде mapStrict(mapfunc)/mapStrict(mapfunc, errorfunc), который получив на входе не null, а на выходе null бросал бы nre, и иначе вовращал бы классический optional. и реализация в принципе-то не сильно сложна, да вот интересно, может можно что-то такое сделать с помощью штатных средств…


            1. SirEdvin
              19.07.2015 21:21

              Можно засунуть выброс npe в сам map
              Как-то так:

              optional.map(el->{
                   if (el.getA()==null){
                       throw new NullPointerException();
                   }
                  return el.getA();
              });
              


              Но только этот код не вызовет npe, если el уже был null


              1. MaximChistov
                19.07.2015 21:32

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

                public int getCount()
                {
                     if(this.parent.a < 0)
                        this.parent.a = 42;
                     return count;
                }
                

                То есть компилятор так писать не запретит, но делать так очень плохо.


    1. cs0ip Автор
      19.07.2015 21:12

      Ну если на scala, то for сам и разворачивает значения и заворачивает обратно. А вот на чистой java с этим всё достаточно печально.


  1. relgames
    28.07.2015 12:07

    А если вот так? Тогда логика отдельно, обработка ошибок отдельно.

    def convertPerson(p: InPerson): OutPerson = {
      try {
        val name = convertName(p.name)
        val homeAddr = convertAddr(p.addr)
        val age = convertAge(p.age)
        val (id, wAddr) = db.query("select id, work_addr from persons where tel = ?", p.tel)
        val workAddr = addrFromStr(wAddr)
        val distance = calcAndCheckDistance(p, homeAddr, workAddr)
        OutPerson(id, name, homeAddr, workAddr, distance)
      } catch {
        case e @ _: AddressException | _: AgeException => throw new PersonException(s"ошибка при обработке человека ${p.name}", p, e)
        case e: NotFoundException => throw new PersonException(s"в базе нет данных о человеке с номером телефона ${p.tel}", p, e)
        case e: ZeroDistanceExceptin => throw new PersonException(s"похоже, что человек ${p.name} живет на работе", p, e)
      }
    }
    
    def calcAndCheckDistance(p: InPerson, homeAddr: Nothing, workAddr: Nothing) = {
      val distance = calcDistance(homeAddr, workAddr)
      if (distance == 0)
        throw new ZeroDistanceExceptin()
      distance
    }
    


    1. cs0ip Автор
      28.07.2015 13:48

      Есть ряд причин, по которым из двух зол (исключения и монады), я бы всегда выбирал монады.

      При работе с исключениями в текущем их виде мне не нравятся следующие вещи:
      — по заголовку функции может быть не понятно, что функция кидает исключение;
      — если ты его не отловил, то компилятор ничего не скажет;
      — если ты его не отловил, то его действие распространяется на вышестоящие функции и может привести к некорректному закрытия каких-то ресурсов и повреждению данных;
      — если же ты его всё-таки отлавливаешь, то часто тонешь в избыточном синтаксисе try...catch (да и throw new многовато для такой фундаментальной операции, как возврат ошибки)

      Монады частично решают эти проблемы:
      — из заголовка явно видно, что функция может завершиться с ошибкой;
      — есть оператор for, который позволяет устранить избыточность синтаксиса;
      — если вышестоящая функция явно не рассчитана на работу с ошибками нижестоящей функции, то это вызовет ошибку компиляции в большинстве случаев;
      — действие монад всегда локально и не затрагивает вышестоящие функции;


      1. relgames
        28.07.2015 14:26

        — по заголовку функции может быть не понятно, что функция кидает исключение;
        — если ты его не отловил, то компилятор ничего не скажет;
        — если ты его не отловил, то его действие распространяется на вышестоящие функции и может привести к некорректному закрытия каких-то ресурсов и повреждению данных;

        Вот мы и пришли к необходимости throws у метода, как в Java :)


        1. cs0ip Автор
          28.07.2015 14:44
          +1

          Ну в принципе да. Сама идея явного описания ошибок в заголовке мне нравится. Но с текущей реализацией в Java работать просто невозможно, т.к. на 10 строк бизнесс-логики будет 50 строк обработки исключений.