Циклы сильных ссылок, захват self и поиск утечек

Retain cycle — это когда объекты держат друг друга за руки и вместе идут ко дну: счётчик ни у одного не дойдёт до нуля.

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

«Что такое retain cycle и как его разорвать?»

Что на самом деле проверяют

Может ли кандидат нарисовать цикл на доске и назвать место, где ставится weak. Дальше почти всегда идёт продолжение про замыкания — именно там циклы встречаются в 90% реальных случаев.

Развёрнутый ответ

Простейший случай — два класса, ссылающиеся друг на друга.

final class Chat {
    var lastMessage: Message?
    deinit { print("deinit Chat") }
}

final class Message {
    var chat: Chat?          // сильная ссылка обратно -> цикл
    deinit { print("deinit Message") }
}

var chat: Chat? = Chat()
var message: Message? = Message()
chat?.lastMessage = message
message?.chat = chat

chat = nil
message = nil
print("обнулили обе переменные")

Вывод:

обнулили обе переменные

Ни одного deinit — оба объекта живы и недостижимы. Лечение: одна ссылка в кольце должна перестать быть сильной. Здесь это weak var chat: Chat? у Message: сообщение принадлежит чату, а не наоборот. Правило — «ребёнок ссылается на родителя слабо».

Замыкания: самый частый цикл

Замыкание — это объект на куче, и оно сильно захватывает всё, что использует. Если объект хранит замыкание, а замыкание обращается к self, цикл замкнулся.

final class FeedViewModel {
    private var onUpdate: (() -> Void)?
    private var items: [String] = []

    func bind() {
        // ЦИКЛ: self -> onUpdate -> self
        onUpdate = {
            print(self.items.count)
        }
    }

    func bindFixed() {
        onUpdate = { [weak self] in
            guard let self else { return }
            print(self.items.count)
        }
    }

    deinit { print("deinit FeedViewModel") }
}

[weak self] — это capture list, он задаёт способ захвата. guard let self else { return } поднимает сильную ссылку на время выполнения блока, чтобы объект не умер посередине. [unowned self] цикл тоже разрывает, но при выполнении блока после смерти объекта даёт крэш.

Те же грабли раскиданы по фреймворкам: подписка Combine или RxSwift, хранимая в self; Timer.scheduledTimer, который RunLoop держит сильно; наблюдатель NotificationCenter с блоком.

final class Ticker {
    private var timer: Timer?
    private var seconds = 0

    func start() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.seconds += 1
        }
    }

    deinit {
        timer?.invalidate()   // без invalidate RunLoop держит таймер вечно
        print("deinit Ticker")
    }
}

«Всегда ли нужен [weak self] в замыкании?»

Что на самом деле проверяют

Ловят cargo cult. Многие пишут [weak self] вообще везде, потому что «так безопаснее», и это признак того, что механику человек не понимает.

Развёрнутый ответ

Цикл возможен, только если замыкание переживает вызов: оно @escaping и где-то сохранено. Non-escaping замыкание отрабатывает синхронно и умирает на выходе из функции — держать self ему негде.

final class Cart {
    var prices: [Int] = [100, 200, 300]
    var total = 0

    func recalc() {
        // non-escaping: map и filter отработают синхронно, weak self не нужен
        total = prices.filter { $0 > 100 }.map { $0 * 2 }.reduce(0, +)

        // non-escaping: UIView.animate выполняется и отпускает замыкание
        UIView.animate(withDuration: 0.2) {
            self.updateLabel()
        }

        // escaping и НЕ сохраняется в self: цикла нет, но self проживёт до вызова блока
        DispatchQueue.main.async { [weak self] in
            self?.updateLabel()
        }
    }

    func updateLabel() { print("total = \(total)") }
}

Отдельно про DispatchQueue.main.async: цикла тут нет — очередь держит блок, блок держит self, но очередь не принадлежит self. Сильный захват лишь отложит освобождение максимум до выполнения блока, и иногда это даже нужно. [weak self] здесь ставят не от утечки, а чтобы не делать работу для закрытого экрана.

«Как вы искали утечку памяти?»

Что на самом деле проверяют

Работали ли вы с инструментами руками или только читали про них.

Развёрнутый ответ

  • deinit с print — самый дешёвый способ. Открыли экран, закрыли, в консоли нет строки — объект жив. Ноль настройки, ловит большинство циклов.
  • Memory Graph Debugger в Xcode: пауза, снимок графа, слева список экземпляров. Ищем «лишние» копии контроллера и смотрим, кто держит: у стрелок подписано имя свойства или closure context.
  • Instruments: шаблон Leaks для недостижимых блоков и Allocations с Mark Generation — открыл экран, закрыл, поставил метку, смотришь, что осталось между поколениями.

И важное различие для финала ответа: утечка и рост памяти — не одно и то же. Утечка — объект зациклен или недостижим и не будет освобождён никогда. Рост может быть законным: кэш изображений, буфер, коллекция, которую вы сами набиваете. Leaks покажет только первое.

Типичные ошибки кандидатов

  • Ставить [weak self] в map, filter, sort и UIView.animate — там замыкание non-escaping, цикла быть не может.
  • Писать [unowned self] в сетевом колбэке: экран закрыли раньше ответа — крэш.
  • Забывать invalidate() у Timer и считать, что [weak self] его убьёт.
  • Путать утечку с ростом памяти и называть утечкой любой график, идущий вверх.
  • Считать, что [weak self] в DispatchQueue.main.async спасает от цикла — цикла там и не было.
  • Не уметь назвать ни одного инструмента, кроме «смотрел в Xcode на график памяти».

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

Retain cycle — это когда объекты держат друг друга сильными ссылками и счётчик ни у одного не обнуляется. ARC граф не обходит, поэтому цикл разрываем мы: одна ссылка в кольце становится weak или unowned. Чаще всего цикл получается через замыкание: self хранит блок, блок захватывает self — лечится capture list [weak self] и guard let self. В non-escaping замыканиях вроде map или UIView.animate weak self не нужен вообще, это лишний ритуал. Ищу утечки так: сначала print в deinit при открытии и закрытии экрана, если не видно — Memory Graph Debugger, кто держит объект, и Instruments Leaks с Allocations. И отделяю утечку от честного роста памяти вроде кэша.

Проверьте себя
1. В каком из вариантов [weak self] действительно не нужен?
AВ замыкании, сохранённом в свойство самого объекта
BВ блоке Timer.scheduledTimer с repeats: true
CВ замыкании, переданном в array.map внутри метода этого объекта
DВ колбэке сетевого запроса, который хранится в менеджере-синглтоне
2. Что делает [unowned self] в колбэке сетевого запроса опасным?
AОн не разрывает цикл, а только маскирует его
BОтвет может прийти после закрытия экрана, и обращение к мёртвому self уронит приложение
CОн заставляет замыкание удерживать self сильнее обычного
DОн запрещён компилятором в escaping-замыканиях
3. Чем утечка памяти отличается от роста потребления памяти?
AЭто одно и то же, разница только в терминологии Instruments
BУтечка видна в Allocations, а рост памяти — только в Leaks
CУтечкой считается любое превышение 100 МБ в приложении
DУтечка — объект не будет освобождён никогда, а рост может быть законным: кэш, буфер, накопленная коллекция