object, companion object, object : ... — одиночные и одноразовые объекты

Kotlin SELF
37 уровень , 4 лекция
Открыта

1. Три формы object в Kotlin

Слово «объект» в Kotlin звучит так, будто сейчас мы откроем портал в ООП‑вселенные, где всё — объект, включая ваш утренний кофе и чувство тревоги перед дедлайном. На практике под словом object в Kotlin скрываются три разные конструкции, и каждая решает свою бытовую проблему программиста: «хочу один экземпляр», «хочу API через имя класса», «хочу реализацию прямо здесь, без отдельного файла».

Чтобы было проще ориентироваться, держите в голове такую таблицу (её стоит перечитать через пару дней — она внезапно становится очень очевидной):

Конструкция Что создаёт Сколько экземпляров Как обращаться Когда уместно
object Foo { ... }
одиночный объект (singleton) 1 на программу
Foo.doSomething()
общий сервис/утилита/команда без состояния или с маленьким состоянием
class A { companion object { ... } }
объект, «приклеенный» к классу 1 на класс
A.create()
фабрики, константы, парсинг, «статические» методы
object : X { ... }
анонимный объект (одноразовый) обычно 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 { ... }"]

Контрольные вопросы

  1. Чем object Foo отличается от class Foo и почему нельзя написать Foo() для object?
  2. В каких случаях companion object читается лучше, чем набор top‑level функций?
  3. Почему Kotlin «теряет» уникальные методы анонимного объекта, если сохранить его в переменную типа Any?
  4. Приведите пример, где 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 особенно хорош там, где операции логично относятся к типу: создание, парсинг, константы и фабрики. Всё остальное лучше держать как отдельные сущности с понятными именами.

1
Задача
Kotlin SELF, 37 уровень, 4 лекция
Недоступна
Единый лог
Единый лог
1
Задача
Kotlin SELF, 37 уровень, 4 лекция
Недоступна
Счётчик визитов
Счётчик визитов
1
Задача
Kotlin SELF, 37 уровень, 4 лекция
Недоступна
Фабрика токена
Фабрика токена
1
Задача
Kotlin SELF, 37 уровень, 4 лекция
Недоступна
Парсер координат
Парсер координат
1
Опрос
Интерфейсы и объекты, 37 уровень, 4 лекция
Недоступен
Интерфейсы и объекты
Интерфейсы и объекты
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ