Введение: «а где у вас static?»

«В Kotlin нет static — что вместо него?» — вопрос, который задают почти на каждом собеседовании. И ответ «companion object» — только половина правильного ответа. Вторая половина — что companion object статикой на самом деле не является, и от этого зависит куча вещей: от @JvmStatic до производительности.

До Java-переезда в Kotlin почти у всех болит голова от переноса статических методов. После переезда начинается второе разочарование: companion object — это не просто «static с другим синтаксисом», у него другая природа. Сегодня разберём, что это такое, как с ним работать и на чём сыпятся кандидаты. Всё по официальной документации Kotlin.

Что такое companion object на самом деле

Если коротко: companion object — это объект-одиночка, объявленный внутри класса. Ключевая фраза — «объект». В Kotlin есть object declarations: конструкция object Name { ... }, которая в одном месте объявляет класс и создаёт его единственный экземпляр — синглтон, причём с thread-safe инициализацией при первом обращении.

Companion object — тот же object, но вложенный в класс и помеченный ключевым словом companion:

User.kt
class User(val name: String) {
    companion object Factory {
        fun create(name: String): User = User(name)
    }
}

fun main() {
    val user = User.create("Аня") // вызов через имя класса
}

Зачем это нужно? Чтобы у класса были функции и свойства уровня класса, как в других языках — фабричные методы, константы, утилиты. В документации это описано так: companion object позволяет определять функции и свойства уровня класса.

И вот ключевой момент, который отличает сильного кандидата: члены companion object выглядят как static, но технически являются членами экземпляра companion object. У класса есть один экземпляр companion object, и его методы вызываются через него. Именно поэтому companion object может реализовывать интерфейсы — обычной статике такое недоступно:

Factory.kt
interface Factory<T> {
    fun create(name: String): T
}

class User(val name: String) {
    companion object : Factory<User> {
        override fun create(name: String): User = User(name)
    }
}

fun main() {
    val factory: Factory<User> = User   // класс как экземпляр Factory
    println(factory.create("Боря").name)
}

Обратите внимание: val factory: Factory<User> = User — имя класса само является ссылкой на companion object. Это работает и для именованных, и для безымянных companion.

Главное

Companion object — это синглтон, вложенный в класс. Члены выглядят как static, но являются членами объекта. Поэтому companion может реализовывать интерфейсы, и поэтому для Java-кода его нужно дополнительно раскрывать через @JvmStatic.

Именованный и безымянный companion

Имя companion object можно задать, а можно опустить. Если имя не задано, объект называется Companion:

Database.kt
class Database {
    companion object {
        const val VERSION = 1
        fun open(): Database = Database()
    }
}

fun main() {
    val db = Database.open()
    println(Database.VERSION)

    val companion = Database.Companion // явная ссылка на объект
}

Что важно знать про доступы:

  • Члены companion object вызываются через имя класса: Database.open().
  • Само имя класса — это ссылка на companion object: val ref = Database.
  • Члены класса могут обращаться к private членам своего companion object напрямую, и наоборот — companion видит private члены класса.
  • Companion object не может быть объявлен внутри функции (локальные object declarations запрещены), но может быть вложен в другой object.

Один нюанс, который любят спрашивать: const val работает только на верхнем уровне, в object или в companion object. Поэтому константы вроде VERSION — классический житель companion object. Требования те же, что и всегда: тип String или примитив, значение известно на этапе компиляции.

Companion object против Java static: @JvmStatic

Теперь самое интересное — Java-интероп. Если ваш Kotlin-класс вызывает код на Java (или его вызывают из Java — например, в проектах с легаси, или в тестах на Mockito), то Database.open() с точки зрения Java выглядит как Database.Companion.open(). Потому что на уровне байткода companion object — это обычный класс с полем Companion внутри Database.

Чтобы сгенерировать настоящие статические методы и поля, есть аннотация @JvmStatic для методов и @JvmField для свойств:

LegacyBridge.kt
class LegacyBridge {
    companion object {
        @JvmStatic
        fun open(): LegacyBridge = LegacyBridge()

        @JvmField
        val TAG = "LegacyBridge"
    }
}

// Из Java: LegacyBridge.open() — настоящий static-метод
// и LegacyBridge.TAG — настоящее static-поле

Это подтверждает и документация Kotlin: на JVM члены companion object можно сгенерировать как настоящие статические методы и поля с помощью @JvmStatic.

Ошибка-классика на собеседовании: «companion object — это и есть static из Java, они одинаковы». Нет. Без @JvmStatic Java-код видит Database.Companion.open(), а не Database.open(). Статическим член делает только аннотация — и она нужна исключительно для Java-интеропа.

А ещё companion object — популярный способ сделать фабрику. Противники и сторонники спорят, но факт: фабричный метод в companion — стандартный паттерн, который встречается в официальных примерах. И отдельно — companion object часто путают с обычным object-синглтоном. Разница простая: object — самостоятельный синглтон (логирование, репозиторий-заглушка), а companion — привязан к своему классу и существует один на класс.

Где кандидаты ошибаются

Соберу промахи, которые реально слышу на собеседованиях и вижу в коде.

  • «Companion object = static». Технически это экземпляр вложенного объекта. Static-члены на JVM создаёт только @JvmStatic / @JvmField.
  • «Имя класса и Companion — разные вещи». Нет: имя класса — это и есть ссылка на companion object.
  • «companion можно объявить внутри функции». Нельзя: object declarations не бывают локальными.
  • «const val можно в обычном классе». Нельзя — только top-level, object или companion object.
  • Путают object и companion. object — самостоятельный синглтон; companion — вложенный объект конкретного класса.
  • Забывают про private-доступ. Класс и его companion видят private-члены друг друга — это частая тема вопросов.

Ещё одна ошибка — злоупотребление: класть в companion object всё подряд, «чтобы не создавать объект». Тяжёлые инициализации в companion выполняются при первом обращении к нему — и могут неожиданно подтормозить старт. Это не значит, что companion плохой — просто константы и фабрики туда, а тяжёлое состояние — в отдельный object или DI-контейнер.

Два уровня ответа

Первый уровень: «companion object заменяет static». Второй, сильный: «это вложенный синглтон, члены которого вызываются через имя класса; для Java-кода нужен @JvmStatic; класс и companion видят private друг друга». На собеседовании засчитывается второй.

Итоги

Companion object — механизм, который выглядит как замена static, но устроен иначе. Понимание этой разницы — то, что отличает кандидата с заученными ответами от человека, который понимает, как язык устроен.

  • Природа: companion object — вложенный синглтон, члены которого вызываются через имя класса.
  • Гибкость: может быть именованным или безымянным, реализовывать интерфейсы, видеть private-члены класса.
  • Java-интероп: настоящую статику для Java дают @JvmStatic и @JvmField.

Главное

Ответ на собеседовании: «companion object — это объект-одиночка внутри класса для функций и свойств уровня класса. В отличие от Java static, это полноценный объект: он может реализовывать интерфейсы. Для вызова из Java как static-членов нужны аннотации @JvmStatic и @JvmField».

Если чувствуете, что в Kotlin есть темы, где ответ обрывается на первом же уточняющем вопросе, — не нужно перебирать статьи наугад. Напишите в Telegram, разберём вашу ситуацию и составим план, что закрыть в первую очередь.