JavaRush /Курсы /Swift SELF /Escaping vs non‑escaping closures в Swift

Escaping vs 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

Сейчас самое полезное правило, которое стоит буквально приклеить себе на монитор (но аккуратно, чтобы монитор не стал sticky repository).

Замыкание становится 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: done")
}

withLog("Добавляем книгу") {
    print("Книга добавлена")
}
// LOG: Добавляем книгу
// Книга добавлена
// LOG: done

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.

И notice: здесь пока нет 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 только тогда, когда реально нужно пережить функцию”.

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