Jak může Kotlin pomoci s modernizací starého projektu

V rámci mých projektů většinou pracuji s moderní Javou (11+), ale přesto občas pracuji i se staršími verzemi (6, 7). Vždy, když přepnu na starší verzi, tak mi chybí moderní funkce – především ty, které najdeme ve funkcionálním programování, jako jsou například lambda výrazy a streamy, které mi přijdou velmi užitečné.

Představme si, že máme starší „udržovací“ projekt ve staré verzi Javy (6 nebo 7) a chtěli bychom ho modernizovat. Je nasnadě otázka, proč prostě a jednoduše neupgradujeme Javu? Protože někdy to z různých důvodů není možné. Například:

  • Náklady na požadované úsilí a potenciální rizika mohou převážit nad výhodami.
  • Upgrade nemusí být technicky možný kvůli závislosti na dalších technologiích používaných v rámci projektu.
  • V nedaleké budoucnosti se plánuje výměna systému.
  • A tak dále…

V takových případech by bylo užitečné, kdyby existovala možnost psát kód v moderní Javě s našimi oblíbenými novými funkcemi a následně kód zkompilovat a spustit na starší verzi. To ale není možné, protože nové funkce vyžadují i nové běhové prostředí (Java Runtime Environment). Toto omezení je sice možné částečně obejít s pomocí knihoven třetích stran, jako je například Retrolambda, ale je dost možné, že se tím ještě více zvýší složitost projektu, a stoupne tak pravděpodobnost problémů.

Svět JVM ale naštěstí nabízí elegantnější řešení – moderní a nadějný jazyk Kotlin. Ten má vše, co potřebujeme. Obsahuje řadu skvělých moderních funkcí z poslední verze Javy, lze ho zkompilovat a spustit i na starém JRE 6, a co je nejdůležitější, můžeme ho použít společně se stávajícím Java kódem ve stejném projektu!

Jak si ukážeme, tato interoperabilita je jedním z hlavních důvodů, proč může být Kotlin v tomto případě tak užitečný. U projektu, ve kterém je upgrade Javy nepřípustný, by totiž s velkou pravděpodobností byla nepřípustná i úplná migrace do jiného jazyka.

Takže místo migrace ve stylu velkého třesku bychom mohli jen iterativně převést malé, pečlivě vybrané části systému – například napsat nový kód v Kotlinu a použít jej spolu se stávajícím základem kódu v Javě. Jak by to bylo náročné a existují nějaké nevýhody? V následujícím textu si shrneme některé důležité body, které je třeba vzít v úvahu.

Než začneme, je dobré mít na paměti, že takový přístup je určitým druhem refaktoringu, takže bychom se měli řídit jeho osvědčenými postupy. K tomu doporučuji skvělou knihu od Martina Fowlera s výstižným názvem Refactoring. Myslím si, že hlavní věc, kterou bychom si měli v tomto případě z knihy odnést, je provádět malé, kontrolované iterace a pravidelně ověřovat, že je systém stále funkční.

Rychlý přehled funkcí

Nejprve se podívejme na některé (ale zdaleka ne všechny) funkce, kterými Kotlin disponuje, abychom získali představu o tom, s čím může pomoci.

Díky Kotlinu získáme přístup k mnoha skvělým funkcím, přičemž některé z nich již známe z Javy 8+. V první řadě je Kotlin celkem podobný Javě, takže pokud znáte Javu, budete ho schopni číst téměř okamžitě. A když mu věnujete ještě trochu času, budete v něm schopni i psát – alespoň do určité míry! Existují totiž také koncepty, které se v Kotlinu zcela liší a které mohou vyžadovat jiný přístup ke konstrukci či jiné myšlení. To si ukážeme později.

Mnoho věcí je v Kotlinu snadnějších a stručnějších než v Javě. Například vlastnosti:

  • Java:
public class Order {
    public List<OrderLine> getOrderLines();
    public void setOrderLines(List<OrderLine> orderLine);
    public Integer getOrderNumber();
    public void setOrderNumber(Integer orderNumber);
    public BigDecimal getPrice();
}

 

  • Kotlin:
class Order {
    var orderLines : List<OrderLine>
    var orderNumber: Int
    val price : BigDecimal
}

 

Jak vidíme, kód vypadá podobně, ale je přehlednější. Místo getterů a setterů deklarujeme pouze tzv. properties (vlastnosti) a Kotlin nám gettery a settery vygeneruje automaticky.
Pokud použijeme var, získáme měnitelnou property, tedy getter a setter. S pomocí val získáme neměnitelnou property, tedy jen getter.

Když chceme získat přístup k property, použijeme pouze název property, nikoli název přístupové metody:

  • Kotlin:
order.orderNumber = 12345
println(order.price)

 

Problém může nastat při míchání kódu Javy a Kotlinu, protože si musíme zvyknout na oba a být schopni mezi nimi neustále přepínat. A i když jsou si jejich koncepce hodně podobné, syntaxe je někdy trochu jiná. Například:

public String formatText(String text)

 

vs.

fun formatText(text: String): String

 

Další věcí jsou konvence kódu. Musíme mít na paměti, že stejná syntaxe může být v jednom jazyce v pořádku, zatímco v druhém jazyce ne. Podívejme se znovu na tento příklad:

order.orderNumber = 12345
println(order.price)

 

Vzhledem k tomu, že se jedná o kód Kotlinu, jde pouze o standardní přístup k properties. V případě Javy by se jednalo o přímý přístup do fieldu, což je obvykle špatný postup (bad practices). Někteří by možná dokonce řekli, že samotné gettery a settery jsou v obecném případě „bad practice“, ale to už jen tak na okraj. Takže kdykoli při psaní přepneme mezi Javou a Kotlinem, musíme přepnout v hlavě i svůj „alarm kódových konvencí“.

Něčeho podobného lze mimochodem dosáhnout i v Javě (i ve starší verzi) s knihovnou Lombok a její anotací @Data.

@Data
class Order {
 private List orderLines;
 private Integer orderNumber;
 private final BigDecimal price;
}

 

Rozšiřující metody

Rozšiřující metoda (neboli extension method) je funkce pro rozšíření existující třídy, aniž by zdědila nebo používala jakýkoli typ konstrukčního vzoru.

Kotlin poskytuje rozšíření k mnoha třídám knihoven Javy, takže jejich použití je snazší.

Potřebujete nový ArrayList?

return arrayListOf(1, 5, 2)

 

Počítáte součet pole?

val sum = arrayOf(12, 33).sum()

 

Nová rozšíření si můžeme vytvořit i sami.

Chytré přetypování

Proč bychom měli přetypovávat objekt, když jsme právě zkontrolovali, že jeho typ je správný? To je úplně zbytečný kód! Kotlin to ví, takže místo kontroly typu objektu a přetypování…

  • Java:
if (obj instanceof String) {
    print(((String)obj).length);
}

 

… ho prostě zkontrolujeme a jdeme dál.

  • Kotlin:
if (obj is String) {
    print(obj.length)
}

 

Null safety

Kotlin pomáhá eliminovat nepopulární výjimku NullPointerException (NPE) – vždy deklarujeme, zda reference může nebo nemůže mít hodnotu null, a Kotlin to během kompilace zkontroluje.

Ve výchozím nastavení reference nemůže nabývat hodnoty null.

var order: Order = new Order()
order = null // Compilation error - cannot be null

 

Pokud chceme, aby hodnoty null nabývat mohla, přidáme znak „?“.

var order: Order? = new Order()
order = null // OK

 

Tím jsme v Kotlinu chráněni před výjimkou NullPointerException, protože k vlastnosti nemáme přístup, dokud si nejsme jisti, že nemá hodnotu null.

var orderPrice = order.price // Compilation error

 

To musíme buď explicitně zkontrolovat:

if (order != null) orderPrice = order.price

 

Jak vidíme, podobá se to chytrému přetypování výše – po „if (order != null)“ Kotlin již ví, že proměnná order nemá hodnotu null, takže k ní máme přístup.

Nebo můžeme použít bezpečný operátor:

var price = order?.price

 

Pokud má proměnná order hodnotu null a „order.price“ ne, vrátí se hodnota null.

Při volání Java kódu z Kotlinu je však v takovém případě nutné být na pozoru:

public class StringUtils {
    static String capitalizeFirst(String text) {
        return text.isEmpty() ? null : text.substring(0,1).toUpperCase() + text.substring(1);
    }
}

fun main() {
    val text: String = StringUtils.capitalizeFirst("")
    println("text length : " + text.length) // java.lang.IllegalStateException: Utils.capitalizeFirst("") must not be null
}

 

V tomto případě Kotlin během kompilace neprovádí kontrolu, takže musíme vždy vědět, zda voláme kód Javy, nebo Kotlinu. U projektu se smíšeným kódem to může být obtížné sledovat.

Funkcionální programování: Lambda výrazy a streamy

Lambda výrazy a streamy jsou skvělým způsobem, jak začlenit principy funkcionálního programování do našeho kódu v Javě 8+. Podívejme se na jednoduchý příklad.

Příklad: Tato metoda vrací informace, pokud daná osoba objednala alespoň jednu propagovanou položku.

  • Java 6:
public boolean hasPromotedItem(Person person) {
    for (Order order : person.getOrders()) {
        for (OrderLine orderLine : order.getOrderLines()) {
            if (orderLine.isPromoted()) {
                return true;
            }
        }
    }
    return false;
}

 

  • Kotlin:
fun hasPromotedItem(Person: person) : Boolean {
    return person.orders.flatMap { it.orderLines }.any { it.promoted }
}

 

Kód je nejen kratší, ale také názornější. Protože používáme běžné vzory, tak snadno vidíme, co tato metoda dělá.

Je to podobné jako u lambda výrazů a streamů z Javy 8+, avšak s tím, že máme navíc k dispozici několik vylepšení. Jak je vidět, lambda výraz je kratší, protože lze vynechat levou část.

  • Java 8:
public boolean hasPromotedItem(Person person) {
    return person.getOrders().stream().flatMap(it -> it.getOrderLines().stream()).any(it -> it.isPromoted());
}

 

Ale pozor na jeden zásadní rozdíl. Kolekce v Kotlinu ve výchozím nastavení nepodporují odloženou inicializaci!

Abychom dosáhli tohoto chování, musíme použít asSequence:

fun hasPromotedItem(person: Person) : Boolean {
    return person.orders.asSequence().flatMap { it.orderLines }.any { it.promoted }
}

 

Další informace najdete zde: https://dzone.com/articles/kotlin-beware-of-java-stream-api-habits

Příklad: Tato metoda vrátí celkový počet propagovaných položek objednaných danou osobou.

  • Java 6:
public int countPromotedItems(Person person) {
    int count = 0;
    for (Order order : person.getOrders()) {
        for (OrderLine orderLine : order.getOrderLines()) {
            if (orderLine.isPromoted()) {
                count += 1;
            }
        }
    }
    return count;
}

 

  • Kotlin:
fun countPromotedItems(person: Person) : Int {
    return person.orders.flatMap { it.orderLines }.filter { it.promoted }.size;
}

 

Vzhledem k tomu, že v Kotlinu jsou lambda výrazy first-class objekty, můžeme je vydávat za parametry metody – například za predikáty. Zde počítáme počet položek objednávky, které splňují podmínku zadanou jako parametr metody.

  • Kotlin:
fun countByPredicate(person: Person, condition : (OrderLine) -> Boolean) : Boolean {
    return person.orders.flatMap { it.orderLines }.filter { condition(it) }.size;
}

 

Všimněte si, že v Javě 6 potřebujeme třídu, abychom mohli predikát vydávat za parametr (například s pomocí predikátové třídy Google Guava).

Přetěžování operátoru

Přetěžování operátorů nám může pomoci psát čitelnější kód. Jednou takovou situací je práce s třídou BigDecimal. Pokud chceme v Javě psát matematické výrazy s pomocí BigDecimal, musíme používat volání metod.

Příklad: Tato metoda vypočítá cenu domu na základě jeho základní ceny za metr čtvereční a počtu dveří a oken, přičemž přidá 5% slevu za každý slevový kupón až do procentuální hodnoty MAX_DISCOUNT. Jinými slovy:

(basePrice + doorPrice * doorCount + wndPrice * wndCount) * max(1 – couponDiscount * couponCount, 1 – MAX_DISCOUNT)

  • Java 6:
basePrice.add(wndPrice.multiply(wndCount)).add(doorPrice.multiply(doorCount)).multiply(BigDecimal.ONE.subtract(couponDiscount.multiply(couponCount))).max(BigDecimal.ONE.subtract(MAX_DISCOUNT));

 

Není ihned zřejmé, co se v tomto kódu děje, jaké je pořadí operací a jaké operace jsou přidružené. Všimněme si funkce max na konci – vztahuje se pouze na slevu, nebo na celý kód?  Přepíšeme kód tak, aby byl čitelnější:

basePrice
.add(wndPrice
  .multiply(wndCount)
)
.add(doorPrice
  .multiply(doorCount)
)
.multiply(
  BigDecimal.ONE
    .subtract(couponDiscount
      .multiply(couponCount)
    )
  )
.max(BigDecimal.ONE.subtract(MAX_DISCOUNT));

 

Nyní již vidíme, že tento kód obsahuje chybu! Metoda max se vztahuje na celý kód, nejen na slevu. Opravíme to…

basePrice
.add(wndPrice
  .multiply(wndCount)
)
.add(doorPrice
  .multiply(doorCount)
)
.multiply(
  (
    BigDecimal.ONE
    .subtract(couponDiscount
      .multiply(couponCount)
    )
  )
  .max(BigDecimal.ONE.subtract(MAX_DISCOUNT))
);

 

To je mnohem lepší, ale stále méně čitelné než kód v popisu. Co na to říká Kotlin?

  • Kotlin:
fun calculateTotalPrice(
        basePrice: BigDecimal,
        wndPrice: BigDecimal, wndCount: BigDecimal,
        doorPrice: BigDecimal, doorCount: BigDecimal,
        couponDiscount: BigDecimal, couponCount: BigDecimal,
        maxDiscount: BigDecimal): BigDecimal {    
    return (basePrice + wndPrice * wndCount + doorPrice * doorCount) * maxOf(
    1.toBigDecimal() - couponDiscount * couponCount, 
    1.toBigDecimal()  maxDiscount);
}

 

Tento kód vypadá téměř jako jeho popis! Poměrně snadno vidíme, co se zde děje (tedy pokud máme základy matematiky a známe pořadí vyhodnocování operátorů).

V praxi bychom samozřejmě rozdělili složitý vzorec na několik menších, aby kód byl čitelnější. Přestože je tento příklad velmi jednoduchý, tak už i v něm operátory hodně pomohou!

Pojmenované parametry

Metoda „calculateTotalPrice“ má mnoho parametrů, takže při volání nevíme, který je který.

  • Java:
calculateTotalPrice(9.toBigDecimal(),
    new BigDecimal(0.1), new BigDecimal(8),
    new BigDecimal(0.2), new BigDecimal(1),
    new BigDecimal(0.1), new BigDecimal(20),
    new BigDecimal (0.3));

 

U Javy metoda s tolika parametry obvykle značí špatný návrh kódu a člověk by jako parametr použil samostatný objekt. V případě Kotlinu můžeme použít pojmenované parametry, aby byl kód srozumitelnější.

  • Kotlin:
calculateTotalPrice(basePrice = 9.toBigDecimal(),
    wndPrice = 0.1.toBigDecimal(), wndCount = 8.toBigDecimal(),
    doorPrice = 0.2.toBigDecimal(), doorCount = 1.toBigDecimal(),
    couponDiscount = 0.1.toBigDecimal(), couponCount = 20.toBigDecimal(),
    maxDiscount = 0.3.toBigDecimal());

 

Kotlin má mnoho dalších skvělých funkcí, které nám mohou pomoci modernizovat a refaktorovat starší zdrojové kódy. Cílem článku nebylo zabývat se všemi, proto určitě doporučujeme podívat se i na ostatní!

Možnosti migrace

Kotlin vypadá skvěle. Nyní se tedy nabízí otázka, jak ho můžeme začlenit do našeho Java projektu?

Společné použití Javy a Kotlinu je snadné – necháme soubory Javy a Kotlinu koexistovat ve stejném projektu. Java používá soubory .java, Kotlin používá soubory .kt a oba typy se ve finální podobě překládají do souborů .class. Takže v takovém kódu můžeme volat Kotlin z Javy a naopak. Pokud tedy potřebujeme novou funkci a chceme použít Kotlin, můžeme jednoduše přidat nový soubor Kotlinu, napsat kód a potom ho zavolat z Javy.

Pokud ale přidáme nový soubor, neznamená to, že přidáme i novou třídu? Ne, protože to není jediný způsob, jak jde rozšířit kód. Čas od času potřebujeme přidat nové metody nebo příkazy do existující třídy. Mohli bychom pro všechny nové metody vytvořit novou pomocnou třídu, například MyClassUtils. To má ale určité nevýhody. Může se tím rozbít původní návrh třídy, ne vždy je takový postup dobrý návrh a dokonce to nemusí být ani technicky možné – jako například přidání nové privátní metody. A v neposlední řadě přijdeme o transparentnost naší kombinace Kotlin-Java. I s tímto nám Kotlin naštěstí dokáže pomoci.

Převod celé třídy

Jednou z možností je přepsání existujícího souboru Java do Kotlinu. IntelliJ IDEA (IDE od stejné společnosti jako Kotlin) pro to má vynikající řešení – stačí dvě kliknutí myší nebo jedna klávesová zkratka a celý Java soubor se automaticky převede na Kotlin soubor. Pak už je možné psát nový kód (nebo měnit ten původní) v Kotlinu a zvenčí se to nikdo (většinou) nedozví.

Převod existujícího souboru má ale i určité nevýhody. I při automatickém převodu je nutné projít převedený kód a zkontrolovat jeho správnost. Za prvé, mezi oběma jazyky jsou určité sémantické rozdíly, které je určitě vhodné zkontrolovat (například null safety atd.), a za druhé si nemůžeme být 100% jisti, že konverze je správná. Převedený kód je proto vždy dobré revidovat a otestovat. A jak už tomu obvykle u vývoje bývá, čím lepší je návrh třídy a čím více je pro ni automatických testů, tím je taková kontrola jednodušší. Je mnohem snazší převádět malé, stručné a zapouzdřené Java třídy, než velké „obludy“.

Výhodou je, že IDEA se snaží napovědět, zda má být nějaká část převodu ručně opravena a revidována. IDEA také navrhne automatický překlad při kopírování Java kódu do existujícího Kotlin souboru.

Rozšiřující metody

Pokud nechceme převádět celou třídu, existuje jiná možnost. Do stávající třídy můžeme přidat nové metody, aniž bychom původní třídu měnili (takže žádné úpravy původního souboru)! Pouze kdekoli v projektu definujeme novou metodu a potom se na ni můžeme odkazovat stejně jako na standardní metodu původní třídy. Daná metoda bude mít dokonce přístup k ostatním členům třídy. Tato funkce se nazývá „rozšiřující metoda“, a jak jsme si ukázali dříve, Kotlin ji často využívá ke zlepšení standardních knihoven, například pro kolekce.

  • Java:

class Dog {
    fun bark() {
        println("I can bark")
    }
}

 

  • Kotlin:

fun Dog.bite() {
    bark();
    println("I bark and then I bite!")
}
val dog = Dog()
dog.bite()

 

Pod pokličkou je však rozšiřující metoda pouze klasická statická metoda se všemi svými omezeními, takže to není úplně to samé jako běžná metoda.

Co zvolit?

Přestože migrace pomocí rozšiřujících metod je technicky proveditelná, vede k míchání dvou jazyků ve stejné třídě, což bude pro dlouhodobou údržbu náročné. Proto si myslím, že převedení celé třídy bude obecně lepší volbou. K čemu bychom však měli především směřovat je, nesnažit se o migraci jednotlivých tříd, ale vždy migrovat sadu souvisejících tříd, které dohromady tvoří zapouzdřenou funkcionalitu, modul nebo komponentu, aby bylo zajištěno jasné oddělení, a zabránilo se tak neudržovatelnému míchání jazyků.

Jak jsem již uvedl na začátku, měli bychom funkčnost systému pravidelně kontrolovat. Jedním z předpokladů je dobré pokrytí automatickými testy. Pokud migrovaná část nemá testy, měli bychom nejdříve „splatit technický dluh“ a doplnit testy pro starý kód a teprve pak tento kód migrovat – opět za dodržení výše uvedených principů. Vzhledem k tomu, že migrujeme zapouzdřené části, tak by nemělo být po migraci nutné tyto automatické testy příliš měnit, protože rozhraní by mělo zůstat stejné. Měníme tedy pouze interní implementaci.

Co je nutné mít neustále na paměti je to, že i když mezi Kotlinem a Javou najdeme řadu podobností, existuje také mnoho jazykových rozdílů. S ohledem na tyto rozdíly je nutné nebo přinejmenším žádoucí zvolit při uvažování a navrhování jiný přístup. Například pokud jde o:

  • neměnné objekty,
  • přidružené objekty,
  • rozšiřující metody,
  • nestatické metody,
  • korutiny,
  • a tak dále…

Závěr

Je nutné podotknout, že míchání dvou jazyků v jednom projektu přirozeně zvyšuje tzv. „accidental complexity“ (to znamená narostlou složitost, která nesouvisí se samotným řešeným problémem). V takovém případě musí vývojáři pracující na projektu znát dva jazyky a být schopni mezi nimi průběžně přepínat. Jak jsme si ukázali, znamená to nejen novou syntaxi, ale také sémantiku a konvence, které je třeba mít na paměti. Pro některé to může být překážkou a je dobré to předem zvážit. V případě dlouhodobého projektu (což takový údržbový projekt pravděpodobně je) se vývojáři projektu mohou (budou) v průběhu času měnit a pro nové vývojáře bude o něco těžší zvyknout si na takový kód. A situace také určitě bude jiná na projektu, kde je jeden člověk, a na projektu s více lidmi. To všechno však lze řešit pečlivě naplánovanou migrací a jasným rozdělením.

Jak vidíte, údržba starší aplikace nemusí znamenat, že po celou dobu používáte staré technologie. S malou pomocí ji můžete modernizovat na pokročilejší úroveň a využívat nové funkce!

A na závěr jedno doporučení. Pokud jste ještě nevyzkoušeli Kotlin, vyzkoušejte ho! Není třeba nic instalovat, stačí navštívit „online hřiště“.

 

Autor: Štěpán Poljak

Senior Software Engineer