Kotlin: опыт боевого применения

в 11:58, , рубрики: android development, java, kotlin, Блог компании Ассоциация ISDEF, Компиляторы, Программирование

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

Kotlin (Ко́тлин) — статически типизированный язык программирования, работающий поверх JVM и разрабатываемый компанией JetBrains. Kotlin сочетает в себе принципы объектно-ориентированного и функционального языка программирования. По заявлению разработчиков, обладает такими качествами, как прагматичность, лаконичность и интероперабельность (pragmatic, concise, interoperable). Программы, написанные на нём, могут выполняться на JVM или компилироваться в JavaScript, не за горами поддержка native компиляции. Важно отметить, язык создавался одновременно с инструментами разработки и был изначально заточен под них.

К настоящему времени Котлину посвящено немало статей и сделано немало докладов. Мы постараемся сосредоточиться не столько на отличительных особенностях языка, хвалить его или ругать, сколько на нашем практическом опыте использования заявленных преимуществ.

Итак, обо всем по порядку…

Лучший tooling

Разработчиком языка Kotlin является компания JetBrains, разработавшая пожалуй лучшую IDE для Java и многих других языков программирования. Несмотря на всю многословность языка Java, скорость написания остается очень высокой: среда “пишет код” за вас.

С Kotlin складывается ощущение, что вы купили новую клавиатуру и все никак не можете привыкнуть к ней и печатать вслепую не получается. IntelliSense зачастую просто не успевает за скоростью набора текста, там где для Java IDE сгенерирует целый класс, для Kotlin вы будете смотреть на прогресс бар. И проблема не только для новых файлов: при активной навигации по проекту IDE просто начинает зависать и спасает только ее перезапуск.

Огорчает, что многие трюки, к которым вы привыкли, просто перестают работать. К примеру, Live Templates. Android Studio — (Версия IntelliJ IDEA заточенная под Android разработку) поставляется вместе с набором удобных шаблонов для часто используемых операций, таких как логгирование. Комбинация logm+ Tab вставит за вас код, который напишет в лог сообщение о том, какой метод и с какими параметрами был вызван:

Log.d(TAG, "method() called with: param = [" + param + "]");

При этом данный шаблон “умеет” правильно определять метод и параметры, в зависимости от того, где вы его применили.

Однако для Kotlin это не работает, более того, вам придется заводить отдельный шаблон (например klogd + Tab) для Kotlin и использовать его в зависимости от языка программирования. Причина, почему для языков, которые стопроцентно совместимы IDE, приходится выставлять настройки дважды, остается для нас загадкой.

Легкость в освоении

Kotlin, несмотря на возможность компиляции в JavaScript и потенциально в нативный код (используя Kotlin.Native), в первую очередь, язык для JVM и нацелен на то, чтобы избавить Java разработчиков от ненужного, потенциально опасного (в смысле привнесения багов) бойлерплейта. Однако ошибочно считать, что вы с первых строк на Kotlin будете писать на Kotlin. Если проводить аналогию с языками, то поначалу вы будете писать на “рунглише” с сильным Java акцентом. Данный эффект подтвержден ревью своего кода, спустя какое-то время, а также наблюдением за кодом коллег, только начинающих освоение языка. Наиболее заметно это проявляется в работе с null и nonNull типами, а также излишней “многословности” выражений — привычки, с которой бороться сложнее всего. Кроме того, наличие просто огромного числа новых возможностей вроде extension-методов открывают “Ящик Пандоры” для написания черной магии, добавляя лишнюю сложность там, где этого не нужно, а также делая код более запутанным, т.е. менее приспособленным для ревью. Чего только стоит перегрузка метода invoke() [https://habrahabr.ru/post/278169/], которая позволяет замаскировать его вызов под вызов конструктора так, что визуально создавая объект типа Dog получаете все что угодно:

class Dog private constructor() {
  companion object {
      operator fun invoke(): String = "MAGIC"
  }
}

object DogPrinter {
  @JvmStatic
  fun main(args: Array<String>) {
      println(Dog()) // MAGIC
  }

Таким образом, несмотря на то, что на освоение синтаксиса уйдет не более недели, на то, чтобы научиться правильно применять фичи языка, может уйти не один месяц. Местами потребуется более детальное изучение принципов работы того или иного синтаксического сахара, включая изучения полученного байт-кода. При использовании Java, вы сможете всегда обратиться к источникам вроде Effective Java для того, чтобы избежать многих неприятностей. Несмотря на то, что Kotlin проектировался с учетом “неприятностей”, привнесенных Java, о “неприятностях”, привнесенных Kotlin еще только предстоит узнать.

Null safety

Язык Kotlin обладает изящной системой типов. Она позволяет в большинстве случаев избежать самую популярную в Java проблему — NullPointerException. Каждый тип имеет два варианта в зависимости от того, может ли переменная этого типа принимать значение null. Если переменной можно присвоить null, к типу добавляется символ вопроса. Пример:

val nullable: String? = null
val notNull: String = ""

Методы nullable переменной вызываются с использованием оператора .? Если такой метод вызван на переменной, имеющей значение null, результат всего выражения тоже примет значение null, при этом метод вызван не будет и NullPointerException не случится. Конечно, разработчиками языка оставлен способ вызвать метод на nullable переменной, не смотря ни на что, и получить NullPointerException. Для этого вместо? придётся написать !!:

nullable!!.subSequence(start, end)

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

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

Если же null из Java кода пробрался в Котлин, произойдёт крэш с исключением следующего вида:

FATAL EXCEPTION: main
Process: com.devexperts.dxmobile.global, PID: 16773
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.devexperts.dxmobile.global/com.devexperts.dxmarket.client.ui.generic.activity.GlbSideNavigationActivity}: java.lang.IllegalArgumentException: Parameter specified as non-null is null: method kotlin.jvm.internal.Intrinsics.checkParameterIsNotNull, parameter savedInstanceState

Дизассемблировав байт-код, находим место, откуда было брошено исключение:

ALOAD 1
LDC "param"
INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkParameterIsNotNull(Ljava/lang/Object;Ljava/lang/String;)V

Дело в том, что для всех параметров не-private методов компилятор добавляет специальную проверку: вызывается метод стандартной библиотеки

kotlin.jvm.internal.Intrinsics.checkParameterIsNotNull(param, "param")

При желании его можно отключить при помощи директивы компилятора
-Xno-param-assertions

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

Ко всем классам, имеющим метод get(), в Котлине можно применять оператор []. Это очень удобно. Например:

val str = “my string”
val ch = str[2]

Однако оператор доступа по индексу можно применять только для non-null типов. Nullable версии не существует, и в таком случае придётся явно вызвать метод get():

var str: String? = null
val ch = str?.get(2)

Properties

Котлин упрощает работу с полями классов. Обращаться к полям можно, как к обычным переменным, при этом будет вызван геттер или сеттер нужного поля.

// Java code
public class IndicationViewController extends ViewController {
    private IndicationHelper indicationHelper;
    protected IndicationHelper getIndicationHelper() {
        return indicationHelper;
    }
}
// Kotlin code
val indicationViewController = IndicationViewController()
val indicationHelper = indicationViewController.indicationHelper

Все усложняется, если требуется переопределить геттер Java класса в классе на Котлине. На первый взгляд кажется, что indicationHelper — это полноценное property, совместимое с Котлином. На самом деле это не так. Если мы попробуем переопределить его “в лоб”, получим ошибку компиляции:

class GlobalAccountInfoViewController(context: Context) :  IndicationViewController(context) {
    protected open val indicationHelper = IndicationHelper(context, this)
}

Kotlin: опыт боевого применения - 1

Всё сделано правильно: в классе наследнике объявлено проперти, геттер которого имеет абсолютно идентичную сигнатуру геттеру суперкласса. Что же не так? Компилятор заботится о нас и считает, что переопределение произошло по ошибке. На эту тему существует даже обсужнение на форуме Котлина. Отсюда мы узнаём две важные вещи:

  1. “Java getters are not seen as property accessors from Kotlin” — Геттеры в Java коде не видны из Котлина как проперти.
  2. “This may be enhanced in the future, though” — есть надежда, что в будущем это изменится к лучшему.

Там же приводится единственный правильный способ всё-таки добиться нашей цели: создать private-переменную и одновременно с этим переопределить геттер.

class GlobalAccountInfoViewController(context: Context) : IndicationViewController(context) {
    private val indicationHelper = IndicationHelper(context, this)
    override fun getIndicationHelper() = indicationHelper
}

100% Java-interop

Пожалуй, стоило поставить этот пункт на первое место, поскольку именно Java-interop позволил новому языку так быстро набрать такую популярность, что даже Google заявил об официальной поддержке языка для разработки под Android. К сожалению, здесь также не обошлось без сюрпризов.

Рассмотрим такую простую и известную всем Java разработчикам вещь, как модификаторы доступа или модификаторы видимости. В Java их четыре штуки: public, private, protected и package-private. Package-private используется по умолчанию, если вы не указали иного. В Kotlin по умолчанию используется модификатор public, и он, как и protected и private, называется и работает точно так же, как в Java. А вот модификатор package-private в Kotlin называется internal и работает он несколько иначе.

Дизайнеры языка хотели решить проблему с потенциальной возможностью нарушить инкапсуляцию при применении package-private модификатора путем создания в клиентском коде пакета с тем же именем, что и в библиотечном коде и предопределении нужного метода. Такой трюк часто используется при написании unit-тестов для того, чтобы не открывать “наружу” метод только для нужд тестирования. Так появился модификатор internal, который делает объект видимым внутри модуля.

Модулем называется:

  • Модуль в проекте IntelliJ Idea
  • Проект в Maven
  • Source set в Gradle
  • Набор исходников скомпилированных одним запуском ant-скрипта

Проблема в том, что на самом деле internal это public final. Таким образом при компиляции на уровне байт кода может так получиться, что вы случайно переопределите метод, который переопределять не хотели. Из-за этого компилятор переименует ваш метод, чтобы такого не произошло, что в свою очередь сделает невозможным вызов данного метода из Java кода. Даже если файл с этим кодом будет находится в том же модуле, в том же пакете.

class SomeClass {
   internal fun someMethod() {
       println("")
   }
}

public final someMethod$production_sources_for_module_test()V

Вы можете скомпилировать ваш Kotlin код с internal модификатором и добавить его как зависимость в ваш Java проект, в таком случае вы сможете вызвать этот метод там, где protected модификатор вам бы этого сделать не дал, т.е получите доступ к приватному API вне пакета (т.к метод де-факто public), хотя и не сможете переопределить. Складывается ощущение, что модификатор internal был задуман не как часть “Прагматичного языка”, а скорее как фича IDE. При том, что сделать такое поведение можно было, например, при помощи аннотаций. На фоне заявлений о том, что в Kotlin очень мало ключевых слов зарезервировано, например, для корутин, internal фактически прибивает гвоздями ваш проект на Kotlin к IDE от JetBrains. Если вы разрабатываете сложный проект, состоящий из большого числа модулей, часть из которых могут использоваться как зависимость коллегами, в проекте на чистой Java, хорошо подумайте о том, стоит ли писать общие части на Kotlin.

Data Classes

Следующая, пожалуй одна из самых известных фич языка — data классы. Data классы позволяют вам быстро и просто писать POJO-объекты, equals, hashCode, toString и прочие методы для которых компилятор напишет за вас.

Это действительно удобная вещь, однако, ловушки могут поджидать вас в совместимости с используемыми в проекте библиотеками. В одном из наших проектов мы использовали Jackson для сериализации/десериализации JSON. В тот момент, когда мы решили переписать некоторые POJO на Kotlin, оказалось что аннотации Jackson некорректно работают с Kotlin и необходимо дополнительно подключать отдельный jackson-module-kotlin модуль для совместимости.

И в заключение

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

Авторы:
Тимур Валеев, инженер-программист Devexperts
Александр Верещагин, инженер-программист Devexperts

Автор: Александр

Источник

Поделиться

* - обязательные к заполнению поля