Объявления объектов и анонимные объекты

В Kotlin объекты позволяют определить класс и создать его экземпляр за один шаг. Это удобно, когда вам нужен либо многократно используемый экземпляр-синглтон, либо одноразовый объект. Для таких случаев Kotlin предоставляет два основных подхода: объявления объектов для создания синглтонов и анонимные объекты для создания одноразовых объектов без имени.

Синглтон гарантирует, что у класса есть только один экземпляр, и предоставляет глобальную точку доступа к нему.

Объявления объектов и анонимные объекты лучше всего подходят для следующих сценариев:

  • Использование синглтонов для общих ресурсов. Нужно гарантировать, что во всём приложении существует только один экземпляр класса. Например, при управлении пулом подключений к базе данных.
  • Создание фабричных методов. Нужен удобный способ эффективно создавать экземпляры. Вспомогательные объекты позволяют определять функции и свойства уровня класса, связанные с классом, что упрощает создание таких экземпляров и управление ими.
  • Временное изменение поведения существующего класса. Нужно изменить поведение существующего класса без создания нового подкласса. Например, добавить объекту временную функциональность для конкретной операции.
  • Необходимость типобезопасного дизайна. Нужны одноразовые реализации интерфейсов или абстрактных классов с помощью анонимных объектов. Это может пригодиться, например, для обработчика нажатия кнопки.

Объявления объектов (ориг.: Object declarations)

В Kotlin вы можете создавать единственные экземпляры объектов с помощью объявлений объектов, у которых после ключевого слова object всегда указывается имя. Это позволяет определить класс и создать его экземпляр за один шаг, что удобно для реализации синглтонов.

// Объявляет объект-синглтон для управления поставщиками данных
object DataProviderManager {
    private val providers = mutableListOf<DataProvider>()

    // Регистрирует нового поставщика данных
    fun registerDataProvider(provider: DataProvider) {
        providers.add(provider)
    }

    // Возвращает всех зарегистрированных поставщиков данных
    val allDataProviders: Collection<DataProvider>
        get() = providers
}

// Пример интерфейса поставщика данных
interface DataProvider {
    fun provideData(): String
}

// Пример реализации поставщика данных
class ExampleDataProvider : DataProvider {
    override fun provideData(): String {
        return "Example data"
    }
}

fun main() {
    // Создаёт экземпляр ExampleDataProvider
    val exampleProvider = ExampleDataProvider()

    // Чтобы обратиться к объекту, используйте его имя напрямую
    DataProviderManager.registerDataProvider(exampleProvider)

    // Получает и печатает всех поставщиков данных
    println(DataProviderManager.allDataProviders.map { it.provideData() })
    // [Example data]
}

Инициализация объявления объекта потокобезопасна и выполняется при первом доступе.

Чтобы обратиться к object, используйте его имя напрямую:

DataProviderManager.registerDataProvider(exampleProvider)

У объявлений объектов также могут быть супертипы, как и у анонимных объектов, которые могут наследоваться от существующих классов или реализовывать интерфейсы:

object DefaultListener : MouseAdapter() {
    override fun mouseClicked(e: MouseEvent) { ... }

    override fun mouseEntered(e: MouseEvent) { ... }
}

Как и объявления переменных, объявления объектов не являются выражениями, поэтому их нельзя использовать в правой части оператора присваивания:

// Синтаксическая ошибка: анонимный объект не может связывать имя
val myObject = object MySingleton {
    val name = "Singleton"
}

Объявления объектов не могут быть локальными, то есть их нельзя вкладывать непосредственно в функцию. Однако они могут быть вложены в другие объявления объектов или в классы, которые не являются inner.

Объекты данных (data object)

Когда в Kotlin печатается обычное объявление объекта, его строковое представление содержит имя и хеш object:

object MyObject

fun main() {
    println(MyObject)
    // MyObject@hashcode
}

Однако если пометить объявление объекта модификатором data, можно указать компилятору возвращать фактическое имя объекта при вызове toString(), как это работает для классов данных:

data object MyDataObject {
    val number: Int = 3
}

fun main() {
    println(MyDataObject)
    // MyDataObject
}

Кроме того, компилятор генерирует для вашего data object несколько функций:

  • toString() возвращает имя объекта данных;
  • equals()/hashCode() позволяют выполнять проверки равенства и использовать хеш-коллекции.

Для data object нельзя предоставить собственную реализацию equals или hashCode.

Функция equals() у data object гарантирует, что все объекты с типом вашего data object считаются равными. В большинстве случаев во время выполнения у вас будет только один экземпляр data object, потому что data object объявляет синглтон. Однако в пограничном случае, когда во время выполнения создаётся ещё один объект того же типа (например, с помощью платформенной рефлексии через java.lang.reflect или JVM-библиотеки сериализации, которая использует этот API внутренне), это гарантирует, что такие объекты будут считаться равными.

Сравнивайте data object только структурно (с помощью оператора ==) и никогда не сравнивайте их по ссылке (с помощью оператора ===). Это помогает избежать ошибок, когда во время выполнения существует больше одного экземпляра объекта данных.

import java.lang.reflect.Constructor

data object MySingleton

fun main() {
    val evilTwin = createInstanceViaReflection()

    println(MySingleton)
    // MySingleton

    println(evilTwin)
    // MySingleton

    // Даже когда библиотека принудительно создаёт второй экземпляр MySingleton,
    // его функция equals() возвращает true:
    println(MySingleton == evilTwin)
    // true

    // Не сравнивайте объекты данных с помощью ===
    println(MySingleton === evilTwin)
    // false
}

fun createInstanceViaReflection(): MySingleton {
    // Рефлексия Kotlin не разрешает создавать экземпляры объектов данных.
    // Здесь новый экземпляр MySingleton создаётся "принудительно" с помощью платформенной рефлексии Java.
    // Не делайте так в своём коде!
    return (MySingleton.javaClass.declaredConstructors[0].apply { isAccessible = true } as Constructor<MySingleton>).newInstance()
}

Поведение сгенерированной функции hashCode() согласовано с функцией equals(), поэтому у всех экземпляров data object, существующих во время выполнения, один и тот же хеш-код.

Различия между объектами данных и классами данных

Хотя объявления data object и data class часто используются вместе и в чём-то похожи, некоторые функции для data object не генерируются:

  • Нет функции copy(). Поскольку объявление data object предназначено для использования в качестве синглтона, функция copy() не генерируется. Синглтоны ограничивают создание экземпляров класса одним экземпляром, а возможность создавать копии этого экземпляра нарушила бы это ограничение.
  • Нет функций componentN(). В отличие от data class, у data object нет свойств данных. Так как попытка деструктурировать такой объект без свойств данных не имела бы смысла, функции componentN() не генерируются.

Использование объектов данных с изолированными иерархиями

Объявления data object особенно полезны для изолированных иерархий, например изолированных классов или интерфейсов. Они позволяют сохранить симметрию с классами данных, которые вы могли определить рядом с объектом.

В этом примере объявление EndOfFile как data object вместо обычного object означает, что объект получит функцию toString() без необходимости переопределять её вручную:

sealed interface ReadResult
data class Number(val number: Int) : ReadResult
data class Text(val text: String) : ReadResult
data object EndOfFile : ReadResult

fun main() {
    println(Number(7))
    // Number(number=7)
    println(EndOfFile)
    // EndOfFile
}

Вспомогательные объекты

Вспомогательные объекты позволяют определять функции и свойства уровня класса. Это упрощает создание фабричных методов, хранение констант и доступ к общим утилитам.

Объявление объекта внутри класса может быть отмечено ключевым словом companion:

class MyClass {
    companion object Factory {
        fun create(): MyClass = MyClass()
    }
}

Члены companion object можно вызывать, просто используя имя класса в качестве определителя:

class User(val name: String) {
    // Определяет вспомогательный объект, который выступает фабрикой для создания экземпляров User
    companion object Factory {
        fun create(name: String): User = User(name)
    }
}

fun main(){
    // Вызывает фабричный метод вспомогательного объекта, используя имя класса в качестве определителя.
    // Создаёт новый экземпляр User
    val userInstance = User.create("John Doe")
    println(userInstance.name)
    // John Doe
}

Имя companion object можно опустить. В этом случае используется имя Companion:

class User(val name: String) {
    // Определяет вспомогательный объект без имени
    companion object { }
}

// Обращается к вспомогательному объекту
val companionUser = User.Companion

Члены класса могут обращаться к private членам соответствующего companion object:

class User(val name: String) {
    companion object {
        private val defaultGreeting = "Hello"
    }

    fun sayHi() {
        println(defaultGreeting)
    }
}
User("Nick").sayHi()
// Hello

Когда имя класса используется само по себе, оно действует как ссылка на вспомогательный объект класса независимо от того, имеет этот объект имя или нет:

class User1 {
    // Определяет именованный вспомогательный объект
    companion object Named {
        fun show(): String = "User1's Named Companion Object"
    }
}

// Ссылается на вспомогательный объект User1 с помощью имени класса
val reference1 = User1

class User2 {
    // Определяет вспомогательный объект без имени
    companion object {
        fun show(): String = "User2's Companion Object"
    }
}

// Ссылается на вспомогательный объект User2 с помощью имени класса
val reference2 = User2

fun main() {
    // Вызывает функцию show() из вспомогательного объекта User1
    println(reference1.show())
    // User1's Named Companion Object

    // Вызывает функцию show() из вспомогательного объекта User2
    println(reference2.show())
    // User2's Companion Object
}

Хотя члены вспомогательных объектов в Kotlin выглядят как статические члены из других языков, на самом деле это члены экземпляра вспомогательного объекта, то есть они принадлежат самому объекту. Благодаря этому вспомогательные объекты могут реализовывать интерфейсы:

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

class User(val name: String) {
    // Определяет вспомогательный объект, который реализует интерфейс Factory
    companion object : Factory<User> {
        override fun create(name: String): User = User(name)
    }
}

fun main() {
    // Использует вспомогательный объект как Factory
    val userFactory: Factory<User> = User
    val newUser = userFactory.create("Example User")
    println(newUser.name)
    // Example User
}

Однако на JVM вы можете сделать так, чтобы члены вспомогательных объектов генерировались как настоящие статические методы и поля, если используете аннотацию @JvmStatic. Подробнее см. в разделе Совместимость с Java.

Анонимные объекты (ориг.: Object expressions)

Анонимные объекты объявляют класс и создают экземпляр этого класса, не задавая имени ни классу, ни экземпляру. Такие классы полезны для одноразового использования. Их можно создавать с нуля, наследовать от существующих классов или использовать для реализации интерфейсов. Экземпляры таких классов также называются анонимными объектами, потому что они определяются выражением, а не именем.

Создание анонимных объектов с нуля

Анонимные объекты начинаются с ключевого слова object.

Если объект не расширяет классы и не реализует интерфейсы, его члены можно определить непосредственно внутри фигурных скобок после ключевого слова object:

fun main() {
    val helloWorld = object {
        val hello = "Hello"
        val world = "World"
        // Анонимные объекты расширяют класс Any, в котором уже есть функция toString(),
        // поэтому её нужно переопределить
        override fun toString() = "$hello $world"
    }

    print(helloWorld)
    // Hello World
}

Наследование анонимных объектов от супертипов

Чтобы создать анонимный объект, наследующийся от какого-либо типа или типов, укажите этот тип после object и двоеточия (:). Затем реализуйте или переопределите члены этого класса так, как если бы вы наследовали от него:

window.addMouseListener(object : MouseAdapter() {
    override fun mouseClicked(e: MouseEvent) { /*...*/ }

    override fun mouseEntered(e: MouseEvent) { /*...*/ }
})

Если у супертипа есть конструктор, передайте в него соответствующие параметры. Несколько супертипов можно указать после двоеточия, разделяя их запятыми:

// Создаёт open-класс BankAccount со свойством balance
open class BankAccount(initialBalance: Int) {
    open val balance: Int = initialBalance
}

// Определяет интерфейс Transaction с функцией execute()
interface Transaction {
    fun execute()
}

// Функция для выполнения специальной транзакции с BankAccount
fun specialTransaction(account: BankAccount) {
    // Создаёт анонимный объект, который наследуется от класса BankAccount и реализует интерфейс Transaction
    // Баланс переданного счёта передаётся в конструктор суперкласса BankAccount
    val temporaryAccount = object : BankAccount(account.balance), Transaction {

        override val balance = account.balance + 500  // Временный бонус

        // Реализует функцию execute() из интерфейса Transaction
        override fun execute() {
            println("Executing special transaction. New balance is $balance.")
        }
    }
    // Выполняет транзакцию
    temporaryAccount.execute()
}

fun main() {
    // Создаёт BankAccount с начальным балансом 1000
    val myAccount = BankAccount(1000)
    // Выполняет специальную транзакцию для созданного счёта
    specialTransaction(myAccount)
    // Executing special transaction. New balance is 1500.
}

Использование анонимных объектов в качестве возвращаемых типов и типов значений

Когда вы возвращаете анонимный объект из локальной или private функции либо свойства, все члены этого анонимного объекта доступны через эту функцию или свойство:

class UserPreferences {
    private fun getPreferences() = object {
        val theme: String = "Dark"
        val fontSize: Int = 14
    }

    fun printPreferences() {
        val preferences = getPreferences()
        println("Theme: ${preferences.theme}, Font Size: ${preferences.fontSize}")
    }
}

fun main() {
    val userPreferences = UserPreferences()
    userPreferences.printPreferences()
    // Theme: Dark, Font Size: 14
}

Это позволяет возвращать анонимный объект с конкретными свойствами и даёт простой способ инкапсулировать данные или поведение без создания отдельного класса.

Если функция или свойство, возвращающие анонимный объект, имеют видимость public, protected или internal, их фактический тип:

  • Any, если у анонимного объекта нет объявленного супертипа;
  • объявленный супертип анонимного объекта, если существует ровно один такой тип;
  • явно объявленный тип, если объявленных супертипов больше одного.

Во всех этих случаях члены, добавленные в анонимный объект, недоступны. Переопределённые члены доступны, если они объявлены в фактическом типе функции или свойства. Например:

interface Notification {
    // Объявляет notifyUser() в интерфейсе Notification
    fun notifyUser()
}

interface DetailedNotification

class NotificationManager {
    // Возвращаемый тип - Any. Свойство message недоступно.
    // Когда возвращаемый тип - Any, доступны только члены класса Any.
    fun getNotification() = object {
        val message: String = "General notification"
    }

    // Возвращаемый тип - Notification, потому что анонимный объект реализует только один интерфейс.
    // Функция notifyUser() доступна, потому что она является частью интерфейса Notification.
    // Свойство message недоступно, потому что оно не объявлено в интерфейсе Notification.
    fun getEmailNotification() = object : Notification {
        override fun notifyUser() {
            println("Sending email notification")
        }
        val message: String = "You've got mail!"
    }

    // Возвращаемый тип - DetailedNotification. Функция notifyUser() и свойство message недоступны.
    // Доступны только члены, объявленные в интерфейсе DetailedNotification.
    fun getDetailedNotification(): DetailedNotification = object : Notification, DetailedNotification {
        override fun notifyUser() {
            println("Sending detailed notification")
        }
        val message: String = "Detailed message content"
    }
}

fun main() {
    // Этот код ничего не выводит
    val notificationManager = NotificationManager()

    // Свойство message здесь недоступно, потому что возвращаемый тип - Any
    // Этот код ничего не выводит
    val notification = notificationManager.getNotification()

    // Функция notifyUser() доступна.
    // Свойство message здесь недоступно, потому что возвращаемый тип - Notification.
    val emailNotification = notificationManager.getEmailNotification()
    emailNotification.notifyUser()
    // Sending email notification

    // Функция notifyUser() и свойство message здесь недоступны, потому что возвращаемый тип - DetailedNotification.
    // Этот код ничего не выводит
    val detailedNotification = notificationManager.getDetailedNotification()
}

Доступ к переменным из анонимных объектов

Код внутри тела анонимного объекта может обращаться к переменным из окружающей области видимости:

import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent

fun countClicks(window: JComponent) {
    var clickCount = 0
    var enterCount = 0

    // MouseAdapter предоставляет реализации по умолчанию для функций событий мыши
    // Имитирует обработку событий мыши с помощью MouseAdapter
    window.addMouseListener(object : MouseAdapter() {
        override fun mouseClicked(e: MouseEvent) {
            clickCount++
        }

        override fun mouseEntered(e: MouseEvent) {
            enterCount++
        }
    })
    // Переменные clickCount и enterCount доступны внутри анонимного объекта
}

Различия в поведении объявлений объектов и анонимных объектов

Между объявлениями объектов и анонимными объектами есть различия в поведении при инициализации:

  • анонимные объекты выполняются и инициализируются сразу в месте использования;
  • объявления объектов инициализируются лениво, при первом доступе;
  • вспомогательный объект инициализируется, когда соответствующий класс загружается (разрешается), что соответствует семантике статического инициализатора Java.