funkcyjne vs imperatywne programowanie

Programowanie funkcyjne w Kotlinie wraz z przykładami

Featured image

Funkcyjne podejście ma swoje początki już w latach 30 ubiegłego wieku. Jest to taki formalny zapis podstaw z matematyki. Jest tam coś takiego jak lambda calculus, czyli zestaw zagadnień związanych, chociażby z rekurencją, czy też definiowaniem funkcji. Programowanie funkcyjne pozwala nam pisać kod, który jest czysty, bezpieczny, a także łatwo testowalny. Oczywiście ma ono również wady z czego jedną z nich może być zbyt duże pójście w abstrakcję co powoduje, że kod staje się niezrozumiały. Tak, czy inaczej jest to tylko jedno z dostępnych rozwiązań.

O czym sobie powiemy?

Biblioteki do funkcyjnego kodu

W Javie mamy różne funkcyjne bibliteki umożliwiające tworzenie bardziej funkcyjnego kodu. Można użyć Vavra albo JOOλ. W Kotlinie jest Arrow choć sam język jest tutaj z natury funkcyjny. Obie biblioteki to jest po prostu implementacja rzeczy naturalnie występujących w chociażby Scali. Funkcyjne struktury to temat na tyle obszerny, że należy mu się osobny wpis. W tym powiemy sobie o samym podejściu do pisania bardziej funkcyjnego kodu. Jeśli jeszcze nie miałeś styczności z Kotlinem to tutaj przygotowałem dla ciebie proste katy porównujące oba języki.

Ale zacznijmy od początku… ?

Funkcyjnie, czyli mamy… funkcje. Mają one jakieś wejście/wyjście. Rozbijamy nasz algorytm na mniejsze pod-funkcje. Jak wiadomo nasze umysły mają tylko ograniczoną ilość RAMu. Dlatego właśnie wymyślamy różne koncepty, paradygmaty, standardy, aby uprościć to wszystko. Małe moduły mogą być kodzone szybko i być łatwo testowane. Do tego jest możliwe ich re-używanie. Daje nam to tyle, że wyciągamy pewną część abstrakcji z powtarzalnych czynności i aplikujemy bardziej generyczne rozwiązanie. Jak chociażby dobrze nam znane: filter { ... }, map { ... }, reduce { ... }

// Kotlin
data class Programmer(val name: String, val daysSpentCoding: Int)

val programmers = listOf(
    Programmer("", 999999),
    Programmer("Morty", 20000),
    Programmer("Rick", 30000)
)
val totalTimeSpentCoding = programmers
    .filter { it.name.isNotBlank() }
    .map { it.daysSpentCoding }
    .reduce { total, next -> total + next }
 
totalTimeSpentCoding // 50000

Używanie FP wcale nie znaczy, że obiektówka jest już passé. Wręcz przeciwnie oba paradygmaty doskonale ze sobą współpracują. Choć może nie do końca współpracują, ale bardziej zastępują lub uzupełniają niektóre techniki zawierające się w innych paradygmatach.

Różnica między kodem imperatywnym, a funkcyjnym?

Imperatywny kod to ten, z którym najczęściej spotykamy się na początku naszej przygody z programowaniem. Jest to najbardziej naturalny sposób, w jaki można pisać aplikację. Tworzymy tutaj ciąg instrukcji jaki nasz program wykonuje ( step-by-step). Opisujemy dokładne czynności, jakie muszą być wykonane podczas działania programu. Podczas tych kroków zmieniamy stan systemu modyfikując go. Wynikiem jest zwrócona wartość lub inny efekt.

Imperatywny kod cechuje:

Object-oriented programming. Oczywiście mówiąc o imperatywnym podejściu nie można nie wspomnieć o najbardziej popularnym z paragymatów, czyli programowaniu obiektowym. Skupiamy się tutaj na hierarchii klas, enkapsulacji oraz wielu, wielu innych elementach przekraczających zakres tego wpisu. Na ogół myślimy w kontekście obiektów. Wbrew pozorom większość popularnych języków obiektowych wspiera/wywodzi się właśnie z imparatywnego/proceduralnego podejścia. Aby przejść z tego paradygmatu na ten bardziej matematyczny/funkcyjny trzeba zmienić myślenie.

Podchodząc do programowania funkcyjnego nie myślimy już o obiektach. Obiektem uwielbienia stają się tutaj funkcje. Główną różnicą pomiędzy imperatywnym, a funkcyjnym jest to, że w tym pierwszym przypisujemy wartości zmiennym i mutujemy je, a w funkcyjnym zwracamy wartość w bezstanowy sposób. Dzięki temu możemy po prostu używać funkcji patrząc na ich input/output. Już za chwilkę zobaczysz to na przykładzie!

Funkcyjny kod cechuje:

Główną różnicą jest tutaj to, że funkcyjne programy są bardziej ekspresyjne (czytelne). Piszemy mniej kodu robiąc to samo co w imperatywnym. Ponadto dzięki niemutowalności oraz większej kontroli nad efektami ubocznymi nasze aplikacje są bardziej deterministyczne. Dzięki czemu czasami uciekniemy od wielowątkowych problemów, jak race-conditions, deadlocks oraz inne. Ponadto nie zawsze musimy się przejmować się kolejnością wykonywania działań w naszym kodzie. Oczywiście to zależy od konkretnego przypadku, ale koniec końców FP pomaga nam w wielu kwestiach.

Przykłady 🧑🏼‍💻

W podejściu imperatywnym:

Skupiamy się na tym, co chcemy zrobić (wykonujemy konkretne czynności step-by-step). Tworzymy wynik.

W podejściu funkcyjnym:

Skupiamy się na tym, co chcemy osiągnąć.

Liczby nieparzyste - imperatywny

val numbers = listOf(1, 2, 3, 4, 5)
val odds = ArrayList<Int>()

for (index in 0..numbers.lastIndex) {
    val item = numbers[index]
    if (item % 2 != 0) odds.add(item)
}

// seria mutacji oddzielonych warunkami, step-by-step 

Liczby nieparzyste - funkcyjny

val numbers = listOf(1, 2, 3, 4, 5)

val odds = numbers.filter { it % 2 != 0 }

// Skupiamy się na tym, co chcemy osiągnąć.

Jak napisać program bez efektu ubocznego?

Nie zawsze się da. Chodzi nam bardziej o to, żeby nie mieć obserwowalnych efektów ubocznych. Co to znaczy? Wyjaśnimy sobie już za chwilkę. Zazwyczaj jak piszemy apkę to mamy widoczny efekt - wynik. Zapisaliśmy coś do bazy danych, wysłaliśmy coś po HTTPie, wrzuciliśmy jakiś event na kolejkę, wygenerowaliśmy raport i tak dalej. Integrujemy się ze światem zewnętrznym. W FP chodzi o odłożenie efektów ubocznych do czasu wykonania obliczeń, a nie podczas ich.

Funkcyjny - deterministyczny, przewidywalny

fun multiply(val a: Int, val b: Int) = a * b
multiply(123456789, 123456789) // -1757895751

Nie funkcyjny - zgłasza wyjątek, nieprzewidywalny

fun divide(a: Int, b: Int) = a / b
divive(123456789, 0) // ArithmeticException: / by zero

W pierwszym przypadku jeśli przekręcimy Inta to dostaniemy ujemną wartość (błędną, ale nie jest to efekt uboczny). Mimo wszystko pierwszy program jest funkcyjny. W drugim przypadku dostaniemy wyjątek. Nie spodziewaliśmy się tego, przez co program nie jest już deterministyczny. Nasze początkowe założenie, że funkcja pomnoży/podzieli wynik jest błędna. Oczywiście rzadko kiedy sytuacja jest, aż tak trywialna, ale można sobie wyobrazić, że to wszystko jest jakimś zapytaniem do bazy lub inną operacją. Chodzi o to, aby nasze funkcje były deterministyczne. Wynik zawsze jest taki sam dla podanych argumentów. Jeśli występują efekty uboczne to wiemy, jakie i odpowiednio reagujemy.

Zróbmy to samo w bardziej funkcyjny sposób

// Kotlin
fun multiply(a: Int, b: Int) = a * b.toFloat()
fun divide(a: Int, b: Int) = a / b.toFloat()

multiply(123456789, 123456789) // 1.52415794E16
divide(123456789, 0)           // Infinity

result.isInfinite().maybe { "return default value or something else" }

W tym momencie dla każdej możliwej wartości będzie ten sam wynik. Również w przypadku poprzedniego wyjątku teraz zostanie zwrócony obiekt Infinity, na którym możemy działać dalej. Można użyć extension function z Kotlina, które pozwala rozszerzyć każdą metodę o dodatkowe funkcjonalności. W tym przypadku zaciągnęliśmy biblioteką Arrow, która zrobiła za nas trochę magii maybe { … }. Zwraca ona none() dla braku wartości lub some() jeśli coś jest.

W Javie niby też mamy obiekty, ale Kotlin poszedł krok dalej, bo dosłownie wszystko obiektem. Tak też zamiast void jest Unit, a gdy wiemy, że metoda zawsze się nie udaje to jest Nothing. Jest to przydatne, chociażby w metodzie TODO(“”) która zwraca właśnie Nothing. Jest jeszcze Any dla wartości non-nullable oraz Any? dla nullable co ma swój odpowiednik w Javowym Object. Na tym zakończmy te rozważania, bo jest to poza zakresem wpisu. Potraktujmy to jako ciekawostkę, bo rzeczy te przydają się również w programowaniu funkcyjnym.

Gdy wiemy czego się spodziewać po funkcji i zawsze zwraca ona prawidłową wartość można nazwać ją czystą. Znaczy to tyle, że takie pure functions nie mają żadnych efektów ubocznych oraz są referencyjnie transparentne. Czym jest referential transparency? Znajdziesz poniżej.

To co zwraca zależy tylko i wyłącznie od parametrów, jakie podaliśmy. Dobrym przykładem są funkcje z klasy Math. Gdzie biorąc sqrt(2.0) pierwiastek kwadratowy wiemy, że wynikiem jest zawsze jakiś obiekt typu Double.

Podsumowując pisać funkcyjnie znaczy jak najbardziej minimalizować efekty uboczne.

Czym jest referential transparency?

Brak zależności od zewnętrznych serwisów, plików, czy nastroju programisty. Funkcja zawsze zwraca to co powinna. Jest deterministyczna. Nie zgłosi wyjątku. Nie przestanie działać z powodu braku danych z API, bazy, czy jakiegoś urządzenia IoT zbierającego dane. Po prostu działa i zawsze zwraca to samo przy podanych argumentach. Wynik jest zawsze ten sam dla podanych argumentów.

Nie jest to referencyjnie przezroczyste

Math.random(); // Wynik jest różny za każdym razem

Jest referencyjnie przezroczysta

Math.max(1, 2); // Wynik zawsze jest taki sam

To jak pisać bardziej funkcyjnie?

Imperatywny przykład:

fun buyBook(bookName: String): Book {
    val book = bookRepository.getBook(bookName)
    creditCard.performPayment(book.price)
    return book
}

Mamy tutaj funkcję creditCard.performPayment() która mogłaby zwracać Unit (odpowiednik Javowego void). Jest to całkiem dobry znak, że funkcja nie jest czysta. Ma efekty uboczne. Moglibyśmy trafić na moment, w którym serwis od jakiego jesteśmy zależni nie odpowiada, albo po prostu jest coś z nim nie tak. W programowaniu funkcyjnym jest wiele struktur, koncepcji, które służą do ukrywania efektów ubocznych. Takie jak, chociażby Try, Either, Option, IO. To wszystko to są takie monadyczne struktury do tworzenia wrappera do operacji, jaką wykonujemy. Oprócz wrappowania mają one zawsze jakiś określony efekt. Choćby efekt braku danych co jest obsługiwane przez Optional w standardowej Javie albo poprzez Option w vavrze. W Kotlinie to samo mamy out-of-box gdzie non-nullability jest wbudowane w język.

Poniżej mamy Pair, czyli taki generyczny kontener na łączenie różnych obiektów ze sobą. W Javie moglibyśmy użyć Tuple od Vavra. Jest to użyteczne, jeśli chcemy przekazywać obiekt całościowo. Powyższy kod jest dość trudny do przetestowania. Musielibyśmy sobie zamockować proces weryfikacji w banku, czy karta w ogóle istnieje, czy posiada środki na koncie i tak dalej. Dopiero po tym zwracamy film. Możemy zrobić to trochę inaczej. Co, jeśliby przetestować to bez kontaktu z bankiem? Moglibyśmy powiązać płatność z książką.

Funkcyjny przykład:

fun buyBook(bookName: String): Pair<Book, Payment> {
    val book = bookRepository.getBook(bookName)
    val payment = Payment(creditCard, book.price)
    return Pair(book, payment)
}

class Payment(val creditCard: CreditCard, val price: BigDecimal)
class Book(val name: String, val price: BigDecimal)

W Javie użylibyśmy Tuple od Vavra

Zauważ, że teraz nie obchodzi nas jak zaaraguje bank. Czy karta zostanie przyjęta, czy odrzucona - nie jest to istotne w tym kontekście. Można też zmodyfikować ten kod i umożliwić kupno różnych przedmiotów. Po czym agregować płatności i dopiero pod koniec wysłać zapytanie do banku. Przetestowanie powyższego kodu jednostkowo jest prostsze.

@Test
fun `Should buy book`() {
    // given:
    val creditCard = CreditCard()
    val bookStore = BookStore()

    // when:
    val purchase = bookStore.buyBook("12 rules for life", creditCard)

   // then:
   assertThat(petersonBookPrice)
      .isEqualTo(purchase.first.price)

   assertThat(creditCard)
       .isEqualTo(purchase.second.creditCard)
}Copy
Kotlin