Послевкусие от Kotlin

в 10:34, , рубрики: kotlin

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

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

image

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)
}

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

Объяснение работы метода

Для начала, 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.

Автор: gnefedev

Источник


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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js