JavaRush /Курсы /Kotlin SELF /@Nullable/@NotNull и nullability Java API в Kotlin

@Nullable/@NotNull и nullability Java API в Kotlin

Kotlin SELF
29 уровень , 2 лекция
Открыта

1. Зачем Kotlin нужны @Nullable/@NotNull

Kotlin на JVM постоянно взаимодействует с Java‑миром: JDK, любые Java‑библиотеки, старый код в проекте. В Kotlin это выглядит «по‑котлиновски», но есть фундаментальная разница: в Java исторически null — обычное значение, и далеко не всегда из сигнатуры понятно, может ли метод вернуть null.

Отсюда и главная тема: как Kotlin узнаёт nullability у Java‑методов и коллекций, когда видит чёткий контракт (String или String?), а когда оставляет платформенную неопределённость (String!).

Когда вы пишете Kotlin‑код, null-safety кажется «магией компилятора»: написал String, и всё безопасно. Но как только вы зовёте Java‑код, появляется реальность: в Java null допускается почти везде, а ответ на вопрос «может ли метод вернуть null» часто хранится в голове автора библиотеки (а иногда — только в баг‑трекере).

Чтобы Kotlin мог быть строгим не только внутри Kotlin‑кода, но и при вызовах Java‑кода, ему нужны подсказки. Этими подсказками становятся аннотации nullability в Java: @Nullable («может быть null») и @NotNull («null запрещён»). Kotlin умеет распознавать такие аннотации (в частности, JetBrains‑аннотации) и на их основе строить видимые в Kotlin типы. Это описывается как enhancing signatures — улучшение сигнатур типов при импорте Java‑API в Kotlin.

Важно понимать тонкий момент: аннотации в Java — это не «железобетонная гарантия», это контракт. То есть автор обещает: «я возвращаю не-null». Kotlin верит контракту настолько, что разрешает писать более строгий код, но если контракт нарушен, то JVM всё равно сможет «протащить» null — и тогда вы получите падение (как минимум, раньше и понятнее, чем где-то дальше по цепочке).

2. Как Kotlin строит типы: T, T? и T!

Короткая формула такая: Kotlin пытается превратить «непонятно что из Java» в «понятный Kotlin‑тип». И делает это по приоритету информации: сначала смотрит на аннотации (@Nullable/@NotNull), а если их нет (или они конфликтуют), может оставить platform type (T!).

В спецификации Kotlin это формулируется примерно так: Java‑тип без аннотаций превращается в flexible type (IDE часто показывает как T!), а наличие аннотаций может этот тип «уточнить» в сторону T или T?.

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

Как выглядит в Java Как Kotlin это обычно видит
Foo (без аннотаций)
Foo!
@Nullable Foo
Foo?
@NotNull Foo
Foo

Теперь важная практическая часть: почему Foo! — это не «ещё один вид nullable», а именно «опасная неопределённость».

  • Foo? заставляет вас писать безопасный код.
  • Foo заставляет вас жить как оптимист (и иногда падать, если оптимизм был лишним).
  • Foo! — это состояние «компилятор не уверен», и иногда он разрешает писать код так, как будто там Foo, хотя в рантайме может прилететь null.

Вот маленький пример, который показывает «психологию» компилятора: если вы явно выбираете тип String, Kotlin имеет право вставить рантайм‑проверку и «упасть сразу», потому что вы объявили строгий контракт (в спецификации это описывается как генерация проверок при наличии expected type).

fun main() {
    val rawFromJava = System.getProperty("my.app.mode") // может быть null
    val mode: String = rawFromJava  // тут может упасть сразу, если rawFromJava == null
    println(mode.length)
}

В корректной программе мы бы, конечно, «приземлили» это в String?, но сам факт важен: как только вы в Kotlin выбираете T, вы как будто подписываете бумагу «я уверен, что не null».

3. Где ставят @Nullable/@NotNull в Java

Пока мы говорили абстрактно: «Java‑метод аннотирован». Но у новичков часто ломается мозг на уровне «а куда именно ставить аннотацию и что именно она означает?». Плюс есть ещё один слой сложности: аннотация может относиться не только к самому типу, но и к типовым аргументам (например, к элементам списка).

В спецификации Kotlin перечисляется, что может быть аннотировано: поле (аннотация относится к типу поля), метод (аннотация относится к возвращаемому типу), параметр (аннотация относится к типу параметра), и отдельно упоминается аннотация типа (Java 8+) — это как раз TYPE_USE.

Представим, что где-то в Java‑библиотеке есть такой интерфейс (мы его не пишем и не компилируем — просто читаем как «паспорт» API):

interface UserDirectory {
    @NotNull String findDisplayName(@NotNull String id);
    @Nullable String findEmail(@NotNull String id);
}

В Kotlin это будет читаться очень естественно:

fun printUserInfo(dir: Any /* допустим, это UserDirectory */) {
    // условно: displayName: String (не nullable)
    // условно: email: String? (nullable)
}

Но самое интересное начинается, когда Java начинает аннотировать внутренности коллекций. Например:

interface TagService {
    @NotNull List<@Nullable String> loadTags();
}

Тогда Kotlin видит: «список точно есть, но элементы могут быть null» — то есть примерно List<String?>.

И это не теоретическая редкость. В документации по компилятору Kotlin 2.x приводится пример, что при Java‑сигнатуре вида @NotNull ResultContainer<@Nullable String> fetchData() Kotlin переносит nullability в тип аргумента и получает ResultContainer<String?>.

4. Коллекции: null у контейнера и null у элементов

Когда речь заходит о списках и множествах, люди часто думают, что «nullable‑коллекция» — это одна проблема. На практике проблем две: может быть null сама коллекция (контейнер), а могут быть null внутри (элементы). Эти случаи по-разному ломают код, и Kotlin заставляет вас проговорить оба.

Контраст удобно увидеть на типах:

  • List<String>? означает «список может отсутствовать, но если он есть — строки внутри не null».
  • List<String?> означает «список есть, но внутри могут быть null».
  • List<String?>? означает «может не быть списка, а если список есть — внутри могут быть null».

Если вы видите такой тип (или подозреваете, что у вас platform type, который на деле ведёт себя так), то самый взрослый подход — нормализация на границе: превратить «грязный внешний мир» в «чистый внутренний мир вашего приложения».

Для нормализации есть знакомые инструменты: ?: emptyList(), filterNotNull(), mapNotNull(). Мы не изучаем ничего «нового и страшного» — мы просто правильно применяем базовый Kotlin.

Вот маленькая и очень практичная функция‑шлюз:

fun normalizeTags(raw: List<String?>?): List<String> {
    return raw
        ?.map { it?.trim() }          // подготавливаем элементы (trim)
        ?.filterNotNull()             // выбрасываем null-элементы
        ?.filter { it.isNotEmpty() }  // выбрасываем пустые после trim
        ?: emptyList()                // если списка нет — считаем его пустым
}

И пример использования:

fun main() {
    val raw: List<String?>? = listOf(" kotlin ", null, "  ", "jvm")
    val tags = normalizeTags(raw)

    println(tags) // [kotlin, jvm]
}

Обратите внимание на стиль: мы не пытаемся «тащить nullable дальше по всей программе». Мы один раз на границе принимаем реальность (данные грязные), чистим, и дальше работаем с нормальным List<String>.

И вот здесь Java‑аннотации @Nullable/@NotNull реально помогают: если библиотека честно написала @NotNull List<@Nullable String>, Kotlin покажет вам правду как List<String?>, и вы сразу понимаете, что нужно делать filterNotNull().

5. Встраивание в CLI: «грязный вход» → «чистая Kotlin‑модель»

У нас по курсу уже есть практическое консольное приложение: оно хранит данные в коллекциях, принимает команды и старается быть устойчивым к мусорному вводу. Сейчас мы добавим маленький, но типичный слой: «данные пришли извне» (в реальности это могла бы быть Java‑библиотека, плагин или SDK), и мы хотим встроить эти данные в доменный код, не разнеся null по всему проекту.

Представим, что где-то «снаружи» нам приходят названия категорий. Мы не будем писать Java‑код, но будем писать Kotlin так, как будто нам дали функцию, возвращающую «список строк, возможно с null». Даже если в Java это было аннотировано, мы всё равно хотим единый шлюз.

Сделаем утилиту, которая превращает внешний список в список категорий, пригодный для команд add/list:

private fun normalizeCategoryNames(raw: List<String?>?): List<String> {
    return raw
        ?.map { it?.trim()?.lowercase() }
        ?.filterNotNull()
        ?.filter { it.isNotEmpty() }
        ?: emptyList()
}

Теперь добавим в наш «main‑цикл» (или в обработчик команды) использование. Пусть команда categories печатает доступные категории (в реальности — из внешнего источника):

fun main() {
    // как будто пришло из Java-библиотеки:
    val rawFromJava: List<String?>? = listOf(" Food ", null, "Transport", "  ")

    val categories = normalizeCategoryNames(rawFromJava)

    println("Категории (${categories.size}):")
    println(categories.joinToString()) // food, transport
}

Что здесь важно методологически: мы не обсуждаем, почему там null, и не пытаемся лечить весь мир. Мы просто строим «санпропускник»: дальше по приложению категории — это нормальный List<String>, и код команд не превращается в вечный фестиваль ?.

Если завтра внешний источник станет более честным и начнёт отдавать @NotNull List<@NotNull String>, то Kotlin‑тип станет ещё строже, но наш санпропускник всё равно останется корректным (просто станет чуть избыточным).

6. Почему Kotlin оставляет T!

Здесь полезно снять розовые очки: «аннотации спасут мир» — звучит красиво, но реальность сложнее. Kotlin старается использовать @Nullable/@NotNull, но у него есть важное правило совместимости: если аннотации исчезнут (или библиотека окажется без них), код всё равно должен иметь шанс компилироваться. Поэтому при неоднозначностях Kotlin может «откатиться» к platform type и, возможно, показать предупреждение.

В спецификации это описывается прямо: если «улучшенная» сигнатура не совместима (например, конфликтует с сигнатурами в иерархии типов), то enhancing может быть отброшен, и вместо строгого типа Kotlin возьмёт platform type.

Нам отсюда нужен практический вывод:

Если вы видите, что метод из Java возвращает Something!, не пытайтесь «додавить компилятор силой мысли». Делайте то же, что мы делали раньше: на границе выбирайте Something? и нормализуйте данные. Это работает и когда аннотаций нет, и когда они есть, но Kotlin их по какой-то причине не применил.

Например, вот такой стиль кода устойчив:

fun safeToken(raw: String?): String? {
    return raw
        ?.trim()
        ?.takeIf { it.isNotEmpty() }
}

fun main() {
    val tokenFromJava: String? = System.getenv("APP_TOKEN") // пришло извне
    val token = safeToken(tokenFromJava)

    println(token ?: "<no token>") // <no token>
}

Как увидеть String, String? и String! в IntelliJ IDEA

IDE — ваш рентген: по нему вы понимаете, что вам реально вернул Java‑код, и что Kotlin смог вывести из аннотаций. Поэтому полезно приучить себя иногда останавливаться и смотреть на тип выражения.

В IntelliJ IDEA обычно достаточно навести курсор на выражение или переменную.

  • Если вы увидели String, значит где-то в контракте гарантируется non-null.
  • Если увидели String?, значит кто-то честно сказал «может быть null» (и вы обязаны обработать).
  • А вот String! — это табличка «Осторожно, возможны сюрпризы»: Kotlin не уверен, и ответственность снова на вас.

И ещё одна подсказка: сообщения компилятора про nullability обычно довольно буквальные. Если вы пытаетесь передать String? туда, где ждут String, он скажет Type mismatch. Если вы пытаетесь вызвать .length у String?, он напомнит про safe call. Это не «ругается» — это буквально ваша программа в стадии «не даю себе наступить на грабли».

7. Типичные ошибки при работе с @Nullable/@NotNull и Java‑коллекциями

Ошибка №1: считать, что @NotNull — это волшебное заклинание, которое физически запрещает null.
Аннотация — это контракт, а не броня. Kotlin может поверить этому контракту и дать вам тип T, но JVM всё ещё может принести null, если библиотека ошиблась. Поэтому даже при @NotNull важно понимать, где вы доверяете внешнему миру, а где хотите «упасть сразу» и диагностировать проблему.

Ошибка №2: путать «список может быть null» и «внутри списка могут быть null».
List<String>? и List<String?> ломают код по-разному. В первом случае вы не можете даже итерироваться без проверки контейнера, во втором — вы можете итерироваться, но обязаны решить судьбу каждого элемента. При Java‑интеграциях легко получить оба риска сразу, и тогда нужен аккуратный санпропускник: ?: emptyList() плюс filterNotNull().

Ошибка №3: тащить platform types (T!) глубоко в доменную логику.
Если вы позволяете String! или MutableList<String!>! гулять по проекту, то «внезапный NPE» становится вопросом времени. Гораздо дешевле один раз на границе решить: «это nullable или нет?», и дальше работать по нормальным Kotlin‑правилам.

Ошибка №4: «лечить» всё через !!, потому что так быстрее.
!! — это не инструмент интеграции, это аварийная кнопка «сломай программу здесь». Иногда это действительно правильное решение (например, без значения приложение не может работать), но в обработке внешних данных чаще нужен fallback (?:), нормализация (trim(), takeIf()) и мягкое поведение (пустой список вместо null‑списка).

Ошибка №5: ожидать, что List в Kotlin делает данные неизменяемыми, если источник — Java.
Даже если вы держите ссылку как List<String>, Java‑источник мог вернуть изменяемую коллекцию. Если для вашего сценария важна стабильность снимка, делайте копию toList() после нормализации. Это не «паранойя», это способ сделать поведение предсказуемым, особенно когда данные приходят из чужого кода.

1
Задача
Kotlin SELF, 29 уровень, 2 лекция
Недоступна
Ник из системы
Ник из системы
1
Задача
Kotlin SELF, 29 уровень, 2 лекция
Недоступна
Грязные теги
Грязные теги
1
Задача
Kotlin SELF, 29 уровень, 2 лекция
Недоступна
Категории с тумблером
Категории с тумблером
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ