Kotlin: programmeertaal voor Java en meer – deel 2
De nieuwe programmeertaal voor Java en meer, deel 2
Kotlin belooft Java-ontwikkelaars een productiviteitsboost. Dat komt met name door het doordachte klassen- en overervingssysteem en goede concepten voor lokale variabelen en operatoren.
Om in Kotlin een klasse te definiëren, volstaat in het eenvoudigste geval het sleutelwoord class gevolgd door een naam: class Point. Een dergelijke klasse is natuurlijk niet echt bruikbaar, vandaar dat op de klassenaam normaal gesproken de parameterlijst van de primaire constructor volgt: class Point(x: Int, y: Int).
De parameters zijn in het init-blok te zien, dat bijvoorbeeld in de body van een constructor in Java zit: class Point(x: Int, y: Int){ init { require(x >= 0) require(y >= 0)
} }
Voor het aanmaken en initialiseren van fields is er een snelle manier: de constructorparameters worden voorafgegaan door het sleutelwoord val (alleen lezen) of var (lezen en schrijven). De compiler bekommert zich om de rest. Voor klassen die in eerste instantie datastructuren zijn, heb je vaak dan ook al genoeg aan één regel:
class Point(val x: Int, val y: Int)
De parameterlijst achter de klassenaam is de primaire constructor. Het sleutelwoord val voor de parameter is voor de compiler een indicatie dat voor de parameter property's aangemaakt moeten worden die alleen gelezen kunnen worden. Dat komt in Java overeen met een private field met een openbare toegangsmethode als getX(). Een vergelijkbaar stuk code zou in Java ongeveer 50 regels lang zijn.
Andere constructors zijn in de body van de klasse met het sleutelwoord constructor te definiëren:
data class Point( val x: Int, val y: Int){ constructor(xy: Int):
this(xy, xy)
}
Instances van klassen krijgen overigens niet het woord new mee. Bij een primaire constructor ziet dat er bijvoorbeeld zo uit:
Point(2, 5).
Functies in klassen zien er net zo uit als hun klasseloze tegenpolen, alleen hebben ze betrekking op een instance van de klasse. Een functie die een nieuw verschoven Point aanmaakt, kun je bijvoorbeeld zoals in het volgende voorbeeld schrijven:
data class Point( val x: Int, val y: Int){ fun shift(distanceX: Int, distanceY: Int)= Point(x + distanceX,
y + distanceY)
}
Kotlin-interfaces lijken in principe veel op Java-interfaces en kunnen declaraties van abstracte functies en functie-implementaties bevatten, maar bovendien ook abstracte property's:
interface Shape { val color: Color fun draw(canvas: Canvas) fun bar(x: Int)= x *x
}
Een klein verschil met Java is dat geïmplementeerde interfacefuncties niet het sleutelwoord default ervoor hebben staan – weer een ding minder om rekening mee te houden.
Als een klasse een interface wil implementeren, schrijf je de interfacenaam met een dubbele punt achter de klassenaam (en de parameterlijst van de primaire constructor):
class Square(val edgeLength: Int) :
Shape { override val color: Color =
Color(122, 0, 64) override fun draw(canvas: Canvas){
canvas.draw(this)
}
}
Het sleutelwoord override dient ertoe het abusievelijk overschrijven of het per ongeluk niet-overschrijven te verhinderen. In Java wordt daar de optionele annotatie @Override voor gebruikt, bij Kotlin is override daarentegen verplicht.
Ook bij het overerven van klassen is de compiler strenger dan bij Java: als overerving niet expliciet toegestaan wordt, is het verboden. Daarmee wordt het principe "Design and document for inheritance or else prohibit it" uit het standaardwerk Effective Java gehuldigd, dat een nauwe koppeling moet tegengaan tussen klassen in een overervingsrelatie en de daaruit volgende fragiele code [1]. Een klasse waarvan andere klassen moeten worden afgeleid, moet daarom expliciet als open gedeclareerd worden. Ook functies en property's die overschrijfbaar moeten zijn, moeten apart als open gekenmerkt worden:
open class A (val y: Int){ // overschrijfbaar open fun foo {
println(“A”)
}
// niet overschrijfbaar fun bar(x: Int)= y *x
}
Als frameworks zoals Spring non-final classes willen hebben, kunnen ze met de Kotlin-compiler-plug-in 'all-open' automatisch voor overerving geopend worden.
Een superklasse wordt overigens net zoals geïmplementeerde interfaces met een vrijstaande dubbele punt achter de naam (en primaire contructor) geschreven en met de eigen constructor opgeroepen:
class B(val w: Int, y: Int): A(y)
Anders dan bij Java, waar elke constructor zelf met super de constructor van de hogere klasse moet aanroepen, is er in Kotlin precies één weg naar de hogere klasse omdat elke andere constructor de primaire constructor moet aanroepen, die op zijn beurt weer de constructor van de hogere klasse aanroept (in het voorbeeld is dat A(y)). Naast klassen kunnen in Kotlin ook objectliterals geschreven worden. Je hoeft dus niet eerst een klasse te definiëren om daar dan een enkel object van te genereren. Dat is handig als je een zogeheten singleton nodig hebt. Net als klassen kunnen objecten ook interfaces implementeren en erven van klassen:
object PersonRepository : Repository<Person>{ override fun findById(id: Long): Person?{
...
}
}
Objectieve begeleiding
Statische functies zijn er in Kotlin niet. In plaats daarvan kunnen functies die niet bij een instance behoren direct op het hoogste niveau van een bestand worden gedefinieerd. Klassen die net als java.lang. Math alleen dienen als containers voor statische methoden, zijn daarom niet nodig. Als functies nauw met een klasse samenwerken of ze een gemeenschappelijke toestand bevatten voor alle instances van een klasse, komt een zogeheten companion object aan bod:
class Line(val start: Point, val end: Point){ companion object { fun createVertical(x: Int, length: Int)= Line(Point(x, 0),
Point(x, length))
} }
Getters en setters
Als je Java kent, weet je ook wat getters en setters zijn. Een private field wordt met een getX- en setX-methode 'ingesloten'. Kotlin maakt de schrijfwijze met property's makkelijker. In het eenvoudigste geval maakt de compiler meteen een property aan als voor een constructorparameter val (alleen lezen) of var (lezen en schrijven) staat. Om zelf een property te schrijven, is dat in principe ook voldoende:
class Message { var text = “hello” }
Het mooie daaraan is dat je achteraf logica kunt toevoegen zonder de buitenste interface van een klasse te hoeven veranderen. Of de waarde binnen de klasse constant is of bij elke aanroep berekend wordt, blijft verborgen voor de aanroeper. Deze standaardisering heet 'uniform access principle' en stamt oorspronkelijk van de programmeertaal Eiffel. Logica voor de toegang staat in – eveneens getter en setter genoemde – blokken:
var text: String = “hello” get() = field.toUpperCase() set(value){
field = value.toLowerCase() }
Bij toegang van buitenaf herken je die logica alleen aan de uitwerking:
val m = Message() m.text = “hi” println(m.text) Getters en setters uit Java-API's kunnen ook in Kotlin met de property-syntaxis aangesproken worden. In plaats van obj.getX() en obj.setX(3) zou je in Kotlin obj.x en obj.x = 3 schrijven. Omgekeerd werkt dat hetzelfde, want Kotlin-property's zijn in Java als gewone getters en setters aan te spreken.
Oorspronkelijk stamt het patroon met getters en setters uit de Java-Beansspecificatie en diende daar voor het eenvoudig instellen van eigenschappen van GUI-componenten. Ook al maakt Kotlin het eenvoudig en compact, het is desondanks geen goed idee om gewoonweg alle fields van objecten openbaar lees- en schrijfbaar te maken. Dat onttrekt de objecten immers aan de verantwoordelijkheid voor hun interne toestand en dan heeft objectgeoriënteerd werken geen zin. Voor zover het niet om 'domme' dataobjecten gaat, kun je dus beter methoden definiëren die een gespecialiseerde actie uitdrukken.
Delegeren
Java-ontwikkelaars gebruiken overerving graag voor het hergebruik van code. Dat leidt vroeger of later echter tot te diepe, moeilijk te begrijpen en lastig aan te passen klassenhiërarchieën. Een hoofdstuk van het standaardwerk Effective Java heet immers niet voor niets 'favor composition over inheritance' [1]. Kotlin gebruikt die slogan voorbeeldig door de ingebouwde delegation.
Wat er onder de motorkap gebeurt en wat het voordeel is tegenover Java, wordt het beste duidelijk met een Javavoorbeeld. Neem aan dat een klasse MyService naar buiten toe ook de methode van de interface Service aan moet bieden. De aanroep van die methode wordt intern gedelegeerd naar het in de klasse zittende object van het type Service:
// Java public class MyService implements Service { private final Service
serviceInstance; public MyService(Service serviceInstance){ this.serviceInstance =
serviceInstance; }
@Override public int serve(int param){ return
serviceInstance.serve(param);
}
@Override public void doIt(String jobName){
serviceInstance.doIt(jobName);
} }
Omdat het op die manier programmeren lastig is, wordt dat in Java-programma's vaak minder gedaan dan voor de programmastructuur wenselijk zou zijn. Hetzelfde gaat in Kotlin duidelijk compacter:
class MyService(serviceInstance: Service): Service by
serviceInstance
Net als bij Java wordt aan de constructor een instance meegegeven die de interface Service implementeert. En net als bij het Java-voorbeeld implementeert ook de klasse MyService die interface zelf, omdat die de methoden daarvan naar buiten wil aanbieden. Door het sleutelwoord by maakt de Kotlin-compiler de methoden voor delegatie echter voor de achter by genoemde instance serviceInstance.
Operator-overloading
Operatoren als +, – en <= zijn dankzij operator-overloading te gebruiken voor het definiëren van eigen datatypen. Een operatorfunctie wordt gedefinieerd met het sleutelwoord operator en een vastgestelde naam zoals plus. Je kunt bijvoorbeeld een klasse voor complexe klassen schrijven die de wiskundige schrijfwijze met + toestaat:
data class Complex(val real: Double, val imaginary: Double){
operator fun plus(other: Complex)= Complex(real + other.real,
imaginary + other.imaginary) } val a = Complex(1.0, 2.2) val b = Complex(3.3, 1.5) val c = a +b
De lijst met mogelijke operatoren is begrensd, maar voor de meeste gevallen toereikend.
Je merkt bij Kotlin aan alles dat de ontwikkelaars veel waarde hechten aan usability. Veel elementen hebben duidelijke namen: initialisatieblokken heten eenvoudig init, constructors constructor, companion objects companion object en zo verder. Ook een gelijkheid wordt met == getest en niet met het veel minder intuïtieve equals. Dat maakt de code goed leesbaar en de taal beter te leren. Bovendien worden verschillende foutbronnen vermeden, zoals NullPointerException, het per ongeluk (niet) overschrijven van methoden, verkeerde toekenningen bij voorwaarden en onbedoelde fall-through bij when.
Ontwikkelaars worden efficiënter door talrijke vereenvoudigingen in de code zoals variabelen in strings, extreem compacte dataklassen, benoemde parameters en type-afleiding. De bondige code maakt het makkelijker om bronteksten te lezen en leidt in vergelijking met Java tot een betere signaal-ruisverhouding.
IntelliJ kan Java automatisch naar Kotlin converteren. De daarmee aangemaakte code kun je vaak nog wel verder vereenvoudigen, maar het grootste deel van het werk wordt door de ontwikkelomgeving gedaan. Je kunt bij projecten sowieso ongemerkt overstappen naar Kotlin omdat je Java- en Kotlin-bestanden geheel door elkaar kunt gebruiken en Java vanuit Kotlin of Kotlin vanuit Java kunt oproepen. Dat was een van de designdoelen van Kotlin omdat JetBrains met zijn miljoenen regels aan Java-code ook een vloeiende overgang moet hebben.
Voeg het pad naar de directory met de door IntelliJ IDEA meegeleverde Kotlincompiler (bijvoorbeeld C:\Program Files (x86)\JetBrains\IntelliJ IDEA Community Edition 2017.1\plugins\Kotlin\kotlinc\bin) toe aan de omgevingsvariabele PATH. Je kunt dan de REPL (Read Evalaute Print Loop) geheten Kotlin-shell starten in een terminal met kotlinc. Als je zonder IDE wilt ontwikkelen, kun je Kotlin afzonderlijk downloaden, het archief uitpakken en de betreffende bin-folder opnemen in de omgevingsvariabele PATH.
Toekomstmuziek
De huidige grote versie, Kotlin 1.2, verscheen tegelijkertijd met Java 9. De constructen async, await en yield voor het asynchroon programmeren waren in versie 1.1 nog experimenteel, maar zijn nu officieel. Kotlin ondersteunt nu ook het als Jigsaw bekend staande modulesysteem. Bovendien zijn de performance en de IDEondersteuning verbeterd.
Voor de verdere toekomst staat op het programma de invoering van metaprogrammering. Dit overstijgt het niveau van reflection en het verwerken van annotaties en moet ook meteen betere toolondersteuning zoals macro's mogelijk maken. Onveranderlijke datastructuren, waarvoor de compiler over de gehele linie de onveranderlijkheid kan garanderen, zijn een andere denkbare uitbreiding. Een ander item op de agenda zijn value-types. Daarmee worden in plaats van een referentie de data zelf doorgegeven. Een praktische toepassing daarvan kunnen typen zijn voor maateenheden zoals inch en centimeter, die net zo efficiënt zijn als double-waarden. De grootste ontwikkeling wordt op de middellange termijn verwacht op het gebied van JavaScript en Kotlin Native, want op die gebieden kan nog veel gebeuren. Het is nu echter al mogelijk met Kotlin applicaties voor de browser te maken en te converteren naar JavaScript. Met Kotlin Native maakt de compiler daarentegen machinecode, zodat je bijvoorbeeld ook native applicaties voor een Raspberry Pi kunt maken. JetBrains wil samen met Google een Kotlin Foundation oprichten om een bedrijfsonafhankelijke doorontwikkeling van Kotlin veilig te stellen.
Kotlin heeft de gulden middenweg gevonden tussen op Scala geïnspireerde moderne talen en naadloze integratie met het Java-ecosysteem. De meeste Javaontwikkelaars moeten er binnen een paar dagen al productiever mee kunnen zijn dan met Java. Door de compacte, goed leesbare broncode en veel praktische programmeertaalkenmerken is ermee werken een feestje.
Kotlin is door zijn goede usability in principe ook prima geschikt om mee te leren programmeren. De interactieve REPL is bovendien een uitstekend middel om het resultaat meteen te testen en er op die manier plezier in te houden. Als er toch vragen opduiken, helpt de website met de uitvoerige documentatie en tutorials je vast wel verder. (nkr)