Типы данных и объявление переменных в 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 состоит из трёх частей:
- Ключевого слова (
valилиvar); - Идентификатора — имени переменной, удовлетворяющего правилам именования (начинается с буквы или символа подчёркивания, далее буквы, цифры,
$,_); - Необязательной аннотации типа (например,
: Int); - Оператора присваивания
=; - Выражения инициализации — литерала, вызова функции, арифметического выражения и т.п.
Примеры:
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, что позволяет выполнять базовые арифметические операции и преобразования.
| Тип | Размер в битах | Диапазон значений | Литералы и особенности |
|---|---|---|---|
Byte | 8 | от –128 до 127 | val b: Byte = 100 |
Short | 16 | от –32 768 до 32 767 | val s: Short = 30_000 |
Int | 32 | от –2 147 483 648 до 2 147 483 647 | val i = 42 (по умолчанию) |
Long | 64 | от –9 223 372 036 854 775 808 до 9 223 372 036 854 775 807 | val 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.
| Тип | Размер в битах | Точность (примерно) | Литералы |
|---|---|---|---|
Float | 32 | 6–7 десятичных цифр | 3.14f, 2e10f |
Double | 64 | 15–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-строка из внешнего источника.!!принудительно считает значение ненулевым и открывает доступ к APIString.- При ошибочной предпосылке (
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-типов
-
Избегайте nullable там, где это возможно. Если значение всегда должно присутствовать (например, идентификатор сущности после сохранения в БД), используйте non-nullable тип. Это делает контракт API чётким.
-
Используйте
valи non-nullable по умолчанию. Меняйте наvarи?только при наличии веских причин. -
Не злоупотребляйте
!!. Если вы часто используете!!, это признак того, что:- либо вы взаимодействуете с небезопасным Java API — в этом случае стоит обернуть вызов в функцию с явной обработкой
null; - либо логика программы не гарантирует наличие значения — тогда следует пересмотреть архитектуру.
- либо вы взаимодействуете с небезопасным Java API — в этом случае стоит обернуть вызов в функцию с явной обработкой
-
Предпочитайте
?:и?.letявнымif-проверкам, когда это улучшает читаемость. Однако для сложной логики с несколькими ветвлениямиifостаётся более прозрачным. -
Документируйте причину nullable-состояния. Если свойство может быть
null, укажите в KDoc, почему — "не инициализировано до первого вызова", "отсутствует в legacy-данных", "опциональный параметр API". -
Используйте 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.