1. Навіщо говорити про захоплення змінних у замиканнях
Коли ви тільки звикаєте до функцій, замикання здаються «функціями в кишені»: створили { ... }, передали далі — і воно виконалося. Але в замикання є одна суперсила: воно пам’ятає значення із зовнішньої області видимості. Це зручно, бо можна «запакувати» поведінку разом із потрібними даними. І небезпечно, бо замикання може випадково утримувати об’єкт довше, ніж ви розраховували.
Почнімо з максимально безпечного прикладу: захоплення звичайної числової змінної. Тут жодного ARC-жаху не буде — просто відчуємо механіку.
import Foundation
func makeGreeter() -> () -> Void {
var counter = 0
return {
counter += 1
print("Привіт #\(counter)") // Спочатку Привіт #1, потім Привіт #2, ...
}
}
let greet = makeGreeter()
greet()
greet()
Замикання захопило counter і продовжує його змінювати, хоча функція makeGreeter() давно завершилася. Це нормальна й корисна поведінка.
Проблеми починаються тоді, коли замикання захоплює посилальний об’єкт (class), особливо якщо цей об’єкт сам зберігає замикання.
2. Як з’являється цикл «об’єкт → замикання → об’єкт»
Найчастіший сценарій виглядає невинно: у об’єкта є властивість-колбек (замикання), яке викличеться «коли-небудь». Ви записуєте туди { ... }, а всередині використовуєте self, бо «ну а хто ще викликатиме мої методи». І ось тут, без фанфар, ви можете отримати retain cycle.
Схема проста:
- об’єкт сильно зберігає замикання у своїй властивості;
- замикання сильно захоплює self (тобто об’єкт);
- утворюється коло, з якого ARC не вміє «здогадатися, що ви хотіли добра».
Це саме той класичний цикл self -> closure -> self, який часто наводять як приклад витоку пам’яті.
Давайте зробимо навчальний фрагмент для нашого консольного застосунку (умовно назвімо його LibraryCLI). Уявімо, що є об’єкт «сесія», який зберігає обробник події.
import Foundation
final class LibrarySession {
var onFinish: (() -> Void)?
deinit {
print("deinit LibrarySession")
}
func finish() {
onFinish?()
}
}
Тепер напишемо контролер, який володіє сесією і «підписується» на завершення:
import Foundation
final class LibraryController {
let session = LibrarySession()
deinit {
print("deinit LibraryController")
}
func start() {
session.onFinish = {
print("Сесію завершено, контролер реагує")
self.cleanup()
}
}
private func cleanup() {
print("cleanup()")
}
}
Виглядає логічно. Але якщо ви створите контролер, викличете start(), а потім «відпустите» зовнішнє посилання на контролер, deinit може так і не спрацювати. Причина проста: LibraryController тримає session, LibrarySession тримає onFinish, а onFinish тримає self (тобто LibraryController).
Можна уявити це такою мінісхемою:
flowchart LR
C[LibraryController] -->|сильно| S[LibrarySession]
S -->|сильно| F[замикання onFinish]
F -->|сильно захоплює| C
ARC дивиться на це і каже: «Ну… у LibraryController точно є сильне посилання — всередині замикання. Отже, живемо далі». І так «далі» може тривати нескінченно, поки застосунок працює.
3. Список захоплень і [weak self]
Що таке список захоплень і де його записують
Capture list — це список захоплень на початку замикання, який пишеться в квадратних дужках перед in. Він існує саме для того, щоб ви могли явно керувати тим, як замикання утримує зовнішні значення.
Синтаксис у загальному вигляді має такий вигляд:
{ [capture list] (params) -> ReturnType in
// тіло
}
На практиці найчастіше трапляються такі варіанти:
| Приклад | Що означає |
|---|---|
|
Явно захопити self — сильно. |
|
Захопити self слабко: не утримувати об’єкт. |
|
Захопити self без утримання, але з жорсткою гарантією, що self живий. |
|
Захопити значення x і зафіксувати його на момент створення. |
|
Слабо захопити інший об’єкт logger. |
Нам сьогодні потрібен найпопулярніший варіант: [weak self].
Важливо: навіть якщо ви нічого не пишете в capture list, замикання все одно може захопити self. Просто це відбуватиметься «за замовчуванням» і не впадатиме в око. Саме тому цикл утримання особливо прикрий: код виглядає звичайним, а витік пам’яті лишається непомітним.
Що робить [weak self] і чому після нього self стає Optional
Коли ви пишете [weak self], ви просите замикання не володіти об’єктом. Тобто замикання матиме слабке посилання на об’єкт: якщо об’єкт зникне, посилання автоматично стане nil. Це пряме продовження того, що ми обговорювали щодо weak-посилань: вони не утримують об’єкт і завжди є опціональними.
Тому всередині такого замикання self стає типу Self? (наприклад, LibraryController?). І компілятор починає чесно вимагати: «Покажіть, як ви обробите випадок, коли self == nil».
Тобто після [weak self] так писати не можна:
session.onFinish = { [weak self] in
cleanup() // компілятор буде невдоволений: незрозуміло, що робити при self == nil
}
А ось так — можна, бо ми явно описали поведінку:
session.onFinish = { [weak self] in
self?.cleanup()
}
Або через guard — це зазвичай читається ще краще, особливо коли логіки більше ніж кілька рядків.
4. Два стилі [weak self]: guard і self?.
З [weak self] у новачка зазвичай дві емоції. Перша: «Чудово, витоків не буде!» Друга — через 10 с: «Чому тепер усюди якісь ? і guard, і чому компілятор не вірить у мою любов до акуратності?»
Це нормальний етап. На практиці у 90 % випадків ви обиратимете один із двох стилів: або «якщо self помер — спокійно виходимо», або «виконуємо окремі дії, якщо self ще живий». Розберімо обидва підходи, щоб обирати їх свідомо, а не «як у першій-ліпшій пораді в інтернеті».
Стиль guard let self else { return }
Цей стиль добрий, коли у вас є шматок логіки, який має сенс виконувати лише тоді, коли об’єкт живий, і ви хочете, щоб посеред виконання self не «зник» раптово. Наприклад, щоб не виявилося: перший рядок ви виконали, а далі об’єкт уже звільнено.
Патерн має такий вигляд:
session.onFinish = { [weak self] in
guard let self = self else { return }
self.cleanup()
print("Готово")
}
Тут важлива думка: після guard let self = self ви отримуєте сильне локальне посилання self — воно «маскує» слабке. На час виконання замикання об’єкт утримується цим локальним сильним посиланням, а після виходу із замикання посилання зникає.
Також ви часто побачите коротшу форму:
session.onFinish = { [weak self] in
guard let self else { return }
cleanup()
}
Тут self — це вже локальна константа, а не «захоплений self» безпосередньо. Тому далі можна писати виклики без self.: cleanup() читається як self.cleanup().
Давайте застосуємо це до нашого LibraryController, виправивши цикл:
import Foundation
final class LibraryController {
let session = LibrarySession()
deinit { print("deinit LibraryController") }
func start() {
session.onFinish = { [weak self] in
guard let self else { return }
self.cleanup()
}
}
private func cleanup() {
print("cleanup()")
}
}
Тепер замикання не утримує контролер постійно, і ARC зможе викликати deinit, коли зовнішні посилання на контролер зникнуть.
Стиль self?.
Цей стиль добрий, коли логіка всередині замикання дуже коротка і не страшно, якщо «нічого не станеться», коли об’єкт уже зник.
session.onFinish = { [weak self] in
self?.cleanup()
}
Він компактний, але має характерну пастку: якщо всередині 5–6 рядків, вам доведеться писати self?. багато разів, і код починає виглядати так, ніби ви ні в чому не впевнені. Плюс іноді вам важливо зробити все або нічого, а self?. перетворює виконання на «можливо, зробимо половину, якщо пощастить» — а це не завжди те, що ви хочете.
Якщо ви помітили, що self?. повторюється частіше двох разів, це добрий привід перейти на guard let self.
5. Корисні нюанси: вкладені замикання і діагностика
Неявний self після unwrap і обережність із вкладеними замиканнями
Після того як ви зробили guard let self else { return }, можна писати виклики методів без self. — тобто використовувати неявний self. Це особливо приємно в коді з weak self, бо інакше було б занадто багато «візуального шуму».
Приклад:
session.onFinish = { [weak self] in
guard let self else { return }
cleanup() // це означає self.cleanup()
print("Завершено")
}
Але є місце, де потрібно вмикати режим обережного програміста: вкладені замикання.
Уявіть, що всередині вашого колбеку ви створюєте ще одне замикання і десь його зберігаєте або передаєте. Вкладені замикання можуть знову непомітно захопити self і створити новий цикл утримання. Тому у вкладених замиканнях варто явно контролювати захоплення: або знову писати capture list, або явно писати self. — щоб читачеві було видно, що саме відбувається.
Навчальний приклад-«маячок» — важлива саме ідея, а не практична користь:
func configure() {
session.onFinish = { [weak self] in
guard let self else { return }
let later = { [weak self] in
guard let self else { return }
self.cleanup()
}
later()
}
}
Так, це трохи довше. Зате читач коду одразу бачить: «ага, тут ще одне замикання, і воно теж керує захопленням self».
Мінідіагностика: як перевірити, що [weak self] справді допоміг
Дуже хочеться, щоб після додавання [weak self] усе стало добре «за замовчуванням». Але на практиці буває так: ви поставили [weak self] в одному місці, а цикл усе ще живе через іншу властивість, колекцію або ще одне замикання.
Найпростіший детектор у навчальних прикладах — print у deinit. Ми цим уже користуємося, і це абсолютно нормальна техніка на перших етапах.
Давайте зберемо мінідемо в коді верхнього рівня, щоб побачити різницю на власні очі:
import Foundation
func demo() {
var controller: LibraryController? = LibraryController()
controller?.start()
controller?.session.finish()
controller = nil
print("Контролер тепер nil")
}
demo()
Якщо все зроблено правильно, ви повинні побачити, що після controller = nil (або одразу після виходу з demo) спрацьовує deinit LibraryController і deinit LibrarySession.
Якщо цього не сталося, то майже завжди десь лишилося сильне посилання. Часта причина — збережене замикання, яке тримає об’єкт через захоплення self.
І ще практична порада: якщо ви зберігаєте замикання у властивості й воно потрібне лише тимчасово, іноді корисно «відписатися» після використання, присвоївши nil:
session.onFinish = { [weak self] in
guard let self else { return }
self.cleanup()
self.session.onFinish = nil // знімаємо підписку
}
Це не «заміна weak self», а додаткова гігієна: ви прибираєте зайві зв’язки, і граф посилань стає простішим.
6. Типові помилки під час захоплення self і використання [weak self]
Перші помилки тут зазвичай не тому, що студент «неуважний», а тому, що мозок ще не звик мислити графом посилань. Ви пишете код як історію дій, а ARC живе як бухгалтер: «хто на кого посилається і хто кого утримує». Давайте акуратно пройдемося по найчастіших граблях, щоб ви впізнавали їх ще до того, як на них наступите.
Помилка № 1: поставити [weak self], а потім написати self! «бо інакше не компілюється».
Таке трапляється часто: людина побачила, що self став Optional, і вирішила «та ну його, я впевнена». Проблема в тому, що weak self якраз говорить: «об’єкт може зникнути». Якщо ви потім робите self!, ви перетворюєте зникнення об’єкта на потенційний креш. Правильна звичка — або guard let self else { return }, або self?..
Помилка № 2: використовувати self?. там, де логіка має виконатися цілком або не виконатися взагалі.
self?.step1(); self?.step2(); self?.step3() виглядає компактно, але це «м’яка» стратегія: якщо self стане nil між рядками (або у вас з’являться додаткові умови), ви можете отримати часткове виконання. Коли важлива цілісна логіка, guard let self робить намір набагато яснішим.
Помилка № 3: не помітити другий цикл через вкладене замикання.
Ви полагодили основний колбек: додали [weak self]. А всередині цього колбеку створили ще одне замикання і десь його зберегли або передали — і воно вже захопило self сильно. Вкладені замикання вимагають особливої уваги: там особливо важливо, щоб захоплення було явним у коді.
Помилка № 4: обрати unowned «бо так коротше».
Коротко — так, безпечно — не завжди. unowned вимагає реального контракту: об’єкт гарантовано живий, коли ви звертаєтеся до посилання. Якщо такої гарантії немає, краще weak. Ми тут зосереджуємося на [weak self] саме тому, що для новачка це найбезпечніша стратегія за замовчуванням.
Помилка № 5: чекати, що deinit викличеться «одразу після останнього використання», а не після зникнення останнього сильного посилання.
Це тонка психологічна пастка. Ви «припинили використовувати» об’єкт, але десь у колекції, властивості або замиканні ще лишилося сильне посилання — і ARC чесно тримає об’єкт живим. Тому deinit — це не нагорода за гарну поведінку, а просто сигнал: «сильних посилань більше немає».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ