Jak Kotlin wypada w codziennej pracy

Ponieważ od listopada zarabiam na chleb pisząc prawie wyłącznie w Kotlinie, czuję się wreszcie uprawniony do podzielenia się opiniami, jak ten język sprawdza się w poważnej pracy. Kotlina używam dłużej, ale wcześniej tworzyłem w nim kod dla moich prywatnych projektów – nie były to warunki, żeby wydać rzeczową ocenę. Codzienna zespołowa praca nad kodem pozwala rozpoznać rzeczy naprawdę usprawniające kodowanie, a nie tylko wyglądające fajnie na papierze. Z drugiej strony ujawnia niedoróbki.

To nie będzie artykuł z gatunku „kompletne porównanie Kotlina z Javą w 10 częściach” ani „jak mam zacząć programować w Kotlinie”. Łatwo takie znaleźć w wyszukiwarce, plus dokumentacja Kotlina jest świetna i czytając ją można znaleźć odpowiedzi na powyższe dwie kwestie. Napiszę tu o wybranych rzeczach, które subiektywnie dla mnie są najbardziej wyraźne i ciekawe. Nie będzie nic o programowaniu na Androida, bo się na tym nie znam – skupię się na tym, w czym mam doświadczenie, czyli pisaniu back-endu.

Zalety

Wygodna biblioteka standardowa

Kotlin dodaje do typów znanych z Javy dużo małych a użytecznych metod.

fun countAverage(numbers: List<Int?>) =
    numbers.filterNotNull().average()

fun newestAddedFile(
        oldFiles: List<String>,
        newFiles: List<String>
): String? = (newFiles - oldFiles).lastOrNull()

Kilka lat temu powiedziałbym, że nie ma to większego znaczenia, że to dobre dla gimnazjalistów popisujących się, który zmieści kod w mniejszej liczbie znaków. Teraz jednak, pod wpływem prezentacji takich jak „Code as Risk” Kevlina Henneya, jestem dużo bardziej wyczulony na „lanie wody” w kodzie i niepotrzebny ceremoniał, który sprawia, że proste rzeczy wyglądają na skomplikowane.

Rozwlekły kod wykonujący operacje na zbyt niskim poziomie abstrakcji, utrudniający znalezienie istotnych rzeczy w szumie, powoduje utratę konkretnych pieniędzy. Większość czasu na pracę z kodem idzie na jego czytanie, więc jeśli programista musi ciągle „odszumiać” w głowie rozwlekły kod, firma traci forsę. Kotlin daje więcej możliwości niż Java na pisanie kodu deklaratywnego, pokazującego jasno intencję, oddanego bardziej w języku domeny niż w języku maszyny.

Mniej zaśmiecone klasy

Kontynuując temat czytelności, wydaje mi się, że Kotlin pozwala przy czytaniu klasy od razu dojść do sedna.

W Javie otwieramy klasę i musimy przedrzeć się przez konstruktor, który nic nie robi, tylko przepisuje parametry na pola, zanim trafimy na metody stanowiące publiczne API. Otwieramy klasę narzędziową ze statycznymi funkcjami – najpierw będzie wątpliwy ozdobnik, czyli prywatny konstruktor.

Dobra współpraca z Javą

Łączenie kodu Kotlina i Javy przebiega naprawdę bezproblemowo. Nie ma obawy, że wciągniemy do kodu jakąś bibliotekę i nie będzie dało się jej wołać z Kotlina.

Zdarzają się rzadkie zgrzyty. W Springu czasem trzeba wyspecyfikować typ tam, gdzie nie jest to potrzebne w Javie. Podobnie w AssertJ, np. w metodzie extracting.

Jedna biblioteka nieprawidłowo deklarująca nullability może zmusić nas do wyłączenia ścisłego przestrzegania nullability na styku Kotlina z Javą. Miałem taki przypadek ze Spring Data. Nie jest to jednak bloker.

Jeśli ktoś uważa, że taka dobra współpraca jest czymś oczywistym, to chciałbym przypomnieć, że są języki na JVM, gdzie miejscami idzie jak po grudzie – w 2017 roku robiłem prezentację porównującym pod tym względem Kotlina i Scalę.

Brak checked exceptions

To jedna z dobroci, których nie zauważałem, aż nie wróciłem do Javy, żeby zintegrować się z biblioteką Google’a do ich kalendarza (jest zresztą wiele innych bibliotek namiętnie używających checked exceptions). Programowanie stało się piekłem – ponieważ w bibliotece każde zapytanie deklarowało IOException, wszystko, co pisałem, stawało się natychmiast czerwone i musiałem walczyć z kompilatorem.

Proste metody nagle stawały się skomplikowane, gdy otaczałem wszystko typowym blokiem:

try {
    // ....
} catch (IOException e) {
    throw new RuntimeException(e);
}

To 4 linijki i dodatkowy poziom wcięcia, który nie był mi do niczego potrzebny.

Jeśli używałem streams:

ids.stream()
    .map(id -> libraryCall(id))

to kod przestawał się kompilować. Musiałem wydzielać metodę pomocniczą, która otaczałaby libraryCall blokiem try-catch.

Checked exceptions zdewastowały mój kod, przykrywając istotną logikę boilerplate’em łapania i przerzucania wyjątków oraz mnożąc bez potrzeby metody.

Tymczasem ponieważ Kotlin nie ma checked exceptions, to z podobnej biblioteki javowej korzysta się z w nim wygodniej niż w Javie.

Łatwość tworzenia danych testowych

Załóżmy że mamy w kodzie klasę User. W testach będziemy w kółko obracać testowymi użytkownikami. W Javie potrzebne jest dopisanie buildera na potrzeby testów. W Kotlinie wystarczy metoda fabryczna:

fun testUser(
        firstName: String = "Joe",
        lastName: String = "Doe"
) = User(
        firstName = firstName,
        lastName = lastName,
        roles = listOf(EMPLOYEE)
)

W teście możemy zdać się na wartości domyślne parametrów metody albo nadpisać tylko te parametry, które chcemy:

userRepository.save(testUser())
userRepository.save(testUser(firstName = "Józek"))

Jeśli konstruktor klasy User ma 10 parametrów nie musimy tworzyć również w metodzie testUser 10 parametrów. O ile User to data class, Kotlin wygeneruje sam metodę copy, która umożliwia zmianę dowolnych pól. Użycie jest odrobinę bardziej rozwlekłe niż nadpisanie parametru w naszej metodzie testUser, ale oszczędzamy czas na odwzorowanie pól klasy User w parametrach metody fabrykującej.

userRepository.save(testUser().copy(roles = listOf(ADMIN)))

Ułatwia to pisanie testów zgodnych z zasadą Keep Cause and Effect Clear: ustawianie wprost tylko tych pól, które mają znaczenie w aktualnie testowanym scenariuszu i żadnych innych.

fun notifiesAdmins() {
    addUser(testUser().copy(email = "joe@mail.com", roles = listOf(ADMIN)))
    addUser(testUser().copy(roles = listOf(EMPLOYEE)))

    notificationService.notifyAdmins()

    assertThat(outbox.mails.map { it.to })
        .isEqualTo(listOf("joe@mail.com"))
}

Da się to samo uzyskać w Javie tworząc testowe buildery, ale nawet z Lombokiem jest to coś upierdliwego. Kotlin daje to niemal za darmo.

Mam wrażenie, że ponieważ w Kotlinie tak łatwo robi się dobre dane testowe, jest mniejsza pokusa, by „oszczędzać” czas tworząc testy byle jak albo odpuszczając sobie pisanie przypadków testowych.

Wygodna obsługa zwracania wielu wartości

W artykule o funkcyjnej obsłudze błędów wspominam, jak składnia Kotlina może znacząco polepszyć czytelność kodu. Upraszczając tamtejsze przykłady, weźmy kod w Javie:

var results = performOperations()
    .collect(partitioningBy(result -> isSuccessful(result));

Teraz results.get(true) to wyniki udane, a results.get(false) to wyniki nieudane. Jest to bardzo nieczytelne i aż zaprasza do popełnienia pomyłki. Można próbować zmniejszyć ryzyko przez zmianę nazewnictwa (np. zamiast result nazwać zmienną resultsBySuccessful), ale wciąż zostajemy z wywołaniem .get(true), które razi sztucznością i nie ma nic wspólnego z ludzkim rozumieniem tego kodu: tam są po prostu wyniki poprawne i niepoprawne, co ma do rzeczy jakiś get?

Można też przypisać .get(true) i .get(false) do zmiennych pomocniczych, ale zwiększa to rozwlekłość kodu, wprowadza dodatkowe zmienne lokalne, więc również ma wpływ na cognitive load.

W Kotlinie można to zapisać następująco:

val (successful, failures) = performOperations()
    .partition { isSuccessful(it) }

W takim kodzie nie ma potrzeby mapowania w głowie kodu zrozumiałego dla komputer na kod zrozumiały dla człowieka. Failures to failures, nad czym tu myśleć?

Podoba mi się też, jak można polepszyć czytelność kodu wykorzystującego reactive streams. W Javie używanie operatora zip produkuje często naprawdę ohydny kod:

findUserId() // Mono<String>
    .zipWith(generateToken()) // też Mono<String>
    .map(pair -> createConnection(pair.getT1(), pair.getT2()));

Nawet przy tak prostych 3 linijkach trzeba się chwilę zastanowić, żeby dojść, co właściwie jest potrzebne do stworzenia fikcyjnego połączenia z przykładu. W prawdziwym kodzie jest duża szansa, że nie będziemy dostawali mono z prostego wywołania findUserId() – być może będzie to zawołanie metody z kilkoma parametrami i zajmie to więcej niż jedną linijkę. Już przy połączeniu dwóch wielolinikowych wywołań będzie ciężko się zorientować, co dostajemy wewnątrz map. Być może podobnie jak w przykładzie nie mamy domenowych typów i wszędzie pakujemy Stringi, dostajemy wtedy poważne ryzyko, że pair.getT2() nie jest tym, czym nam się wydaje.

W Kotlinie można napisać następująco:

findUserId()
    .zipWith(generateToken())
    .map { (userId, token) -> createConnection(userId, token) }

Ponownie, jak dla mnie znacząco polepsza to przekazywanie intencji kodu.

Delegacja

Częsty problem przy korzystaniu ze Springa albo innej biblioteki z rozrośniętymi interfejsami o wielu metodach. Chcemy zmienić jeden mały fragment zachowania klasy bibliotecznej, więc nadpisujemy jedną metodę własną logiką, a całą resztę delegujemy do standardowej implementacji z biblioteki. Powstaje klasa o stu nastu liniach, z czego istotne jest 5, bo reszta obsługuje delegację.

Kotlin ma składniowe wsparcie delegacji, więc w kodzie wpisujemy tylko to, co jest istotne.

Problemy

Słabe wsparcie IDE

Początkowo sam dałem się przekonać, że Kotlin nie będzie w IDE zachowywał się tak fatalnie jak Scala, ponieważ jest językiem stworzonym przez producentów najpopularniejszego IDE dla JVM, którzy swoje nowe dziecko intensywnie promują. To czysty marketing, a prawda jest taka, że wsparcie Kotlina w Idei jest mocno w kratkę.

Dla kogoś sprawnie korzystającego z automatycznych przekształceń Javy w IntelliJ przesiadka na Kotlina może być szokiem. Na pewno nie poszalejemy jak Włodek Krakowski w szkoleniach i prezentacjach z efektywnej refaktoryzacji. Ja czuję się, jakby ktoś wsadzi mnie z powrotem do Eclipse’a albo jakby mój IntelliJ cofnął się o 10 lat w czasie.

Nie ma działa Move Instance Method. Przy Extract Interface IDE nie zaproponuje zastąpienia użyć klasy użyciami nowego interfejsu. Safe Delete w pewnych przypadkach usuwa nie ten parametr, który trzeba. Do niedawna nie było możliwości przenoszenia między pakietami więcej niż 1 klasy na raz (naprawdę!), poprawka wylądowała tylko w niestabilnej wersji czekającej na wydanie.

Nawet pomijając refaktoryzację, zwyczajna praca pełna jest irytujących niedociągnięć. Najzwyklejsze copy-paste zmienia formatowanie kodu, cofając importy i psując czytelność. Podpowiadanie składni proponuje metody, które nie pasują typem, więc wywalą kompilację.

Pewnym pocieszeniem jest, że Kotlin jest językiem mniej rozwlekłym niż Java, więc zrobienie czegoś ręcznie jest często prawie tak samo szybkie jak skorzystanie z akcji w IDE. Niemniej jednak gryzie mnie, że moje doświadczenie w sprawnej pracy z IDE idzie do kosza, gdy przestawiam się z Javy na Kotlina.

Ułomne ukrywanie kodu

Gdy programuję w Javie i widzę, że aktualny plik rozrósł się za bardzo, odruchowo wydzielam kawałek. Jeśli mam kod, który nie jest związany z jakimś stanem, ląduje jako metoda statyczna w klasie package-protected.

Upraszczając, mając kod typu

package com.example.salary;

public class SalaryCalculator {
    public int calculate(String userId, YearMonth yearMonth) {
        return this.wageService.getHourlyWage(userId, yearMonth) * workingHoursIn(yearMonth);
    }

    private static int workingHoursIn(YearMonth yearMonth) {
        // ...
    }
}

tworzę:

package com.example.salary;

final class WorkingHours {
    static int workingHoursIn(YearMonth yearMonth) {
        // ...
    }
}

Gdy natomiast mam kod dotykający stanu, tworzę klasę package-protected z polami i niestatycznymi metodami.

Prawie nigdy nie testuję tak powstałych klas package-protected: są to dla mnie metody prywatne umieszczone w osobnym pliku dla zwiększenia cohesion (czyli dbając o Single Responsibility, jeśli lubimy SOLID). Przy refaktoringu mogę swobodnie zmieniać te moje pomocnicze klasy, nawet wcielić kod z nich z powrotem do oryginalnej klasy macierzystej – wszystko dlatego, że mają dostęp pakietowy i nikt się do nich nie dobierze.

Natomiast w Kotlinie czuję się niekomfortowo programując w ten sposób. Dbając o małe, łatwe do zrozumienia pliki, o trzymanie się Single Responsibility, mnożę klasy publiczne. Mimo że moją intencją jest „wydzielenie metod prywatnych do osobnego pliku”, stworzone przeze mnie klasy (obiekty, funkcje top-level itp.) są sugerowane wszędzie w podpowiadaniu składni Idei. Inny programista może uznać, że świetnym pomysłem jest bezpośrednie użycie mojej klasy pomocniczej – i nie mam sposobu, żeby zaznaczyć, że to nieprawidłowy sposób użycia tego kodu. Mam pisać ostrzeżenia w JavaDocu, żeby klasy nie używać poza jej pakietem? Bądźmy poważni, nikt tego nie czyta.

Widoczność pakietowa to jak dla mnie killer feature Javy, nieobecny chyba w żadnym innym popularnym obecnie języku. Pozwala na ułatwienie poruszania się po kodzie: jeśli wchodzimy w pakiet i jest w nim jedna klasa publiczna, wiadomo, gdzie zacząć czytać. Pozwala na bezpieczne wydzielanie kodu do osobnego pliku.

Popularna jest opinia, że widoczność pakietowa jest sknocona, nic nie zapewnia i w ogóle po co to komu, skoro jest Jigsaw, czyli moduły w Javie. Owszem, package protected samo w sobie nie pozwala na tworzenie modularnego kodu, ale odrzucanie tej możliwości to wylewanie dziecka z kąpielą. Po pierwsze, Jigsaw do tej pory nie zdobył popularności i moim prywatnym zdaniem nigdy nie zdobędzie (twórcy Springa nie obsługują modularności i wprost przyznają, że to nie jest dla nich priorytet). Po drugie, nawet działający system modułów nie wystarcza do dobrej organizacji kodu. Zazwyczaj w projektach moduł (JAR) zawiera wiele pakietów, kilkadziesiąt klas. Jeśli wewnątrz modułu wszystko jest publiczne, każda klasa ma dostęp do każdej innej klasy, to przy typowym rozmiarze modułu mamy po prostu bajzel. Widoczność pakietowa daje możliwość kontroli na poziomie większym niż klasa, ale mniejszym niż cały moduł.

Nie widziałem wypowiedzi twórców Kotlina na temat widoczności pakietowej, ale oceniając po czynach, zaliczają się do grupy uważającej tejże widoczność za kompletnie bezwartościową. Ponurą Odyseją są ostatnie perypetie obsługi package-protected w Idei.

IDE JetBrainsa długo miało regresję psującą pokazywanie w drzewku projektu, czy dana klasa jest publiczna, czy nie. Gdy z wielką łaską zostało to naprawione, twórcy Idei zadecydowali, że nikt nie patrzy, czy klasy są publiczne, więc domyślnie te ikony schowa.

Kotlin ma jedynie możliwość zadeklarowania klasy albo jako publicznej albo jako internal, czyli widocznej tylko w aktualnym module. Niezależnie co wybierzemy, nie ma żadnej wizualnej różnicy. Nie da się przeglądając kod w drzewku projektu zobaczyć, co jest udostępnione, a co nie – mimo, że da się dla kodu w Javie.

W bug trackerze Kotlina wisi zgłoszenie KT-29227 o udostępnienie mechanizmu w stylu widoczności pakietowej. Są tam świetnie przedstawione argumenty na rzecz potrzeby takiego mechanizmu – dużo lepiej, niż skrótowo wykonałem to wyżej. Zgłoszenie ma ponad rok i dyskutują tam tylko użytkownicy – nikt z twórców Kotlina nie zniżył się do odpowiedzi.

Taka nieumiejętność podjęcia dyskusji podważa moje zaufanie, że ludzie z JetBrainsów będą dobrze kierowali rozwojem swojego języka. Mam też dysonans: z jednej strony patrząc na coroutines i structured concurrency wydaje mi się, że decyzje projektowe podejmują ludzie z dużym doświadczeniem i patrzący kilka kroków na przód, a z drugiej strony zaniedbania w kwestii ograniczenia widoczności i brak jasnego stanowiska w tej sprawie rodzą u mnie obawę, że ktoś tam rozwija język bez głębszego zrozumienia i planu.

Ogólnie

Biorąc wszystko powyższe pod uwagę, jestem zadowolony z Kotlina. Cieszę się, że Java idzie do przodu, ale nawet patrząc na najnowsze możliwości języka tworzonego przez Oracle’a, dalej mając wolny wybór zdecydowałbym się na Kotlina.

Mam wrażenie, że ostatnie dodatki do Javy to głównie lukier składniowy. Pewnych naprawdę ważnych, a irytujących rzeczy Java nie jest w stanie ruszyć – jak choćby checked exceptions, podejścia do nulli, mutability w kolekcjach. W Kotlinie rozwiązano to lepiej i przez to nawet kod w Javie lepiej woła się z Kotlina niż z Javy. Czas kompilacji jest akceptowalny, niedociągnięcia w IDE są irytujące, ale do zniesienia, a JetBrains powoli je usuwa.