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

Типы данных и объявление переменных в Kotlin

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

Дальше: Коллекции и Sequence — сводка операций List / Set / Map · Справочник Kotlin


Типы данных и переменные в Kotlin​

Kotlin наследует статическую и сильную модель JVM (как Java): типы проверяются компилятором, вывод типов сокращает шаблонный код. Теория — типы данных, типизация, переменные и области видимости.

Эта тема кажется базовой, но от неё зависит почти весь дальнейший Kotlin-код. Когда тип выбран точно, IDE подсказывает корректные операции, компилятор ловит ошибки раньше запуска, а API становится понятнее всей команде.

Практический фокус

Читайте раздел так: сначала val/var и nullable, затем коллекции и generics. После этого переходите в Коллекции и Sequence и Важные классы stdlib, чтобы закрепить типы на реальных задачах.


Переменные и константы — семантика и назначение​

В Kotlin существует два ключевых способа связывания имени с данными: переменные (var) и константы (val). Несмотря на сходство в синтаксисе объявления, различия между ними принципиальны и влияют как на поведение программы, так и на её читаемость и надёжность.


Константы (val)​

Ключевое слово val (сокращение от value) вводит неизменяемую ссылку на значение. После инициализации переменная, объявленная с val, не может быть переприсвоена другому значению. Это не означает, что само значение обязательно "неизменяемо" в смысле внутреннего состояния — например, val list = mutableListOf(1, 2, 3) остаётся изменяемым списком, и к нему можно добавлять элементы. Однако сама ссылка list останется неизменной: попытка написать list = mutableListOf(4, 5) приведёт к ошибке компиляции.

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


Переменные (var)​

Ключевое слово var (сокращение от variable) объявляет изменяемую переменную. Ей можно присвоить новое значение в любой момент после инициализации, при условии, что новое значение совместимо по типу с объявлением. Переменные используются для хранения состояния, которое по своей природе изменяется в процессе выполнения — например, счётчик цикла, накопительная сумма, текущая позиция в потоке данных.

Kotlin поощряет декларативный стиль программирования, где данные преобразуются через функции, а не модифицируются на месте. Поэтому чрезмерное использование var считается плохой практикой, если только изменяемость не является неотъемлемой частью логики (например, при оптимизации производительности через мутабельные аккумуляторы).


Объявление и инициализация​

Объявление переменной или константы в Kotlin состоит из трёх частей:

  1. Ключевого слова (val или var);
  2. Идентификатора — имени переменной, удовлетворяющего правилам именования (начинается с буквы или символа подчёркивания, далее буквы, цифры, $, _);
  3. Необязательной аннотации типа (например, : Int);
  4. Оператора присваивания =;
  5. Выражения инициализации — литерала, вызова функции, арифметического выражения и т.п.

Примеры:

val pi: Double = 3.1415926535
var counter: Int = 0
val greeting = "Добро пожаловать"

Разбор:

  • val pi: Double = ... задаёт неизменяемую ссылку с явным типом Double; присвоить новое значение позже нельзя.
  • var counter: Int = 0 объявляет изменяемую переменную-счётчик, её можно увеличивать и переиспользовать в циклах.
  • val greeting = "..." показывает вывод типа: компилятор сам определяет String по строковому литералу.
  • Блок демонстрирует три базовых паттерна: val с явным типом, var с явным типом и val с неявным типом.

В первых двух строках тип указан явно. В третьем случае компилятор выводит, что значение "Добро пожаловать" является строковым литералом, и, следовательно, тип переменной greeting — String. Это — работа механизма вывода типов.

Kotlin требует, чтобы все переменные и константы были инициализированы до первого использования. Отложенная инициализация возможна, но только при явном указании через специальные механизмы (lateinit var, делегированные свойства), которые рассматриваются в отдельных главах.


Явное указание типа — когда и зачем​

Хотя вывод типов устраняет большую часть необходимости в аннотациях, существуют случаи, когда явное указание типа предпочтительно:

  • Повышение читаемости в сложных выражениях, где выводимый тип неочевиден читающему;
  • Ограничение обобщённого типа, когда литерал может иметь несколько интерпретаций (например, 0 может быть Int, Long, Byte — без аннотации или контекста компилятор выберет Int);
  • Документирование намерений, особенно при работе с API, где конкретный тип критичен;
  • Работа с nullable-типами, где важно явно различать String и String?;
  • Объявление свойств класса без немедленной инициализации, где тип должен быть известен компилятору заранее (например, в теле класса: var user: User? = null).

В остальных случаях предпочтителен краткий синтаксис без аннотации.


Play ITЗагрузка интерактивного демо…


Встроенные типы данных​

Kotlin не использует "примитивы" в том смысле, как это сделано в Java на уровне виртуальной машины. Вместо этого все значения являются объектами, но компилятор и среда выполнения выполняют специализацию — для эффективности базовые типы (числа, логические значения, символы) представляются соответствующими JVM-примитивами (int, long, boolean и т.д.) на этапе генерации байт-кода, если это возможно. Для программиста же весь набор типов выглядит единообразно: у каждого есть методы и свойства, доступные через точечную нотацию.

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


Целочисленные типы​

Kotlin предоставляет четыре знаковых целочисленных типа фиксированной разрядности. Все они реализуют интерфейс Number, что позволяет выполнять базовые арифметические операции и преобразования.

ТипРазмер в битахДиапазон значенийЛитералы и особенности
Byte8от –128 до 127val b: Byte = 100
Short16от –32 768 до 32 767val s: Short = 30_000
Int32от –2 147 483 648 до 2 147 483 647val i = 42 (по умолчанию)
Long64от –9 223 372 036 854 775 808 до 9 223 372 036 854 775 807val l = 123L (суффикс L обязателен)

Отметим несколько важных моментов:

  • Беззнаковые типы. До Kotlin 1.3 в стандартной библиотеке были только знаковые целые. С Kotlin 1.3 появились UByte, UShort, UInt, ULong (сначала experimental), со stable-статусом — начиная с 1.5.
  • Литералы без суффикса всегда интерпретируются компилятором как Int, если значение укладывается в его диапазон. Если значение выходит за пределы Int, компилятор попытается использовать Long, но только при наличии суффикса L. Попытка записать val big = 3_000_000_000 (без L) вызовет ошибку компиляции, поскольку значение превышает лимит Int.
  • Разделители разрядов (_) разрешены внутри числовых литералов и игнорируются компилятором: 1_000_000, 0xFF_EC_DE_5E.

Вещественные типы​

Для представления чисел с плавающей точкой Kotlin предоставляет два типа, соответствующих стандарту IEEE 754.

ТипРазмер в битахТочность (примерно)Литералы
Float326–7 десятичных цифр3.14f, 2e10f
Double6415–16 десятичных цифр3.14, 2.718281828459045, 1.5e-10

Важные детали:

  • Литералы без суффикса интерпретируются как Double. Для получения Float необходимо явно указать суффикс f или F.
  • Тип Double используется по умолчанию в математических вычислениях, если не требуется экономия памяти или совместимость с внешним интерфейсом (например, OpenGL).
  • Поскольку числа с плавающей точкой хранятся в двоичном виде, операции с ними не являются абсолютно точными. Это общеизвестное ограничение арифметики с плавающей точкой, и оно не специфично для Kotlin.

Логический тип Boolean​

Тип Boolean принимает ровно два значения: true и false. Он используется в условиях, циклах, логических выражениях. В Kotlin отсутствует неявное приведение Boolean к числовому типу и наоборот — невозможно написать if (1) или if (null). Это исключает распространённые ошибки, характерные для языков с "истинностью" значений.


Символьный тип Char​

Тип Char представляет один символ в кодировке UTF-16. Литералы записываются в одинарных кавычках — 'A', 'я', '€', '\n', '\u00A9'. Несмотря на то, что в JVM символы хранятся как 16-битные единицы, Kotlin скрывает детали представления и предоставляет методы для работы с Unicode-символами (например, code, isDigit()).

Важно: Char — это не число, и его нельзя напрямую использовать в арифметических операциях. Для получения числового кода символа следует использовать свойство code:

val c = 'A'
val code = c.code // 65

Разбор:

  • val c = 'A' создаёт символ (Char), а не строку и не число.
  • Свойство code возвращает UTF-16 код символа, поэтому для 'A' получаем 65.
  • Этот приём нужен, когда требуется перейти от текстового представления к числовому (например, в алгоритмах шифрования или парсинга).
  • Обратное преобразование обычно делается через toChar() у числа.

Строковый тип String​

String — неизменяемая последовательность символов (Char). Строковые литералы заключаются в двойные кавычки — "Привет", "Kotlin\n", """Многострочный текст""". Kotlin поддерживает интерполяцию строк через символ $:

val name = "Тимур"
val message = "Здравствуйте, $name!"
val lengthInfo = "Длина имени: ${name.length}"

Разбор:

  • name хранит исходное строковое значение, которое потом переиспользуется в шаблонах.
  • $name вставляет значение переменной напрямую в строку без конкатенации через +.
  • ${name.length} позволяет выполнить выражение внутри шаблона и встроить его результат.
  • Такой формат уменьшает шум в коде и делает формирование сообщений более читаемым.

Внутри ${} может стоять любое выражение, результат которого преобразуется в строку вызовом toString().

Строки в Kotlin индексируются от нуля, поддерживают безопасный доступ к символам (str.getOrNull(index)), итерацию, сравнение лексикографически. Все строки неизменяемы, что гарантирует потокобезопасность и позволяет эффективно использовать внутренний пул строк.


Play ITЗагрузка интерактивного демо…


Вывод типов — принципы и ограничения​

Механизм вывода типов в Kotlin реализован на уровне компилятора и активируется при инициализации переменной или константы значением, тип которого однозначно определим. Рассмотрим несколько сценариев.


Прямой вывод из литерала​

val x = 42 // Int
val y = 42L // Long
val z = 3.14 // Double
val w = 3.14f // Float
val flag = true // Boolean
val letter = 'K' // Char
val text = "Hello" // String

Разбор:

  • Суффикс L у 42L принудительно задаёт тип Long, иначе целый литерал был бы Int.
  • 3.14 по умолчанию трактуется как Double, а 3.14f через суффикс становится Float.
  • Булевы, символьные и строковые литералы (true, 'K', "Hello") выводятся в Boolean, Char, String.
  • Блок полезен как компактная шпаргалка по правилам type inference для литералов в Kotlin.

Компилятор сопоставляет форму литерала с известными шаблонами и назначает соответствующий тип.


Вывод из вызова функции​

fun currentTimeMillis(): Long = System.currentTimeMillis()

val now = currentTimeMillis() // Long

Разбор:

  • Функция явно объявляет возвращаемый тип Long, поэтому вызов всегда даёт 64-битное целое.
  • System.currentTimeMillis() возвращает Unix-время в миллисекундах с эпохи 1970-01-01 UTC.
  • val now = ... наследует тип из сигнатуры функции, без дополнительной аннотации у переменной.
  • Такой подход делает код короче и одновременно сохраняет строгую типизацию.

Здесь тип now выводится из объявления возвращаемого значения функции currentTimeMillis().


Вывод в контексте​

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

listOf(1, 2, 3).map { it * 2 } // it имеет тип Int, результат — List<Int>

Компилятор знает сигнатуру map, ожидает преобразователь T → R, и, зная, что исходный список — List<Int>, выводит T = Int, а затем R = Int, так как it * 2 — выражение типа Int.


Когда вывод невозможен​

Если переменная объявляется без инициализации или инициализируется выражением с неопределённым типом (например, null без аннотации), компилятор требует явного указания:

val name: String? // допустимо: тип объявлен, инициализация может быть отложена
val size // ошибка: тип неизвестен, инициализатор отсутствует
val data = null // ошибка: null сам по себе не имеет типа

Разбор:

  • name — String? демонстрирует, что nullable-тип можно объявить заранее, но требуется корректная инициализация по правилам контекста.
  • val size без типа и без значения запрещён, потому что компилятор не может вывести тип.
  • val data = null даёт неоднозначность: null не несёт конкретного целевого типа без дополнительной подсказки.
  • Для исправления обычно задают тип явно, например val data: String? = null.

В последнем случае необходимо либо указать тип (val data — String? = null), либо инициализировать значением, не равным null.


Nullable-типы — философия и синтаксис​

В большинстве современных языков программирования ссылочные типы по умолчанию допускают значение null, что означает "отсутствие объекта". Эта особенность — источник множества ошибок времени выполнения, в первую очередь NullPointerException (NPE), получившего известность как "миллиардная ошибка" по выражению Тони Хоара, который ввёл концепцию null в ALGOL. Kotlin решает эту проблему радикально: по умолчанию все типы не допускают null.

Это достигается за счёт разделения пространства типов на две категории:

  • Non-nullable типы — например, String, Int, List<String>. Переменная такого типа гарантированно содержит значение. Присвоить ей null невозможно — компилятор выдаст ошибку.
  • Nullable типы — обозначаются суффиксом ? — String?, Int?, List<String>?. Переменная такого типа может содержать либо значение соответствующего non-nullable типа, либо null.

Таким образом, возможность отсутствия значения становится частью сигнатуры типа, а не скрытым свойством реализации. Это позволяет компилятору отслеживать все потенциально опасные операции на этапе компиляции.


Объявление nullable-переменных​

Объявление nullable-переменной ничем не отличается по структуре от обычного, кроме добавления ? к имени типа:

var name: String? = "Тимур"
name = null // допустимо
val count: Int? = null
val items: List<String>? = null

Разбор:

  • Суффикс ? у типа разрешает хранить как реальное значение, так и null.
  • name объявлен через var, поэтому можно сначала присвоить строку, а потом явно установить null.
  • count — Int? и items — List<String>? показывают, что nullable работает не только для строк, но и для чисел и коллекций.
  • Такой дизайн фиксирует возможность отсутствия данных прямо в контракте типа.

Обратите внимание: суффикс ? применяется к всему типу, а не к имени переменной. Это означает, что String? — это отдельный, самостоятельный тип, связанный с String отношением субтипирования: String является подтипом String?, но не наоборот. Эта иерархия позволяет безопасно присваивать non-nullable значения nullable-переменным, но запрещает обратное без проверки.


Попытка присвоить null non-nullable переменной​

Следующий код не скомпилируется:

val message: String = null // Ошибка: Type mismatch.
// Required: String
// Found: Nothing?

Компилятор сообщает, что требуется String, а предоставлено null, тип которого в Kotlin интерпретируется как Nothing? — специальный нижний тип, не имеющий значений кроме null. Таким образом, ошибка обнаруживается до запуска программы.

Аналогично запрещено вызывать методы или обращаться к свойствам nullable-переменной напрямую:

val text: String? = "Пример"
println(text.length) // Ошибка: Only safe (?.) or non-null asserted (!!.) calls are allowed

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


Операторы работы с nullable-типами​

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


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

Оператор ?. позволяет выполнить вызов метода или доступ к свойству только в том случае, если ссылка не равна null. Если значение null, результатом всего выражения будет null.

val text: String? = "Привет"
val length: Int? = text?.length // 6

val empty: String? = null
val len: Int? = empty?.length // null

Разбор:

  • ?. выполняет доступ к свойству только если ссылка слева не равна null.
  • В первой части text содержит строку, поэтому text?.length возвращает 6.
  • Во второй части empty равен null, поэтому выражение безопасно даёт null, без исключения.
  • Итоговый тип — Int?, потому что результат потенциально может отсутствовать.

Обратите внимание на тип результата: Int?, а не Int. Это логично — поскольку исходное значение может быть null, результат также может быть null. Оператор ?. сохраняет nullability в цепочке вызовов:

val author: Person? = getAuthor() // предположим, что Person имеет свойство name: String
val firstChar: Char? = author?.name?.firstOrNull()

Здесь, если author == null или author.name == null, выражение завершится с null, не вызвав исключения. Метод firstOrNull() возвращает Char?, что делает цепочку полностью безопасной.


Оператор элвиса (?:)​

Оператор "элвис" (?:) — альтернатива тернарному условию, специализированная для обработки null. Он возвращает левый операнд, если он не равен null, и правый — в противном случае.

val name: String? = null
val displayName = name ?: "Гость" // "Гость"

val configPath: String? = readConfigPath()
val actualPath = configPath ?: "/etc/app.conf"

Разбор:

  • В обоих случаях ?: подставляет запасное значение при null слева.
  • displayName и actualPath после выражения становятся пригодными для использования без доп. null-проверок.
  • Такой подход централизует fallback-логику и делает её явной в одной строке.
  • Паттерн особенно удобен на границах системы — конфиг, пользовательский ввод, внешний API.

Правый операнд может быть любым выражением, включая вызов функции, бросание исключения или логирование:

val userId: String? = request.getParameter("id")
val id = userId ?: throw IllegalArgumentException("ID обязателен")

Разбор:

  • userId может отсутствовать, поэтому имеет nullable-тип.
  • В правой части Elvis стоит throw, что немедленно завершает выполнение при null.
  • Если userId не null, id получает валидное строковое значение.
  • Шаблон помогает быстро валидировать обязательные входные данные.

Важно: правый операнд не вычисляется, если левый не null. Это поведение lazy evaluation обеспечивает эффективность и безопасность.


Оператор принудительного разыменования (!!)​

Оператор !! — небезопасный оператор, который превращает nullable-значение в non-nullable, бросая NullPointerException, если значение равно null. Его следует использовать крайне редко — только когда программист абсолютно уверен в ненулевости значения, а компилятор не может это подтвердить (например, при взаимодействии с Java-кодом без аннотаций @NonNull).

val s: String? = possiblyNullString()
val len = s!!.length // если s == null → NPE

Разбор:

  • s приходит как nullable-строка из внешнего источника.
  • !! принудительно считает значение ненулевым и открывает доступ к API String.
  • При ошибочной предпосылке (s == null) здесь будет NullPointerException.
  • Использовать стоит только при сильной внешней гарантии или в тестовых сценариях.

Применение !! — сигнал о том, что гарантии языка нарушены. В хорошо спроектированном Kotlin-коде его наличие должно быть обосновано и, по возможности, локализовано.


Безопасное приведение (as?)​

Оператор as? выполняет приведение типа и возвращает null, если приведение невозможно, вместо выбрасывания ClassCastException.

val obj: Any = "Текст"
val str: String? = obj as? String // "Текст"
val num: Int? = obj as? Int // null

Разбор:

  • obj имеет общий тип Any, поэтому компилятор не знает конкретный runtime-тип заранее.
  • as? выполняет безопасное приведение и возвращает null вместо исключения при несовпадении.
  • Приведение к String успешно, а к Int нет — это видно по результатам str и num.
  • Паттерн удобен для обработки неоднородных данных из внешних систем.

Это особенно полезно в сочетании с ?::

val value = input as? String ?: input.toString()

Разбор:

  • Сначала система пытается трактовать input как String.
  • Если это удалось, используется исходная строка без дополнительных преобразований.
  • Если нет, вызывается toString() и формируется строковое представление альтернативным путём.
  • Конструкция объединяет типовую проверку и fallback в одной выразительной строке.

Smart cast — автоматический вывод non-null состояния​

Одна из сильнейших сторон Kotlin — механизм smart cast (умное приведение типов). После проверки переменной на null в условиях if, when, или при использовании оператора !! в узком контексте, компилятор запоминает это знание и позволяет использовать переменную как non-nullable внутри блока, где гарантия сохраняется.

fun process(text: String?) {
if (text != null) {
// Внутри этого блока 'text' автоматически имеет тип String
println("Длина: ${text.length}") // OK: нет ?. и !!
println("Первый символ: ${text[0]}") // OK
}
// За пределами блока — снова String?
}

Разбор:

  • Сигнатура text: String? фиксирует возможность null на входе.
  • Проверка if (text != null) активирует smart cast: внутри блока text рассматривается как String.
  • Благодаря этому можно безопасно вызывать length и индексатор без ?. и !!.
  • За пределами блока гарантия исчезает, тип снова становится nullable.

Smart cast работает с множеством конструкций:

  • Проверка is / !is для приведения типов;
  • Цепочки условий (text != null && text.isNotEmpty());
  • Использование let, also, run с nullable-приёмниками.

Пример с let:

text?.let { nonNullText ->
// nonNullText имеет тип String
println("Обработка: $nonNullText")
}

Разбор:

  • let запускается только если text не равен null.
  • Параметр nonNullText внутри лямбды всегда non-null, что упрощает дальнейшую обработку.
  • Такой приём локализует область, где значение гарантированно присутствует.
  • Паттерн хорошо подходит для коротких действий над опциональными данными.

Здесь let вызывается только если text не null, и лямбда получает параметр non-nullable типа. Это идиоматичный способ выполнить побочный эффект при наличии значения.

Важное ограничение: smart cast не работает для var-переменных, если между проверкой и использованием возможна модификация из другого потока или через setter. В таких случаях компилятор сохраняет nullable-тип, чтобы избежать гонки данных. Для val ограничение не действует, так как значение неизменно.


Работа с nullable-числами и примитивами​

Для числовых типов nullable-варианты (Int?, Double? и т.д.) ведут себя аналогично ссылочным, но требуют особого внимания при арифметике. Арифметические операторы (+, -, *, /) не определены для nullable-типов напрямую:

val a: Int? = 5
val b: Int? = 3
// val sum = a + b // Ошибка: Operator '+' cannot be applied to 'Int?' and 'Int?'

Разбор:

  • Int? может содержать null, поэтому прямой оператор + не разрешён компилятором.
  • Комментарий показывает типичную ошибку при попытке арифметики над nullable-числами.
  • Перед вычислением нужно либо проверить null-состояния, либо подставить значения по умолчанию.
  • Это ограничение защищает код от скрытых NPE в арифметике.

Необходимо либо использовать безопасные операторы в цепочке, либо преобразовать к non-nullable:

val sum = a?.plus(b ?: 0) // 5 + 3 = 8
val product = if (a != null && b != null) a * b else null

Альтернатива — использование функций-расширений из стандартной библиотеки, например takeIf, let, или написание собственных утилит.

Сравнение nullable-чисел с помощью операторов <, <=, >, >= также запрещено. Разрешены только == и !=, где null == null → true, а null == 5 → false.


Практические рекомендации по использованию nullable-типов​

  1. Избегайте nullable там, где это возможно. Если значение всегда должно присутствовать (например, идентификатор сущности после сохранения в БД), используйте non-nullable тип. Это делает контракт API чётким.

  2. Используйте val и non-nullable по умолчанию. Меняйте на var и ? только при наличии веских причин.

  3. Не злоупотребляйте !!. Если вы часто используете !!, это признак того, что:

    • либо вы взаимодействуете с небезопасным Java API — в этом случае стоит обернуть вызов в функцию с явной обработкой null;
    • либо логика программы не гарантирует наличие значения — тогда следует пересмотреть архитектуру.
  4. Предпочитайте ?: и ?.let явным if-проверкам, когда это улучшает читаемость. Однако для сложной логики с несколькими ветвлениями if остаётся более прозрачным.

  5. Документируйте причину nullable-состояния. Если свойство может быть null, укажите в KDoc, почему — "не инициализировано до первого вызова", "отсутствует в legacy-данных", "опциональный параметр API".

  6. Используйте sealed-классы или Result для представления ошибок и альтернативных состояний, когда null не передаёт достаточной семантики. Например, вместо String? для результата парсинга лучше использовать sealed interface ParseResult { data class Success(val value: String) : ParseResult; object Failure : ParseResult }.


Мини-паттерны для повседневной типизации​

Ниже короткие шаблоны, которые регулярно встречаются в прикладном коде.


1. "Сырые" данные из внешнего API​

Код ITЗагрузка примера кода…

Идея: nullable оставляем на границе системы (DTO), а во внутренней модели держим строгие non-null поля.


2. Явный тип в публичном API​

fun buildTags(input: List<String>): Set<String> =
input
.map { it.trim().lowercase() }
.filter { it.isNotEmpty() }
.toSet()

Возвращаем Set<String>, чтобы контракт сразу показывал "дубликатов не будет". Это повышает читаемость кода в вызывающей стороне.


3. Безопасный разбор числа​

fun parsePort(raw: String?): Int =
raw?.toIntOrNull()?.takeIf { it in 1..65535 } ?: 8080

Такая запись сразу покрывает null, пустое значение и выход за диапазон.


Тип Nothing и его роль в системе типов​

Для полноты картины упомянем тип Nothing. Это нижний тип в иерархии Kotlin — он является подтипом всех остальных типов и не имеет значений. Единственный способ "получить" значение Nothing — завершить выполнение (например, бросить исключение или вызвать exitProcess).

fun fail(message: String): Nothing {
throw IllegalStateException(message)
}

Здесь возвращаемый тип Nothing сообщает компилятору, что функция никогда не завершится нормально. Это позволяет, например, использовать fail в ветке else, не нарушая правила инициализации:

val result: String = when (input) {
"ok" -> "Готово"
"error" -> fail("Ошибка")
else -> "Неизвестно" // обязателен, иначе тип результата был бы Nothing
}

Nothing? — это тип, который может быть только null. Именно к нему относится литерал null в отсутствие контекста.


Play ITЗагрузка интерактивного демо…


Пользовательские типы — классы и другие формы определения структур данных​

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


Обычные классы​

Объявление класса в Kotlin лаконично:

class Person(val name: String, var age: Int)

Здесь конструктор является первичным (primary constructor), и параметры name и age автоматически становятся свойствами: val — неизменяемым, var — изменяемым. Это не синтаксический "сокращатель" — это полноценная идиома языка, отражающая приверженность Kotlin принципам краткости без потери выразительности.

Класс может содержать:

  • вторичные конструкторы (constructor);
  • свойства (val/var);
  • функции-члены;
  • вложенные и внутренние классы;
  • init-блоки для инициализации.

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

class Empty
// эквивалентно
class Empty {}

data-классы​

Особый вид класса — data class — предназначен для хранения данных. При объявлении с ключевым словом data компилятор автоматически генерирует следующие методы:

  • equals() и hashCode() — на основе всех свойств, объявленных в первичном конструкторе;
  • toString() — в форме ClassName(prop1=value1, prop2=value2);
  • copy() — функция для создания копии с изменёнными полями;
  • компонентные функции (component1(), component2(), …) — для деструктуризации.

Пример:

data class Point(val x: Int, val y: Int)

val p1 = Point(1, 2)
val p2 = p1.copy(y = 5) // Point(1, 5)
val (a, b) = p1 // a = 1, b = 2

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

  • Должен иметь хотя бы один параметр в первичном конструкторе;
  • Все параметры конструктора должны быть помечены как val или var;
  • Не может быть абстрактным, open, sealed или внутренним;
  • Наследование разрешено только от интерфейсов (не от других классов), если не указано open.

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


Sealed-классы и интерфейсы​

sealed class (и с Kotlin 1.5 — sealed interface) — механизм для определения замкнутых иерархий типов. Это означает, что все прямые подклассы должны быть объявлены в том же файле, что и сам sealed-тип. Компилятор знает полный набор возможных подтипов, что позволяет использовать when без ветки else при обработке значений sealed-типа.

sealed class Result
data class Success(val data: String) : Result()
data class Failure(val reason: String) : Result()
object Loading : Result()

Обработка:

fun handle(result: Result) = when (result) {
is Success -> println("Успех: ${result.data}")
is Failure -> println("Ошибка: ${result.reason}")
Loading -> println("Загрузка…")
// else не требуется — компилятор проверил полноту
}

Sealed-типы особенно ценны в функциональном стиле, при реализации state-машин, обработке событий и в архитектурах типа MVI или Redux, где состояние моделируется как неизменяемая сумма типов.


Enum-классы​

Перечисления в Kotlin — полноценные классы, наследующие от Enum<T>. Они могут содержать свойства, методы, реализовывать интерфейсы.

enum class LogLevel(val priority: Int) {
DEBUG(1), INFO(2), WARN(3), ERROR(4);

fun shouldLog(current: LogLevel): Boolean = this.priority <= current.priority
}

Каждая константа — это singleton-объект, инициализированный при первом доступе к перечислению.

Enum-классы поддерживают стандартные методы — values(), valueOf(), ordinal, а также безопасную деструктуризацию и when-обработку.


Тип-псевдоним (typealias)​

Иногда длинные обобщённые типы затрудняют чтение:

val handlers: Map<String, (Request) -> Response>

Для повышения выразительности и абстрагирования от конкретной реализации используется typealias:

typealias RequestHandler = (Request) -> Response
typealias RouteTable = Map<String, RequestHandler>

val routes: RouteTable = mapOf(
"/api/users" to { req -> UsersResponse() }
)

Важно: typealias — это чисто компиляторная замена. Во время выполнения псевдоним исчезает, и тип остаётся тем же. Это означает, что RouteTable и Map<String, (Request) -> Response> взаимозаменяемы и не дают дополнительной безопасности типов, но значительно улучшают читаемость и рефакторинг.


Play ITЗагрузка интерактивного демо…


Обобщённые типы (generics)​

Kotlin поддерживает параметризацию типов, что позволяет писать переиспользуемый и типобезопасный код. Синтаксис аналогичен Java и C#, но с рядом важных уточнений и расширений. Общая теория — Обобщения и обобщённое программирование.


Базовый синтаксис​

class Box<T>(val value: T)

val intBox = Box(42) // Box<Int>
val strBox = Box("Text") // Box<String>

Типовой параметр T заменяется конкретным типом на этапе компиляции (стирание типов, type erasure), но благодаря выводу типов и smart cast, безопасность сохраняется.


Ограничения типов (where и :)​

Можно наложить ограничения на допустимые типы:

fun <T> T.printIfToString() where T : Any {
println(this.toString())
}

Здесь T — Any означает, что T не может быть Nothing или nullable-типом без конкретизации (например, String? допустим, так как String? : Any?, но при явном : Any требуется non-nullable). Более сложные ограничения:

fun <T> process(item: T) where T : CharSequence, T : Comparable<T> {
println("Длина: ${item.length}, сравнение: ${item.compareTo("base")}")
}

Вариантность — in, out, и проекции​

Вариантность — одна из наиболее тонких тем в обобщённых типах. В Java для этого используются wildcard-типы (? extends T, ? super T). Kotlin вводит понятия ковариантности (out) и контравариантности (in) на уровне объявления типа.

  • out T — тип может только возвращать значения T, но не принимать их. Это делает контейнер читаемым. Пример: List<out T> (на самом деле List<T> объявлен как interface List<out E>).
  • in T — тип может только принимать значения T, но не возвращать. Это делает контейнер записываемым. Пример: Comparable<in T> (интерфейс Comparable<in T> позволяет сравнивать объекты, даже если тип аргумента шире).

Пример ковариантности:

val strings: List<String> = listOf("a", "b")
val anys: List<Any> = strings // OK, потому что List<out E>

Без out такое присваивание было бы запрещено.

Для конкретных случаев, когда требуется временно изменить вариантность, Kotlin поддерживает проекции типов:

fun consume(list: List<out CharSequence>) { /* читаем, но не пишем */ }
fun produce(list: MutableList<in String>) { /* пишем String, но не читаем как String */ }

Эти механизмы позволяют использовать обобщённые типы безопасно и гибко, избегая избыточных копий и приведений.


Расширения — функции и свойства вне класса​

Одна из самых выразительных возможностей Kotlin — расширения (extensions). Они позволяют добавлять новые функции и свойства к уже существующим классам без наследования и модификации исходного кода.


Функции-расширения​

fun String.lastChar(): Char? = if (isEmpty()) null else this[length - 1]

println("Kotlin".lastChar()) // 'n'

Синтаксически функция выглядит как член класса, но компилируется в статический метод, принимающий экземпляр в качестве первого параметра. this внутри расширения ссылается на получатель (receiver).

Важные свойства:

  • Расширения не имеют доступа к private-членам получателя;
  • Они разрешаются статически, то есть при вызове obj.extension() выбор функции зависит от объявленного типа obj, а не от его реального типа во время выполнения;
  • Могут быть generic: fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null.

Свойства-расширения​

Аналогично можно объявлять вычисляемые свойства:

val String.wordCount: Int
get() = split(Regex("\\s+")).size

println("Hello world".wordCount) // 2

Свойства-расширения не могут иметь backing field, поэтому поддерживаются только get-выражения (и set, если свойство изменяемое — но тогда необходимо явно управлять хранением).


Расширения с ограничениями​

Можно определять расширения только для подтипов:

fun <T : Comparable<T>> List<T>.isSorted(): Boolean =
zipWithNext().all { (a, b) -> a <= b }

Такое расширение доступно только для списков элементов, реализующих Comparable<T>.

Расширения — инструмент композиции. Они позволяют организовывать код по признаку использования, а не по иерархии наследования.


Делегированные свойства​

Kotlin поддерживает паттерн делегирование на уровне синтаксиса. С помощью ключевого слова by свойство может делегировать свою логику получения и установки значений внешнему объекту.


Встроенные делегаты​

Стандартная библиотека предоставляет несколько полезных делегатов:

  • by lazy { … } — отложенная инициализация (только для val), потокобезопасная по умолчанию:
val database by lazy { Database.connect() }
  • by Delegates.observable(initial) { prop, old, new -> … } — выполнение callback при изменении значения (var):
var name: String by Delegates.observable("Гость") { _, old, new ->
println("Имя изменено: $old → $new")
}
  • by Delegates.vetoable { … } — возможность отменить присваивание на основе условия.

  • by map — чтение из Map по ключу, например, для конфигураций:

class Config(map: Map<String, Any?>) {
val host: String by map
val port: Int by map
}
val config = Config(mapOf("host" to "localhost", "port" to 8080))

Пользовательские делегаты​

Любой класс, реализующий интерфейсы ReadOnlyProperty (для val) или ReadWriteProperty (для var), может выступать делегатом. Это позволяет реализовать кэширование, валидацию, логирование доступа и другие cross-cutting concerns без дублирования кода.

Пример: свойство, которое не может быть пустым:

class NonEmptyStringDelegate {
private var value: String = ""
operator fun getValue(thisRef: Any?, property: KProperty<*>): String = value
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
require(newValue.isNotEmpty()) { "Значение не может быть пустым" }
value = newValue
}
}

var title: String by NonEmptyStringDelegate()
title = "Kotlin" // OK
title = "" // Исключение

Делегированные свойства усиливают декларативность кода: вместо ручного написания геттеров и сеттеров с логикой, вы указываете поведение через делегат.


Типы во взаимодействии с другими экосистемами​

Совместимость с Java​

Kotlin полностью совместим с JVM-экосистемой. Однако Java не различает nullable и non-nullable типы. Kotlin при компиляции аннотирует параметры и возвращаемые значения специальными аннотациями (@Nullable, @NotNull), если они присутствуют в Java-коде. При отсутствии аннотаций тип импортируется как платформенный тип (String!), который ведёт себя как "небезопасный" — можно вызывать методы без ?., но при null может возникнуть NPE.

Рекомендация: при написании Java-кода для совместного использования с Kotlin — обязательно используйте @NonNull и @Nullable из org.jetbrains.annotations или javax.annotation.


Совместимость с JavaScript (Kotlin/JS)​

При компиляции в JavaScript типы стираются полностью, как и в TypeScript. Однако компилятор Kotlin/JS сохраняет проверки на null и генерирует runtime-проверки там, где это необходимо, чтобы сохранить семантику языка. Операторы ?., ?:, !! работают корректно.


Влияние на сериализацию и отражение​

При использовании библиотек вроде kotlinx.serialization, Jackson или Gson, nullable-тип влияет на поведение по умолчанию:

  • String? сериализуется как null, если значение null;
  • String (non-nullable) требует значения — при отсутствии поля в JSON может быть использовано значение по умолчанию или выброшено исключение;
  • data-классы с nullable-полями легко описывают необязательные поля в API.

Аннотации вроде @SerialName, @Required, @Transient позволяют тонко настраивать соответствие.


Практические сниппеты​

data class ProfileInput(
val nickname: String?,
val city: String?
)

fun normalize(input: ProfileInput): Pair<String, String> {
val nickname = input.nickname?.trim()?.takeIf { it.isNotBlank() } ?: "anonymous"
val city = input.city?.trim()?.replaceFirstChar { it.uppercase() } ?: "Unknown"
return nickname to city
}

Разбор:

  • ProfileInput хранит сырые nullable-данные, как они часто приходят с формы или API.
  • nickname проходит очистку (trim) и валидацию (takeIf), после чего получает fallback.
  • city нормализуется в единый формат и также закрывается значением по умолчанию.
  • Возвращаемая пара уже non-null и готова для безопасного использования в доменной логике.
fun parseLimit(raw: String?): Int {
val value = raw?.toIntOrNull() ?: return 50
return value.coerceIn(1, 500)
}

Разбор:

  • toIntOrNull() парсит число без исключений и возвращает null при некорректном вводе.
  • ?: return 50 мгновенно завершает функцию с дефолтом, если значение не удалось прочитать.
  • coerceIn(1, 500) ограничивает число допустимым диапазоном.
  • Сниппет показывает полный путь обработки пользовательского ввода: parse -> fallback -> clamp.