1. Навіщо потрібні рівні доступу
Якщо ви поки що пишете один файл, контроль доступу може здаватися чимось на кшталт: «Ну так, є private, щоб ховати змінні». Але щойно ви починаєте вести проєкт як продукт — навіть якщо це навчальний проєкт, — рівні доступу стають не прикрасою, а ременем безпеки. Вони захищають код від випадкового неправильного використання, у тому числі від вас самих через тиждень.
У Swift рівні доступу розв’язують дві пов’язані задачі. По-перше, вони обмежують доступ до деталей реалізації, щоб ви могли змінювати «нутрощі» без переписування всього проєкту. По-друге, вони фіксують контракт: що вважається публічним API модуля або типу. Ідея проста: чим меншою є поверхня публічного API, тим менше точок, які можна випадково зламати.
Межі видимості: оголошення → файл → модуль
Коли ви обираєте рівень доступу, корисно тримати в голові не п’ять ключових слів, а три межі, через які код намагається вийти назовні. Саме ці межі й відрізняють private, fileprivate, internal, public/open.
Уявімо це як карту:
flowchart LR
A["Оголошення (тип / розширення / функція)"] --> B["Файл (File.swift)"]
B --> C["Модуль (SwiftPM target)"]
C --> D["Інші модулі / зовнішні клієнти"]
Сенс простий: чим «ширшим» є рівень доступу, тим далі за стрілкою може дістатися використання вашого коду.
У Swift у межах сьогоднішньої лекції нас цікавлять такі рівні: private, fileprivate, internal, public, open. Їхній зміст легко звести до формули «видно в межах X», де X — це область оголошення, файл або модуль. Ця модель історично закріплена й описується так: public — видно поза межами модуля, internal — усередині модуля, fileprivate — усередині файла, private — усередині оголошення.
2. Рівні доступу: від private до open
internal за замовчуванням
Майже кожен новачок хоча б раз натрапляє на ті самі граблі: «Я не вказав жодного модифікатора — чому це не public?» Swift за замовчуванням поводиться досить консервативно: якщо ви не вказали рівень доступу, то він internal.
internal означає: «цим можна користуватися з будь-якого файла всередині поточного модуля (таргета), але не з інших модулів».
Саме це дуже корисно для навчального проєкту: поки ви не готові брати на себе зовнішні зобов’язання, Swift не дасть вам випадково винести назовні половину проєкту.
Мініприклад:
import Foundation
struct Defaults { // internal за замовчуванням
let timeoutSeconds = 10 // internal за замовчуванням
}
func showDefaults() { // internal за замовчуванням
let d = Defaults()
print(d.timeoutSeconds) // 10
}
Навіть якщо цей код лежить у таргеті Domain, він доступний лише всередині Domain. У таргеті LibraryCLI ви не зможете його використати, доки не зробите типи або функції public (або доки не почнете використовувати @testable import у тестах — але це окрема історія, не сьогодні).
private: тільки всередині оголошення
Коли ви пишете private, ви говорите: «Це деталь реалізації, не чіпайте». Найважливіша думка тут: private у Swift — це лексична область. Тобто видимість обмежується поточним оголошенням (типом, функцією, extension), а не всім файлом.
Таку семантику часто формулюють простіше: private — символ видно всередині поточного оголошення.
Простий приклад: сховаємо «сирий рядок токена» всередині типу.
import Foundation
struct AuthToken {
private let raw: String
init(raw: String) {
self.raw = raw
}
func masked() -> String {
"\(raw.prefix(3))***" // доступ є: ми всередині AuthToken
}
}
func debugPrintToken(_ token: AuthToken) {
// print(token.raw) // ❌ помилка: 'raw' є недоступним через рівень захисту 'private'
print(token.masked())
}
Тут raw захищений від зовнішнього доступу. І це не заради краси: якщо ви пізніше зміните спосіб зберігання токена (наприклад, почнете зберігати його у вигляді Data або в зашифрованому вигляді), зовнішній код не зламається, бо він і не мав права лізти в raw.
private і розширення в одному файлі
Історично в Swift були складнощі з тим, що private у типі не був доступний у extension цього типу. Але зараз (уже в багатьох версіях) діє правило: розширення одного типу в одному файлі вважаються одним простором для private. Це зроблено спеціально, щоб можна було красиво групувати код за розширеннями й не перетворювати все на fileprivate.
Покажемо ідею на прикладі, який близький до нашого CLI: форматування рядка виведення.
import Foundation
struct OutputFormatter {
func format(title: String) -> String {
"\(bullet()) \(title)"
}
private func bullet() -> String {
"•"
}
}
extension OutputFormatter {
func formatWarning(_ message: String) -> String {
"\(bullet()) ПОПЕРЕДЖЕННЯ: \(message)" // bullet() доступний: extension у тому самому файлі
}
}
На практиці це означає: private діє всередині типу та його розширень у цьому файлі. Це дуже зручний спосіб тримати «публічний фасад» і «внутрішні дрібниці» поруч, не розкриваючи їх назовні.
fileprivate: всередині файла
fileprivate розширює доступ ще трохи далі: символ видно в будь-якому місці файла, але не за його межами. Саме так зазвичай і визначають: fileprivate — видимість у межах поточного файла.
Навіщо це може знадобитися? Уявіть, що в одному файлі лежать:
- кілька типів,
- кілька верхньорівневих функцій-помічників,
- і ви хочете, щоб вони ділили між собою одну утиліту,
- але не хочете відкривати її для всього модуля.
Наприклад, у LibraryCLI у нас може бути файл TextTable.swift, де живуть дрібні функції форматування. Нехай одна функція потрібна двом різним типам у цьому самому файлі.
import Foundation
fileprivate func padRight(_ s: String, to width: Int) -> String {
if s.count >= width { return s }
return s + String(repeating: " ", count: width - s.count)
}
struct Row {
let key: String
let value: String
func render() -> String {
"\(padRight(key, to: 12)): \(value)" // доступно
}
}
struct Header {
let title: String
func render() -> String {
padRight(title.uppercased(), to: 20) // доступно
}
}
Якби padRight був private, він був би видимий лише всередині одного оголошення, і Row не зміг би його використати. Якщо зробити internal, ми відкриємо функцію для всього таргета, і її почнуть викликати де завгодно. fileprivate — компроміс: функція доступна рівно там, де лежить її реалізація.
У реальних проєктах fileprivate намагаються використовувати рідко. І це не снобізм: просто fileprivate часто стає костилем, якщо ви переплутали «спільну допоміжну функцію для файла» з «допоміжним методом типу». Якщо допоміжна функція логічно належить типу, частіше краще зробити її private-методом, а не fileprivate-функцією.
public: видно ззовні модуля
public означає: тип, функція або властивість видно з інших модулів. У визначеннях це зазвичай формулюють так: public — символ видно поза межами поточного модуля.
Тут починається найцікавіше: щойно ви пишете public, ви ніби підписуєте контракт — «Ось цим можна користуватися, і я намагатимуся не ламати це без потреби».
Для нашого проєкту це означає: Domain має мати невеликий набір public-сутностей, якими користується LibraryCLI. Усе інше — internal або private.
Важливий момент, який неочікувано ловить новачків: public struct не робить автоматично все всередині «публічним». Ви явно задаєте доступ кожному члену API: ініціалізатору, властивостям і методам. Інакше Swift застосує рівень за замовчуванням — зазвичай internal — і ви отримаєте «публічний тип, який ззовні не можна створити».
Приклад:
import Foundation
public struct BookID {
public let rawValue: String
public init(rawValue: String) {
self.rawValue = rawValue
}
}
Якщо init буде без public, то з іншого таргета ви не зможете написати BookID(rawValue: "123"). Це не баг: Swift вважає, що «публічність» має бути максимально явною.
open: лише для класів
open — це рівень доступу, який існує переважно заради наслідування. Він стосується лише класів і їхніх членів. Простіше кажучи: public означає «можна використовувати», а open — «можна використовувати й розширювати через наслідування/override з іншого модуля».
Чому так зроблено? Тому що наслідування — жорсткіший контракт, ніж просто виклик методів. Якщо ви дозволили зовнішнім модулям наслідувати ваш клас, ви повинні зважати на те, що зовнішній код може перевизначати поведінку, а ви — підтримувати сумісність із цим фактом.
Мініприклад:
import Foundation
open class BaseCommand {
public init() {}
open func run() {
print("BaseCommand.run()") // BaseCommand.run()
}
}
Якщо BaseCommand оголошений як public class, то інший модуль зможе створити об’єкт, але не зможе написати class MyCmd: BaseCommand { override func run() { ... } }. Для цього потрібен саме open.
Для нашого CLI-застосунку, де ми частіше використовуємо struct і композицію, open зазвичай не потрібен. Але розуміти його сенс важливо, щоб не ставити open «про всяк випадок».
Правило публічних сигнатур
Коли ви починаєте робити API публічним, Swift вмикає сувору логіку: якщо оголошення має вищий рівень доступу, воно не може використовувати у своїй сигнатурі типи з нижчим рівнем доступу. Це стосується параметрів, типу результату, а також типів властивостей.
Простіше кажучи, компілятор свариться, якщо public-функція використовує internal-тип у параметрі або результаті.
Це правило — не знущання, а захист здорового глузду. Якщо зовнішній клієнт бачить public func make() -> SecretType, але SecretType йому недоступний, то як він узагалі має користуватися результатом?
Побачимо цю помилку в маленькому прикладі (уявімо, що ми в таргеті Domain):
import Foundation
struct SecretParser { // internal
func parse(_ text: String) -> Int { Int(text) ?? 0 }
}
public func parsePublic(_ text: String) -> SecretParser {
// ❌ помилка: public function cannot return internal type 'SecretParser'
SecretParser()
}
Виправлення завжди одне з двох, і це корисно запам’ятати як розгалуження дизайну:
- Якщо тип справді має бути частиною публічного контракту, ви робите його public (і, можливо, ховаєте деталі всередині нього через private).
- Якщо тип не має стирчати назовні, ви перебудовуєте API так, щоб назовні виходили публічні типи: наприклад, повертаєте Int, String, Result<...> або інший public об’єкт-значення.
Наприклад, правильніше зробити так:
import Foundation
struct SecretParser {
func parse(_ text: String) -> Int { Int(text) ?? 0 }
}
public func parsePublic(_ text: String) -> Int {
SecretParser().parse(text)
}
Тепер назовні виходить простий Int, а парсер залишається внутрішньою деталлю реалізації.
4. Швидкий компас по модифікаторах доступу
Коли ви пишете код, у вас рідко є час на філософію. Тому корисно мати «швидкий компас»: який модифікатор ставити за замовчуванням і коли підвищувати доступ.
Нижче — практична таблиця:
| Модифікатор | Де видно | Коли це ваш вибір за замовчуванням |
|---|---|---|
|
всередині оголошення (тип/extension/функція) | коли це деталь реалізації конкретного типу або конкретної функції |
|
всередині файла | коли допоміжна функція потрібна кільком сутностям у файлі, але не має бути частиною API модуля |
|
у межах таргета | базовий вибір для всього «неекспортованого» коду модуля |
|
в інших модулях | коли ви свідомо робите API модуля для використання ззовні |
|
в інших модулях + можна наслідувати/override | коли ви свідомо робите клас «точкою розширення» для зовнішнього коду |
Ключовий стиль, який допомагає не потонути: починайте з internal (або взагалі без модифікатора), потім звужуйте до private, коли бачите, що це лише деталь. І лише якщо справді потрібно — розширюйте до public.
5. Практика в проєкті LibraryCLI
На рівні модулів
Зараз ми спробуємо застосувати правила не на абстрактних прикладах, а на нашому реальному навчальному застосунку. Нехай у нас є таргет Domain, а LibraryCLI має вміти додавати книги, шукати їх і друкувати.
З погляду контролю доступу ідеальна картина така: Domain експортує назовні мінімум. Наприклад, Book, BookID і, можливо, якийсь сервісний інтерфейс. Але зараз ми не ускладнюємо архітектуру сервісу — нам важливо саме те, що видно назовні.
Уявімо файл Sources/Domain/Book.swift:
import Foundation
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 BookID {
public let rawValue: String
public init(rawValue: String) {
self.rawValue = rawValue
}
}
Тепер LibraryCLI (в іншому таргеті) може зробити:
import Foundation
import Domain
func demo() {
let id = BookID(rawValue: "b-001")
let book = Book(id: id, title: "Swift без паніки")
print(book.title) // Swift без паніки
}
А ось допоміжні речі — нормалізатори рядків, внутрішні правила форматування, «чернеткові» парсери — залишаються internal або private, і LibraryCLI про них не знає. Це і є вузька поверхня API.
Мінісценарій: private vs fileprivate
До повноцінного парсингу CLI в наступних лекціях ми ще дійдемо, але базові речі у нас уже є: читаємо рядок, split, аналізуємо. Навіть у такому простому коді легко відчути різницю між private і fileprivate.
Уявімо файл Sources/LibraryCLI/CommandTokenizer.swift (один файл, кілька сутностей). Ми хочемо розділити рядок на токени, а потім відформатувати їх для налагодження.
import Foundation
fileprivate func normalizeSpaces(_ text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
}
struct CommandTokenizer {
func tokenize(_ line: String) -> [String] {
let cleaned = normalizeSpaces(line)
return cleaned.split(separator: " ").map(String.init)
}
}
struct DebugTokensPrinter {
func printTokens(_ tokens: [String]) {
print(tokens.joined(separator: "|"))
}
}
Якщо normalizeSpaces зробити private, то CommandTokenizer (як окремий тип) уже не зможе його викликати, бо private обмежений областю оголошення. Якщо зробити internal, то будь-який файл у таргеті LibraryCLI зможе використовувати normalizeSpaces, і він почне розповзатися по проєкту як «Ой, давайте й тут підріжемо пробіли». fileprivate дозволяє сказати: «У цьому файлі ми вважаємо нормалізацію частиною реалізації, але назовні її не віддаємо».
Звичка: читати сигнатури як контракт
Є приємний ефект, який з’являється, коли ви дисципліновано використовуєте рівні доступу. Ви починаєте бачити архітектуру, просто відкривши файл і пробігшись очима по модифікаторах.
Наприклад, якщо ви відкрили Domain і бачите, що все підряд public, то це тривожний дзвіночок: або ви справді будуєте бібліотеку для всього інтернету, або просто не встигли сховати нутрощі. Якщо ви бачите, що public-сутностей небагато, а все інше internal/private, значить модуль має зрозумілий вхід і менше шансів перетворитися на звалище.
У Swift це особливо важливо, бо компілятор справді допомагає вам тримати межі. Він не сподівається на вашу сумлінність: він робить так, що код або відповідає контракту, або не збирається.
6. Типові помилки під час роботи з рівнями доступу
Помилка №1: робити все public, щоб «не заважало компілюватися».
Таке бажання виникає, коли ви вперше розділили проєкт на таргети й раптом «усе зламалося». Найпростіший спосіб змусити проєкт зібратися — роздати public направо й наліво. Найгірший довгостроковий результат — ви більше не розумієте, що є API, а що є внутрішньою кухнею. Правильна звичка — починати з internal (дефолту) і розширювати доступ лише точково, коли зовнішній модуль справді має це використовувати.
Помилка №2: плутати private і fileprivate, а потім лікувати все fileprivate.
Дуже типовий шлях новачка: «Чому розширення не бачить мій private-метод? Гаразд, зроблю fileprivate». У результаті файл перетворюється на міні-модуль без меж, і будь-які сутності починають лазити одна до одної в кишені. Важливо пам’ятати, що private у сучасних версіях Swift нормально працює з розширеннями одного типу в одному файлі, і часто цього достатньо, щоб не розширювати доступ.
Помилка №3: оголосити public struct, але забути зробити public init і отримати «публічний тип, який не можна створити».
Це боляче вперше, бо виглядає як «Swift знущається». Але це логічно: ви явно вирішуєте, які члени входять до API. Якщо тип створюється ззовні — його ініціалізатор має бути публічним. Якщо не створюється — значить, можливо, він узагалі не має бути public, або має створюватися через фабричний метод (але це вже наступна лекція про інкапсуляцію та дизайн API).
Помилка №4: впиратися в помилку «public сигнатура використовує internal тип» і намагатися «обдурити компілятор».
Компілятор тут правий. Якщо публічна функція повертає або приймає внутрішній тип, зовнішній модуль не зможе коректно цим користуватися. Це не «синтаксична дрібниця», а порушення контракту. Майже завжди правильне рішення — або зробити тип публічним, якщо він є частиною API, або змінити сигнатуру так, щоб назовні виходив публічний тип чи простий тип, якщо цей тип є деталлю реалізації.
Помилка №5: ставити open, бо «звучить крутіше, ніж public».
open — не «найпублічніший public», а дозвіл зовнішньому коду наслідувати й перевизначати. Це набагато сильніша обіцянка, ніж просто «можна викликати метод». Якщо ви не будуєте бібліотеку з явними точками розширення, open вам майже напевно не потрібен. У нашому LibraryCLI ми частіше проєктуємо через struct і композицію, тому open має з’являтися лише за дуже усвідомленої потреби.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ