Перейти к основному содержимому

5.09. Синтаксис Kotlin

Разработчику Архитектору

Синтаксис Kotlin

Kotlin — язык статической типизации, разработанный компанией JetBrains с 2011 года и официально признанный Google основным языком для Android-разработки в 2017 году. Его синтаксис формировался под влиянием широкого спектра языков: Java (совместимость и экосистема), Scala (мощные абстракции), C# (свойства, делегаты, асинхронность), Python и Groovy (лаконичность, читаемость) — однако итоговый результат не является простым миксом. Вместо этого Kotlin последовательно реализует принцип практической выразительности: каждая синтаксическая единица решает конкретную инженерную задачу, устраняя избыточность без жертвования надёжностью и производительностью.

Синтаксис Kotlin — рефакторинг парадигмы: замена шаблонных идиом, склонных к ошибкам (например, null-проверки, шаблонные классы данных, повторяющиеся шаблоны инициализации), на встроенные, проверяемые на этапе компиляции конструкции. Лаконичность достигается не за счёт умолчаний, ведущих к неопределённости, а за счёт вывода типов, интеллектуального умолчания, структурированной неизменяемости и семантически обогащённой грамматики.

Ниже рассматриваются ключевые аспекты синтаксиса — от базовых деклараций до продвинутых идиом — с акцентом на почему так, а не только как.


1. Объявление переменных

В Kotlin отсутствует ключевое слово final, привычное по Java. Вместо этого используется двухуровневая система деклараций:

  • val (от value) — объявляет неизменяемую ссылку.
  • var (от variable) — объявляет изменяемую ссылку.

Это сознательный дизайн-решение, направленное на смещение баланса в сторону иммутабельности как стандартного подхода. Переменная, объявленная через val, не может быть переприсвоена — ни явно (val x = 5; x = 6 — ошибка компиляции), ни неявно (например, в цикле for). При этом объект, на который ссылается val, может быть изменяемым — например, val list = mutableListOf(1, 2, 3); list.add(4) допустимо. То есть val фиксирует саму ссылку, а не состояние объекта.

Тип может быть указан явно (val name: String = "Alice"), но в подавляющем большинстве случаев компилятор выводит его из инициализатора (val age = 30Int). Это не «динамическая типизация» — тип фиксируется на этапе компиляции, и попытка присвоить age = "thirty" вызовет ошибку. Вывод типов устраняет избыточность без потери строгой проверки.

Примечательно, что Kotlin допускает отложенную инициализацию (lateinit var) и делегированные свойства (by lazy, by Delegates.observable), но эти механизмы явно маркируются и требуют осознанного использования — в отличие от неявной отложенной инициализации в Java (где ссылка по умолчанию null), что является частой причиной NullPointerException.


2. Функции

Функции в Kotlin объявляются ключевым словом fun. Синтаксис включает:

  • Имя функции;
  • Список параметров в круглых скобках, каждый с именем и типом (обязательно);
  • Необязательный возвращаемый тип после двоеточия;
  • Тело — либо блок {}, либо одно выражение после =.
fun greet(name: String): Unit {
println("Hello, $name")
}

Обратите внимание на возвращаемый тип Unit. Это аналог void в Java, но с принципиальным отличием: Unit — это реальный тип, имеющий ровно одно значение — Unit. Это позволяет трактовать все функции как возвращающие значение, что упрощает обобщённое программирование (например, в обобщённых контекстах, где требуется возвращаемый тип). При этом при записи Unit можно опустить — компилятор подставит его автоматически, если тело — блок без явного return.

Если функция состоит из одного выражения, её можно записать в выражении-функции (expression body):

fun square(x: Int) = x * x

Здесь возвращаемый тип выводится как Int. Такая форма особенно удобна для простых преобразований, делегирования и extension-функций.

Параметры функций всегда имеют имена и типы. Передача по имени (greet(name = "Alice")) поддерживается, что повышает читаемость при вызове с несколькими параметрами одинакового типа.


3. Строка и интерполяция

В Kotlin строки поддерживают интерполяцию — подстановку значений выражений непосредственно в текстовую константу:

val name = "Alice"
println("Hello, $name") // → Hello, Alice
println("Length: ${name.length}") // → Length: 5

Символ $ не является строковым макросом или препроцессорной конструкцией — это часть лексического анализа строковых литералов. После $ допускается либо простое имя переменной, либо произвольное выражение в фигурных скобках. Это безопаснее, чем конкатенация ("Hello, " + name), так как исключает случайное пропускание пробела или непарных кавычек, и выразительнее — особенно при вложенных структурах.

Строки в Kotlin неизменяемы (String — финальный класс), как и в Java, но стандартная библиотека предоставляет богатый набор функций-расширений (trim(), split(), replace(), lines(), take(), drop(), регулярные выражения и др.), что делает работу с ними близкой по удобству к Python.


4. Null Safety

Одна из самых значимых инноваций Kotlin — система null-безопасности, реализованная на уровне системы типов. В отличие от Java, где любой объектный тип может принимать значение null неявно, Kotlin разделяет типы на не nullable и nullable:

  • String — не может быть null;
  • String? — может быть null.

Это изменение кардинально: компилятор запрещает вызов методов или доступ к свойствам nullable-типа без явной проверки. Существует несколько стандартных способов безопасной работы с nullable-значениями:

4.1. Оператор безопасного вызова ?.

val length = nullableName?.length  // Int? — будет null, если nullableName == null

Если левый операнд null, выражение возвращает null, не вызывая исключения.

4.2. Оператор элвиса ?:

Позволяет задать значение по умолчанию:

val len = nullableName?.length ?: 0
println(nullableName?.length ?: "No name")

Выражение справа от ?: вычисляется только если левое — null. Это аналог Optional.orElse() в Java, но без накладных расходов на обёртку.

4.3. Утверждение non-null !!

val len = nullableName!!.length  // NullPointerException, если null

Используется сознательно — когда разработчик гарантирует ненулевое значение, а компилятор не может это проверить (например, при взаимодействии с Java-кодом). Это сигнал о ручной ответственности.

4.4. Безопасное приведение и проверки с is, as?

if (obj is String) {
println(obj.length) // obj автоматически приведён к String в этом блоке
}
val str = obj as? String // возвращает String? или null

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

Важно: система null-безопасности работает статически. Ошибки вида «возможно null» обнаруживаются на этапе компиляции, а не во время выполнения. Это не абсолютная гарантия (например, при работе с Java-кодом без @Nullable/@NonNull аннотаций), но радикально снижает долю NPE в «чистом» Kotlin-коде.


5. Data классы

В Java класс, представляющий данные (например, User), требует ручной реализации или генерации equals(), hashCode(), toString(), copy(), геттеров/сеттеров. В Kotlin достаточно:

data class User(val id: Int, val name: String)

Ключевое слово data автоматически генерирует:

  • equals() и hashCode() на основе всех свойств в первичном конструкторе;
  • toString() вида User(id=1, name=Alice);
  • copy() — метод для создания модифицированной копии:
    val user2 = user1.copy(name = "Bob");
  • компонентные функции component1(), component2() — для деструктурирования:
    val (id, name) = user.

Это не магия — код генерируется на этапе компиляции и доступен в байткоде. Data-классы не могут быть абстрактными, открытыми (open), наследоваться от других классов (кроме Any) и не должны объявлять дополнительные свойства в теле, влияющие на семантику равенства (хотя технически могут — с предостережением компилятора).

Data-классы — инструмент для транспортных и хранящих структур: DTO, модели, конфигурационные записи. Они фокусируются на идентичности по содержимому, а не по ссылке.


6. Extension-функции и свойства

Одна из наиболее мощных возможностей Kotlin — расширение существующих классов без изменения их исходного кода и без наследования. Это достигается через extension-функции и extension-свойства:

fun String.addExclamation() = this + "!"
val String.lastChar: Char get() = this.last()

Эти объявления компилируются в статические методы (в случае JVM — в static final методы класса-хоста), принимающие экземпляр расширяемого типа как первый параметр (в данном случае — this). При вызове:

"Hello".addExclamation()  // → "Hello!"
println("World".lastChar) // → 'd'

— создаётся иллюзия, что метод/свойство принадлежит классу, хотя на уровне JVM это обычный вызов статического метода.

Важные ограничения и особенности:

  • Extension-функция не имеет доступа к private или protected членам расширяемого класса — только к публичному API.
  • При наличии конфликта имён (например, extension-функция и метод класса с одинаковой сигнатурой), приоритет имеет метод класса.
  • Extensions не наследуются — они привязаны к статическому типу переменной, а не к её динамическому типу:
    open class A
    class B : A()

    fun A.foo() = "A"
    fun B.foo() = "B"

    val b: A = B()
    println(b.foo()) // → "A", несмотря на то, что b — экземпляр B

Это следствие статической диспетчеризации — и оно предсказуемо.

Extensions позволяют:

  • Организовывать вспомогательный код по семантическим модулям, а не по иерархии наследования;
  • Создавать DSL (Domain-Specific Languages), например, для конфигурации, тестирования, построения UI;
  • Улучшать API сторонних библиотек без wrapper-классов.

7. Управление потоком выполнения

В Kotlin практически все управляющие конструкции — выражения, возвращающие значение. Это устраняет необходимость в отдельных переменных-аккумуляторах и делает код более декларативным.

7.1. if как выражение

В отличие от Java, где if — это исключительно оператор, в Kotlin if всегда возвращает значение — результат последнего выражения в выбранной ветке:

val max = if (a > b) a else b

Каждая ветка (then, else) должна быть выражением одного типа (или Unit). Пустой блок не допускается — это исключает ошибки вида «забыл else». При многострочных ветках используется блочная форма:

val description = if (score >= 90) {
println("Высокий результат")
"Отлично"
} else if (score >= 60) {
"Удовлетворительно"
} else {
"Неудовлетворительно"
}

Компилятор проверяет, что все ветви возвращают совместимые типы. Если одна ветвь — String, а другая — Int, будет ошибка. Это гарантирует согласованность логики на уровне типов.

7.2. when — обобщённое сопоставление с образцом

when заменяет switch из Java, но значительно мощнее. Он поддерживает:

  • Сопоставление по значению (включая диапазоны, списки, условия);
  • Проверку типов (is);
  • Выражения в ветках;
  • Использование как выражения и как оператора.

Примеры:

val result = when (x) {
0 -> "Ноль"
in 1..10 -> "Маленькое число"
!in 11..100 -> "Вне диапазона"
is String -> "Строка длиной ${x.length}"
else -> "Что-то другое"
}

Ветки проверяются сверху вниз; выполнение останавливается на первой совпавшей. Нет необходимости в break — это исключает «проваливание» (fall-through), характерное для Java.

when может использоваться и без аргумента — тогда каждая ветка содержит логическое выражение:

when {
x < 0 -> println("Отрицательное")
x == 0 -> println("Ноль")
else -> println("Положительное")
}

Это эквивалент if-else if-else, но в более структурированной форме — особенно удобно при большом числе условий.

Важно: when должен быть исчерпывающим в контексте выражения — либо охватывать все возможные значения (например, все значения enum), либо содержать else. Компилятор проверяет полноту покрытия для sealed class и enum, что делает when ключевым инструментом в функционально-ориентированном стиле.

7.3. Циклы: for, while, do-while и range-based итерации

Цикл for в Kotlin унифицирован и работает только по итерируемым (Iterable<T>) или диапазонам (ClosedRange<T>). Нет синтаксиса for (int i = 0; ...) — вместо этого используются:

  • Диапазоны: 1..10 (включительно), 1 until 10 (исключая 10), 10 downTo 1, 1..10 step 2;
  • Функции-расширения: indices, withIndex().

Примеры:

for (i in 1..5) println(i)                 // 1, 2, 3, 4, 5
for (i in 1 until 5) println(i) // 1, 2, 3, 4
for (i in 5 downTo 1) println(i) // 5, 4, 3, 2, 1
for (i in 1..10 step 3) println(i) // 1, 4, 7, 10

val list = listOf("a", "b", "c")
for (item in list) println(item)
for ((index, value) in list.withIndex()) {
println("$index: $value")
}

Циклы while и do-while сохраняют привычную семантику, но их использование поощряется только при условных итерациях, где нельзя предсказать число шагов (например, чтение из потока).

Диапазоны (IntRange, CharRange, LongRange) — это полноценные классы с методами (contains, isEmpty, first, last), реализующие Iterable. Они работают с любыми типами, реализующими Comparable, а также поддерживают пользовательские шаги через step.


8. Функции высшего порядка и лямбды

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

8.1. Типы функций

Тип функции в Kotlin записывается как (T1, T2, ...) -> R, где T1... — типы параметров, R — возвращаемый тип.

Примеры:

  • () -> Unit — функция без параметров и возврата (аналог Runnable);
  • (Int) -> String — функция, принимающая Int и возвращающая String;
  • (String, String) -> Boolean — компаратор.

Такой тип может использоваться как аннотация параметра:

fun performOperation(x: Int, y: Int, operation: (Int, Int) -> Int): Int {
return operation(x, y)
}

val sum = performOperation(3, 4) { a, b -> a + b }

8.2. Лямбда-выражения

Лямбда — анонимная функция, записываемая в фигурных скобках:

{ x: Int -> x * x }
{ a, b -> a + b }
{ println("Hello") }

Особенности:

  • Типы параметров можно опустить, если они выводятся из контекста (как в примере выше);
  • Последнее выражение — возвращаемое значение;
  • Лямбда может захватывать переменные из окружающей области (closure), при этом val-переменные захватываются по значению, var — по ссылке (через обёртку в Ref).

Если лямбда — последний аргумент функции, её можно вынести за скобки:

list.map { it.length }
list.filter { it.startsWith("A") }.sortedBy { it }

Если функция принимает ровно одну лямбду, скобки можно опустить полностью.

8.3. it — неявный параметр

В лямбде с одним параметром можно использовать неявное имя it:

list.map { it.uppercase() }

Это не глобальная переменная — it вводится локально и привязан к единственному параметру. При вложенных лямбдах внешний it недоступен — нужно явно именовать параметры.

8.4. Inline-функции

Передача лямбд в обычные функции создаёт анонимные классы (на JVM) — по одному на вызов. Это может быть дорого при частых вызовах (например, в циклах или коллекциях).

Решение — inline:

inline fun <T> measureTime(block: () -> T): T {
val start = System.nanoTime()
val result = block()
val end = System.nanoTime()
println("Время: ${(end - start) / 1_000_000} мс")
return result
}

При компиляции вызов measureTime { ... } заменяется встроенным кодом тела функции с подстановкой лямбды — как макрос, но с проверкой типов. Это устраняет аллокацию объекта и вызов виртуального метода.

Ограничения:

  • inline-функции не могут вызываться рекурсивно;
  • Лямбды внутри inline нельзя сохранять в поля (val f = block — ошибка), так как они не существуют как объекты во время выполнения;
  • Для таких случаев используется noinline или crossinline.

crossinline позволяет передавать лямбду в другую inline-функцию, запрещая в ней return из внешней функции — это важно для корректной работы с корутинами и коллбэками.


9. Обобщения (Generics)

Kotlin наследует систему обобщений от Java, но устраняет ряд неудобств через variance-аннотации и reified-типы.

9.1. Проблема неизменяемости обобщённых типов

В Java List<String> не является подтипом List<Object> — несмотря на то, что String — подтип Object. Это называется инвариантностью. В Kotlin это выражается явно:

interface MutableList<T> // инвариантный — можно и читать, и писать
interface List<out T> // ковариантный — только читать
interface Comparable<in T> // контравариантный — только писать
  • out T — «производит T» (например, get() возвращает T), но не принимает T в параметрах. Такой тип можно безопасно привести вверх: List<String>List<Any> допустимо.
  • in T — «потребляет T» (например, compareTo(T)), но не возвращает T. Приведение вниз: Comparable<Any>Comparable<String> безопасно.

Пример:

fun printAll(strings: List<String>) {
// List<out String> — ковариантен
val anys: List<Any> = strings // OK
}

Это позволяет писать более гибкие API без ? extends T / ? super T (wildcards), которые в Java усложняют сигнатуры.

9.2. Reified-типы

Из-за type erasure в JVM обобщённый тип недоступен во время выполнения. Kotlin частично обходит это ограничение с помощью reified:

inline fun <reified T> List<*>.filterIsInstance(): List<T> {
return this.filter { it is T } as List<T>
}

val numbers = list.filterIsInstance<Int>()

Только inline-функции могут иметь reified-параметры. При встраивании компилятор подставляет конкретный тип (Int), и it is T становится it is Int — безопасной проверкой.

Это широко используется в сериализации, DI-контейнерах, тестовых фреймворках.


10. Делегирование

Делегирование — один из ключевых принципов проектирования («предпочитай композицию наследованию»). Kotlin встраивает его на синтаксический уровень.

10.1. Делегирование классов через by

interface Printer {
fun print(message: String)
}

class ConsolePrinter : Printer {
override fun print(message: String) = println(message)
}

class LoggingPrinter(val impl: Printer) : Printer by impl {
override fun print(message: String) {
println("[LOG] $message")
impl.print(message)
}
}

Здесь LoggingPrinter делегирует все методы Printer экземпляру impl, кроме print, который переопределён. Это устраняет необходимость в ручном пробрасывании методов.

10.2. Делегированные свойства через by

Синтаксис val/var name: Type by Delegate() позволяет вынести логику чтения/записи в отдельный объект — делегат.

Стандартные делегаты:

  • lazy { ... } — отложенная инициализация (thread-safe по умолчанию):
    val config by lazy { loadConfig() }
  • Delegates.observable(initial) { prop, old, new -> ... } — уведомление при изменении:
    var name: String by Delegates.observable("Anonymous") { _, old, new ->
    println("Имя изменено: $old$new")
    }
  • Delegates.vetoable { ... } — возможность отменить присвоение;
  • Delegates.notNull() — для non-null свойств, инициализируемых позже (альтернатива lateinit var для не-варов).

Кастомный делегат реализует интерфейсы ReadOnlyProperty или ReadWriteProperty. Это позволяет создавать DSL для конфигурации, кэширования, привязки к UI и т.д.

Пример: делегат для преобразования строки в целое:

class IntDelegate {
private var value: Int = 0
operator fun getValue(thisRef: Any?, property: KProperty<*>): Int = value
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
value = newValue.toInt()
}
}

var strAsInt: Int by IntDelegate()
strAsInt = "42" // → 42

Делегирование — механизм композиции поведения, интегрированный в модель свойств.


11. Корутины

Корутины — синтаксическая и семантическая конструкция, встроенная в язык через ключевое слово suspend. Они позволяют писать асинхронный код в последовательном стиле, избегая вложенности коллбэков («callback hell») и сложности управления состоянием вручную.

11.1. suspend-функции: приостанавливаемые вычисления

Функция, помеченная suspend, может приостанавливать своё выполнение без блокировки потока. Это не означает, что она «работает в фоне» — приостановка происходит кооперативно: только в точках, где вызывается другая suspend-функция (например, delay(), withContext(), сетевой запрос).

suspend fun fetchData(): String {
delay(1000) // приостановка на 1 секунду — поток НЕ блокируется
return "Data"
}

suspend fun process() {
val data = fetchData() // приостановка до завершения fetchData
println(data)
}

Ключевые принципы:

  • suspend-функцию можно вызывать только из другой suspend-функции или из корутинной области (scope);
  • При компиляции suspend-функция преобразуется в конечный автомат (state machine), где каждая приостановка — переход между состояниями;
  • Нет создания потоков «на лету» — корутины легковесны (одна корутина ≈ несколько десятков байт), и тысячи могут работать параллельно на одном потоке.

11.2. Корутинные области и построители (launch, async, runBlocking)

Для запуска корутин требуется область (CoroutineScope), определяющая жизненный цикл и контекст выполнения.

  • launch — запускает корутину «в фоне», возвращает Job (дескриптор для отмены):

    val job = GlobalScope.launch {
    delay(1000)
    println("Завершено")
    }
    job.join() // дождаться завершения
  • async — запускает корутину и возвращает Deferred<T> — отложенное значение:

    val deferred = async { fetchData() }
    val result = deferred.await() // приостановка до результата
  • runBlocking — блокирует текущий поток до завершения корутины (используется в main, тестах):

    fun main() = runBlocking {
    val data = async { fetchData() }
    println(data.await())
    }

Важно: GlobalScope следует избегать в production-коде — предпочтительны структурированные области (viewModelScope, lifecycleScope в Android, или собственные CoroutineScope), привязанные к жизненному циклу компонента. Это гарантирует, что корутины автоматически отменяются при уничтожении владельца.

11.3. Контексты и диспетчеры

Выполнение корутины происходит в контексте (CoroutineContext), который включает:

  • CoroutineDispatcher — определяет, на каком потоке выполняется блок:
    • Dispatchers.Main — UI-поток (Android, Compose);
    • Dispatchers.Default — пул потоков для CPU-нагрузки;
    • Dispatchers.IO — пул потоков для блокирующих операций (файлы, сеть);
    • Dispatchers.Unconfined — продолжает в том же потоке, где произошла приостановка.

Смена контекста — через withContext():

suspend fun loadAndProcess(): String = withContext(Dispatchers.IO) {
val data = fetchDataFromNetwork() // блокирующий вызов — OK в IO
data.uppercase()
} // возврат в исходный контекст

Это безопаснее, чем async(Dispatchers.IO) { ... }.await(), так как withContext() сохраняет иерархию отмены.

11.4. Flow: реактивные потоки данных

Для работы с последовательностями асинхронных значений используется Flow<T> — холодный, не имеющий состояния, отменяемый аналог RxJava Observable, но с синтаксисом, встроенным в язык.

fun numbers(): Flow<Int> = flow {
for (i in 1..3) {
delay(100)
emit(i) // отправка значения
}
}

numbers()
.map { it * 2 }
.filter { it > 3 }
.collect { println(it) } // 4, 6

Особенности:

  • flow { ... } — builder для создания Flow;
  • Операторы (map, filter, flatMapConcat, retry, catch) — расширения;
  • collect — терминальная операция, запускающая поток;
  • StateFlow и SharedFlow — горячие потоки для UI-состояний и событий.

Flow интегрируется с suspend-функциями: любой оператор может содержать приостановки, и отмена распространяется корректно.

Корутины не скрывают асинхронность — они структурируют её, делая явной и управляемой, без искажения логики программы.


12. Sealed-классы и when-полноценность

Sealed-классы — это обобщение enum, позволяющее описывать иерархию типов с конечным числом подтипов, известных на этапе компиляции. Они используются для моделирования алгебраических типов данных (ADT), особенно сумм («или»-типов).

sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading : Result()
}

Особенности:

  • Все прямые подтипы должны быть объявлены в том же файле;
  • Подтипы могут быть class, object, data class;
  • Наследование от sealed-класса закрыто — нельзя расширить его извне.

Главная ценность — полный when без else:

fun handleResult(result: Result) = when (result) {
is Result.Success -> println("Данные: ${result.data}")
is Result.Error -> println("Ошибка: ${result.message}")
Result.Loading -> println("Загрузка...")
// else не требуется — компилятор знает все случаи
}

Если добавить новый подтип (data class Timeout : Result()), компилятор укажет на все неполные when — это гарантирует, что изменение модели состояния не приведёт к неперехваченным веткам.

Sealed-классы применяются:

  • Для представления состояний экрана (Idle, Loading, Content, Error);
  • Для обработки результатов API-вызовов;
  • В парсерах, интерпретаторах, конечных автоматах.

Это синтаксическая поддержка defensive programming через систему типов.


13. Объектные выражения и companion-объекты

В Kotlin отсутствует ключевое слово static. Вместо этого используются:

13.1. object-выражения и object-декларации

  • Объектное выражение создаёт анонимный класс с единственным экземпляром — аналог new Runnable() { ... } в Java:

    val comparator = object : Comparator<String> {
    override fun compare(a: String, b: String) = a.length - b.length
    }
  • Объектная декларация — singleton с именем:

    object Constants {
    const val PI = 3.14159
    val DATABASE_URL = "jdbc:..."
    }

    Обращение: Constants.PI. Экземпляр создаётся при первом обращении (lazy, thread-safe).

13.2. companion object — «почти статика»

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

class User private constructor(val id: Int) {
companion object {
fun fromId(id: Int): User = User(id)
const val MAX_ID = 1000
}
}

val user = User.fromId(42) // вызов «статического» метода

Особенности:

  • companion object может реализовывать интерфейсы;
  • Члены с const или @JvmField компилируются в static final поля на JVM;
  • Для совместимости с Java используются аннотации:
    companion object {
    @JvmStatic fun create() = User()
    @JvmField val VERSION = "1.0"
    }
    Тогда User.create() и User.VERSION работают как обычные static-члены.

Это осознанный дизайн: singleton-экземпляр с интерфейсом, а не глобальное состояние без типа.


14. Взаимодействие с Java

Kotlin полностью совместим с Java бинарно и исходно. Однако различия в синтаксисе требуют механизмов сглаживания:

14.1. Платформенные типы (T!)

При вызове Java-кода компилятор Kotlin сталкивается с неизвестной null-семантикой. Результат — платформенный тип (String!), который может использоваться как String или String?. Это временный тип, разрешаемый при присвоении:

val s: String = javaMethod() // OK, если javaMethod() != null во время выполнения
val s: String? = javaMethod() // всегда безопасно

Для точного управления используются аннотации Java (@Nullable, @NonNull), которые Kotlin учитывает.

14.2. Аннотации для совместимости

  • @JvmStatic — метод в companion object становится static;
  • @JvmOverloads — генерирует перегрузки для параметров по умолчанию;
  • @JvmName — задаёт имя метода на JVM (полезно при конфликтах сигнатур);
  • @Throws — указывает проверяемые исключения (для совместимости с Java-контрактом);
  • @JvmField — делает свойство полем без геттера/сеттера.

Пример:

class StringUtils {
@JvmStatic
@JvmOverloads
fun repeat(str: String, times: Int = 3): String = str.repeat(times)
}

В Java: StringUtils.repeat("a"), StringUtils.repeat("a", 5).

14.3. Обработка проверяемых исключений

Kotlin не требует объявления throws и не проверяет исключения на этапе компиляции. Но при вызове Java-методов, бросающих IOException, его можно перехватить как обычно:

try {
Files.readAllBytes(path)
} catch (e: IOException) {
// обработка
}

Без @Throws Kotlin считает, что метод не бросает проверяемых исключений — это упрощает написание, но требует осторожности при интеграции.


15. Kotlin Multiplatform: expect/actual и общая логика

Kotlin Multiplatform (KMP) — архитектурная парадигма, поддерживаемая компилятором через синтаксические конструкции для разделения общего и платформенно-специфичного кода.

15.1. expect и actual: договор о реализации

В общем модуле (commonMain) объявляется ожидаемая сущность через expect:

expect class PlatformLogger() {
fun log(message: String)
}

expect fun getPlatformName(): String

В платформенных модулях (androidMain, iosMain, jvmMain) — её реализация через actual:

// androidMain
actual class PlatformLogger actual constructor() {
actual fun log(message: String) {
android.util.Log.d("APP", message)
}
}

actual fun getPlatformName() = "Android"
// iosMain
actual class PlatformLogger actual constructor() {
actual fun log(message: String) {
NSLog(message)
}
}

actual fun getPlatformName() = "iOS"

Особенности:

  • expect может применяться к классам, функциям, свойствам, аннотациям;
  • actual-реализация должна точно соответствовать сигнатуре expect (включая модификаторы open, suspend);
  • actual может использовать платформенные API (Android SDK, iOS Foundation), недоступные в common.

Это статическая композиция: на этапе линковки выбирается нужная реализация. Ошибки несоответствия обнаруживаются на этапе компиляции.

15.2. Ограничения и обходные пути

  • В common нельзя использовать платформенные типы напрямую → решения:

    • Абстракция через интерфейсы + expect-фабрики;
    • @OptionalExpectation — пометка, что реализация может отсутствовать;
    • expect object для singleton-сервисов.
  • actual не может расширять expect class → вместо этого используется композиция.

  • Для общих DTO используется @Serializable (см. ниже) или простые data class в common.

KMP позволяет выносить до 80–90% бизнес-логики в общий код, оставляя платформенный слой тонким и тестируемым.


16. Kotlin/JS: синтаксис для мира JavaScript

При компиляции в JavaScript синтаксис Kotlin адаптируется под экосистему JS, сохраняя семантику, но добавляя механизмы взаимодействия.

16.1. Внешние объявления (external)

Для использования существующих JS-библиотек объявляются external сущности:

external fun fetch(url: String): Promise<Response>

external class Response {
fun json(): Promise<dynamic>
}

external val window: Window

external — это «доверенное» объявление: компилятор предполагает, что символ существует во время выполнения. Ошибки проявятся только в runtime — поэтому часто используются вместе с инструментами вроде kotlinx.js или генераторами из .d.ts.

16.2. dynamic — обход системы типов для совместимости

Тип dynamic отключает проверку типов во время компиляции:

val obj: dynamic = getObjectFromJS()
obj.method("arg") // любой вызов разрешён

Это необходимо при работе с динамическими API (например, eval, JSON.parse), но его использование должно быть минимизировано — предпочтительны external интерфейсы.

16.3. Модульность и экспорт

  • @JsExport — делает класс/функцию доступной из JS:

    @JsExport
    class Calculator {
    fun add(a: Double, b: Double) = a + b
    }

    В JS: new Calculator().add(2, 3).

  • @JsName — задаёт имя в JS (полезно при перегрузке или зарезервированных словах):

    @JsName("createUser")
    fun createUser(name: String) = User(name)
  • @JsModule("axios") — импорт ES-модуля:

    @JsModule("axios")
    external val axios: Axios

Kotlin/JS сохраняет статическую типизацию там, где это возможно, и чётко локализует «дыры» в типобезопасности через external и dynamic.


17. Kotlin/Native

Kotlin/Native компилируется в машинный код (через LLVM) и работает без виртуальной машины. Его синтаксис учитывает особенности управления памятью и межпоточности.

17.1. Управление памятью

Kotlin/Native использует подсчёт ссылок с циклическим сборщиком (ARC + cycle collector), а не stop-the-world GC. Это накладывает ограничения:

  • Объекты «замораживаются» (freeze()) при передаче между потоками;
  • Заморожённые объекты становятся неизменяемыми и разделяемыми;
  • @SharedImmutable — аннотация для констант, безопасных для совместного доступа:
    @SharedImmutable
    val PI = 3.14159

Синтаксически это проявляется в необходимости явного управления жизненным циклом в многопоточных сценариях — но компилятор помогает: попытка изменить замороженный объект вызовет InvalidMutabilityException.

17.2. C-интероперабельность

Для вызова C-кода используются extern-объявления (через cinterop):

fun main() {
memScoped {
val buffer = allocArray<ByteVar>(256)
fgets(buffer, 256, stdin)
val str = buffer.toKString()
println("Вы ввели: $str")
}
}

Генерируются Kotlin-обёртки над заголовочными файлами, где:

  • IntVar, ByteVar — указатели на примитивы;
  • CPointer<T> — безопасная абстракция над void*;
  • memScoped — управляет временем жизни стека.

Это не «unsafe-код» в стиле Rust — границы безопасности сохраняются через области (scope), но с явным указанием зон ответственности.


18. DSL-конструирование

Kotlin предоставляет несколько механизмов для создания внутренних DSL — API, читающихся как специализированный язык:

18.1. Infix-функции

Позволяют вызывать бинарные функции без точек и скобок:

infix fun Int.times(str: String) = str.repeat(this)

val result = 3 times "Hello " // → "Hello Hello Hello "

Используется в тестовых фреймворках (1 shouldBe 1), маршрутизации (get "/users" { ... }).

18.2. Операторные функции

Перегрузка операторов (+, -, [], invoke, ..) через operator fun:

operator fun User.plus(other: User) = User(this.id + other.id, "${this.name}, ${other.name}")

val combined = user1 + user2

Важно: перегрузка должна сохранять семантику оператора (например, + — коммутативен), иначе читаемость страдает.

18.3. @DslMarker: защита от неправильного вложения

Аннотация предотвращает случайный выход из контекста DSL:

@DslMarker
annotation class HtmlTagMarker

@HtmlTagMarker
abstract class Tag {
abstract fun render(): String
}

class Body : Tag() {
override fun render() = "<body>...</body>"
}

// Внутри HtmlDsl нельзя вызывать методы вне текущего тега

Это гарантирует, что в html { body { div { ... } } } нельзя «выпрыгнуть» из div в html напрямую — повышает безопасность DSL.


19. Аннотации как расширения языка

Аннотации в Kotlin — не просто метаданные. Они активно участвуют в компиляции и позволяют встраивать новые возможности без изменения синтаксиса ядра.

19.1. @Serializable: сериализация как часть типа

@Serializable
data class User(val id: Int, val name: String)

val json = Json.encodeToString(User(1, "Alice"))
val user = Json.decodeFromString<User>(json)

Плагин компиляции генерирует сериализатор на этапе компиляции — без reflection, быстро и безопасно. Поддерживает JSON, ProtoBuf, CBOR.

19.2. @OptIn и @RequiresOptIn: контролируемое использование экспериментальных API

Разработчик API может пометить функцию как экспериментальную:

@RequiresOptIn(message = "Эта функция может измениться")
annotation class ExperimentalIo

@ExperimentalIo
suspend fun experimentalRead() = ...

Пользователь должен явно согласиться:

@OptIn(ExperimentalIo::class)
suspend fun load() = experimentalRead()

Это предотвращает случайное использование unstable-кода и даёт понятную миграционную траекторию.

19.3. @Deprecated: эволюция API с контролем

Расширенная версия @Deprecated из Java:

@Deprecated(
message = "Используйте processAsync",
replaceWith = ReplaceWith("processAsync()", "import suspend processAsync"),
level = DeprecationLevel.ERROR
)
fun process() { ... }

Компилятор может:

  • Предупреждать (WARNING);
  • Запрещать использование (ERROR);
  • Автоматически предлагать замену (в IDE).

Это инструмент ответственного рефакторинга.