Пропустити до головного контенту

Область видимості

Тепер перейдемо до досить цікавої, але трішки складної теми — області видимості. Раніше ми розглядали змінні та функції, тому тепер варто розглянути випадки, коли функція або змінна може бути недоступна або навпаки доступна в деяких місцях. Область видимості (англ. scope) в програмуванні — важлива концепція, що визначає доступність змінних, функцій та інших сутностей. Ця концепція поділяє змінні, функції тощо на глобальні та локальні. Розгляньмо на прикладі:

fun foo() {
val a = 1
}
fun main() {
println(a + 1)
}

Даний код викличе помилку:

Unresolved reference: a

Що означає, що змінна створена у функції foo() недоступна у функції main(). Чому? У цьому конкретному випадку, a не може існувати й в теорії, тому що вона створюється при виклику функції foo, а вона не викликається. А якщо ми її викличемо?

fun foo() {
val a = 1
}
fun main() {
foo()
println(a + 1)
}

Тепер вона створена і, по-ідеї, програма повинна працювати, але як би не так, ми отримаємо ту ж помилку:

Unresolved reference: a

Річ в тім, що змінні видно тільки в місці створення і нижче за ієрархією. Нижче за ієрархію? Перепишемо наш код так щоб наша змінна була доступною:

var a = 0
fun foo() {
a = 1
}
fun main() {
foo()
println(a + 1)
}

(Для наочності змінюємо змінну під час виклику foo())

Подібне й означає «нижче за ієрархію». Функція, що використовує змінну a успадковує область видимості (aka scope) файлу, у якому її створили. І так працює із будь-яким місцем, де змінну створюють. Навіть у функції:

var c = 2
fun foo() {
var a = 2 // створюємо функції на рівні функції.
c = 4 // змінна, що знаходиться поза функцією (в файлі), доступна
fun bar() {
val b = a.pow(2) // змінна `a` доступна у функції `bar` через успадкування області видимості
a = b
}
bar() // функція доступна у місці її створення (декларування)
println(a) // змінна доступна у місці її створення
}

Так, так, у котліні можна навіть створювати функції у функціях, не дивуйтесь! Хоча зараз не про це. У цьому випадку, функція bar успадковує область видимості файлу, функції foo і так може тривати нескінченно. Взагалі фігурні дужки {} можна розглядати як оператор, який створює нову область видимості. Візуалізуймо те, як будується наша область видимості: scope

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

PS: Взагалі змінні, що створюються в функціях, називають - локальними змінними (функції ж, логічно, локальними функціями). А змінна, яку ми створювали поза функцією — «глобальною». Вона помітна скрізь, починаючи з того ж файлу, закінчуючи іншими. Закінчуючи іншими? Так само як ми створювали файл під ім'ям «Main», ми можемо створити будь-який інший файл. Як мінімум, щоб не тримати весь код в одному файлі. Це спростить навігацію у проєктах трошки складніших за ті, які ми робили раніше. А що буде, якщо створити ще один файл, у якому ми створимо деякі функції та змінні? Що ж, перевіримо:

// File: another.kt
val abc = 999_999_999
fun someFunction() {
println("someFunction()")
}

Перейшовши у файл «Main» і спробувавши викликати ці функції, на нас чекає успіх:

fun foo() {
val a = 2
println(a + abc) // отримуємо змінну з файла `another.kt`
}

Що це означає? А це означає те, що файл також, як і, наприклад функція, має дочірній scope (область видимості) і це деякі інші файли. Деякі інші файли? Не всі?

Річ у тім, що файли ідентифікуються не тільки за їхньою назвою, а й за їхнім пакетом. Пакет? Логічно припустити, що ніхто не мав на увазі поліетиленовий або якийсь інший пакет, а якийсь унікальний ідентифікатор. Що за унікальний ідентифікатор та навіщо він? Все для того ж, для чого створюються інші файли: для зручності. Потрібно ж розділяти та сортувати написаний код. З реальних прикладів, ви можете взяти системні теки типу Music, Videos, Images та інші, що містять інформацію тільки певної категорії. У Котліні подібна система категоризації коду, єдине, що відрізняється, — це термін (пакет). Власне, як і з системними теками, ми можемо робити структуру нашого проєкту поділяючи на якісь осмислені частини. Наприклад, для будь-яких математичних обчислень ми можемо створити такий пакет: math.calculations. У файловій структурі ми просто створюємо відповідні частині пакета (розділені точкою) теки: Тобто теку math, а в ній ще одну теку calculations. Після чого можна вже створювати наші файли з кодом. Наприклад створимо файл з функцією, яка вирішуватиме наступний вираз:

f(x)={2x2якщо x<0xякщо x150(x2)2якщо x>50<2001інакшеf(x) =\left\{ \begin{array}{ c l } 2x^2 & \quad \textrm{якщо } x < 0 \\ x & \quad \textrm{якщо } x \geq 1 \le 50 \\ (x \cdot 2)^2 & \quad \textrm{якщо } x > 50 < 200 \\ 1 & \quad \textrm{інакше} \end{array} \right.
// файл Function.kt
package math.calculations // автоматично додалось нашою IDE (ідентифікатор нашого файлу)
fun f(x: Double): Double {
return when {
x < 0 -> 2 * x.pow(2)
x >= 1 <= 50 -> x
x > 50 < 200 -> (x * 2).pow(2)
else -> 1
}
}

Як ви вже помітили, зверху у нас додався рядок коду з місцем нашого файлу. Він є обов'язковим, навіть якщо ви помістили його у відповідну теку. Це тому, що Kotlin допускає вказівку пакета вільно (тобто ви можете не створювати файлову структуру, що відповідатиме пакету). Це робиться у нескладних проєктах, де 8-10 файлів та проблем з навігацією немає, але я вам рекомендую завжди створювати відповідну файлову структуру. Що ж, перейдемо до виклику нашої функції:

// файл Main.kt
fun main() {
println(f(1.0))
}

По-ідеї має працювати, але запустивши ми отримаємо наступну помилку:

Unresolved reference: foo

Річ у тому, що за замовчуванням, область видимості обмежується поточним пакетом (у нашому випадку хоч він і відсутній, але він такий же самий ідентифікатор, навіть якщо він і порожній). Для того, щоб отримати щось з іншої області видимості (aka пакета), потрібно для початку «імпортувати» ідентифікатор. "Імпорт" роблять за допомогою ключового слова import. Він завжди повинен вказуватись зверху, відразу після пакета (ну або за його відсутності, просто зверху). Схема імпорту така:

import [пакет].[ідентифікатор]

Тобто, щоб викликати функцію f(x: Double), нам потрібно зробити наступне:

// файл Main.kt
import math.calculations.f

fun main() {
println(f(1.0))
}

І у нас все запуститися успішно! Але якщо без імпортування ідентифікаторів інших пакетів не видно, то чи можна створювати дублікати назв? Так, ви можете створювати дублікати імен, за винятком ситуацій, коли ви намагаєтеся створити однаковий ідентифікатор в одному конкретному скоупі (області видимості). Тобто наступне заборонено:

fun main() {
val a = 1 // Conflicting declarations: val a: Int, val a: Int
println(a)
val a = 2 // Conflicting declarations: val a: Int, val a: Int
println()
}

Але, таке можливо:

val a = 1

fun main() {
println(a)
val a = 2 // але то так не робіть))
println(a)
}

Це все тому, що пріоритетним простором імен (з нашими ідентифікаторами) є поточна область видимості (aka скоуп). Це все тому, що функція (або будь-яке інше місце) — це новий незалежний скоуп (область видимості). Ми не можемо бути впевнені, що рано чи пізно ми не імпортуємо якусь змінну або не оголосимо таку ж у цьому ж файлі. А вигадувати нові імена не зробить код простіше, а тільки зробить його складнішим. До речі, варто зазначити, що створення дублікатів в одному пакеті неможливе.

Для котліна файл не є незалежною структурною одиницею і вона існує тільки у вашій структурі. Згадайте приклад з функцією в пакеті math.calculations, чи ми вказуємо конкретний файл при виклику функції або її імпорті? Ні. Тому дублікати в одному пакеті й неможливі, оскільки визначити конкретний ідентифікатор з файлу неможливо. Що ж, для закріплення, візуалізуємо все те, що ми обговорили вище: usage У нас є проєкт з двома унікальними пакетами: math.calculations та батьківським (порожнім). Файл "Main" зав'язаний на функцію f(x: Double) у пакеті math.calculations (ми це виділили лінією для візуалізації). Що ж, підіб'ємо проміжний підсумок:

  • Програма поділяється на різні області видимості (скоупи), які мають чітку ієрархію залежно від того, де та що ви створюєте.
  • Ієрархія зазвичай така: область видимості на рівні пакета -> область видимості на рівні декларації (функції, наприклад) -> тощо (наприклад вкладені функції або умовні оператори).
  • Батьківською областю видимості є пакет, у якому є наш ідентифікатор (функція, змінна). Ідентифікатори інших пакетів не видно за замовчуванням.
  • При необхідності можна розширити простір імен (ідентифікатори, що видно в іншій області видимості) за допомогою імпорту (import [пакет].[ідентифікатор]).

Модифікатори видимості

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

f(x)={x2якщо x<0a(x)інакшеf(x) =\left\{ \begin{array}{ c l } x^2 & \quad \textrm{якщо } x < 0 \\ a(x) & \quad \textrm{інакше} \end{array} \right.

Функція a(x)a(x) у нас така:

a(x)={2xякщо x>0<2001інакшеa(x) =\left\{ \begin{array}{ c l } 2x & \quad \textrm{якщо } x > 0 < 200 \\ 1 & \quad \textrm{інакше} \end{array} \right.

На Kotlin нам треба написати наступне (в файлі math.calculations.Function):

fun f(x: Double): Double {
return if(x < 0) x.pow(2) else a(x)
}
fun a(x: Double): Double {
return if(x > 0 < 200) 2 * x else 1
}

Тепер же, викличемо функцію f(x)

fun main() {
val input = 2.0 // будь-яке число
println(f(input))
}

І на цьому наша програма, умовно, закінчена. Подивившись на функцію a(x: Double) ми можемо подумати про те, що вона використовується тільки у функції f(x: Double) і в принципі вона ніде окрім як у файлі 'Function.kt' не потрібна. Можна цю функцію просто ігнорувати в підказках і не імпортувати, однак, якщо таких функцій багато? Або нам потрібно сховати щось заради чогось іншого. Це очевидно, захаращує глобальний простір імен, навіть якщо він не імпортований. На допомогу до нас приходять модифікатори видимості! Модифікатори видимості — ключові слова, що описують те, де видно ідентифікатор. Для нашого випадку існує модифікатор private. Він показує, що функцію видно лише там, де її створили й нижче за ієрархією. Насправді формула створення тієї ж функції виглядає так:

[модифікатор-видимості] fun [назваФункції](параметр: Тип): Тип {...}

За умовчанням, до всіх декларацій (функцій, змінних та іншого) неявно застосовується модифікатор public (тобто публічний, видимий назовні). fun main() -> public fun main(). У нашому випадку, ми робимо таке:

private fun a(x: Double): Double {
return if(x > 0 < 200) 2 * x else 1
}

До речі, вказівка однакових приватних ідентифікаторів у одному пакеті, але різних файлах допускається, оскільки конфлікт просто неможливий.

Зі змінною буде так само:

private val a: Int = 0

Висновок

Початковий розгляд унікальності імен, створення змінних та функцій виявився не таким простим, як ви вже зрозуміли. Як я вже згадував раніше, ідентифікатор функції будується на наступних його властивостях – це ім'я та параметри. З урахуванням розглянутих тем: область видимості та модифікаторів видимості, ми їх також додаємо в унікальність ідентифікатора (зазвичай це називають сигнатурою). Та й те саме ми робимо зі змінною. Підсумковим варіантом ідентифікаторів у нас будуть:

  • Функція — модифікатор видимості + область видимості + ім'я + набір параметрів (відмінність у кількості чи типі).
  • Змінна – модифікатор видимості + область видимості + ім'я.

Бажано самому погратись із цим для більшого розуміння!