Java vs. Kotlin для Android. День 2: А может ну его, этот Kotlin?
Необходимо сделать лирическое отступление и констатировать факт, что со времени написания оригинальной серии статей по Kotlin и публикацией их на данном ресурсе, прошло уже достаточное время. Время достаточное для того чтобы: а) вышла обнова на Kotlin 1.1; б) наш любимый Гугль решил-таки начать нативно поддерживать Java 8 под Android. Более того, второе событие произошло непосредственно в день выхода первой части этой серии статей — вот прям как звезды сошлись на небе.
Не знаю, какое из событий меня больше порадовало, но в любом случае пришлось кой-чего переписывать...к примеру, вот эти самые строки. Безусловно, я рад за платформу и благодарен Гуглу за такой ответственный шаг, НО Kotlin стал еще вкуснее и приятнее взору, пальцам и мозгам. Для тех, кого цепанула вторая новость, просим милости сюда изучать, чем нам это грозит. А для тех, кто, так же как и я, пропустил событие
Вы все еще здесь? А может ну его, этот 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 классный, как лаконичнее код выглядит с применением вышеизложенного и прочее бла-бла-бла, НО, как мы знаем,
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 умеет, я постараюсь вменяемо рассказать в следующей (последней) части. А теперь можно и на боковую...и только бы не Йода мне приснился, только бы не эта зеленая марты...