1. Що таке @escaping і навіщо він потрібен
Коли ви вперше бачите @escaping у сигнатурі, відчуття часто таке: «О, чудово, ще один магічний прапорець, який треба поставити, щоб код скомпілювався». І тут Swift справді протестує. Бо @escaping — це не декоративна наліпка на параметр, а частина контракту: функція чесно попереджає, що передане замикання може спрацювати після того, як сама функція вже завершилася.
У Swift параметри-замикання за замовчуванням non-escaping. Це означає: якщо в параметрах функції написано work: () -> Void, компілятор виходить із припущення, що work або викличуть усередині функції, або не викличуть узагалі — але точно не «випустять» назовні. Таку поведінку обрано не через примху, а тому, що вона безпечніша й простіша для оптимізації: компілятор може суворіше перевіряти захоплення та час життя.
Почнімо з невеликого прикладу, щоб побачити це на практиці.
import Foundation
func performNow(_ work: () -> Void) {
work()
}
performNow {
print("Зроблено!") // Зроблено!
}
Тут work не зобов’язаний бути @escaping, тому що його викликають просто всередині performNow. Функція завершилася — і замикання вже точно не існує як «щось, що ще може спрацювати».
Як читати @escaping у сигнатурі
Важливо не заплутатися: @escaping стосується типу параметра, а не всієї функції й не імені параметра. Тобто він стоїть перед типом замикання.
Нормальний запис виглядає так:
import Foundation
func storeForLater(_ action: @escaping () -> Void) {
// реалізація не важлива, важливо: action може "жити" довше за виклик
}
А тепер два поширені варіанти запису з помилкою. Покажу їх як псевдокод, бо це не скомпілюється:
Псевдокод / схема:
func bad1(@escaping action: () -> Void) { } // ❌ @escaping не ставиться перед імʼям
func bad2(action: () -> @escaping Void) { } // ❌ @escaping не є частиною типу, що повертається
@escaping пишеться після двокрапки й перед типом замикання.
Ще один важливий момент під час читання сигнатури на око: якщо ви бачите completion: @escaping (...) -> Void, то це не «фіча» реалізації, а обіцянка API. Той, хто читає функцію, заздалегідь розуміє: «Ага, цей completion, найімовірніше, викличуть пізніше; отже, можуть бути нюанси із захопленням обʼєктів і часом життя».
2. Коли потрібен @escaping
Escaping через зберігання
Найчастіший сценарій: ви зберегли замикання у властивість, глобальну змінну або колекцію. У цей момент замикання буквально «втікає» з функції й може спрацювати будь-коли пізніше — отже, воно escaping.
Почнімо з найпростішого «сховища на потім».
import Foundation
var stored: (() -> Void)?
func storeForLater(_ action: @escaping () -> Void) {
stored = action
}
storeForLater {
print("Я виконаюся пізніше")
}
stored?() // Я виконаюся пізніше
Чому компілятор вимагає @escaping? Бо якби action був non-escaping, Swift міг би припускати, що всі захоплення всередині action безпечні в межах життя виклику. Але якщо ми кладемо його в stored, життя замикання стає потенційно нескінченним — доки хтось не присвоїть stored = nil.
Дуже схожий — і не менш частий — варіант: зберігання в масиві.
import Foundation
var handlers: [() -> Void] = []
func addHandler(_ handler: @escaping () -> Void) {
handlers.append(handler)
}
addHandler { print("обробник #1") }
addHandler { print("обробник #2") }
handlers.forEach { $0() }
// обробник #1
// обробник #2
Тут корисно помітити одну річ: escaping — це не обов’язково асинхронність. Ми нічого не робили у фоні, не використовували таймери чи черги. Ми просто зберегли замикання — і цього вже досить, щоб воно вважалося escaping, бо виклик може статися пізніше, за межами вихідної функції.
Escaping через передавання далі
Іноді ви не зберігаєте замикання напряму, а передаєте його в іншу функцію, яка збереже його далі. Тоді @escaping стає «заразним»: щоб передати замикання туди, де його збережуть, ваш параметр теж має бути позначений як @escaping.
Зробімо невеликий приклад із нижнім рівнем, який зберігає callback.
import Foundation
var saved: (() -> Void)?
func acceptEscaping(_ action: @escaping () -> Void) {
saved = action
}
func forward(_ action: @escaping () -> Void) {
acceptEscaping(action)
}
Зверніть увагу: forward сам нічого не зберігає, але контракт у нього все одно escaping, бо він обіцяє: «Я приймаю те, що може пережити мене».
Це важливо під час читання чужого коду. Іноді ви відкриваєте функцію, бачите @escaping, а всередині — «нічого особливого». І це нормально: функція може бути просто «проводом» до місця, де замикання справді збережуть.
Змішані параметри: одне замикання «зараз», інше — «потім»
Дуже корисна практична навичка — помітити, що в одній функції можуть одночасно бути і non-escaping, і escaping замикання. Це виглядає як «робимо роботу зараз, а завершення збережемо або викличемо пізніше».
Уявімо мініоперацію: work виконуємо просто зараз, а completion зберігаємо, щоб викликати потім, наприклад після певної події. Ми не моделюємо реальну асинхронність; нам важлива саме механіка часу життя.
import Foundation
var storedCompletion: (() -> Void)?
func run(work: () -> Void, completion: @escaping () -> Void) {
work()
storedCompletion = completion
}
run(work: {
print("робота") // робота
}, completion: {
print("завершення") // завершення
})
storedCompletion?() // завершення
Тут work не escaping: він відпрацював і завершився в межах виклику run. А completion — escaping: він потрапив у storedCompletion.
Корисно помітити, що @escaping — це не «налаштування функції», а «налаштування конкретного параметра». Тому можна мати два параметри-замикання, але позначити лише той, який справді «втікає».
Мінісхема: як відрізняти escaping за тілом функції
Коли ви пишете свою функцію, зазвичай усе вирішується дуже просто: компілятор сам скаже «Гей, ти намагаєшся зберегти non-escaping замикання, додай @escaping». Але вміння зрозуміти це раніше за компілятор економить час і робить код осмисленішим.
Ось проста блок-схема, яку можна тримати в голові:
flowchart TD
A["Є параметр-замикання"] --> B{"Замикання викликається лише всередині функції?"}
B -->|Так| C["Non-escaping (за замовчуванням): @escaping не потрібен"]
B -->|Ні / не лише| D{"Ми його зберігаємо або передаємо туди, де його збережуть?"}
D -->|Так| E["@escaping обовʼязковий"]
D -->|Ні, але виклик пізніше можливий| E
Тут ключова евристика: якщо є точка зберігання (властивість, змінна, масив, словник), майже напевно потрібен @escaping.
3. @escaping у completion-API та Result
У курсі ми вже бачили, що Result допомагає виражати «успіх або помилка» без двозначностей. І саме в completion-API він особливо корисний: completion майже завжди спрацьовує пізніше, а отже часто є escaping.
Давайте розглянемо невеликий фрагмент нашого застосунку (LibraryCLI). Уявімо, що в нас є сервіс, який «зберігає бібліотеку», але робить це не миттєво: він зберігає completion і викличе його пізніше, коли ми скажемо: «Ну все, збереження завершено».
Спочатку — помилка під час збереження:
import Foundation
enum StorageError: Error {
case failedToSave
}
Тепер — «зберігач», який зберігає completion:
import Foundation
class LibrarySaver {
private var pending: ((Result<Void, Error>) -> Void)?
func save(completion: @escaping (Result<Void, Error>) -> Void) {
pending = completion
}
func finishSaveSuccessfully() {
pending?(.success(()))
pending = nil
}
}
Зверніть увагу на два моменти.
Перший: completion обов’язково має бути @escaping, бо ми кладемо його в pending.
Другий: після виклику ми занулюємо pending. Навіть якщо ви поки що не думаєте про витоки, це добра дисципліна: одноразовий колбек відпрацював — і більше не потрібен.
А тепер — як це виглядає з боку користувача API:
import Foundation
let saver = LibrarySaver()
saver.save { result in
switch result {
case .success:
print("Збережено!") // Збережено!
case .failure(let error):
print("Помилка: \(error)")
}
}
saver.finishSaveSuccessfully() // Збережено!
І тут видно, чому @escaping — частина контракту: той, хто викликає код, розуміє, що completion може надійти пізніше, а не «прямо зараз, усередині save».
typealias: як спростити сигнатуру
Коли completion стає довгим, сигнатури перетворюються на «ASCII-арт». Тоді допомагає typealias. Важливо: @escaping усе одно ставиться в місці використання, тобто в параметрі функції.
Зробімо коротку назву для колбека збереження:
import Foundation
typealias SaveCompletion = (Result<Void, Error>) -> Void
class LibrarySaver2 {
private var pending: SaveCompletion?
func save(completion: @escaping SaveCompletion) {
pending = completion
}
}
Так читабельність зазвичай зростає: ви бачите @escaping SaveCompletion і розумієте, що це колбек завершення збереження, а не випадковий набір дужок.
4. Коли не варто ставити @escaping
Є спокуса: «А давайте всі замикання завжди будемо робити @escaping, щоб потім не переробляти». Це приблизно як носити каску в квартирі: іноді корисно, але частіше ви просто стукатиметеся нею об дверний косяк і швидко почнете дратуватися.
Проблема не лише в зайвому слові. @escaping змінює правила гри: замикання може жити довше, отже компілятор суворіше ставитиметься до захоплень і часу життя обʼєктів. Ви можете раптово отримати додаткові вимоги (наприклад, явний self у деяких випадках) і додаткові ризики (наприклад, утримання обʼєктів довше, ніж ви очікували).
Навіть якщо ви ще не заглиблюєтеся в ці деталі, корисно тримати принцип: @escaping ставимо тоді, коли справді є «життя після функції» — зберігання або передавання на зберігання.
Це робить API чесним: читач бачить, що саме «живе» довше, і не сприймає кожне замикання як потенційну міну.
5. Non-escaping vs escaping: коротке порівняння
| Властивість | Non-escaping (за замовчуванням) | Escaping (@escaping) |
|---|---|---|
| Коли викликається | Тільки всередині функції, якщо взагалі викликається | Може спрацювати після завершення функції |
| Чи потрібен @escaping | Ні | Так |
| Типова ознака в коді | work() викликається всередині | замикання зберігають у властивість/масив або передають туди, де його збережуть |
| Чому це важливо | Простіше міркувати й безпечніше для захоплень | впливає на час життя захоплених обʼєктів і контракт API |
6. Типові помилки під час роботи з @escaping
Помилка №1: поставити @escaping не туди (перед імʼям параметра).
Це одна з тих помилок, які здаються дрібними, але дратують: у голові все складено правильно, а компілятор не погоджується. У Swift @escaping стосується типу, тому пишеться після двокрапки: completion: @escaping (...) -> Void. Якщо ловите себе на func f(@escaping completion: ...), зупиніться й просто переставте слова місцями.
Помилка №2: намагатися зберегти non-escaping замикання у властивість і дивуватися помилці компілятора.
Це якраз той випадок, коли компілятор допомагає: він не вередує, а прямо каже «друже, ти хочеш, щоб замикання жило довше за функцію; будь ласка, познач це в контракті». Правильна реакція — не «як змусити компілятор замовкнути», а «так, я справді зберігаю замикання, отже параметр має бути @escaping».
Помилка №3: не помітити непряме зберігання (наприклад, через append у масив).
Початківці часто думають: «Ну, я ж не писав self.onComplete = completion, я лише handlers.append(completion)». Але для часу життя різниці немає: append — це теж зберігання. Якщо замикання кладеться в колекцію, воно точно може пережити функцію, а отже має бути escaping.
Помилка №4: оголосити @escaping без реальної причини й погіршити контракт API.
Якщо замикання насправді викликається суворо всередині функції, @escaping вводить читача в оману: він очікує виклик пізніше, а отримує виклик зараз. Плюс це створює зайві ризики й обмеження. Гарний стиль — залишати non-escaping там, де це відповідає дійсності, і робити escaping лише там, де замикання справді «втікає».
Помилка №5: забути, що @escaping вимагає керувати часом життя колбека.
Щойно ви зберігаєте completion у властивість, з’являється практичне питання: коли ви його очищуєте? Якщо це одноразовий колбек, після виклику його зазвичай зануляють (pending = nil), щоб випадково не викликати вдруге і не утримувати зайві обʼєкти. Навіть без глибокого занурення в ARC це правило робить код передбачуванішим і спокійнішим.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ