Первые часы с Kotlin — сплошные «а, так можно было»: меньше точек с запятой, меньше типов, меньше повторов. Но за мелочами стоят два системных сдвига: многое из того, что в Java было инструкцией, в Kotlin стало выражением — возвращает значение, — и неизменяемость здесь выбор по умолчанию. Разберём базу, которой хватит, чтобы читать чужой код.
Примеры ниже стоит набирать, а не читать. Самый быстрый способ — открыть файл с расширением .kt в IntelliJ IDEA, где поддержка языка уже встроена, и написать fun main() { ... } вокруг любого фрагмента; ставить отдельно ничего не нужно. Без установки подойдёт онлайн-площадка на сайте языка, а для локального запуска без проекта есть компилятор командной строки, который умеет выполнить одиночный файл. Подробнее про установку и первый проект — в статье зачем Java-разработчику Kotlin.
val и var: неизменяемость по умолчанию
Половина багов с состоянием — переменная, которую кто-то переприсвоил не там. Kotlin заставляет выбирать сразу: менять можно или нет.
val name = "Order-42" // неизменяемая ссылка, аналог final
var retries = 0 // изменяемая
retries++ // ок
// name = "Other" // не компилируется
Тип выводится из значения — писать String name не нужно, но можно: val name: String = "Order-42". Правило стиля простое: везде val, var — только когда без него не обойтись. В Java final у локальных переменных пишут редко, потому что многословно; в Kotlin неизменяемость короче изменяемости — и оттого встречается повсеместно.
Оговорка на будущее. «Аналог final» — это про локальную переменную. У свойства класса val значит другое: «есть геттер, сеттера нет». Поля с final под ним может не оказаться вовсе, а наследник вправе переопределить такое свойство (open val в родителе, override val в потомке). Разница всплывёт уже в статье про классы.
Функции: от инструкций к выражениям
Функция из одной строки в Java тянет за собой фигурные скобки, return и тип — в Kotlin её пишут как выражение.
fun grade(percent: Int): String {
return if (percent >= 90) "отлично" else "нормально"
}
fun grade2(percent: Int) = if (percent >= 90) "отлично" else "нормально"
Здесь два новшества сразу. Первое: if — выражение, оно возвращает значение, поэтому тернарный оператор в Kotlin не нужен — его роль играет обычный if/else. Второе: функцию из одного выражения можно записать через = без фигурных скобок и return.
Сверху путь Java, снизу Kotlin: смотрите, на каком шаге появляется значение и почему в Kotlin отдельная переменная не нужна.
Выражениями стали не все конструкции, и границу лучше знать сразу. Значение возвращают if, when и — что часто выручает — try:
val n = try { s.toInt() } catch (e: NumberFormatException) { 0 }
А for, while и присваивание значения не возвращают: ни val x = (y = 1), ни val r = for (...) не скомпилируются.
Функции живут и вне классов — на верхнем уровне файла. Утилитный класс со статическими методами превращается в файл с функциями.
when: switch, который дорос
Ветвление по типу или по диапазону в Java превращается в лестницу if; в Kotlin это одна конструкция.
val label = when (status) {
"NEW", "PENDING" -> "в работе"
"DONE" -> "готово"
else -> "неизвестно"
}
when — тоже выражение. Ветки не «проваливаются» (никакого break), можно матчить по диапазонам (in 1..9), типам (is String) и произвольным условиям (when { x > 0 -> ... }). В связке с sealed-классами компилятор проверяет полноту веток — об этом в статье про классы.
when без аргумента
Самая частая форма when в реальном коде — вообще без выражения в скобках. Тогда каждая ветка это условие, и конструкция прямо заменяет лестницу if / else if / else:
val tariff = when {
weight > 30 -> "негабарит"
weight > 10 && express -> "срочная тяжёлая"
express -> "срочная"
else -> "обычная"
}
Читается это лучше лестницы по двум причинам: все условия выровнены в столбик, и результат каждой ветки виден рядом с условием, а не в теле блока. В таком виде when встречается в чужом коде чаще, чем when (x), — и почти всегда там, где на Java был бы if / else if.
when с захватом значения
Второй приём, который выглядит незнакомо: прямо в скобках можно объявить переменную.
when (val response = client.fetch(id)) {
is Response.Ok -> save(response.body)
is Response.NotFound -> log.warn("нет заказа $id")
is Response.Error -> throw IllegalStateException(response.message)
}
Смысл в области видимости: response существует только внутри when, и после проверки типа обращаться к её полям можно без приведения. Альтернатива — объявить переменную строкой выше, но тогда она останется жить до конца функции, хотя нужна была на три строки.
Полнота веток: где else обязателен, а где нет
Здесь у Kotlin есть правило, которое Java-разработчик считает необязательным, а компилятор — нет.
when как выражение (результат куда-то присваивается) обязан быть полным всегда: компилятору нужно значение для любого случая. Либо перечислены все варианты, либо есть else.
when как инструкция (результат никуда не идёт) раньше допускал неполноту с предупреждением. Начиная с Kotlin 1.7 неполный when над enum, sealed-иерархией и Boolean — ошибка компиляции, а не предупреждение:
enum class Status { NEW, PAID, SHIPPED }
fun handle(status: Status) {
when (status) {
Status.NEW -> reserve()
Status.PAID -> ship()
}
}
Этот код не соберётся: вариант SHIPPED не разобран. Практический смысл строгости в том, что она превращает добавление нового варианта в список задач: дописали значение в enum — компилятор сам показал все места, где его забыли обработать. Именно поэтому в Kotlin не принято ставить else -> {} «на всякий случай»: else глушит эту проверку, и добавленный через полгода вариант молча уедет в пустую ветку. else пишут там, где вариантов действительно бесконечно много — например, при сопоставлении чисел или строк.
Циклы и диапазоны
Цикл Java-разработчик пишет в первый же день, и здесь синтаксис отличается сильнее всего.
Классический счётный цикл собирается из диапазона:
for (i in 1..5) print(i) // 12345 — обе границы включительно
for (i in 1..<5) print(i) // 1234 — верхняя граница исключена
for (i in 5 downTo 1) print(i) // 54321
for (i in 1..10 step 3) print(i) // 1 4 7 10
Главная ловушка для пришедшего из Java: 1..5 включает пятёрку. Привычному «до, но не включая» соответствует 1..<5 (раньше это же писали как 1 until 5, и такая запись встречается в существующем коде повсеместно). Ошибка на единицу здесь — самая частая при переходе.
По коллекции ходят напрямую, а если нужен индекс — не через size, а через готовые формы:
for (item in items) println(item)
for ((index, item) in items.withIndex()) println("$index: $item")
for (i in items.indices) println(items[i])
Когда индекс не нужен вообще, а нужно просто повторить N раз, есть отдельная функция:
repeat(3) { println("попытка ${it + 1}") }
Здесь it — номер повторения, считая с нуля; про это неявное имя параметра лямбды подробнее в статье про функции.
Диапазон — не только про цикл. Это полноценное значение, и им проверяют попадание:
val grade = when (score) {
in 90..100 -> "отлично"
in 60..<90 -> "нормально"
else -> "плохо"
}
if (age in 18..65) allow()
if (letter in 'a'..'z') accept()
Про while и do while добавить нечего: они как в Java. А вот break и continue в Kotlin умеют адресовать внешний цикл по метке — та же идея, что помеченный break в Java:
outer@ for (order in orders) {
for (item in order.items) {
if (item.blocked) continue@outer
}
}
Одна оговорка про производительность, чтобы не пугаться: диапазон в for компилятор разворачивает в обычный счётный цикл по числам, объектов при этом не создаётся. А вот если положить диапазон в переменную и передать куда-то как коллекцию, объект появится — это уже обычное значение.
Шаблоны строк
Сообщение об ошибке из пяти кусков через + не читается; в Kotlin значение подставляют прямо в строку.
val msg = "Заказ $orderId: ${items.size} позиций"
Вместо конкатенации или String.format — подстановка прямо в строке: $имя для переменной, ${выражение} для выражения. Многострочные строки — в тройных кавычках """...""", без экранирования. У многострочных есть спутник — trimIndent(): он убирает общий отступ у всех строк, чтобы блок можно было выровнять по коду, а не по левому краю файла.
Отдельно про сам символ доллара, потому что на него натыкаются на первой же строке с ценой или регулярным выражением. Доллар — служебный символ, и написать его буквально нужно явно:
val price = "Цена: \$42" // обычная строка: экранируем обратным слешем
val raw = """Цена: ${'$'}42""" // многострочная: подставляем сам символ
val re = Regex("""^\d+${'$'}""") // конец строки в регулярном выражении
В обычной строке работает привычное экранирование обратным слешем. В многострочной его нет вовсе — там вообще ничего не экранируется, и это её смысл, — поэтому доллар вставляют как подстановку выражения, значение которого и есть символ доллара: ${'$'}. Выглядит неуклюже, но это штатный приём, и в чужом коде он встречается именно в регулярных выражениях и в шаблонах с деньгами. Практический обход для регулярных выражений: вынести шаблон в константу и не собирать его в многострочной строке.
Именованные аргументы и значения по умолчанию
Конструктор с шестью параметрами, из которых четыре обычно одинаковые, в Java рождает пять перегрузок или билдер.
fun connect(host: String, port: Int = 5432, ssl: Boolean = false) { /* ... */ }
connect("db.local") // порт и ssl — по умолчанию
connect("db.local", ssl = true) // именованный аргумент
Это закрывает две больные темы Java разом: телескопические перегрузки (пять версий метода с разным числом параметров) и билдеры ради читаемости вызова. Один метод со значениями по умолчанию заменяет всю лестницу перегрузок.
С одной оговоркой: из Java этих значений не видно — там придётся передать все параметры. Если функцию зовут из Java-кода, поставьте на неё @JvmOverloads, и компилятор сам дорисует ту самую лестницу перегрузок для Java-вызывающих.
Глубже: ещё четыре конструкции из чужого кодарасширенное
Эти четыре вещи не нужны, чтобы написать первый файл, но встретятся в первый же день чтения рабочего кода, и без объяснения выглядят загадочно.
Переменное число аргументов — vararg. Та же идея, что многоточие в Java, только словом:
fun log(message: String, vararg args: Any?) { }
log("заказ", id, total)
val values = arrayOf(id, total)
log("заказ", *values) // передать готовый массив: звёздочка перед ним
Внутри функции args — массив. Новое по сравнению с Java — оператор * (его называют spread): он «раскладывает» готовый массив в отдельные аргументы. Без него массив уехал бы как один элемент.
Вызов без точки и скобок — infix. Функцию с одним параметром можно помечать как инфиксную и вызывать «в середине»:
infix fun Int.по(cost: Int) = this * cost
val total = 3 по 250
val pair = "ru" to "Россия" // to из стандартной библиотеки — обычная infix-функция
Это объясняет to в создании отображений: никакого особого синтаксиса там нет, это обычная функция, вызванная инфиксно. Своих инфиксных функций в прикладном коде заводят мало — читаемость выигрывает только там, где выражение действительно читается фразой.
Своё имя для типа — typealias. Не новый тип, а сокращение:
typealias OrderId = Long
typealias Handler = (Order, Reason) -> Unit
typealias Catalog = Map<String, List<Product>>
Пользы больше всего на типах функций и на длинных обобщённых типах: сигнатура перестаёт занимать две строки. Важная оговорка: это только псевдоним, проверки типов он не добавляет — OrderId и Long взаимозаменяемы, и передать одно вместо другого компилятор не помешает. Настоящий отдельный тип для идентификатора — это value class, о котором в статье про классы.
Разбор на части — деструктуризация. Значение можно разложить на переменные прямо в объявлении:
val (id, total) = order // для data class
for ((key, value) in catalog) println("$key=$value")
val (first, second) = "a-b".split("-")
В цикле по отображению это стандартная форма — entry.key и entry.value в Kotlin почти не пишут. Работает это не магией: разбор доступен там, где у типа есть функции component1(), component2() и так далее, а data class объявляет их сам. Ловушка одна, и она неприятная: разбор идёт по позиции, а не по имени. Переставили два поля в data class местами — все места, где его разбирали на части, продолжат компилироваться и начнут возвращать перепутанные значения. Поэтому для классов с несколькими однотипными полями разбор лучше не применять.
Глубже: мелочи, которые встретятся сразурасширенное
- Точка с запятой почти всегда не нужна. Обязательна она ровно в двух местах: когда два выражения стоят в одной строке (
val a = 1; val b = 2) и когда после списка константenumидут члены класса. newнет:val user = User("Anna").- Приведение типов — через
as, и при несовпадении типа оно броситClassCastException. Мягкий вариант —as?: вместо исключения вернётnull, дальше с ним работают как с любым nullable-значением. - Проверка типа — через
is; после успешной проверки компилятор сам считает тип уточнённым (smart cast) — явный каст не нужен. - Пакеты и импорты выглядят как в Java, но с послаблениями: имя файла не обязано совпадать с именем класса, а пакет не обязан совпадать с каталогом на диске (раскладывать как в Java всё равно принято — иначе файл не найти глазами).
Unit— аналогvoid, но полноценный тип; в сигнатурах его обычно опускают.
У smart cast есть условие, о которое спотыкаются в первый же день. Компилятор уточняет тип, только когда уверен, что между проверкой и использованием значение не поменялось. Для локальной переменной это так всегда. А вот для var-свойства класса, для var, захваченной лямбдой, и для свойства с собственным геттером он откажется и скажет «Smart cast to 'String' is impossible…» — потому что значение теоретически мог поменять кто-то другой. Обход простой: сделайте локальную копию val local = status и проверяйте её.
Коротко
val— неизменяемая ссылка,var— изменяемая; правило: везде val, var — по необходимости.if,whenиtry— выражения: возвращают значение, тернарный оператор не нужен, ветки when не проваливаются; аfor,whileи присваивание значения не возвращают.- Функция из одного выражения пишется через
=безreturn, а жить она может вне класса: утилитный класс со статическими методами превращается в файл с функциями. - Шаблоны строк
$xи${expr}заменяют конкатенацию; именованные аргументы и значения по умолчанию — телескопические перегрузки и билдеры. - Smart cast: после
is-проверки тип уточняется автоматически — но только там, где значение между проверкой и использованием заведомо не менялось (локальная переменная — да,var-свойство класса — нет). - Самая частая форма
when— без аргумента в скобках: каждая ветка это условие, и это прямая замена лестницеif / else if; переменную можно объявить прямо в скобках, и жить она будет только внутри. - Неполный
whenнадenum,sealedиBoolean— ошибка компиляции с Kotlin 1.7; поэтомуelse -> {}«на всякий случай» не пишут: он глушит проверку, и новый вариант молча уедет в пустую ветку. - Диапазоны:
1..5включает пятёрку, «до, но не включая» это1..<5(прежняя записьuntil), естьdownToиstep; индекс берут изwithIndex()илиindices, а простое повторение — черезrepeat. - Доллар в строке экранируют обратным слешем, а в многострочной — подстановкой
${'$'}, потому что в тройных кавычках экранирования нет вовсе. - В чужом коде сразу встретятся
varargсо звёздочкой при передаче массива, инфиксные функции (to— обычная из них),typealiasкак псевдоним без проверок типа и разбор на части, который идёт по позиции, а не по имени.
Что почитать дальше
- Null-safety: типы против NPE — главное отличие системы типов.
- Классы: data, sealed, object — куда делись геттеры и сеттеры.
- Функции и коллекции — лямбды и extension-функции.