Исключения – это базовый элемент многих языков программирования. Они обычно используются для обработки аномальных или непредусмотренных условий, при устранении которых необходим особый подход, нарушающий нормальный поток задач в приложении. В некоторых языках, например, в C++ или Java, исключения используются повсюду. Но не все языки спроектированы так. В C# или Kotlin нет проверяемых исключений. В других языках, например, Go и Rust, исключений нет вообще.

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

Различные типы ошибок

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

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


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

Как это обычно выглядит?

Давайте для примера воспользуемся сервисом бекенда, предоставляющим REST API. Простая архитектура выглядит так: тонкий контроллер вызывает сервис, который, в свою очередь, вызывает сторонний API — такая структура выглядит хорошо тестируемой и прямолинейной. Пока не столкнемся с условиями, приводящими к ошибке.

Если не обработать исключение, оно будет всплывать до тех пор, пока API не вернет ошибку 500 вызывающей стороне. Нам такого не надо.

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

@ExceptionHandler(JWTVerificationException::class)

fun handleException(exception: JWTVerificationException): ResponseEntity<ErrorMessage> {

    return ResponseEntity

      .status(HttpStatus.BAD_GATEWAY)

      .body(ErrorMessage.fromException(exception))

}

Когда из-за исключений становится сложнее понимать поток программы

Допустим, наш сервис из вышеприведенной схемы должен проверить веб-токены JSON. Идея проста. Мы получаем JWT в виде строки и хотим знать, является ли она действительным токеном. Если является, то мы хотим получить конкретные свойства, которые обернем в TokenAuthentication. Следующий интерфейс определяет именно это:

interface Verifier {

    / **

     * @param jwt a jwt token

     * @return authentication credentials

     * /

    fun verify(jwt: String): TokenAuthentication
}

Сигнатура, не сообщающая, какова ситуация на самом деле

Если закопаться в реализацию Verifier, то в конечном итоге найдется нечто подобное:

/ **

 * Выполнить верификацию применительно к конкретному токену

 *

 * @param token to verify.

 * @return a verified and decoded JWT.

 * @throws AlgorithmMismatchException 

 * @throws SignatureVerificationException

 * @throws TokenExpiredException

 * @throws InvalidClaimException

 * /

public DecodedJWT verifyByCallingExternalApi(String token);

Как упоминалось выше, в Kotlin нет проверяемых исключений. Но это означает, что сигнатура Verifier в данном случае врет! Этот метод может выбросить исключение. Единственный способ понять, что именно происходит – посмотреть на реализацию. Тот факт, что за этой информацией приходится лезть в реализацию, явно свидетельствует, что инкапсуляции не хватает.

Явный подход

Таким образом, хочу изменить в реализации Verifier две вещи.

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

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

Знакомьтесь с Either (тадам!...).

Тип данных Either

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

В нашем случае Either  то сущность, значение которой может быть одного из двух типов, «левое» и «правое». Есть соглашение, по которому Right соответствует успеху, а Left – ошибке. Этот паттерн распространен в сообществе функционального программирования. Он позволяет выразить тот факт, что вызов может вернуть либо корректное значение, либо ошибку, а также различать их. Но принцип именования Left/Right – это просто условность. Он может помочь тем, кто пользуется номенклатурой существующих библиотек. Можно воспользоваться иным соглашением, более понятным в вашей команде, например, Error/Success.

Можно создать простую реализацию при помощи запечатанных классов. Ниже показано, как они сочетаются с выражением when, чтобы код стал чище и в то же время безопаснее.

sealed class Either<out L, out R> {

    data class Left<out L, out R>(val a: L) : Either<L, R>()

    data class Right<out L, out R>(val b: R) : Either<L, R>()

}

fun <E> E.left() = Either.Left<E, Nothing>(this)

fun <T> T.right() = Either.Right<Nothing, T>(this)

Давайте перепишем наш код с использованием Either.

Адаптируем интерфейс

Теперь класс Verifier возвращает тип Either, чтобы указать, что вычисление может закончиться неудачно.

interface Verifier {

    / **

     * @param jwt a jwt token

     * @return authentication credentials, or an error if the validation fails

     * /

    fun verify(jwt: String): Either<JWTVerificationException, TokenAuthentication>

}

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

Обертываем код, выбрасывающий исключение

Внутри нашей реализации Verifier мы обертываем проблемный код в метол расширения под названием unsafeVerify. Используем методы расширения, определенные выше, чтобы создать обе стороны Either:

private fun JWTVerifier.unsafeVerify(jwt: String): Either<JWTVerificationException, TokenAuthentication> = try {

    verifyByCallingExternalApi(jwt).right()

} catch (e: JWTVerificationException) {

    e.left()

}

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

Использование в качестве клиента

Реализация готова. Как же теперь использовать ее на вызывающей стороне? Мы хотим принимать решение о дальнейших действиях в зависимости от того, увенчались ли вычисления успехом.

Можем использовать выражение when, благодаря тому, что определили Either как запечатанный класс.

val result = verifier.verify(jwt)

when (result) {

    is Either.Left -> ResponseEntity.badRequest().build()

    is Either.Right -> ResponseEntity.ok("Worked!")

}

То же самое, что использовать функциональный метод fold. При помощи свертки мы выдаем результат сразу для обоих возможных значений.

Операции над значением Either

Я только что показал вам, как обращаться с Either, основываясь на двух возможных значениях (левом и правом). Но мы также оперируем значением в пределах всего приложения, и при этом не нужно каждый раз разворачивать его и обертывать обратно – в противном случае код снова стало бы сложно читать.

Нужно расширить Either двумя новыми методами, map и flatMap. Начнем с map:

fun <L, R, B> Either<L, R>.map(f: (R) -> B): Either<L, B> = when (this) {

    is Either.Left -> this.a.left()

    is Either.Right -> f(this.b).right()

}

Мы хотим применить функцию к тому значению, которое содержится у нас внутри Either. Either сдвинут вправо; это означает, что, как только он принимает значение Left (соответствующее ошибке), дальнейшие вычисления не применяются. Возвращаясь к нашему методу ‘unsafeVerify, мы хотим преобразовать результат данного вызова, что и сделаем при помощи нашего нового метода map:

verifier

    .unsafeVerify(jwt)

    .map { it.asToken() }

Нам еще остается закрыть один случай. Что, если операция, которую мы хотим применить, сама возвращает Either? Если мы используем словарь, то вернем Either от Either и будем так вкладывать типы до тех пор, пока это не станет невозможно. Чтобы это предотвратить, воспользуемся новым методом flatMap.

fun <L, R, B> Either<L, R>.flatMap(f: (R) -> Either<L, B>): Either<L, B> = when (this) {

    is Either.Left -> this.a.left()

    is Either.Right -> f(this.b)

}

Если хотите глубже покопаться в этих функциональных концепциях – почитайте эту статью.

Стрелочная библиотека

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

Одна интересная черта библиотеки Arrow – в том, что в ней предоставляется собственная разновидность нотации do , позволяющая значительно упростить сцепленные операции над типами. В стрелочной библиотеке это называется выделение монад.

Нормальная цепочка операций могла бы выглядеть так:

request.getHeader(Headers.AUTHORIZATION)

  .toEither()

  .flatMap { header ->

    header.extractToken()

      .flatMap { jwt ->

        verifier

          .verify(jwt)

          .map { token ->

            SecurityContextHolder.getContext().authentication = token

        }

      }

  }

Можно избавиться от синтаксиса с вложениями, и получится так:

Either.fx {

    val (header) = request.getHeader(Headers.AUTHORIZATION).toEither()

    val (jwt) = header.extractToken()

    val (token) = verifier.verify(jwt)

    SecurityContextHolder.getContext().authentication = token

}

Думаю, этот код проще читать, он напоминает синтаксис async/await, применяемый с промисами в JavaScript.

Приложение: встроенное решение

В Kotlin 1.3 и выше существует встроенный способ работы с вычислениями, которые могут не удаться. Это класс Result, обычно используемый в блоке runCatching:

runCatching {

    methodThatMightThrow()

}.getOrElse { ex ->

    dealWithTheException(ex)

}

Этот класс еще нельзя использовать в качестве возвращаемого типа, поэтому и в нашем интерфейсе он неприменим. Более того, Either красиво интегрируется со всем прочим функционалом стрелочной библиотеки.  

Заключение

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

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

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


  1. alex1t
    17.11.2021 10:33
    +4

    Мы пишем на C# и используем такой же подход, только у нас тип называется Result<T>. В целом с ним удобнее оказалось. Вдохновлялись Railway-Oriented-Programming и библиотекой Виктора Хорикова - CSharpFunctionalExtensions


  1. Xop
    17.11.2021 11:08
    +9

    Ну почему, почему, почему опятьEither<Left,Right>, а не Result<Ok,Err>? Единственное, что тут немного радует - всё-таки догадались в котлине сделать flatMap над Either асимметричным, в отличие от скалы и тайпскрипта, что упрощает его применение для обработки ошибок, но добавляет легкий разрыв шаблона.


    1. mayorovp
      17.11.2021 11:18

      Э-э-э, а где в тайпскрипте вообще тип Either и его flatMap?


      1. Xop
        17.11.2021 11:28

        Возможно с TS чуть погорячился - просто свежи воспоминания, как немного полыхнуло с этой статьи. В целом да - в ядре Either нет, но зато есть fp-ts, которая насколько я заметил довольно популярная и много где рекомендуется к использованию, и там как раз есть симметричный Either.


    1. GeorgeII
      18.11.2021 10:13
      +2

      Что вы имеете в виду под симмитричным? Что Left и Right равноценны? В скале это исправили годах в 2016-17 и теперь:
      Either is right-biased, which means that Right is assumed to be the default case to operate on. If it is Left, operations like map and flatMap return the Left value unchanged.


      1. Xop
        18.11.2021 17:31

        Ух ты, и правда, спасибо большое. Похоже у меня уже Indian code induced mental damage


  1. codefun
    17.11.2021 11:28

    в этом подходе не очень нравится то что теперь существует оба варианта обработки ошибок параллельно. В теории все выглядит хорошо, но я видел проекты в которых писали с таким подходом и выходила мешанина. Поскольку Either это надстройка, а сам язык работает с исключениями, то в итоге приходится иметь дело и с исключениями, и с Either. Я видел такое и по итогу выходило что параллельно существовали одни и те же ошибки в виде исключений и объектов, и код который в try обрабатывал ошибки в ФП стиле, но и catch все равно ставил, т.к. исключения по прежнему возможны. Either выглядит как workaround, потому что нет проверяемых исключений. Плюс это возврат к старому С-стилю кодов возврата, только с небольшим синтаксическим сахарком. Насколько я понял, каких-то дополнительных статических гарантий Either не дает, и вполне можно пропустить ветку обработки ошибок точно так же как и проигнорировать исключения, или нет?


    1. mayorovp
      17.11.2021 11:56
      +2

      Пропуск ветки обработки ошибок приведёт к исключению.


  1. grossws
    17.11.2021 13:53
    +3

    Смешались в кучу either/result/validation.

    Для разумного удобства работы с either нужны проекции, иначе начинают плодиться mapLeft/mapRight и далее по тексту.

    Обработка ошибок и валидация - несколько разные задачи. В первой хочется fail fast, во второй - не столько.

    При обработке ошибок можно использовать either как poor man's result/try. В реальности result/try должен перехватывать не всё throwable, а только те из которых есть шанс восстановиться (и это зависит от контекста). Можете ли вы в данном случае пережить, скажем, oom или нет.

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


  1. AMDmi3
    19.11.2021 23:31
    +2

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

    Это утверждение требует доказательства, без которого вся статья не имеет смысла. Мой опыт показывает обратное - только при использовании исключений код будет прозрачным, очевидным и сконцентрированным на том что мы делаем, а не на том что может пойти не так. Код обработки ошибок будет обособлен в ограниченном числе мест на верхнем уровне где мы можем сделать что-то осмысленное, а где-то вообще не нужен - консольное приложение пусть просто упадёт (в Python будет готовый трейс, в C++, ок, придётся обернуть main в try чтобы распечатать what()). В веб бэкенде исключение перехватится на уровне обработки запроса и клиенту вернётся 503. В GUI приложении я бы обернул,например, только целиком код open/save чтобы сказать "не шмогла" и продолжить работу.

    Все же подходы с возвратом значений заставляют думать об обработке ошибок в каждой строке, на каждом шаге. Даже если это сделано как в rust - односимвольным оператором, мне все равно надо думать, возвращает ли эта функция голое значение или Result, хочу ли я ? или !, и что будет если сторонний код который я вызываю вызовет panic? Если функция не возвращала ошибку, но начала возвращать, мне придётся менять все её вызовы (в полюсах есть похожая проблема с noexcept, но он про оптимизацию и его можно, а в публичных интерфейсах, пожалуй, и нужно не использовать.

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


    1. AnthonyMikh
      20.11.2021 20:00
      +1

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

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


      1. AMDmi3
        20.11.2021 20:33
        +2

        Нет, не приводит, потому что перехватывается std::exception.


        1. AnthonyMikh
          23.11.2021 20:09

          … Который код кидать не обязан, ибо в качестве исключения может выступать значение произвольного типа.


          1. AMDmi3
            24.11.2021 16:15
            +1

            Это не имеет значения потому что есть catch(...), а вообще код который кидает что-то не унаследованное от std::exception можно смело приравнивать к ill-formed.