Программирование SwiftARC и памятьРазработчик приложений на Swift

Практическая ситуация: после обнуления внешней ссылки проверьте, будет ли вызван deinit, и объясните механи...

Практическая ситуация: после обнуления внешней ссылки проверьте, будет ли вызван deinit, и объясните механизм.

final class Worker {
    lazy var action: () -> Void = {
        print(self)
    }

    deinit {
        print("deinit")
    }
}

var worker: Worker? = Worker()
worker?.action()
worker = nil
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

deinit не будет вызван: замыкание, сохранённое в action, по умолчанию захватывает self сильной ссылкой. Возникает цикл: Worker владеет замыканием, а замыкание владеет Worker.

Исторический контекст

ARC появился в Swift как автоматический механизм управления временем жизни объектов вместо ручного подсчёта ссылок. Компилятор добавляет операции удержания и освобождения ссылок, поэтому объект уничтожается, когда число сильных ссылок становится равным нулю.

Обычный подсчёт ссылок не может самостоятельно разорвать цикл: каждая сторона цикла всё ещё считается используемой. Поэтому в Swift существуют weak и unowned ссылки, не увеличивающие сильный счётчик ссылок.

Постановка проблемы

После присваивания worker = nil внешняя сильная ссылка исчезает. Однако объект всё ещё владеет свойством action, а замыкание в этом свойстве удерживает объект через self.

В результате счётчик сильных ссылок не становится нулевым, deinit не выполняется, а объект и замыкание остаются в памяти до конца времени жизни цикла. Для объектов, живущих недолго или создаваемых часто, это приводит к утечкам памяти.

Подробное решение

Замыкания захватывают ссылки на используемые экземпляры сильно по умолчанию. В примере цепочка выглядит так: Worker → action → closure → Worker.

Обычно цикл разрывают захватом self как weak:

final class Worker { lazy var action: () -> Void = { [weak self] in guard let self = self else { return } print(self) } deinit { print("deinit") } } var worker: Worker? = Worker() worker?.action() worker = nil

weak не увеличивает счётчик сильных ссылок и автоматически становится nil, когда объект уничтожен. Поэтому после worker = nil цикл отсутствует, и deinit вызывается.

Недостаток weakself внутри замыкания становится optional. Код обязан корректно обработать ситуацию, когда объект уже уничтожен. Нельзя без проверки принудительно разыменовывать такую ссылку, если её обнуление является штатным сценарием.

Unowned также не удерживает объект, но не обнуляется. Обращение к unowned self после уничтожения объекта приводит к аварийному завершению. Его допустимо использовать только при доказанном условии, что замыкание не переживёт self; иначе безопаснее weak.

Иногда цикл можно разорвать не захватом weak, а явным освобождением замыкания, например присваиванием action = nil. Этот вариант подходит, если жизненный цикл callback известен и его можно надёжно завершить, но он требует дисциплины со стороны владельца.

Ситуация из практики

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

Рассматривались варианты:

  • Сильный захват self — прост и безопасен во время вызова, но создаёт цикл при хранении замыкания самим объектом.
  • unowned self — не создаёт цикла и не требует optional, но аварийно завершает программу при ошибочном порядке уничтожения.
  • weak self — безопасно переживает уничтожение объекта, но требует обработки nil; callback может стать бездействующим.
  • Явное обнуление callback — разрывает цикл детерминированно, но легко забыть выполнить очистку во всех путях завершения.

Выбрали [weak self], потому что callback мог быть вызван асинхронно после закрытия экрана. При уничтожении ViewModel callback становился безопасным no-op, deinit начал вызываться, а утечка исчезла.

Что кандидаты часто упускают

  1. Всегда ли [weak self] устраняет цикл?

Нет. Он устраняет только сильный захват self именно этим замыканием. Если замыкание сильно захватывает другой объект, а тот через цепочку ссылок снова владеет исходным объектом, цикл может сохраниться косвенно. Нужно анализировать весь граф владения, а не только наличие capture list.

  1. Почему guard let self = self внутри замыкания не создаёт постоянный цикл?

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

  1. Когда сильный захват self в замыкании является правильным?

Он корректен, если замыкание не хранится объектом, который оно захватывает, либо если сильная ссылка намеренно продлевает жизнь объекта до завершения операции. Например, одноразовая асинхронная задача может временно удерживать объект, чтобы тот существовал до callback. Важно проверить, что операция действительно завершится; бесконечный таймер или никогда не вызываемый callback снова создадут утечку.