Weak self, история про управление памятью и замыкания в Swift
Перевод статьи Benoit Pasquier – https://benoitpasquier.com/weak-self-story-memory-management-closure-swift/
Управление памятью — большая тема в разработке на Swift под iOS. Есть много руководств, объясняющих, когда использовать weak self с замыканием, вот короткая история, когда с ним все еще могут происходить утечки памяти.
Предположим, что у нас есть следующий класс с двумя функциями. Каждая функция что-то выполняет и завершает выполнение выполнением замыкания.
Обновление от 9 апреля 2022: Я пересмотрел примеры, чтобы выделить случаи увеличения счетчика ссылок и случаев, когда это может привести к утечке памяти.
class MyClass { func doSomething(_ completion: (() -> Void)?) { // do something completion?() } func doSomethingElse(_ completion: (() -> Void)?) { // do something else completion?() } }
Теперь возникает новое требование: нам нужна новая функция doEverything, которая будет вызывать как doSomething, так и doSomethingElse в указанном порядке. Попутно мы меняем состояние класса, чтобы следить за прогрессом.
var didSomething: Bool = false var didSomethingElse: Bool = false func doEverything() { self.doSomething { self.didSomething = true // <- сильная ссылка self print("did something") self.doSomethingElse { self.didSomethingElse = true // <- сильная ссылка self print("did something else") } } }
С самого начала мы видим, что self сильно захвачен в первом и втором замыканиях: замыкания сохраняют сильную ссылку на self, которая внутренне увеличивает счетчик ссылок и может предотвратить освобождение экземпляра во время выполнения doSomething.
Это означает, что если эти функции были асинхронными, и мы хотим освободить экземпляр до того, как он завершит выполнение, системе все равно придется ждать, чтобы завершить его, прежде чем освободить память.
Конечно, мы это знаем, и мы настроили weak self для замыканий:
func doEverything() { self.doSomething { [weak self] in self?.didSomething = true print("did something") self?.doSomethingElse { [weak self] in self?.didSomethingElse = true print("did something else") } } }
Подождите, нам действительно нужны оба [weak self] для каждого замыкания?
На самом деле, нет.
Когда у нас есть вложенные замыкания, как здесь, мы всегда должны присваивать weak self первое, внешнее замыкание. Внутреннее замыкание, вложенное во внешнее, может повторно использовать один и тот же weak self.
func doEverything() { self.doSomething { [weak self] in self?.didSomething = true print("did something") self?.doSomethingElse { in self?.didSomethingElse = true print("did something else") } } }
Однако, если бы мы поступили наоборот, имея weak self только во вложенном замыкании, внешнее замыкание все равно сильно захватило бы self и увеличило счетчик ссылок. Так что будьте осторожны, когда вы устанавливаете это.
func doEverything() { self.doSomething { in self.didSomething = true // <- сильная ссылка self print("did something") self.doSomethingElse { [weak self] in self?.didSomethingElse = true print("did something else") } } }
Все идет нормально.
Так как мы хотим изменить другие переменные по пути, давайте очистим код с помощью guard let, чтобы убедиться, что экземпляр все еще доступен.
func doEverything() { self.doSomething { [weak self] in guard let self = self else { return } self.didSomething = true print("did something") self.doSomethingElse { in self.didSomethingElse = true // <-- сильная ссылка? print("did something else") } } }
Но теперь возникает вопрос: поскольку у нас есть сильная ссылка self во внешнем замыкании, захватывает ли его сильно внутреннее замыкание? Как мы можем это проверить?
Это те вопросы, в которые стоит углубиться, и Xcode Playground идеально подходит для этого. Я выведу несколько логов, чтобы отслеживать шаги, а также регистрировать счетчик ссылок.
Давайте не будем усложнять первый пример, чтобы мы могли видеть, как счетчик ссылок увеличивается по пути.
class MyClass { func doSomething(_ completion: (() -> Void)?) { // do something completion?() } func doSomethingElse(_ completion: (() -> Void)?) { // do something else completion?() } var didSomething: Bool = false var didSomethingElse: Bool = false deinit { print("Deinit") } func printCounter() { print(CFGetRetainCount(self)) } func doEverything() { print("start") printCounter() self.doSomething { self.didSomething = true print("did something") self.printCounter() self.doSomethingElse { self.didSomethingElse = true print("did something else") self.printCounter() } } printCounter() } } do { let model = MyClass() model.doEverything() }
Вот результат
# output start 2 did something 4 did something else 6 2 Deinit
Только с сильными ссылками self мы можем видеть, как счетчик увеличивается до 6. Однако, как и ожидалось, как только обе функции выполняются, экземпляр освобождается.
Теперь давайте поставим weak self во внешнем замыкании.
func doEverything() { print("start") printCounter() self.doSomething { [weak self] in self?.didSomething = true print("did something") self?.printCounter() self?.doSomethingElse { self?.didSomethingElse = true print("did something else") self?.printCounter() } } printCounter() }
При первом weak self экземпляр по-прежнему освобождается, и счетчик идет только до 4.
# output start 2 did something 3 did something else 4 2 Deinit
Так что же происходит с guard let self?
func doEverything() { print("start") printCounter() self.doSomething { [weak self] in guard let self = self else { return } self.didSomething = true print("did something") self.printCounter() self.doSomethingElse { self.didSomethingElse = true print("did something else") self.printCounter() } } printCounter() }
Вот результат
# output start 2 did something 3 did something else 5 2 Deinit
Если экземпляр успешно деинициализирован, мы можем видеть, что счетчик на самом деле увеличивается с 4 до 5, когда мы выполняем doSomethingElse, что означает, что замыкание сильно захватывает наше временное self.
Выглядит уже подозрительно, но давайте попробуем на другом примере. Что, если вместо функций doSomething и doSomethingElse мы будем использовать замыкания, которые будут свойствами класса. Давайте адаптируем код для аналогичного исполнения.
class MyClass { var doSomething: (() -> Void)? var doSomethingElse: (() -> Void)? var didSomething: Bool = false var didSomethingElse: Bool = false deinit { print("Deinit") } func printCounter() { print(CFGetRetainCount(self)) } func doEverything() { print("start") printCounter() doSomething = { [weak self] in guard let self = self else { return } self.didSomething = true print("did something") self.printCounter() self.doSomethingElse = { self.didSomethingElse = true print("did something else") self.printCounter() } self.doSomethingElse?() } doSomething?() printCounter() } } do { let model = MyClass() model.doEverything() }
Вот результат
# output start 2 did something 3 did something else 5 3
На этот раз класс даже не освобождается 🤯. Чтобы исправить это, мы должны сохранить слабый экземпляр.
func doEverything() { print("start") printCounter() doSomething = { [weak self] in self?.didSomething = true print("did something") self?.printCounter() self?.doSomethingElse = { self?.didSomethingElse = true print("did something else") self?.printCounter() } self?.doSomethingElse?() } doSomething?() printCounter() }
# output start 2 did something 3 did something else 3 2 Deinit
Ура, на этот раз экземпляр правильно деаллоцирован. Подтверждено, что внутреннее замыкание создало сильную ссылку на guard let self.
Итак, что это значит для моего кода?
Когда мы сталкиваемся с замыканием, мы склонны писать weak self, за которым следует guard let, чтобы быстро обойти, не слишком задумываясь о выполнении дальше. Здесь нам все еще нужно быть осторожными. Такого рода утечки памяти легко пропустить, поэтому вот несколько выводов:
Во-первых, что касается формата, я лично использую в замыкании guard let strongSelf вместо guard let self. Причина в том, что во время просмотра кода может быть сложно понять, какой self мы имеем в виду дальше в коде.
Во-вторых, если есть вложенное замыкание, я бы предпочел сохранить ссылку на weak (и опциональный) self? и никогда не указывать strongSelf, поэтому у меня есть страховка, чтобы избежать любых сильных ссылок на него.
func doEverything() { doSomething = { [weak self] in guard let strongSelf = self else { return } strongSelf.didSomething = true print("did something") strongSelf.doSomethingElse = { self?.didSomethingElse = true print("did something else") } strongSelf.doSomethingElse?() } doSomething?() }
И последнее, но не менее важное: если у нас слишком много замыканий, лучше всего вынести их как отдельную функцию или использовать более новый API, чтобы избежать этих ошибок. Я думаю о функциональном реактивном программировании, таком как RxSwift или Combine, но также и об Async.
Конечно, представленный сегодня код может быть немного надуманным и может не отражать ежедневное использование вами замыканий, но, на мой взгляд, все же важно помнить об управлении памятью и ссылках, которые мы делаем для наших экземпляров. Кроме того, этот вопрос попал прямо в середину экспертной оценки, поэтому мы никогда не были слишком осторожны 😉
Надеюсь, вам понравился этот пост, удачного кодирования!
Вопрос? Обратная связь? Не стесняйтесь, пишите мне в Twitter.
Полезно, спасибо за труды