JavaRush /Курси /Swift SELF /Escaping і non‑escaping closures у Swift

Escaping і non‑escaping closures у Swift

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

1. Інтуїція: «замикання встигає втекти чи ні?»

Коли вперше чуєте про escaping closure, легко уявити замикання з валізою та паспортом, яке втікає з функції назустріч заходу сонця. І знаєте що? Це не така вже й погана метафора. У Swift різниця справді полягає в тому, чи покидає замикання межі виклику функції. Тобто чи може воно бути викликане після того, як функція вже завершила роботу.

Уявіть, що функція — це кухонний таймер. Якщо ви передали в неї замикання, і воно гарантовано спрацює, поки таймер «цокає», — це одна ситуація. А якщо ви попросили функцію «зберегти цей рецепт, а приготуємо колись потім» — це вже зовсім інша історія: рецепт переживає кухню, переїжджає в холодильник, а іноді й у наступну відпустку.

Визначення людською мовою

Non‑escaping closure — замикання не переживає виконання функції, у яку ви його передали. Воно або буде викликане всередині цієї функції, або взагалі не буде викликане, але в будь-якому разі не «живе далі» після return.

Escaping closure — замикання може бути викликане після завершення функції. Зазвичай тому, що його зберегли кудись: у властивість, змінну, масив, словник або передали в API, яке зберігає його далі.

Важливий історичний факт: у Swift, починаючи зі Swift 3, параметри-замикання за замовчуванням вважаються non‑escaping, а escaping потрібно позначати явно. Це рішення ухвалили навмисно, щоб зменшити кількість неочікуваних проблем, зокрема циклів утримання, і щоб компілятор міг краще оптимізувати код.

2. Мініприклад: «зараз» vs «потім» без асинхронності

Перш ніж говорити, навіщо компілятор це розрізняє, давайте зафіксуємо різницю на простих прикладах — без таймерів, потоків і мережі. Нам не потрібна асинхронність, щоб побачити escaping. Нам потрібно лише зберігання.

import Foundation

func performNow(_ work: () -> Void) {
    work() // викликаємо всередині функції
}

performNow {
    print("Зроблено просто зараз") // Зроблено просто зараз
}

Тут замикання явно «не втікає»: його викликали всередині performNow. Отже, параметр за замовчуванням non‑escaping — і все добре.

Тепер — «потім».

import Foundation

var storedAction: (() -> Void)?

func storeForLater(_ action: @escaping () -> Void) {
    storedAction = action // зберігаємо => замикання "втікає"
}

storeForLater {
    print("Це буде виконано пізніше")
}

storedAction?() // Це буде виконано пізніше

Тут замикання пережило виклик storeForLater: функція завершилася, а дія залишилася лежати в storedAction. Це і є escaping за змістом.

3. Навіщо компілятор розрізняє escaping і non‑escaping

Зараз буде важлива думка: розрізнення non‑escaping/escaping — це не «стиль коду», а частина системи безпеки й оптимізації мови. Компілятор насправді ухвалює різні рішення залежно від того, «втікає» замикання чи ні.

Уявіть, що ви — компілятор Swift. Вам дали функцію і замикання. Питання таке: «Чи можу я гарантувати, що це замикання не буде викликане після виходу з функції?» Якщо так — ви можете багато чого спростити. Якщо ні — доводиться діяти обережно, тому що замикання може бути викликане пізніше й спробувати використати те, чого вже немає, або утримувати те, що вже час відпустити.

Далі — три великі причини, і вони справді практичні.

Захоплення та час життя даних

Замикання може захопити змінну:

import Foundation

func demoCapture() {
    var counter = 0

    let work = {
        counter += 1
        print(counter)
    }

    work() // 1
    work() // 2
}

demoCapture()

Поки work живе всередині demoCapture, усе просто: counter живе поруч, усе в одному «коридорі часу».

Але якщо work стає escaping, то counter (або те, що замикання захопило) має жити довше, інакше замикання отримає доступ до «трупа змінної» — вибачте за драму. Тому escaping-замикання часто потребують іншого підходу до зберігання захоплених значень, і компілятор має це враховувати.

ARC і ризик циклів утримання

Escaping-замикання дуже часто зберігається десь надовго. А якщо воно при цьому сильно захопило об’єкт self, то об’єкт також буде жити довго. Іноді — вічно, до кінця застосунку, якщо виник retain cycle.

Навіть Swift Evolution підкреслює, що одна з причин вимоги явності в контексті escaping-замикань — не дати програмісту випадково створити прихований цикл утримання, коли self утримується непомітно.

З non‑escaping усе простіше: навіть якщо ви захопили self, замикання точно буде виконане або не буде виконане в межах виклику, і утримання не перетвориться на «заморожування об’єкта на роки».

Ексклюзивний доступ до пам’яті та inout

Є менш очевидна, але дуже «свіфтова» причина: правила ексклюзивного доступу до пам’яті (exclusive access). Swift хоче гарантувати, що поки хтось змінює значення через inout, ніхто інший паралельно не намагається змінювати або читати те саме місце пам’яті в конфліктний спосіб.

І ось тут розрізнення non‑escaping/escaping стає критичним: якщо замикання не втікає, компілятор може міркувати так: «виклик відбудеться тут і зараз», і правила доступу можна перевіряти статично. Якщо замикання escaping — його можна викликати коли завгодно, і статично гарантувати багато речей уже не можна: потрібно або заборонити деякі ситуації, або застосовувати важчі перевірки.

Простіше кажучи: компілятор розрізняє їх, тому що це впливає на коректність програми, а не лише на зручність читання.

4. Коли замикання стає escaping

Зараз найкорисніше правило, яке варто буквально приклеїти собі на монітор, але обережно, щоб монітор не став липким репозиторієм.

Замикання стає escaping, якщо ви робите так, що воно може бути викликане після виходу з поточної функції. Найчастіше це виглядає як зберігання.

import Foundation

var handlers: [() -> Void] = []

func addHandler(_ handler: @escaping () -> Void) {
    handlers.append(handler) // append = зберігання
}

addHandler { print("Перший обробник") }
addHandler { print("Другий обробник") }

handlers.forEach { $0() }
// Перший обробник
// Другий обробник

Тут не потрібно гадати: якщо замикання потрапило в масив handlers, воно переживе addHandler. Отже, escaping.

І дуже важливе застереження: escaping — це не обов’язково «асинхронно», «в іншому потоці» або «після затримки». Escaping — це про час життя, а не про паралельність. Асинхронність часто робить замикання escaping, але сама по собі не є визначенням.

5. Практика: порівняння та читання escaping у коді

Таблиця порівняння

Щоб закріпити, зведемо різницю в компактну таблицю. Таблиці — це такий компроміс: ніби не список, але мозок щасливий.

Характеристика Non‑escaping Escaping
Чи може бути викликане після return з функції Ні Так (така можливість є)
Чи потрібно явно позначати в сигнатурі Зазвичай ні (це значення за замовчуванням) Так, щоб компілятор знав намір
Захоплення self небезпечне циклом утримання Рідше й зазвичай локально Часто, особливо якщо зберігається у властивості
Чи може компілятор «сміливо» оптимізувати Так, більше можливостей Менше можливостей
Типове джерело map, filter, «виконай зараз» callback/completion, зберігання обробників

Типові сигнали: як «побачити» escaping очима

Коли ви читаєте чужий код, корисно швидко визначати: «це замикання безпечно локальне чи потенційно довгоживуче?» У реальності ви рідко побачите табличку «Це escaping!» у коментарях. Але ви побачите ознаки.

Перша ознака — присвоєння замикання у властивість. Друга — додавання до колекції. Третя — передача далі у функцію, яка явно зберігає. Четверта — сам стиль API, де замикання називається completion, callback, handler.

І є важлива звичка: якщо ви бачите, що замикання «йде у властивість», автоматично вмикайте в голові режим: «А що воно захоплює? А чи немає ризику утримати зайве?» Сьогодні ми ще не розбираємо, як саме обирати [weak self] і [unowned self] (це окрема лекція), але сам рефлекс «escaping => думайте про захоплення» дуже корисний.

Чому за замовчуванням non‑escaping

Можна поставити логічне запитання: «Чому Swift не зробив усе escaping за замовчуванням, щоб не заважати розробнику жити?»

Відповідь приблизно така: тому що більшість замикань у звичайному коді — це «виконати зараз» (ітерації, сортування, перетворення), і їм не потрібно втікати. А non‑escaping дає відразу два бонуси: менше неочікуваностей, особливо з утриманням об’єктів, і більше оптимізацій. У Swift Evolution це було оформлено окремим рішенням: зробити non‑escaping значенням за замовчуванням і вимагати явності для escaping.

Тобто це не бюрократія «поставте анотацію», а мова говорить: «Якщо замикання житиме довше, давайте домовимося про це явно, щоб я, компілятор, міг правильно забезпечити безпеку та продуктивність».

6. Приклад: події в мінізастосунку

Щоб не вивчати теорію у вакуумі, давайте додамо маленьку деталь до нашого навчального консольного застосунку, умовно назвемо його LibraryCLI. Уявімо, що ми хочемо друкувати повідомлення щоразу, коли «книгу додано». Жодної справжньої бази даних, жодної мережі — просто подія.

І тут у нас є два сценарії:

  • «Зроби просто зараз» — non‑escaping.
  • «Підпишись на подію та виклич пізніше» — escaping.

Non‑escaping: зробити відразу

import Foundation

func withLog(_ message: String, _ work: () -> Void) {
    print("LOG:", message)
    work()
    print("LOG: готово")
}

withLog("Додаємо книгу") {
    print("Книгу додано")
}
// LOG: Додаємо книгу
// Книгу додано
// LOG: готово

work тут не може «втекти»: ми викликаємо його всередині withLog. Це зручний патерн для обгорток «зроби й залогуй», «зроби й виміряй час», «зроби й перевір».

Escaping: підписка на подію

Тепер зробімо найпростіший «центр подій», який зберігає обробники.

import Foundation

class LibraryEventCenter {
    private var onBookAdded: (() -> Void)?

    func setOnBookAdded(_ handler: @escaping () -> Void) {
        onBookAdded = handler
    }

    func notifyBookAdded() {
        onBookAdded?()
    }
}

Чому @escaping? Тому що ми зберігаємо handler у властивість onBookAdded. Тобто handler переживає виклик setOnBookAdded.

І зверніть увагу: тут поки немає self, немає ARC-пасток — це чиста демонстрація часу життя замикання.

Використаємо:

import Foundation

let events = LibraryEventCenter()

events.setOnBookAdded {
    print("Подія: книгу додано!")
}

events.notifyBookAdded() // Подія: книгу додано!

Ось і все: замикання «втікає» з setOnBookAdded і живе всередині об’єкта events.

7. Типові помилки

Помилка №1: плутати «викликали наприкінці функції» з escaping.
Новачки іноді думають так: «Ну я ж викликаю замикання не одразу, а в кінці — отже, escaping». Ні: якщо виклик усе одно відбувається всередині функції і до return, замикання залишається non‑escaping. Escaping — це не «пізніше всередині», а «може бути після виходу назовні».

Помилка №2: не помічати, що append — це зберігання.
Коли замикання додається до масиву обробників, мозок може сприймати це як «я просто збираю список». Але компілятор сприймає це як «я зберігаю на потім». І він має рацію: масив переживе функцію, отже замикання теж.

Помилка №3: вважати, що escaping — це обов’язково асинхронність.
Плутають причину і наслідок. Асинхронні API часто вимагають escaping-замикань, тому що результат приходить пізніше. Але escaping може бути і без асинхронності: достатньо покласти замикання у змінну й викликати вручну.

Помилка №4: ігнорувати, що різниця впливає на безпеку мутацій.
На рівні новачка здається, що @escaping — це «просто щоб компілювалося». Але всередині це частина моделі пам’яті та правил доступу: non‑escaping дозволяє компілятору робити сильніші гарантії, а escaping ці гарантії послаблює, тому що момент виклику невідомий.

Помилка №5: робити «все escaping про всяк випадок».
Це пастка: здається, що так простіше. Але ви втрачаєте користь non‑escaping, тобто оптимізації та простіші гарантії, а головне — підвищуєте шанс захопити щось зайве й отримати складні ефекти часу життя об’єктів. Краще тримати правило: «escaping лише тоді, коли справді потрібно пережити функцію».

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