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, він швидко перетворюється на роман Толстого (а ми хочемо романи читати, а не компілювати).
Зробімо два маленькі розширення для рядків:
- нормалізація команди (trim() + lowercase()),
- розбиття на «слова» за пробілами, але без порожніх шматочків.
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"? Тому що тип параметра a — Animal. Навіть якщо всередині реально лежить 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) |
| Як викликається | |
|
| Вибір реалізації | Може бути динамічним через 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‑метод із тією ж сигнатурою, він переможе.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ