Swift: ARC и управление памятью

Автор оригинала: Maxime Defauw, Mark Struzinski
  • Перевод
  • Tutorial
Будучи современным языком высокого уровня, Swift в основном берёт на себя управление памятью в ваших приложениях, занимаясь выделением и освобождением памяти. Это происходит благодаря механизму, который называется Automatic Reference Counting, или сокращенно ARC. В этом руководстве вы разберётесь, как работает ARC и как правильно управлять памятью в Swift. Понимая этот механизм, вы сможете влиять на время жизни объектов, размещенных в куче (heap).

В этом руководстве вы прокачаете свои знания Swift и ARC, изучив следующее:

  • как работает ARC
  • что такое циклы ссылок (reference cycles) и как их правильно устранять
  • как создать пример цикла ссылок
  • как находить циклы ссылок при помощи визуальных средств, предлагаемых Xcode
  • как обращаться с ссылочными типами и типами-значениями

Начинаем


Загрузите исходные материалы. Откройте проект в папке Cycles/Starter. В первой части нашего руководства, разбираясь в ключевых понятиях, мы будем заниматься исключительно файлом MainViewController.swift.

Добавьте этот класс внизу MainViewController.swift:

class User {
  let name: String
  
  init(name: String) {
    self.name = name
    print("User \(name) was initialized")
  }

  deinit {
    print("Deallocating user named: \(name)")
  }
}

Здесь определяется класс User, который при помощи операторов print сигнализирует нам об инициализации и освобождении экземпляра класса.

Теперь создадим экземпляр класса User вверху MainViewController.

Разместите этот код перед методом viewDidLoad():

let user = User(name: "John")

Запустите приложение. Сделайте консоль Xcode видимой при помощи Command-Shift-Y, чтобы видеть вывод операторов print.

Обратите внимание на то, что на консоли появилась надпись User John was initialized, но оператор print внутри deinit не был исполнен. Значит, этот объект не был освобождён, так как он не вышел из области видимости (scope).

Другими словами, пока view controller, содержащий этот объект, не выйдет из области видимости, объект никогда не будет освобождён.

Он в области видимости?


Завернув экземпляр класса User в метод, мы разрешим выйти ему из области видимости, тем самым позволив ARC освободить его.

Создадим метод runScenario() внутри класса MainViewController и переместим инициализацию экземпляра класса User внутри него.

func runScenario() {
  let user = User(name: "John")
}    

runScenario() определяет области видимости экземпляра User. На выходе из этой зоны user должен быть высвобожден.

Теперь вызовем runScenario() добавив это в конце viewDidLoad():

runScenario()

Запустите приложение. Вывод в консоли теперь выглядит так:

User John was initialized
Deallocating user named: John

Это означает, что вы высвободили объект, покинувший области видимости.

Время жизни объекта



Существование объекта делится на пять этапов:

  • выделение памяти: из стека или из кучи
  • инициализация: выполняется код внутри init
  • использование
  • деинициализация: выполняется код внутри deinit
  • высвобождение памяти: выделенная память возвращается в стек или кучу

Не существует прямого способа чтобы отследить этапы выделения и освобождения памяти, но можно использовать код внутри init и deinit.

Счётчик ссылок (reference counts), также известный как 'количество использований' (usage counts), определяет, когда объект больше не нужен. Этот счётчик показывает число тех, кто «пользуется» этим объектом. Объект становится ненужным, когда счётчик использований равен нулю. Затем объект деинициализируется и высвобождается.



При инициализации объекта User его счетчик ссылок равен 1, так как константа user ссылается на этот объект.

В конце runScenario(), user выходит из области видимости и счётчик ссылок уменьшается до 0. В результат user деинициализируется и затем высвобождается.

Циклы ссылок (Reference Cycles)


В большинстве случаев ARC работает так, как надо. Разработчику обычно не нужно беспокоиться об утечках памяти, когда неиспользуемые объекты остаются неосвобожденными на неопределённое время.

Но не всегда! Возможны утечки памяти.

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



Это цикл сильных ссылок (strong reference cycle). Такая ситуация сбивает с толку ARC и не позволяет ему очистить память.

Как видите, счетчик ссылок в конце не равен 0 и, хотя никакие объекты уже не нужны, object1 и object2 не будут освобождены.

Проверим наши ссылки


Чтобы проверить все это в деле, добавьте этот код после класса User в MainViewController.swift:

class Phone {
  let model: String
  var owner: User?
  
  init(model: String) {
    self.model = model
    print("Phone \(model) was initialized")
  }

  deinit {
    print("Deallocating phone named: \(model)")
  }
}    

Этот код добавляет новый класс Phone с двумя свойствами, одно для модели и другое для владельца, а также методы init и deinit. Свойство владельца опциональное, так как у телефона может и не быть владельца.

Теперь добавьте эту строчку в runScenario():

let iPhone = Phone(model: "iPhone Xs")

Это создаст экземпляр класса Phone.

Удерживаем мобилу


Теперь добавьте этот код в класс User, сразу после свойства name:

private(set) var phones: [Phone] = []

func add(phone: Phone) {
  phones.append(phone)
  phone.owner = self
}

Добавляем массив телефонов, которыми владеет user. Сеттер помечен как private, так что нужно использовать add(phone:).

Запустите приложение. Как видите, экземпляры классов Phone и User objects высвобождаются, как надо

User John was initialized
Phone iPhone XS was initialized
Deallocating phone named: iPhone Xs
Deallocating user named: John

Теперь добавим это в конце runScenario():
user.add(phone: iPhone)


Здесь мы добавляем наш айфончик в список телефонов, которыми владеет user, а также устанавливаем свойство телефона owner в 'user'.

Еще раз запустите приложение. Вы увидите, что объекты user и iPhone не высвобождаются. Цикл сильных ссылок между ними не позволяет ARC высвободить их.



Ссылки Weak


Чтобы разорвать цикл сильных ссылок, вы можете обозначить отношение между объектами как слабое (weak).

По умолчанию все ссылки являются сильными и присваивание приводит к увеличению счётчика ссылок. При использовании слабых ссылок (weak references) счётчик ссылок не увеличивается.

Другими словами, слабые ссылки не влияют на управление жизнью объекта. Слабые ссылки всегда объявлены как optional. Таким образом, когда счётчик ссылок станет равным 0, ссылка может быть установлена в nil.



На этой иллюстрации штриховые линии обозначают слабые ссылки. Обратите внимание, что счётчик ссылок object1 равен 1, так как на него ссылается variable1. Счётчик ссылок object2 равен 2, так как на него ссылается variable2 и object1.

object2 также ссылается на object1, но СЛАБО, что означает, что это не влияет на счетчик ссылок на object1.

Когда variable1 и variable2 освобождаются, у object1 счётчик ссылок становится равным 0, что высвобождает его. Это, в свою очередь, освобождает сильную ссылку на object2, что приводит уже к его высвобождению.

В классе Phone измените объявление свойства owner следующим образом:

weak var owner: User?

Объявлением ссылки на свойство owner как 'weak' мы разрываем цикл сильных ссылок между классами User и Phone.



Запустите приложение. Теперь user и phone корректно высвобождаются.

Ссылки Unowned


Существует также другой модификатор ссылки, который не приводит к увеличению счётчика ссылок: unowned.

В чём же отличие unowned от weak? Ссылка weak всегда optional и автоматически становится nil, когда ссылаемый объект высвобождается.

Вот почему мы должны объявлять weak свойства как переменную optional типа: это свойство должно измениться.

Ссылки Unowned, напротив, никогда не optional. Если вы попробуете получить доступ к unowned свойству, которое ссылается на освобождённый объект, вы получите ошибку, похожую на принудительное разворачивание содержащую nil переменной (force unwrapping).



Давайте попробуем применить unowned.

Добавим новый класс CarrierSubscription в конце MainViewController.swift:

class CarrierSubscription {
  let name: String
  let countryCode: String
  let number: String
  let user: User
              
  init(name: String, countryCode: String, number: String, user: User) {
    self.name = name
    self.countryCode = countryCode
    self.number = number
    self.user = user
    
    print("CarrierSubscription \(name) is initialized")
  }

  deinit {
    print("Deallocating CarrierSubscription named: \(name)")
  }
}        

У CarrierSubscription четыре свойства:

Name: название провайдера.
CountryCode: код страны.
Number: телефонный номер.
User: ссылка на пользователя.

Кто ваш провайдер?


Теперь добавьте это в класс User после свойства name:

var subscriptions: [CarrierSubscription] = []

Здесь мы держим массив провайдеров пользователя.

Теперь добавьте это в класс Phone class, после свойства owner:

var carrierSubscription: CarrierSubscription?

func provision(carrierSubscription: CarrierSubscription) {
  self.carrierSubscription = carrierSubscription
}

func decommission() {
  carrierSubscription = nil
}

Это добавляет опциональное свойство CarrierSubscription и два метода для регистрации и разрегистрации телефона у провайдера.

Теперь добвьте внутри метода init у класса CarrierSubscription, прямо перед оператором print:

user.subscriptions.append(self)

Мы добавляем CarrierSubscription в массив провайдеров пользователя.

И, наконец, добавьте это в конце метода runScenario():

let subscription = CarrierSubscription(
  name: "TelBel", 
  countryCode: "0032",
  number: "31415926", 
  user: user)
iPhone.provision(carrierSubscription: subscription)

Мы создаем подписку на провайдера для пользователя и подключаем к ней телефон.

Запустите приложение. В консоли вы увидите:

User John was initialized
Phone iPhone Xs was initialized
CarrierSubscription TelBel is initialized

И опять цикл ссылок! user, iPhone и subscription не высвободились в конце.

Сможете найти проблему?



Разрываем цепь


Или ссылка из user на subscription или ссылка из subscription на user должна быть unowned, чтобы разорвать цикл. Вопрос в том, какой вариант выбрать. Давайте разберемся в структурах.

Пользователь владеет подпиской на провайдера, но наоборот — нет, подписка на провайдера не владеет пользователем.

Более того, нет никакого смысла в существовании CarrierSubscription без привязки к владеющему ей пользователю.

Таким образом, ссылка на пользователя должна быть unowned.

Измените объявление user в CarrierSubscription:

unowned let user: User

Теперь user unowned, что разрывает цикл ссылок и позволит высвободить все объекты.



Циклы ссылок в замыканиях


Циклы ссылок применительно к объектам возникают, когда у объектов есть свойства, ссылающиеся друг на друга. Как и объекты, замыкания — это ссылочный тип, и могут приводить к циклам ссылок. Замыкания «захватывают» (capture) объекты, которые используют.

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



Добавьте этот код к CarrierSubscription сразу после свойства user:

lazy var completePhoneNumber: () -> String = {
  self.countryCode + " " + self.number
}

Это замыкание вычисляет и возвращает полный телефонный номер. Свойство объявлено как lazy, оно будет присвоено при первом использовании.

Это необходимо так как оно использует self.countryCode и self.number, которые будут недоступны до выполнения кода инициалайзера.

Добавьте в конец runScenario():

print(subscription.completePhoneNumber())

Вызов completePhoneNumber() приведет к исполнению замыкания.

Запустите приложение и вы увидите, что user и iPhone высвобождаются, а CarrierSubscription — нет, по причине цикла сильных ссылок между объектом и замыканием.



Списки захвата (Capture Lists)


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

Для демонстрации списка захвата рассмотрим следующий код:

var x = 5
var y = 5

let someClosure = { [x] in
  print("\(x), \(y)")
}
x = 6
y = 6

someClosure()        // Prints 5, 6
print("\(x), \(y)")  // Prints 6, 6

x есть в списке захвата замыкания, таким образом значение x копируется в месте определения замыкания. Оно захвачено по значению.

y нет в списке захвата, оно захвачено по ссылке. Это означает, что значение y будет таким, какое оно в момент вызова замыкания.

Списки замыкания помогают определить отношения weak или unowned взаимодействие по отношению к захвачиваемым внутри замыкания объектам. В нашем случае подходящий выбор — unowned, так как замыкание не может существовать, если экземпляр CarrierSubscription высвободится.

Захватите себя


Замените определение completePhoneNumber в CarrierSubscription::

lazy var completePhoneNumber: () -> String = { [unowned self] in
  return self.countryCode + " " + self.number
}

Мы добавляем [unowned self] в список захвата замыкания. Это означает, что мы захватили self как unowned ссылку вместо сильной.

Запустите приложение и вы увидите, что теперь CarrierSubscription высвобождается.

На самом деле приведённый выше синтаксис — это короткая форма более длинного и полного, в котором появляется новая переменная:

var closure = { [unowned newID = self] in
  // Use unowned newID here...
}

Тут newID — это unowned копия self. Вне замыкания self остается самим собой. В короткой форме, приведенной ранее, мы создаём новую переменную self, которая затеняет существующий self внутри замыкания.

Используйте Unowned осторожно


В вашем коде отношения между self и completePhoneNumber обозначены как unowned.

Если вы уверены, что объект, используемый в замыкании, не высвободится, можете использовать unowned. Если он всё-таки высвободится, вы в беде!

Добавьте этот код в конце MainViewController.swift:

class WWDCGreeting {
  let who: String
  
  init(who: String) {
    self.who = who
  }

  lazy var greetingMaker: () -> String = { [unowned self] in
    return "Hello \(self.who)."
  }
}

Теперь вот это в конце runScenario():

let greetingMaker: () -> String

do {
  let mermaid = WWDCGreeting(who: "caffeinated mermaid")
  greetingMaker = mermaid.greetingMaker
}

print(greetingMaker()) // ЛОВУШКА!        

Запустите приложение и вы увидите аварийное завершение и что-такое в консоли:

User John was initialized
Phone iPhone XS was initialized
CarrierSubscription TelBel is initialized
0032 31415926
Fatal error: Attempted to read an unowned reference but object 0x600000f0de30 was already deallocated2019-02-24 12:29:40.744248-0600 Cycles[33489:5926466] Fatal error: Attempted to read an unowned reference but object 0x600000f0de30 was already deallocated

Исключение возникло по причине того, что замыкание ждёт, что self.who существует, но он был высвобожден, как только mermaid вышла из области действия в конце блока do.

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

Разминируем ловушку


Замените greetingMaker в классе WWDCGreeting таким образом:

lazy var greetingMaker: () -> String = { [weak self] in
  return "Hello \(self?.who)."
}

Мы сделали две вещи: во-первых, мы заменили unowned на weak. Во-вторых, так как self стал weak, мы получаем доступ к свойству who через self?.who. Игнорируйте предупреждение Xcode, мы его скоро исправим.

Приложение больше не крашится, но, если его запустить, мы получим забавный результат: “Hello nil.”

Возможно, полученный результат вполне приемлем, но часто нам нужно сделать что-то, если объект был освобождён. Это можно сделать при помощи оператора guard.

Замените текст замыкания этим:

lazy var greetingMaker: () -> String = { [weak self] in
  guard let self = self else {
    return "No greeting available."
  }
  return "Hello \(self.who)."
}

Оператор guard присваивает self, взятое из weak self. Если self — nil, замыкание возвращает “No greeting available.” В противном случае, self становится сильной ссылкой, так что объект гарантированно доживёт до конца выполнения замыкания.

Ищем циклы ссылок в Xcode 10


Теперь, когда вы понимаете принципы работы ARC, что такое циклы ссылок и как их разрывать, пришло время посмотреть пример реального приложения.

Откройте проект Starter, находящийся в папке Contacts.

Запустите приложение.



Это простейший менеджер контактов. Попробуйте кликнуть на контакте, добавьте пару новых.

Назначение файлов:

ContactsTableViewController: показывает все контакты.
DetailViewController: показывает подробную информацию выбранного контакта.
NewContactViewController: позволяет добавить новый контакт.
ContactTableViewCell: ячейка таблицы, показывающая детали контакта.
Contact: модель контакта.
Number: модель номера телефона.

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

К счастью, в Xcode 10 есть встроенные средства, чтобы найти мельчайшую утечку памяти.

Запустите снова приложение. Удалите 3-4 контакта при помощи свайпа влево и кнопки delete. Похоже, что они исчезают совсем, да?



Где же течёт?


При запущенном приложении кликните на кнопке Debug Memory Graph:



Понаблюдайте за Runtime Issues в Debug navigator. Они отмечены пурпурными квадратами с белым восклицательным знаком внутри:



Выберите в навигаторе один из проблемных Contact объектов. Цикл чётко виден: объекты Contact и Number ссылаясь друг на друга, удерживают.



Похоже, вам пора заглянуть в код. Учитывайте, что контакт может существовать без номера, но не наоборот.

Как бы вы разрешили этот цикл? Ссылка из Contact на Number или из Number на Contact? weak или unowned? Попробуйте сначала сами!

Если потребовалась помощь...
Есть 2 возможных решения: или сделать ссылку из Contact на Number weak, или из Number на Contact unowned.

Документация Apple рекомендует, чтобы родительский объект владел сильной ссылкой на «детский» — не наоборот. Это означает, что мы даем Contact сильную ссылку на Number, а Number — unowned ссылку на Contact:

class Number {
  unowned var contact: Contact
  // Other code...
}

class Contact {
  var number: Number?
  // Other code...
}


Бонус: циклы с ссылочными типами и типами-значениями.


В Swift есть ссылочные типы (классы и замыкания) и типы-значения (структуры, перечисления). Тип-значение копируется при его передаче, а ссылочные типы делят одно значение при помощи ссылки.

Это значит, что в случае типов-значений циклов не может быть. Для возникновения цикла нам нужно как минимум 2 ссылочных типа.

Вернёмся к проекту Cycles project и добавим этот код в конце MainViewController.swift:

struct Node { // Error
  var payload = 0
  var next: Node?
}

Не выйдет! Структура — тип значение и не может иметь рекурсию на экземпляр самой себя. В противном случае, у такой структуры был бы бесконечный размер.

Изменим структуру на класс.

class Node {
  var payload = 0
  var next: Node?
}

Ссылка на себя вполне допустима для классов (ссылочный тип), так что проблем у компилятора не возникает.

Теперь добавим это в конце MainViewController.swift:

class Person {
  var name: String
  var friends: [Person] = []
  init(name: String) {
    self.name = name
    print("New person instance: \(name)")
  }

  deinit {
    print("Person instance \(name) is being deallocated")
  }
}

А это — в конце runScenario():

do {
  let ernie = Person(name: "Ernie")
  let bert = Person(name: "Bert")
  
  ernie.friends.append(bert) // Not deallocated
  bert.friends.append(ernie) // Not deallocated
}

Запустите приложение. Обратите внимание: ни ernie, ни bert не высвобождены.

Ссылка и значение


Это пример сочетания ссылочного типа и типа-значения, которое привело к циклу ссылок.

ernie и bert остаются невысвобожденными, держа друг друга в своих массивах друзей, хотя массивы сами по себе — типы-значения.

Попробуйте сделать архив friends как unowned, и Xcode покажет ошибку: unowned применим только к классам.

Чтобы пофиксить этот цикл, нам придётся создать объект-обёртку и использовать его для добавления экземпляров в массив.

Добавьте следующее определение перед классом Person:

class Unowned<T: AnyObject> {
  unowned var value: T
  init (_ value: T) {
    self.value = value
  }
}

Затем измените определение friends в классе Person:

var friends: [Unowned<Person>] = []

Наконец, замените содержимое блока do в runScenario():

do {
  let ernie = Person(name: "Ernie")
  let bert = Person(name: "Bert")
  
  ernie.friends.append(Unowned(bert))
  bert.friends.append(Unowned(ernie))
}

Запустите приложение, теперь ernie и bert корректно высвобождаются!

Массив friends больше не является коллекцией объектов Person. Теперь это коллекция объектов класса Unowned, которые служат обёрткой для экземпляров Person.

Чтобы получить объекты Person из Unowned, используйте свойство value:

let firstFriend = bert.friends.first?.value // get ernie 

Заключение


Теперь вы хорошо понимаете управление памятью в Swift и знаете, как работает ARC. Надеюсь, публикация была для вас полезной.

Apple: Automatic Reference Counting
Поделиться публикацией

Комментарии 18

    +1
    Интересная статья, спасибо! Даже имея опыт разработки, нашел для себя некоторые интересные моменты с которыми не приходилось сталкиваться, но с которыми могу столкнуться в будущем.
      0
      Рад, что публикация оказалась полезной!
      +1
      тип значение и не может иметь рекурсию на экземпляр самой себя

      Может.

      indirect enum Optional<T> {
          case some(T)
          case none
      }
      
      struct Node {
          var payload: Int
          var next: Optional<Node>
      }
      
      let node = Node(payload: 16, next: .some(Node(payload: 13, next: .none)))
        0
        Да, ценное замечание. Эта возможность появилась в Swift 5.0, а оригинал статьи писался под 4.2
          0
          В Swift 3 вроде появилась.

          Картинка
          image
          0
          Хотя, строго говоря, все ж нет. Ведь структура ссылается не на себя, а на перечисление, в которую изподвыподверта завернута структура. Но помнить об этой возможности полезно, не спорю.
            +1
            В статье как бы Optional, который так же перечисление.
            var next: Node?

            Можно записать так.
            var next: Optional<Node> = .none

            И Optional который написал я, от свифтового Optional ничем не отличается.
              0
              Спасибо за полезные комментарии!
              А я что-то
              всё время невпопад )
          0
          «Разместите этот код над методом viewDidLoad():
          let user = User(name: „John“)
          … но оператор print внутри deinit не был исполнен. Значит, этот объект не был освобождён, так как он не вышел из области видимости (scope).»
          Очень странно, вообще deinit сработает, так как область видимости у let user — viewDidLoad.
            0
            Спасибо за внимательное чтение, но «над методом» не означает «внутри метода» ) Впрочем, поправлю на «перед методом»
              0
              ааа все — это я очень «внимательно» читал=)
            0
            del
              0
              У меня один вопрос. Swift — единственный язык из распространенных, который не умеет решать эту проблему без вмешательства разработчика? JVM уж точно такие циклические взаимные ссылки умеет определять и освобождать от них память — и не требует для этого со стороны разработчика вообще ничего.
                0
                Вообще, насколько я понимаю, тут два принципиально разных подхода: Garbage Collector vs. No Garbage Collector. JVM — я правильно понимаю, что там есть сборщик мусора? Ну не было его в Objective C, нет и в Swift. А чудес не бывает, прибирать за собой надо. Есть ARC — и на том спасибо, просто нужно знать его особенности.
                  0
                  Да, и, собственно ответ на вопрос
                  Swift — единственный язык из распространенных, который не умеет...

                  C++ достаточно распространён?
                    0
                    C++ не умеет за собой прибирать, насколько я знаю. Или уже тоже научили?

                    Kotlin сейчас вовсю развивается, и я что-то не припомню, чтобы при компиляции в Kotlin/Navite были какие-то особенности.
                      0
                      Сборщика мусора там тоже нет, и есть также проблема циклических ссылок. Это, собственно, ответ на вопрос, «единственный ли такой урод Swift». )
                        0
                        Понял. Буду знать :)

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое