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 одной строкой.
Пример: throws → Result → 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: он дисциплинирует контракт.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ