Послевкусие от Kotlin, часть 1

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

Сразу оговорюсь: я ничуть не жалею об использовании Kotlin и всем его рекомендую. Однако хочется предупредить о некоторых подводных камнях.



1. Annotation Processors


Проблема в том, что Kotlin компилируется в Java-bytecode, а уже на его основе генерятся классы, скажем, для JPA или, как в моём случае, QueryDsl. Поэтому результат работы annotation processor не удастся использовать в том же модуле (в тестах можно).

Варианты обхода проблемы:

  • выделить классы, с которыми работает annotation processor в отдельный модуль.
  • исползовать результат annotation processor только из Java класов (их можно будет легально вызывать из Kotlin). Придётся возиться с maven, чтобы он в точности соблюдал последовательность: компилируем Kotlin, наш annotation processor, компилируем Java.
  • попробовать помучиться с kapt (у меня с QueryDsl не вышло)
  • в комментариях написали, что в gradle kapt работает для QueryDsl. Сам не проверял, но вот пример. На maven у меня не вышло. UPD на gradle действительно всё работает. Нужно немного магии

2. Аннотации внутри конструктора


Наткулся на это при объявлении валидации модели. Вот класс, который правильно валидируется:

class UserWithField(param: String) {
    @NotEmpty var field: String = param
}

А вот этот уже нет:
class UserWithConstructor(
    @NotEmpty var paramAndField: String
)

Если аннотация может применяться к параметру (ElementType.PARAMETER), то по умолчанию она будет подвешена к параметру конструктора. Вот починеный вариант класа:

class UserWithFixedConstructor(
    @field:NotEmpty var paramAndField: String
)

Сложно винить за это JetBrains, они честно задокументировали это поведение. И выбор дефолтного поведения понятен – параметры в конструкторе — не всегда поля. Но я чуть не попался.
Мораль: всегда ставьте @field: в аннотациях конструктора, даже если это не нужно (как в случае javax.persistence.Column), целее будете.

3. Переопределение setter


Вещь полезная. Так, к примеру, можно обрезать дату до месяца (где это ещё делать?). Но есть одно но:

class NotDefaultSetterTest {
    @Test fun customSetter() {
        val ivan = User("Ivan")
        assertEquals("Ivan", ivan.name)
        ivan.name = "Ivan"
        assertEquals("IVAN", ivan.name)
    }

    class User(
            nameParam: String
    ) {
        var name: String = nameParam
            set(value) {
                field = value.toUpperCase()
            }
    }
}

С одной стороны, мы не можем переопределить setter, если объявили поле в конструкторе, с другой – если мы используем переданный в конструктор параметр, то он будет присвоен полю сразу, минуя переопределенный setter. Я придумал только один адекватный вариант лечения (если есть идеи по-лучше, пишите в коменты, буду благодарен):

class User(
        nameParam: String
) {
    var name: String = nameParam.toUpperCase()
        set(value) {
            field = value.toUpperCase()
        }
}

4. Особенности работы с фреймворками


Изначально были большие проблемы работы со Spring и Hibernate, но в итоге появился плагин, который всё решил. Вкратце – плагин делает все поля not final и добавляет конструктор без параметров для классов с указанными анотациями.

Но интересные вещи начались при работе с JSF. Раньше я, как добросовестный Java-программист, везде вставлял getter-setter. Теперь, так как язык обязывает, я каждый раз задумываюсь, а изменяемо ли поле. Но нет, JSF это не интересно, setter нужен через раз. Так что всё, что у меня передавалось в JSF, стало полностью mutable. Это заставило меня везде использовать DTO. Не то чтобы это было плохо…

А ещё иногда JSF нужен конструктор без параметров. Я, если честно, даже не смог воспроизвести, пока писал статью. Проблема связана с особенностями жизненного цикла view.

Мораль: надо знать чего ожидает от вашего кода фреймворк. Особенно надо уделить внимание тому, как и когда сохраняются/восставнавливаются объекты.

Дальше идут соблазны, которые подпитываются возможностями языка.

5. Код, понятный только посвященным


Изначально всё остается понятным для неподготовленного читателя. Убрали get-set, null-safe, функциональщина, extensions… Но после погружения начинаешь использовать особенности языка.

Вот конкретный пример:

fun getBalance(group: ClassGroup, month: Date, payments: Map<Int, List<Payment>>): Balance {
    val errors = mutableListOf<String>()
    fun tryGetBalanceItem(block: () -> Balance.Item) = try {
        block()
    } catch(e: LackOfInformation) {
        errors += e.message!!
        Balance.Item.empty
    }

    val credit = tryGetBalanceItem {
        creditBalancePart(group, month, payments)
    }
    val salary = tryGetBalanceItem {
        salaryBalancePart(group, month)
    }
    val rent = tryGetBalanceItem {
        rentBalancePart(group, month)
    }
    return Balance(credit, salary, rent, errors)
}

Это расчет баланса для группы учеников. Заказчик попросил выводить прибыль, даже если не хватает данных по аренде (я его предупредил, что доход будет высчитан неверно).

Объяснение работы метода
Для начала, try, if и when являются блоками, возвращающими значения (последняя строка в блоке). Особенно это важно для try/catch, потому что следующий код, привычный Java-разработчику не компилируется:

val result: String
try {
    //some code
    result = "first"
    //some other code
} catch (e: Exception) {
    result = "second"
}

С точки зрения компилятора нет никакой гарантии, что result не будет проинециализирован дважды, а он у нас immutable.

Дальше: fun tryGetBalanceItem – локальная функция. Прямо как в JavaScript, только со строгой типизацией.

Кроме того, tryGetBalanceItem принимает в качестве аргумента другую функцию и выполняет её внутри try. Если переданная функция провалилась, ошибка добавляется в список и возвращается дефолтный объект.

6. Параметры по умолчанию


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

Например, мы решили, что у User есть обязательные поля, которые нам будут известны при регистрации. А есть поле, вроде даты создания, которое явно имеет только одно значение при создании объекта и будет указываться явно только при восстановлении объекта из DTO.

data class User (
        val name: String,
        val birthDate: Date,
        val created: Date = Date()
)
fun usageVersion1() {
    val newUser = User("Ivan", SEPTEMBER_1990)
    val userFromDto = User(userDto.name, userDto.birthDate, userDto.created)
}

Через месяц мы добавляем поле disabled, которое, так же как и created, при создании User имеет только одно осмысленное значение:

data class User (
        val name: String,
        val birthDate: Date,
        val created: Date = Date(),
        val disabled: Boolean = false
)
fun usageVersion2() {
    val newUser = User("Ivan", SEPTEMBER_1990)
    val userFromDto = User(userDto.name, userDto.birthDate, userDto.created, userDto.disabled)
}

И вот тут возникает проблема: usageVersion1 продолжает компилироваться. А за месяц мы немало уже успели написать. При этом поиск использования конструктора выдаст все вызовы, и правильные, и неправильные. Да, я использовал параметры по умолчанию в неподходящем случае, но изначально это выглядело логично…

7. Лямбда, вложенная в лямбду


val months: List<Date> = ...
val hallsRents: Map<Date, Map<String, Int?>> = months
        .map { month ->
            month to halls
                    .map { it.name to rent(month, it) }
                    .toMap()
        }
        .toMap()

Здесь получаем Map от Map. Полезно, если хочется отобразить таблицу. Я обязан в первой лямбде использовать не it, а что-нибудь другое, иначе во второй лямбде просто не получиться достучаться до месяца. Это не сразу становится очевидно, и легко запутаться.

Казалось бы, обычный стримоз мозга – возьми, да и замени на цикл. Но есть одно но: hallsRents станет MutableMap, что неправильно.

Долгое время код оставался в таком виде. Но сейчас подобные места заменяю на:

val months: List<Date> = ...
val hallsRents: Map<Date, Map<String, Int?>> = months
        .map { it to rentsByHallNames(it) }
        .toMap()

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

Свой проект я считаю репрезентативным: 8500 строк, при том что Kotlin лаконичен (в первый раз считаю строки). Могу сказать, что кроме описаных выше, проблем не возникало и это показательно. Проект функционирует в prod два месяца, при этом проблемы возникали только дважды: один NPE (это была очень глупая ошибка) и одна бага в ehcache (к моменту обнаружения уже вышла новая версия с исправлением).

PS. В следующей статье напишу о полезных вещах, которые дал мне переход на Kotlin.

UPD
Послевкусие от Kotlin, часть 2
Послевкусие от Kotlin, часть 3. Корутины — делим процессорное время

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 28

    +2
    Спасибо за статью.

    Проблема в том, что Kotlin компилируется в Java
    Вы же, наверное, имели в виду генерацию Java-файлов annotation processor'ом из аннотаций в коде на Kotlin? Обычные исходники на Kotlin компилируются напрямую в байт-код, без трансляции в Java.

    <...> если мы используем переданный в конструктор параметр, то он будет присвоен полю сразу, минуя переопределенный setter
    Тут можно сделать что-нибудь в духе

    class User(nameParam: String) {
        var name: String = ""
            set(value) { field = value.toUpperCase() }
    
        init { name = nameParam } // вызовется сеттер
    }
    

    Лямбда, вложенная в лямбду
    Про такое, кстати, даже в документации (coding conventions) сказано, что если лямбды вложены, то у всех лучше явно писать все параметры вместо использования it.
      0
      Вы же, наверное, имели в виду генерацию Java-файлов annotation processor'ом из аннотаций в коде на Kotlin? Обычные исходники на Kotlin компилируются напрямую в байт-код, без трансляции в Java.

      Да, аннотации в коде на Kotlin. Ещё раз уточню вопрос и поправлю статью
      Тут можно сделать что-нибудь в духе

      Я пробовал похожий вариант. В моем варианте поведение поля остается прерогативой поля, а не конструктора. Если таких полей несколько, а класс большой, то это начинает играть роль.
      0
      1. Код, понятный только посвященным

      Очень хорошо ложится, кстати, если не знать java при изучении kotlin (в моём случае — scala).
      Типичная "функциональщина".

        +4

        Лаконичность кода на языке K это не столько свойство языка K, сколько умение программиста четко и ясно выражать свои мысли вне зависимости от языка. Даже на Java можно писать коротко и понятно (если уметь). А если дать правильному джависту в руки C...


        В последнее время читаю очень много кода на Kotlin. И скажу вам, Котлин, там где я его вижу ни разу не лаконичен. Почему? А потому что пишут на нем как на Java. С таким же страшным форматированием. И Scala не лаконична в неправильных руках. Так что если говорить о преимуществах и недостатках. Да, получается короче, но не очень. И не факт, что те 8500 строк которые вы имеете в проекте, нельзя приравнять к 8500 строкам кода на Java.

          0
          Согласен, проблема в головах. Но даже если просто везде, где нужно, добавить setter-getter, toString, equals и hashcode, которые мне достались бесплатно, то код разрастется где-то на треть.
            0
            Эмм, лаконичность в К конечно есть, как и в APL, но я думаю что большинство людей на Хабре сходу не скажут вам даже приблизительно, что делает выражение 2_&{&/x!/:2_!x}'!R. А в ЯП все-таки хочется получать какую-то дозу интуитивности. По крайней мере в Java/Kotlin/C что-то понятно на интуитивном уровне, а К, APL, Perl, даже F# я бы сказал — это отчасти write-only языки.
            0
            Для начала, try, if и when являются блоками, возвращающими значения (последняя строка в блоке)
            То есть можно написать
            fun muFunc(x: Int): Int = try {
              val y = ...
              x+y
            }
            

            и не мучиться с return?
            Мне этот язык становится интересен.
              +1
              К сожалению, Ваш пример не компилируется, не хватает catch:
              fun myFunc(x: Int) = try {
                  val y = 10
                  x+y
              } catch (e: Exception) {
                  42
              }
              

              Зато можно не указывать, что возвращаете Int, компилятор и так об этом знает
                0
                Неприятно, что придется придумывать возвращаемое значение в невозможном случае. Но в catch ведь можно просто сказать throw e — тайпчекер это съест.
                  0
                  В этом случае Вам просто не нужен try. В Kotlin нет «checked» исключений.
                    0
                    Я return не хочу писать.
                      0
                      fun muFunc(x: Int): Int = run {
                      val y = ...
                      x+y
                      }


                      Но это не каноничный Kotlin. Лучше писать с return.
                        +1
                        Если следовать канону пропадает смысл перехода со Scala.
                          +1
                          Ещё можно вот так))
                          val myFun: (Int) -> Int = {
                              val y = 10
                              it+y
                          }
                          
                          fun use() {
                              myFun(10)
                          }
                          

                          Но это будет работать только при одном входном параметре
                            0
                            То есть, несмотря на наличие локальной val, return писать не обязательно?
                              0
                              Дело не в локальной или не локальной val, а в том, лямбда это или нет. В данном случае поле — экземляр лямбды. Вот ещё вариант лямбды:
                              fun myFunc(x: Int) = {
                                  val y = 10
                                  x + y
                              }.invoke()
                              

                              Это как run, только наоборот.
                                0
                                run, пожалуй, наиболее удобная конструкция. Придется писать на Kotlin — буду пользоваться.
                            0

                            В scala в этом месте return так же не нужен.

                          +1
                          На расте так можно
                          fn add_2(x: i32) -> i32 { x + 2 }
                          
                  +1

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

                    0
                    Я, как-раз, пишу, что если выбирать не совсем корректное применение параметров по умолчанию, до добавление нового НЕ ЛОМАЕТ ничего, хотя по смыслу использования должно.
                    +1
                    По поводу пункта 1.

                    1. Пожалуйста, убедитесь, что используете последнюю версию Kotlin Gradle Plugin и ‘kotlin-kapt’-плагина (https://kotlinlang.org/docs/reference/kapt.html);
                    2. Я добавил пример с QueryDsl в наш репозиторий с примерами (https://github.com/JetBrains/kotlin-examples/tree/master/gradle/kotlin-querydsl). Надеюсь, он поможет;
                    3. Не бойтесь публиковать ишью в наш баг-трекер (http://kotl.in/issue). Даже если то, с чем вы столкнулись, на самом деле не баг, мы постараемся вам помочь.
                      0
                      Ничуть не сомневаюсь. Но у меня maven, и переходить на gradle пока не собираюсь. В статье допишу вариант с переходом на gradle
                        0

                        Можете написать по какой причине? Предположу, что это "старый" проект, который продолжили писать на Kotlin.

                          0
                          Навскидку можно предположить больше причин:
                          1) Отсутствие нужных плагинов либо их недостаточно хорошая работа в Gradle. Как пример — при запуске JUnit тестов простым образом нельзя подсунуть Listener. Это аффектит работу Allure и либо требует некоторых и доработок, которые могут ломать привычные имеющиеся наработки.
                          2) Отсутствие специалистов. В любой технологии нужно разобраться. Если проект сложнее стандартного — доработать технологию под себя.
                          3) Боязнь Gradle из-за его некоторой нестабильности. Смена API для плагинов как пример(да, лично нарывался на ситуацию, когда это что то сильно аффектило). Другой пример — смена языка написания build-скриптов(Hello Kotlin!).
                            0
                            Потому что это было бы единственной причиной учить gradle. При этом использование QueryDsl у меня совершенно естественным образом перетекло в другой модуль (работа с фронтом отделена и только там мне понадобилось). Итог: проблемы бы получил, а выгоду — нет.
                            Проект не старый, изначально писался на Kotlin. Но достаточно старый чтобы застать проблемы с совместимостью с Spring.
                        +1

                        По поводу пункта 5 — на мой взгляд, код получился достаточно понятным. А вот что в Котлине действительно выносит мозг, на мой взгляд, даже сильнее имплиситов в Scala, так это неявный this в лямбдах, через который делаются билдеры и прочие DSLи. Из-за него внутри лямбды магическим образом становится возможно вызывать методы, которые определены на типе receiver'а, и откуда эти методы берутся в конкретном куске кода, без дополнительного исследования понять нельзя. В Scala, например, через имплиситы нельзя добавить новые методы непосредственно в область видимости; новые методы могут быть добавлены только к какому-нибудь объекту (через имплиситные классы).


                        Понятно, что и в том и в другом случае при наличии IDE найти, откуда берется какой-то метод, не составляет особой сложности, но лично мое ИМХО — понять код с имплиситами в Скале проще, чем код с переопределенным this для лямбд в Котлине. Кроме того, синтаксическая привязанность неявно добавленных методов к объекту упрощает чтение — код в Котлине был бы существенно понятнее, хоть и существенно многословнее, если бы для вызова методов на this необходимо было бы всегда писать this явно.

                          0
                          Для укрощения неявных this в некоторых случаях может помочь аннотация @DslMarker.

                        Only users with full accounts can post comments. Log in, please.