неділю, 23 липня 2017 р.

Kotlin. Часть 3. Мигрируем из Java

Ранее вышло две статьи по языку Kotlin. В первой статье мы с вами познакомились с языком, а во второй рассмотрели популярные конструкции, которые могут быть не очевидны для типового Java разработчика.

Цели миграции

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

  1. Вас интересуют только тесты.
  2. Вас интересует только новая функциональность или часть старого кода.
  3. Вас интересует полная замена старого кода и дальнейшая разработка.

Чем ниже позиция, тем больше масштаб трагедии.

Если вас интересует пункт 1, то данная статья вам не нужна, для вас будет 4 и 5 часть цикла о Kotlin.

Если вас интересует второй пункт, то нужно понимать, что смесь Java и Kotlin в определенных пропорциях в одном проекте может затормозить вашу работу. Но это при условии сильно смешанного Java и Kotlin кода внутри одного проекта. Об этом ниже.

Третий пункт хорош тем, что у вас не будет проблем с долгой компиляцией, как во втором случае. Однако, это путь самый долгий, но к счастью у нас есть средства автоматизации.

Картина: Котлин захватывает умы мобильных разработчиков

Java + Kotlin

Давайте разберемся как же совместно работают Java и Kotlin с технической стороны. В чем мы уверены? В виртуальной машине! Есть байт-код, который будет исполняться на виртуальной машине Java. Байт-код мы получаем в результате компиляции Java и Kotlin. На итоговой машине может стоять любая версия JVM, а нам подойдет 6, 7 или 8, т.к. Kotlin может быть скомпилирован под одну из них с незначительными ограничениями. Как вы, возможно, знаете, файлы с расширением .java, которые содержат исходный код, компилируются с помощью javac в файлы .class, которые содержат байт-код. Аналогично в случае Kotlin. Файлы с исходным кодом имеют расширение .kt, а файлы с байт-кодом также .class. Компиляция происходит с помощью компилятора kotlinc. На картинке ниже изображен упрощенный процесс сборки приложения с Kotlin.

Упрощенный процесс сборки приложения

Как видно выше приложение с Kotlin требует Kotlin Runtime библиотеки. Благодаря ним в приложении становятся доступными стандартные Kotlin классы и расширения.

Полная совместимость означает, что вы можете не только использовать классы Java, но и наследоваться от них, реализовывать интерфейсы, применять Java аннотации в своем Kotlin коде и т.д. Более того, из Java можно вызывать Kotlin код в естественном виде, никаких спец. хаков, ничего лишнего.

На случай, более привычного использования Kotlin библиотек в Java, есть специальные средства для того, чтобы минимизировать отличия Kotlin от Java после сборки. Например, first-class функции на самом деле оборачиваются в сгенерированные классы, но названия этих классов менее звучные, чем, например, StringUtils. Для того, чтобы из Java кода имена сгенерированных Kotlin классов-оберток были привычнее, вы можете использовать конструкцию

@file:JvmName("SomeUtils")

fun someFunc(param: String?) {}

Такой код в Java будет выглядеть как вызов

SomeUtils.someFunc("abc"); 

Компилятор Kotlin справляется и в случае множественных зависимостей в коде между языками, а среда разработки Intellij Idea поддерживает автодополнения и другую функциональность не смотря на смешанный код.

Теперь у вас есть представление о том как это работает. Важно отметить, что т.к. компилятор, который обрабатывает связанный код не идеален, как и любой процессинг в такой ситуации, то в проектах в которых и Java, и Kotlin присутствуют в большом объеме может замедлиться разработка. На собственном примере я ощутил как долго среда разработки выполняет автодополнение. Трудно точно локализовать проблему, но она общеизвестна и разработчики языка активно с ней борются.

Инструменты миграции

Вместе с языком Kotlin в цикл разработки входит конвертер из Java в Kotlin. Сразу обратите внимание, что обратной конвертации нет. Хорошо, если у вас есть система контроля версий, например, Git, и вы сможете откатить изменения, если они вас не устроят. Конвертировать можно из меню Code -> Convert Java File to Kotlin File или используйте shortcut Сtrl + Alt + Shift + K.

Важно понимать, что задача конвертера не оптимизировать ваш код, а сделать конвертацию конструкций из Java в Kotlin. По этому то, что получится в результате, обычно, следует обязательно пересмотреть и улучшить с помощью возможностей Kotlin. О проблемах конвертации поговорим в самом конце.

Просмотр байт-кода

В плагин для Kotlin входит функциональность просмотра получающегося байт кода. Для того чтобы его увидеть достаточно в меню Tools -> Kotlin выбрать Show Kotlin Byte code.

Заглянем за кулисы

Давайте посмотрим на особенности байт-кода Kotlin. Там мы можем найти кое что интересное для понимания принципов языка.

Такая замечательная вещь как контроль Nullability привносит с собой не значительный оверхед в виде явной проверки на null. Для примера я приведу пустой метод, который ничего не делает, но требует значение параметра не равный null (напомню, что в случае nullable параметра в Kotlin используется String?).

fun someFunc(param: String) {} 

Для этого метода, кроме RETURN команды, в байт-коде мы увидим явную проверку на null:

INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkParameterIsNotNull (Ljava/lang/Object;Ljava/lang/String;)V

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

fun someFunc(param: String?) {}

из байт кода, естественно, исчезает проверка, но всегда генерируется аннотация Nullable или NotNull (это помогает среде разработки подсвечивать опасное использование переменных, которые могут быть null)

  @Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0

Особенности конвертации

Конвертер не стоит на месте и силами разработчиков языка исправляются всё больше ошибок. Наиболее мажорные из них исправлены к версии 1.1. Тем не менее, конвертированный код не идеален и ниже рассмотрим конвертацию лямбд из Java в Kotlin, а также Stream Api.

Отличие лямбд в Kotlin и Java

Простым языком: лямбда - это кусок кода, который можно сохранить в переменную, он может принимать, возвращать значения или может быть передан в функцию.

Один из приятных моментов, который Kotlin привносит в возможности языка - это inline функции. Проще говоря - компилятор способен встроить код того или иного метода в место вызова и, как следствие, это иногда влечет за собой ускорение, при увеличение размера байт-кода.

Например, код метода main

fun main(args: Array<String>) {
    invokeLambda {
        println("Hello world")
    }
}

inline fun invokeLambda(lambda: () -> Unit) {
    lambda()
}

в результате имеет байт код схожий, но не в точности, с

fun main(args: Array<String>) {
    println("Hello world")
}

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

При нежелании, чтобы ваша лямбда была "заинлайнена" используйте ключевое слово noinline для параметра inline функции.

Рассмотрим результат конвертации лямбды. В Java, обычно, для типичных лямбд используются такие функциональные интерфейсы как Runnable, Supplier, Function и т.д. В Kotlin же, как вы уже знаете, тип лямбды декларируется как (ParamsTypes) -> ReturnType. Изначально мы имеем следующий код:

public class Example {
    public static void main(String[] args) {
        invokeLambda(() -> {
            System.out.println("Hello world");
        });
    }
    private static void invokeLambda(Runnable lambda) {
        lambda.run();
    }
}

В результате конвертации мы получаем Kotlin код:

object Example {
    @JvmStatic fun main(args: Array<String>) {
        invokeLambda { println("Hello world") }
    }
    private fun invokeLambda(lambda: Runnable) {
        lambda.run()
    }
}

Ключевое слово object - обозначает своего рода singleton, т.к. у класса нет контекста (полей класса), то нет никакого смысла создавать для него новые инстансы.

Для того чтобы метод main из Java был представлен как статический (в Kotlin нет статических методов, для этих целей обычно служит companion object) используется аннотация @JvmStatic.

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

fun main(args: Array<String>) = invokeLambda { println("Hello world") }

private fun invokeLambda(lambda: () -> Unit) = lambda()

В версии Kotlin 1.1 всё конвертируется отлично, хотя в прошлых версиях была проблема с конвертацией лямбд.

Stream Api

Как вы знаете, с появлением Java 8 в арсенале разработчика появился мощная возможность. Имя ей Stream Api. Задача, которая решается с его помощью - это обработка коллекции данных в функциональном стиле. При этом код в Java выглядит компактным и хорошо структурированным. В Stream Api есть терминальные операции - это завершающие операции, при достижении которых начинается обработка описанного стрима (forEach, collect, sum и т.д.). Важный момент, стримы в Java исключительно lazy, т.е. выполняются только при достижении терминальной операции, а если её нет, то стрим просто ничего не сделает.

В Kotlin, есть аналог Stream Api, но в отличии от Java есть еще и не lazy обработка. Она доступна прямо на уровне коллекции, например

val incrementedCollection = someIntCollection.map { it + 1 }

Прямым аналогом stream являются последовательности. Получить последовательность можно с помощью метода asSequence вызванного на коллекции, а дальше работать аналогично как со стримом.

Обратите внимание, что когда мы используем функцию map для коллекции, то это inline функция, т.е. фактически код будет встроен в место вызова, однако это не верно для функции map у последовательности.

Итак, давайте конвертируем Java stream в Kotlin. Пусть у нас есть исходный код, который должен собирать фразу "hello world !" на Java вот таким изощренным способом.

List<String> words = Arrays.asList("Hello", "my", "world", "world", "!");
String resultString = words.stream()
      .filter((word) -> !Objects.equals("my", word))
      .map(String::toLowerCase)
      .distinct()
      .collect(Collectors.joining(" "));
System.out.println(resultString);

Воспользуемся конвертером

Бэм! И мы получили не компилируемый код. Как я писал, конвертер не идеален.

val words = Arrays.asList("Hello", "my", "world", "world", "!")
val resultString = words.stream()
    .filter { word -> "my" != word }
    .map<String>(Function<String, String> { it.toLowerCase() })
    .distinct()
    .collect<String, *>(Collectors.joining(" "))
println(resultString)

Для начала заставим его компилироваться. Для этого подкорректируем его и получим уже компилируемый код

val words = Arrays.asList("Hello", "my", "world", "world", "!")
val resultString = words.stream()
        .filter { word -> "my" != word }
        .map { it.toLowerCase() }
        .distinct()
        .collect(Collectors.joining(" "))
println(resultString)

В принципе, задача решается уже верно, но давайте перепишем на нативное использование Kotlin без привязки к Java. Ради красоты и читаемости.

    val words = listOf("Hello", "my", "world", "world", "!")
    val resultString = words.asSequence()
            .filterNot { it == "my" }
            .map(String::toLowerCase)
            .distinct()
            .joinToString(separator = " ")
    println(resultString)

Готово! Понимаю, что использование filterNot, конечно, на любителя, но в целом мы имеем отличный, читаемый, компилируемый ленивый поцессинг коллекции. А если убрать asSequence, то процессинг перестанет быть ленивым. Оператор "==" в Kotlin это equals в Java, т.е. в Kotlin строки можно всегда сравнивать через "==".

Модификатор internal

Наконец, в Kotlin есть модификатор internal использование его обозначает, что метод/класс/поле доступно к использованию только внутри модуля (jar-ника). Если вы использовали для класса A этот модификатор, запаковали его и подключили как зависимость, то этого класса в зависимом проекте вы не найдете. Но как вы понимаете в Java нет ничего подобного, а всё реализуется на виртуальной Java машине. Решение оказалось не сложным. При компиляции internal компоненты языка проходят своего рода обфускацию, т.е. они переименовываются страшным образом и использовать такие поля/методы/классы из Java кода не очень хочется.

Заключение

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

Немає коментарів:

Дописати коментар

HyperComments for Blogger

comments powered by HyperComments