Null безопасность

Null безопасность - это возможность Kotlin, которая существенно снижает риск обращения к null-ссылкам, также известный как “ошибка на миллиард долларов”.

Один из самых распространённых подводных камней во многих языках программирования, включая Java, - это доступ к члену null-ссылки, который приводит к исключению null reference. В Java этому соответствует NullPointerException, или сокращённо NPE.

Kotlin явно поддерживает допустимость null как часть системы типов. Это значит, что вы можете явно объявить, каким переменным или свойствам разрешено принимать значение null. Кроме того, когда вы объявляете non-null переменные, компилятор следит, чтобы такие переменные не могли хранить null, и тем самым предотвращает NPE.

Null безопасность Kotlin делает код безопаснее, потому что потенциальные проблемы, связанные с null, обнаруживаются во время компиляции, а не во время выполнения. Явное указание null-значений повышает надёжность, читаемость и сопровождаемость кода: такой код проще понимать и поддерживать.

В Kotlin NPE может возникнуть только в следующих случаях:

  • Явный вызов throw NullPointerException();
  • Использование оператора утверждения not-null !!;
  • Несогласованность данных во время инициализации, например:
    • Неинициализированный this, доступный в конструкторе, используется где-то ещё (“утечка this”);
    • Конструктор суперкласса вызывает open-член, реализация которого в производном классе использует неинициализированное состояние.
  • Взаимодействие с Java:
    • Попытка получить доступ к члену null-ссылки платформенного типа;
    • Проблемы с null-допустимостью у обобщённых типов. Например, фрагмент Java-кода может добавить null в Kotlin MutableList<String>, и для корректной обработки такого значения потребуется MutableList<String?>;
    • Другие проблемы, вызванные внешним Java-кодом.

Кроме NPE, с null безопасностью связано исключение UninitializedPropertyAccessException. Kotlin выбрасывает его при попытке обратиться к свойству, которое ещё не было инициализировано. Так Kotlin гарантирует, что non-null свойства не используются до готовности. Обычно это происходит со lateinit свойствами.

Nullable типы и non-null типы

В Kotlin система типов различает типы, которые могут хранить null (nullable типы), и типы, которые не могут этого делать (non-null типы). Например, обычная переменная типа String не может хранить null.

fun main() {
    // Присваивает переменной non-null строку
    var a: String = "abc"
    // Пытается повторно присвоить null переменной non-null типа
    a = null
    print(a)
    // Null can not be a value of a non-null type String
}

Вы можете безопасно вызвать метод или обратиться к свойству через a. Это гарантированно не вызовет NPE, потому что a является non-null переменной. Компилятор следит, чтобы a всегда содержала корректное значение String, поэтому риска обратиться к свойствам или методам a, когда она равна null, нет.

fun main() {
    // Присваивает переменной non-null строку
    val a: String = "abc"
    // Возвращает длину non-null переменной
    val l = a.length
    print(l)
    // 3
}

Чтобы разрешить null-значения, объявите переменную со знаком ? сразу после типа. Например, nullable строку можно объявить как String?. Такая запись делает String типом, который может принимать null.

fun main() {
    // Присваивает переменной nullable строку
    var b: String? = "abc"
    // Успешно присваивает null nullable переменной
    b = null
    print(b)
    // null
}

Если попытаться обратиться к length напрямую через b, компилятор сообщит об ошибке. Это происходит потому, что b объявлена как nullable переменная и может хранить null. Прямой доступ к свойствам nullable значений потенциально привёл бы к NPE.

fun main() {
    // Присваивает переменной nullable строку
    var b: String? = "abc"
    // Присваивает null nullable переменной
    b = null
    // Пытается напрямую вернуть длину nullable переменной
    val l = b.length
    print(l)
    // Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
}

В примере выше компилятор требует использовать безопасные вызовы, чтобы проверить null-допустимость перед доступом к свойствам или выполнением операций. Есть несколько способов работать с nullable значениями:

Читайте следующие разделы, чтобы подробнее узнать об инструментах и приёмах обработки null.

Проверка на null с помощью if

При работе с nullable типами нужно безопасно обрабатывать null-допустимость, чтобы избежать NPE. Один из способов - явно проверять значение на null с помощью условного выражения if.

Например, проверьте, равна ли b значению null, и только затем обратитесь к b.length.

fun main() {
    // Присваивает nullable переменной null
    val b: String? = null
    // Сначала проверяет null-допустимость, затем обращается к length
    val l = if (b != null) b.length else -1
    print(l)
    // -1
}

В примере выше компилятор выполняет умное приведение, меняя тип с nullable String? на non-null String. Он также отслеживает информацию о выполненной проверке и разрешает вызов length внутри условного выражения if.

Более сложные условия тоже поддерживаются.

fun main() {
    // Присваивает переменной nullable строку
    val b: String? = "Kotlin"
    // Сначала проверяет null-допустимость, затем обращается к length
    if (b != null && b.length > 0) {
        print("Строка длиной ${b.length}")
        // Строка длиной 6
    } else {
        // Запасной вариант, если условие не выполнено
        print("Пустая строка")
    }
}

Обратите внимание: пример выше работает только тогда, когда компилятор может гарантировать, что b не изменится между проверкой и использованием. Это то же требование, что и для умных приведений.

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

Оператор безопасного вызова ?. позволяет безопасно и кратко обрабатывать null-допустимость. Если объект равен null, оператор ?. не выбрасывает NPE, а просто возвращает null.

fun main() {
    // Присваивает переменной nullable строку
    val a: String? = "Kotlin"
    // Присваивает nullable переменной null
    val b: String? = null
    // Проверяет null-допустимость и возвращает длину или null
    println(a?.length)
    // 6
    println(b?.length)
    // null
}

Выражение b?.length проверяет null-допустимость и возвращает b.length, если b не равна null; иначе оно возвращает null. Тип такого выражения - Int?.

В Kotlin оператор ?. можно использовать и с var, и с val переменными:

  • Nullable var может хранить null (например, var nullableValue: String? = null) или non-null значение (например, var nullableValue: String? = "Kotlin"). Если в ней хранится non-null значение, вы можете в любой момент заменить его на null;
  • Nullable val может хранить null (например, val nullableValue: String? = null) или non-null значение (например, val nullableValue: String? = "Kotlin"). Если в ней хранится non-null значение, позже заменить его на null нельзя.

Безопасные вызовы полезны в цепочках. Например, Bob - это сотрудник, который может быть прикреплён к отделу или не прикреплён. У этого отдела, в свою очередь, может быть другой сотрудник в роли руководителя отдела. Чтобы получить имя руководителя отдела Bob, если такой руководитель есть, напишите:

bob?.department?.head?.name

Такая цепочка вернёт null, если любое из её свойств равно null.

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

person?.department?.head = managersPool.getManager()

В примере выше, если один из получателей в цепочке безопасных вызовов равен null, присваивание пропускается, а выражение справа вообще не вычисляется. Например, если person или person.department равны null, функция не вызывается. Ниже приведён эквивалент того же безопасного вызова через условное выражение if.

if (person != null && person.department != null) {
    person.department.head = managersPool.getManager()
}

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

При работе с nullable типами можно проверить значение на null и предоставить альтернативу. Например, если b не равна null, обратитесь к b.length; иначе верните другое значение.

fun main() {
    // Присваивает nullable переменной null
    val b: String? = null
    // Проверяет null-допустимость. Если значение не null, возвращает length. Если null, возвращает 0
    val l: Int = if (b != null) b.length else 0
    println(l)
    // 0
}

Вместо полного if-выражения это можно записать короче с помощью элвис-оператора ?:.

fun main() {
    // Присваивает nullable переменной null
    val b: String? = null
    // Проверяет null-допустимость. Если значение не null, возвращает length. Если null, возвращает non-null значение
    val l = b?.length ?: 0
    println(l)
    // 0
}

Если выражение слева от ?: не равно null, элвис-оператор возвращает его. Иначе элвис-оператор возвращает выражение справа. Выражение справа вычисляется только в том случае, если выражение слева равно null.

Поскольку throw и return в Kotlin являются выражениями, их тоже можно использовать справа от элвис-оператора. Это удобно, например, при проверке аргументов функции.

fun foo(node: Node): String? {
    // Проверяет getParent(). Если значение не null, оно присваивается parent. Если null, возвращается null
    val parent = node.getParent() ?: return null
    // Проверяет getName(). Если значение не null, оно присваивается name. Если null, выбрасывается исключение
    val name = node.getName() ?: throw IllegalArgumentException("name expected")
    // ...
}

Оператор утверждения not-null

Оператор утверждения not-null !! преобразует любое значение в non-null тип.

Когда оператор !! применяется к переменной, значение которой не равно null, эта переменная безопасно обрабатывается как значение non-null типа, и код выполняется как обычно. Однако если значение равно null, оператор !! принудительно интерпретирует его как non-null значение, что приводит к NPE.

Когда b не равна null, оператор !! возвращает её non-null значение (в этом примере - String) и корректно обращается к length.

fun main() {
    // Присваивает переменной nullable строку
    val b: String? = "Kotlin"
    // Обрабатывает b как non-null значение и обращается к его длине
    val l = b!!.length
    println(l)
    // 6
}

Когда b равна null, оператор !! всё равно пытается вернуть её как non-null значение, и возникает NPE.

fun main() {
    // Присваивает nullable переменной null
    val b: String? = null
    // Обрабатывает b как non-null значение и пытается обратиться к его длине
    val l = b!!.length
    println(l)
    // Exception in thread "main" java.lang.NullPointerException
}

Оператор !! особенно полезен, когда вы уверены, что значение не равно null и NPE невозможен, но компилятор не может гарантировать это из-за определённых правил. В таких случаях !! позволяет явно сообщить компилятору, что значение не является null.

Nullable получатель

Вы можете использовать функции-расширения с nullable типом получателя. Такие функции можно вызывать для переменных, которые могут быть равны null.

Если определить функцию-расширение для nullable типа получателя, null-значения можно обрабатывать внутри самой функции, а не проверять на null в каждом месте вызова.

Например, функцию-расширение toString() можно вызвать для nullable получателя. При вызове на значении null она безопасно возвращает строку "null" и не выбрасывает исключение.

fun main() {
    // Присваивает null nullable объекту Person, который хранится в переменной person
    val person: Person? = null

    // Применяет .toString к nullable переменной person и печатает строку
    println(person.toString())
    // null
}

// Объявляет простой класс Person
data class Person(val name: String)

В примере выше, несмотря на то что person равна null, функция .toString() безопасно возвращает строку "null". Это может быть полезно при отладке и логировании.

Если вы ожидаете, что функция .toString() вернёт nullable строку - строковое представление или null, - используйте оператор безопасного вызова ?.. Оператор ?. вызывает .toString() только если объект не равен null; иначе он возвращает null.

fun main() {
    // Присваивает nullable объект Person переменной
    val person1: Person? = null
    val person2: Person? = Person("Alice")

    // Печатает "null", если person равен null; иначе печатает результат person.toString()
    println(person1?.toString())
    // null
    println(person2?.toString())
    // Person(name=Alice)
}

// Объявляет класс Person
data class Person(val name: String)

Оператор ?. позволяет безопасно обрабатывать потенциальные null-значения и при этом обращаться к свойствам или функциям объектов, которые могут быть равны null.

Функция let

Чтобы обрабатывать null-значения и выполнять операции только над non-null типами, можно использовать оператор безопасного вызова ?. вместе с функцией let.

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

fun main() {
    // Объявляет список nullable строк
    val listWithNulls: List<String?> = listOf("Kotlin", null)
    // Проходит по каждому элементу списка
    for (item in listWithNulls) {
        // Проверяет элемент на null и печатает только non-null значения
        item?.let { println(it) }
        // Kotlin
    }
}

Безопасные приведения типов

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

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

fun main() {
    // Объявляет переменную типа Any, которая может хранить значение любого типа
    val a: Any = "Hello, Kotlin!"
    // Безопасно приводит к Int с помощью оператора 'as?'
    val aInt: Int? = a as? Int
    // Безопасно приводит к String с помощью оператора 'as?'
    val aString: String? = a as? String

    println(aInt)
    // null
    println(aString)
    // "Hello, Kotlin!"
}

Код выше печатает null, потому что a не является Int, поэтому приведение безопасно завершается неудачей. Он также печатает "Hello, Kotlin!", потому что значение соответствует типу String?, и безопасное приведение успешно выполняется.

Коллекции nullable типа

Если у вас есть коллекция nullable элементов и вы хотите оставить только non-null элементы, используйте функцию filterNotNull().

fun main() {
    // Объявляет список с null и non-null целочисленными значениями
    val nullableList: List<Int?> = listOf(1, 2, null, 4)

    // Отфильтровывает null-значения, получая список non-null целых чисел
    val intList: List<Int> = nullableList.filterNotNull()
    println(intList)
    // [1, 2, 4]
}

Что дальше?