Обработка ошибок в Swift — меч и магия

http://nomothetis.svbtle.com/error-handling-in-swift
  • Перевод
Если издали видно общую картину, то вблизи можно понять суть. Концепции, которые казались мне далекими и, прямо скажем, странными во время экспериментов с Haskell и Scala, при программировании на Swift становятся ослепительно очевидными решениями для широкого спектра проблем.

Взять вот обработку ошибок. Конкретный пример – деление двух чисел, которое должно вызвать исключение если делитель равен нулю. В Objective C я бы решил проблему так:

NSError *err = nil;
CGFloat result = [NMArithmetic divide:2.5 by:3.0 error:&err];
if (err) {
    NSLog(@"%@", err)
} else {
    [NMArithmetic doSomethingWithResult:result]
}

Со временем это стало казаться самым привычным способом написания кода. Я не замечаю, какие загогулины приходится писать и как косвенно они связаны с тем, что я на самом деле хочу от программы:

Верни мне значение. Если не получится – то дай знать, чтобы ошибку можно было обработать.

Я передаю параметры, разыменовываю указатели, возвращаю значение в любом случае и в некоторых случаях потом игнорирую. Это неорганизованный код по следующим причинам:

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

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

Если во время вычисления может возникнуть ошибка, то результата может быть два:

  1. Успешный – с возвращаемым значением
  2. Безуспешный – желательно, с объяснением причины ошибки

Эти варианты взаимоисключающие – в нашем примере, деление на 0 вызывает ошибку, а все остальное – возвращает результат. Swift выражает взаимоисключение с помощью «перечислений». Вот так выглядит описание результата вычисления с возможной ошибкой:

enum Result<T> {
    case Success(T)
    case Failure(String)
}

Экземпляром данного типа может быть либо метка Success со значением, либо Failure с сообщением, описывающим причину. Каждое ключевое слово case описывает конструктор: первый принимает экземпляр T (значение результата), а второе String (текст ошибки). Вот так бы выглядел приведенный раннее код на Swift:

var result = divide(2.5, by:3)

switch result {
    case Success(let quotient):
        doSomethingWithResult(quotient)
    case Failure(let errString):
        println(errString)
}

Чуть подлиннее, но гораздо лучше! Конструкция switch позволяет связать значения с именами (quotient и errString) и обращаться к ним в коде, и результат можно обрабатывать в зависимости от возникновения ошибки. Все проблемы решены:

  • Указателей нет, а разыменований и подавно
  • Не требуется передавать функции divide лишние параметры
  • Компилятор проверяет, все ли варианты перечисления обрабатываются
  • Поскольку quotient и errString оборачиваются перечислением, они объявлены только в своих ветках и невозможно обратиться к результату в случае ошибки

Но самое главное – этот код делает именно то, что я хотел – вычисляет значение и обрабатывает ошибки. Он напрямую соотносится с заданием.

Теперь давайте разберем пример посерьезнее. Допустим, я хочу обработать результат – получить из результата магическое число, найдя от него наименьший простой делитель и получив его логарифм. В самом вычислении ничего магического нет – я просто выбрал случайные операции. Код бы выглядел вот так:

func magicNumber(divisionResult:Result<Float>) -> Result<Float> {
    switch divisionResult {
        case Success(let quotient):
            let leastPrimeFactor = leastPrimeFactor(quotient)
            let logarithm = log(leastPrimeFactor)
            return Result.Success(logarithm)

        case Failure(let errString):
            return Result.Failure(errString)
    }
}

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

func magicSpell(magicNumResult:Result<Float>) -> Result<String> {
    switch magicNumResult {
        case Success(let value):
            let spellID = spellIdentifier(value)
            let spell = incantation(spellID)
            return Result.Success(spell)

        case Failure(let errString):
            return Result.Failure(errString)
    }
}

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

Когда вещи начинают повторяться, стоит подумать о способе абстракции. И опять же, в Swift есть нужные инструменты. Перечисления могут иметь методы, и я могу избавиться от необходимости в этих выражениях switch с помощью метода map для перечисления Result:

enum Result<T> {
    case Success(T)
    case Failure(String)

    func map<P>(f: T -> P) -> Result<P> {
        switch self {
            case Success(let value):
                return .Success(f(value))
            case Failure(let errString):
                return .Failure(errString)
        }
    }
}

Метод map назван так, потому что преобразует Result<T> в Result<P>, и работает очень просто:

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

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

func magicNumber(quotient:Float) -> Float {
    let lpf = leastPrimeFactor(quotient)
    return log(lpf)
}

func magicSpell(magicNumber:Float) {
    var spellID = spellIdentifier(magicNumber)
    return incantation(spellID)
}

Теперь заклинание можно получить так:

let theMagicSpell = divide(2.5, by:3).map(magicNumber)
                                     .map(magicSpell)

Хотя от методов можно и вообще избавиться:

let theMagicSpell = divide(2.5, by:3).map(findLeastPrimeFactor)
                                     .map(log)
                                     .map(spellIdentifier)
                                     .map(incantation)

Разве не круто? Вся необходимость в обработке ошибок убрана внутрь абстракции, а мне нужно только указать необходимые вычисления – ошибка будет проброшена автоматически.

Это, с другой стороны, не значит, что мне больше никогда не придется использовать выражение switch. В какой-то момент придется либо вывести ошибку, либо передать результат куда-то. Но это будет одно единственное выражение в самом конце цепочки обработки, и промежуточные методы не должны заботиться об обработке ошибок.

Магия, скажу я вам!

Это все – не просто академические «знания ради знаний». Абстрагирование обработки ошибок очень часто применяется при трансформации данных. Например, частенько бывает нужно получить данные с сервера, которые приходят в виде JSON (строка с ошибкой или результат), преобразовать их в словарь, потом в объект, а потом передать этот объект на уровень UI, где из него будет создано еще несколько отдельных объектов. Наше перечисление позволит писать методы так, будто они всегда работают на валидных данных, а ошибки будут пробрасываться между вызовами map.

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



Если вы разбираетесь в математике, вы наверняка заметили баг в моем примере. Функция логарифма не объявлена для отрицательных чисел, и значения типа Float могут таковыми быть. В таком случае, log вернет не просто Float, а скорее Result<Float>. Если передать такое значение в map, то мы получим вложенный Result, и работать с ним так просто не получится. Для этого тоже есть прием – попробуйте придумать его самостоятельно, а для тех, кому лень – опишу в следующей статье.
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 27
  • +1
    Очень упрощенный пример… Как оно в реальной жизни будет работать, когда функции не будут такие замечательные — обязательно с одним параметром? Я подозреваю, что этих проверок switch + Success + Failure будет как проверок «if( res != NULL )» в С, или не будет вообще (оставят без проверок) — уж больно многословные и код загромождают.
    Как по мне, лучше exceptions для обработки ошибок пока ничего не придумали.
    • 0
      В swift можно каррировать функции, так что проблемы с количеством параметров толком нет.
      • +2
        В статье по ссылке сам автор статьи пишет — "… Я не уверен, что я найду им применение в Свифте..."

        I agree that curried functions are pretty odd at first. They definitely are an acquired taste! I’m not sure if I will find them useful in Swift, time will tell.


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

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

        Я не ради холивара — я понять пытаюсь…
        • +1
          Имхо, автор не изобрел ничего нового. В данном случае map — обычная монада, в которую вводятся данные и над которой производится цепочка вычислений. Это не значит, что всё нужно делать через нее — скорее думать головой и использовать ее там, где это действительно улучшило бы качество кода.

          Каррирование — тоже не такая уж дикая вещь. Напрямую он мало где пока поддерживается, но точно того же результата можно достичь практически в любом другом языке с помощью анонимных функций. В Swift, если я правильно понимаю, передать функцию с несколькими аргументами в map и не потерять в читаемости можно было бы вот так:

          getData().map({result in processData(result, scheme: someScheme)})
          
          • 0
            А используя trailing closure syntax, даже чуть приятнее:
            getData().map() { result in processData(result, scheme: someScheme) }
            • +1
              .map это всё-таки функтор, а не монада.
      • +1
        Замечательная статья — плюсую.
        Но это лишь самая первая статья Alexandros Salazar из серии блестящих статей, очень осторожно подводящих нас, пришедших из Objective-C и несведущих в основных концепциях функционального программирования, к понятиям Монады (Monads), Функторы, Аппликативные Функторы (Applicative Functors) и Каррирование (Currying) в Swift.
        В заключительной статье The Culmination: Final Part (Кульминация — Финальная часть) он приходит к монадическим операторам, которые сейчас отсутствуют в Swift, но по мнению автора являются «Магическим будущим» Swift.
        В моем арсенале семь его статей на эту тему.
        Продолжайте выкладывать статьи Alexandros Salazar.
        В этих статьях с целью краткости и простоты изложения приводится лишь набросок кода, иллюстрирующий ту или иную идею.
        Поэтому я изучала его статьи с параллельным кодированием в Swift — «Управление ошибками и Optionals в Swift – интерпретация в коде».
        • –1
          Это жесть какая-то.
          В С++, Java и других языка, поддерживающих исключения код будет выглядеть гораздо проще. Вот примерно так.

          try {
              float quotient = divide(1,0);
          }
          catch(ZeroDivisionException e) {
              println("zero division");
          }
          


          А тут тот же Objective C вид сбоку.
          • 0
            Я не уверен, почему в Swift отказались от реализации механизма исключений — скорее всего, из-за проблем совместимости с Objective C и ручным управлением ресурсами. Субъективно мне тоже механизм исключений кажется наиболее логичным способом уведомить об ошибке, но обработку ошибок (особенно если общаемся с сервером и нужен auto-retry) иногда нет-нет да и хочется обернуть в аналогичный функтор. Кроме того, было бы интересно сравнить производительность перехвата исключений и обработки Result.

            • 0
              Думаю что это получилось сразу по нескольким причин:
              Во-первых, из-за совместимостью с obj-c,
              И во-вторых, из-за того что Swift взял курс на функциональщину по сравнению с obj-c.
              Дело в том, насколько я знаю, исключения (такие как в C++ и Java) не очень дружат с функциональщиной, так как выброс исключения это по сути side-effect.
              • –1
                Ну Страуструп как-то добавил исключения в С++ не убив из-за этого совместимость С++. А про сайд-эффект, так передача индикатора неудачного результата по цепочке это точно то же самое, что исключения, только с бойлерплейтом и без оптимизаций компилятором. Сайд-эффект же не в самом выбросе исключения, а потенциально в его обработке.
                • +2
                  Нет, сайд эффект как раз в выбросе исключения.

                  Есть вот такой пример, в языке Scala есть монада Try.
                  Суть в том, что на самом деле Try технически не является монадой, т.к. нарушается первый закон
                  f(x) == unit(x).bind(f).
                  (где unit(x) — это конечно Success(x)).

                  Допустим f(x) = x / 0.
                  Получается
                  x / 0 == Success(x).bind(_ / 0)
                  Разворачиваем правую сторону.
                  x / 0 == try { Success(x / 0) } catch (e) { Failure(e) }

                  Очевидно что левая и правая стороны не равны, так как в правой стороне мы получим Failure(DivByZero), а в левой стороне мы НЕ получим результата, но при этом будет выброшено исключение.

                  Так если исключения менее «функциональны», то зачем их вставлять в язык? Лучше уж переиспользовать существующие механизмы, особенно если они лучше ложатся на функциональный подход.
                  • –1
                    Всегда был уверен, что сайд эффект — это когда функция делает что-то помимо возврата результата. Тут не так. Тут как раз что справа, что слева происходит одно и то же. Только в случае с исключениями процесс автоматизирован, а в случае с вводом перечисления как результата все контролируется вручную. Наверное есть какие-то ситуации, когда это к лучшему, но в стандартных случаях это как правило минус.

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

                    Преимущества функционального подхода (помимо эстетических) тут вообще не совсем понятны. Или в Swift есть возможность объявить чистую функцию? Или компилятор умеет доказывать её чистоту?
                    • +1
                      >Механизм исключений позволяет не вводить дополнительного возвращаемого типа

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

                        И второй пункт. Вы хотите сказать, что в примере из статьи сигнатура функции отражает что-то кроме того, что в ней может возникнуть исключительная ситуация?
                      • +1
                        Всегда был уверен, что сайд эффект — это когда функция делает что-то помимо возврата результата.

                        Все несколько сложнее.

                        Чистота функции это не совсем про «делать что-то помимо возврата результата». Давайте попробуем посмотреть на следующий псевдокод:
                        val m: Map[Int, Int]
                        def get(k: Int): Int {
                          var v = m.get(k)
                          if (v == null) {
                            v = /* pure calc */
                            m.put(k, v)
                          }
                          return v
                        }
                        


                        Является ли этот метод чистым? Он ведь что-то делает помимо возврата результата.

                        Механизм исключений позволяет не вводить дополнительного возвращаемого типа, позволяет не вводить метод map и при этом писать понятный и простой код.


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

                        Также позволяет не писать в функциональном стиле, если тебе это не нравится и писать когда это приятно.

                        Мне показалось вы допускаете использование исключений в функциональном коде. По моему опыту лучше этого избегать.

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

                        Если Вам действительно интересно, то, наверное, Вам стоит поближе познакомится с ФП языками и с тем как ФП применяют в императивных языках. Из меня, увы, плохой рассказчик.
                        • 0
                          Ну метод явно нечистый, поскольку сайд-эффект налицо.

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

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

                          О проверке типов компилятором и четком понимании на каком участке кода какие ситуации могут возникнуть тут речи нет, вернее компилятору понять такой код не проще, чем код с исключениями.

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

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

                          И если бы в обмен на отсутствие исключений мы получили возможность объявить чистую функцию, результаты выполнения которой можно кешировать и параллелить, то это можно было бы декларировать фичей языка. Но я так понял об этом нет и речи. Может есть хотя бы модификатор const для методов, как в С++?
                          • 0
                            Уважаемый poxu, я не имею ни желания ни возможности разводить холивары и повторять о том, что я уже писал выше.
                            Если Вы хотите понять почему же исключения не приживаются в ФП, каким таким волшебным образом код становится безопаснее и чище, да и еще от добавления каких то непонятных «дополнительных» возвращаемых типов, которых еще и руками нужно обрабатывать, то Вам придется либо самому все потрогать, либо дождаться другого собеседника.
                            • 0
                              Жаль конечно, но что ж поделаешь :).

                              Может у вас найдётся время дать ссылку на что-нибудь демонстрирующее преимущества описанного в статье подхода по сравнению с исключениями? Если нет — в любом случае, хорошо побеседовали :).
                      • 0
                        >> а в левой стороне мы НЕ получим результата, но при этом будет выброшено исключение.

                        Это вопрос терминологии. Если исключение считать результатом (т.е. если рассматривать любое выражение в языке как Succes(value)|Error(exception), то все ок. Просто семантика == в данном случае не эквивалентна нормальной функциональной, но это уже другой разговор.
                • +2
                  Ну и отлично, меньше любителей исключений будут писать на Swift ;)
                • 0
                  А в свифте можно поднимать в монаду Maybe функции произвольной арности? Хотя бы бинарные? Или — только унарные, только через функцию-член .map?
                  • 0
                    Эта структура на Хаскеле пишется так (и находится в стандартной библиотеке):

                    data Either a b = Left a | Right b
                    
                    instance Functor (Either a) where
                        fmap _ (Left x)  = Left x
                        fmap f (Right y) = Right (f y)
                    

                    Хочу заметить, что можно параметрезировать и «ошибочную» часть
                    • 0
                      В Swift можно использовать тип Either<A, B>. Это позволит нам вернуть пользователю объект как в случае, если все проходит успешно, так и в случае возникновения ошибки. В Swift можно так реализовать тип Either<A, B>:

                      enum Either<A, B> {
                        case Left(A)
                        case Right(B)
                      }
                      


                      Мы можем использовать Either<NSError, User> в качестве типа, который на финальном этапе преобразований может быть обработан так:

                      switch either {
                        case let .Left(error):
                          // показываем сообщение об ошибке (error) 
                      
                        case let .Right(user):
                          // делаем что-то с user
                        }
                      


                      Мы немного упростили это, предполагая, что Left всегда будет NSError.
                      Вместо этого используется подобный, но другой тип Result <A>, который будет содержать либо значение, которое мы ищем, либо ошибку. Его реализация выглядит так:

                      enum Result<A> {
                        case Error(NSError)
                        case Value(A)
                      }
                      


                      В настоящий момент в Swift перечисления enum не могут быть дженериками (generic) на самом топовом уровне, но, могут быть представлены как generic, если их обернуть в «постоянный» class box:

                      final class Box<A> {
                        let value: A
                      
                        init(_ value: A) {
                          self.value = value
                        }
                      }
                      
                      enum Result<A> {
                        case Error(NSError)
                        case Value(Box<A>)
                      }
                      


                      И финальное «раскрытие» result: Result <User> будет выглядеть так:

                      switch result {
                        case let .Error(error):
                          //  показываем сообщение об ошибке (error) 
                      
                        case let .Value(boxedUser):
                          let user = boxedUser.value
                          // делаем что-то с user
                        }
                      

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.