В 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() по правилам JavaBeans | amount(), без приставки |
Что из этой таблицы важно на практике:
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-иерархию расширить нельзя — на этом запрете проверка полноты и держится.
Наследников ровно три и все они в одном модуле и пакете, а снизу видно, чем оборачивается добавление четвёртого.
Отсюда и главная польза: 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.
Что почитать дальше
- Функции и коллекции — следующий шаг маршрута.
- Java: современные возможности — records и sealed на стороне Java.
- Null-safety: типы против NPE — если пропустили: свойства и nullability работают вместе.