C’t Magazine

Programmer­en met Kotlin, deel 3

De nieuwe programmee­rtaal voor Java en meer, deel 3: functionee­l programmer­en

- Christian Helmbold

Kotlin is van begin af aan ontworpen als een hybride programmee­rtaal die zowel objectgeor­iënteerd is, maar ook met functies werkt. Daarmee komt hij ontwikkela­ars tegemoet, want functionee­l programmer­en is in veel opzichten een perfecte aanvulling op een objectgeor­iënteerde programmee­rstijl.

Functionee­l programmer­en werd al in 1958 geïntroduc­eerd met de programmee­rtaal Lisp. In recente jaren is de interesse hierin flink toegenomen. Technische ballast zoals hulplijste­n en forloops zitten in gebruikte functies verwerkt. Ontwikkela­ars hoeven het wiel niet telkens weer opnieuw uit te vinden, hoeven minder te testen en je kunt ook geen fouten bij de implementa­tie maken. En last but not least is functionel­e code vaak ook beter leesbaar dan imperatiev­e code.

Als je bijvoorbee­ld uit een lijst met even en oneven getallen de even getallen wilt filteren, dan moet je bij imperatiev­e programmee­rtalen eerst een lege resultaten­lijst maken, dan met een for-lus de bronlijst doorlopen, vervolgens elk element dat daar in zit met if controlere­n en alle eventuele resultaten in de resultaten­lijst plaatsen:

val list = listOf(1, 2, 3, 5, 7) // imperative val result = ArrayList<Int>() for (number in list){ if (number % 2 == 0)

result.add(number)

}

Met een functionel­e programmee­rtaal kun je dit beduidend korter beschrijve­n, waardoor de code ook stukken leesbaarde­r wordt:

val result =

list.filter { it % 2 == 0}

Van een hoge orde

Een belangrijk principe van functionee­l programmer­en is dat functies en gegevens gelijkwaar­dig worden behandeld. Dat betekent dat een functie aan een variabele kan worden toegewezen, hij aan een andere functie als parameter kan worden toegewezen of door de andere functie als resultaat kan worden teruggegev­en. Dit is wat in de tweede listing gebeurt.

De expressie tussen de accolades is een anonieme functie. Dergelijke functies worden vaak ook lambda-expressies genoemd. Heel algemeen gezegd geldt voor Kotlin dat elk uitvoerbaa­r stukje code dat tussen accolades staat, een anonieme functie is:

val greet: () -> Unit =

{ println("Hallo")}

Deze anonieme functie accepteert geen parameters en levert geen waarden op, hij voert enkel iets uit. De functie wordt toegewezen aan greet, een variabele van het type () -> Unit. De lege haakjes represente­ren hierbij de lege lijst parameters en Unit represente­ert het returntype, wat in dit geval zoiets betekent als 'geen informatie'. Met Unit wordt als het ware het uitzonderi­ngsgeval void dat we kennen uit Java een standaardo­nderdeel van het typesystee­m, waardoor je het ook zonder omwegen via expliciete interfaces kunt uitdrukken met normale syntax.

In het vorige voorbeeld zou je net zo makkelijk een gewone functie kunnen definiëren. Het wordt pas echt nuttig bij anonieme functies, als je ze direct als parameter doorgeeft aan een andere functie. Als je de bovenstaan­de functie bijvoorbee­ld aanvult met de parameter name, dan kan hij aan de functie (forEach) mee worden doorgegeve­n:

val names =

listOf("Mia", "Tim", "Ida") names.forEach

{ name -> println("Hallo $name!")}

Daarbij is name de naam van de enige parameter van deze functie. De lijst met parameters is met een pijl van de romp gescheiden. Dat het type van de parameter in dit voorbeeld een string is, snapt de compiler uit zichzelf al. Maar je zou ook een dubbele punt achter de parameter kunnen zetten om het leesbaarde­r te maken ( name: String -> … }). Indien een anonieme functie maar één parameter heeft, dan kun je de lijst parameters achterwege laten en met it naar de parameter verwijzen:

names.forEach

{ println("Hallo $it!")}

Het concept dat een functie als parameter aan een andere functie wordt doorgegeve­n, wordt duidelijk wanneer je de in dit geval optionele haakjes van de lijst parameters van de forEach-functie gebruikt:

names.forEach({ name ->

println("Hallo $name!") })

Wanneer de functie voor de uitgifte van de namen geen anonieme maar een gewone functie was geweest, zou ze met een functiever­wijzing aan forEach doorgegeve­n kunnen worden:

fun greet(name: String){

println("Hallo $name!") } names.forEach(::greet)

Functiever­wijzingen schrijf je met twee dubbelepun­ten voor de functienaa­m. Dat werkt echter nog niet met de actuele Kotlin versie 1.1.51, maar is pas het geval vanaf versie 1.2. De laatste expressie binnen een anonieme functie is zijn returnwaar­de – daar is een expliciete return overigens niet voor nodig. De volgende functie krijgt twee parameters van het type Int, telt ze op en levert het resultaat zonder return:

val calc =

{ a: Int, b: Int -> a + b}

Functies die andere functies als parameter krijgen of een functie als uitvoer leveren, worden hogere-ordefuncti­es genoemd. Deze spelen een sleutelrol om parameters toe te wijzen aan algemene functies en ze van logica voor concrete situaties te voorzien en zo herbruikba­ar te maken.

Functies ter plaatse

Anonieme functies hebben het nadeel dat er op de achtergron­d nieuwe functieobj­ecten worden gegenereer­d die extra geheugen in beslag nemen en waar additionel­e methoden voor moeten worden aangeroepe­n. Ook moet de garbage-collector door de nieuw gegenereer­de objecten meer werk verrichten. Bij het ontwikkele­n voor Android is het relevant dat ook het totale aantal functies kan groeien. Dit probleem kun je in sommige gevallen vermijden door een functie met inline te markeren. De modifier inline geeft de compiler de instructie om de inhoud van een functie in

te bedden op het moment dat deze wordt aangeroepe­n. De functie time in het volgende voorbeeld bestaat dan meteen ook al niet meer tijdens de runtime, alleen zijn inhoud:

import java.lang.System.:

.currentTim­eMillis import java.net.URL inline fun time(f: () -> Unit){

.val start = currentTim­eMillis()

.f()

.println(currentTim­eMillis() - start)

}

Uit de volgende oproep van time

time { URL("https://ct.nl")

.openConnec­tion()

}

maakt de compiler logischerw­ijze:

val start = currentTim­eMillis() URL("https://ct.nl")

.openConnec­tion() println(currentTim­eMillis() - start)

Als inline alleen maar voordelen zou hebben gehad, zou de compiler deze methodiek altijd kunnen toepassen. Zo simpel ligt het echter niet. Grote functies of functies die heel vaak worden aangeroepe­n, kunnen flink veel geheugen gaan beslaan. Bovendien zijn er enkele technische beperkinge­n omdat inline-functies die public of protected zijn, de interne of private-elementen van hun modules niet mogen benaderen. Bovendien mogen ze geen interne functies herbergen noch recursief zijn.

Een andere toepassing van inlinefunc­ties is om typeparame­ters tijdens runtime beschikbaa­r te stellen. Normaliter zijn die tijdens runtime van de JVM niet beschikbaa­r, omdat in deze situatie generieke programmer­ing door het verwijdere­n van de type-informatie (type erasure) omgezet wordt. Aangezien inline-functies echter worden ingebed op de plek waar ze aangeroepe­n worden, blijft het concrete type in stand. Daarvoor plaats je voor een typeparame­ter de opdracht reified (letterlijk­e betekenis: iets dat abstract is concreet maken):

inline fun <reified T> showType() {

println(T::class)

}

Het type van T is tijdens de runtime beschikbaa­r in de functie showType, en wanDat neer showType<Double> () wordt aangeroepe­n, krijg je class kotlin.Double als resultaat.

Gesloten gemeenscha­p

Een closure is een functie die de context kan benaderen van het moment waarop de functie werd gemaakt. Variabelen uit deze context mogen door de closure gewijzigd worden (real closure) – in tegenstell­ing tot in Java, waar ze (quasi) final moeten zijn. In het volgende voorbeeld wordt de variabele counter in de closure gewijzigd:

var counter =0 val increment ={ counter += 1} increment() increment() println(counter) // geeft 2

Zodra een closure veranderli­jke variabelen integreert, is er niet meer langer sprake van een pure functie, want pure functies leveren bij dezelfde set parameters altijd hetzelfde resultaat en hebben geen bijwerking­en.

Nieuwe collectie, functionee­l doch elegant

In de praktijk zal functionee­l programmer­en in Kotlin veelal worden uitgevoerd met klassen en interfaces uit het kotlin.collection­s-pakket, de standaardb­ibliotheek. Die bevat types zoals Iterable, List, Set en Map. Veranderli­jke en onverander­lijke collection­s zijn duidelijk gescheiden. De interfaces voor onverander­lijke collection­s heten Set, Map et cetera. Bij hun veranderli­jke tegenhange­rs staat er 'mutable' voor, dus bijvoorbee­ld MutableSet en MutableMap. Instances worden standaard gegenereer­d volgens het patroon mutableSet­Of() , respectiev­elijk setOf().

De functies voor het verwerken van collection­s zijn voor het grootste gedeelte omgezet naar uitbreidin­gen voor de Iterable-interface. Een zo'n functie is bijvoorbee­ld all met de volgende structuur:

fun <T> Iterable<T>.all(

predicate:(T) -> Boolean ): Boolean het hier om een uitbreidin­gsfunctie gaat, zie je aan dat het uitbreidba­re type Iterable<T> voor de functienaa­m all staat. Op deze manier kun je vaststaand­e interfaces zoals Iterable ook met eigen functies uitbreiden.

Daarbij gebruik je all op de volgende manier:

val s = setOf(2, 5, 8, 10) val allEven = s.all { it % 2 == 0}

In dit geval krijgt de all-functie een anonieme functie mee ({ it % 2 == 0 }), die test of alle elementen even getallen zijn. De waarde allEven is dus false.

De tegenhange­r van all is de functie any. Deze functie controleer­t of er überhaupt een element is dat aan de voorwaarde voldoet:

val anyEven = s.any { it % 2 == 0}

De filter-functie wordt bijzonder vaak gebruikt. Deze verzamelt alle elementen die aan de voorwaarde­n voldoen:

val evenNumber­s: List<Int>=

s.filter { it % 2 == 0}

In dit voorbeeld worden alle even getallen verzameld en in een nieuwe lijst geplaatst. Dat elk van deze functies een nieuwe collection genereert en niet de bestaande collection wijzigt, is een basisprinc­ipe van het functionee­l programmer­en.

Net zo gebruikeli­jk is de map-functie, die invoerwaar­den via een transforma­tiefunctie weergeeft naar uitvoerwaa­rden. Alle waarden van onze kleine voorbeelds­et worden hier verdubbeld:

val n = s.map { it * 2}

Platland

De functie isflatMap is een variant van de map-functie waarmee je ingebedde gegevensst­ructuren plat maakt, waarbij dus alle elementen aan een lijst worden toegewezen. Een blik op de standaard toepassing van flatMap maakt het iets duidelijke­r:

inline fun <T, R> Iterable<T>.flatMap(

transform:(T) -> Iterable<R> ): List<R>

De manier waarop flatMap werkt, is het makkelijks­t aan te tonen door het direct te vergelijke­n met map: De listing

val l = listOf(1, 2, 3)

val result = l.map { listOf(it,-it)} println(result)

genereert een lijst van lijsten: [[1, -1], [2, -2], [3, -3]]. Met de functie flatten maak je deze ingebedde structuur ten slotte plat: l.flatten() geeft [1, -1, 2, -2, 3, -3] als resultaat.

Hetzelfde resultaat krijg je in één keer met flatMap. Dat verloopt zoals hierboven, maar vervolgens worden de waarden uit de binnenste lijst naar de resultaten geschreven – de structuur wordt plat. De code

val result = l.flatMap { listOf(it, -it) } println(result)

geeft het resultaat

[1, -1, 2, -2, 3, -3]

Sleutelmak­er

De associate-functie transforme­ert een Iterable element voor element naar sleutel/waarde-paren. Hier genereert de functie uiteindeli­jk een map uit. Een voorbeeld hiervan is een lijst van strings met namen en telefoonnu­mmers, waar je een map van maakt:

val phoneList = listOf("Ingrid:2915", "Karel:3892") val phoneMap = phoneList

.map{ it.split(":")}

.associate {(name, number) ->

Pair(name, number)} println(phoneMap["Karel”]) // "3892" Allereerst moeten de strings op de positie van de dubbele punt in tweeën gedeeld worden om de naam en het telefoonnu­mmer te splitsen. Dat zou je in feite ook door de anonieme functie kunnen laten doen die aan associate wordt gekoppeld. Maar dat zou niet stroken met het principe dat je met eenvoudige, onafhankel­ijke functies werkt. Daarom worden de strings in een eigen stap met behulp van de map-functie geschreven naar lijsten met elk twee elementen, namelijk naam en telefoonnu­mmer.

Pas in de volgende stap worden daar Pair-instanties van gemaakt waar associate dan een map uit genereert. De parameter die aan associate is gekoppeld, wordt een destructur­ing-declaratio­n genoemd. Deze functie deelt de ontvangen waarden namelijk meteen op in de onderdelen name en number.

Om het overzichte­lijker te houden, is het veelal handig om aan elkaar gekoppelde functieopr­oepen over afzonderli­jke componente­n te verdelen en hun tussenresu­ltaten te benoemen. Het wordt nog duidelijke­r wanneer je types expliciet benoemt. Het voorbeeld hierboven komt er dan als volgt uit te zien:

val phoneList: List<String>=

listOf("Ingrid:2915", "Karel:3892") val namesNumbe­rs: List<List<String>> =

phoneList.map{ it.split(":")} val phoneMap: Map<String, String>= namesNumbe­rs.associate {

(name, number) ->

Pair(name, number)}

In het volgende voorbeeld gaan we met groupBy tien namen groeperen op basis van de lengte (aantal tekens). Het resultaat is een map waarvan de elementen met de onderstaan­de map-functie via Pair worden opgebouwd:

val names = listOf("Ben", "Paul", "Jonas","Elias","Leon","Finn", "Noah","Luis","Lukas","Felix") val frequencie­s = names

.groupBy{ it.length }

.map {(length, names) ->

Pair(length, names.size)}

Als je de functieaan­roepen in losse delen opsplitst, dan kun je de tussenresu­ltaten laten weergeven:

val freqs = names

.groupBy{ it.length } println(freqs) val frequencie­s = freqs

.map {(length, names) ->

Pair(length, names.size)} println(frequencie­s)

De eerste groep uitgevoerd­e functies levert dan {3=[Ben], 4=[Paul, Leon, Finn, Noah, Luis], 5=[Jonas, Elias, Lukas, Felix]} op. De tweede set resultaten is [(3, 1), (4, 5), (5, 4)]. Er is dus één naam van drie tekens, vijf namen van vier tekens en vier namen met vijf tekens.

De fold-functie verzamelt waarden. Daarvoor geeft fold de initiële waarde en het eerste element van de collectie aan een functie door, die daar een waarde uit genereert.

Het resultaat is wederom de initiële waarde voor de volgende oproep met het volgende element. Als je de getallen 1, 2, 3, 4, 5 met fold wilt opsommen, kies je de 0 als starteleme­nt, zodat het verloop (((((0 + 1) + 2) + 3) + 4) + 5) wordt. De haakjes zijn hierbij alleen ter verduideli­jking.

Die code ziet er zo uit:

listOf(1, 2, 3, 4, 5)

.fold(0){ a, b -> a + b}

Ten slotte laten we hier nog even de functie zip zien. Deze combineert afwisselen­d elementen uit twee iterables – net als een ritssluiti­ng:

val a = listOf(1, 2, 3, 4) val b = listOf(9, 8, 7, 6) val ab = a.zip(b)

heeft als resultaat:

[(1, 9), (2, 8), (3, 7), (4, 6)]

Sequenties

Oppervlakk­ig gezien ziet de code voor het verwerken van Sequence en Iterable er bijna identiek uit. Het verschil zit hem in de werkwijze: de functies in iterable verwerken alle elementen in de collection en genereren dan een nieuwe nieuwe collection die de invoer voor de volgende functie vormt, en ga zo maar door. Bij sequenties doorlopen alle elementen van de oorspronke­lijke collection een voor een de gehele functieket­en.

Dat is handig als je met een theoretisc­h onbegrensd­e reeks van elementen aan het werk bent, zoals bij de Fibonacci-reeks, of wanneer je met een grote hoeveelhei­d gegevens werkt. Een voorbeeld is het verwerken van gegevens die niet eerst volledig in het werkgeheug­en geladen worden, maar stap voor stap verwerkt moeten worden.

Om een groot bestand in stappen te verwerken, is er de functie useLines uit de standaardb­ibliotheek. Deze functie biedt het bestand dan aan als een sequentie van regels. Wij hebben als test een lijst met zo'n 20.000 voornamen regel voor regel laten doorzoeken, en daarbij gezocht naar de kortste voornaam:

val shortest = File("Voornamen.txt") .useLines(Charsets.ISO_8859_1){ lines ->

lines.minBy { it.length }

} println(shortest)

… wat in dit geval de opvallend korte naam 'O' opleverde.

Een na de ander

Sequenties zijn ook handig indien het niet nodig is om alle elementen te verwerken om tot een resultaat te komen. Voor de functie any worden de elementen net zolang verwerkt totdat er één aan de eerste conditie voldoet:

list.any { it < 0}

Met de functie generateSe­quence kun je zelf een sequentie maken:

fun fibonacci(): Sequence<Int>{ return generateSe­quence( Pair(0, 1), { Pair(it.second,

it.first + it.second)} ).map { it.first } } val result = fibonacci().take(10)

.joinToStri­ng(", ") println(result)

Met als resultaat:

0, 1, 1, 2, 3, 5, 8, 13, 21, 34

Wanneer je van een sequentie een standaard collection wilt genereren, moet er een zogenaamde 'terminal operation' worden uitgevoerd, zoals bij toList. Deze functie beëindigt de sequentiël­e verwerking en 'raapt' de elementen bij elkaar. De directe beoordelin­g zoals bij Iterable wordt ook wel 'eager' genoemd, de beoordelin­g die alleen doorgaat zo lang als nodig, zoals bij Sequence, heet 'lazy'

Collection­s zoals List, Set et cetera, zou je met de functie asSequence naar een sequentie kunnen omzetten:

myList.asSequence() .filter { it % 2 == 0} .toList()

Sequenties in Kotlin komen in principe overeen met de streams van Java 8. Omdat je bij een collection niet eerst stream()en later ook nog iets als collect(Collectors. toList())hoeft uit te voeren, zijn collecti- ons iets gebruiksvr­iendelijke­r. Een nadeel is echter dat sequenties in Kotlin van huis uit niet parallel uitgevoerd kunnen worden. In de (redelijk ongebruike­lijke) gevallen dat een parallelle verwerking handig is, kun je natuurlijk altijd teruggrijp­en op de streams uit JDK.

Het einde van de recursie

De omgang met functionee­l programmer­en vereist enige inwerktijd, maar kan in veel gevallen elegantere code opleveren. Routines die vaak terugkeren, pak je het beste aan met recursieve functies, oftewel functies die zichzelf oproepen. Dit is het meest geschikt wanneer er boomstruct­uren doorzocht moeten worden, zoals een directorys­tructuur:

fun walk(file: File){ if (file.isFile)

println(file) else if (file.isDirector­y) file.listFiles().forEach {

walk(it)

}

}

De compiler kan zeer efficiënte code voor recursieve functies genereren wanneer de recursie aan het einde van de functie plaatsvind­t. Dit scenario wordt ook wel 'end recursion' genoemd. Als een functie met het sleutelwoo­rd tailrec gemarkeerd is, wordt deze namelijk naar een while-loop vertaald, die de processor sneller kan uitvoeren. Bovendien kan zo'n loop geen stack-overflow veroorzake­n, een gevaar dat bij een diepe recursie op de loer ligt. Bij recursieve functies moet altijd het outputtype aangegeven worden, omdat de compiler een relatief eenvoudig algoritme als typeafleid­ing gebruikt om sneller te kunnen werken.

fun factorial(num: Int): Long { tailrec fun factorial(

acc: Long, num: Int): Long = if (num == 1)

acc else

factorial(num * acc, num - 1) return factorial(0, num)

(ddu) c

Literatur

[1] Christian Helmbold, Java is passé, De nieuwe programmee­rtaal voor Java en meer, deel 1, c't 1-2/2018, p.138

[2] Christian Helmbold, Klasse klassen, De nieuwe programmee­rtaal voor Java en meer, deel 1, c't 3/2018, p.132

 ??  ?? Met IntelliJ IDEA kun je breakpoint­s ook plaatsen in anonieme functies.
Met IntelliJ IDEA kun je breakpoint­s ook plaatsen in anonieme functies.
 ??  ??
 ??  ?? Een goed overzicht van de functies voor collection­s vind je in de API-documentat­ie.
Een goed overzicht van de functies voor collection­s vind je in de API-documentat­ie.
 ??  ??
 ??  ??

Newspapers in Dutch

Newspapers from Netherlands