Привет, Хабр! Это вторая статья из цикла моих заметок. В прошлый раз мы затронули тему ARC а также такие понятия, как weak и unowned ссылки. Сегодня разберёмся, что такое ARC на самом деле, посмотрим, как он работает на уровне SIL, и заглянем немного в историю управления памятью в iOS.

Не переключайтесь, поехали!

Что такое ARC?

ARC (Automatic Reference Counting) - это метод подсчёта ссылок на объекты, который используется Apple для управления памятью в Swift. Суть метода заключается в том, что Swift отслеживает количество сильных ссылок на объект и, когда счётчик ссылок достигает нуля, объект удаляется из памяти согласно своему жизненному циклу (см. статью про память в Swift).

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

Что было до?

До появления ARC существовал так называемый MRC (Manual Reference Counting) - ручной подсчёт ссылок. В этом методе разработчик сам добавлял вызовы retain и release, чтобы управлять количеством ссылок на объект. Пример:

#import <Foundation/Foundation.h>

int main(int argc, const char * argv[]) {
    NSMutableString *str = [[NSMutableString alloc] initWithString:@"Hello"];
    NSLog(@"%lu", [str retainCount]); // 1
    
    [str retain];
    NSLog(@"%lu", [str retainCount]); // 2
    
    [str release];
    NSLog(@"%lu", [str retainCount]); // 1
    
    [str release]; // 0
    // тут вызов retainCount вызовет креш, так как объект удален
    str = nil;

    return 0;
}

Перед тем как компилировать этот файл нужно будет у main.m поставить флаг -fno-objc-arc в Build phases -> Compile sources, чтобы XCode дал возможность использования MRC

Такой код может выглядеть непривычно, но именно так работал MRC. Для сравнения, современный Swift позволяет писать то же самое гораздо лаконичнее:

import Foundation

func main() {
  print("hello")
}

Этот пример наглядно показывает, какой упрощённый и чистый код стал возможен благодаря ARC.

NSAutoreleasePool
#import <Foundation/Foundation.h>

@interface MyObject : NSObject
@end

@implementation MyObject

- (void)dealloc {
    NSLog(@"MyObject dealloc");
    [super dealloc];
}

@end

int main(int argc, const char * argv[]) {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];

    MyObject *obj = [[[MyObject alloc] init] autorelease];

    NSLog(@"%@", obj);
  
    [pool drain];

    return 0;
}

Помимо ручного retain/release также использовался NSAutoreleasePool. Его подробное описание можно найти тут, но концептуально он работал так: когда объекту отправляли сообщение autorelease, он помещался в текущий пул. При его деаллокации у всех объектов внутри него автоматически вызывалсяrelease. Таким образом объекты автоматически уничтожались без необходимости вручную вызывать у нихrelease.

Итак, мы рассмотрели, что такое ARC и как раньше работал подсчёт ссылок. Следующий шаг - понять, как именно работает ARC?

Что такое MRC?

Это ручной подсчет ссылок при помощи retain/release

Устройство ARC

На самом деле тут ничего мистического нет: ARC - это по сути тот же MRC, но автоматизированный и оптимизированный. Swift на этапе компиляции (на уровне генерации SIL) вставляет инструкции подсчёта ссылок (например, retain_value / release_value). В результате всё то же самое, что раньше делал разработчик руками (retain/release), теперь выполняется автоматически, с дополнительными оптимизациями - и код становится чище и безопаснее.

Небольшой экскурс про SIL

SIL (Swift Intermediate Language) - это промежуточное представление кода, в которое компилятор Swift переводит исходный код разработчика. На уровне SIL компилятор применяет различные оптимизации, включая автоматическое добавление инструкций подсчёта ссылок для ARC.

Для тех, кто хочет попробовать самостоятельно, можно создать файл main.swift со следующим кодом:

import Foundation

class A {
  let a = 10
}

func main() {
  var a: A? = A()
  var b: A? = a
  var c: A? = a

  b = nil
  c = nil
  a = nil
}

Затем запустить команду:

swiftc -emit-sil -Onone main.swift -o main.sil

В полученном файле main.sil будет много кода, но нас интересуют эти строки:

  retain_value %4 : $Optional<A>                  // id: %5
  retain_value %4 : $Optional<A>                  // id: %6

  ...

  release_value %14 : $Optional<A>                // id: %16
  ...
  release_value %20 : $Optional<A>                // id: %22
  ...
  release_value %26 : $Optional<A>                // id: %28

Здесь видно, что объект a получает две дополнительные ссылки, а затем они последовательно освобождаются. Кроме того, Swift иногда может опускать вызовы retain и release для оптимизации. В таких случаях используется только инструкция destroy_addr, которая сама по себе выполняет release(это можно посмотреть, убрав присвоение nil у переменных).

Тут важная ремарка. Согласно документации по SIL здесь используется retain_value вместо strong_retain, так как мы работает с Optional, а он - enum. С чистым классом компилятор бы использвал strong_retain/strong_release.

Дальше углубляться в детали SIL мы пока не будем, так как тема довольно большая и сложная, но возможно в будущем я напишу отдельную статью и о нём.

Итак, мы рассмотрели устройство ARC. Он удобен и упрощает работу с памятью, но у него есть нюансы и потенциальные проблемы, с которыми разработчики сталкиваются при реальной разработке - например, циклы сильных ссылок.

Что такое retain cycle?

Как мы уже выяснили, ARC считает ссылки. Рассмотрим следующую ситуацию: объект A держит по сильной ссылке объект B, а объект B в свою очередь держит по сильной ссылке объект A.

         +------------+                      +------------+
         |            | --------ref--------> |            |
         |     A      |                      |      B     |
         |            | <-------ref--------  |            |
         +------------+                      +------------+
class A {
    var b: B?
}

class B {
    var a: A?
}

var a: A? = A()
var b: B? = B()

a?.b = b
b?.a = a

Вопрос: как в такой ситуации должен вести себя ARC?

Проблема в том, что ни A, ни B не могут быть удалены: на A указывает B, а на B - A. Такая ситуация называется retain cycle или цикл сильных ссылок.

Когда возникает retain cycle, ARC не может автоматически освободить память, и объекты остаются в памяти на протяжении всей жизни программы. Это особенно критично для больших объектов, например, ViewController, так как такие утечки могут быстро забивать оперативную память.

Итак, что с этим делать? Здесь нам помогут слабые (weak) и бесхозные (unowned) ссылки.

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

Swift на этапе компиляции (на уровне генерации SIL) вставляет инструкции подсчёта ссылок (например, retain_value / release_value).

Weak и unowned ссылки

weak-ссылки - это ссылки, которые ARC не учитывает при подсчёте количества сильных ссылок на объект. Таким образом, цикл разрывается, и объекты могут быть удалены. Классическим примером использования слабых ссылок является паттерн делегат. Выглядит он так:

protocol Delegate {
  func foo()
}

class A {
  weak var delegate: Delegate?
}

class B: Delegate {
  func foo() {
    print("do smth")
  }
}

let a = A()
let b = B()
a.delegate = b

Здесь у нас образуется двусторонняя связь между A и B. Если сделать делегат сильной ссылкой, то объекты образуют цикл сильных ссылок и не будут удалены до завершения программы.

          +------------+      weak ref       +------------+
          |            | ------------------> |            |
          |     A      |                     |      B     |
          |            | <----conforms------ |  Delegate  |
          +------------+                     +------------+

Почему weak объявлена как var? Попробуйте заменить её на let и появится ошибка: 'weak' must be a mutable variable, because it may change at runtime. Это и есть ключевое преимущество слабых ссылок: они могут становиться nil в рантайме, если объект к моменту обращения уже был уничтожен.

Ещё один частый источник retain cycle - это замыкания. По умолчанию замыкание захватывает self по сильной ссылке, что легко может привести к циклу. Чтобы этого избежать, используют список захвата с weak или unowned.

class Interactor {
  func loadData(completion: @escaping (String) -> Void) {
      sleep(5) // что то долго грузим
      completion("Done")
  }
}

class ViewController: UIViewController {
  private let interactor: Interactor
  private let label = UILabel()

  init(interactor: Interactor) {
      self.interactor = interactor
  }

  func start() {
      interactor.loadData { [weak self] text in
        self?.label.text = text
      }
  }
}

Здесь мы захватили self как weak, чтобы при закрытии контроллера замыкание не удерживало его в памяти.

Окей, мы разобрались с weak-ссылками. Но что же тогда делают unowned-ссылки?

Они также не увеличивают количество сильных ссылок на объект. Разница в том, что unowned никогда не бывают nil. Если объект уничтожен, то при обращении к unowned произойдёт креш.

На этом моменте может показаться, что weak всегда лучше, но у unowned есть свои преимущества:

  • Нет необходимости разворачивать опционал при использовании

  • Хранят меньше метаданных

  • Не создают side table

Классический пример использования unowned - это зависимость, когда один объект живёт либо дольше другого, либо то же время что и первый. Например, клиент и его кредитка:

class Customer {
  let name: String
  var card: CreditCard?

  init(name: String) {
      self.name = name
  }
}

class CreditCard {
  let number: Int
  unowned let customer: Customer

  init(number: Int, customer: Customer) {
      self.number = number
      self.customer = customer
  }
}

Кредитная карта не существует отдельно - она всегда связана с конкретным клиентом. Поэтому срок её существования либо совпадает со сроком существования объекта Customer, либо короче него.

Итак, с ссылками вроде понятно, но тут промелькнуло понятие боковой таблицы(side table) и также мы поняли, что unowned ссылки не создают side таблицу, но что тогда ее создает?

В чем отличие weak от unowned ссылок?

weak ссылки возвращают nil, если объект, лежащий по ссылке, был уничтожен.

unowned ссылки вызовут креш, если объект будет равен nil

В чем преимущество unowned ссылок?
  • При использовании unowned ссылок нет необходимости разворачивать optional

  • Хранят меньше метаданных

  • Не создают side table

Weak ссылки и side table

Side table - это вспомогательная структура, которая хранится отдельно от объекта и содержит слабые ссылки на него. Выглядит она так:

class HeapObjectSideTableEntry { 
    std::atomic<object> object; 
    SideTableRefCounts refCounts;
};

Зачем она нужна? В первую очередь - для экономии памяти. Не каждый объект имеет боковую таблицу: она создаётся только тогда, когда объект получает хотя бы одну слабую ссылку. Благодаря этому нет накладных расходов для объектов, у которых не используются слабые ссылки, так как нам не нужно держать их счетчик в объекте с момента его создания.

Зачем нужна side table?

Для экономии памяти

Выводы

Итак, в этой статье мы проследили путь от ручного управления памятью (MRC) до современного ARC, разобрались, почему Apple выбрала именно автоматический подсчёт ссылок и чем это упростило работу с памятью. Мы заглянули под капот компилятора и увидели, как на уровне SIL выглядят инструкции retain и release, и поняли, что на самом деле ARC - это не магия, а та же механика MRC, только автоматизированная.

Мы также рассмотрели основные проблемы, с которыми может столкнуться разработчик: retain cycle и утечки памяти. На примере слабых (weak) и бесхозных (unowned) ссылок разобрались, как правильно разрывать циклы и когда использовать каждый из подходов. Параллельно познакомились с понятием side table и узнали, что оно появляется только при работе со слабыми ссылками.

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

Ссылки

Контакты

Комментарии (5)


  1. NeoCode
    04.09.2025 17:28

    Понятно что автоматическое удобнее чем ручное. Интереснее было бы сравнить ARC с другими алгоритмами "сборки мусора", применяемыми в других языках.


    1. Siemargl
      04.09.2025 17:28

      Самый дорогой механизм из всех, наверное


  1. Bardakan
    04.09.2025 17:28

    #import <Foundation/Foundation.h>
    
    int main(int argc, const char * argv[]) {
        // тут первая ссылка на объект
        NSString *str = [[NSString alloc] initWithString:@"Hello"]; 
    
        [str retain];   // увеличиваем счётчик ссылок
    
        NSLog(@"%@", str);
    
        [str release];  // уменьшаем счётчик
    
        [str release];  // освобождаем объект
    
        return 0;
    
    }

    опять будете рассказывать, что статья не сгенерирована нейросетями? Добавьте сюда лог `retainCount` и посмотрите, что он выведет на самом деле.