1. Модель «функция с рюкзаком»
Когда вы впервые видите замыкания, они кажутся просто ещё одним способом написать маленькую функцию. Но довольно быстро вы замечаете странность: замыкание может использовать переменные “снаружи”, как будто у него есть маленький портал в прошлое. Это удобно, но иногда приводит к неожиданным результатам: “почему условие изменилось?” или “почему счётчик не сбросился?”. Чтобы не ловить такие баги, важно понять захват интуитивно.
Под захватом (capture) в Swift обычно понимают ситуацию, когда замыкание использует значения из внешней области видимости: переменные и константы, объявленные вокруг него. То есть замыкание не живёт в вакууме — оно “видит” окружение.
Интуитивная модель: замыкание как «функция с рюкзаком»
Представьте, что замыкание — это не только код, но и “рюкзак”, в который оно кладёт всё, что ему нужно из внешнего мира. Когда мы передаём замыкание куда-то или сохраняем в переменную, мы как бы переносим код + рюкзак вместе.
Можно изобразить это так:
[Внешняя область]
var threshold = 10
let f = { x in x > threshold }
│
▼
f = (код: "x > threshold")
+ (окружение: "threshold")
И вот тут ключевой вопрос лекции: что именно лежит в “окружении”? Это копия числа 10? Это ссылка на переменную threshold? Это “что-то третье”?
Что именно захватывается: переменная, а не «снимок»
Обычно новичок думает так: “Ну раз threshold был 10, когда я написал замыкание, значит замыкание навсегда запомнит 10”. А Swift такой: “Ха. Хо-хо. Сейчас посмотрим”.
Сделаем пример, где замыкание сохраняется, а потом выполняется, когда внешняя переменная уже изменилась.
import Foundation
var threshold = 10
let isBig: (Int) -> Bool = { x in
x > threshold
}
threshold = 20
print(isBig(15)) // false
print(isBig(25)) // true
Почему isBig(15) стало false? Потому что к моменту вызова threshold уже равен 20, и замыкание использует текущее значение.
Интуитивный вывод: замыкание чаще всего “помнит не число 10”, а саму переменную threshold (условно — “коробку”, внутри которой лежит значение). Мы поменяли содержимое коробки — замыкание увидело новое содержимое.
3. Захват let и var: стабильность и состояние
Захват let: проще всего, потому что значение не меняется
С let проще психологически: если значение не меняется, то и сюрпризов меньше. Вы один раз задали константу — и она всегда одинаковая, поэтому даже если замыкание “помнит переменную”, переменная неизменяемая, и результат стабилен.
import Foundation
let prefix = "BOOK-"
let makeCode: (Int) -> String = { number in
"\(prefix)\(number)"
}
print(makeCode(7)) // BOOK-7
print(makeCode(42)) // BOOK-42
В реальном Swift есть важная деталь: захват неизменяемых значений, объявленных через let, можно рассматривать как “безопасный” и фактически “снимок значения”, потому что менять всё равно нечего. В материалах по развитию Swift это формулируется как идея “неизменяемые значения захватываются по значению (by-value)”.
Захват var: общая «коробка» для двух областей видимости
Теперь вернёмся к var, потому что именно он создаёт большинство “что вообще происходит” моментов. Замыкание может читать var, и это уже интересно. Но ещё веселее — оно может менять этот var, и переменная снаружи тоже увидит изменения.
import Foundation
var query = "swift"
let matchesQuery: (String) -> Bool = { title in
title.lowercased().contains(query)
}
print(matchesQuery("Swift Basics")) // true
query = "ios"
print(matchesQuery("Swift Basics")) // false
print(matchesQuery("iOS Guide")) // true
Это тот же эффект “коробки”: замыкание и внешний код смотрят на одну и ту же переменную. Поэтому изменение query снаружи меняет поведение замыкания.
Здесь появляется первое правило читаемости: если замыкание хранится где-то и будет вызываться позже, захват var превращает его поведение в “зависящее от текущего состояния программы”. Это может быть круто (например, фильтр с меняющимся режимом), но может быть и ловушкой (“почему фильтр внезапно стал другим?”).
Замыкание как состояние: генератор ID и память между вызовами
Окей, мы поняли, что замыкание может читать внешнюю переменную. Теперь следующий шаг: а что если замыкание само будет хранить внутреннее состояние между вызовами? Звучит как “маленький объект без класса” (классы будут позже, не торопимся), но по ощущениям — примерно так.
Сделаем генератор ID: каждый вызов возвращает следующее число.
import Foundation
func makeIDGenerator() -> () -> Int {
var id = 0 // локальная переменная
let nextID: () -> Int = { // фактически объявили функцию внутри фукнции
id += 1
return id
}
return nextID // вернули дочернюю функцию наружу, а она захватывает локальную переменную
}
let gen = makeIDGenerator()
print(gen()) // 1
print(gen()) // 2
print(gen()) // 3
Что произошло? Переменная id была объявлена внутри makeIDGenerator(). Казалось бы, функция завершилась — переменная должна исчезнуть. Но мы вернули замыкание nextID, которое захватило id, и теперь оно живёт вместе с замыканием.
Это и есть мощная сторона захвата: замыкания позволяют создавать функции с “памятью”, даже не вводя новые типы.
4. Захват и параметры: как не сделать код загадочным
Когда вы видите, что замыкание может “таскать” с собой внешние переменные, возникает соблазн: “А давайте вообще всё будем брать из внешней области — меньше параметров, красота”. Обычно это заканчивается тем, что код становится похож на детектив, в котором слишком много подозреваемых: непонятно, откуда пришло значение и кто его поменял.
Практичнее держать такую модель:
| Что это по смыслу | Лучше сделать… | Почему |
|---|---|---|
| “Это входные данные для вычисления” | параметром замыкания/функции | так проще тестировать и читать |
| “Это настройка, одинаковая для многих вызовов” | захватить let | поведение стабильное, меньше шума |
| “Это режим, который может меняться” | захватить var, но явно | поведение зависит от состояния — это должно быть заметно |
| “Это накопление состояния (счётчик, статистика)” | захватить var, но не прятать | иначе следующий читатель кода будет плакать |
Если сказать совсем по-человечески: параметры — это то, что вы хотите видеть в скобках, а захват — то, что вы готовы “держать в фоне”, потому что оно либо константное, либо очень осмысленно является состоянием.
5. Как «заморозить» значение при захвате
Иногда нужно ровно обратное тому, что мы видели в начале: вы хотите, чтобы замыкание использовало значение на момент создания, даже если внешняя переменная потом поменяется. Например: “создай фильтр по порогу 10” — и пусть он всегда будет про 10, даже если мы потом поменяли глобальную настройку.
Способ 1: локальная константа
Первый — самый понятный новичку: переложить текущее значение в let, а замыканию дать захватить эту константу.
import Foundation
var threshold = 10
let fixedThreshold = threshold
let isBigFixed: (Int) -> Bool = { x in
x > fixedThreshold
}
threshold = 20
print(isBigFixed(15)) // true
Способ 2: capture list
Второй способ — capture list. Это синтаксис вроде { [someVar] in ... }, который явно говорит: “захвати вот это значение вот так”. В Swift capture list часто используют для контроля захвата, и в частности, чтобы захватить изменяемую переменную как неизменяемую “копию на момент создания”.
import Foundation
var suffix = "!"
let addSuffix: (String) -> String = { [suffix] text in //запоминает suffix на момент захвата, а не вызова
text + suffix
}
suffix = "??"
print(addSuffix("Hi")) // Hi!
Мы изменили внешнюю suffix, но addSuffix продолжает использовать старое значение "!", потому что мы явно “заморозили” его через capture list.
Важно: мы сейчас не превращаем capture list в обязательную религию. На старте курса достаточно понимать идею: по умолчанию замыкание часто “смотрит” на переменную, а при необходимости можно заставить его “сфотографировать” значение.
6. Практика: фильтры для списка книг
Теперь давайте соберём всё в один смысловой пример, который выглядит как часть реального приложения. Пусть у нас есть мини‑каталог книг (пока без struct, просто tuples и массив — то, что вы уже умеете).
import Foundation
let books: [(title: String, year: Int)] = [
(title: "Swift Basics", year: 2021),
(title: "iOS Guide", year: 2020),
(title: "Algorithms", year: 2018)
]
let minYear = 2020
let recent = books.filter { book in
book.year >= minYear
}
print(recent) // [(title: "Swift Basics", year: 2021), (title: "iOS Guide", year: 2020)]
Здесь minYear — let, он захватывается спокойно и предсказуемо.
Теперь сделаем интереснее: хотим фабрику фильтров “содержит подстроку”. Это хороший кейс на захват, потому что строка‑запрос — это настройка фильтра.
import Foundation
func makeTitleContainsFilter(_ rawQuery: String) -> ((title: String, year: Int)) -> Bool {
let query = rawQuery.lowercased()
return { book in
book.title.lowercased().contains(query)
}
}
let containsSwift = makeTitleContainsFilter("swift")
print(containsSwift((title: "Swift Basics", year: 2021))) // true
print(containsSwift((title: "Algorithms", year: 2018))) // false
Обратите внимание на маленький трюк для читаемости: мы специально сделали query константой (let query = ...). Это как раз “заморозка” значения, но без capture list: фильтр стабилен, и нам не нужно думать, что там будет “позже”.
Теперь совместим с filter:
import Foundation
let books: [(title: String, year: Int)] = [
(title: "Swift Basics", year: 2021),
(title: "iOS Guide", year: 2020),
(title: "Algorithms", year: 2018)
]
let predicate = makeTitleContainsFilter("iOS")
let result = books.filter(predicate)
print(result) // [(title: "iOS Guide", year: 2020)]
Вот здесь вы начинаете чувствовать, зачем захват вообще нужен: мы сделали маленький “настраиваемый фильтр”, который можно передавать куда угодно — в filter, в свою функцию, хранить в переменной, комбинировать.
7. Подводные камни: inout и неожиданные копии
Есть особый класс ситуаций, когда захват приводит к эффекту “я думал, что меняю переменную, а она не меняется”. Чаще всего это связано с тем, что вы пытаетесь захватывать то, что живёт как временный “доступ на изменение”, например inout-параметр. На уровне интуиции это похоже на попытку унести из магазина тележку, пока кассир ещё пробивает товары: формально тележка есть, но правила доступа не дают сделать это так, как вам хочется.
В истории Swift это было источником очень неожиданных эффектов, когда замыкание “убегало” наружу (возвращалось из функции) и продолжало менять не оригинальную переменную, а её “теневую копию”.
Здесь нам не нужно углубляться в детали (мы не вводим сейчас правила escaping/non-escaping), но практическое ощущение такое: если вы видите inout и замыкание в одной комнате — действуйте осторожно и старайтесь не “уносить” замыкание наружу, если оно опирается на inout.
8. Типичные ошибки при захвате переменных
Ошибка №1: ожидать, что замыкание “запоминает значение навсегда”, хотя захвачена изменяемая переменная.
Это классическая ситуация с var threshold: вы создали замыкание, потом поменяли threshold, а потом удивились, что поведение изменилось. Лечится очень просто: либо делайте захватываемое значение константой (let fixed = threshold), либо используйте capture list, если вы уже готовы к этому синтаксису.
Ошибка №2: прятать важные входные данные в захват, вместо того чтобы сделать их параметрами.
Если замыкание зависит от “чего-то снаружи”, читателю приходится искать, где это “снаружи” объявлено и кто это меняет. Если значение по смыслу является входом “на каждый вызов”, лучше передать его параметром и не превращать код в квест.
Ошибка №3: случайно сделать замыкание stateful, а потом удивляться, что результат зависит от прошлых вызовов.
Счётчики, накопление суммы, генераторы ID — всё это нормально, но должно быть очевидно из кода. Если внутри замыкания есть counter += 1, это уже не “чистая функция”, а функция с памятью. Обычно лучше давать таким переменным честные имена (counter, nextID) и держать тело замыкания коротким.
Ошибка №4: захватывать слишком много всего подряд.
Замыкание легко превращается в “тащу весь мир”, когда вы используете внутри него кучу внешних переменных. От этого страдает и читаемость, и предсказуемость. Хорошая привычка — перед созданием замыкания сформировать небольшое “контекстное” let-значение (например, нормализованный query), и захватывать ровно его.
Ошибка №5: пытаться сочетать захват и inout так, как будто это обычная переменная.
Даже если какие-то примеры “работают”, в более сложных случаях можно получить очень неочевидное поведение или запреты компилятора. Если вам нужно изменить значение, переданное через inout, лучше сделать это напрямую в функции, а замыкание использовать как правило вычисления (без попытки удерживать inout внутри замыкания).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ