Quite a lot of articles have been written about Kotlin, but there are very few about its use in real projects. In particular, Kotlin is often praised, so I will talk about problems.
Immediately make a reservation: I do not regret at all about using Kotlin and recommend it to everyone. However, I want to warn about some pitfalls.
1. Annotation Processors
The problem is that Kotlin is compiled into Java-bytecode, and already on its basis classes are generated, say, for JPA or, as in my case, QueryDsl. Therefore, the result of the annotation processor cannot be used in the same module (it is possible in the tests).
')
Workarounds for the problem:
- select the classes with which the annotation processor works in a separate module.
- use the result of the annotation processor only from the Java classes (they can be legally called from Kotlin). We'll have to mess with maven so that he will exactly follow the sequence: compile Kotlin, our annotation processor, compile Java.
- try to suffer with kapt (I didn’t work with QueryDsl)
- in the comments they wrote that in gradle kapt works for QueryDsl. I did not check it myself, but here is an example . I did not get to maven. UPD on gradle really works. Need some magic
2. Annotations inside the constructor
Stumbled upon this when declaring model validation. Here is the class that is correctly validated:
class UserWithField(param: String) { @NotEmpty var field: String = param }
But this one is no longer:
class UserWithConstructor( @NotEmpty var paramAndField: String )
If the annotation can be applied to the parameter (ElementType.PARAMETER), then by default it will be suspended from the constructor parameter. Here is the repaired version of the class:
class UserWithFixedConstructor( @field:NotEmpty var paramAndField: String )
It's hard to blame JetBrains for this, they honestly
documented this behavior. And the choice of default behavior is clear - the parameters in the constructor are not always fields. But I almost got caught.
Moral: always put @field: in the annotations of the constructor, even if it is not necessary (as in the case of
javax. Persistence. Column), you will be
more whole.
3. Override setter
The thing is useful. So, for example, you can trim the date to a month (where else can you do this?). But there is one thing:
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() } } }
On the one hand, we cannot redefine the setter if we declared a field in the constructor, on the other hand, if we use the parameter passed to the constructor, it will be assigned to the field immediately, bypassing the overridden setter. I came up with only one adequate treatment option (if there are any better ideas, write to comments, I will be grateful):
class User( nameParam: String ) { var name: String = nameParam.toUpperCase() set(value) { field = value.toUpperCase() } }
4. Features of working with frameworks
Initially there were big problems with working with Spring and Hibernate, but in the end a plugin appeared that solved everything. In short, the plugin makes all fields not final and adds a parameterless constructor for classes with the specified anotations.
But interesting things started when working with JSF. Before, as a bona fide Java programmer, I inserted getter-setter everywhere. Now, as language obliges, every time I think about whether the field is changeable. But no, JSF is not interesting, setter is needed through time. So everything that I passed to JSF has become completely mutable. This made me use DTO everywhere. Not that it was bad ...
And sometimes JSF needs a constructor without parameters. I honestly could not even reproduce while writing the article. The problem is connected with the features of the life cycle view.
Moral: you need to know what to expect from your code framework. Especially, it is necessary to pay attention to how and when objects are saved / restored.
Next come the temptations that feed off the possibilities of the language.
5. Code understandable only to the dedicated
Initially, everything remains clear to the unprepared reader. Removed get-set, null-safe, functional, extensions ... But after the dive you start using the features of the language.
Here is a specific example:
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) }
This is a balance calculation for a group of students. The customer asked to withdraw the profit, even if there is not enough data on the lease (I warned him that the income would be calculated incorrectly).
Explanation of the methodTo begin with, try, if and when are blocks that return values (the last line in a block). This is especially important for try / catch, because the following code, familiar to a Java developer, does not compile:
val result: String try {
From the point of view of the compiler, there is no guarantee that result will not be reinitialized twice, and we have it immutable.
Further: fun tryGetBalanceItem is a local function. Just like in JavaScript, only with strict typing.
In addition, tryGetBalanceItem takes another function as an argument and executes it inside try. If the transferred function fails, the error is added to the list and the default object is returned.
6. Default Settings
The thing is just wonderful. But it is better to think about using it if the number of parameters may increase over time.
For example, we decided that User has required fields that we will know when registering. And there is a field, like the creation date, which obviously has only one value when the object is created and will be indicated explicitly only when the object is restored from 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) }
After a month, we add the field disabled, which, like created, when creating a User has only one meaningful value:
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) }
And here the problem arises: usageVersion1 continues to compile. And in a month we have already managed to write a lot. In this case, a search for using a constructor will give all the calls, both correct and incorrect. Yes, I used the default settings in the wrong case, but initially it seemed logical ...
7. Lambda enclosed in lambda
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()
Here we get the Map from the Map. Useful if you want to display a table. I am obliged in the first lambda to use not it, but something else, otherwise in the second lambda it is simply impossible to get through to a month. It does not immediately become obvious and easy to get confused.
It would seem that the usual stremoz of the brain - take it and replace it with a cycle. But there is one thing: hallsRents will become MutableMap, which is wrong.
For a long time, the code remained in this form. But now I replace such places with:
val months: List<Date> = ... val hallsRents: Map<Date, Map<String, Int?>> = months .map { it to rentsByHallNames(it) } .toMap()
And the wolves are fed and the sheep are whole. Avoid at least something complicated in lambdas, put it into separate methods, then it will be much more pleasant to read.
I consider my project to be representative: 8500 lines, while Kotlin is laconic (for the first time I consider lines). I can say that, apart from the ones described above, there were no problems and this is significant. The project has been functioning in prod for two months, while problems arose only twice: one NPE (this was a very stupid mistake) and one bug in ehcache (by the time of detection, a new version had been released with a fix).
Ps. In the
next article I will write about the useful things that the transition to Kotlin gave me.
UPD
Kotlin aftertaste, part 2
Aftertaste from Kotlin, part 3. Korutiny - we divide the processor time