1. Три форми object у Kotlin
Слово «об’єкт» у Kotlin звучить так, ніби ми ось-ось відкриємо портал в ООП-всесвіти, де все — об’єкт: і ваша ранкова кава, і відчуття тривоги перед дедлайном. Але на практиці під словом object у Kotlin ховаються три різні конструкції — і кожна розв’язує свою буденну проблему програміста: «хочу один екземпляр», «хочу API через імʼя класу», «хочу реалізацію прямо тут, без окремого файлу».
Щоб вам було простіше орієнтуватися, тримайте в голові таку таблицю (її варто перечитати за кілька днів: тоді вона раптово стає дуже очевидною):
| Конструкція | Що створює | Скільки екземплярів | Як звертатися | Коли доречно |
|---|---|---|---|---|
|
одиночний об’єкт (singleton) | 1 на програму | |
спільний сервіс/утиліта/команда без стану або з невеликим станом |
|
об’єкт, «приклеєний» до класу | 1 на клас | |
фабрики, константи, парсинг, «статичні» методи |
|
анонімний об’єкт (одноразовий) | зазвичай 1 «на місце» | збережіть у змінній/передайте у функцію | швидкі реалізації інтерфейсу «на місці» |
Якщо хочете дуже коротку аналогію: class — це креслення, Foo() — виготовлення деталі за кресленням, object Foo — «єдина деталь, яка вже лежить на столі», а object : Interface { ... } — «я зараз нашвидкуруч зберу штуку, і більше вона мені не знадобиться».
2. object-оголошення: один екземпляр і зрозуміле імʼя
Коли проєкт зростає, з’являється спокуса зробити «папку utils» і скидати туди все підряд — як у шухляду столу з дротами: ніби корисно, але потім нічого не знайти. object-оголошення — дисциплінованіший спосіб сказати: «ось ця сутність існує в єдиному екземплярі — і має імʼя». Це ідеальний інструмент для невеликих сервісів, конфігурації, простих реєстрів і, особливо, для команд у CLI-застосунках.
Мінімальний приклад: синглтон-утиліта
Почнемо з максимально простого. Уявімо, що в нашому навчальному CLI-застосунку (нехай це буде маленький трекер витрат) ми хочемо друкувати повідомлення в одному стилі: "INFO", "ERROR" тощо. Замість того щоб прокидати «принтер» через увесь код, можемо зробити один об’єкт.
object Console {
fun info(text: String) {
println("[INFO] $text") // [INFO] ...
}
fun error(text: String) {
println("[ERROR] $text") // [ERROR] ...
}
}
Використання виглядає так само просто:
fun main() {
Console.info("Застосунок запущено") // [INFO] Застосунок запущено
Console.error("Щось пішло не так") // [ERROR] Щось пішло не так
}
Зверніть увагу на важливу дрібницю: у Console немає конструктора. Ви не пишете Console() — об’єкт уже існує. У цьому й полягає сенс синглтона.
object може реалізовувати інтерфейси
У попередніх лекціях ми домовилися мислити контрактами. Продовжимо: у нашому трекері витрат можуть бути команди "help", "add", "list", "exit". Зручно описати це інтерфейсом.
interface Command {
val name: String
fun run(args: String): String
}
Тепер найприємніше: кожну команду можна оголосити як object, адже «команда help» не зобов’язана існувати у 20 екземплярах. Їй достатньо одного.
object HelpCommand : Command {
override val name: String = "help"
override fun run(args: String): String {
return "Команди: help, add, list, exit"
}
}
Виклик:
fun main() {
val text = HelpCommand.run("")
println(text) // Команди: help, add, list, exit
}
Такий стиль часто несподівано робить проєкт чистішим: «ось список команд, ось їхні реалізації». І так, це все ще звичайні об’єкти — їх можна складати в колекцію.
object і стан: можна, але обережно
Синглтони спокушають зберігати стан «десь глобально». Іноді це нормально (наприклад, лічильник ID для демонстраційного застосунку), але важливо пам’ятати: глобальний стан любить влаштовувати сюрпризи.
Зробімо простий генератор ID для витрат:
object IdGenerator {
private var next: Int = 1
fun newId(): Int {
val id = next
next += 1
return id
}
}
Перевіримо:
fun main() {
println(IdGenerator.newId()) // 1
println(IdGenerator.newId()) // 2
}
Це працює, але запам’ятайте відчуття: «магічний лічильник десь в одному місці». Якщо потім ви почнете тестувати код або перезапускати сценарії, можна несподівано отримати: «чому в мене ID починається з 57?». Це не баг Kotlin — це плата за глобальний стан.
object як вкладена сутність
object можна оголошувати не лише на верхньому рівні файлу, а й усередині класу (як вкладений синглтон). Це зручно, коли ви хочете «прибити» утиліту до конкретного типу, але не робити companion object.
Наприклад, якщо в нас є налаштування форматування:
class MoneyFormatter {
object Defaults {
const val currency: String = "USD"
}
}
І використання:
fun main() {
println(MoneyFormatter.Defaults.currency) // USD
}
Це виглядає трохи «багатослівно», але добре передає ідею: object допомагає групувати зміст поруч із місцем, де він потрібен.
3. companion object: API через імʼя класу
Якщо object-оголошення — це «один об’єкт сам по собі», то companion object — «один об’єкт, який живе поруч із класом і виглядає як частина класу». Історично це відповідь на вічний біль: у Java звикли до static, а в Kotlin ключового слова static немає. Натомість Kotlin пропонує: «хочете “методи класу” — тримайте companion object».
Константи та «статичні» налаштування
Почнемо з найпростішого: у companion object часто тримають константи та значення за замовчуванням.
class AppConfig {
companion object {
const val appName: String = "Expense Tracker"
const val defaultCurrency: String = "USD"
}
}
Використання:
fun main() {
println(AppConfig.appName) // Expense Tracker
println(AppConfig.defaultCurrency) // USD
}
Ви звертаєтеся до членів companion object через імʼя класу. І це саме те, чого зазвичай очікують від «статичних» речей: вони належать типу, а не конкретному екземпляру.
Фабричний метод: контролюємо створення об’єкта
Іноді ви хочете заборонити «створювати об’єкт як завгодно» й дозволити лише через акуратну функцію, яка нормалізує вхідні дані.
Уявімо, що в нас є Token і ми хочемо гарантувати, що в ньому немає пробілів по краях.
class Token private constructor(val value: String) {
companion object {
fun fromRaw(raw: String): Token {
return Token(raw.trim())
}
}
}
Використання:
fun main() {
val t = Token.fromRaw(" abc ")
println(t.value) // abc
}
Ключові моменти тут дуже «дорослі», хоча код і невеликий: ми сховали конструктор (private constructor) і дали єдиний офіційний шлях створення. Це дисциплінує код значно сильніше, ніж «ну просто не забувайте робити trim».
Парсинг рядків у модель
У CLI-застосунках ми постійно парсимо введення. companion object — чудове місце для таких функцій, бо парсинг стосується типу: «Expense уміє створюватися з рядка».
Зробімо мінімальну модель витрати:
data class Expense(val id: Int, val title: String, val cents: Long) {
companion object {
fun fromUserInput(raw: String, id: Int): Expense? {
val parts = raw.trim().split(";")
if (parts.size != 2) return null
return Expense(id, parts[0].trim(), parts[1].trim().toLongOrNull() ?: return null)
}
}
}
Перевіримо:
fun main() {
val e = Expense.fromUserInput("Coffee; 350", id = 1)
println(e) // Expense(id=1, title=Coffee, cents=350)
}
Тут важлива сама ідея: Expense.fromUserInput(...) виглядає як «спосіб класу створити екземпляр» і читається набагато краще, ніж якась зовнішня функція parseExpense(...), розкидана по проєкту.
4. object : ...: одноразова реалізація без окремого класу
Іноді ви розумієте: «мені потрібен об’єкт, який реалізує інтерфейс, але тільки тут — в одному місці». Створювати файл SomeStrategyImpl.kt заради шести рядків — це як купувати окремий холодильник заради одного йогурту (хоча, звісно, дехто так робить, але це вже інший курс).
Ось тут і з’являється object expression: object : Interface { ... }.
Реалізація інтерфейсу «на місці»
Візьмемо наш інтерфейс команди:
interface Command {
val name: String
fun run(args: String): String
}
Тепер створимо команду прямо в main, без окремого класу й без object SomeName:
fun main() {
val ping: Command = object : Command {
override val name: String = "ping"
override fun run(args: String): String = "pong"
}
println(ping.run("")) // pong
}
Це «одноразова команда»: вона існує рівно там, де ми її оголосили. Такий підхід добре підходить для тестів, прототипів, тимчасових заглушок і ситуацій «мені потрібна поведінка, але я ще не вирішив, як вона має виглядати в архітектурі».
Анонімний об’єкт може успадковуватися та реалізовувати кілька контрактів
Object expression дозволяє і успадкування від класу, і реалізацію інтерфейсу — усе в одному місці. Це корисно, коли ви хочете трохи перевизначити поведінку.
У нашому стилі (простішому й ближчому до практики) це виглядає так:
open class BaseLogger {
open fun log(text: String) = println(text) // ...
}
fun main() {
val logger = object : BaseLogger() {
override fun log(text: String) {
println("[DBG] $text") // [DBG] ...
}
}
logger.log("hello") // [DBG] hello
}
Тут ми не створювали class DebugLogger, бо він потрібен лише в одному місці.
Обмеження анонімних об’єктів: тип змінної «обрізає» доступ
Це тонкий момент, на якому часто спотикаються новачки: анонімний об’єкт може мати «унікальний» метод. Але якщо ви збережете його в змінній типу Any або інтерфейсу, то побачите лише те, що є в цьому типі.
fun main() {
val obj = object {
val x: Int = 10
fun msg(): String = "x=$x"
}
println(obj.msg()) // x=10
val asAny: Any = obj
println(asAny.toString()) // ... (а msg() уже не видно)
}
Це не «Kotlin вередує», а базове правило типізації: доступні члени визначаються типом змінної, а не тим, що «насправді лежить усередині».
5. Як вибрати форму об’єкта в застосунку
Коли в голові три конструкції, хочеться чарівного правила «завжди робіть так». Але в програмуванні чарівні правила зазвичай живуть поруч із чарівними багами. Тож краще триматися простої та зрозумілої логіки вибору.
Уявімо, що наш трекер витрат обробляє команди. Ми хочемо мати «постійні» команди ("help", "exit") і одну експериментальну ("ping"), яку поки не готові виносити в окремий файл. Тоді рішення може виглядати так: постійні команди — це object-оголошення, а експериментальна — object : ....
Ось «скелет» такого підходу:
import kotlin.system.exitProcess
object ExitCommand : Command {
override val name: String = "exit"
override fun run(args: String): String {
exitProcess(0)
}
}
І десь у main:
fun main() {
val ping = object : Command {
override val name: String = "ping"
override fun run(args: String): String = "pong"
}
println(ping.run("")) // pong
}
А companion object у цій історії чудово підходить для «створення з рядка» або «спільних констант типу». Наприклад, витрату ми можемо парсити через Expense.fromUserInput(...), і це читається як «офіційний шлях створення». Це той випадок, коли companion object допомагає не плодити глобальні функції parseXxx, які потім важко знайти — і ще важче підтримувати.
Якщо захочете уявити це як схему, вийде приблизно так:
flowchart TD
A["Потрібна поведінка"] --> B{"Скільки екземплярів потрібно?"}
B -->|Один на програму| C["object Foo { ... }"]
B -->|Прив’язано до класу, викликати через ім’я типу| D["companion object { ... }"]
B -->|Один раз в одному місці| E["object : Interface { ... }"]
Контрольні запитання
- Чим object Foo відрізняється від class Foo і чому не можна написати Foo() для object?
- У яких випадках companion object читається краще, ніж набір top-level функцій?
- Чому Kotlin «втрачає» унікальні методи анонімного об’єкта, якщо зберегти його в змінну типу Any?
- Наведіть приклад, де object : Interface { ... } доречніше, ніж окремий class.
6. Типові помилки під час роботи з object і companion object
Помилка №1: перетворювати object на «звалище всього підряд».
Дуже легко зробити object Utils і почати скидати туди і форматування дат, і парсинг, і мережеві виклики, і «тимчасовий костиль, який потім приберемо». У підсумку об’єкт стає не утилітою, а чорною дірою проєкту. Лікується це просто: у object має бути чітке, вузьке призначення та нормальне імʼя (Console, IdGenerator, HelpCommand), а не «все про все».
Помилка №2: зберігати багато змінного стану в синглтоні без потреби.
object виглядає як зручне місце для лічильників, кешів і «останнього введеного значення». Проблема в тому, що глобальний стан ламає передбачуваність: поведінка функції починає залежати від того, що було раніше. У навчальних прикладах (як-от IdGenerator) це допустимо, але в реальному коді краще мінімізувати var у синглтонах і тримати стан або незмінним, або дуже малим і контрольованим.
Помилка №3: плутати object Foo і object : Foo.
object Foo — це іменований одиночний об’єкт, до якого ви звертаєтеся за імʼям (Foo.doIt()). object : Foo { ... } — це анонімний об’єкт «на місці», який найчастіше не має імені й існує як значення виразу. Плутанина призводить до дивних запитань на кшталт «чому я не можу імпортувати object : ...?» — бо імпортувати можна імʼя, а не фрагмент виразу.
Помилка №4: додавати в анонімний об’єкт «унікальні» методи, а потім втрачати до них доступ через тип змінної.
Частий сценарій: ви зробили val x = object { fun debug() {} }, потім передали x кудись як Any, і раптово debug() зник. Це очікувано: доступ визначається статичним типом. Якщо вам справді потрібен унікальний метод, значить, потрібен інтерфейс або клас із цим методом, а не анонімний об’єкт «без контракту».
Помилка №5: робити все через companion object, навіть коли це погіршує читабельність.
Іноді companion object починають використовувати як заміну модулю утиліт: «нехай усе буде всередині класу». У результаті ExpenseParser раптово живе в Expense.Companion, Console ховається в App.Companion, і навігація кодом ускладнюється. companion object особливо добрий там, де операції логічно належать типу: створення, парсинг, константи та фабрики. Усе інше краще тримати як окремі сутності зі зрозумілими назвами.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ