Programmeren met Kotlin, deel 3
De nieuwe programmeertaal voor Java en meer, deel 3: functioneel programmeren
Kotlin is van begin af aan ontworpen als een hybride programmeertaal die zowel objectgeoriënteerd is, maar ook met functies werkt. Daarmee komt hij ontwikkelaars tegemoet, want functioneel programmeren is in veel opzichten een perfecte aanvulling op een objectgeoriënteerde programmeerstijl.
Functioneel programmeren werd al in 1958 geïntroduceerd met de programmeertaal Lisp. In recente jaren is de interesse hierin flink toegenomen. Technische ballast zoals hulplijsten en forloops zitten in gebruikte functies verwerkt. Ontwikkelaars hoeven het wiel niet telkens weer opnieuw uit te vinden, hoeven minder te testen en je kunt ook geen fouten bij de implementatie maken. En last but not least is functionele code vaak ook beter leesbaar dan imperatieve code.
Als je bijvoorbeeld uit een lijst met even en oneven getallen de even getallen wilt filteren, dan moet je bij imperatieve programmeertalen eerst een lege resultatenlijst maken, dan met een for-lus de bronlijst doorlopen, vervolgens elk element dat daar in zit met if controleren en alle eventuele resultaten in de resultatenlijst 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 functionele programmeertaal kun je dit beduidend korter beschrijven, waardoor de code ook stukken leesbaarder wordt:
val result =
list.filter { it % 2 == 0}
Van een hoge orde
Een belangrijk principe van functioneel programmeren is dat functies en gegevens gelijkwaardig 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 teruggegeven. 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 uitvoerbaar 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 representeren hierbij de lege lijst parameters en Unit representeert het returntype, wat in dit geval zoiets betekent als 'geen informatie'. Met Unit wordt als het ware het uitzonderingsgeval void dat we kennen uit Java een standaardonderdeel van het typesysteem, 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 bovenstaande functie bijvoorbeeld aanvult met de parameter name, dan kan hij aan de functie (forEach) mee worden doorgegeven:
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 leesbaarder 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 doorgegeven, 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 functieverwijzing aan forEach doorgegeven kunnen worden:
fun greet(name: String){
println("Hallo $name!") } names.forEach(::greet)
Functieverwijzingen schrijf je met twee dubbelepunten voor de functienaam. 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 returnwaarde – 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-ordefuncties genoemd. Deze spelen een sleutelrol om parameters toe te wijzen aan algemene functies en ze van logica voor concrete situaties te voorzien en zo herbruikbaar te maken.
Functies ter plaatse
Anonieme functies hebben het nadeel dat er op de achtergrond nieuwe functieobjecten worden gegenereerd die extra geheugen in beslag nemen en waar additionele methoden voor moeten worden aangeroepen. Ook moet de garbage-collector door de nieuw gegenereerde objecten meer werk verrichten. Bij het ontwikkelen 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 aangeroepen. De functie time in het volgende voorbeeld bestaat dan meteen ook al niet meer tijdens de runtime, alleen zijn inhoud:
import java.lang.System.:
.currentTimeMillis import java.net.URL inline fun time(f: () -> Unit){
.val start = currentTimeMillis()
.f()
.println(currentTimeMillis() - start)
}
Uit de volgende oproep van time
time { URL("https://ct.nl")
.openConnection()
}
maakt de compiler logischerwijze:
val start = currentTimeMillis() URL("https://ct.nl")
.openConnection() println(currentTimeMillis() - 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 aangeroepen, kunnen flink veel geheugen gaan beslaan. Bovendien zijn er enkele technische beperkingen 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 inlinefuncties is om typeparameters tijdens runtime beschikbaar te stellen. Normaliter zijn die tijdens runtime van de JVM niet beschikbaar, omdat in deze situatie generieke programmering door het verwijderen van de type-informatie (type erasure) omgezet wordt. Aangezien inline-functies echter worden ingebed op de plek waar ze aangeroepen worden, blijft het concrete type in stand. Daarvoor plaats je voor een typeparameter de opdracht reified (letterlijke betekenis: iets dat abstract is concreet maken):
inline fun <reified T> showType() {
println(T::class)
}
Het type van T is tijdens de runtime beschikbaar in de functie showType, en wanDat neer showType<Double> () wordt aangeroepen, krijg je class kotlin.Double als resultaat.
Gesloten gemeenschap
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 tegenstelling 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 veranderlijke 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 bijwerkingen.
Nieuwe collectie, functioneel doch elegant
In de praktijk zal functioneel programmeren in Kotlin veelal worden uitgevoerd met klassen en interfaces uit het kotlin.collections-pakket, de standaardbibliotheek. Die bevat types zoals Iterable, List, Set en Map. Veranderlijke en onveranderlijke collections zijn duidelijk gescheiden. De interfaces voor onveranderlijke collections heten Set, Map et cetera. Bij hun veranderlijke tegenhangers staat er 'mutable' voor, dus bijvoorbeeld MutableSet en MutableMap. Instances worden standaard gegenereerd volgens het patroon mutableSetOf() , respectievelijk setOf().
De functies voor het verwerken van collections zijn voor het grootste gedeelte omgezet naar uitbreidingen voor de Iterable-interface. Een zo'n functie is bijvoorbeeld all met de volgende structuur:
fun <T> Iterable<T>.all(
predicate:(T) -> Boolean ): Boolean het hier om een uitbreidingsfunctie gaat, zie je aan dat het uitbreidbare type Iterable<T> voor de functienaam all staat. Op deze manier kun je vaststaande 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 tegenhanger van all is de functie any. Deze functie controleert 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 voorwaarden voldoen:
val evenNumbers: 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 basisprincipe van het functioneel programmeren.
Net zo gebruikelijk is de map-functie, die invoerwaarden via een transformatiefunctie weergeeft naar uitvoerwaarden. Alle waarden van onze kleine voorbeeldset worden hier verdubbeld:
val n = s.map { it * 2}
Platland
De functie isflatMap is een variant van de map-functie waarmee je ingebedde gegevensstructuren plat maakt, waarbij dus alle elementen aan een lijst worden toegewezen. Een blik op de standaard toepassing van flatMap maakt het iets duidelijker:
inline fun <T, R> Iterable<T>.flatMap(
transform:(T) -> Iterable<R> ): List<R>
De manier waarop flatMap werkt, is het makkelijkst aan te tonen door het direct te vergelijken 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]
Sleutelmaker
De associate-functie transformeert een Iterable element voor element naar sleutel/waarde-paren. Hier genereert de functie uiteindelijk een map uit. Een voorbeeld hiervan is een lijst van strings met namen en telefoonnummers, 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 telefoonnummer 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, onafhankelijke 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 telefoonnummer.
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 destructuring-declaration genoemd. Deze functie deelt de ontvangen waarden namelijk meteen op in de onderdelen name en number.
Om het overzichtelijker te houden, is het veelal handig om aan elkaar gekoppelde functieoproepen over afzonderlijke componenten te verdelen en hun tussenresultaten te benoemen. Het wordt nog duidelijker 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 namesNumbers: List<List<String>> =
phoneList.map{ it.split(":")} val phoneMap: Map<String, String>= namesNumbers.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 onderstaande map-functie via Pair worden opgebouwd:
val names = listOf("Ben", "Paul", "Jonas","Elias","Leon","Finn", "Noah","Luis","Lukas","Felix") val frequencies = names
.groupBy{ it.length }
.map {(length, names) ->
Pair(length, names.size)}
Als je de functieaanroepen in losse delen opsplitst, dan kun je de tussenresultaten laten weergeven:
val freqs = names
.groupBy{ it.length } println(freqs) val frequencies = freqs
.map {(length, names) ->
Pair(length, names.size)} println(frequencies)
De eerste groep uitgevoerde 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 startelement, zodat het verloop (((((0 + 1) + 2) + 3) + 4) + 5) wordt. De haakjes zijn hierbij alleen ter verduidelijking.
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 afwisselend elementen uit twee iterables – net als een ritssluiting:
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
Oppervlakkig 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 oorspronkelijke collection een voor een de gehele functieketen.
Dat is handig als je met een theoretisch onbegrensde reeks van elementen aan het werk bent, zoals bij de Fibonacci-reeks, of wanneer je met een grote hoeveelheid gegevens werkt. Een voorbeeld is het verwerken van gegevens die niet eerst volledig in het werkgeheugen geladen worden, maar stap voor stap verwerkt moeten worden.
Om een groot bestand in stappen te verwerken, is er de functie useLines uit de standaardbibliotheek. 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 generateSequence kun je zelf een sequentie maken:
fun fibonacci(): Sequence<Int>{ return generateSequence( Pair(0, 1), { Pair(it.second,
it.first + it.second)} ).map { it.first } } val result = fibonacci().take(10)
.joinToString(", ") 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ële verwerking en 'raapt' de elementen bij elkaar. De directe beoordeling zoals bij Iterable wordt ook wel 'eager' genoemd, de beoordeling die alleen doorgaat zo lang als nodig, zoals bij Sequence, heet 'lazy'
Collections 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 gebruiksvriendelijker. Een nadeel is echter dat sequenties in Kotlin van huis uit niet parallel uitgevoerd kunnen worden. In de (redelijk ongebruikelijke) gevallen dat een parallelle verwerking handig is, kun je natuurlijk altijd teruggrijpen op de streams uit JDK.
Het einde van de recursie
De omgang met functioneel programmeren 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 boomstructuren doorzocht moeten worden, zoals een directorystructuur:
fun walk(file: File){ if (file.isFile)
println(file) else if (file.isDirectory) 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 plaatsvindt. Dit scenario wordt ook wel 'end recursion' genoemd. Als een functie met het sleutelwoord 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 veroorzaken, 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 typeafleiding 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 programmeertaal voor Java en meer, deel 1, c't 1-2/2018, p.138
[2] Christian Helmbold, Klasse klassen, De nieuwe programmeertaal voor Java en meer, deel 1, c't 3/2018, p.132