The aftertaste from Kotlin, part 1

Quite a lot of articles have been written about Kotlin, but there are only a few about its use in real projects. In particular, Kotlin is often praised, so I will talk about problems.

I’ll make a reservation right away: I have no regrets about using Kotlin and I recommend it to everyone. However, I want to warn about some of the pitfalls.



1. Annotation Processors


The problem is that Kotlin is compiled in 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 will not be able to be used in the same module (in tests it is possible).

Workaround options:

  • separate classes with which annotation processor works in a separate module.
  • use the annotation processor result only from Java classes (they can be legally called from Kotlin). We'll have to mess with maven so that it follows the sequence exactly: compile Kotlin, our annotation processor, compile Java.
  • try tormenting myself with kapt (I didn’t succeed 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 . On maven, it did not work out for me. UPD on gradle really works. Need some magic

2. Annotations inside the constructor


Stumbled upon this when declaring model validation. Here is a class that validates correctly:

class UserWithField(param: String) {
    @NotEmpty var field: String = param
}

But this one is gone:
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 a fixed 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 constructor annotations, even if it is not needed (as in the case of javax .persistence.Column), you will be more intact.

3. Overriding setter


Useful thing. So, for example, you can trim the date to a month (where else can this be done?). But there is one but:

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 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 redefined setter. I came up with only one adequate treatment option (if you have any better ideas, write in 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 working with Spring and Hibernate, but in the end a plug-in appeared that solved everything. In short - the plugin does all the fields not final and adds a constructor without parameters for classes with the specified annotations.

But interesting things started when working with JSF. I used to, as a conscientious Java programmer, getter getterter everywhere. Now, since the language is binding, every time I wonder if the field is changeable. But no, JSF is not interesting, setter is needed through time. So everything that was transferred to me in JSF became completely mutable. It made me use DTO everywhere. Not that it was bad ...

And sometimes JSF needs a constructor without parameters. To be honest, I could not even reproduce while writing the article. The problem is with view lifecycle features.

Moral: you need to know what the framework expects from your code. Particular attention should be paid to how and when objects are saved / restored.

Then come the temptations, which are fueled by the capabilities of the language.

5. Code understood only by the initiates


Initially, everything remains clear to an unprepared reader. Removed get-set, null-safe, functionality, extensions ... But after the dive, you start using the features of the language.

Here is a concrete example:

fun getBalance(group: ClassGroup, month: Date, payments: Map>): Balance {
    val errors = mutableListOf()
    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 me to withdraw profit, even if there is not enough data on the lease (I warned him that the income would be calculated incorrectly).

Method explanation
To begin with, try, if and when are blocks that return values ​​(the last line in the block). This is especially important for try / catch, because the following code familiar to the Java developer does not compile:

val result: String
try {
    //some code
    result = "first"
    //some other code
} catch (e: Exception) {
    result = "second"
}

From the point of view of the compiler, there is no guarantee that result will not be initialized twice, but we have it immutable.

Next: fun tryGetBalanceItem is a local function. Just like in JavaScript, but with strong typing.

In addition, tryGetBalanceItem takes another function as an argument and executes it inside try. If the passed function failed, the error is added to the list and the default object is returned.

6. Default options


The thing is just wonderful. But it is better to think about it before use, if the number of parameters can grow over time.

For example, we decided that User has required fields that we will be aware of during registration. And there is a field, like the creation date, which obviously has only one value when creating the object and will be indicated explicitly only when restoring the object from the 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 disabled field, which, like created, when creating 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 a problem arises: usageVersion1 continues to compile. And in a month we already managed to write a lot. At the same time, the search for using the constructor will return all the calls, both correct and incorrect. Yes, I used the default options in the wrong case, but initially it looked logical ...

7. Lambda nested in lambda


val months: List = ...
val hallsRents: Map> = months
        .map { month ->
            month to halls
                    .map { it.name to rent(month, it) }
                    .toMap()
        }
        .toMap()

Here we get Map from Map. Useful if you want to display a table. I am obliged to use not it in the first lambda, but something else, otherwise in the second lambda I just cannot get through to the month. This does not immediately become obvious, and it is easy to get confused.

It would seem that the usual brain strimosis - take it, and replace it with a cycle. But there is one thing: hallsRents will become a MutableMap, which is wrong.

For a long time, the code remained in this form. But now I replace these places with:

val months: List = ...
val hallsRents: Map> = months
        .map { it to rentsByHallNames(it) }
        .toMap()

And the wolves are full, and the sheep are whole. Avoid at least anything complicated in lambdas, put it in separate methods, then it will be much more pleasant to read.

I consider my project representative: 8500 lines, while Kotlin is concise (for the first time I consider lines). I can say that in addition to those described above, there were no problems and this is indicative. The project has been running in prod for two months, with problems occurring only twice: one NPE (it was a very stupid mistake) and one bug in ehcache (a new version with a fix had already been released by the time it was discovered).

PS. In the next article I will write about the useful things that the transition to Kotlin gave me.

UPD
Aftertaste from Kotlin, part 2
Aftertaste from Kotlin, part 3. Coroutines - share processor time

Also popular now: