Как работает ARC

ARC — это не сборщик мусора, а расстановка retain/release компилятором: объект умирает ровно в ту секунду, когда на него больше никто не смотрит.

ARC (Automatic Reference Counting) — механизм управления памятью, при котором компилятор на этапе сборки вставляет в код вызовы увеличения и уменьшения счётчика ссылок. Когда счётчик доходит до нуля, объект немедленно разрушается.

«Что такое ARC и чем он отличается от garbage collector?»

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

Интервьюер хочет понять, видите ли вы разницу между compile-time и runtime подходом. Кандидат, который отвечает «ARC — это автоматическая сборка мусора в Swift», расписывается в том, что не понимает, почему в iOS бывают retain cycle и почему нет пауз на сборку. Дальше из этого ответа обычно вырастает разговор про утечки, замыкания и производительность.

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

Garbage collector — это подсистема рантайма. Она периодически просыпается, обходит граф объектов от корней и освобождает недостижимое. Отсюда две особенности: циклы собираются сами собой (они недостижимы от корней), но момент освобождения непредсказуем, а обход стоит процессорного времени и даёт паузы.

ARC работает иначе. Никакого обхода графа нет. Компилятор анализирует код и сам расставляет retain и release там, где ссылка появляется и исчезает. В рантайме остаётся только атомарный инкремент и декремент счётчика.

final class Avatar {
    let id: Int

    init(id: Int) {
        self.id = id
        print("init Avatar \(id)")
    }

    deinit {
        print("deinit Avatar \(id)")
    }
}

var a: Avatar? = Avatar(id: 1)   // счётчик = 1
var b = a                        // retain  -> счётчик = 2
a = nil                          // release -> счётчик = 1, объект жив
print("между присваиваниями")
b = nil                          // release -> счётчик = 0, вызывается deinit
print("после")

Вывод:

init Avatar 1
между присваиваниями
deinit Avatar 1
после

Обратите внимание на порядок строк: deinit случился строго между двумя print, а не «когда-нибудь потом». Это и есть детерминированное освобождение — главный практический плюс ARC. Файл закроется, сокет отключится, наблюдатель отпишется ровно в тот момент, когда умерла последняя ссылка. Никакого обхода графа за этим не стоит: компилятор просто вставил в бинарник вызовы swift_retain и swift_release в нужных точках.

Плата за предсказуемость — циклы ARC не собирает. Если два объекта держат друг друга сильными ссылками, счётчик каждого никогда не станет нулём, и никто не придёт это заметить. Именно поэтому в iOS существуют weak, unowned и capture list, а в Java или C# их аналоги не нужны.

Для каких типов ARC вообще работает

Счётчик есть только у ссылочных типов: class, замыкания (это объекты на куче), actor. У struct и enum счётчика нет — они копируются по значению. Но косвенно они в ARC участвуют: если внутри структуры лежит поле-класс, копирование вызывает retain этого поля.

struct Profile {
    let name: String
    let avatar: Avatar      // ссылочное поле внутри значимого типа
}

let p1 = Profile(name: "Ann", avatar: Avatar(id: 2))
let p2 = p1                 // структура скопирована, но avatar общий: retain

«Когда именно вызывается deinit?»

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

Проверяют, понимаете ли вы, что deinit — это не «финализатор, который когда-нибудь позовут», а гарантированная точка. И умеете ли вы ей пользоваться для уборки ресурсов, не наделав при этом типичных бед.

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

deinit вызывается синхронно, на том потоке, где произошёл последний release, сразу после того как счётчик стал нулём и до освобождения памяти. Затем ARC разрушает все хранимые свойства объекта, а после — вызывает deinit суперкласса. Порядок всегда снизу вверх: наследник, потом родитель. Своего deinit нельзя вызвать руками и нельзя объявить в структуре.

final class Session {
    private var observer: NSObjectProtocol?
    private var socket: Socket?

    deinit {
        // Хорошо: синхронная детерминированная уборка
        socket?.close()
        if let observer {
            NotificationCenter.default.removeObserver(observer)
        }

        // Плохо: self уже нельзя «спасти» — сохранять его наружу нельзя
        // Плохо: async-работа с self внутри deinit — объект уже разрушается
        // Плохо: deinit не может бросать ошибки и не может быть async
    }
}

Ещё тонкость: если последняя ссылка исчезла в фоновой очереди, deinit выполнится в фоне. Трогать UIKit оттуда без перехода на главный поток — риск.

Немного истории и краёв

До 2011 года в Objective-C был MRC: retain, release и autorelease писали руками, а autoreleasepool собирал отложенные освобождения на границе цикла событий. ARC — та же модель, но расстановку взял на себя компилятор. Отсюда наследие вроде @objc и toll-free bridging между NSString и CFString.

Отдельный край — Core Foundation: функции с Create или Copy в имени отдают объект во владение вам, и в неаннотированном C-коде его придётся освобождать через CFRelease. Unmanaged тоже выводит из-под ARC — баланс retain/release снова ваша забота.

let raw = Unmanaged.passRetained(session).toOpaque()   // +1 вручную
// ... передали указатель в C-API ...
Unmanaged<Session>.fromOpaque(raw).release()          // -1 вручную, иначе утечка

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

  • Называть ARC «сборщиком мусора Swift» — и не мочь объяснить, откуда тогда берутся retain cycle.
  • Считать, что счётчик ссылок считается в рантайме «по графу»: на самом деле граф никто не обходит.
  • Думать, что у struct тоже есть счётчик ссылок.
  • Верить, что deinit вызовется «когда-нибудь позже», и не использовать его для уборки ресурсов.
  • Пытаться сделать deinit асинхронным или продлить жизнь self внутри него.
  • Забывать, что deinit выполняется на потоке последнего release, и дёргать оттуда UIKit.

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

ARC — это автоматический подсчёт ссылок. Компилятор на этапе компиляции сам расставляет retain и release, в рантайме остаётся только менять счётчик. Как только счётчик обнуляется, немедленно вызывается deinit — освобождение детерминированное, пауз на сборку нет. В отличие от garbage collector, который обходит граф объектов в рантайме, ARC ничего не обходит, поэтому не умеет разрывать циклы: за них отвечаем мы через weak и unowned. Счётчик есть только у ссылочных типов — классов, замыканий, акторов; структуры копируются, но их поля-классы retain'ятся.

Проверьте себя
1. Чем ARC принципиально отличается от garbage collector?
AARC обходит граф объектов в рантайме, но делает это чаще и быстрее
BКомпилятор расставляет retain/release при сборке, поэтому обхода графа нет и освобождение детерминированное
CARC освобождает память только при нехватке памяти в системе
DARC работает и со ссылочными, и со значимыми типами одинаково
2. Когда вызывается deinit?
AВ конце текущего прогона RunLoop, вместе со сливом autoreleasepool
BПри следующей сборке мусора, момент не определён
CСинхронно и немедленно, как только счётчик сильных ссылок стал нулём
DПри выходе из области видимости, даже если на объект остались другие ссылки
3. Есть ли счётчик ссылок у структуры со свойством-классом?
AУ самой структуры счётчика нет, но её поле-класс retain'ится при каждом копировании структуры
BЕсть: как только внутри появляется класс, структура становится ссылочным типом
CНет, и поле-класс тоже не удерживается — оно копируется побайтово
DЕсть только у структур, помеченных final