$ sudo teach IT

Зачем замыканию помнить, что было вокруг

Замыкание — это не просто кусок кода. Когда вы создаёте замыкание внутри функции, оно может пользоваться переменными и константами, которые были рядом в момент его создания, даже после того, как эта функция закончила работу. Например, функция может вернуть замыкание-«счётчик», которое продолжает помнить и увеличивать число, хотя сама функция уже давно завершилась. Без этого свойства замыкания были бы бесполезны для таких задач, как обработчики событий, отложенные вычисления или фабрики функций.

Официально это свойство называется захватом контекста (capture of context, или capturing values). Замыкание захватывает ссылки на переменные и константы из окружающего кода, и эти переменные продолжают жить, пока живёт хотя бы одно замыкание, которое их использует.

Как именно происходит захват

Захват по ссылке, а не снимок значения

Важно понимать: замыкание не запоминает значение переменной на момент своего создания. Оно запоминает саму переменную и читает её актуальное значение в момент вызова.

var score = 0
let showScore = { print("Счёт: \(score)") }
score = 42
showScore()

Замыкание showScore создано, когда score было равно нулю, но к моменту вызова значение уже изменилось на 42. На экране будет Счёт: 42, а не Счёт: 0. Замыкание держит связь с переменной, а не её застывшую копию.

Замыкание может пережить функцию, в которой родилось

Захваченные переменные не исчезают вместе со стеком функции, как обычные локальные переменные. Пока существует замыкание, которое их использует, они хранятся в памяти отдельно.

func makeCounter() -> () -> Int {
    var count = 0
    return {
        count += 1
        return count
    }
}

let counter = makeCounter()
print(counter())
print(counter())

Функция makeCounter объявляет локальную переменную count и возвращает замыкание, которое её изменяет. Сама функция уже завершилась, а count продолжает жить внутри возвращённого замыкания. Каждый вызов counter() увеличивает именно эту переменную, поэтому вывод — 1, затем 2. Если вызвать makeCounter() ещё раз, получится новый, независимый счётчик со своей копией count.

Capture list: явно указываем, что запомнить

Иногда захват по ссылке — это проблема, а не удобство. Представьте, что вы создаёте несколько замыканий в цикле, а переменная, которую они используют, продолжает меняться дальше. Все замыкания будут смотреть на одну и ту же переменную и в итоге увидят её последнее значение, а не то, что было в момент их создания.

var buttons: [() -> Void] = []
var value = 0
for i in 1...3 {
    value = i * 100
    buttons.append {
        print("Кнопка со значением \(value)")
    }
}
buttons[0]()
buttons[1]()
buttons[2]()

Все три замыкания захватили одну и ту же переменную value по ссылке. К моменту вызова цикл давно закончился, и value равно 300 — поэтому все три вызова напечатают Кнопка со значением 300, хотя логически ожидалось 100, 200 и 300.

Чтобы каждое замыкание запомнило значение переменной именно в момент своего создания, а не общую ссылку на неё, используется список захвата (capture list) — квадратные скобки сразу после открывающей фигурной скобки замыкания, перед in (а если параметров и типа возврата нет, список захвата стоит первым внутри скобок):

var buttonsFixed: [() -> Void] = []
var current = 0
for i in 1...3 {
    current = i * 100
    buttonsFixed.append { [current] in
        print("Кнопка со значением \(current)")
    }
}
buttonsFixed[0]()
buttonsFixed[1]()
buttonsFixed[2]()

Запись [current] создаёт для замыкания собственную константу с именем current, в которую копируется значение внешней переменной на момент создания замыкания. Дальнейшие изменения внешней current уже не влияют на то, что запомнило замыкание. Теперь вывод — 100, 200, 300, как и ожидалось.

В списке захвата можно перечислить несколько переменных через запятую: [a, b] in ..., а также дать захваченному значению другое имя: [capturedValue = value] in ....

@escaping: когда замыкание переживает вызов функции

По умолчанию Swift предполагает, что замыкание, переданное в функцию параметром, будет использовано и забыто до того, как функция завершится: например, вызвано внутри самой функции. Это позволяет компилятору более строго проверять код и эффективнее его выполнять. Но что если функция должна сохранить замыкание — например, положить его в массив, чтобы вызвать позже?

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

func schedule(_ action: @escaping () -> Void) {
    savedActions.append(action)
}

schedule {
    print("Выполнено позже")
}
print("Действие поставлено в очередь")
savedActions[0]()

Без ключевого слова @escaping перед типом параметра компилятор не разрешит строку savedActions.append(action): он не может гарантировать, что замыкание безопасно хранить дольше, чем длится вызов schedule. Пометка @escaping — это честное предупреждение и компилятору, и читателю кода: «это замыкание может быть вызвано уже после того, как функция вернёт управление». В примере schedule кладёт переданное действие в массив, и оно выполняется только тогда, когда до него доходит очередь — в отдельной строке, после того как schedule давно отработала.

@escaping ставится только на параметр-замыкание, а не на обычные локальные замыкания и не на возвращаемый функцией тип — компилятор сам подскажет, если пометка нужна, а вы её не поставили.

Сильные ссылки и утечки памяти: коротко

Есть ещё одна ловушка, связанная с захватом: если замыкание держит ссылку на объект, а этот объект, в свою очередь, где-то хранит само замыкание, оба будут держать друг друга в памяти сколько угодно долго — даже когда они больше никому не нужны. Такая ситуация называется циклом сильных ссылок. Лекарство — пометить в списке захвата нужную переменную как weak, чтобы замыкание держало на неё не полноценную, а слабую ссылку, которая не мешает освобождению памяти. Полностью разберём эту тему, включая слово self в классах, когда дойдём до устройства памяти в Swift — сейчас достаточно знать, что такая проблема существует и у неё есть стандартное решение.

Частые ошибки

  • Ждать от захваченной переменной «снимок» значения на момент создания замыкания. На самом деле по умолчанию замыкание видит актуальное, самое свежее значение.
  • Создавать несколько замыканий в цикле, которые захватывают одну общую переменную, а потом удивляться, что все они «видят» одинаковое последнее значение — забыт список захвата.
  • Добавлять @escaping «на всякий случай» ко всем параметрам-замыканиям. Ставьте его только тогда, когда компилятор действительно об этом просит: замыкание сохраняется, передаётся дальше или вызывается не сразу.
  • Путать список захвата с параметрами замыкания: [value] — это захват внешней переменной, а не аргумент, который передают при вызове замыкания.

Резюме

  • Замыкание захватывает переменные и константы из окружающего кода и может использовать их даже после того, как содержащая их функция завершилась.
  • По умолчанию захват идёт по ссылке: замыкание видит текущее значение переменной, а не то, что было в момент его создания.
  • Список захвата [x] перед in заставляет замыкание запомнить значение x именно в момент создания, а не следить за переменной дальше.
  • @escaping нужен параметру-замыканию, который переживёт вызов функции — например, будет сохранён и вызван позже.
  • Замыкание, которое держит объект, и объект, который держит замыкание, могут образовать цикл сильных ссылок; решение через weak подробно разберём при изучении памяти.

Проверьте себя

4 вопроса

Счётчик из замыкания

Напишите функцию makeCounter(start:), которая принимает начальное значение start: Int и возвращает замыкание типа () -> Int.

Каждый вызов возвращённого замыкания должен увеличивать внутреннее значение на 1 и возвращать новое значение. Первый вызов должен вернуть start + 1, второй — start + 2, и так далее.

Замыкания, полученные от двух разных вызовов makeCounter, обязаны считать независимо друг от друга: изменение одного не должно влиять на другое.

Снимки значения через список захвата

Напишите функцию makeSnapshots(count: Int) -> [() -> Int]. Она должна вернуть массив из count замыканий.

Внутри функции заведите переменную-счётчик, начните её с 0, и на каждом шаге цикла увеличивайте на 10 перед тем, как создать очередное замыкание. Каждое замыкание при вызове обязано возвращать то значение счётчика, которое было в момент ЕГО СОЗДАНИЯ, а не финальное значение после всего цикла — то есть первое замыкание должно возвращать 10, второе — 20, третье — 30 и так далее.

Без списка захвата все замыкания будут ссылаться на одну и ту же переменную и в итоге вернут последнее значение — используйте [x], чтобы каждое замыкание запомнило своё собственное значение.