JavaRush /Курси /Swift SELF /Замикання та захоплення s...

Замикання та захоплення self: список захоплень [ weak self ]

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

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] in ... }
Явно захопити self — сильно.
{ [weak self] in ... }
Захопити self слабко: не утримувати об’єкт.
{ [unowned self] in ... }
Захопити self без утримання, але з жорсткою гарантією, що self живий.
{ [x] in ... }
Захопити значення x і зафіксувати його на момент створення.
{ [weak logger] in ... }
Слабо захопити інший об’єкт 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 — це не нагорода за гарну поведінку, а просто сигнал: «сильних посилань більше немає».

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