Kotlin — один из самых грамотно спроектированных, понятных и логичных языков последних лет. На Kotlin можно начать создавать приложения для Android, не изучая язык, но достигнуть мастерства одной лишь практикой вряд ли получится. Эта статья — сборник советов, которые помогут лучше понять особенности языка и программировать более эффективно.
 

Null или не null?

Null safety — одна из ключевых особенностей Kotlin. Язык гарантирует, что программист не сможет по ошибке вызвать методы объекта, имеющего значение null, или передать этот объект в качестве аргумента другим методам. На практике это означает, что система типов Kotlin разделена на две ветви, в одной из которых существуют nullable-типы (со знаком вопроса на конце), а в другой — типы, у которых может быть только имеющее смысл значение.

Ты можешь написать такой код:

var string: String? = null

Но не такой:

var string: String = null

Не nullable-тип String просто не может содержать значение null.

Язык имеет ряд операторов для удобной и надежной работы с nullable-типами:

// Присвоить переменной length значение одноименного свойства string1 либо null
val length = string1?.length

// Выполнить код в блоке, только если string1 не null
string1?.let {
    System.out.println(string1)
}

// Заверить компилятор, что в данный момент значение string1 не может быть null
string1!!.length

// Объявить переменную не nullable-типа и заверить компилятор, 
// что она будет проинициализирована позже, до первого использования
lateinit var recyclerView: RecyclerView

В целом удобная и понятная система. Но есть оператор, который может сделать код еще более компактным и красивым. Оператор Elvis вычисляет выражение слева и, если его результат null, вычисляет выражение справа. Он особенно удобен в двух случаях. Во-первых, его можно использовать, чтобы присвоить переменной дефолтное значение:

val name: String = person.name ?: "unknown"

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

fun addPerson(person: Person) {
    val name = person.name ?: return
}

Но есть и подводные камни. Допустим, у нас есть следующий код:

data?.let { updateData(data) } ?: run { showLoadingSpinner() }

Может показаться, что этот код делает то же самое, что и такой код:

if (data != null) { 
    updateData(data) 
} else { 
    showLoadingSpinner()
}

Но это не так. Последний пример кода полностью бинарный: либо первый блок, либо второй. А вот в предыдущем фрагменте кода могут быть выполнены оба блока! Это возможно, если функция updateData(data) сама вернет null. Тогда все выражение слева вернет null и будет выполнен код справа от оператора Elvis. Обойти эту проблему можно, заменив let на apply.

Еще одна вещь, которую следует помнить о null safety, — это автоматическое выведение типов. Компилятор (и плагин среды разработки) Kotlin достаточно умен, чтобы понять тип переменной даже в самых сложных случаях. Но иногда он дает сбои.

Такое может быть при параллельной обработке данных. Возьмем следующий пример:

class Player(var tune: Tune? = null) {
    fun play() {
        if (tune != null) {
            tune.play()
        }
    }
}

Среда разработки сообщит, что не выведет тип tune, потому что это изменяемое свойство. Компилятор не может быть уверен, что между проверкой tune на null и вызовом метода play() другой поток не сделает tune = null.

Чтобы это исправить, достаточно сделать так:

class Player(var tune: Tune? = null) {
    fun play() {
        tune?.play()
    }
}

Или так:

class Player(var tune: Tune? = null) {
    fun play() {
        tune?.let {
            val success = it.play()
        }
    }
}

Сбои могут возникать и при взаимодействии с кодом на Java. В Java понятия null safety нет, поэтому компилятор не может знать тип переменой наверняка. Kotlin решает эту проблему двумя способами:

  1. Компилятор Kotlin поддерживает практически все разновидности nullable-аннотаций, поэтому, если код аннотирован с помощью @NotNull и ему подобных аннотаций, компилятор будет считать, что аннотированная переменная не может быть null.
  2. Для взаимодействия с Java (и другими языками для JVM) в Kotlin есть специальные типы с восклицательным знаком на конце (например, String!, Integer!). Это так называемые platform types, и при работе с ними Kotlin использует ту же логику, что и Java. Однако ее можно изменить, если указать тип напрямую:

    val docBuilderFactory: DocumentBuilderFactory = DocumentBuilderFactory.newInstance()
    

Далее DocumentBuilderFactory будет считаться не nullable-переменной.

 

Типы Unit, Nothing, Any в Kotlin

Система типов Kotlin несколько отличается от системы типов Java и может вызвать у незнающего человека много вопросов. Наиболее проблемными обычно оказываются типы Unit и Nothing.

  • Unit — эквивалент типа void в Java. Другими словами, он нужен для того, чтобы показать, что функция ничего не возвращает. Unit наследуется от типа Any, а при работе с Java-кодом автоматически транслируется в void.

  • Nothing — субкласс любого класса (именно так), не позволяющий создать объект своего типа (конструктор приватный). Используется для представления результата исполнения функции, которая никогда не завершается (например, потому что она выбрасывает исключение). Пример:

    public inline fun TODO(): Nothing = throw NotImplementedError()
    fun determineWinner(): Player = TODO()
    
  • Any — родитель всех остальных классов. Аналог Object в Java.

 

Корутины

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

 

Как это работает

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

checkNetworkConnection(context) {
    fetchData(url) {
        updateUi(it)
    }
}

Это достаточно типичный код, который проверяет подключение к интернету (в фоновом потоке). Затем, если результат положительный, запускает функцию для получения данных из сети (опять в фоновом потоке), а после получения отображает эти данные на экране (в этот раз в основном потоке приложения).

Чтобы выполнить все эти функции последовательно и не заблокировать основной поток исполнения, используются лямбды. И хотя в целом код не выглядит таким уж страшным, в реальном проекте все эти колбэки в итоге приведут к мешанине под названием callback hell.

Корутины позволяют справиться с проблемой красиво и без лишнего кода:

CoroutineScope(Dispatchers.Main).launch {
    val isConnected = checkNetworkConnection(context)
    if (isConnected) {
        val data = fetchData(url)
        updateUi(data)
    }
}

...

suspend fun checkNetworkConnection(context: Context) = withContext(Dispatchers.IO) {
   ...
}

suspend fun fetchData(url: Url) = withContext(Dispatchers.IO) {
   ...
}

Код стал последовательным, но остался неблокируемым. Пока мы ждем завершения работы checkNetworkConnection() и fetchData(), которые выполняются внутри так называемых suspend-функций в фоновом потоке, основной поток приложения может спокойно продолжать выполнение. В случае с Android это значит, что интерфейс приложения останется плавным.

Как такое возможно? Очень просто — компилятор Kotlin, по сути, превращает второй пример кода в первый, со всеми его колбэками! Если быть более точным, то каждая suspend-функция превращается в объект класса Continuation, реализующий машину состояний, которая приостанавливает выполнение кода и сохраняет состояние при вызове других suspend-функций. Но для понимания корутин будет достаточно и первого объяснения. Просто запомни этот пример.

 

Пулы потоков

Вторая особенность корутин, которую необходимо запомнить, — корутины не привязаны к потокам Java и могут перемещаться между ними. В примере выше для переключения между потоками используется функция withContext, которая принимает аргументом контекст корутины, в качестве которого можно передать диспетчер. Диспетчер как раз и определяет пул потоков, в котором продолжит свою работу корутина.

Есть два стандартных пула потоков: I/O-bound (Dispatchers.IO) и CPU-bound (Dispatchers.Default) — плюс основной поток приложения (Dispatchers.Main). Первый пул предназначен для блокирующих операций, таких как получение данных из сети, чтение диска / запись на диск и так далее. Это большой пул потоков, где всегда есть свободные потоки, готовые для выполнения ожидающих операций. Второй пул предназначен для вычислений и состоит из небольшого количества потоков, равного количеству процессорных ядер.

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

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

override fun onCreate(savedInstanceState: Bundle?) {
    CoroutineScope(Dispatchers.Main).launch {
        System.out.println("1")
    }

    System.out.println("2")
}

Причина в том, что код, печатающий 1, будет поставлен в очередь сразу за текущим блоком кода, который заканчивается вызовом println("2").

Этого можно избежать, если использовать Dispatchers.Main.immediate вместо Dispatchers.Main. Но разработчики Kotlin настоятельно не рекомендуют этого делать: могут возникнуть трудноуловимые баги.

 

Вычисления в основном потоке

Описанную особенность, связанную с очередями потоков, можно использовать для создания весьма интересных эффектов. Лукас Лехнер (Lukas Lechner) в своей статье показал, как использовать корутины для выполнения тяжеловесных вычислений в основном потоке приложения.

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

private suspend fun calculateFactorialOnDefaultDispatcher(number: Int): BigInteger =
    withContext(Dispatchers.Default) {
        var factorial = BigInteger.ONE
        for (i in 1..number) {
            factorial = factorial.multiply(BigInteger.valueOf(i.toLong()))
        }
        factorial
    }

Если вынести код этого метода из блока withContext() и вызвать его из основного потока приложения, то он закономерно подвесит интерфейс на несколько секунд. Но! Если при этом добавить в код вызов функции yield(), интерфейс никак не пострадает и останется плавным:

private suspend fun calculateFactorialInMainThreadUsingYield(number: Int): BigInteger {
    var factorial = BigInteger.ONE
    for (i in 1..number) {
        yield()
        factorial = factorial.multiply(BigInteger.valueOf(i.toLong()))
    }
    return factorial
}

Как это возможно? Все дело в том, как Android вызывает код отрисовки интерфейса в основном потоке приложения. Каждые 16 миллисекунд (при частоте обновления экрана в 60 герц) фреймворк Android добавляет новый Runnable (блок кода) с кодом обновления интерфейса в очередь исполнения основного потока приложения. Если основной поток не занят в это время другой работой, он исполнит этот код. В противном случае продолжится исполнение текущего кода, а операция обновления будет пропущена. Так получается пропуск кадра, а пропуск нескольких кадров подряд выглядит как фриз интерфейса.

Именно это должно было произойти при запуске предыдущего кода. Но не произошло благодаря вызову функции yield(). Она приостанавливает исполнение текущей корутины до получения следующего элемента (в данном случае числа). Приостановка корутины приводит к перемещению кода обработки следующего элемента в очереди исполнения. В итоге весь код вычисления факториала разбивается на множество маленьких блоков, которые помещаются в очередь исполнения вперемешку с кодом обновления экрана. Поток успевает выполнить несколько шагов вычисления факториала, затем код обновления UI, затем еще несколько шагов факториала и так далее.

Это канонический пример того, что называют словом concurrency в противовес параллельному вычислению. Мы по максимуму загружаем основной поток работой, при этом позволяя ему быстро переключаться между задачами. Факториал при таком подходе вычисляется примерно в два раза медленнее, зато интерфейс остается плавным даже без использования фоновых потоков.

 

Structured concurrency

Третья особенность корутин — то, что называется structured concurrency (об этом хорошо написал Роман Елизаров из команды Kotlin).

В приведенном в начале раздела коде корутина запускается с помощью функции CoroutineScope.launch. Это один из билдеров корутин, порождающих новые корутины.

Продолжение доступно только участникам

Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».

Присоединяйся к сообществу «Xakep.ru»!

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее

Оставить мнение