30. 10.

Proč použít Kotlin místo Javy

Java je jedním z nejpoužívanějších a nejstabilnějších objektově orientovaných jazyků pro backendové aplikace, což má své výhody i nevýhody. Má obrovskou uživatelskou základnu, nespočet knihoven a frameworků, podporu všech hlavních operačních systémů. Nicméně nese s sebou břímě v podobě nemožnosti radikálnějších změn v syntaxi, a to kvůli zpětné kompatibilitě. To brání „odlehčení“ jazyka od zbytečného textu nebo jeho rychlejšímu vývoji jako takovému.

Změny v základu Javy, které zásadně usnadnily vývojářům práci, jsou veskrze tři:

  • Collections framework v J2SE 1.2 – datum vydání 8. 12. 1998
  • Generiky, enumerace a anotace v J2SE 5.0 – datum vydání 30. 9. 2004
  • Lambda výrazy, Stream API a default metody v Java SE 8 – datum vydání 18. 3. 2014

Proto se stále častěji ozývají hlasy ohledně těžkopádnosti Javy při porovnání s moderními jazyky a příklonu k funkcionálnímu programování.

Na stejné problémy jsme narazili i u nás v Seznamu při modernizaci reklamního systému. Kvůli výkonnosti, jednoduchosti vývoje a škálovatelnosti byl vybrán toolkit Eclipse Vert.x a s ním i použití vývojového jazyka Java. V průběhu prvního prototypování se ovšem začala těžkopádnost jazyka projevovat. To vyústilo v obcházení konvencí, například public proměnné pro eliminaci přístupových metod, použití Optional jako typu proměnných nebo využití projektu Lombok, který původní „constructor hell“ změnil na o něco lepší „annotation hell“. Navíc standardní Java nemá dostatečnou podporu pro kolekce nebo pro práci s texty, což znamená nutnost použití dalších knihoven, jako je Guava, Apache-commons či Jtwig, které nejsou nutné pro samotný běh aplikace. Padlo tedy rozhodnutí využít jiného jazyka na JVM platformě.

Díky robustnosti JVM platformy vznikla na jejím základě celá plejáda nových jazyků, jako je například Scala, Groovy nebo Clojure. Ty jsou přizpůsobeny různým programovacím stylům, mají přístup k Java knihovnám a více či méně eliminují problémy při používání Javy. Ale jejich zakomponování do existující aplikace není zadarmo. Mnohdy vyžadují skokově změnit způsob programování (Clojure vychází z Lisp dialektu, Scala je zaměřená na funkcionální programování) či oželet rychlost zpracování (Groovy je dynamicky typovaný jazyk, což způsobuje výkonnostní problémy na JVM platformě). Hlavně je ale vždy problémová interoperabilita mezi Javou a jiným jazykem, zvláště pokud se má volat jiný jazyk z Javy. Proto se při volbě jiného jazyka většinou musí počítat s tím, že stávající kód bude od základu přepsán.

V době modernizace reklamního systému již naštěstí existovalo řešení v podobě jazyka Kotlin. Ten vznikl s cílem být „lepším jazykem“, primárně zaměřeným na efektivitu vývoje aplikací a plnou interoperabilitu s Javou. To je kritický požadavek pro postupný přechod existujících aplikací na Kotlin při zachování jejich plné funkčnosti. Pro modernizaci se původně počítalo s použitím jazyka Scala, ale po předchozích zkušenostech přišel od vývojářů návrh na použití Kotlinu. Zpětně se tato volba osvědčila jako nejlepší možné řešení, protože konverze kódu nenarušovala stávající funkcionalitu, usnadnila rapidní vývoj a prototypování, eliminovala spoustu chyb vyplývajících z použití Javy a zároveň usnadnila hledání produkčních chyb a jejich eliminaci, a to díky odstranění přebytečného boilerplate kódu. Efektivita vývoje se v Kotlinu dostala až do takové úrovně, že jsme mnohdy schopni vytvořit funkční prototyp dříve, než jsou formulovány všechny produktové požadavky.

Širší rozbor jazyka Kotlin by byl nad rámec tohoto článku. Místo toho si v něm ukážeme, jak Kotlin řeší jeden z hlavních a v Javě obtížně řešitelných problémů, a v budoucnu se případně zaměříme na další aspekty jazyka nebo zajímavé postřehy z jeho používání.

Výše zmiňovaným problémem v Javě je použití hodnoty null a s ní spojené výjimky NullPointerException. K tomu budeme potřebovat částečně porozumět syntaxi jazyka, se kterou se seznámíme v následující části.

Uvedené příklady porovnání kódu Javy a Kotlinu jsou psané ve verzích Java SE 8 a Kotlin 1.2. U většiny příkladů bude dále uveden odkaz pro hlubší seznámení. (poznámka autora)

Syntaxe jazyka Kotlin

Kotlin byl vyvíjen pragmaticky s ohledem na použitelnost. Proto jsou v Kotlinu změny již v samotné syntaxi kódu, která odstraňuje nejčastěji opakované texty bez přidané hodnoty. Například psaní středníku je nutné pouze pro oddělení více příkazů na jedné řádce, defaultní viditelnost je public, třídy jsou v základu final, stejně jako podpora top level funkcí nebo odstranění klíčových slov new, static, final, void, implements a extends.

Dopad těchto změn je vidět již v základním programu typu „Hello world!“, kdy kód v Javě obsahuje oproti verzi v Kotlinu pro nováčka spoustu nesrozumitelných slov a příkazů:

// Java
public class Main {

    public static void main(String[] args) {
        System.out.println("Hello world!");
    }
}
// Kotlin - Top level funkce 
fun main(args: Array) {
    println("Hello World!")
}

Pro definice proměnných se používají klíčová slova val jako hodnota pouze pro čtení (nahrazuje deklaraci proměnné a současné použití modifikátoru final) a var pro proměnnou, kterou je za běhu možno změnit. Také se využívá typové inference, takže není nutné psát typ proměnné, pokud ho lze zjistit například z výstupního typu volané funkce – Properties and Fields:

val txt = "Hello" // txt má typ String

Další drobné rysy jazyka jsou například:

// Java
public int sum(int a, int b) {
    return a + b;
}
// Kotlin
fun sum(a: Int, b: Int) = a + b // návratový typ funkce je inferovaný
// Java
public int increment(int what, int by) {
    return what + by;
}

public int increment(int what) {
    return increment(what, 1);
}
// Kotlin
fun increment(what: Int, by: Int = 1) = what + by
  • if příkaz v Kotlinu funguje jako výraz (nahrazuje ternární operátor) – If expression
// Java
public int max(int a, int b) {
    return a > b ? a : b
}
// Kotlin
fun max(a: Int, b: Int) = if(a > b) a else b

Nejzásadnější rozdíl v kódu se ale týká vytváření samotných tříd a speciálně POJO (Plain Old Java Object).

V Javě se POJO používá pro uchování strukturovaných dat, kde jsou obsahem třídy atributy, jejich přístupové metody, jeden či více konstruktorů a případně základní objektové metody, jako je toString(), hashCode() a equals().

Jako příklad vytvoříme třídu Person, která bude mít tři atributy name, surname a age, kde jak surname, tak age nemusí být vyplněno a místo nich se doplní buď prázdný řetězec pro surname, anebo −1 pro age. Kód této třídy psaný v Javě bude vypadat následovně:

class Person {

    private static final int DEFAULT_AGE = -1;
    private static final String DEFAULT_SURNAME = "";

    private String name;
    private String surname;
    private int age;

    public Person(String name, String surname, int age) {
        this.name = name;
        this.surname = surname;
        this.age = age;
    }

    public Person(String name, String surname) {
        this(name, surname, DEFAULT_AGE);
    }

    public Person(String name, int age) {
        this(name, DEFAULT_SURNAME, age);
    }

    public Person(String name) {
        this(name, DEFAULT_SURNAME, DEFAULT_AGE);
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getSurname() {
        return surname;
    }

    public void setSurname(String surname) {
        this.surname = surname;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }

        Person person = (Person) o;

        if (age != person.age) {
            return false;
        }
        if (!name.equals(person.name)) {
            return false;
        }
        return surname.equals(person.surname);
    }

    @Override
    public int hashCode() {
        int result = name.hashCode();
        result = 31 * result + surname.hashCode();
        result = 31 * result + age;
        return result;
    }

    @Override
    public String toString() {
        return "Person{name='" + name +
                "', surname='" + surname +
                "', age=" + age +
                '}';
    }
}

To je přes 80 řádek textu kvůli jedné třídě o třech položkách. V několika krocích si ukážeme, jak Kotlin dokáže stejnou třídu deklarovat na řádce jediné.

1. Kotlin nepoužívá přístupové metody – Getters and Setters

Interně se pro každou proměnnou třídy vygeneruje get metoda, a pokud se jedná o var, tak i set metoda. Nově tak v Kotlinu bude vypadat třída takto:

class Person {
    var name: String
    var surname: String
    var age: Int

    // Použijeme defaulty místo 4 konstruktorů
    constructor(name: String, surname: String = "", age: Int = -1 ) {
        this.name = name
        this.surname = surname
        this.age = age
    }
    // ... equals, hashCode, toString funkce... 
}

Pokud se dokonce pokusíte implementovat například funkci fun getName(): String, selže kompilátor s chybou: Platform declaration clash: The following declarations have the same JVM signature (getName()Ljava/lang/String;)

Pokud je potřeba definovat vlastní logiku pro set a get funkce, používá se speciální konstrukt přímo u definice proměnné, např.:

var name: String
    get() = name.toUpperCase()

2. Kotlin podporuje primární konstruktor s přímou definicí proměnných – Constructors

Jelikož většina konstruktorů obsahuje přiřazení předaných hodnot do interních proměnných, umožňuje Kotlin zkrácený zápis jednoho z nich spolu s deklarací atributů:

class Person(
    var name: String, 
    var surname: String = "", 
    var age: Int = -1
) {
// ... equals, hashCode, toString funkce... 
}

I při použití primárního konstruktoru se dají nadefinovat další atributy do těla třídy. Primární konstruktor nepodporuje dodatečný kód. K tomu slouží init bloky, které jsou spuštěny postupně při inicializaci objektu:

// "rawSurname" není atribut třídy a je dostupný pouze pro inicializaci
class Person(val name: String, rawSurname: String){ 
    val surname: String
    
    init {
        surname = rawSurname.toUpperCase()
    }
}

3. Stringové šablony – String templates

Pro psaní čitelnějšího textu podporuje Kotlin použití textových šablon. Pomocí znaku $ ve Stringu máte přístup k proměnným a funkcím. Díky tomu bude funkce toString() vypadat takto:

// Místo těla funkce s returnem použijeme přiřazení
override fun toString() = "Person(name='$name', surname='$surname', age=$age)"

4. When, smart cast, == a === operátor

Příkaz when zastává funkci vylepšeného příkazu switch nebo vícenásobného if else příkazu. Zároveň se chová jako výraz, takže je možné jeho výsledek přiřadit do proměnné. Více info: When expression.

Další problém Javy je v porovnávání. U primitivních typů a referencí se provádí pomocí operátoru ==, kdežto porovnání obsahu instancí tříd pomocí funkce equals(). Kotlin pro přehlednost zavedl navíc operátor ===, který slouží pro porovnání referencí, a původní operátor == nově volá funkci equals(). Kotlin také nemá primitivní typy (avšak interně s nimi kvůli výkonnosti pracuje), takže není potřeba se k nim při porovnávání chovat jinak. Dále na: Equality.

Díky smart castu není nutné psát explicitní přetypování, pokud je kompilátor schopný typ rozpoznat. Například if příkaz s podmínkou, zda je proměnná typu String, umožní uvnitř bloku dále pracovat s touto proměnnou jako se Stringem. Podrobnější popis: Smart casts.

Aplikací všech výše uvedených vlastnosti přepíšeme funkci equals takto:

override fun equals(other: Any?): Boolean = when {
    this === other -> true // Referenční porovnání
    other !is Person -> false // Smart cast pozná, že "other" je dále "Person"
    age != other.age -> false
    name != other.name -> false // Ekvivalent volání !name.equals(other.name)
    else -> surname == other.surname
}

5. Použití data class – Data classes

I přesto, že Kotlin výrazně usnadňuje implementaci metod toString, equals a hashCode, stále není vyřešen jeden zásadní problém. Tyto funkce je třeba ručně implementovat a udržovat. Jakmile do definice třídy přidáte další parametr, znamená to explicitní úpravy, které mohou být novým zdrojem chyb.
Proto byla do Kotlinu přidána možnost vytvořit speciální typ třídy data class, která z proměnných definovaných v primárním konstruktoru interně vygeneruje následující metody:

  • equals() a hashCode()
  • toString() ve formě „Person(name=John, surname=Doe, age=42)“,
  • copy() pro snadné vytvoření kopie, kde je v nové instanci umožněno měnit
  • jednotlivé proměnné od originálu,
  • další funkce specifické pro Kotlin.

Data classy mají i svá omezení: v primárním konstruktoru musí být alespoň jedna proměnná, lze použít pouze val/var proměnné, třída nemůže být abstraktní nebo například nelze definovat její potomky (ekvivalentem final class). Pokud ovšem omezení nepředstavují problém, tak značně snižují množství nutného kódu. Zároveň programátora nenásilně směřují k tomu, aby odděloval datové třídy od tříd, které zajišťují jejich zpracování. A umožňují vývoj pomocí imutabilních objektů bez dodatečné penalizace v podobě nutnosti vytváření copy metod nebo obdobných funkcí.

Finální verze třídy Person v Kotlinu s veškerou požadovanou funkcionalitou vypadá následovně:

data class Person(var name: String, var surname: String = "", var age: Int = -1)

Řešení NullPointerException problému v Kotlinu

Java nemá přímou jazykovou podporu pro not-null proměnné a nejspíše ji ani nepůjde do jazyka přidat bez ztráty zpětné kompatibility. Částečná podpora byla přidána vytvořením wrapperu Optional ve verzi 8, ale jeho použití není úplně intuitivní a zbytečně přidává na složitosti kódu.

Kotlin přímou podporu má, a to v podobě oddělení nullable typu pomocí ? operátoru (viz Nullable types and not-null types), což například dovoluje eliminovat chyby nastávající při změně not-null proměnné na nullable. Dále ve většině případů odstraňuje nejasné chování pomocí přesně definovaných kompilačních chyb nebo omezením jazyka. Třeba základní definice proměnné musí být vždy inicializovaná:

var a: String  // Kompilační chyba – není abstraktní ani inicializovaná 

var b: String = "Ahoj" 
b = null // Kompilační chyba – přiřazení "null" do not-null proměnné
 
// Otazník za typem označuje nullable proměnnou
var c: String? = null 

// Not-null typ lze přiřadit do nullable
c = b // c = "Ahoj"

b = c // Kompilační chyba – nullable nelze přiřadit do not-null proměnné

Pokud v Kotlinu vznikne nějaké místo, kde je možné volat neinicializovanou proměnnou, skončí běh konkrétní výjimkou problému místo NPE:

// Klíčové slovo "lateinit" umožní pozdější nastavení proměnné
lateinit var a: String

// Vyvolá chybu kotlin.UninitializedPropertyAccessException
println(a)

Tím, že má Kotlin ošetřeny null stavy na úrovni jazyka, umožňuje použití této speciální hodnoty pro neexistující prvek. Odpadá tak například nutnost práce s obalujícím objektem, jako je použití Optional.

Pro lepší práci s nullable typy přináší Kotlin další syntaxi, jako je safe call nebo elvis operátory. Safe call operátor ?. (Safe calls) vrátí výsledek volání za operátorem pouze tehdy, když prvek před operátorem není null. Elvis operátor ?: (Elvis operator) naopak zavolá výraz za operátorem pouze tehdy, pokud je výraz před operátorem null:

var txt: String? = "Something"
val a: Int = txt.length // Kompilační chyba – txt je nullable "String"
val b: Int = txt?.length // Kompilační chyba – výraz vrací typ "Int?"

txt = null
val c: Int = txt?.length ?: -1 // Elvis zaručí not-null výsledek; c = -1
val d: Int? = txt?.length // d = null

txt = "Hello"
val e: Int? = txt?.length // e = 5

Příklad pro porovnání s Javou

Máme třídu Person obsahující name a nepovinný contact, což je instance třídy Person.Contact, která má address a nepovinný phone. Máme vytvořit metodu, která z předaného person získá phone nebo null pokud není telefonní kontakt uveden. Reprezentace Person v Kotlinu vypadá takto:

class Person(val name: String, val contact: Contact?) {
    class Contact(val address: String, val phone: String?)
}

Funkce v Javě, která zajistí bezpečné zavolání bez použití Optional, by vypadala takto:

public String getPhoneNumber(Person person) {
    if (person != null) {
        Person.Contact contact = person.getContact();
        if (contact != null) {
            return contact.getPhone();
        }
    }
    return null;
}

S použitím Optional vypadá funkce o trochu čitelněji:

public String getPhoneNumber(Person person) {
    return Optional.ofNullable(person)
            .map(Person::getContact)
            .map(Person.Contact::getPhone)
            .orElse(null);
}

V Kotlinu vypadá funkce takto:

fun getPhoneNumber(person: Person?): String? = person?.contact?.phone

Výše uvedené příklady jsou pouze triviální ukázky, jak Kotlin umožňuje soustředit se na produktivitu vývoje. Věci jako extensions, inline funkce, infix notace, lambda výrazy, cílení na imutabilitu, přetěžování operátorů, bohatá knihovna funkcí, podpora JS a nativního systému, vícevláknové operace pomocí coroutines a další by zabraly celou knihu.

Kotlin má oproti ostatním JVM jazykům ale jednu podstatnou výhodu – umožňuje i zarytému Java programátorovi plynule navyknout na nový styl programování a postupně začít využívat jeho výhod. Ze začátku lze psát kód z velké většiny stejně jako v Javě, ale postupem času programátor objevuje stále nové způsoby, jak napsat vlastní kód efektivněji.

Jakmile si po nějaké době člověk na efektivitu vývoje v Kotlinu zvykne, uvědomí si, že opadlo to neustálé opisování opakujícího se kódu a z programování se opět stala kreativní činnost.

Externí odkazy:

Kotlin homepage – https://kotlinlang.org

Kotlin online tutorial – https://try.kotlinlang.org/#/Kotlin Koans/Introduction/Hello, world!/Task.kt

Kotlin dokumentace – https://kotlinlang.org/docs/reference/

Scala homepage – https://www.scala-lang.org/

Groovy homepage – http://groovy-lang.org/

Clojure homepage – https://clojure.org/

Otakar Křížek

Programátor senior