Funkcje w Kotlinie jako obywatele pierwszej kategorii
Programowanie funkcyjne – większość z nas, programistów czy to backendu, frontendu czy aplikacji mobilnych korzysta na co dzień z dobrodziejstw tego paradygmatu używając takich języków jak: Java, JavaScript, Kotlin. Ten sposób definiowania poleceń maszynie zaskarbił sobie w ostatnich latach wielu zwolenników ze względu na czytelność, łatwość zastosowania niektórych optymalizacji czy lepszą testowalność. Nawet do tak “do bólu obiektowego” języka jak Java zdecydowano się jednak wprowadzić wraz z wersją 8 takie konstrukty jak: lambdy, monady, interfejsy funkcyjne.
Jedną z zalet programowania funkcyjnego jest łatwość, z jaką można przekazywać zachowanie w kodzie. Dla programisty języków obiektowych czy strukturalnych świat wygląda prosto: w zmiennych przechowujemy wartości, będące stanem programu, zaś metody lub funkcje modyfikują ten stan. Programowanie funkcyjne upraszcza ten obraz jeszcze bardziej – funkcje również mogą być traktowane jak szczególne przypadki zmiennych. Tę właściwość nazywamy wspomnianym w tytule określeniem – funkcje są obywatelami pierwszej kategorii (po angielsku: first-class citizens).
Aby móc powiedzieć, że funkcje w danym języku są pierwszej kategorii, musi on posiadać możliwość:
- zadeklarowania funkcji jako zmiennej,
- przekazania funkcji jako argumentu do innej funkcji,
- zwrócenia funkcji jako wyniku innej funkcji,
- przechowywania funkcji w strukturach danych.
Kotlin, pomimo tego, że nie jest językiem czysto funkcyjnym, posiada funkcje pierwszej kategorii, co czyni go naprawdę przyjemnym w użyciu językiem. Wspomniane powyżej własności można również zrealizować w Javie. Podam jednak poniżej przykłady, z których wynika, że taki kod nie jest aż tak ekspresyjny i zwięzły, jak jego odpowiednik napisany w Kotlinie (a ekspresyjny, a zarazem zwięzły kod to coś, do czego każdy programista wysokopoziomowy dąży).
Przykład przypisania funkcji do zmiennej:
var isPies: (Creature) -> Boolean = { creature: Creature -> creature.isMammal() && creature.isLoyal() } // albo może inaczej zdefiniuj psa? isPies = Creature::canBark
Wartość pierwszego przypisania to znana również w Javie lambda. Drugie przypisanie z kolei stanowi odniesienie do funkcji (function reference). „::” to operator takiego odniesienia.
Tę samą logikę można zapisać w Javie:
Predicate<Creature> isPies = (Creature creature) -> creature.isMammal() && creature.isLoyal(); isPies = Creature::canBark;
W tym wypadku kod wygląda podobnie – mamy zarówno lambdę, jak i odwołanie do metody, jednak jest zasadnicza różnica – musimy „opakować” zmienną isPies
w typ Predicate<>
. W Kotlinie nasza zmienna miała typ:
(Creature) -> Boolean
Typy zmiennych przechowujących funkcje w Kotlinie mają następującą składnię:
(<Typ pierwszego argumentu funkcji>, <Typ drugiego argumentu funkcji>, ...) -> <Typ zwracany przez funkcję>
Daje nam to o wiele większą elastyczność i zapewnia zgrabną unifikację – nie musimy chociażby zastanawiać się, jakiego typu użyć do opakowania zmiennych przechowujących funkcje – czy Predicate
, Consumer
, Supplier
, a może BiConsumer
będzie odpowiedni w konkretnym przypadku? Czy może trzeba uciec się do bardziej uniwersalnego interfejsu Function
?
Kolejna własność to przekazywanie funkcji jako argumentu do innej funkcji. Takie funkcje przyjmujące jako argumenty funkcje (albo je zwracające, ale o tym za chwilę) nazywamy funkcjami wyższego rzędu.
Oto przykład w Kotlinie:
fun trainModel( model: Model, inputs: List<Input>, activationFunction: (Input) -> Output, trainingParams: Params ) { // (..) activationFunction(input) // wywołanie activationFunction() gdzieś we wnętrzu trainModel() // (..) }
W powyższym przykładzie activationFunction
jest funkcją, możemy to poznać po jej typie (Input) -> Output
. Taki styl programowania zapewnia świetną elastyczność, testowalność i zwięzłość kodu – możemy z łatwością używać w różnych miejscach w kodzie trainModel()
z różnymi rodzajami funkcji aktywacji bez konieczności powtarzania tych samych linii kodu. W razie, gdyby zaistniała potrzeba dodania kolejnej implementacji funkcji aktywacji, już stworzony kod funkcji trainModel()
może pozostać nienaruszony.
Przypomnijmy sobie w tym miejscu zasadę SOLID: twórz kod otwarty na rozszerzanie funkcjonalności, a zamknięty na modyfikacje.
Odpowiednik w Javie:
void trainModel(Model model, List<Input> inputs, Function<Input, Output> activationFunction, Params params) { // (..) activationFunction.apply(input); // (..) }
I znowu musimy używać interfejsów funkcyjnych – w tym wypadku Function<T, R>
. Również samo wywołanie przekazanej jako argument funkcji w Kotlinie jest o wiele bardziej zwięzłe przy użyciu operatora (). Może się wydawać, że różnica pomiędzy ()
a apply()
nie jest aż tak wielka, jednak przy naprawdę wysokiej częstotliwości użycia apply()
zaczyna ono irytować.
Ten przykład uświadamia nam, że w Javie funkcje to nie to samo co zmienne, skoro żeby je wyrazić, trzeba je opakować w interfejs funkcyjny. activationFunction()
jest bowiem obiektem, a nie metodą. Inaczej sprawa ma się w Kotlinie, gdzie activationFunction()
to zwykła funkcja, która, w szczególności, jest wywoływana jak funkcja.
Następna własność to możliwość zwrócenia funkcji jako wyniku innej funkcji. Przykład w Kotlinie:
class Event( val id: String, val data: Any ) val context = Context() fun log(): (Event) -> Unit = return if (context.canLogSensitiveData) { { event -> log("Processing event with id ${event.id}, event data: ${event.data}") } } else { { event -> log("Processing event with id ${event.id}") } }
Funkcja log()
zwraca sposób, w jaki będą logowane obiekty typu Event
w zależności od tego, co znajduje się w statycznym kontekście aplikacji. Dzięki temu, że funkcje są w Kotlinie obywatelami pierwszej kategorii, można swobodnie przekazywać w programie wynik tej funkcji – lambdę i wywoływać ją w tym momencie, który nam odpowiada. Warunek context.canLogSensitiveData
będzie sprawdzony tylko wtedy, gdy zajdzie taka konieczność (gdy podejrzewamy, że zmienna context
została zaktualizowana), a nie za każdym razem, gdy będziemy chcieli zalogować obiekt typu Event
. A pamiętajmy, że wywołanie context.canLogSensitiveData
może być kosztowną operacją – zarówno pod kątem zasobów, jak i czasu.
I ostatnia własność, jaką język musi spełnić, żeby móc uznać, że jego funkcje są obywatelami pierwszej kategorii – możliwość przechowania ich w strukturach danych: listach, zbiorach, mapach. Przykład:
var stringTransformations = listOf<(String) -> String>( String::trim, String::toUpperCase, { input -> input.replace(" ", "_") } ) listOf(" furious einstein ", "romantic curie ", " jovial heisenberg").map { element -> stringTransformations.fold(element, { input, transformation -> transformation(input) }) }
Zmienna stringTransformations
przechowuje listę transformacji, jakim mogą zostać poddane obiekty typu String; w tym wypadku transformacje te przekształcają dowolnego stringa do konwencji snakecase, pisany wielkimi literami. Lista ta może być dynamicznie aktualizowana w zależności od stanu aplikacji – nowe transformacje mogą być dodawane, stare usuwane, ich kolejność zmieniana itp.
Pokazuje to doskonale, na czym w praktyce polega to, że „funkcje to to samo co zmienne” – mogą być one tak samo jak zmienne aktualizowane. W tym wypadku aktualizowanym zachowaniem jest stringTransformations
, które agreguje po kolei wywoływane funkcje. Funkcje String:trim
, String:toUpperCase
i { input -> input.replace(" ", "_") }
to „klocki”, za pomocą których składamy w całość nasze przekształcenie.
Drugie wyrażenie pokazuje, jak przekształcić listę przykładowych stringów zawartością stringTransformations. map
i fold
użyte w tym celu są wspomnianymi wyżej funkcjami wyższego rzędu – przyjmują inne funkcje jako argumenty. map
przekształca osobno każdy element kolekcji, a fold
aplikuje sekwencyjnie przekazaną jako argument funkcję do każdego elementu kolekcji, na której jest wywoływany oraz do dodatkowej wartości, której przekształcona wartość jest przekazywana do kolejnych wywołań.
Innym pojęciem związanym z obywatelami pierwszej kategorii jest pojęcie curryingu. Wbrew pozorom nie wzięło ono swojej nazwy od przyprawy curry, lecz od nazwiska matematyka Haskella Curry, który prowadził nad nim prace.
Czym w takim razie jest currying w programowaniu? Jest to sposób pisania kodu, w którym zamiast przekazywania do funkcji wielu argumentów, przekształcamy ją w sekwencję wywołań, z których każde pobiera na wejściu po jednym kolejnym argumencie.
Co nam w zasadzie daje taki sposób programowania? Największą zaletą jest możliwość częściowej aplikacji argumentów. W pewnym miejscu w kodzie można przekazać do tak przekształconej funkcji pierwszy argument (po obliczeniu jego wartości), następnie możemy zająć się pewnymi czynnościami niezwiązanymi z wywołaniem tej konkretnej funkcji.
Po ich zrealizowaniu można wrócić do rezultatu poprzedniego wywołania i przekazać kolejny argument i tak dalej aż do zebrania kompletu argumentów niezbędnych do ostatniego wywołania funkcji i uzyskania w ten sposób jej wyniku.
Jest to niezwykle przydatne w sytuacji, gdy nasz system składa się z wielu serwisów realizujących określone zadania, zaś komunikacja pomiędzy nimi przebiega po zdefiniowanym API. Zamiast zwracać jako wynik działania obiekty, taki serwis może przyjmować funkcję z częściowo zaaplikowanymi argumentami i zwracać tę samą funkcję z dodanymi od siebie argumentami.
Jak w takim razie wykorzystać możliwości tej bardzo przydatnej techniki pisząc kod w Kotlinie? Kotlin sam w sobie nie wspiera curryingu, możemy jednak pomóc mu trochę w osiągnięciu celu pisząc kod w określony sposób. Zamiast takiej definicji:
val processTransaction: (Issuer, Acquirer, Transaction) -> Unit { // logika funkcji }
możemy zapisać:
val processTransaction: (Issuer) -> (Acquirer) -> (Transaction) -> Unit = { issuer -> { acquirer -> { transaction -> // logika funkcji } } }
Dzięki temu mamy możliwość częściowej aplikacji argumentów funkcji:
val issuer = getIssuer() val processTransactionForIssuer = processTransaction(issuer) // wykonaj pewne czynności przygotowawcze przed wywołaniem getAcquirer() val acquirer = getAcquirer() val processTransactionForIssuerAndAcquirer = processTransactionForIssuer(acquirer) // znowu przerywamy obsługę transakcji w celu wykonania jakiś innych czynności val transaction = getTransaction() processTransactionForIssuerAndAcquirer(transaction)
Rozwiązanie to oczywiście nie jest idealne: nakłada konieczność pisania funkcji w inny, mniej intuicyjny dla większości programistów sposób, jednak korzyści płynące z curryingu mogą być tego warte.
Podsumowanie
Zmiana sposobu programowania z obiektowego na funkcyjny niesie ze sobą konieczność zmiany myślenia o abstrakcjach, jakie udostępnia język programowania, jednak zalety, jakie ono ze sobą niesie sprawiają, że warto dać mu szansę. Funkcje jako obywatele pierwszej kategorii są niezwykle użyteczną własnością języków funkcyjnych. Podsumowując, do najważniejszych ich zalet należą:
- łatwiejsze pisanie rozszerzalnego kodu,
- łatwiej jest wyekstraktować powtarzalne, generyczne zachowania, dzięki temu nie powtarzamy tych samych linii kodu,
- kod jest czytelniejszy,
- można odłożyć wykonanie funkcji w czasie (w stosunku do utworzenia jej lub przekazania argumentów).
Zdjęcie główne artykułu pochodzi z unsplash.com.