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

Управление памятью — большая тема в разработке Swift и iOS. Если есть много руководств, объясняющих, когда использовать слабое я с замыканием, вот короткая история, когда с ним все еще могут происходить утечки памяти.

Для целей этого поста предположим, что у нас есть следующий класс с двумя функциями. Каждая функция что-то выполняет и завершает выполнение выполнением замыкания.

ОБНОВЛЕНИЕ — 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 // <- strong reference to self
        print("did something")

        self.doSomethingElse {
            self.didSomethingElse = true // <- strong reference to 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] для каждого замыкания?

На самом деле, нет.

Когда у нас есть вложенные замыкания, как здесь, мы всегда должны устанавливать слабое я в первое, внешнее замыкание. Внутреннее замыкание, вложенное во внешнее, может повторно использовать одно и то же слабое «я».

func doEverything() {

    self.doSomething { [weak self] in 
        self?.didSomething = true
        print("did something")

        self?.doSomethingElse { in
            self?.didSomethingElse = true
            print("did something else")
        }
    }
}

Однако, если бы мы поступили наоборот, имея слабое «я» только во вложенном замыкании, внешнее замыкание все равно сильно захватило бы «я» и увеличило счетчик ссылок. Так что будьте осторожны, когда вы устанавливаете это.

func doEverything() {

    self.doSomething { in 
        self.didSomething = true // <- strong reference to self
        print("did something")

        self.doSomethingElse { [weak self] in 
            self?.didSomethingElse = true
            print("did something else")
        }
    }
}

Все идет нормально.

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

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 // <-- strong reference?
            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

Только с сильными ссылками на себя мы можем видеть, как счетчик увеличивается до 6. Однако, как и ожидалось, как только обе функции выполняются, экземпляр освобождается.

Теперь давайте представим слабое «я» во внешнем замыкании.

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()
}

При первом слабом я экземпляр по-прежнему освобождается, и счетчик идет только до 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, что означает, что замыкание гнезда сильно захватывает наше временное «я».

Выглядит уже подозрительно, но давайте попробуем на другом примере. Что, если вместо функций 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 strongSelf вместо guard let 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.