1. Три формы object в Kotlin
Слово «объект» в Kotlin звучит так, будто сейчас мы откроем портал в ООП‑вселенные, где всё — объект, включая ваш утренний кофе и чувство тревоги перед дедлайном. На практике под словом object в Kotlin скрываются три разные конструкции, и каждая решает свою бытовую проблему программиста: «хочу один экземпляр», «хочу API через имя класса», «хочу реализацию прямо здесь, без отдельного файла».
Чтобы было проще ориентироваться, держите в голове такую таблицу (её стоит перечитать через пару дней — она внезапно становится очень очевидной):
| Конструкция | Что создаёт | Сколько экземпляров | Как обращаться | Когда уместно |
|---|---|---|---|---|
|
одиночный объект (singleton) | 1 на программу | |
общий сервис/утилита/команда без состояния или с маленьким состоянием |
|
объект, «приклеенный» к классу | 1 на класс | |
фабрики, константы, парсинг, «статические» методы |
|
анонимный объект (одноразовый) | обычно 1 «на место» | храните в переменной/передайте в функцию | быстрые реализации интерфейса «на месте» |
Если хотите очень короткую аналогию: class — это чертёж, Foo() — изготовление детали по чертежу, object Foo — «единственная деталь, которая уже лежит на столе», а object : Interface { ... } — «я сейчас на коленке соберу штуку, и больше она мне не нужна».
2. object-объявление: один экземпляр и понятное имя
Когда проект растёт, появляется соблазн сделать «папку utils» и туда складывать всё подряд, как в ящик стола с проводами: вроде полезно, но потом ничего не найти. object-объявление — это более дисциплинированный способ сказать: «вот эта сущность существует в единственном экземпляре, и у неё есть имя». Это идеальный инструмент для небольших сервисов, конфигурации, простых реестров и, особенно, для команд в CLI‑приложениях.
Минимальный пример: singleton-утилита
Начнём с максимально простого. Представим, что в нашем учебном 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() — объект уже существует. Это и есть смысл singleton‑формы.
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 можно объявлять не только на верхнем уровне файла, но и внутри класса (как вложенный singleton). Это удобно, когда хотите «прибить» утилиту к конкретному типу, но не делать 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 ради 6 строк — это как покупать отдельный холодильник ради одного йогурта (хотя, конечно, некоторые так делают, но это уже другой курс).
Вот тут и появляется 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: хранить много изменяемого состояния в singleton’е без необходимости.
object выглядит как удобное место для счётчиков, кешей и «последнего введённого значения». Проблема в том, что глобальное состояние ломает предсказуемость: поведение функции начинает зависеть от того, что было раньше. В учебных примерах (типа IdGenerator) это допустимо, но в реальном коде лучше минимизировать var в singleton’ах и держать их либо неизменяемыми, либо очень маленькими и контролируемыми.
Ошибка №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 особенно хорош там, где операции логично относятся к типу: создание, парсинг, константы и фабрики. Всё остальное лучше держать как отдельные сущности с понятными именами.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ