Weak self, история про управление памятью и замыкания в Swift

Перевод статьи Benoit Pasquierhttps://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.

 

Один ответ к «Weak self, история про управление памятью и замыкания в Swift»

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *