Захват self в замыкании — обычная вещь в Swift, которая скрывает множество нюансов. Нужно ли делать его weak, чтобы избежать цикла ссылок? И является ли проблемой сделать его weak постоянно?
На прошлой неделе в iOS Dev Weekly была опубликована статья Benoit Pasquier, посвященная захвату self в замыканиях. Моя статья будет ей противоречить. И это нормально! Примите все эти советы с определенной долей скептицизма, разберитесь в компромиссах и выберите те техники, которые подходят вам лучше всего.
Итак, давайте начнем.
Три золотых правила
Дискутировать о циклах удержания сложно. Когда я обучаю людей использованию weak self (или списков захвата), чтобы избежать утечек памяти, то привожу три золотых правила:
Сильно удерживаемый self не всегда является циклом удержания.
Слабо удерживаемый self никогда не будет циклом удержания.
Делайте апгрейд уровня 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) замыканиями.
Убегающие и неубегающие замыкания
Возможно, вы написали метод, который выполнил замыкание, после чего на вас накричал компилятор:
Эта аннотация требуется компилятору, если аргумент закрытия вашего метода имеет время жизни, превышающее лайфтайм метода. Другими словами, выходит ли он за фигурные скобки вашей 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.
Подведем итоги
Как я уже сказал, это нелегко аргументировать. Если вы не хотите долго рассуждать, следуйте трем правилам:
Используйте сильное
self
только дляnon-@escaping
замыканий (в идеале, оставьте его и доверьтесь компилятору).Используйте weak
self
, если вы не уверены.Выполните апгрейд
self
до сильно удерживаемогоself
в верхней части вашего замыкания.
Эти правила могут быть слишком многословными, но они приводят к самому безопасному коду, который легче всего осмыслить. И их довольно легко удержать запомнить.
Приглашаем всех заинтересованных на открытый урок, на котором создадим простое приложение-таймер. Участие в вебинаре даст понимание, как за небольшой отрезок времени написать готовое решение и использовать его в повседневной жизни. Регистрация доступна по ссылке.