← назад к разделу

В Java значительную часть кода занимают церемонии вокруг классов: поля, геттеры, сеттеры, конструкторы, equals/hashCode, статические фабрики. Kotlin сокращает это в разы — но не магией, а другими конструкциями по умолчанию. Разберём четыре: свойства, data classes, sealed-иерархии и object.

Обязательно

Свойства вместо полей

class Account(val id: String, var balance: BigDecimal)

Одна строка объявляет класс, конструктор и два свойства: id — только чтение, balance — чтение и запись. Обращение — как к полю (account.balance), но под капотом это геттер/сеттер, и их можно написать свои, не меняя вызывающий код.

Тут есть шаг, который легко пропустить. Свойству, объявленному прямо в первичном конструкторе, свой аксессор дописать некуда — в конструкторе для тела сеттера нет места. Поэтому значение принимают обычным параметром, а свойство объявляют в теле класса:

class Account(val id: String, initialBalance: BigDecimal) {

    var balance: BigDecimal = initialBalance
        set(value) {
            require(value >= BigDecimal.ZERO) { "Баланс не может быть отрицательным" }
            field = value
        }
}

initialBalance здесь без val — это параметр конструктора, а не свойство: снаружи его не видно, он нужен только чтобы задать начальное значение.

Классы по умолчанию final — наследоваться нельзя, пока не написано open. Это перевёрнутое java-умолчание: расширяемость — осознанное решение, а не побочный эффект. Вторую половину того же правила замечают позже и больнее: методы тоже final по умолчанию, и open на классе их не открывает — переопределяемому методу нужен свой open. Для Spring это создаёт нюанс с прокси — решается плагином.

init: проверка при создании

Сеттер выше проверяет присваивание, но остаётся вопрос: а где проверять то, что пришло в конструктор? В Java для этого пишут тело конструктора. В Kotlin у первичного конструктора тела нет — вместо него блок init:

class Account(val id: String, val currency: String) {

    init {
        require(id.isNotBlank()) { "идентификатор счёта пустой" }
        require(currency.length == 3) { "код валюты должен быть из трёх букв, пришло: $currency" }
    }
}

Блок выполняется при каждом создании объекта, сразу после того, как свойства первичного конструктора получили значения. Блоков может быть несколько — они выполняются сверху вниз, вперемешку с инициализаторами свойств, ровно в том порядке, в котором написаны в теле класса. Отсюда единственная ловушка: обратиться из init к свойству, объявленному ниже, нельзя — оно ещё не инициализировано, и компилятор это поймает.

Три функции, которыми проверяют, различаются тем, чья это ошибка, и это ровно то различие, которое стоит запомнить один раз:

ФункцияБросаетСмысл
require(условие) { сообщение }IllegalArgumentExceptionаргумент передали неправильно, виноват вызывающий
check(условие) { сообщение }IllegalStateExceptionобъект не в том состоянии, виноваты мы
error("сообщение")IllegalStateException«сюда попасть не должны»

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

Если проверка нужна до создания объекта или может дать несколько разных исходов, её выносят в фабрику в companion object — это тот случай, когда конструктор делают приватным и создают объект только через функцию, которая умеет отказать внятно. Про companion object — в разделе ниже.

И одна важная оговорка про init вместе с data class. Проверка в init работает при создании через конструктор, но не спасает от copy: копия тоже проходит init, а вот библиотека, которая создаёт объект в обход конструктора (сериализатор, отображение строки базы данных), обходит и проверку. Поэтому init — это защита от ошибки в своём коде, а не валидация входных данных: для внешних данных остаётся обычная валидация на границе.

data class: носитель данных за одну строку

data class Money(val amount: BigDecimal, val currency: String)

Компилятор генерирует equals, hashCode, toString, componentN для деструктуризации и copy:

val price = Money(BigDecimal("99.90"), "RUB")
val discounted = price.copy(amount = BigDecimal("79.90"))   // новый объект: amount другой, остальное скопировано
val (amount, currency) = price                              // деструктуризация

Важная граница, про которую молчит слово «генерирует»: equals, hashCode и toString собираются только по свойствам первичного конструктора. Свойство, объявленное в теле класса, в них не попадёт — и два объекта, различающиеся только им, окажутся равными. Если поле участвует в равенстве, его место — в конструкторе.

copy — ключ к работе с неизменяемыми объектами: меняете одно свойство — получаете новый экземпляр. У него тоже есть дно, о котором лучше узнать сейчас, а не в проде: копия поверхностная. Копируются ссылки, а не то, на что они указывают. Положите внутрь изменяемый список — и обе копии будут держать один и тот же список, правка через любую видна другой. Спасает не copy, а неизменяемость внутри: List вместо MutableList, вложенные data class вместо изменяемых носителей.

Сравнение с альтернативами из Java-мира:

  • record (Java 16+) — ближайший аналог, но без copy и с позиционным конструктором без значений по умолчанию;
  • Lombok @Value/@Builder — закрывает то же, но через кодогенерацию аннотациями; в Kotlin надобность отпадает — data class плюс именованные аргументы заменяют и Value, и Builder.

Чем data class отличается от record по-настоящему

Сравнение «как record, но без copy» верно и недостаточно: различий больше, и часть из них меняет решения.

data class (Kotlin)record (Java 16+)
Наследованиесам не может быть open, abstract, sealed или inner; от класса наследоваться можетнеявно final, унаследован от java.lang.Record, от класса наследоваться не может
Изменяемость компонентовvar разрешён: data class P(var x: Int) — законный кодкомпоненты всегда неизменяемы, var не существует
equals и hashCodeтолько по свойствам первичного конструкторапо всем компонентам, а других полей экземпляра у него и не бывает
Дополнительные свойстваможно объявить в теле класса, в равенство они не попадутдополнительных полей экземпляра быть не может вовсе
Значения по умолчаниюесть, плюс именованные аргументынет, конструктор строго позиционный
Копия с изменениемcopy(amount = ...)нет, пишут руками или через конструктор
Валидация при созданииблок initкомпактный конструктор
Имена методов чтенияgetAmount() по правилам JavaBeansamount(), без приставки

Что из этой таблицы важно на практике:

  • record даёт неизменяемость, а data class — нет. data class с var-свойствами компилируется и работает; гарантию неизменяемости в Kotlin обеспечивает дисциплина (val везде), а не конструкция. Это первая причина, по которой data class иногда ведёт себя не так, как ожидает пришедший из Java.
  • Свойство в теле класса не участвует в равенстве. У record такой ситуации не бывает по устройству, у data class — бывает и удивляет: два объекта равны, хотя различаются.
  • Значения по умолчанию плюс именованные аргументы заменяют билдер, чего у record нет: там либо перегрузки, либо отдельный билдер.
  • Дополнительное поле java.lang.Record в иерархии значит, что библиотеки умеют опознавать record по типу и обрабатывать особым образом. data class в байткоде — обычный класс, и опознать его извне можно только по сгенерированным методам.

Когда data class брать не надо

Слово «носитель данных» в названии — это и есть граница применимости. Три случая, где data class вредит.

Сущность базы данных. Самый частый и самый дорогой случай, тем более что раздел про Spring предлагает плагин, который делает сущности на Kotlin возможными, — и читатель легко соединяет одно с другим. Соединять не надо, причин четыре:

  • equals и hashCode считаются по изменяемым полям. Положили новую сущность в множество до сохранения, база присвоила идентификатор — и объект в множестве больше не находится по себе самому.
  • Правильное равенство сущности — по идентификатору, и только для сохранённых объектов. Сгенерированное равенство по всем полям означает, что две загруженные копии одной строки с разным состоянием не равны, а два разных объекта с одинаковыми полями равны.
  • Сгенерированные equals и hashCode читают поля напрямую, минуя методы чтения. У отложенного прокси поля пустые, и сравнение прокси с загруженной сущностью даёт «не равны».
  • toString печатает все свойства, включая отложенные связи, — и превращает вывод в лог в неожиданную загрузку коллекции, а вне транзакции в исключение.

Плюс copy, который создаёт объект, выглядящий как сущность, но не связанный с контекстом хранения. Для сущностей пишут обычный класс с равенством по идентификатору; data class остаётся для объектов передачи данных и объектов-значений, которые из сущности собирают.

Класс, который нужно наследовать или расширять. data class нельзя объявить open, abstract, sealed или inner. Как только в требованиях появляется «а ещё бывает такой же, но с полем», выбор другой: sealed-иерархия из раздела ниже или обычный класс.

Класс с изменяемым состоянием и поведением. Если у объекта есть жизненный цикл, инварианты и методы, которые состояние меняют, сгенерированное равенство по всем полям вредно: объект отвечает на вопрос «кто ты», а не «чему ты равен». Признак, по которому это видно заранее: в классе есть методы, которые что-то делают, а не только отдают данные.

Практическое правило: data class — для того, что можно напечатать в лог, сравнить по значению и положить в отображение как ключ. Всё остальное — обычный класс.

sealed: закрытые иерархии

Результат платежа бывает трёх видов, и в Java его либо кодируют исключением на каждый исход, либо строкой-флагом, которую забывают проверить. В Kotlin такую иерархию закрывают:

sealed interface PaymentResult
data class Success(val transactionId: String) : PaymentResult
data class Declined(val reason: String) : PaymentResult
data object GatewayTimeout : PaymentResult

sealed и означает «закрытая»: все наследники известны компилятору. Гарантия не бесплатная — за неё платят ограничением на размещение: наследники обязаны лежать в том же модуле и в том же пакете (так с Kotlin 1.5; до неё требовался даже тот же файл). Поэтому чужую sealed-иерархию расширить нельзя — на этом запрете проверка полноты и держится.

sealed PaymentResult Success тот же пакет Declined тот же пакет GatewayTimeout тот же пакет новый наследник when без ветки ошибка компиляции

Наследников ровно три и все они в одном модуле и пакете, а снизу видно, чем оборачивается добавление четвёртого.

Отсюда и главная польза: when по sealed-типу проверяется на полноту — забыли ветку, получили ошибку компиляции:

val message = when (result) {
    is Success -> "Платёж ${result.transactionId} проведён"
    is Declined -> "Отказ: ${result.reason}"
    GatewayTimeout -> "Шлюз не ответил, попробуйте позже"
}   // ветка else не нужна — и не пишите её: она отключит проверку полноты

Это рабочая лошадка моделирования результатов и состояний: вместо исключений на каждый исход или флагов-строк — типизированная иерархия, где добавление нового исхода подсвечивает все места, которые нужно обновить. Java добавила sealed в 17-й версии — идея та же.

Про версии самого Kotlin, чтобы листинг выше собрался: sealed interface работает с 1.5, data object — с 1.9. На компиляторе постарше data object GatewayTimeout записывают обычным object GatewayTimeout.

object: синглтон и «статика»

object OrderIdFormat {
    private const val PREFIX = "ORD-"
    fun render(number: Long): String = PREFIX + number.toString().padStart(8, '0')
}

object — класс с единственным экземпляром, создаётся лениво и потокобезопасно. Оговорка на будущее: это глобальное состояние, которое нельзя подменить в тесте, поэтому годится он для того, у чего нет ни состояния, ни зависимостей, — форматов, таблиц соответствий, простых преобразований. Всё, что зависит от времени, сети или базы (например, источник текущего времени Clock), заводят обычным бином и передают через конструктор.

Для «статических» членов обычного класса — companion object:

class Order private constructor(val id: String) {
    companion object {
        fun create(): Order = Order(UUID.randomUUID().toString())
    }
}

val order = Order.create()

Статических методов в Kotlin нет — их роль играют функции верхнего уровня (для утилит) и companion object (для фабрик и констант класса).

Из Java это видно не так, как ожидаешь: Order.create() там не найдётся, метод придёт как Order.Companion.create(). Чтобы получился настоящий static, на метод ставят @JvmStatic. С константами та же история: val MAX = 100 в companion-объекте — это геттер, а настоящая константа, которую компилятор подставляет в место вызова и которая видна из Java как поле, получается из const val MAX = 100.

Анонимный объект: object : Interface

«А где анонимный внутренний класс?» — вопрос первого дня. Он есть, и записывается тем же словом object, только с двоеточием и типом:

val listener = object : PaymentListener {
    override fun onSuccess(id: String) = log.info("оплата $id")
    override fun onFailure(reason: String) = log.warn("отказ: $reason")
}

orders.sortedWith(object : Comparator<Order> {
    override fun compare(a: Order, b: Order) = a.total.compareTo(b.total)
})

Разница с object OrderIdFormat выше — существенная: там объявлялся единственный экземпляр на всю программу, здесь новый объект создаётся при каждом выполнении выражения. Одно слово, два разных смысла, и различает их двоеточие.

Три особенности, которых нет у анонимного класса Java:

  • Можно реализовать несколько интерфейсов сразу: object : Runnable, AutoCloseable { }. В Java для этого понадобился бы именованный класс.
  • Можно объявить свои свойства и состояние, не только переопределять методы.
  • Захваченные переменные не обязаны быть неизменяемыми. Java требует final или «фактически final»; Kotlin разрешает захватить var и менять его из объекта. Удобно и опасно одновременно: это общее изменяемое состояние.

Когда анонимный объект всё-таки нужен, а когда нет: если интерфейс с одним методом и он объявлен в Java — пишут лямбду, компилятор сам сделает из неё нужный тип. Если методов несколько, или нужно состояние, или интерфейс объявлен в Kotlin (для них такое преобразование по умолчанию не работает, для этого интерфейс помечают словом fun), — анонимный объект.

nested против inner: перевёрнутое умолчание

Ещё одно умолчание, вывернутое относительно Java, того же рода, что final у классов.

class Outer(val name: String) {

    class Nested {
        fun describe() = "я не знаю ничего про Outer"
    }

    inner class Inner {
        fun describe() = "я вижу $name внешнего объекта"
    }
}

val nested = Outer.Nested()                 // без внешнего объекта
val inner = Outer("шкаф").Inner()           // только через экземпляр

Вложенный класс в Kotlin по умолчанию не связан с внешним объектом — то есть ведёт себя как static class в Java. Чтобы получить связь и доступ к свойствам внешнего экземпляра, пишут inner. В Java всё наоборот: по умолчанию класс внутренний, а чтобы он стал независимым, надо не забыть static.

Практический смысл этого разворота тот же, что у final: по умолчанию выбран более безопасный вариант. Внутренний класс держит ссылку на внешний объект, и это классический источник удержания памяти: вложенный класс, живущий дольше внешнего (слушатель, задача в очереди, кэшированный объект), не даёт собрать внешний объект целиком. В Java такую утечку получают по невнимательности, в Kotlin для неё надо явно написать inner.

Из inner-класса до внешнего объекта добираются через this@Outer — та же помеченная форма, что у break@label в циклах.

Делегирование: by

Слово by встречается в чужом Kotlin-коде постоянно и в двух совершенно разных ролях. Обе стоит узнавать с первого взгляда.

Делегирование интерфейса. Класс объявляет, что реализует интерфейс, и тут же говорит, кто за него это делает:

interface EventSink {
    fun accept(event: Event)
    fun flush()
}

class CountingSink(private val delegate: EventSink) : EventSink by delegate {
    var count = 0
        private set

    override fun accept(event: Event) {
        count++
        delegate.accept(event)
    }
}

Компилятор сам дописывает все методы интерфейса, перенаправляя их в delegate; переопределяются только те, что нужны. В Java то же самое пишут руками — отсюда классы-обёртки, где девять методов из десяти состоят из одной строки перенаправления. Это штатный способ получить композицию там, где по привычке взяли бы наследование: обёртка добавляет поведение, не завися от внутренностей того, кого оборачивает.

Одна ловушка, о которой лучше знать заранее: делегат не знает про переопределение. Если внутренний метод delegate.flush() сам зовёт accept(), он вызовет свой accept, а не ваш переопределённый — счётчик выше такие события не увидит. Наследование в этом месте вело бы себя иначе, и это не недостаток, а следствие композиции.

Делегирование свойства. Вторая роль: вычисление значения свойства отдаётся отдельному объекту.

class Report(private val repo: OrderRepository) {
    val summary: String by lazy { repo.buildSummary() }
}

by lazy — самый частый случай: значение считается при первом обращении и запоминается; по умолчанию это потокобезопасно (первый пришедший считает, остальные ждут). Это готовая замена ручной проверке «если ещё не посчитано — посчитать», которая в Java пишется полем и условием и почти всегда пишется неправильно.

Рядом живут ещё два готовых делегата: Delegates.observable вызывает переданный блок при каждом изменении свойства (удобно для журналирования и реакции на смену состояния), а Delegates.notNull() — альтернатива lateinit для случаев, когда значение присваивают позже, но lateinit не подходит (например, для числовых типов, где он не работает).

Как устроен механизм и как написать свой делегат — в разделе про делегирование статьи про функции и коллекции.

Дополнительно: при первом чтении можно пропустить

Глубже: enum class и value classрасширенное

Два вида классов, без которых не прочитать чужой Kotlin-код и не сделать типизированные идентификаторы, о которых говорит раздел про DDD.

enum class. Как в Java, но со свойствами в конструкторе, методами и тем же исчерпывающим when, что у sealed: компилятор требует все ветки или else. С версии 1.9 список значений это entries (вместо values(), которое создавало массив на каждый вызов). Разница с sealed из раздела выше: у enum значения фиксированы и одинаковы по форме, у sealed каждый вариант это свой класс со своими полями. Статус заказа это enum, результат платежа с разными данными это sealed.

enum class OrderStatus(val terminal: Boolean) {
    NEW(false), PAID(false), SHIPPED(false), DELIVERED(true), CANCELLED(true);
    fun canCancel() = this == NEW || this == PAID
}

value class. Обёртка над одним значением, которой в байткоде нет: @JvmInline value class OrderId(val value: UUID) в сигнатурах методов превращается в сам UUID, без объекта и без затрат, а компилятор при этом не даёт передать CustomerId туда, где ждут OrderId. Это ответ на вопрос, как получить типизированные идентификаторы бесплатно, и его берут для идентификаторов, денег в копейках, единиц измерения.

Ограничения, которые всплывают сразу. Одно свойство в конструкторе. Обёртка всё же появляется (боксинг), когда значение попадает в обобщённый тип (List<OrderId>), в nullable-тип или используется через интерфейс, и на горячем пути это стоит помнить. Из Java такой класс виден как исходный тип, а методы, принимающие value class, получают искажённые имена (с суффиксом), поэтому для вызова из Java им ставят @JvmName. Jackson и jOOQ работают с ними через свои модули и преобразователи, JPA с идентификатором-value class требует конвертера; это решаемо, но не автоматически, и потому первые value class заводят там, где они не пересекают границу с фреймворком.

Коротко

  • Свойства объявляются прямо в конструкторе: class Account(val id: String, var balance: BigDecimal) — это класс, конструктор, геттеры и сеттер разом. Классы по умолчанию final, и методы тоже: open на классе не открывает его методы, каждому нужен свой.
  • data class генерирует equals/hashCode/toString/copy/деструктуризацию — но только по свойствам первичного конструктора, а copy поверхностная: вложенные изменяемые объекты у копий общие.
  • sealed — закрытая иерархия: наследники в том же модуле и пакете, when проверяется на полноту веток, else не нужен и вреден.
  • object — синглтон для того, у чего нет состояния; companion object — фабрики и константы. Из Java companion виден как Order.Companion.create(): настоящий static даёт @JvmStatic, настоящую константу — const val. enum class это значения с свойствами, методами и исчерпывающим when, entries вместо values(); value class это обёртка без объекта в байткоде для типизированных идентификаторов, с боксингом в обобщённых и nullable-типах и искажёнными именами для Java.
  • Проверка при создании живёт в блоке init: require для вины вызывающего, check и error для своего состояния, сообщение считается лениво; init не спасает от создания в обход конструктора.
  • Отличия от record глубже, чем отсутствие copy: data class допускает var и потому не гарантирует неизменяемость, равенство считает только по конструкторным свойствам, зато даёт значения по умолчанию и copy.
  • data class не берут для сущности базы данных (равенство по изменяемым полям, чтение полей минуя прокси, toString по отложенным связям), для наследуемого класса и для объекта с поведением и жизненным циклом.
  • object : Interface { } — анонимный объект: новый экземпляр на каждое выполнение, можно несколько интерфейсов и своё состояние, можно захватить var.
  • Вложенный класс по умолчанию не связан с внешним объектом (как static в Java) — связь включает слово inner, и именно она даёт классическое удержание памяти.
  • by работает в двух ролях: делегирование интерфейса даёт композицию вместо наследования (но делегат не видит переопределений), делегирование свойства — это by lazy и готовые делегаты вроде observable.

Что почитать дальше