JavaRush /Курси /Swift SELF /@escaping у сигнатурі функції — як читати й писати

@escaping у сигнатурі функції — як читати й писати

Swift SELF
Рівень 38 , Лекція 1
Відкрита

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 це правило робить код передбачуванішим і спокійнішим.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ