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

Первые часы с 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 объявить переменную if как инструкция присвоить в ветках Kotlin if как выражение значение наружу

Сверху путь 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 как псевдоним без проверок типа и разбор на части, который идёт по позиции, а не по имени.

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