Денис Крешихин

Денис
Крешихин

iOS-разработчик с 15+ летним опытом

Тимлид/сеньор по обстоятельствам

Интересы: swift, uikit, rxswift, oop/ood, devops, agile

2024 © Денис Крешихин

Нюансы обработки ошибок в completion-коллбеке

В практике программирования Swift часто можно встретить следующий формат completion-коллбеков, которые могут возвращать либо результат без ошибки, либо ошибку:

func doSomething(_ completion: (data: Data?, error: Error?) -> Void())

Проблема таких completion-коллбеков в опционалах которые должны разрешить проблемы возвращения nil на месте параметра data в случае ошибки и nil в параметре error. Но кроме этих двух состояния возникают ещё два непредусмотренных логикой - когда оба параметра nil, или когда оба параметра установлены. В этой заметке я хотел бы порассуждать о том как правильно обрабатывать такие вызовы, т.к. это проблема встречается часто при работе со старыми методами из CocoaTouch, при разработке кода с обратной совместимостью API, а так же при работе с библиотеками третьих сторон.

Как должна была бы выглядеть идеальная обработка? Идеально следовало бы использовать guard else инструкцию что бы разделить разветвление на код для обработки успешной ситуации от неуспешной. Принято успешную ситуации обрабатывать в теле функции (или замыкания) - т.е. в главной ветви, а неуспешный внутри блока guard или if - т.е. в побочной ветви.

С учётом этого все, код мог бы выглядеть так:

doSomething { (data, error) in
    guard error == nil else {
        // Неуспешная ситуация
    }

    // Успешная ситуация
}

Допустим мы пишем некоторую обёртку для RxSwift для операции типа Single. Если закрыть глаза на ошибки компиляции, то в идеальном мире это должно было бы привести к следующему коду:

func doSomethingAsync() -> Single<Data?> {
    .create { single in
        doSomething { (data, error) in
            guard error == nil else {
                single(.failure(error))
                return
            }

            single(.success(data))
        }

        return Disposables.create {}
    }
}

Какие проблемы в этом коде можно обнаружить?

  1. Строчка single(.failure(error)) требует что бы ошибка error имела неопциональный тип, но наш блок guard не снимает опциональность.
  2. Обработка ситуации когда data == nil перекладывается на подписчика.
  3. Не обрабатывается ситуация когда есть и ошибка error != nil, и данные data != nil (возможно полезные для дебага)

В действительности все вышеприведённые проблемы справедливы не только для асинхронного кода, но и для синхронного императивного. Просто в реактивном коде такие проблемы проявляют себя наиболее чётко.

Рассмотрим возможные решения по порядку.

Обработка опциональной ошибки

Вариант 1 - force unrawp.

Т.к. guard нам гарантирует что error в блоке else-return уже не может быть nil, то можно использовать force unwrap:

guard error == nil else {
    single(.failure(error!))
    return
}
single(.success(data))

У этого подхода есть ряд проблем:

  • всё же абсолютных гарантий нет, что после невыполнения условия error == nil в блоке else переменная error действительно будет продолжать иметь значение, поэтому это источника потенциального крэша
  • force unrawp это плохая практика в целом, поэтому наличие разрешённых force unrawp может замыливать глаз при код-ревью и усложнять автоматизацию линтинга

Вариант 2 - guard-else-if.

Вместо force unrawp можно добавить ещё один блок if-else. Это решает проблемы с возможным рассинхронном, т.к. if let создаст копию переменной error:

guard error == nil else {
    if let error = error {
        single(.failure(error))
    }
    return
}
single(.success(data))

У этого подхода есть ряд проблем:

  • громоздкий синтаксис

Вариант 3 - optional map

Следующий вариант строится на наличии специального вариант map для опционального значения что позволяет получить распакованное значение внутри блока map:

guard error == nil else {
    error.map { single(.failure($0) }
    return
}
single(.success(data))

Проблемы этого подхода:

  • непонятный синтаксис

Вариант 4 - do catch

Можно заменить конструкцию guard на аналогичный do-catch блок:

do {
    try error.map { throw $0 }
    single(.success(data))
} catch {
    single(.failure(error))
}

Проблемы этого подхода:

  • непонятный синтаксис
  • по-факту вариация if-else

Вариант 5 - хелпер

Можно завести хелпер который бы обслуживал ошибку:

func handle(_ error: Error?, with completion: (Error) -> Void) -> Bool {
    error.map { completion($0); false } ?? true
}

Тогда guard-блок примет следующий вид:

guard handle(error, with: {
    single(.failure($0))
}) else {
    return
}
single(.success(data))

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

Минусы

  • запутанно
  • сам хендлер может быть источником проблем.

Ситуация когда пустые данные это ошибка

Желательно проектировать интерфейсы так что бы не перекладывать на потребителя кода дополнительную работу по проверке полученного результата. Таким образом желательно что бы функция doSomethingAsync возвращала не Single<Data?>, а Single<Data>. Но в таком случае у нас появляется ещё один тип ошибок (поверх того что может быть возвращён функцией doSomething):

enum ServiceError: Error {
    case noData
}

class Service {
    func doSomethingAsync() -> Single<Data> {
        .create { single in
            doSomething { (data, error) in
                guard error == nil else {
                    if let error = error {
                        single(.failure(error))
                    }
                    return
                }

                guard let data = data else {
                    single(.failure(ServiceError.noData))
                    return
                }

                single(.success(data))
            }

            return Disposables.create {}
        }
    }
}

Ситуация когда с ошибкой пришли непустые данные

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

enum ServiceError: Error {
    case noData
    case errorWithData(Error, Data)
}

class Service {
    func doSomethingAsync() -> Single<Data> {
        .create { single in
            doSomething { (data, error) in
                guard error == nil else {
                    if let error = error {
                        if let data = data {
                            single(.failure(ServiceError.errorWithData(error, data)))
                        } else {
                            single(.failure(error))
                        }
                    }
                    return
                }

                guard let data = data else {
                    single(.failure(ServiceError.noData))
                    return
                }

                single(.success(data))
            }

            return Disposables.create {}
        }
    }
}

Как видно из примера - код становится всё запутанней, один из вариантов решения проблемы это перейти к switch-case или do-catch.

Вариант switch-case

enum ServiceError: Error {
    case noData
    case errorWithData(Error, Data)
}

class Service {
    func doSomethingAsync() -> Single<Data> {
        .create { single in
            doSomething { (data, error) in
                switch (data, error) {
                case let .some(data), nil:
                    single(.success(data))
                case nil, nil:
                    single(.failure(ServiceError.noData))
                case nil, let .some(error):
                    single(.failure(error))
                case let .some(data), let .some(error):
                    single(.failure(ServiceError.errorWithData(error, data)))
                }
            }

            return Disposables.create {}
        }
    }
}

Вариант do-catch

Подход с do-catch строится по тому же принципу - нужно добавить проверки для каждого случая и в случае ошибки выбросить её для перехвата в catch блоке:

enum ServiceError: Error {
    case noData
    case errorWithData(Error, Data)
}

class Service {
    func doSomethingAsync() -> Single<Data> {
        .create { single in
            doSomething { (data, error) in
                do {
                    if let error = error, let data = data {
                        throw ServiceError.errorWithData(error, data)
                    }

                    if let error = error {
                        throw error
                    }

                    guard let data = data else {
                        throw ServiceError.noData
                    }

                    single(.success(data)
                } catch {
                    single(.failure(error)
                }
            }

            return Disposables.create {}
        }
    }
}

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

enum ValidationError<T>: Error {
    case noData
    case errorWithData(Error, T)
}

func validate<T>(_ value: T?, error: Error?) -> T throws {
    if let error = error, let data = data {
        throw ValidationError<T>.errorWithData(error, data)
    }

    if let error = error {
        throw error
    }

    guard let data = data else {
        throw ValidationError<T>.noData
    }
}

class Service {
    func doSomethingAsync() -> Single<Data> {
        .create { single in
            doSomething { (data, error) in
                do {
                    single(.success(try validate(data, error))
                } catch {
                    single(.failure(error)
                }
            }

            return Disposables.create {}
        }
    }
}

Преимущество такого решения в том что функцию validate можно использовать с произвольными типами.

Заключение

Как можно видеть - универсального решения проблемы обработки ошибок в completion-коллбеке нет. В простых случаях можно обойтись guard блоком с обработкой ошибки через map или аналогичную if-let конструкцию. В более сложных следует писать функции-обработчики, либо валидаторы.

Наиболее мощным решением с позиции ООП будет писать функции-валидаторы которые способы выбрасывать ошибки, в этом случае всё сводится к тривиальном блоку do-catch:

    do {
        single(.success(try validate(data, error))
    } catch {
        single(.failure(error)
    }

При этом сами валидаторы могут вести логирование и собирать дополнительную информацию.