JavaRush /Курси /Swift SELF /Цикл утримання «A ↔ B»: як зʼявляється витік

Цикл утримання «A ↔ B»: як зʼявляється витік

Swift SELF
Рівень 37 , Лекція 1
Відкрита

1. Чому ARC допускає цикл «A ↔ B»

Почнімо з людської логіки: коли в голові немає моделі, код перетворюється на «магічні заклинання, які то працюють, то ні». ARC — це не «збирач сміття, який усе знайде сам», а система, що чесно рахує утримувальні посилання. І якщо вони склалися в коло, ARC не «здогадається», що воно вам уже не потрібне: він бачить лише посилання і робить висновок, що об’єкти ще комусь потрібні.

Уявіть, що маємо два об’єкти: A і B. A зберігає посилання на B, а B — на A. Поки ці посилання сильні (strong), кожен об’єкт стає для іншого «власником», а отже, лічильник сильних посилань у жодного з них не падає до нуля. Навіть якщо в коді ви вже пішли далі і явно їх не використовуєте.

Можна зобразити це як невеликий граф:

flowchart LR
    A[об’єкт A] -- сильне --> B[об’єкт B]
    B -- сильне --> A

ARC дуже дисциплінований: «бачу strong-посилання — вважаю, що об’єкт повинен жити». І в цьому сенсі цикл утримання — не вада ARC, а передбачуваний наслідок неправильної моделі володіння.

2. Мінімальний приклад: два класи і два сильні посилання

Зараз буде приклад, який майже надто простий. Але це добрий знак: найдорожчі баги часто виглядають занадто простими, щоб їх помітити у великому проєкті.

Зробімо два класи Author і BookEdition (умовно: автор і конкретне видання). Нехай автор «знає» своє видання, а видання — автора. На папері це звучить логічно, а в коді — уже небезпечно.

import Foundation

final class Author {
    let name: String
    var edition: BookEdition?

    init(name: String) { self.name = name }
    deinit { print("deinit Author \(name)") }
}

final class BookEdition {
    let isbn: String
    var author: Author?

    init(isbn: String) { self.isbn = isbn }
    deinit { print("deinit BookEdition \(isbn)") }
}

Тепер зберемо їх «у пару», а потім обнулимо зовнішні посилання:

import Foundation

func demoCycle() {
    var a: Author? = Author(name: "Ann")
    var e: BookEdition? = BookEdition(isbn: "ISBN-001")

    a?.edition = e
    e?.author = a

    a = nil
    e = nil

    print("кінець demoCycle")
}

demoCycle()

Якби циклу не було, ви б очікували побачити два deinit. Але часто (і в реальному проєкті майже завжди) ви побачите лише:

// кінець demoCycle

Тобто deinit не викликано. Об’єкти «живуть» далі. Не тому, що Swift лінується, а тому, що всередині залишилося замкнене коло strong-посилань.

3. Чому a = nil і e = nil не рятують

На цьому місці майже всі новачки ставлять одне й те саме запитання: «Почекайте, але ж я все обнулив… чому вони не зникли?» Це дуже правильне запитання — отже, ви починаєте мислити як людина, яка не вірить у магію.

Розберімо покроково, як на це дивиться ARC.

Усередині demoCycle() у нас є дві зовнішні змінні: a і e. Вони справді є сильними посиланнями, доки не стануть nil. Коли ми пишемо a = nil, ми прибираємо одне сильне посилання на об’єкт Author. Але в Author усе ще лишається сильне посилання від BookEdition, бо BookEdition.author — звичайна властивість (strong за замовчуванням). Аналогічно, коли ми пишемо e = nil, ми прибираємо зовнішнє сильне посилання на BookEdition, але в BookEdition залишається сильне посилання від Author через Author.edition.

І виходить, що зовнішні «ручки» ви відпустили, а всередині вони й далі тримаються одне за одне, ніби дивляться фінал сезону. ARC не може втрутитися між ними й вирішити, що час розійтися: його завдання — рахувати strong-посилання, а не вгадувати ваші наміри.

Схема «до» і «після» виглядає так:

flowchart TD
    subgraph BeforeNil["До a = nil і e = nil"]
        A1[Author] -->|сильне посилання edition| E1[BookEdition]
        E1 -->|сильне посилання author| A1
        Va[a variable] -->|сильне| A1
        Ve[e variable] -->|сильне| E1
    end

    subgraph AfterNil["Після a = nil і e = nil"]
        A2[Author] -->|сильне посилання edition| E2[BookEdition]
        E2 -->|сильне посилання author| A2
        %% зовнішніх змінних більше немає
    end

Саме це і є цикл утримання: сильні посилання замкнулися, а «точки входу» зовні вже не потрібні, щоб об’єкти продовжували жити.

4. Як цикл зʼявляється в CLI-застосунку

Тепер зробімо крок від іграшкового прикладу до чогось схожого на реальність. У нашому навчальному CLI-проєкті (умовно LibraryCLI) зазвичай є сутності домену: бібліотека, книги, читачі, видачі. На ранніх етапах курсу ми могли моделювати багато чого як struct, але сьогодні свідомо використовуємо class, щоб побачити реальне володіння та час життя.

Уявімо, що ми вирішили зробити «зручно»: бібліотека зберігає список книг, а кожна книга — посилання на бібліотеку, щоб, наприклад, знати налаштування або вміти логувати. Не робімо так насправді — це лише ілюстрація. І ось так можна випадково зібрати цикл.

import Foundation

final class Library {
    let name: String
    var books: [Book] = []

    init(name: String) { self.name = name }
    deinit { print("deinit Library \(name)") }
}

final class Book {
    let title: String
    var library: Library?   // поки звичайне сильне посилання

    init(title: String) { self.title = title }
    deinit { print("deinit Book \(title)") }
}

Створімо бібліотеку і книгу, зв’яжімо їх в обидва боки та відпустімо зовнішнє посилання:

import Foundation

func demoLibraryCycle() {
    var lib: Library? = Library(name: "City Library")
    let book = Book(title: "Swift for Humans")

    lib?.books.append(book)    // масив утримує book сильним посиланням
    book.library = lib         // book утримує lib сильним посиланням

    lib = nil                  // зовнішнє посилання прибрали
    print("кінець demoLibraryCycle")
}

demoLibraryCycle()

Чому це місце особливо підступне: у коді все виглядає як «ну, додали книгу в масив, ну дали книзі посилання на бібліотеку». Але масив books утримує елементи сильними посиланнями, а книга утримує бібліотеку — і маємо коло:

flowchart LR
    L[Бібліотека] -->|"сильне посилання на books[]"| B[Книга]
    B -->|сильне посилання на library| L

І знову: deinit не викликається, бо є цикл утримання. Важливо запам’ятати саме ідею: цикл часто зʼявляється не як «дві стрічки поруч», а через колекцію або через кілька рівнів об’єктів.

5. Як підтвердити цикл утримання: «ручне розривання»

Важливе застереження: ми поки що не обговорюємо правильне рішення через неутримувальні посилання — це наступна тема. Але щоб мозок перестав сумніватися, корисно вміти діагностувати цикл простим експериментом: «якщо я вручну розірву зв’язок, deinit з’явиться?»

Зробімо невелику перевірку на попередньому прикладі. Ми розірвемо один бік, наприклад, у книги приберемо посилання на бібліотеку, а потім відпустимо зовнішню змінну.

import Foundation

func demoManualBreak() {
    var lib: Library? = Library(name: "City Library")
    let book = Book(title: "Swift for Humans")

    lib?.books.append(book)
    book.library = lib

    book.library = nil     // ручне розривання зв’язку

    lib = nil
    print("кінець demoManualBreak")
}

demoManualBreak()

Після такого втручання шанс побачити deinit стає набагато вищим, і зазвичай ви його побачите. Цей прийом корисний як тест гіпотези: проблема справді в циклі, а не в тому, що об’єкт десь іще зберігається.

Зверніть увагу на тонкість: іноді одного book.library = nil може бути недостатньо, якщо цикл утримання складніший (наприклад, бібліотека зберігається ще десь статично або лежить в іншому контейнері). Але як базова діагностика — це чудовий ліхтарик.

6. Чому цикл утримання — це витік

Тут легко скотитися в плутанину: «Ну й що, об’єкт не видалився, може, так і треба?» Іноді справді треба: наприклад, об’єкт-сінглтон або глобальний кеш за задумом живе до кінця програми. Але цикл утримання відрізняється тим, що життя об’єктів стає «вічним» не тому, що ви так задумали, а тому, що вони випадково стали власниками одне одного.

Документація Swift щодо витоків прямо зазначає: найчастіше витоки в Swift виникають саме через сильні цикли посилань, попри ARC. Це не теоретична рідкість, а доволі частий клас помилок, особливо коли проєкт росте й зв’язки між об’єктами стають складнішими.

Щоб розрізняти «очікувано живе довго» і «витекло», зручно ставити собі одне питання: «Хто власник? Хто має відповідати за час життя?» Якщо відповіді немає, а об’єкт раптом безсмертний — це підозріло.

7. Як розпізнати проблему й не плутати володіння з доступом

Симптоми циклу утримання

Зведімо ознаки в таблицю, щоб можна було швидко звірятися, коли у вас зник deinit.

Спостереження в коді/логах Що це зазвичай означає
Ви точно вийшли з функції або області видимості, але deinit не спрацював Об’єкт усе ще десь утримується сильним посиланням
Ви обнулили змінну var x: X? = ...; x = nil, але deinit не з’явився Сильне посилання залишилося в іншому місці (часто — в іншому об’єкті або колекції)
Є двосторонній зв’язок через властивості (a.b = b, b.a = a) Це кандидат № 1 на цикл утримання
Між A та B є контейнер (A.items.append(b), а b.owner = a) Дуже частий «прихований» цикл через колекції
«Ручне розривання» одного зі зв’язків раптово повертає deinit Майже напевно це цикл утримання, а не «якийсь дивний Swift»

Володіння vs зручний доступ

Дуже корисно розрізняти дві ідеї, які новачки часто змішують в одну: «мені потрібен доступ» і «я володію».

Коли ви пишете в класі властивість var other: Other?, ви не просто кажете «хочу іноді бачити Other». Ви кажете «я готовий утримувати Other живим». Тобто берете на себе роль власника або співвласника. Це нормально, але тільки якщо ви свідомо обираєте, хто кого утримує.

Цикли з’являються тоді, коли обидві сторони одночасно кажуть: «я власник». Найчастіше це трапляється не тому, що розробник хотів створити власників, а тому, що «так зручніше: нехай у кожного буде посилання на іншого». Зручніше — так. Дешевше для пам’яті — ні.

Корисна звичка: коли додаєте зворотне посилання (back-reference), промовляйте вголос: «Це володіння чи просто вказівник для зручності?» Якщо це «для зручності», то в наступній лекції ми побачимо правильні інструменти, як зробити це посилання неутримувальним, не створюючи циклу.

8. Типові помилки

Помилка № 1: думати, що цикл лікується «правильним порядком = nil».
Іноді здається, що якщо спочатку написати a = nil, потім b = nil, потім ще раз a = nil (про всяк випадок), то об’єкти «зрозуміють натяк». Вони не зрозуміють. У циклі утримання проблема не в порядку обнулення зовнішніх змінних, а в тому, що всередині об’єктів лишилися strong-посилання, замкнені в коло.

Помилка № 2: робити двосторонні властивості strong «за замовчуванням», тому що так швидше написати.
Ця помилка особливо підступна тим, що в маленькому прикладі все виглядає безневинно. А потім проєкт росте, і таке «швидше написати» перетворюється на «чому в нас пам’ять росте з кожною командою CLI?». Звичка думати про володіння трохи повільніше, ніж друкувати код, зрештою економить дні налагодження.

Помилка № 3: не вважати колекції (Array, Dictionary) частиною графа володіння.
Масив — це не просто «список». Для об’єктів class це контейнер сильних посилань. Якщо об’єкт лежить у масиві, він утримується. Якщо об’єкт, який лежить у масиві, утримує власника масиву, цикл може виникнути взагалі без прямої пари «властивість ↔ властивість».

Помилка № 4: плутати «витік через цикл» і «об’єкт за задумом довгоживучий».
Якщо об’єкт справді повинен жити довго (наприклад, глобальний менеджер), це не витік. Але якщо у вас немає ясного власника, а deinit зник і повертається лише після ручного розривання зв’язків — це вже дуже схоже на цикл утримання, і це варто виправляти на рівні моделі посилань.

Помилка № 5: не ставити deinit-маячки, поки проєкт ще маленький.
Новачки часто додають deinit тільки «коли все зламалося». Але значно дешевше заздалегідь поставити кілька print у ключових класах, щоб бачити життєвий цикл. Тоді цикл утримання виявляється майже одразу, а не через тиждень після того, як «щось стало повільним».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ