Если издали видно общую картину, то вблизи можно понять суть. Концепции, которые казались мне далекими и, прямо скажем, странными во время экспериментов с 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 вообще не существует, поскольку он прячет под капотом всю работу с указателями. Остальные два пункта решаются с помощью перечислений.
Если во время вычисления может возникнуть ошибка, то результата может быть два:
- Успешный – с возвращаемым значением
- Безуспешный – желательно, с объяснением причины ошибки
Эти варианты взаимоисключающие – в нашем примере, деление на 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
, и работать с ним так просто не получится. Для этого тоже есть прием – попробуйте придумать его самостоятельно, а для тех, кому лень – опишу в следующей статье.
Автор: impwx