JavaRush /Курси /Swift SELF /willSet/didSet: переваги та недоліки

willSet/didSet: переваги та недоліки

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

1. Основи property observers

Коли ви пишете struct, досить швидко зʼявляється бажання реагувати на зміни: вивести лог, перерахувати лічильник, оновити повʼязане поле. У Swift для цього є механізм property observers — блоки willSet і didSet, які спрацьовують до та після присвоєння значення stored property. Це схоже на потрібні «гачки», але важливо розуміти: це не магія контролю коректності, а радше реакція на сам факт запису.

У Swift observers зазвичай привʼязують до збереженої (stored) властивості var:

import Foundation

struct Counter {
    var value: Int = 0 {
        willSet { print("Зараз стане \(newValue)") }        // до запису
        didSet  { print("Було \(oldValue), стало \(value)") } // після запису
    }
}

var c = Counter()
c.value = 10
// Зараз стане 10
// Було 0, стало 10

Тут важливо одразу зафіксувати філософію: willSet/didSet — це не про те, як зберігати дані, а про те, що зробити поруч із присвоєнням.

willSet: ще не змінили, але вже збираємося

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

Простий приклад із нашого навчального CLI-світу. Припустімо, у нас є сутність «чернетка книги»: користувач вводить дані, а ми поки що зберігаємо їх у структурі.

import Foundation

struct BookDraft {
    var title: String = "" {
        willSet {
            print("Змінюємо заголовок: '\(title)' -> '\(newValue)'")
        }
    }
}

var draft = BookDraft()
draft.title = "Clean Code"
// Змінюємо заголовок: '' -> 'Clean Code'

Зверніть увагу: у willSet title ще має старе значення. Саме в цьому й сенс: ви бачите «до» і «майбутнє», але «після» ще не настало.

Іноді зручно дати newValue більш читабельне імʼя, особливо якщо це не просто число, а змістовні дані:

import Foundation

struct UserInput {
    var query: String = "" {
        willSet(newQuery) {
            print("Новий запит користувача: \(newQuery)")
        }
    }
}

Так код менше нагадує закляття, а більше — звичайну українську мову. Ну, настільки, наскільки це можливо всередині Swift.

didSet: уже змінили — можна порівняти з минулим

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

Мініприклад: ми хочемо друкувати, як змінюється прогрес читання книги, поки без складних правил — лише щоб показати роботу спостерігача:

import Foundation

struct ReadingProgress {
    var pagesRead: Int = 0 {
        didSet {
            print("Прочитано сторінок: \(oldValue) -> \(pagesRead)")
        }
    }
}

var progress = ReadingProgress()
progress.pagesRead = 5
// Прочитано сторінок: 0 -> 5
progress.pagesRead = 20
// Прочитано сторінок: 5 -> 20

Важливий нюанс на практиці: didSet виконується після запису. Отже, якщо ви звертаєтеся до pagesRead, уже бачите нове значення.

Ще один корисний прийом — реагувати лише тоді, коли зміна справді відбулася, бо observers спрацьовують при кожному присвоєнні, навіть якщо ви записали те саме значення.

import Foundation

struct Volume {
    var level: Int = 0 {
        didSet {
            if level == oldValue { return }
            print("Гучність змінилася: \(oldValue) -> \(level)")
        }
    }
}

var v = Volume()
v.level = 0      // нічого не друкуємо
v.level = 3      // Гучність змінилася: 0 -> 3

Порядок виконання і що видно всередині observers

Коли ви тільки починаєте, у голові легко переплутати oldValue і newValue, а також момент, коли що відбувається. Тому корисно тримати перед очима просту модель: присвоєння — це маленький сценарій, а observers просто вставлені до нього і після нього.

Невелику таблицю варто буквально запамʼятати:

Спостерігач Коли викликається Що доступно одразу Яке значення має властивість
willSet
до запису
newValue
ще старе
didSet
після запису
oldValue
вже нове

І ось схема потоку, як це виглядає концептуально:

flowchart TD
    A[Зовнішній код виконує: x = 42] --> B[willSet: newValue = 42]
    B --> C[Відбувається запис: x стає 42]
    C --> D[didSet: oldValue = попереднє значення]

Ще один важливий технічний момент — без зайвого пафосу: отримання oldValue може бути не безкоштовним, якщо значення велике, наприклад масив. У сучасних версіях Swift компілятор намагається не створювати oldValue, якщо ви не використовуєте його в тілі didSet. Це окрема оптимізація, яка втілює просту ідею: «не чіпай oldValue, якщо він не потрібен».

2. Коли observers спрацьовують і де бувають сюрпризи

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

По-перше, observers стосуються var stored properties. На let їх не привʼяжеш: let і так не змінюється, а спостерігати там нічого.

По-друге, observers спрацьовують на присвоєння. Це означає, що якщо ви написали код, який багато разів записує те саме значення, didSet викликатиметься багато разів — навіть коли смислової зміни немає. Тому порівняння з oldValue іноді треба робити явно.

По-третє, є тонкий момент із мутацією складених значень. Наприклад, якщо в struct є поле-масив, і ви виконуєте append, ви начебто не присвоюєте масив цілком, але масив усередині структури змінюється, а отже фактично відбувається запис у stored property, і observers можуть спрацювати.

import Foundation

struct LibraryDraft {
    var tags: [String] = [] {
        didSet { print("Тепер теги: \(tags)") }
    }
}

var d = LibraryDraft()
d.tags.append("swift")
// Тепер теги: ["swift"]
d.tags.append("cli")
// Тепер теги: ["swift", "cli"]

По-четверте, окремий класичний сюрприз: observers не варто сприймати як універсальний код, який завжди спрацює під час будь-якої ініціалізації. Ініціалізація (init) — це особливий момент життя значення, і мова намагається зробити його передбачуваним. Тому, якщо ви розраховуєте, що в init обовʼязково спрацює didSet і ви там усе виправите, ви майже напевно проєктуєте модель із неправильною відповідальністю. Правила коректності мають жити в init і в явних методах, а не в сподіванні на observers.

3. Де willSet/didSet доречні в CLI-коді

У прикладному коді, особливо в навчальному CLI-проєкті, observers — це хороший інструмент для двох речей: простого логування і синхронізації маленьких залежних полів, які ви свідомо зберігаєте окремо. Тут важливе слово — «свідомо»: якщо залежність простіше виразити через computed property, зазвичай краще використати саме computed property (це було в минулій лекції), а observers залишити для випадків, коли потрібна реакція.

Уявімо, що ми створюємо структуру, яка накопичує статистику введення команд, щоб відстежувати поведінку під час розробки:

import Foundation

struct CommandStats {
    var executedCommands: Int = 0 {
        didSet {
            print("Команд виконано: \(executedCommands)") // проста діагностика
        }
    }
}

var stats = CommandStats()
stats.executedCommands += 1
// Команд виконано: 1
stats.executedCommands += 1
// Команд виконано: 2

Ще один акуратний сценарій — синхронізувати лічильник. Припустімо, у нас є чернетка бібліотеки, де ми тимчасово зберігаємо список книг, і хочемо зберігати кількість окремо, бо це частина знімка стану. Наприклад, ви хочете швидко друкувати її, не рахуючи щоразу. Хоча частіше для цього все ж доречніша computed property.

import Foundation

struct LibrarySnapshot {
    var books: [String] = [] {
        didSet { booksCount = books.count }
    }

    private(set) var booksCount: Int = 0
}

var snap = LibrarySnapshot()
snap.books.append("The Pragmatic Programmer")
print(snap.booksCount) // 1

Тут observers працюють як клей: поле booksCount не можна змінювати ззовні, але воно оновлюється автоматично щоразу, коли змінюється books. Це допустимо, доки логіка проста й очевидна.

4. Чому observers не замінюють інваріанти

Найчастіша помилка новачка звучить так: «Я зроблю didSet і там підтримуватиму коректність — якщо ввели погане значення, я відкотю його назад». На перший погляд це здається логічним. Насправді такий підхід перетворює тип на загадку: зовні ви начебто присвоїли -10, а всередині значення кудись зникло. Такий код складно читати, складно тестувати, і дуже легко випадково розширити його так, що він почне жити власним життям.

Подивімося на приклад того, як не треба. Нехай у книги є rating від 1 до 5:

import Foundation

struct BookRating {
    var rating: Int = 1 {
        didSet {
            if rating < 1 || rating > 5 {
                rating = oldValue // "відкочуємо" назад
            }
        }
    }
}

var r = BookRating()
r.rating = 10
print(r.rating) // 1

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

Особливо слизькою стає ситуація, коли «лагодження» всередині didSet починає змінювати не лише це поле, а й інші. Тоді ви отримуєте тип, який на будь-яку дію відповідає каскадом прихованих наслідків. У маленькому проєкті це ще терпимо, а в реальному — типовий шлях до фрази «воно саме».

5. Як правильно тримати правила: init і методи

Якщо у вашого типу є інваріант — правило коректності, яке має бути істинним завжди, — краще тримати його в місцях, де ви явно контролюєте зміну стану: у init і в методах, які змінюють стан. А щоб зовнішній код не міг обійти ваші правила, ви закриваєте прямий запис через private(set) і даєте публічний метод.

Зробімо той самий рейтинг, але по-дорослому: напряму присвоїти не можна, можна лише викликати метод, який гарантує потрібний діапазон.

import Foundation

struct BookRating {
    private(set) var value: Int

    init(value: Int) {
        self.value = min(5, max(1, value))
    }

    mutating func setValue(_ newValue: Int) {
        value = min(5, max(1, newValue))
    }
}

var rating = BookRating(value: 10)
print(rating.value) // 5
rating.setValue(0)
print(rating.value) // 1

Тут правило видно. Читач коду розуміє: «ага, значення нормалізується». Жодних сюрпризів на кшталт «присвоїв одне — отримав інше» через прихований механізм.

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

import Foundation

struct BookRating {
    private(set) var value: Int = 1 {
        didSet { print("Рейтинг оновлено: \(oldValue) -> \(value)") }
    }

    mutating func setValue(_ newValue: Int) {
        value = min(5, max(1, newValue))
    }
}

Тут didSet не «лагодить» світ, а просто повідомляє про подію. Це значно надійніший стиль.

6. Ризики observers: рекурсія і побічні ефекти

Observers виглядають безневинно, доки ви не починаєте робити всередині них щось корисне. А потім раптом виявляється, що «корисне» — це ще одне присвоєння, яке знову викликає observers, і ви отримуєте рекурсію або багаторазові спрацювання.

Найпростіший приклад: ми змінюємо ту саму властивість усередині didSet без жорсткої умови.

import Foundation

struct BadNormalizer {
    var x: Int = 0 {
        didSet {
            if x < 0 {
                x = 0 // це знову присвоєння -> didSet знову спрацює
            }
        }
    }
}

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

Друга категорія проблем — побічні ефекти. Якщо всередині didSet ви друкуєте, пишете у файл, змінюєте інше поле, то кожне присвоєння перетворюється на мінісценарій. Це може бути неочікувано, особливо коли присвоєння відбуваються в циклі.

Третя категорія — продуктивність. Якщо значення велике, наприклад масив, то мати oldValue може бути дорого. У Swift є оптимізація: якщо didSet не використовує oldValue, його можуть не обчислювати, щоб не робити зайвої роботи. Тут також добре видно типову проблему: спостерігач на масиві, який спрацьовує багато разів, може призводити до зайвих копій, якщо необережно звертатися до oldValue.

Практичне правило для нашого рівня просте: observers мають бути короткими, передбачуваними і не перетворювати присвоєння на пригоди.

7. Типові помилки під час роботи з willSet/didSet

Помилка № 1: плутанина oldValue і newValue.
Це найчастіша дрібниця, яка потім перетворюється на дивні логи й неправильні умови. Запамʼятовується просто: newValue живе у willSet (бо «ще не записали, але вже знаємо, що прийде»), а oldValue живе у didSet (бо «вже записали, але можемо згадати, що було»).

Помилка № 2: використання observers як основного механізму інваріантів.
Коли правило коректності заховане в didSet і працює як відкат назад або виправлення після поломки, тип стає непередбачуваним: зовні ви присвоїли одне, а отримали інше, і це неочевидно з API. Набагато краще тримати правило в init і методах, а зовні обмежити запис через private(set).

Помилка № 3: зміна тієї самої властивості всередині didSet без жорсткого контролю.
Присвоєння всередині didSet — це нове присвоєння, а отже observers можуть спрацювати повторно. Іноді це випадково працює нормально, але масштабується погано: додаєте умови, змінюєте порядок — і отримуєте каскад повторних викликів. Якщо вже ви змушені так робити, умови мають бути максимально явними й короткими.

Помилка № 4: важка логіка і побічні ефекти в observers.
Якщо didSet починає виконувати багато дій — перевірки, перескладання даних, кілька присвоєнь, друк десяти рядків, — ви втрачаєте контроль над тим, що означає просте x = .... У результаті читання коду перетворюється на квест: «а що буде, якщо я просто зміню поле?». Краще винести логіку в методи, а observers залишити як компактну реакцію.

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

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