Porównanie programowania funkcyjnego oraz imperatywnego

braintelligence.pl 5 lat temu

Czy da się napisać program bez efektów ubocznych? ?

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, iż kod staje się niezrozumiały. Tak, czy inaczej jest to tylko jedno z dostępnych rozwiązań.

O czym sobie powiemy?

  • Parę słów o programowaniu imperatywnym, funkcyjnym.
  • Czy da się napisać program bez efektów ubocznych?
  • Ciekawy przykład z bardziej funkcyjnym kodem. Jak pisać bardziej funkcyjnie?

Jakich bibliotek możemy użyć 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 jest po prostu implementacja rzeczy naturalnie występujących w chociażby Scali. Funkcyjne struktury to temat na tyle obszerny, iż należy mu się osobny wpis. W tym powiemy sobie o samym podejściu do pisania bardziej funkcyjnego kodu. jeżeli jeszcze nie miałeś styczności z Kotlinem to tutaj przygotowałem dla ciebie proste katy porównujące oba języki – tutaj.

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 gwałtownie i być łatwo testowane. Do tego jest możliwe ich re-używanie. Daje nam to tyle, iż 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, iż 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 końcowym jest zwrócona wartość lub inny efekt.

Imperatywny kod cechuje:
  • zmieniający się stan podczas każdej iteracji
  • kolejność wykonywania jest ważna (step-by-step)
  • najczęściej wywołujemy jakieś funkcje, pętle, warunki

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. Generalnie 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, iż 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:
  • stan nie istnieje, immutable objects
  • kolejność wykonywania nie zawsze jest ważna (często może być asynchroniczna)

Główną różnicą jest tutaj to, iż 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.

Dwa przykłady imperatywnego/funkcyjnego kodu ?

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ąć.

Promujemy możliwe jak najmniejszą ilość efektów ubocznych oraz nie zmieniamy stanu obiektu.

2️⃣ Prosty przykład – liczby nieparzyste

Chcemy tylko nieparzyste liczby. Wrzucamy je do listy odds (ang. nieparzyste).

// Imperatywny przykład 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) } // Powyższy kod jest zbudowany z wyrażeń. // Skupiamy się tutaj na tym co robimy/chcemy zrobić. // Jest to seria mutacji oddzielonych warunkami.

// Funkcyjny przykład val numbers = listOf(1, 2, 3, 4, 5) val odds = numbers.filter { it % 2 != 0 } // Skupiamy się na tym co chcemy osiągnąć. // Do tego taki kod jest czytelniejszy.

3️⃣ Bardziej rozbudowany przykład

Po pierwsze chcemy znaleźć liczby pierwsze. PrimeFinder.isPrime(it)

Po drugie chcemy znaleźć pierwiastek kwadratowy dla k liczb pierwszych startując z n.

(Mimo, iż jest to Kotlin użyłem tutaj Javowych rzeczy – Stream.iterate, IntStream.range)

// Kotlin class PrimeFinder { companion object { fun isPrime(number: Int): Boolean { return number > 1 && IntStream.range(2, number) .noneMatch { number % it == 0 } } } } // Kotlin fun computeImperativeStyle(n: Int, k: Int): Int { var result: Double = 0.0; var index: Int = n; var count = 0; while (count < k) { if (PrimeFinder.isPrime(index)) { result += Math.sqrt(index.toDouble()) count++ } index++ } return result.toInt() } fun computeFunctionalStyle(n: Int, k: Int): Int = Stream.iterate(n, { it + 1 }) .filter { PrimeFinder.isPrime(it) } .mapToDouble { Math.sqrt(it.toDouble()) } .limit(k) .sum().toInt()

Przykład z kodem oraz testami na Githubie.

Jak napisać program bez efektu ubocznego? ?

Krótka odpowiedź. Nie da się. Chodzi nam bardziej o to, żeby nie mieć obserwowalnych efektów ubocznych. Co to znaczy? Wyjaśnimy sobie już za chwilkę. zwykle 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.

// Kotlin fun multiply(val a: Int, val b: Int) = a * b // funkcyjny - jest w miarę deterministyczny fun divide(a: Int, b: Int) = a / b // nie funkcyjny - zgłasza wyjątek multiply(123456789, 123456789) // -1757895751 divive(123456789, 0) // ArithmeticException: / by zero

W pierwszym przypadku jeżeli 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, iż funkcja pomnoży/podzieli wynik jest błędna. Oczywiście rzadko kiedy sytuacja jest, aż tak trywialna, ale można sobie wyobrazić, iż 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żeli 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 { print("You can't divide by 0") }

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żeli 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, iż metoda zawsze się nie udaje to jest Nothing. Jest to przydatne, chociażby w metodzie TODO(“”) która zwraca właśnie Nothing. pozostało 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, iż 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, iż wynikiem jest zawsze jakiś obiekt typu Double.

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

Tutaj całkiem fajne obrazki tłumaczące, czym są pure functions.

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? ?

// Na początek 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, iż 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żeli 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ą.

// Bardziej 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ż, iż 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) }

Podobny przykład można znaleźć w Java – Programowanie Funkcyjne.

Innym imperatywnym przykładem jest kod z tego wpisu.

Polecam też poświęcić godzinkę lub dwie na poniższe prezentacje

Zdjęcie Główne: Rico Reutimann on Unsplash

Idź do oryginalnego materiału