Написано довольно много статей о Kotlin, но об его использовании в реальных проектах – единицы. В особенности, Kotlin часто хвалят, поэтому я буду говорить о проблемах.
Сразу оговорюсь: я ничуть не жалею об использовании Kotlin и всем его рекомендую. Однако хочется предупредить о некоторых подводных камнях.
1. Annotation Processors
Проблема в том, что Kotlin компилируется в Java, а уже на основе Java генерятся классы, скажем, для JPA или, как в моём случае, QueryDsl. Поэтому результат работы annotation processor не удастся использовать в том же модуле (в тестах можно).
Варианты обхода проблемы:
- выделить классы, с которыми работает annotation processor в отдельный модуль.
- исползовать результат annotation processor только из Java класов (их можно будет легально вызывать из Kotlin). Придётся возиться с maven, чтобы он в точности соблюдал последовательность: компилируем Kotlin, наш annotation processor, компилируем Java.
- попробовать помучиться с kapt (у меня с QueryDsl не вышло)
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, а что-нибудь другое, иначе во второй лямбде просто не получиться достучаться до месяца. Это не сразу становится очевидно, и легко запутаться.
Казалось бы, обычный стримоз
Долгое время код оставался в таком виде. Но сейчас подобные места заменяю на:
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.
Автор: gnefedev