Nullable-типы в Kotlin

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

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

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

  • те, которые не поддерживают null, и

  • так называемые nullable-типы, которым среди прочего можно присвоить null.

Nullable можно перевести как обнуляемые. Другими словами, это типы с поддержкой пустого значения. Подобное разделение помогает избегать ошибок во время выполнения программы, так как то, что ошибка потенциально возможна, становится ясно на этапе написания программы. И компилятор Kotlin не даст скомпилировать такой потенциально-ошибочный код.

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

Так, например, функция readLine() читает строку (в смысле линию текста) со стандартного ввода. По умолчанию на стандартный ввод поступают данные с клавиатуры. Однако можно перенаправить поток ввода на чтение из файла. В этом случае readLine() будет читать файл строку за строкой, то есть линию за линией. Когда функция попытается прочитать что-то после последней строки, где уже ничего нет, то вернет null. Программе этот сигнал сообщит, что достигнут конец файла.

Таким образом, даже если мы читаем данные с клавиатуры, функция readLine() предполагает возможный возврат из себя null. Хотя при чтении из клавиатурного потока ввода получить null почти невозможно, так как даже пустая строка – это не null, а обычная строка с нулевым количеством символов – "". Несмотря на это, в Kotlin мы не может присвоить результат, возвращенный readLine(), переменной типа String. Когда раньше мы писали так val s = readLine(), то на самом деле тип переменной был не String, а немного другим – String?.

Тип данных String?

Разница между типами String и String? только в том, что последний допускает значение null. Переменным этого типа можно присвоить такое значение. Переменным, чей тип не имеет вопросительного знака в конце имени типа, присваивать null в Kotlin запрещено. Аналогичные nullable-вариации есть у других типов. Так типу Int соответствует Int?, типу Double – Double? и т. д.

Сравним в работе обычную строку и строку с возможным null. Измерить длину строки типа String? не удастся, потому что свойство length работает только с данными типа String.

fun main() {
    val s1: String? = readLine()
    val s2: String = "Hello"
 
    val lenS1 = s1.length // ошибка
    val lenS2 = s2.length
 
    println(lenS1)
    println(lenS2)
}

Функция readLine() возвращает nullable-тип

Что делать, если readLine() возвращает String?, однако мы знаем, что null там быть не может, и хотим узнать длину строки, то есть количество символов в ней? На помощь может придти условный оператор. Если переменная указывает не на null, то измерить длину. Если на null, то сделать что-то еще. Например, присвоить переменной, предназначенной для хранения длины, значение -1. (Примечание: оператор != означает "не равно".)

fun main() {
    val s1: String? = readLine()
    val s2: String = "Hello"
 
    val lenS1: Int
 
    if (s1 != null)
        lenS1 = s1.length
    else
        lenS1 = -1
 
    val lenS2 = s2.length
 
    println(lenS1)
    println(lenS2)
}

Умное приведение типов в Kotlin

Посмотрите внимательно на программу. Переменная s1 нигде явно не меняет своего типа, она так и остается nullable. Как же мы измеряем ее длину в выражении s1.length? По-идее в теле ветки if мы должны были вручную привести ее к обычному строковому типу командой s1.toString(). При этом создается копия значения s1, но уже типа String. Она может быть присвоена другой переменной. И только к этому новому String-значению мы могли применять свойство length.

На самом деле примерно так и происходит. Умный компилятор Kotlin делает рутинную работу за нас. Подобное явление называется умным приведением типов (smart cast). Когда в теле if мы пытаемся измерить длину, то компилятор за нас приводит строку, содержащуюся в s1, к типу String, потому что из условия уже известно, что это не null, и только после этого вызывает length.

Если все-таки присвоить значение null переменной типа String?, то сработает ветка else, никакого приведения типа не будет, а в переменную lenS запишется значение -1, которое в нашем коде мы можем интерпретировать как сигнал об отсутствии строки.

Обработка null с помощью if-else

Конструкция с if-else слишком громоздкая для выполнения такой мелкой задачи, как обработка значения null. Мы можем записать ее в одну строку:

val lenS = if (s != null) s.length else -1

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

Однако в Kotlin существуют другие, более короткие варианты работы с nullable-типами.

Если после переменной поставить вопросительный знак, после которого вызвать функцию (или свойство), не принимающую null, то эта функция будет вызвана, только если значение не null. Если же оно null, то функция вызвана не будет. Будет возвращен null.

fun main() {
    val s: String? = null
    val lenS = s?.length
 
    println(lenS)
}

Тип данных Int?

В примере выражение s?.length возвращает null, потому что s связана с null. В иных случаях вернулась бы длина строки. Однако в любом случае (будет ли lenS присвоен null или длина строки) переменная lenS будет иметь nullable-тип Int?.

Что если мы не хотим, чтобы lenS была nullable-типа, нам удобнее, чтобы она была обычного числового типа? В этом случае следует использовать так называемый элвис-оператор, обозначаемый вопросительным знаком, после которого идет двоеточие – ?:. Его работа чем-то схожа с if-else.

Если до элвиса получился null, вернется то, что после него, если нет – то, что до него.

fun main() {
    val s: String? = null
    val lenS = s?.length ?: -1
 
    println(lenS)
}

Элвис-оператор

В нашем примере в любом случае выражение s?.length ?: -1 дает целое число – либо длину строки, либо -1, а значит тип переменной lenS определяется как Int.

Еще одним способом избавления от null является оператор !!, который преобразует значение nullable-типа в аналогичное без поддержки null. Например:

fun main() {
    val sN: String? = "Hello"
    val s: String = sN!!
 
    println(s) // Hello
}

Проблема этого оператора в том, что если nullable-переменная все же содержит null, произойдет выброс исключения уже на этапе исполнения программы:

Исключение NullPointerException

Присвоить обычному типу значение null невозможно. Поэтому программа аварийно завершается с сообщением, что в потоке "main" произошло исключение типа NullPointerException. Однако если есть уверенность, что null быть не может, как например в случае функции readLine(), читающей строку с клавиатуры, то оператор !! может быть самым удобным способом преобразования:

fun main() {
    val s1: String = readLine()!!
    val lenS1: Int = s1.length
 
    val s2: String? = readLine()
    val lenS2: Int = s2!!.length
 
    println(lenS1)
    println(lenS2)
}

В примере показано, что избавиться от nullable-типа можно на разных этапах. В первом случае мы сразу приводим строку к обычному типу, во втором – переменная s2 остается nullable, однако при вызове length создается копия значения, приводится к типу String и затем измеряется ее длина.

Практическая работа:

Напишите программу, которая запрашивает с ввода два числа, знак операции (+, -, * или /) и выполняет над числами указанную операцию. Для приведения строкового типа к числовому воспользуйтесь строковыми функциями toInt() или toDouble() и подобными.