Pull to refresh

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

Reading time5 min
Views17K
Original author: Alexandros Salazar
Если издали видно общую картину, то вблизи можно понять суть. Концепции, которые казались мне далекими и, прямо скажем, странными во время экспериментов с 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, и работать с ним так просто не получится. Для этого тоже есть прием – попробуйте придумать его самостоятельно, а для тех, кому лень – опишу в следующей статье.
Tags:
Hubs:
+22
Comments27

Articles

Change theme settings