Даже когда они захватывают себя

Итак, вы ведете хорошую борьбу за производительный, чистый, оптимальный код Swift и, как следствие, внимательно следите за погодой на предмет скрытых циклов надежных ссылок и утечки памяти, которые они вызывают. И, как это происходит, вы оказались в ситуации, когда требуется добавить сохраненное свойство к классу, которому для вычислений нужны другие члены класса. Это свойство, однако, не потребуется немедленно и требует некоторого нетривиального времени для инициализации. Конечно, вам нравится использовать ленивые свойства там, где это необходимо для повышения эффективности, которое они могут обеспечить, но теперь вы находитесь в ситуации, когда вы будете захватывать себя в закрытии, которое вы предоставляете своему ленивому var, и это устанавливает ваш строгий цикл ссылок сигнализация гудит! Захват себя в замыкании требует явного списка захвата и часто требует использования weak или unowned, верно? Какие меры предосторожности нужно предпринять в этом случае? Давайте нырнем!

Фон

1. Ленивые свойства

Ленивое сохраненное свойство - это свойство, значение которого не будет вычисляться до первого обращения к нему. Это означает, что это полезно в ситуациях, когда рассматриваемое значение требует некоторого нетривиального количества вычислительного времени для создания и не обязательно требуется немедленно или не всегда необходимо. Если к ленивому свойству никогда не обращаются, вам не придется тратить это время на его вычисления, а если к нему обращаются, по крайней мере, вам не придется тратить это время на инициализацию экземпляра класса, хранящего его. Один из способов воспользоваться этой «ленью» - установить значение свойства lazy, равное немедленно оцениваемому закрытию, которое вычисляет и возвращает значение, которое будет использоваться:

Ленивое свойство всегда должно быть объявлено как var , поскольку его значение не будет установлено во время инициализации, а постоянным свойствам let требуется значение перед инициализацией можно считать завершенной.

2. Замыкания и справочные циклы

Поскольку мы используем замыкание для этого ленивого свойства, нам лучше внимательнее (читайте: взгляд на замыкание), что это означает.

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

Замыкания захватывают значения из своего контекста, то есть хранят ссылки на эти значения и позволяют вам получить к ним доступ в пределах вашего замыкания. В большинстве случаев значение автоматически фиксируется замыканием, когда вы обращаетесь к нему из тела замыкания.

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

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

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

В то время как сильная захваченная ссылка оставляет вас уязвимым для цикла ссылок, слабые и бесхозные ссылки не учитываются при автоматическом подсчете ссылок, что дает вам возможность фиксировать ссылку на себя без создание утечки памяти. Разница между слабыми и unowned ссылками заключается в том, что слабые ссылки предназначены для ссылки на экземпляры, которые будут иметь более короткое время жизни, чем класс, хранящий ссылку на них, в то время как бесхозные ссылки являются предназначен для указания экземпляров, которые будут длиться столько же или дольше, чем класс, указывающий на них (подробнее об этом можно найти в документации Swift здесь).

И на всякий случай Swift заставляет вас явно ссылаться на себя при обращении к нему из замыкания, просто чтобы напомнить вам об опасности сильного ссылочного цикла.

Ближе…

Итак, теперь мы возвращаемся к нашему ленивому свойству.

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

// Lazy var capturing unowned self
lazy var myLazyVariable = { [unowned self]
     return self.someValue * 5
}()

Простой способ проверить циклы сильных ссылок - использовать функцию класса deinit (), которая вызывается, когда класс деинициализируется и его память освобождается. Поскольку deinit никогда не будет вызываться, когда цикл сильных ссылок поддерживает экземпляр, я могу поместить оператор print в deinit, чтобы убедиться, что мой экземпляры размещены правильно. Следующая площадка реализует это на практике: во-первых, он определяет класс с ленивым свойством, закрытие которого явно захватывает незарегистрированное «я». Затем он создает ссылку на экземпляр этого класса и обращается к свойству lazy. Наконец, он устанавливает для своей ссылки на этот класс значение nil, что должно освободить этот экземпляр класса для освобождения. Если мне удалось избежать цикла сильных ссылок, должно быть напечатано «Человек, вызываемый деинициалом».

Мои результаты показывают, что захват self как unowned на самом деле предотвратил цикл сильных ссылок, позволив ARC очистить память моего экземпляра Person.

… Большой финал

Однако непредвиденное происходит, когда я удаляю список захвата и явную ссылку на себя из моего закрытия:

Согласно Swift docs:

Swift требует, чтобы вы писали self.someProperty или self.someMethod() (а не просто someProperty или someMethod()) всякий раз, когда вы ссылаетесь на член self в закрытии

Похоже, что приведенный выше код противоречит этому утверждению! Я фиксирую себя в своем замыкании, обращаясь к сохраненным свойствам self, но я явно не ссылаюсь на self и не получаю ошибку компилятора. Более того, я фиксирую сильную ссылку на себя, но не создаю цикл сильной ссылки, как показывает успешный вызов deinit (). Что тут происходит?

Чтобы ответить на этот вопрос, позвольте мне познакомить вас с Джо Гроффом, старшим инженером по компиляторам Swift в Apple. Вышеупомянутый вопрос был задан Гроффу в Twitter, на что он дал следующий ответ:

@noescape - ключевое слово Swift, указывающее, что закрытие не будет сохранено его владельцем и не будет вызываться вне времени существования его контекста; это отличается от замыканий @escaping, которые могут быть вызваны после возврата их включающей функции.

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

TL; DR: при написании ленивого свойства с использованием немедленно применяемого замыкания закрытие автоматически становится @noescape, и поэтому вы можете ссылаться на себя неявно и без использования слабой или незарегистрированной ссылки.

Ресурсы
Документы Swift по Автоматическому подсчету ссылок
Документы Swift по Избеганию замыканий
Документы Swift по Ленивым свойствам
Это Github обсуждение использования ленивых свойств