JavaRush /Курсы /Swift SELF /гарантия “completion вызывается один раз”

гарантия “completion вызывается один раз”

Swift SELF
38 уровень , 4 лекция
Открыта

1. “Ровно один раз” — это контракт, а не просьба

Когда мы пишем API с completion handler’ом, мы как бы подписываем с вызывающим кодом невидимый договор. Он звучит примерно так: “Я (функция) не могу вернуть результат прямо сейчас, но обещаю, что позже передам его через completion. И сделаю это ровно один раз”.

В материалах и обсуждениях вокруг completion‑handler API это формулируется довольно прямолинейно: обработчик завершения должен быть вызван ровно один раз на всех путях выполнения, включая ошибочные. Иначе ломается логика вызывающей стороны: она либо “никогда не продолжит”, либо продолжит дважды и выполнит одно и то же действие повторно.

Чтобы это стало не абстракцией, а “внутренним рефлексом”, полезно чётко понимать две категории багов.

2. Две категории багов: “0 раз” и “2+ раза”

Про completion часто думают так: “главное — вызвать его”. На практике это половина правды. Не менее важно — не вызвать его дважды. Эти две ошибки симметричны, но проявляются по‑разному и одинаково неприятны.

Ошибка “0 раз” обычно выглядит как “всё зависло”. Вызывающий код ждёт, например, чтобы выключить индикатор загрузки, или чтобы продолжить цепочку действий. Но completion не пришёл — и программа не упала.

Ошибка “2+ раза” выглядит как “логика поехала”: данные добавились дважды, состояние переключилось туда‑сюда, сообщение пользователю показали два раза.

Давайте посмотрим на оба случая в минимальных примерах.

Анти‑кейс: completion не вызван (0 раз)

enum ValidationError: Error {
    case emptyText
}

func validateName(_ text: String, completion: (Result<String, Error>) -> Void) {
    if text.isEmpty {
        completion(.failure(ValidationError.emptyText))
        return
    }
    // Ошибка: на успехе completion не вызывается
}

Этот код компилируется и даже “частично работает”: на пустой строке вы получите ошибку. Но на непустой строке — тишина. Если вы где‑то выше по стеку ждёте ответ, вы его не дождётесь никогда.

Анти‑кейс: completion вызван дважды (2 раза)

enum ValidationError: Error {
    case emptyText
}

func validateName(_ text: String, completion: (Result<String, Error>) -> Void) {
    if text.isEmpty {
        completion(.failure(ValidationError.emptyText))
        // Ошибка: забыли return
    }
    completion(.success(text)) // при пустой строке будет второй вызов
}

Это классическая “ошибка без тормоза”: вы добавили обработку ошибки, но забыли остановить выполнение. В итоге при пустой строке вы сначала отправляете .failure, а потом тут же .success с пустым текстом. Вызывающая сторона в этот момент начинает жить в двух реальностях одновременно.

3. Базовый шаблон №1: guard ... else { completion(...); return }

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

Идея такая: все проверки входных данных и условий, которые могут привести к ошибке, мы пишем через guard. Внутри else мы вызываем completion и делаем return. После серии guard остаётся “чистая зона”, где можно спокойно выполнять основную логику и в конце сделать один вызов .success.

Мини‑пример: проверка аргумента

enum ParseError: Error {
    case notANumber
}

func parsePositiveInt(_ text: String, completion: (Result<Int, Error>) -> Void) {
    guard let value = Int(text) else {
        completion(.failure(ParseError.notANumber))
        return
    }

    completion(.success(value))
}

Обратите внимание: здесь всего две ветки, и на каждой ровно один вызов completion. Мы даже глазами это легко проверяем.

Схема потока управления

Псевдокод/схема:

flowchart TD
    A[Старт функции] --> B{Проверка guard}
    B -- не прошло --> C["completion(.failure) return"]
    B -- прошло --> D[основная логика]
    D --> E["completion(.success)"]

С такой схемой у вас почти не остаётся места, чтобы “случайно” забыть вызов или сделать двойной вызов.

Когда guard особенно спасает

В callback‑коде часто возникает “лесенка” условий: сначала проверили формат строки, потом диапазон числа, потом какие‑то правила. Если всё это писать вложенными if, вы легко потеряете одну ветку.

guard превращает это в линейный, почти “протокольный” код: проверка → ранний выход → проверка → ранний выход. И в каждой точке раннего выхода у вас есть completion + return, то есть контракт соблюдён.

4. Базовый шаблон №2: одна точка вызова через Result

guard-стиль прекрасен, но иногда в функции много разветвлений, и вы не хотите размазывать completion(...) по десяти местам, даже если везде стоят return.

В таких случаях удобно собрать результат в переменную result, а completion вызвать один раз в конце. Это особенно приятно, когда у вас уже есть логика, которая может бросать ошибки (throws) — тогда можно конвертировать её в Result одной строкой.

Пример: throwsResult → completion один раз

enum MathError: Error {
    case divisionByZero
}

func divide(_ a: Int, by b: Int) throws -> Int {
    guard b != 0 else { throw MathError.divisionByZero }
    return a / b
}

func divideAsyncStyle(_ a: Int, by b: Int, completion: (Result<Int, Error>) -> Void) {
    let result = Result { try divide(a, by: b) }
    completion(result)
}

Здесь completion физически вызывается один раз, потому что он написан один раз. Ошибка “забыли ветку” становится почти невозможной.

Пример: сложные if, но completion один раз

enum TicketError: Error {
    case tooYoung
    case invalidCity
}

func buyTicket(age: Int, city: String, completion: (Result<String, Error>) -> Void) {
    let result: Result<String, Error>

    if age < 18 {
        result = .failure(TicketError.tooYoung)
    } else if city.isEmpty {
        result = .failure(TicketError.invalidCity)
    } else {
        result = .success("Билет куплен для \(city)")
    }

    completion(result)
}

Смысл тот же: ветвлений сколько угодно, но “точка выхода” одна.

5. Хранимый completion: делаем one‑shot через обнуление

Теперь пример, который показывает две вещи одновременно:

— почему completion часто @escaping (потому что мы его храним),
— как обеспечить “ровно один раз”, если completion лежит в свойстве.

Представим простую консольную модель “мини‑библиотеки”: пользователь делает запрос “одолжить книгу”, а библиотекарь (наша программа) позже либо подтверждает, либо отклоняет. Мы не используем сеть, таймеры и очереди — просто вручную вызываем методы approve() / reject(). Нам важно только корректно держать контракт completion.

Модель: менеджер “одолжить книгу”

final class BorrowManager {
    enum BorrowError: Error {
        case alreadyHasPendingRequest
        case rejected
    }

    private var pendingCompletion: ((Result<String, Error>) -> Void)?

    func requestBorrow(bookID: String, completion: @escaping (Result<String, Error>) -> Void) {
        guard pendingCompletion == nil else {
            completion(.failure(BorrowError.alreadyHasPendingRequest))
            return
        }

        pendingCompletion = completion
        print("Запрос принят: bookID=\(bookID)") // Запрос принят: bookID=...
    }

    func approve() {
        pendingCompletion?(.success("Одобрено! Забирайте книгу."))
        pendingCompletion = nil
    }

    func reject() {
        pendingCompletion?(.failure(BorrowError.rejected))
        pendingCompletion = nil
    }
}

Здесь сразу несколько важных деталей.

Во‑первых, completion помечен как @escaping, потому что мы сохраняем его в pendingCompletion. Это та самая “escaping через хранение”.

Во‑вторых, мы сделали правило: одновременно может быть только один “pending” запрос. Если запрос уже есть — мы мгновенно возвращаем ошибку и выходим.

В‑третьих, самое важное для темы лекции: в approve() и reject() мы вызываем completion и сразу зануляем pendingCompletion. Это превращает completion в “одноразовый” (one‑shot): даже если кто‑то случайно вызовет approve() дважды, второй раз pendingCompletion уже nil, и повторного вызова completion не будет.

Мини‑использование

let manager = BorrowManager()

manager.requestBorrow(bookID: "B-42") { result in
    print(result) // success("Одобрено! Забирайте книгу.") или failure(...)
}

manager.approve()
manager.approve() // второй раз ничего не произойдёт

Это “скучный” вывод — и это комплимент. В нормальном коде скука означает предсказуемость.

6. Хранимый completion: анти‑кейсы и быстрая диагностика

Когда completion хранится в свойстве, самая частая ошибка — забыть его очистить после вызова. Тогда объект продолжит удерживать замыкание (и, возможно, self внутри него), а ещё вы сможете вызвать completion повторно, даже если логика “по идее” одноразовая.

Анти‑кейс: не очистили completion и получили повторный вызов

final class BadBorrowManager {
    private var pendingCompletion: ((Result<String, Error>) -> Void)?

    func request(completion: @escaping (Result<String, Error>) -> Void) {
        pendingCompletion = completion
    }

    func approveTwiceByAccident() {
        pendingCompletion?(.success("OK"))
        pendingCompletion?(.success("OK")) // второй вызов — баг контракта
    }
}

Этот код компилируется, но он нарушает контракт “ровно один раз”. Иногда такой баг проявляется не как двойной print, а как двойное списание денег, двойное добавление элемента в массив и т.д.

Таблица “симптом → причина → лечение”

Иногда полезно иметь “диагностическую линейку” прямо в голове — не для зубрёжки, а чтобы быстрее узнавать проблему в реальном коде.

Симптом Что это обычно значит Как починить, не переписывая всё
Вызывающий код “ждёт и молчит” completion не вызван на одной из веток привести код к guard ... else { completion; return } или собрать Result и вызвать completion один раз
Действие произошло дважды completion вызван дважды (часто забыли return) после completion(.failure(...)) делать return, либо сделать “одну точку вызова”
Вызов “одобрить/отменить” сработал дважды completion хранится и не очищается после вызова completion занулять ссылку (one‑shot)
Код стал нечитаемой паутиной completion размазан по 10 местам Result в переменную + один completion в конце

7. Типичные ошибки при контракте “completion ровно один раз”

Ошибка №1: забыли вызвать completion на ветке успеха (или на одной из веток ошибки).
Это самая обидная категория ошибок: программа не падает, компилятор не ругается, но UI/логика зависают. Обычно причина — ранний return или условие, в котором вы “вышли”, не вызвав completion. Лечится дисциплиной: либо guard ... else { completion(...); return } на всех проверках, либо подход “собрать Result и вызвать completion один раз в конце”.

Ошибка №2: вызвали completion, но забыли return, и выполнение продолжилось.
Классический сценарий: вы добавили обработку ошибки в if, вызвали completion(.failure(...)), но забыли остановить функцию. Дальше код доходит до “нормального” completion и вызывает его повторно. Это та самая ситуация, которую guard предотвращает почти автоматически, потому что return — часть конструкции.

Ошибка №3: completion хранится в свойстве, но после вызова не очищается.
Это приводит к двум проблемам: во‑первых, completion можно вызвать повторно (случайно или из‑за бага в логике), а во‑вторых, замыкание может продолжать удерживать захваченные объекты дольше, чем нужно. Если completion по смыслу одноразовый, хороший стиль — после вызова занулить ссылку (pendingCompletion = nil). Это не “косметика”, а часть соблюдения контракта.

Ошибка №4: несколько “параллельных” путей к одному completion без общей точки контроля.
Даже без настоящей асинхронности можно случайно создать два пути, которые вызывают completion: например, один метод “успешно завершает”, а второй “отменяет”, и оба могут быть вызваны в произвольном порядке. Если у вас completion хранится, удобно вводить простое правило: любой публичный метод, который может завершить операцию, сначала берёт локальную копию completion, потом очищает свойство, и только потом вызывает. Тогда даже при повторном вызове у вас просто не останется completion для второго срабатывания.

Ошибка №5: выбор completion‑сигнатуры допускает неоднозначные состояния, и из-за этого completion вызывают “для надёжности” дважды.
Когда API сделан как (Value?, Error?), у новичков иногда возникает желание “подстраховаться”: сначала отправить ошибку, потом ещё раз отправить nil, или наоборот. С Result так сделать сложнее психологически и проще технически: вы выбираете ровно одно значение — .success или .failure — и вызываете completion один раз. Именно поэтому Result так любят в callback‑API: он дисциплинирует контракт.

1
Задача
Swift SELF, 38 уровень, 4 лекция
Недоступна
Имя витрины
Имя витрины
1
Задача
Swift SELF, 38 уровень, 4 лекция
Недоступна
Диапазон скидки
Диапазон скидки
1
Задача
Swift SELF, 38 уровень, 4 лекция
Недоступна
Сообщение доставки
Сообщение доставки
1
Задача
Swift SELF, 38 уровень, 4 лекция
Недоступна
Менеджер токена
Менеджер токена
1
Опрос
Escaping замыкания, 38 уровень, 4 лекция
Недоступен
Escaping замыкания
Работа с escaping closures в Swift
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ