Захват self в замыкании — обычная вещь в Swift, которая скрывает множество нюансов. Нужно ли делать его weak, чтобы избежать цикла ссылок? И является ли проблемой сделать его weak постоянно?

На прошлой неделе в iOS Dev Weekly была опубликована статья Benoit Pasquier, посвященная захвату self в замыканиях. Моя статья будет ей противоречить. И это нормально! Примите все эти советы с определенной долей скептицизма, разберитесь в компромиссах и выберите те техники, которые подходят вам лучше всего.

Итак, давайте начнем.

Три золотых правила

Дискутировать о циклах удержания сложно. Когда я обучаю людей использованию weak self (или списков захвата), чтобы избежать утечек памяти, то привожу три золотых правила:

  1. Сильно удерживаемый self не всегда является циклом удержания.

  2. Слабо удерживаемый self никогда не будет циклом удержания.

  3. Делайте апгрейд уровня self до сильного в верхней части замыканий, чтобы избежать странного поведения.

Давайте посмотрим на эти правила в действии.

Пример цикла удержания

Цикл удержания — это когда объект сохраняет сам себя. В данном случае дочерний класс сохраняет замыкание, ссылающееся на его родительский класс, вызывая тем самым цикл удержания:

class Parent {
    let child = Child()
    var didChildPlay = false
    func playChildLater() {
        child.playLater {
            self.didChildPlay = true
        }
    }
}

class Child {
    var finishedPlaying: () -> Void = {}
    func playLater(completion: @escaping () -> Void) {
        finishedPlaying = completion
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
            // Play! ⚽️????????
            completion()
        }
    }
}

let parent = Parent()
parent.playChildLater()
// `parent` is no longer used, but not recycled.

Такой цикл удержания представлен на данной диаграмме:

Этот цикл может быть прерван. Если после вызова finishedPlaying будет переназначен пустому замыканию, цикл разорвется и память будет освобождена. С нашей стороны требуется особая осведомленность, чтобы быть очень внимательным к тому, когда такое может случиться, и в какой момент это необходимо устранить.

Правило 1: Сильный self не всегда является циклом удержания

Хотя передача сильно удерживаемого self в замыкание — это действительно хороший способ случайно создать цикл удержания, это не гарантирует, что он появится. На самом деле, компилятор пытается помочь нам правильно использовать память. Он делает различие между убегающими (escaping) и неубегающими (non-escaping) замыканиями.

Убегающие и неубегающие замыкания

Возможно, вы написали метод, который выполнил замыкание, после чего на вас накричал компилятор:

Ошибка: Присвоение non-escaping параметра 'closure' закрытию @escaping.
Ошибка: Присвоение non-escaping параметра 'closure' закрытию @escaping.

Эта аннотация требуется компилятору, если аргумент закрытия вашего метода имеет время жизни, превышающее лайфтайм метода. Другими словами, выходит ли он за фигурные скобки вашей function ? Если нет, то когда метод возвращается, мы знаем, что уже ничто не может его удержать. Если ничто не может присвоить себе это замыкание, то оно не может быть частью цикла удержания, независимо от того, что оно сильно захватывает. Другими словами, всегда безопасно использовать сильный self в non-@escaping замыкании.

Non-escaping дочерний метод

Давайте посмотрим это на примере нашего кода выше. Если мы можем гарантировать, что закончим работу с методом завершения до того, как метод вернется, то можно убрать аннотацию @escaping. Давайте напишем non-escaping метод play(completion:) без аннотации:

extesion Child {
    func play(completion: () -> Void) {
        // ????
        completion()
    }
}

Используя этот метод от Parent, мы можем увидеть его в действии:

extension Parent {
    func playChild() {
        child.play {
            self.didChildPlay = true
        }
    }
}

Еще лучше то, что нам не нужно указывать ключевое слово self. Замыкание по-прежнему захватывает self, но компилятор знает, что это не создаст цикл удержания, поэтому не требует указывать его явно

extension Parent {
     func playChild() {
         child.play {
-            self.didChildPlay = true
+            didChildPlay = true
         }
     }
 }

Это один из способов, с помощью которого компилятор нам помогает. Если удается обойтись без написания self., то мы можем быть уверены, что это замыкание не будет сохранено как часть цикла удержания.

Правило 2: Слабый self никогда не будет участвовать в цикле удержания

Возможно, вы предпочитаете видеть self. даже когда этого не требуется, чтобы сделать семантику захвата явной. Теперь вам нужно решить, должен ли self быть захвачен слабо или нет. Реальный вопрос из первого Золотого правила таков: уверены ли вы, что замыкание в этом методе не является @escaping ?

  • Проверяли ли вы документацию на каждое созданное вами замыкание?

  • Вы уверены, что документация соответствует реализации?

  • Уверены ли вы, что при обновлении зависимостей имплементация не изменилась?

Если какой-либо из этих вопросов посеял зерно сомнения, вы поймете, почему техника использования [weak self] везде, где вы используете замыкание, так популярна. Давайте используем weak self в нашем методе playLater(completion:):

class Parent {
    // ...
    func playChildLater() {
        child.playLater { [weak self] in
            self?.didChildPlay = true
        }
    }
}

Не имеет значения, как это замыкание передается, сохраняется, если оно @escaping или нет. Это замыкание не захватывает сильную ссылку на класс Parent, поэтому мы уверены, что оно не создаст цикл удержания.

Правило 3: Делайте апгрейд self, чтобы избежать странного поведения

Если мы будем следовать второму правилу, то нам придется работать с большим количеством weak self повсюду. Это может стать обременительным. Стандартный совет — использовать оператор guard let для апгрейда self до сильной ссылки в верхней части закрытия, например, так:

class Parent {
    // ...
    func playChildLater() {
        child.playLater { [weak self] in
            guard let self = self else { return }
            self.didChildPlay = true
        }
    }
}

Но почему? Почему нет...

  • Использовать strongSelf, чтобы я мог сохранить слабую ссылку?

  • Просто использовать weak self несколько раз в моем коде?

Использование strongSelf вместо self

Рассмотрим следующий фрагмент кода:

class Parent {
    // ...
    let firstChild = Child()
    let secondChild = Child()
    func playWithChildren(completion: @escaping (Int) -> Void) {
        firstChild.playLater { [weak self] in
            guard let strongSelf = self else {
                return
            }
            strongSelf.gamesPlayed += 1
            strongSelf.secondChild.playLater {
                if let strongSelf = self {
                    print("Played \(self?.gamesPlayed ?? -1) with first child.")
                }
                strongSelf.gamesPlayed += 1
                completion(strongSelf.gamesPlayed)
            }
        }
    }
}

Здесь мы называем наш обновленный self "strongSelf", чтобы можно было передать слабую ссылку в последующий метод. Этот код работает, но увеличивает сложность программы, которую вам приходится писать. При увеличении сложности возрастает вероятность появления коварных ошибок.

Например, вы заметили:

  • strongSelf не подсвечивается синтаксисом, как self, поэтому его труднее заметить.

  • self?.gamesPlayed ?? -1 используется там, где можно было бы использовать strongSelf.gamesPlayed

  • strongSelf случайно захватывается во внутреннем замыкании, вызывая цикл удержания в замыкании, в котором использовался weak self

Вы можете увидеть это и подумать: "Да, но я бы не стал писать такой код". А вот и нет! Вы уверены, что вся ваша команда понимает этот нюанс? Мне приходилось исправлять подобные ошибки с strongSelf в командах сильных кодеров. Такие ошибки случаются. Почему бы не позволить инструментарию сделать все возможное, чтобы облегчить их поиск?

Я просто буду использовать self? везде

Предположим, что я напугал вас с strongSelf. Рассмотрим следующий код:

class Parent {
    // ...
    let points = 1
    let firstChild = Child()
    func awardPoints(completion: @escaping (Int) -> Void) {
        firstChild.playLater { [weak self] in
            var totalPoints = 0
            totalPoints += self?.points ?? 0 // 1️⃣
            totalPoints += self?.points ?? 0 // 2️⃣
            completion(totalPoints)
        }
    }
}

Это работает, и совершенно безопасно, но может привести к странному поведению, которого вы, возможно, не ожидаете.

Пока self слабый, он не увеличивает счетчик удержания self. Это означает, что в любой момент объект, удерживающий self, может его освободить. Поскольку это многопоточная среда, то такое может произойти в середине вашего замыкания. Другими словами, любая ссылка на self? может стать первой, которая вернет nil до окончания работы вашего метода.

Вполне возможно, что завершение может быть:

  • Вызвано со значением 0;

  • Вызвано со значением 2;

  • Вызвано со значением 1.

Подождите... Что? Итоговое значение 1 похоже на ошибку. Это может произойти, когда self становится nil после выполнения строки  1️⃣, но до выполнения строки 2️⃣. Фактически, каждое обращение к self? создает ветвь в вашем коде, до которой self существует, а после нее он становится nil.

Это намного сложнее, по сравнению с тем, что мы обычно хотим создать. Как правило, мы просто пытаемся избежать цикла удержания и хотим, чтобы замыкание исполнялось на протяжении всего процесса. Хорошая новость: вы можете обеспечить "все или ничего" в вашем замыкании, сделав апгрейд self до сильной ссылки в верхней части вашего замыкания.

class Parent {
    // ...
    let points = 1
    let firstChild = Child()
    func awardPoints(completion: @escaping (Int) -> Void) {
        firstChild.playLater { [weak self] in
            guard let self = self else {
                completion(0)
                return
            }
            var totalPoints = 0
            totalPoints += self.points
            totalPoints += self.points
            completion(totalPoints)
        }
    }
}

Теперь есть только одна ветвь, где self может быть nil, и она убрана с пути раньше. Либо self уже стал nil до выполнения этого замыкания, либо он гарантированно будет существовать в течение всего времени его выполнения. Завершение будет вызвано либо с 2 или 0, но оно никогда не может быть вызвано с 1.

Подведем итоги

Как я уже сказал, это нелегко аргументировать. Если вы не хотите долго рассуждать, следуйте трем правилам:

  1. Используйте сильное self только для non-@escaping замыканий (в идеале, оставьте его и доверьтесь компилятору).

  2. Используйте weak self, если вы не уверены.

  3. Выполните апгрейд self до сильно удерживаемого self в верхней части вашего замыкания.

Эти правила могут быть слишком многословными, но они приводят к самому безопасному коду, который легче всего осмыслить. И их довольно легко удержать запомнить.


Сегодня в 20:00 в OTUS состоится открытое занятие «Пишем простой фоторедактор для iOS». На нем рассмотрим, как можно создать несложный фоторедактор для iOS для простой обработки изображений, поработаем с фильтрами и цветовыми тонами. Интерфейс приложения будем создавать с использованием UIKit Autolayout. Регистрация для всех желающих — по ссылке.

Комментарии (2)


  1. valery_garaev
    19.05.2022 09:41
    +1

    не раскрыта тема необходимости [weak self] в UIView.animate и DispatchQueue.async????


  1. Gargo
    19.05.2022 18:10

    приходилось ли вам иметь дело с прямо противоположной ситуацией, когда со слабыми ссылками класс уничтожается сам и уничтожает completion block до того, как тот исполнится?

    Конкретно в моем случае класс уничтожался в фоновом потоке и не дожидался completion, который нужно было исполнить в главном потоке. Ситуация осложнялась тем, что не я создаю этот класс (проект-framework), а следовательно и не могу сохранить сильную ссылку на класс/блок где-то еще.