JavaRush /Курси /Swift SELF /Рівні доступу у Swift

Рівні доступу у Swift

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

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. Швидкий компас по модифікаторах доступу

Коли ви пишете код, у вас рідко є час на філософію. Тому корисно мати «швидкий компас»: який модифікатор ставити за замовчуванням і коли підвищувати доступ.

Нижче — практична таблиця:

Модифікатор Де видно Коли це ваш вибір за замовчуванням
private
всередині оголошення (тип/extension/функція) коли це деталь реалізації конкретного типу або конкретної функції
fileprivate
всередині файла коли допоміжна функція потрібна кільком сутностям у файлі, але не має бути частиною API модуля
internal
у межах таргета базовий вибір для всього «неекспортованого» коду модуля
public
в інших модулях коли ви свідомо робите API модуля для використання ззовні
open
в інших модулях + можна наслідувати/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 має з’являтися лише за дуже усвідомленої потреби.

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