JavaRush /Курси /Swift SELF /Інкапсуляція: ховаємо деталі реалізації

Інкапсуляція: ховаємо деталі реалізації

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

1. Навіщо потрібна інкапсуляція

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

Уявіть LibraryCLI як маленьку бібліотеку — у сенсі «місце з книжками», а не «пакет Swift». У неї є зрозумілі дії: додати книгу, видалити книгу, знайти книгу, вивести список. Користувачеві бібліотеки не потрібно знати, лежать книжки в масиві, мапі чи в коробці з-під печива — йому потрібно, щоб команди працювали передбачувано.

«Меню» і «кухня»: модель публічного API

Інкапсуляцію найпростіше зрозуміти через побутову аналогію. У ресторані вам дають меню: «піца, паста, кава». Це і є публічний API. А от як саме на кухні смажать, солять і миють посуд — це деталі реалізації. Клієнт не повинен залежати від того, якої марки у кухаря сковорідка; інакше будь-яке оновлення кухні зламає «контракт», а клієнт почне вимагати: «Поверніть стару сковорідку».

У Swift роль «меню» відіграють public/internal властивості та методи. Роль «кухні» відіграють private/fileprivate деталі: сховища, допоміжні функції, проміжні структури. І ключовий момент: private у Swift — це лексична область видимості, а не просто «щось десь поруч у файлі».

Давайте одразу подивимося на антиприклад — «бібліотека без кухні, усе на видноті».

public struct Library {
    public var books: [String] = []
}

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

2. Ховаємо зберігання і підтримуємо інваріанти

У нашому LibraryCLI є модуль Domain, який описує предметну область: книги, ідентифікатори, каталог. Командний модуль LibraryCLI має вміти користуватися доменом, але не повинен знати, як саме той зберігає дані всередині.

Саме тут інкапсуляція стає практикою: ми ховаємо сховище й віддаємо назовні лише операції. У Swift це зазвичай виглядає як private var сховище та набір public/internal методів поверх нього.

Почнемо з міні-моделей — спрощено, але достатньо для прикладу, — які можна розмістити в Sources/Domain/.

public struct BookID: Hashable {
    public let rawValue: String

    public init(rawValue: String) {
        self.rawValue = rawValue
    }
}

public struct Book {
    public let id: BookID
    public let title: String

    public init(id: BookID, title: String) {
        self.id = id
        self.title = title
    }
}

А тепер — сам каталог, але вже з інкапсуляцією:

public struct LibraryCatalog {
    private var booksByID: [BookID: Book] = [:]

    public init() {}

    public var count: Int {
        booksByID.count
    }
}

Ми сховали booksByID. Ззовні тепер не можна «випадково» зробити catalog.booksByID.removeAll(). Це і є мінімальний, але дуже важливий крок до передбачуваності.

Чому private має саме таку силу? Бо private у Swift означає: видно лише всередині поточного оголошення та пов’язаних extension того самого типу в межах цього файла. Тобто він справді ховає внутрішню реалізацію, а не просто імітує це.

Інваріанти: правила життя типу

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

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

public struct LibraryCatalog {
    private var booksByID: [BookID: Book] = [:]

    public init() {}

    public mutating func add(_ book: Book) -> Bool {
        guard booksByID[book.id] == nil else { return false }
        booksByID[book.id] = book
        return true
    }
}

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

Тепер додамо читання — але теж акуратно. Ми можемо віддати список книг, але в такій формі, яка не дає змінювати внутрішній стан напряму.

public struct LibraryCatalog {
    private var booksByID: [BookID: Book] = [:]

    public var allBooks: [Book] {
        Array(booksByID.values)
    }
}

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

public private(set): читання назовні, запис — через методи

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

Swift дає для цього окремий інструмент: модифікатор доступу на сетері, тобто private(set). Це не «магія», а свідома частина моделі access control: читання і запис можна обмежувати по-різному.

Додамо лічильник:

public struct LibraryCatalog {
    private var booksByID: [BookID: Book] = [:]
    public private(set) var successfulAdds: Int = 0

    public mutating func add(_ book: Book) -> Bool {
        guard booksByID[book.id] == nil else { return false }
        booksByID[book.id] = book
        successfulAdds += 1
        return true
    }
}

Тепер ззовні можна написати:

var catalog = LibraryCatalog()
print(catalog.successfulAdds) // 0

Але не можна:

// catalog.successfulAdds = 999 // помилка: сетер private

І це саме те, що нам потрібно: назовні — «телеметрія», усередині — контроль.

3. Контроль створення об’єктів

Іноді вам потрібно зробити тип видимим ззовні (public), але не дозволити створювати його абияк. Це особливо корисно для сутностей із правилами, які ви хочете тримати під контролем: токени, ідентифікатори, нормалізовані рядки.

У Swift це робиться просто: для public типу ви робите ініціалізатор не public, а залишаєте його за замовчуванням (internal) або робите private, а назовні даєте фабрику static func ....

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

Приклад для BookID: нехай ми хочемо, щоб BookID створювався лише з коректного введення — із обрізанням пробілів і забороною порожнього значення. Для цього init робимо внутрішнім, а назовні даємо fromUserInput.

import Foundation

public struct BookID: Hashable {
    public let rawValue: String

    init(rawValue: String) {              // внутрішній init
        self.rawValue = rawValue
    }

    public static func fromUserInput(_ text: String) -> BookID? {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return nil }
        return BookID(rawValue: trimmed)
    }
}

Зверніть увагу на тонку, але важливу логіку: зовнішній код не може зробити BookID(rawValue: ""), бо init не публічний. Він зобов’язаний пройти через «вхідні двері» fromUserInput.

4. Організація коду: extensions і межі модулів

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

Swift дає змогу організувати це через extension. При цьому в extension є свої правила доступу: модифікатор на extension задає доступ за замовчуванням для членів усередині нього, і це можна використовувати як інструмент структурування файла.

Extensions: «вітрина» і «підсобка»

Уявімо, що нам потрібен акуратний пошук усередині каталогу. Публічний метод — один, а внутрішня нормалізація рядка — прихована.

import Foundation

public struct LibraryCatalog {
    private var booksByID: [BookID: Book] = [:]
    public init() {}
}

public extension LibraryCatalog {
    func containsTitle(_ text: String) -> Bool {
        let needle = normalize(text)
        return booksByID.values.contains { normalize($0.title) == needle }
    }
}

private extension LibraryCatalog {
    func normalize(_ text: String) -> String {
        text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
    }
}

Тут дуже приємний ефект: коли інший розробник або ви за два тижні вводите catalog. в автодоповненні, він бачить «меню»: containsTitle, add, count тощо. А normalize туди не з’являється і не спокушає використовувати його «для зручності».

І так, private у таких сценаріях — не косметика, а сувора межа: «ззовні не можна».

Інкапсуляція на межі модулів

Тепер подивімося на інкапсуляцію ширше: у SwiftPM межі видимості — це не лише private, а й межа модуля. За замовчуванням internal означає «видно лише всередині target». Це дає змогу тримати половину кухні взагалі за стіною, через яку зовнішній код не пройде.

У нас типова структура курсу: LibraryCLI (виконуваний модуль) імпортує Domain (бібліотечну ціль). У цій зв’язці дуже корисно тримати правило: Domain показує назовні лише те, чим справді мають користуватися команди CLI.

Якщо ми зробимо все public, то будь-який код у LibraryCLI зможе:

  • почати залежати від внутрішностей домену,
  • потім ви не зможете змінити реалізацію без масового рефакторингу,
  • а помилки виглядатимуть як «зламали все» замість «змінилася внутрішня кухня».

Тому в Domain часто виходить така стратегія:

  • публічні типи: Book, BookID, LibraryCatalog (те, що справді потрібно споживачеві),
  • приховані деталі: приватні хелпери, внутрішні структури індексу, внутрішні функції нормалізації,
  • методи, які підтримують інваріанти: публічні або internal, але не «відкриті поля».

Це і є інкапсуляція «за шарами»: частину ховаємо на рівні private, частину — на рівні internal (усередині модуля), і зовсім невелику частину показуємо як public.

5. Міні-рефакторинг каталогу

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

public struct LibraryCatalog {
    public var books: [Book] = []
}

А десь у LibraryCLI (у виконуваному модулі) ви робили:

var catalog = LibraryCatalog()
catalog.books.append(Book(id: BookID(rawValue: "1"), title: "Swift"))
catalog.books.append(Book(id: BookID(rawValue: "1"), title: "Swift 2")) // дублікат
catalog.books.removeAll() // «ой»

Це не «поганий розробник» — це нормальна людина, яку не зупинили межі доступу.

Тепер версія з інкапсуляцією. У Domain:

public struct LibraryCatalog {
    private var booksByID: [BookID: Book] = [:]

    public init() {}

    public mutating func add(_ book: Book) -> Bool {
        guard booksByID[book.id] == nil else { return false }
        booksByID[book.id] = book
        return true
    }

    public func find(by id: BookID) -> Book? {
        booksByID[id]
    }
}

А в LibraryCLI ви змушені використовувати контракт:

var catalog = LibraryCatalog()

let ok1 = catalog.add(Book(id: BookID(rawValue: "1"), title: "Swift"))
let ok2 = catalog.add(Book(id: BookID(rawValue: "1"), title: "Swift 2"))

print(ok1) // true
print(ok2) // false

І ось це «змушені» — радше комплімент архітектурі. Інкапсуляція робить правильний шлях простим, а неправильний — неможливим.

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

Помилка №1: «Зроблю властивості публічними, щоб було простіше».
Зазвичай це трапляється не зі злим умислом, а через бажання швидше написати код у CLI: «ну мені ж треба додати книгу, навіщо мені метод». Проблема в тому, що публічна властивість — це постійно відчинені двері у вашу кухню. Щойно зовнішній код почав напряму змінювати books, ви більше не можете гарантувати унікальність, сортування, коректність лічильників і взагалі будь-які правила. У результаті тип перестає бути «розумним» і стає просто мішком даних.

Помилка №2: Інкапсуляція лише «на словах», без підтримки компілятора.
Іноді намагаються домовитися коментарем: «не чіпайте це поле», «використовуйте метод». Але Swift-компілятор не читає коментарі, він читає модифікатори доступу. Якщо поле не має використовуватися ззовні, воно має бути private (або хоча б internal усередині модуля), інакше це не правило, а побажання.

Помилка №3: Надмірне використання fileprivate замість акуратного private.
fileprivate здається зручним: «нехай увесь файл бачить усе». Але поступово файл розростається, туди додаються нові типи, і раптом вони починають користуватися чужими внутрішностями просто тому, що «можуть». Краще починати з private (межа оголошення) і розширювати доступ лише тоді, коли справді виникає потреба ділити хелпери на рівні файла. Різниця між private і fileprivate якраз у цьому: один ховає в межах оголошення, інший відкриває всьому файлу.

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

Помилка №5: Плутати «публічний тип» і «публічну можливість створити що завгодно».
Дуже часта пастка: ви робите public struct BookID, а потім автоматично робите public init(rawValue:) і дозволяєте створювати BookID(rawValue: ""). Потім ви додаєте валідацію і розумієте, що у вас уже є зовнішній код, який створює «порожні» ID. Набагато спокійніше одразу проєктувати точки створення: залишити init непублічним і дати одну-дві зрозумілі фабрики. Це і є інкапсуляція на рівні життєвого циклу об’єкта.

1
Опитування
SwiftPM Імпорт, рівень 49, лекція 4
Недоступний
SwiftPM Імпорт
Імпорт, залежності та рівні доступу
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ