Блог

Интересное

Orangesoft

Размышления об ошибках в Swift 2
Александр Евсюченя, iOS Developer


Литературный перевод статьи «Thoughts on Swift 2 Errors», Nick Lockwood.


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

Поэтому я был удивлен, когда узнал, что в Swift 2 добавили традиционную схему обработки исключений.

Похожим образом были удивлены функцональные Swift программисты, которые обрели свою веру в Haskell-стиле обработки ошибок, где каждая функция возвращает enum (или monad, если желаете), содержащий валидный результат или ошибку. Это выглядело лаконичным решением для Swift 1.x, так почему же Apple сделала выбор в пользу решения, предназначенного для неуклюжих императивных языков?

Я собираюсь разобраться в 3 вопросах в этом посте:

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

2. Почему enum, как возвращаемое значение функции, не является универсальным решением для обработки ошибок в Swift.

3. Что я сделал вместо этого.


1. Исключение, которое подтверждает правило

Почему Apple говорит нам, что исключения — плохой способ обработки ошибок, а потом презентует исключения как способ обработки ошибок в Swift? Или это очередной пример искажения реальности, среди таких легенд, как «7-дюймовые планшеты слишком маленькие», «5-дюймовые телефоны слишком большие» и т. д.

И да и нет. Исключения считаются/считались плохими по двум причинам — технической и проблемой удобства использования:

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

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

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

И исключения в Swift абсолютно такие же: это всего лишь синтаксический сахар вокруг нового типа возвращаемого значения. В нем нету затратного раскручивания стека, так как программа уходит от своего нормального исполнения, пропуская элементы стека до того момента, пока не достигнет ближайшего catch; Решение в Swift уменьшает количество кода в сравнении с передачей указателя на NSError в Objective-C, при этом присутствует обратная совместимость.

Но, за исключением реализации, обработка ошибок в Swift имеет практически такой же синтаксис и семантику как и checked exceptions в Java. Мы получаем ту же громоздкость в коде, но без затрат в производительности, которые есть в Java.

Поэтому «не используйте исключения для контроля выполнения программной логики» — это не что-то вроде примера хороших практик при написании Swift-кода, а вопрос чисто-технического удобства. Они нам говорят, что исключения в Objective-C неэффективны и поэтому вы не должны их использовать, но с обработкой ошибок в Swift всё ОК.

Но действительно всё ли ОК с ними? Что же, разберемся и с этим

2. Функциональный императив

Повторяйте за мной: «Swift — это не Haskell!»

Методы в Swift имеют побочные эффекты. Объекты в Swift имеют изменяемое состояние. Синглтоны живы и чувствуют себя в порядке в Swift’e. Swift не пришел с ленивыми-по-умолчанию вычислениями или монадами, или сигналами, или «обещаниями», или ключевым словом «await», или «do» выражениями из Haskell. Писать чистый функцональный код в Swift затруднительно и непрактично, потому что у всего из системного API, которое ты обязан использовать, чтобы сделать свои непосредственные задачи, нету никакого интереса в твоих функциональных стремлениях.

Как сообщество, мы с радостью приняли enum’ы в Swift, потому что они намного проще и изящнее в использовании нежели старый Objective-C подход с передачей указателя на ошибку и последующую проверку на nil или false результат функции. Но давайте будем честны — так получилось по большей причине из-за того, что в Swift старый механизм неудобен в использовании, в сравнении с Objective-C, «благодаря» опционалам, более строгой типизации и обфускации указателей. К тому же передача nil указателя в функцию и проверка является ли он всё ещё nil по завершению выполнения — это не просто «не Swift-way» решения проблем.

Я не утверждаю, что enum не являются объективно лучше, чем error параметры. Просто удобство это основная причина, почему люди так полюбили их в Swift.

Брэд Ларсон написал хорошую статью о том, почему ему так понравились результирующие enum’ы и почему он всё равно переходит на новую Swift 2 систему обработки ошибок. Основная причина в том, что новая система позволяет ему делать те же вещи, но с меньшим количеством кода.

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

Но

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

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

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

То же самое и с указателями на ошибку в Objective-C (про которые многие разработчики забывают и просто передают NULL). Механизм, при котором ошибка возвращается, не так важен — гораздо большую роль играет обработка ошибки на вызывающей стороне. И это то, что исключения в Swift улучшили заменой бесконечных if (error != nil) { return error } на выражение try.

Но try работает только в случаях с последовательными императивными выражениями. И если Брэд Ларсон работал бы над чем-нибудь вроде сетевой библиотеки вместо контроллера робота, результирующие enum’ы подошли бы намного лучше Swift 2 исключений, потому что exception-стиль ошибок не совсем работает для асинхронных, callback-ориентированных API. Рассмотрите следующий пример:

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

sendRequest(url: NSURL, callback: { result: Result in 
  switch result {
    case let .Success(result): // обработка успешного завершения
    case let .Failure(error): // обработка ошибки
  }
}

Внутри sendRequest может делать что-то вроде этого

sendRequest(url: NSURL, callback: callback) {
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
    var result, error;
    // do something that takes a long time and either yields a result or an error
    if (result) {
      dispatch_async(dispatch_get_main_queue()) {
        callback(.Success(result))
      }
    } else {
      dispatch_async(dispatch_get_main_queue()) {
        callback(.Failure(error))
      }
    }
  }
}

Как бы это работало с использованием исключений? Если мы попробуем бросить ошибку внутри dispatch блока, мы застрянем, потому что GCD будет обрабатывать ошибку, а не наша вызывающая функция (которая закончила своё выполнение к этому моменту), и не наша callback функция (которая никогда не будет вызвана). Мы не сможем с пользой здесь использовать наши try и catch.

И оборачивание этих callback’ов в API, основанное на обещаниях, не принесет никаких изменений; если наша функция sendRequest будет возвращать обещание вместо callback’a, нам всё ещё необходимо обрабатывать ошибки внутри путём передачи ошибки как аргумент в блок/замыкание, а не возвращая её из функции, где она была брошена. Мы не можем возвращать исключение вместо обещания, потому что в этом случае мы ещё не знаем завершился запрос успешно или нет.

Результирующие типы и традиционные Objective-C ошибки могут быть использованы как возвращаемые значения или как аргументы callback’a, что делает их универсальными для синхронной и асинхронной логик. Исключения просто особый тип возвращаемого значения и поэтому они могут быть переданы как аргумент функции, try/catch семантика для из использования может быть применена только если они бросаются (возвращаются) из функции, а не передаются.

3. Решение, подходящее для Swift

Так если не-совсем-новые не-совсем-исключения в Swift хорошо работают для синхронного, императивного кода, и enum’ы подходят для чистых функций и асинхронных callback’ов или обещаний, то всё хорошо, верно? Система работает.

Нет.

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

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

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

Поэтому, может, мы оставим ключевое слово try, но изменим значение на «если этот код возвращает ничего другого, кроме ErrorType, ничего не делать, иначе выбросить нас из текущей функции и вернуть ошибку». Это может выглядеть так:

func doSomethingThatMayFail() -> ErrorType? {
  ...
}

func doSomeThings() -> ErrorType? {
  try doSomethingThatMayFail() // если функция возвращает nil, то продолжаем выполнение, иначе возвращаем ошибку
  doTheNextThing()
}

Функция doSomethingThatMayFail не возвращает ничего по умолчанию, но вернет ошибку, при ее наличии, что отражено возвращаемым значением ErrorType?. Это то же самое, что написать func doSomethingThatMayFail() throws в Swift 2, но без необходимости нового ключевого слова.

Мы можем вызвать функцию doSomethingThatMayFail() двумя способами — мы можем сохранить её возвращаемое значение или мы можем использовать try. Использование try является эквивалентом следующего кода:

func doSomeThings() -> ErrorType? {
  if let error = doSomethingThatMayFail() {
    return error
  }
  doTheNextThing()
}

Игнорирование результата полностью будет ошибкой компиляции (или просто предупреждением, и поэтому это не замедлит прототипирование). Использование try внутри функции, которая ге возвращает ErrorType также вызовет ошибку/предупреждение на этапе компиляции.

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

А что делать в случае функций, которые возвращают значение при успешном завершении? Как их использовать с try? Что же, в этом случае необходимо использовать enum следующего вида в качестве возвращаемого значения:

enum Result<T: Any, U: ErrorType> {
  case Success(T):
  case Failure(U)
}

И try в этом случае декомпозирует это в Success значение, если функция не вернет ошибку:

let foo = try functionThatReturnsFooOrAnError()

Выполнение такой декомпозиции в общем случае выглядит «жульничеством», так как она будет включать выделение типа ошибки из enum для формирования нового типа. Но если бы Result был стандартной конструкцией, как опционалы, это было бы гораздо проще. Вместо произвольного enum типа «ErrorType + что-либо-ещё», функции должны бы были возвращать или Result или ErrorType? и вызываться с try.

(Комментарий: я злоупотребляю в статье модификатором ? в значении «или void» вместо «или nil», но чем больше я думаю об этом, тем больше я убеждаюсь в том, что это одно и то же. В идеальной версии Swift я не вижу необходимости, чтоб nil и void имели разные концепции. Преимуществом этого может быть то, что функции, которые возвращают ErrorType? не должны явно возвращать nil при успешном выполнении, они просто используют голый return, или достигают } без указания return, подразумевая успешное завершение.)

А что по поводу асинхонных callback’ов — поможет ли новое значение try здесь? Или здесь нету ничего лучше, чем существующая в Swift 2 система исключений.

Что ж, если мы посмотрим как, используя try, выполняется декомпозиция ResultвErrorTypeи валидное значение, то можем увидеть параллель с тем какif letилиguard let` используется для декомпозиции опционалов.

Также как guard let x = x принимает опционал и возвращает non-nil значение или уходит из контекста выполнения, try делает то же самое для Result. Поэтому для callback’ов мы можем использовать такой же вид логики:

sendRequest(url: NSURL, callback: { result: Result in 
  let value = try result // decomposes Result into value/error, returns if error 
  // do something with result
}

Этот подход требует, чтобы наш callback имел возвращаемое значение типа ErrorType? вместо void, но это значит, что мы можем использовать такую же логику в асинхронных callback’ах как и в синхронных функциях — остановиться на первой ошибке и уведомить вызывающую сторону, что мы так сделали.

Так как семантика try похожа на guard, мы можем даже поддерживать опционально else. Так что синтаксис становится буквально идентичным:

let value = try result else {
  // clean up
  return result // at this point result is known to be an error
}

В каком случае мы должны избегать требование, что мы должны возвращать ErrorType? из нашего callback’a? К примеру, мы можем вместо этого передавать ошибку в другую функцию:

let value = try result else {
  callErrorHandler(result)
  return
}

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

Заключение

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

Конечно же, я не рассчитываю, что дизайнеры Swift прочитают это и попрощаются с throw, throws и catch. Основная цель — мысленный эксперимент как гибридный функциональный/императивный язык Swift может элегантно обрабатывать ошибки.

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

Контакты

Say hello!

Каждую минуту в мире публикуется парочка мобильных приложений. Медлить нельзя! Пишите или звоните нам прямо вот сейчас.

Напишите нам
Контакты
Москва: +7 (925) 025-70-55, Оксана Клименко.
Минск: +375 (29) 731-70-15, Алена Мельченко.
Минск: +375 (29) 733-47-08, Александр Мельченко.
Адрес: Минск 220020, Победителей 103, 306



Мы в социальных сетях
Orangesoft