Написано довольно много статей о 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. Корутины — делим процессорное время