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 умеет, я постараюсь вменяемо рассказать в следующей (последней) части. А теперь можно и на боковую...и только бы не Йода мне приснился, только бы не эта зеленая марты...

Похожие статьи:
С 11 июня по 7 июля 2018 года мы проводили очередной анонимный зарплатный опрос, в котором приняли участие 9610 человек. Исходные данные...
16 січня Верховна Рада підтримала законопроєкт № 10062, який стосується цифровізації Збройних сил України. Про це повідомив народний...
Понад дев’ять місяців росія атакує ракетами українські міста та села. Останні масовані атаки відзначились особливою жорстокістю...
Приглашаем читателей DOU вспомнить год 2016-й. Оцените самые значимые события и итоги года лично для вас. Загрузка... Результаты буду...
...— Достало меня это «внутри нет деталей, обслуживаемых пользователем». Хочется посмотреть, что же там есть. ...— Русская...
Яндекс.Метрика