JavaRush /Курси /Kotlin SELF /Extension‑функції: синтаксис, receiver, видимість

Extension‑функції: синтаксис, receiver, видимість

Kotlin SELF
Рівень 38 , Лекція 0
Відкрита

1. Вступ

Якщо ви коли-небудь ловили себе на думці «чому я вдесяте пишу одне й те саме перетворення рядка?», то ви вже майже готові до розширень. Extension‑функції — це спосіб додати зручні «ніби методи» до вже наявних типів, не змінюючи їхній код. Це особливо корисно, коли тип чужий (стандартна бібліотека чи стороння бібліотека) і ви фізично не можете (та й не повинні) його правити.

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

Уявіть, що тип — це кавовий автомат в офісі. Автомат чужий, розкривати його не можна. Але ви можете наклеїти поруч на стіну «інструкцію швидкого приготування» і сказати колегам: «Робіть ось так». Автомат не змінився, зате користуватися ним стало зручніше. От extension — це та сама інструкція, тільки компілятор робить вигляд, ніби вона «вбудована» в автомат.

Щоб не йти в абстракції, відштовхуватимемося від міні‑застосунку в стилі CLI (консоль): припустімо, ми поступово робимо облік витрат. У нас є команди на кшталт add, list, total, і ми хочемо чисто й однаково обробляти введення користувача.

2. Синтаксис extension‑функції

У цьому розділі ми спокійно розберемося, який вигляд має extension‑функція в Kotlin і чому компілятор узагалі дозволяє такий синтаксис. Головна мета — перестати сприймати fun String.xxx() як щось надприродне. Це не «ламання мови», а дуже дисциплінований синтаксичний цукор. Він перетворюється на виклик звичайної функції — просто записаний читабельніше.

Синтаксис extension‑функції такий:

fun ReceiverType.functionName(params...): ReturnType {
    ...
}

Де ReceiverType — це тип, який ми «розширюємо». Сам клас ми не змінюємо, зате отримуємо виклик вигляду obj.functionName().

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

fun String.normalizeCommand(): String {
    return trim().lowercase()
}

fun main() {
    val raw = "   AdD   "
    println(raw.normalizeCommand()) // add
}

Тут normalizeCommand()звичайна функція, але завдяки запису fun String.normalizeCommand() її можна викликати як метод рядка.

Технічно (якщо спростити) компілятор сприймає це приблизно так: raw.normalizeCommand() перетворюється на normalizeCommand(raw). Тобто це просто приємніша форма запису: «передайте обʼєкт першим параметром».

3. Receiver: хто такий this усередині extension‑функції

Слово «receiver» звучить так, ніби ми збираємося підіймати мережевий сервер. Насправді все простіше. Receiver — це обʼєкт, на якому викликається extension‑функція. Усередині тіла функції цей обʼєкт доступний як this, так само як і в тілі звичайного методу класу. Саме тому extension‑функція відчувається як метод: усередині ви можете писати length, trim(), lowercase() без додаткових кваліфікаторів.

У документації Kotlin підкреслюють: розширення «завжди викликаються на receiver», а receiver type має збігатися з типом розширюваного класу або інтерфейсу.

Подивімося на приклад, де всередині extension‑функції ми використовуємо this явно (хоча зазвичай це не потрібно):

fun String.isBlankAfterTrim(): Boolean {
    val prepared = this.trim()
    return prepared.isEmpty()
}

fun main() {
    println("   ".isBlankAfterTrim()) // true
    println("  hi ".isBlankAfterTrim()) // false
}

Так, this тут — це вихідний рядок. Це не «особливий this», не новий обʼєкт і не копія. Це той самий receiver.

І тут зʼявляється важливий практичний висновок: extension‑функції чудово підходять для «маленьких операцій над обʼєктом», коли ви хочете дати їм влучне імʼя й не розмазувати логіку по коду.

4. Extension‑функції в CLI: прибираємо шум із main

Зараз буде один із найприємніших ефектів розширень: вони роблять код сценарію (CLI) коротшим і зрозумілішим. У консольних застосунках багато «обвʼязки»: прочитати рядок, нормалізувати його, розібрати на частини, перевірити, чи команда взагалі існує. Якщо все це тримати просто в main, він швидко перетворюється на роман Толстого (а ми хочемо романи читати, а не компілювати).

Зробімо два маленькі розширення для рядків:

  1. нормалізація команди (trim() + lowercase()),
  2. розбиття на «слова» за пробілами, але без порожніх шматочків.
fun String.normalizeCommand(): String = trim().lowercase()

fun String.splitWords(): List<String> =
    trim().split(Regex("\\s+"))

fun main() {
    val input = "  ADD   coffee  250  "
    val words = input.normalizeCommand().splitWords()

    println(words) // [add, coffee, 250]
}

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

І ще один момент. У стандартній бібліотеці Kotlin уже повно extension‑функцій (наприклад, для колекцій), і це нормальна філософія мови: багато корисного API реалізовано саме через розширення.

5. Видимість: що extension‑функція може бачити

Зараз буде момент, який часто ламає очікування новачків: «раз це як метод, то я можу дістатися private-полів!». Не можна. І це добре, бо інакше ми б легко руйнували інкапсуляцію чужих класів. Extension‑функція не стає частиною класу, тому вона підпорядковується звичайним правилам видимості: бачить лише те, що видно «ззовні».

У документації Kotlin це сформульовано прямолінійно: extension, оголошена поза класом‑receiverʼом, не може отримати доступ до private/protected членів цього класу.

Приклад: не можна читати private поля receiverʼа

class User(private val password: String) {
    fun passwordLength(): Int = password.length
}

fun User.isSecure(): Boolean {
    // password тут недоступний, тому що він private:
    // return password.length >= 8

    return passwordLength() >= 8
}

fun main() {
    val u = User("supersecret")
    println(u.isSecure()) // true
}

Тут extension‑функція змушена користуватися публічним API (passwordLength()), а не лізти напряму в password. Так і має бути.

Тонкість: private на рівні файла доступний усередині того ж файла

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

У контексті нашого CLI це зручно: можна сховати регулярні вирази або допоміжні речі як private, а extension використовуватиме їх для акуратного API.

private val spaces = Regex("\\s+")

fun String.splitWords(): List<String> =
    trim().split(spaces)

fun main() {
    println("a   b    c".splitWords()) // [a, b, c]
}

Це акуратний спосіб не засмічувати зовнішній світ «службовими деталями», але водночас писати чисті розширення.

6. Як Kotlin вибирає extension‑функції

Нижче — кілька правил, які найчастіше визначають поведінку розширень у практичному коді: де їх тримати, як підʼєднувати, хто «переможе» за конфлікту імен і чому розширення не дають поліморфізму, як override.

Де тримати extensions і як підключати імпортом

Коли ви починаєте писати розширення, зʼявляється новий різновид хаосу: «у мене є файл Utils.kt, у ньому 400 розширень на все підряд». Майже традиція. Але ми спробуємо уникнути цього заздалегідь.

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

Мініілюстрація (схематично):

// Файл: cli/TextExtensions.kt
package app.cli

fun String.normalizeCommand(): String = trim().lowercase()
// Файл: cli/Main.kt
package app.cli

import app.cli.normalizeCommand

fun main() {
    println("  ADD ".normalizeCommand()) // add
}

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

Extension vs member function: хто переможе за однакового імені

Зараз важлива розвилка: extension‑функції викликаються як методи, але в Kotlin бувають і справжні методи (member functions). Що станеться, якщо в класу вже є метод із таким самим іменем?

Правило Kotlin дуже чітке: якщо збігаються імʼя й сигнатура, то пріоритет має member function, а extension програє.

class Example {
    fun printType() {
        println("Member")
    }
}

fun Example.printType() {
    println("Extension")
}

fun main() {
    Example().printType() // Member
}

Чому так? Бо інакше розширення могли б несподівано «перехоплювати» поведінку класів, особливо з бібліотек. Kotlin обрав передбачуваність: справжній метод завжди важливіший за «наліпку на кавомашину».

Але є приємна можливість: extension може перевантажувати метод, якщо сигнатура інша.

class Example {
    fun printType() {
        println("Member")
    }
}

fun Example.printType(index: Int) {
    println("Extension #$index")
}

fun main() {
    Example().printType()      // Member
    Example().printType(2)     // Extension #2
}

Для нашого CLI це означає: якщо ви розширюєте типи стандартної бібліотеки (String, List, Map), ви майже ніколи не «ламаєте» їхній API. Зазвичай ви або додаєте нові імена, або обережно робите перевантаження з іншою сигнатурою (але краще без фанатизму).

Статичний вибір: чому це не поліморфізм

Це центральна «пастка дня». Ми вже знаємо, що в ООП override вибирається за реальним типом обʼєкта під час виконання. Новачки за інерцією очікують того ж від розширень. Але Kotlin робить інакше: extension‑функції розвʼязуються статично, тобто за типом змінної на етапі компіляції, а не за фактичним обʼєктом.

open class Animal
class Dog : Animal()

fun Animal.kind(): String = "animal"
fun Dog.kind(): String = "dog"

fun printKind(a: Animal) {
    println(a.kind())
}

fun main() {
    val a: Animal = Dog()
    val d: Dog = Dog()

    printKind(a)        // animal
    println(d.kind())   // dog
}

Чому printKind(a) друкує "animal"? Тому що тип параметра aAnimal. Навіть якщо всередині реально лежить Dog, extension вибирається за типом змінної, а не за «начинкою».

Запамʼятати просто: extension‑функція не є частиною віртуальної таблиці методів (не бере участі в механізмі override). Це зовнішній синтаксичний цукор. Тому поліморфізм тут не вмикається.

У практичному коді це означає: не намагайтеся через розширення «поліморфно» обробляти ієрархії типів. Для цього існують звичайні методи й override (або when по sealed-ієрархії, але це інша історія — і сьогодні ми туди не йдемо).

Extension‑функції не замінюють override: демонстрація на «форматері»

Зараз зробимо приклад, який наочно показує різницю: override вибирається за реальним обʼєктом, а extension — за типом змінної.

Уявімо, що в нас є модель витрати:

data class Expense(
    val title: String,
    val cents: Int
)

Ми хочемо красиво друкувати витрату в консолі. Зробімо два форматери (через звичайні методи й override), а потім для контрасту додамо extension.

open class ExpenseFormatter {
    open fun format(e: Expense): String = "${e.title}: ${e.cents} центів"
}

class PrettyExpenseFormatter : ExpenseFormatter() {
    override fun format(e: Expense): String = "• ${e.title} — ${e.cents}¢"
}

fun main() {
    val f: ExpenseFormatter = PrettyExpenseFormatter()
    val e = Expense("Coffee", 250)

    println(f.format(e)) // • Coffee — 250¢
}

Тут усе як очікується: format() перевизначено, і під час виклику вибирається реалізація PrettyExpenseFormatter.

А тепер додамо extension на ExpenseFormatter (суто для демонстрації; у реальному проєкті так зазвичай робити не потрібно):

fun ExpenseFormatter.formatHeader(): String = "=== Витрати ==="

fun main() {
    val f: ExpenseFormatter = PrettyExpenseFormatter()
    println(f.formatHeader()) // === Витрати ===
}

Це працює, але важливо розуміти: formatHeader() не може бути «перевизначений» через наслідування так само, як звичайний метод, бо це не метод класу. Це зовнішня функція із синтаксисом методу.

Отже, якщо вам потрібна заміна поведінки залежно від типу — це override. Якщо вам потрібно додати зручну операцію «ззовні» — це extension.

Мінітаблиця: метод vs extension

Іноді корисно просто поставити два поняття поруч, щоб мозок перестав їх плутати. Receiver є в обох. Під час виклику вони виглядають однаково, але поводяться по-різному — і під час вибору, і щодо доступу.

Характеристика Звичайний метод класу (member function) Extension‑функція
Де оголошена Усередині класу Ззовні (зазвичай top-level)
Як викликається
obj.f()
obj.f()
Вибір реалізації Може бути динамічним через override Статичний: за типом змінної
Доступ до private членів receiverʼа Є (це ж частина класу) Немає (якщо extension ззовні класу)
За конфлікту імен Це і є джерело істини Програє member’у за тієї ж сигнатури

Цю табличку не треба вчити напамʼять. Достатньо тримати в голові два «якорі»: extension вибирається статично й не бачить private‑члени receiverʼа, якщо оголошена ззовні.

7. Типові помилки під час роботи з extension‑функціями

Помилка № 1: очікувати поліморфізм, як у override.
Дуже поширена пастка: написати fun Dog.kind() і думати, що під час виклику через Animal усе одно буде "dog". Але extension вибирається за оголошеним типом змінної (статично), а не за фактичним обʼєктом.

Помилка № 2: намагатися отримати доступ до private/protected членів класу‑receiverʼа.
Якщо extension оголошена поза класом, вона не стає «своєю людиною»: private‑члени залишаються приватними. Якщо вам потрібен доступ, отже класу потрібен публічний метод або властивість (або ви намагаєтеся розвʼязати задачу не там).

Помилка № 3: перетворювати extensions на сміттєвий ящик Utils.kt.
Коли розширень стає багато, зʼявляється спокуса складати їх «кудись». За місяць це перетворюється на археологічні розкопки: «де оголошено normalize() і чому їх три?». Краще тримати розширення поруч за змістом і підʼєднувати імпортом там, де вони справді використовуються.

Помилка № 4: давати розширенням надто загальні імена.
Імена на кшталт process(), handle() або doStuff() однаково погано виглядають і як методи, і як extensions. Але в розширень є додаткова проблема: вони читаються як «вбудована можливість типу». Якщо в рядка раптом зʼявляється process(), майбутній читач коду буде змушений шукати, що це взагалі робить. Хороші імена описують результат: normalizeCommand(), splitWords(), toMoneyLabel().

Помилка № 5: забувати про пріоритет member‑методів.
Іноді розробник пише extension «із тим самим іменем», очікуючи змінити поведінку класу, а потім дивується: «чому не викликається?». Якщо в класу вже є member‑метод із тією ж сигнатурою, він переможе.

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