Сразу оговорюсь: я ничуть не жалею об использовании Kotlin и всем его рекомендую. Однако хочется предупредить о некоторых подводных камнях.
![](https://habrastorage.org/web/bbb/206/b31/bbb206b317394ac3b53519354f994cb1.png)
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 у меня не вышло
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)
}
Это расчет баланса для группы учеников. Заказчик попросил выводить прибыль, даже если не хватает данных по аренде (я его предупредил, что доход будет высчитан неверно).
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.
Комментарии (28)
maxru
20.06.2017 15:10- Код, понятный только посвященным
Очень хорошо ложится, кстати, если не знать java при изучении kotlin (в моём случае — scala).
Типичная "функциональщина".
ppopoff
20.06.2017 16:50+4Лаконичность кода на языке K это не столько свойство языка K, сколько умение программиста четко и ясно выражать свои мысли вне зависимости от языка. Даже на Java можно писать коротко и понятно (если уметь). А если дать правильному джависту в руки C...
В последнее время читаю очень много кода на Kotlin. И скажу вам, Котлин, там где я его вижу ни разу не лаконичен. Почему? А потому что пишут на нем как на Java. С таким же страшным форматированием. И Scala не лаконична в неправильных руках. Так что если говорить о преимуществах и недостатках. Да, получается короче, но не очень. И не факт, что те 8500 строк которые вы имеете в проекте, нельзя приравнять к 8500 строкам кода на Java.
gnefedev
20.06.2017 19:10Согласен, проблема в головах. Но даже если просто везде, где нужно, добавить setter-getter, toString, equals и hashcode, которые мне достались бесплатно, то код разрастется где-то на треть.
mezastel
21.06.2017 13:53Эмм, лаконичность в К конечно есть, как и в APL, но я думаю что большинство людей на Хабре сходу не скажут вам даже приблизительно, что делает выражение
. А в ЯП все-таки хочется получать какую-то дозу интуитивности. По крайней мере в Java/Kotlin/C что-то понятно на интуитивном уровне, а К, APL, Perl, даже F# я бы сказал — это отчасти write-only языки.2_&{&/x!/:2_!x}'!R
potan
20.06.2017 16:57Для начала, try, if и when являются блоками, возвращающими значения (последняя строка в блоке)
То есть можно написать
fun muFunc(x: Int): Int = try { val y = ... x+y }
и не мучиться с return?
Мне этот язык становится интересен.gnefedev
20.06.2017 19:03+1К сожалению, Ваш пример не компилируется, не хватает catch:
fun myFunc(x: Int) = try { val y = 10 x+y } catch (e: Exception) { 42 }
Зато можно не указывать, что возвращаете Int, компилятор и так об этом знаетpotan
20.06.2017 19:16Неприятно, что придется придумывать возвращаемое значение в невозможном случае. Но в catch ведь можно просто сказать throw e — тайпчекер это съест.
gnefedev
20.06.2017 19:26В этом случае Вам просто не нужен try. В Kotlin нет «checked» исключений.
potan
20.06.2017 20:02Я return не хочу писать.
vsb
20.06.2017 20:19fun muFunc(x: Int): Int = run {
val y = ...
x+y
}
Но это не каноничный Kotlin. Лучше писать с return.potan
20.06.2017 20:49+1Если следовать канону пропадает смысл перехода со Scala.
gnefedev
20.06.2017 21:33+1Ещё можно вот так))
val myFun: (Int) -> Int = { val y = 10 it+y } fun use() { myFun(10) }
Но это будет работать только при одном входном параметреpotan
20.06.2017 21:45То есть, несмотря на наличие локальной val, return писать не обязательно?
gnefedev
20.06.2017 22:37Дело не в локальной или не локальной val, а в том, лямбда это или нет. В данном случае поле — экземляр лямбды. Вот ещё вариант лямбды:
fun myFunc(x: Int) = { val y = 10 x + y }.invoke()
Это как run, только наоборот.potan
21.06.2017 12:03run, пожалуй, наиболее удобная конструкция. Придется писать на Kotlin — буду пользоваться.
Morj
20.06.2017 18:10+1Если вы используете параметры по умолчанию, и общее количество параметров достаточно большое, то лучше делать вызовы с именованными параметрами там, где метод/функция была использована. Тогда добавление новых параметров ничего не ломает.
gnefedev
20.06.2017 18:54Я, как-раз, пишу, что если выбирать не совсем корректное применение параметров по умолчанию, до добавление нового НЕ ЛОМАЕТ ничего, хотя по смыслу использования должно.
yanex
20.06.2017 18:46+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). Даже если то, с чем вы столкнулись, на самом деле не баг, мы постараемся вам помочь.gnefedev
20.06.2017 18:48Ничуть не сомневаюсь. Но у меня maven, и переходить на gradle пока не собираюсь. В статье допишу вариант с переходом на gradle
shishmakov
21.06.2017 12:00Можете написать по какой причине? Предположу, что это "старый" проект, который продолжили писать на Kotlin.
TheKnight
21.06.2017 12:58Навскидку можно предположить больше причин:
1) Отсутствие нужных плагинов либо их недостаточно хорошая работа в Gradle. Как пример — при запуске JUnit тестов простым образом нельзя подсунуть Listener. Это аффектит работу Allure и либо требует некоторых и доработок, которые могут ломать привычные имеющиеся наработки.
2) Отсутствие специалистов. В любой технологии нужно разобраться. Если проект сложнее стандартного — доработать технологию под себя.
3) Боязнь Gradle из-за его некоторой нестабильности. Смена API для плагинов как пример(да, лично нарывался на ситуацию, когда это что то сильно аффектило). Другой пример — смена языка написания build-скриптов(Hello Kotlin!).
gnefedev
21.06.2017 15:17Потому что это было бы единственной причиной учить gradle. При этом использование QueryDsl у меня совершенно естественным образом перетекло в другой модуль (работа с фронтом отделена и только там мне понадобилось). Итог: проблемы бы получил, а выгоду — нет.
Проект не старый, изначально писался на Kotlin. Но достаточно старый чтобы застать проблемы с совместимостью с Spring.
Googolplex
20.06.2017 21:48+1По поводу пункта 5 — на мой взгляд, код получился достаточно понятным. А вот что в Котлине действительно выносит мозг, на мой взгляд, даже сильнее имплиситов в Scala, так это неявный this в лямбдах, через который делаются билдеры и прочие DSLи. Из-за него внутри лямбды магическим образом становится возможно вызывать методы, которые определены на типе receiver'а, и откуда эти методы берутся в конкретном куске кода, без дополнительного исследования понять нельзя. В Scala, например, через имплиситы нельзя добавить новые методы непосредственно в область видимости; новые методы могут быть добавлены только к какому-нибудь объекту (через имплиситные классы).
Понятно, что и в том и в другом случае при наличии IDE найти, откуда берется какой-то метод, не составляет особой сложности, но лично мое ИМХО — понять код с имплиситами в Скале проще, чем код с переопределенным this для лямбд в Котлине. Кроме того, синтаксическая привязанность неявно добавленных методов к объекту упрощает чтение — код в Котлине был бы существенно понятнее, хоть и существенно многословнее, если бы для вызова методов на this необходимо было бы всегда писать this явно.
Beholder
20.06.2017 22:34Для укрощения неявных this в некоторых случаях может помочь аннотация
@DslMarker
.
h0tkey
Спасибо за статью.
Вы же, наверное, имели в виду генерацию Java-файлов annotation processor'ом из аннотаций в коде на Kotlin? Обычные исходники на Kotlin компилируются напрямую в байт-код, без трансляции в Java.Тут можно сделать что-нибудь в духе
Про такое, кстати, даже в документации (coding conventions) сказано, что если лямбды вложены, то у всех лучше явно писать все параметры вместо использования
it
.gnefedev
Да, аннотации в коде на Kotlin. Ещё раз уточню вопрос и поправлю статью
Я пробовал похожий вариант. В моем варианте поведение поля остается прерогативой поля, а не конструктора. Если таких полей несколько, а класс большой, то это начинает играть роль.