Java vs. Kotlin для Android. День 2: А может ну его, этот Kotlin?

Необходимо сделать лирическое отступление и констатировать факт, что со времени написания оригинальной серии статей по Kotlin и публикацией их на данном ресурсе, прошло уже достаточное время. Время достаточное для того чтобы: а) вышла обнова на Kotlin 1.1; б) наш любимый Гугль решил-таки начать нативно поддерживать Java 8 под Android. Более того, второе событие произошло непосредственно в день выхода первой части этой серии статей — вот прям как звезды сошлись на небе.

Не знаю, какое из событий меня больше порадовало, но в любом случае пришлось кой-чего переписывать...к примеру, вот эти самые строки. Безусловно, я рад за платформу и благодарен Гуглу за такой ответственный шаг, НО Kotlin стал еще вкуснее и приятнее взору, пальцам и мозгам. Для тех, кого цепанула вторая новость, просим милости сюда изучать, чем нам это грозит. А для тех, кто, так же как и я, пропустил событие 1-ого марта в Котляндии, рекомендую не дочитывать эту фразу, а сразу пропустить следующий абзац...

Вы все еще здесь? А может ну его, этот Kotlin, и на боковую, а?;) Ах, да, чуть не забыл — дабы сократить поток негодования на тему «ах, да как ты/вы мог(ли) вот это не упомянуть или вот это?!», сразу ответственно заявляю, что данный артефакт не является мануалом к языку ни в коем случае. Я всего лишь описываю то, что МНЕ понравилось больше всего, когда я пропустил через себя Kotlin. То, что вот я бы непосредственно практически каждый день с радостью использовал в трудовых буднях.

Так-с...на чем я там остановился в прошлый раз? Ах, да, по диагонали изучил классы и хотел посмотреть аспекты of Functional Programming. Но я думаю, что правильнее все-таки будет сначала на базовый синтаксис взглянуть и узнать, что там есть вкусного. Есть у меня предчувствие, что вечерночь не пройдет зря. Поехали...

Null Safety

Начнем, пожалуй, с такой штуки как nullability. В отличие от Java переменная в Kotlin не может содержать null, если компилятор об этом не знает. Т.е. объявляя переменную, нужно явным образом указать может она быть nullable или нет. Такое простое решение — на уровне системы типов заставить разработчика заранее подумать о том, как должна вести себя переменная, какие значения она может принимать. Все сделано для того, чтобы максимально исключить возможность появления NPE (Null Pointer Exception...хотя кому это я объясняю???) в коде, и особенно в рантайме. Но все-таки NPE могут возникнуть в таких случаях:

  • явный вызов throw NullPointerException()
  • ошибка во внешнем Java-коде
  • использование специфичного оператора !!
  • Как же это все работает? Возьмем к примеру следующий код:
var strNotNull : String = "Hello"
strNotNull = null //compilation error

Во второй строке мы получим ошибку при попытке компиляции. Для того, чтобы иметь возможность записать null в переменную, нам нужно сделать следующее:

var strNull : String? = "Hello"

Теперь все сработает. Кстати, знание того, что в переменной НИКОГДА не будет null избавляет нас от дурацких проверок каждый раз, когда, к примеру, нам нужно получить длину строки. Можно смело делать вот так:

val length = strNotNull.length

и нам за это ничего не будет. А что же делать в случае, когда у нас может быть null в переменной? Просто использовать safe-call operator — ?.

val length = strNull?.length

И в данном случае в переменной length будет сохранена либо длина строки, либо null, в зависимости от состояния strNull. И тип переменной будет автоматически приведен к Int?. Если же мы не хотим возвращать null, то можно использовать Elvis operator:

val length = strNull?.length ?: -1 //length is instance of Int, but not Int?

Можно построить целую цепочку таких safe-calls, к примеру:

message?.sender?.birthdate?.year

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

//say good-bye to if (message != null) {}
message?.let{
..
}

Отличнейшая альтернатива заезженным блокам if/else. Настоятельно рекомендуется к использованию.

Как по мне, то очень интересное и элегантное решение проблемы с NPE в рантайме. По факту большинство NPEs вылазит именно из-за того, что мы забываем проверить на null или думаем, что вот в этом самом месте нам никогда не придет null или даже оставляем это «на потом» в спешке. Хотел было засчитать еще одно очко в пользу Kotlin, но решил, что данное решение заслуживает двух.;)

Smart casts & type checks

Как бы так поделикатнее описать свои мысли о реализации of smart casts & type checks in Kotlin и при этом не сильно обидеть Java? Одним словом — НАКОНЕЦ-ТО!!! Забудьте об этих дурацких дублирующих друг друга операциях с приведением типов как в примере ниже:

if (obj instanceOf String) len = ((String) obj).length();

Каждый раз, когда я пишу подобный код, мне хочется плакать....Ну почему компилятор не может догадаться, что если я проверяю переменную на принадлежность к какому-то типу, то при выполнении этого условия я хочу использовать именно этот интерфейс?! Ведь из-за этих ограничений Java-компилятора рождаются кошмарные конструкции и блоки кода, на который без слез смотреть невозможно.

Но ребята в JetBrains похоже услышали мольбы разработчиков и реализовали систему of smart casts. Вышеуказанный код на Kotlin выглядит следующим образом:

if (obj is String) len = obj.length

А теперь представьте, что вам нужно выполнить несколько вызовов переменной, и посмотрите, как это красиво можно сделать с помощью функции with:

with(obj) {
    val oldLen = length // what happens here is obj.length() is being called
    val newLen = replace(" ", "").length //and obj.replace(“ “, “”).length() here 	
}

Последний раз такую вкусность я использовал в Pascal/Delphi, и это было чертовски удобно, за такими вещами скучаешь. Особенно часто я вспоминаю об этом, когда инициализирую какой-нибудь объект типа Paint, и надо сделать несколько вызовов подряд, чтобы задать шрифт, цвет, тип заливки, антиалиасинг и прочее.

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

Ну а как быть, если мы хотим привести переменную к какому-то типу? Kotlin предлагает на выбор две опции:

  • unsafe cast (небезопасныйое секс, пардон, приведение) с помощью infix operator as
  • safe cast с помощью infix operator as?

В случае первого при попытке привести null value к какому-то типу выкинет исключение. Второй же оператор просто вернет null в результирующую переменную. Я бы рекомендовал использовать именно его.

Что мы имеем в итоге? Красивый, элегантный, читабельный код, не перегруженный синтаксисом. Прямо вот не могу удержаться и накидываю еще 2 очка в пользу Kotlin. Прости, Java, сегодня, видимо, не твой счастливый день.

Type aliases (доступно в версии 1.1)

Раз уж мы поговорили про приведение типов, то целесообразным будет упомянуть возможность объявления альтернативных имен для существующих типов. Что-то подобное я встречал давно в Pascal. Но в Kotlin эта фича работает и для функций, что делает ее особенно интересной! Что конкретно нам это дает?

  • мы можем объявлять «свои типы данных», к примеру, сократить длинный женерик до короткого дружелюбного имени
    typealias BestHashMapEver = HashMap<String, Int>
    val myMap = BestHashMapEver()

    Да, знаю, имя тут вышло не совсем короткое, но вы меня поняли.;)

    typealias Str = String
    val myStr:Str = “Hello DOU!”
  • мы можем объявлять псевдонимы для функций и использовать их при описании параметров
    typealias BestFunctionEver = (Int, Int) -> Boolean
    fun myFun(f: BestFunctionEver) : f(10, 11)
    myFun{x, y -> x > y}
    val bestFun: BestFunctionEver = {x, y -> y > x}
    myFun(bestFun)

Следует заметить, что type alias не генерирует нового типа данных. На уровне компилятора он расценивается как тот самый основной тип, что делает их взаимозаменяемыми. Я бы предложил следующие ситуации, когда можно использовать type aliases:

  • меняющиеся структуры данных;
  • меняющиеся сигнатуры функций;
  • тестирование;
  • создание flavour-билдов;
  • смысловое разделение типов данных и функций по именам/категориям.

Не могу сказать, что вот прямо конкретно этой возможностью я бы пользовался каждый день, но отторжения она у меня не вызвала, а скорее наоборот. Я использовал ее в Pascal/Delphi и не вижу, что может мне помешать сейчас! Однозначно +1 в корзинку Kotlin.

Ranges & Controlling Flow

Если мы говорим о логических структурах, то в Kotlin главной их фишкой является то, что их можно использовать как выражения. Все мы писали что-то наподобие этого не один раз:

if (a > b) {
   max = a;
} else {
   max = b;
}

Я специально привел самый простой пример, на Kotlin он будет выглядеть вот так:

val max = if (a > b) a else b

Чертовски удобно ведь! Если надо выполнить больше кода в блоках условия — нет проблем, но последняя строка должна возвращать результат работы в переменную. Этот же самый принцип работает для when expression. when — это тот же switch из Java и других С-подобных языков. Но вот только when дает намного больше свобод и вариативности использования. В сравнении с ним switch выглядит мелковато и абсолютно не юзабельно. Да, и забудьте про ненавистный break для каждой из веток условий.

Я не случайно объединил ranges и Flow Control Structures в один раздел. Нельзя рассказывать про блоки условий и циклы и не затронуть ranges, ведь именно они являются еще одним замечательным бонусом, который будет облегчать вам жизнь. Признаться, я еще со времен Паскаля очень любил ranges и считал их очень удобным решением, и все никак не мог понять, почему их нет как отдельного типа данных в том же С/С++ или в РНР(с которым в обнимку провел 9 лет) или в JS и, конечно же, в Java. И вот в Kotlin я снова с ними повстречался, но вот только варианты их использования стали шире и удобнее по сравнению с Паскалем. К примеру, range можно сохранить в переменную и использовать в блоке условия:

val myRange = 1..10
if (x in myRange) { //you might want to use !in to check if value IS NOT in range
…
}

Естественно, что вместо чисел 1 и 10, могут быть использованы переменные или даже вызовы функций. При использовании в цикле for можно указывать направление движения и величину шага:

for (i in 1..4 step 2) print(i) // prints "13"
for (i in 4 downTo 1 step 2) print(i) // prints "42"

Ах, да, еще в циклах можно использовать break & continue с метками для прыжков между циклами как и в Java.

Ниже приведен кусочек кода, где я попытался вынести все возможные варианты использования when:

//just a helper function that checks whether int value is even or not?
fun Int?.isEven() : Boolean = this?.mod(2) == 0

val range = 10..20;

//function with when-construction that takes a parameter of Any kind
fun test1(x:Any?) {
    when(x) { //here we pass in x to when
        null -> print("x is null")
        0,1,2 -> print("x = $x")            
        is CharRange -> print("x is a CharRange $x")
        in range -> print("x is in range $range, x = $x") 
        is String -> print("x is a String, x = \"$x\"")
        else -> print("x is of ${x?.javaClass}, x = $x")
    }
    print("\n")
}

//function with when-construction that does not take a parameter and may substitute if/else-if blocks completely
fun test2(x:Any?) {
    when { //no value is passed in
        x == null -> print("x is null")
        x in 0..2 -> print("x = $x")
        x is CharRange -> print("x is a CharRange $x")
        x in range -> print("x is in range $range, x = $x") 
        (x is Int? && x?.isEven() ?: false) -> print("x is even integer, x = $x")
        x is String -> print("x is a String, x = \"$x\"")
        else -> print("x is of ${x?.javaClass}, x = $x")
    }
    print("\n")
}

fun main(args: Array<String>) {
    test1(null) // prints "x is null"
    test1(2) // prints "x = 2"
    test1('A'..'Z') // prints "x is a CharRange A..Z"
    test1(15) // prints "x is in range 10..20, x = 15"
    test2(22) // prints "x is even integer, x = 22"
    test1("HELLO!") // prints "x is a String, x = "HELLO!""
    test1(101) // prints "x is of class java.lang.Integer, x = 101"
}

Сколько бы вы баллов накинули Kotlin за этот раздел?

Изначально на этом месте я подводил итоги по базовым вещам и говорил о том, какой же все-таки Kotlin классный, как лаконичнее код выглядит с применением вышеизложенного и прочее бла-бла-бла, НО, как мы знаем, 1-ого марта вышло обновление 1.1., и оно нам принесло:

Coroutines (доступно в версии 1.1)

Правильнее даже сказать: «доступно с версией 1.1», потому что корутины не входят в сам язык на данный момент, а являются экспериментальной функциональностью и вынесены в отдельный артефакт. Для использования оных нужно в build.gradle файле проекта указать:

kotlin {
    experimental {
        coroutines ‘enable’
    }
}
dependencies {
…
    compile "org.jetbrains.kotlinx:kotlinx-coroutines-core:0.14"
…
}
//по непонятным для меня причинам в один прекрасный момент gradle перестал
//находить артефакт с корутинами, и пришлось явным образом указать, где их стоит искать
repositories {
   maven { url "https://kotlin.bintray.com/kotlinx/" }
}

И теперь нам становятся доступны корутины. Что же это такое? В каком-то смысле — это легковесная нить, но очень дешевая в обслуживании, которую можно приостанавливать и возобновлять. Она может состоять из вызовов других корутин, которые в свою очередь могут выполняться в совершенно разных нитях. Одна нить (Thread) может обслуживать множество корутин. Kotlin предоставляет несколько механизмов использования корутин, но я рассмотрю только парочку наиболее интересных в контексте Android-разработки.

Первый вариант использования — это функция launch():

launch(CommonPool){
...
}

CommonPool представляет собой пул повторно используемых нитей и под капотом использует java.util.concurrent.ForkJoinPool. Ниже приведены два варианта одного и того же решения: блокирующие вызовы (никогда так не делайте!) и неблокирующие на основе корутин.

//this is a blocking example, isn’t it? ;)
println("Start")
for(i in  1..100) { //our main thread sleeps for 10 seconds...arghhh!!!
    Thread.sleep(100)
}
println("Hello!")
println("Stop")

И в консоли результат выполнения у нас будет такой:

11:22:41.250 419-419/com.test I/System.out: Start
11:22:51.269 419-419/com.test I/System.out: Hello!
11:22:51.269 419-419/com.test I/System.out: Stop

А вот неблокирующий вызов:

//this ain’t a blocking example, isn’t it?;)
println("Start")
launch(CommonPool) {
    for(i in  1..100) {
        delay(100) //hey, hey, what are we doing here?
    }
    println("Hello!")
}
println("Stop")

И в этом случае результат выполнения у нас будет такой:

11:27:32.824 6212-6212/com.test I/System.out: Start
11:27:32.838 6212-6212/com.test I/System.out: Stop
11:27:42.884 6212-6238/com.test I/System.out: Hello!

Зоркий глаз и пытливый ум уже заметили, что для задержки в 100 миллисекунд внутри функции launch() использовался вызов delay(). Он аналогичен Thread.sleep(), но используется в контексте корутины и блокирует ее, а не основной поток. Важно запомнить, что все конструкции, специфичные для корутин, могут быть использованы только в их пределах.

Ахх, я вижу как у вас загорелись глаза и начали роиться мысли о том, как с помощью одной такой конструкции можно заменить связку Thread-Handler(UI)? Как же нам данные из корутины корректно передать в UI-поток? Самые невнимательные могут предложить такое решение:

launch(CommonPool) {
    for(i in  1..100) {
        delay(100) 
    }
    myTextView.text = "Hello!" //can’t touch UI-stuff in non-UI thread!
}

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

launch(CommonPool) {
    for(i in  1..100) {
        delay(100)
    }
    runOnUiThread { myTextView.text = "Hello!" } //will work, but it looks weird
}

и это будет работать, но выглядит оно как-то не очень, согласитесь. И прежде чем показать, как это можно сделать «по красоте», необходимо разобраться с функцией async(). Она очень похожа на launch(), с одним лишь отличием, что возвращает результат работы экземпляром Deferred, а у него есть метод await(), который собственно и возвращает результат работы функции.

Давайте смотреть, как это выглядит. Предположим, нам нужно посчитать факториалы двух чисел и получить их сумму. Не спрашивайте меня, кому это может понадобиться, просто мне так захотелось.;) Для этого опишем следующую корявенькую функцию подсчета факториала (+1 в карму первому комментатору данной реализации, а все желающие углубить свои познания о факториале милости просим сюдой):

fun factorial(num : Int) : Deferred<Long> {
   return async(CommonPool) {
           var f:Long = 1
           for (i in 2..num) {
               f *= i
               delay(100) //this is just for purposes of an experiment
           }
           f //it's not a typo, it's a <strong>'return'</strong> statement!!!
       }
}

Внутри функции запускается async-корутина, которая вернет нам значение типа Long. Я поставил задержку в цикле, чтобы показать одну примечательную деталь работы корутин (о ней чуток позже). Теперь сделаем вызов этой функции и посмотрим, как оно отработает:

println("Start")
launch(CommonPool) {
   val f5 = factorial(5)
   val f25 = factorial(25)
   println((f5.await() + f25.await()))
}
println("Stop")

Результаты работы следующие:

11:42:35.050 26517-26517/? I/System.out: Start
11:42:35.061 26517-26517/? I/System.out: Stop
11:42:37.486 26517-26530/com.test I/System.out: 7034535277573963896

А теперь о том, на чем я бы хотел заострить ваше внимание. Если вы заметили, то разница между последними двумя выводами в консоли — около 2.5 секунд. Хотя 5! будет посчитан за ~0.5 секунды. А вот 25! как раз просчитается где-то за 2.5 секунды. Таким образом функция вывода println() не будет выполнена пока не вернутся результаты из всех корутин, т.е. по факту — пока не отработает самая долгая из них.

Отлично, с этим разобрались, и, собственно, теперь можем вернуться к поставленной выше задаче: как результаты работы корутины правильно передать в UI-поток. И в этом нам поможет модуль kotlinx-coroutines-android, который предоставляет UI контекст для корутин — замену тому самому CommonPool, что мы использовали в примерах выше. Для этого закидываем этот модуль в зависимости:

dependencies {
    …
    compile "org.jetbrains.kotlinx:kotlinx-coroutines-android:0.14"
}

Теперь мы смело можем сделать так:

println("Start")
launch(UI) {
   val f5 = factorial(5)
   val f25 = factorial(25)
   myTextView.text = (f5.await() + f25.await()).toString()
}
println("Stop")

Первым делом я проверил, как это дело работает в случае, если контекст умер — работает отлично, утечек я не пронаблюдал! Честно признаться: я и до этого времени AsyncTask совсем не часто использовал, а теперь не вижу необходимости вообще.

Отдельным пунктом хочу заметить, что с применением корутин отпадает необходимость использования колбеков для асинхронных вызовов. Я на дух не переношу ситуации, когда у меня флоу состоит из нескольких последовательных API-коллов. Эти громоздкие конструкции/ветвления переходящие одно в другое — бррр. Теперь же мы просто можем закинуть пачку API-коллов в нужной нам последовательности в корутину, обернуть их, к примеру, в try/catch (чтобы отлавливать различные исключения) и voilà, все готово! И, если я еще не сильно надоел, то хочу подкинуть еще чуток на корутинный вентилятор:

//fetching data for list/recyclerview
launch(UI) {
   try {
       val call = MyHttpClient.getLatestItems() //some call with async() inside
       itemsAdapter.setData(call.await())
       itemsAdapter.notifyDataSetChanged()
    } catch (exc : CustomException) {
           //logging exception  
    }
}

//fetching and processing images
launch(UI) {
   try {
       val imageCall1 = MyHttpClient.fetchImageByUrl(url1)
       val imageCall2 = MyHttpClient.fetchImageByUrl(url2)
       val resultImageFun = combineImages(imageCall1.await(), imageCall2.await()) //also an async() function
       imageView.setImageBitmap(resultImageFun.await())
    } catch (exc : CustomException) {
           //logging exception  
    }
}

Я специально остановился только на двух механизмах работы с корутинами, там их больше, и даже питонисты найдут свой любимый yield.;)

Пытливый ум может упрекнуть меня, что корутины не совсем тянут на базовые вещи в Kotlin, и отвести бы им место в следующей статье рядом с лямбдами, функциями и прочей вкусняшкой....и где-то я с ними соглашусь. Но уж больно хороши они мне показались, и я не смог не поделиться этой информацией с вами!;) Слишком уж сильно они упрощают жизнь и сокращают время от точки А (создание proof of concept) до точки Б (получение готовых результатов), а это дорогого стоит, не так ли?

Лично для себя я сделал уже выбор, и, боюсь, он не сильно в пользу Java. Так что можно дальше баллы не считать. Но о том, ЧТО еще Kotlin умеет, я постараюсь вменяемо рассказать в следующей (последней) части. А теперь можно и на боковую...и только бы не Йода мне приснился, только бы не эта зеленая марты...

Похожие статьи:
Меня зовут Виталий Балашов, я сотрудничаю с компанией ЕРАМ в роли Senior Security Engineer и руковожу профильной командой, распределенной...
Привіт! Мене звуть Дмитро, я розробляю сервіси для компанії YouControl. У цій статті розповім про наш проєкт — інтерактивну карту...
Корпоративний благодійний фонд «Відкриті очі» компанії SoftServe оголошує про старт проєкту «ПоколінняТех»: дітей військових...
Добрый день. Я Андрей, и я девопс в DevOps-Service. Последние года три я ловлю себя на мысли, что могу точно угадать, когда...
До Дія City вже приєдналися 498 компаній. В Опендатабот проаналізували, із яких країн походять іноземні резиденти,...
Яндекс.Метрика