Przygotuj swój kod na Androida na przyszłość, część 1: Podstawy programowania funkcjonalnego i reaktywnego

Opublikowany: 2022-08-31

Pisanie czystego kodu może być wyzwaniem: Biblioteki, frameworki i interfejsy API są tymczasowe i szybko stają się przestarzałe. Ale matematyczne koncepcje i paradygmaty są trwałe; wymagają lat badań naukowych i mogą nawet nas przeżyć.

To nie jest samouczek pokazujący, jak zrobić X z biblioteką Y. Zamiast tego skupiamy się na trwałych zasadach programowania funkcjonalnego i reaktywnego, dzięki czemu możesz budować przyszłościową i niezawodną architekturę Androida oraz skalować i dostosowywać się do zmian bez kompromisów efektywność.

Ten artykuł kładzie fundamenty, a w części 2 zagłębimy się w implementację funkcjonalnego programowania reaktywnego (FRP), które łączy zarówno programowanie funkcjonalne, jak i reaktywne.

Ten artykuł został napisany z myślą o programistach Androida, ale koncepcje są istotne i przydatne dla każdego programisty z doświadczeniem w ogólnych językach programowania.

Programowanie funkcjonalne 101

Programowanie funkcjonalne (FP) to wzorzec, w którym budujesz swój program jako kompozycję funkcji, przekształcając dane z $A$ na $B$, na $C$ itd., aż do uzyskania pożądanego wyniku. W programowaniu obiektowym (OOP) mówisz komputerowi, co ma robić instrukcja po instrukcji. Programowanie funkcjonalne jest inne: rezygnujesz z przepływu sterowania i definiujesz „przepis funkcji”, aby zamiast tego uzyskać wynik.

Zielony prostokąt po lewej stronie z tekstem „Input: x” ma strzałkę wskazującą jasnoszary prostokąt oznaczony „Function: f”. Wewnątrz jasnoszarego prostokąta znajdują się trzy cylindry ze strzałkami skierowanymi w prawo: pierwszy jest jasnoniebieski, oznaczony „A(x),”, drugi jest ciemnoniebieski, oznaczony „B(x)”, a trzeci jest ciemnoszary, oznaczony „C (x)." Na prawo od jasnoszarego prostokąta znajduje się zielony prostokąt z tekstem „Wyjście: f(x)”. Na dole jasnoszarego prostokąta znajduje się strzałka skierowana w dół do tekstu „Efekty uboczne”.
Funkcjonalny wzorzec programowania

FP wywodzi się z matematyki, a konkretnie z rachunku lambda, logicznego systemu abstrakcji funkcji. Zamiast pojęć OOP, takich jak pętle, klasy, polimorfizm lub dziedziczenie, FP zajmuje się ściśle abstrakcją i funkcjami wyższego rzędu, funkcjami matematycznymi, które akceptują inne funkcje jako dane wejściowe.

W skrócie, FP ma dwóch głównych „graczy”: dane (model lub informacje wymagane dla twojego problemu) i funkcje (reprezentacje zachowań i transformacji między danymi). W przeciwieństwie do tego, klasy OOP wyraźnie wiążą konkretną strukturę danych specyficzną dla domeny — oraz wartości lub stan skojarzony z każdą instancją klasy — z zachowaniami (metodami), które mają być z nią używane.

Dokładniej przyjrzymy się trzem kluczowym aspektom PR:

  • FP jest deklaratywna.
  • FP wykorzystuje kompozycję funkcji.
  • Funkcje FP są czyste.

Dobrym punktem wyjścia do dalszego zanurzenia się w świecie FP jest Haskell, mocno typowany, czysto funkcjonalny język. Polecam Ucz się Haskella za wielkie dobro! interaktywny samouczek jako przydatny zasób.

Składnik PR nr 1: Programowanie deklaratywne

Pierwszą rzeczą, jaką zauważysz w programie FP, jest to, że jest napisany w stylu deklaratywnym, a nie imperatywnym. Krótko mówiąc, programowanie deklaratywne mówi programowi, co należy zrobić, a nie jak to zrobić. Oprzyjmy tę abstrakcyjną definicję na konkretnym przykładzie programowania imperatywnego i deklaratywnego, aby rozwiązać następujący problem: Mając listę imion, zwróćmy listę zawierającą tylko nazwy z co najmniej trzema samogłoskami i samogłoskami pisanymi wielkimi literami.

Rozwiązanie imperatywne

Najpierw przyjrzyjmy się imperatywnemu rozwiązaniu tego problemu w Kotlinie:

 fun namesImperative(input: List<String>): List<String> { val result = mutableListOf<String>() val vowels = listOf('A', 'E', 'I', 'O', 'U','a', 'e', 'i', 'o', 'u') for (name in input) { // loop 1 var vowelsCount = 0 for (char in name) { // loop 2 if (isVowel(char, vowels)) { vowelsCount++ if (vowelsCount == 3) { val uppercaseName = StringBuilder() for (finalChar in name) { // loop 3 var transformedChar = finalChar // ignore that the first letter might be uppercase if (isVowel(finalChar, vowels)) { transformedChar = finalChar.uppercaseChar() } uppercaseName.append(transformedChar) } result.add(uppercaseName.toString()) break } } } } return result } fun isVowel(char: Char, vowels: List<Char>): Boolean { return vowels.contains(char) } fun main() { println(namesImperative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }

Przeanalizujemy teraz nasze imperatywne rozwiązanie, mając na uwadze kilka kluczowych czynników rozwoju:

  • Najbardziej wydajne: to rozwiązanie ma optymalne wykorzystanie pamięci i dobrze sprawdza się w analizie Big O (na podstawie minimalnej liczby porównań). W tym algorytmie sensowne jest analizowanie liczby porównań między znakami, ponieważ jest to główna operacja w naszym algorytmie. Niech $n$ będzie liczbą imion, a $k$ będzie ich średnią długością.

    • Najgorsza liczba porównań: $n(10k)(10k) = 100nk^2$
    • Objaśnienie: $n$ (pętla 1) * $10k$ (dla każdego znaku porównujemy z 10 możliwymi samogłoskami) * $10k$ (wykonujemy ponownie sprawdzenie isVowel() , aby zdecydować, czy znak ma być pisany wielkimi literami — ponownie, w w najgorszym przypadku jest to porównanie z 10 samogłoskami).
    • Wynik: Ponieważ średnia długość nazwy nie będzie większa niż 100 znaków, możemy powiedzieć, że nasz algorytm działa w czasie $O(n)$ .
  • Złożone ze słabą czytelnością: W porównaniu do rozwiązania deklaratywnego, które rozważymy dalej, to rozwiązanie jest znacznie dłuższe i trudniejsze do naśladowania.
  • Podatne na błędy: kod mutuje result , vowelsCount i transformedChar ; te mutacje stanu mogą prowadzić do subtelnych błędów, takich jak zapominanie o zresetowaniu vowelsCount z powrotem do 0. Przebieg wykonywania może również stać się skomplikowany i łatwo jest zapomnieć o dodaniu instrukcji break w trzeciej pętli.
  • Słaba konserwacja: ponieważ nasz kod jest złożony i podatny na błędy, refaktoryzacja lub zmiana zachowania tego kodu może być trudna. Na przykład, gdyby problem został zmodyfikowany, aby wybrać nazwy z trzema samogłoskami i pięcioma spółgłoskami, musielibyśmy wprowadzić nowe zmienne i zmienić pętle, pozostawiając wiele okazji do błędów.

Nasze przykładowe rozwiązanie ilustruje, jak może wyglądać złożony kod imperatywny, chociaż można go ulepszyć, refaktoryzując go na mniejsze funkcje.

Rozwiązanie deklaratywne

Teraz, gdy rozumiemy, czym programowanie deklaratywne nie jest , zaprezentujmy nasze rozwiązanie deklaratywne w Kotlinie:

 fun namesDeclarative(input: List<String>): List<String> = input.filter { name -> name.count(::isVowel) >= 3 }.map { name -> name.map { char -> if (isVowel(char)) char.uppercaseChar() else char }.joinToString("") } fun isVowel(char: Char): Boolean = listOf('A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u').contains(char) fun main() { println(namesDeclarative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }

Używając tych samych kryteriów, których użyliśmy do oceny naszego imperatywnego rozwiązania, zobaczmy, jak radzi sobie kod deklaratywny:

  • Wydajna: zarówno imperatywna, jak i deklaratywna implementacja działają w czasie liniowym, ale imperatywna jest nieco bardziej wydajna, ponieważ użyłem tutaj name.count() , która będzie kontynuować liczenie samogłosek do końca nazwy (nawet po znalezieniu trzech samogłosek ). Możemy łatwo rozwiązać ten problem, pisząc prostą hasThreeVowels(String): Boolean . To rozwiązanie używa tego samego algorytmu, co rozwiązanie imperatywne, więc tutaj obowiązuje ta sama analiza złożoności: Nasz algorytm działa w czasie $O(n)$ .
  • Zwięzły z dobrą czytelnością: imperatywne rozwiązanie to 44 wiersze z dużym wcięciem w porównaniu do naszego rozwiązania deklaratywnego o długości 16 wierszy z małym wcięciem. Wiersze i tabulatory to nie wszystko, ale po spojrzeniu na dwa pliki widać, że nasze rozwiązanie deklaratywne jest znacznie bardziej czytelne.
  • Mniej podatne na błędy: w tym przykładzie wszystko jest niezmienne. Przekształcamy List<String> wszystkich nazw na List<String> nazw z trzema lub więcej samogłoskami, a następnie przekształcamy każde słowo String w słowo String z samogłoskami pisanymi wielkimi literami. Ogólnie rzecz biorąc, brak mutacji, zagnieżdżonych pętli lub przerw i rezygnacja z przepływu sterowania sprawia, że ​​kod jest prostszy i mniej miejsca na błędy.
  • Dobra łatwość konserwacji: możesz łatwo refaktoryzować kod deklaratywny ze względu na jego czytelność i niezawodność. W naszym poprzednim przykładzie (powiedzmy, że problem został zmodyfikowany, aby wybrać nazwy z trzema samogłoskami i pięcioma spółgłoskami) prostym rozwiązaniem byłoby dodanie następujących instrukcji w warunku filter : val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 .

Jako dodatkowy pozytyw, nasze deklaratywne rozwiązanie jest czysto funkcjonalne: każda funkcja w tym przykładzie jest czysta i nie ma skutków ubocznych. (Więcej o czystości później.)

Bonusowe rozwiązanie deklaratywne

Przyjrzyjmy się deklaratywnej implementacji tego samego problemu w czysto funkcjonalnym języku, takim jak Haskell, aby zademonstrować, jak to czyta. Jeśli nie znasz Haskella, pamiętaj, że . operator w Haskell brzmi „po”. Na przykład solution = map uppercaseVowels . filter hasThreeVowels solution = map uppercaseVowels . filter hasThreeVowels przekłada się na „mapowanie samogłosek na wielkie litery po przefiltrowaniu nazw, które mają trzy samogłoski”.

 import Data.Char(toUpper) namesSolution :: [String] -> [String] namesSolution = map uppercaseVowels . filter hasThreeVowels hasThreeVowels :: String -> Bool hasThreeVowels s = count isVowel s >= 3 uppercaseVowels :: String -> String uppercaseVowels = map uppercaseVowel where uppercaseVowel :: Char -> Char uppercaseVowel c | isVowel c = toUpper c | otherwise = c isVowel :: Char -> Bool isVowel c = c `elem` vowels vowels :: [Char] vowels = ['A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u'] count :: (a -> Bool) -> [a] -> Int count _ [] = 0 count pred (x:xs) | pred x = 1 + count pred xs | otherwise = count pred xs main :: IO () main = print $ namesSolution ["Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"] -- ["IlIyAn","AnnAbEl","NIcOlE"]

To rozwiązanie działa podobnie do naszego deklaratywnego rozwiązania Kotlin, z pewnymi dodatkowymi korzyściami: jest czytelne, proste, jeśli rozumiesz składnię Haskella, czysto funkcjonalne i leniwe.

Kluczowe dania na wynos

Programowanie deklaratywne jest przydatne zarówno dla FP, jak i programowania reaktywnego (które omówimy w dalszej części).

  • Opisuje „co” chcesz osiągnąć, a nie „jak” to osiągnąć, z dokładną kolejnością wykonywania oświadczeń.
  • Abstrahuje przepływ sterowania programu i zamiast tego skupia się na problemie w kategoriach przekształceń (tj. $A \rightarrow B \rightarrow C \rightarrow D$).
  • Zachęca do mniej złożonego, bardziej zwięzłego i bardziej czytelnego kodu, który jest łatwiejszy do refaktoryzacji i zmiany. Jeśli Twój kod Androida nie czyta się jak zdanie, prawdopodobnie robisz coś nie tak.

Jeśli Twój kod Androida nie czyta się jak zdanie, prawdopodobnie robisz coś nie tak.

Ćwierkać

Programowanie deklaratywne ma jednak pewne wady. Możliwe jest uzyskanie nieefektywnego kodu, który zużywa więcej pamięci RAM i działa gorzej niż implementacja imperatywna. Sortowanie, propagacja wsteczna (w uczeniu maszynowym) i inne „algorytmy mutujące” nie pasują do niezmiennego, deklaratywnego stylu programowania.

Składnik FP #2: Skład funkcji

Kompozycja funkcji jest pojęciem matematycznym leżącym u podstaw programowania funkcjonalnego. Jeśli funkcja $f$ akceptuje $A$ jako swoje wejście i generuje $B$ jako swoje wyjście ($f: A \rightarrow B$), a funkcja $g$ akceptuje $B$ i generuje $C$ ($g: B \rightarrow C$), wtedy można utworzyć trzecią funkcję, $h$, która przyjmuje $A$ i generuje $C$ ($h: A \rightarrow C$). Tę trzecią funkcję możemy zdefiniować jako kompozycję $g$ z $f$, również oznaczoną jako $g \circ f$ lub $g(f())$:

Niebieskie pole oznaczone „A” ma strzałkę „f” wskazującą na niebieskie pole oznaczone „B”, które ma strzałkę „g” wskazującą na niebieskie pole oznaczone „C”. W polu „A” znajduje się również równoległa strzałka „g o f”, wskazująca bezpośrednio na pole „C”.
Funkcje f, g i h, złożenie g z f.

Każde rozwiązanie imperatywne można przełożyć na rozwiązanie deklaratywne, rozkładając problem na mniejsze problemy, rozwiązując je niezależnie i ponownie składając mniejsze rozwiązania w rozwiązanie ostateczne poprzez złożenie funkcji. Przyjrzyjmy się naszemu problemowi z nazwami z poprzedniej sekcji, aby zobaczyć ten koncept w działaniu. Nasze mniejsze problemy z rozwiązania imperatywnego to:

  1. isVowel :: Char -> Bool : Biorąc pod uwagę Char , zwróć czy jest to samogłoska czy nie ( Bool ).
  2. countVowels :: String -> Int : Podany String , zwraca liczbę zawartych w nim samogłosek ( Int ).
  3. hasThreeVowels :: String -> Bool : Podany String , zwróć czy zawiera co najmniej trzy samogłoski ( Bool ).
  4. uppercaseVowels :: String -> String : Podany String , zwraca nowy String z samogłoskami pisanymi wielkimi literami.

Naszym deklaratywnym rozwiązaniem, uzyskanym poprzez złożenie funkcji, jest map uppercaseVowels . filter hasThreeVowels map uppercaseVowels . filter hasThreeVowels .

Górny diagram ma trzy niebieskie pola „[Ciąg]” połączone strzałkami skierowanymi w prawo. Pierwsza strzałka jest oznaczona jako „filter has3Vowels”, a druga jest oznaczona jako „map uppercaseVowels”. Poniżej drugi diagram ma dwa niebieskie prostokąty po lewej, „Char” u góry i „String” poniżej, wskazujące na niebieskie pole po prawej „Bool”. Strzałka od „Char” do „Bool” ma etykietę „isVowel”, a strzałka od „String” do „Bool” ma etykietę „has3Vowels”. W polu „Ciąg” znajduje się również strzałka wskazująca na siebie, oznaczona jako „wielkie samogłoski”.
Przykład kompozycji funkcji z użyciem naszych nazw problem.

Ten przykład jest nieco bardziej skomplikowany niż prosta formuła $A \rightarrow B \rightarrow C$, ale pokazuje zasadę tworzenia funkcji.

Kluczowe dania na wynos

Kompozycja funkcji to prosta, ale potężna koncepcja.

  • Zapewnia strategię rozwiązywania złożonych problemów, w której problemy są dzielone na mniejsze, prostsze kroki i łączone w jedno rozwiązanie.
  • Dostarcza cegiełki, dzięki czemu możesz łatwo dodawać, usuwać lub zmieniać części ostatecznego rozwiązania, nie martwiąc się, że coś zepsujesz.
  • Możesz skomponować $g(f())$, jeśli wyjście $f$ pasuje do typu wejściowego $g$.

Podczas tworzenia funkcji można przekazywać nie tylko dane, ale także funkcje jako dane wejściowe do innych funkcji — przykład funkcji wyższego rzędu.

Składnik PR nr 3: Czystość

Jest jeszcze jeden kluczowy element tworzenia funkcji, którym musimy się zająć: Funkcje, które tworzysz, muszą być czyste , kolejne pojęcie wywodzące się z matematyki. W matematyce wszystkie funkcje są obliczeniami, które zawsze dają te same dane wyjściowe, gdy są wywoływane z tymi samymi danymi wejściowymi; to jest podstawa czystości.

Spójrzmy na przykład pseudokodu z użyciem funkcji matematycznych. Załóżmy, że mamy funkcję makeEven , która podwaja liczbę całkowitą, aby uczynić ją parzystą, i że nasz kod wykonuje wiersz makeEven(x) + x przy użyciu wejścia x = 2 . W matematyce to obliczenie zawsze przekłada się na obliczenie 2x + x = 3x = 3(2) = 6 $ i jest czystą funkcją. Jednak nie zawsze jest to prawdą w programowaniu — jeśli funkcja makeEven(x) x przez podwojenie go, zanim kod zwróci nasz wynik, wtedy nasza linia obliczy 2x + (2x) = 4x = 4(2) = 8 $ a co gorsza, wynik zmieniał się z każdym wywołaniem makeEven .

Przyjrzyjmy się kilku typom funkcji, które nie są czyste, ale pomogą nam dokładniej zdefiniować czystość:

  • Funkcje częściowe: Są to funkcje, które nie są zdefiniowane dla wszystkich wartości wejściowych, takich jak dzielenie. Z perspektywy programowania są to funkcje, które zgłaszają wyjątek: fun divide(a: Int, b: Int): Float wyrzuci wyjątek ArithmeticException dla wejścia b = 0 spowodowany dzieleniem przez zero.
  • Funkcje sumy: te funkcje są zdefiniowane dla wszystkich wartości wejściowych, ale mogą generować różne dane wyjściowe lub efekty uboczne, gdy są wywoływane z tymi samymi danymi wejściowymi. Świat Androida jest pełen wszystkich funkcji: Log.d , LocalDateTime.now i Locale.getDefault to tylko kilka przykładów.

Mając na uwadze te definicje, możemy zdefiniować czyste funkcje jako funkcje całkowite bez skutków ubocznych. Kompozycje funkcji zbudowane przy użyciu tylko czystych funkcji dają bardziej niezawodny, przewidywalny i testowalny kod.

Wskazówka: Aby funkcja total była czysta, możesz wyabstrahować jej skutki uboczne, przekazując je jako parametr funkcji wyższego rzędu. W ten sposób możesz łatwo przetestować wszystkie funkcje, przekazując fałszywą funkcję wyższego rzędu. W tym przykładzie użyto adnotacji @SideEffect z biblioteki, którą zbadamy w dalszej części samouczka, Ivy FRP:

 suspend fun deadlinePassed( deadline: LocalDate, @SideEffect currentDate: suspend () -> LocalDate ): Boolean = deadline.isAfter(currentDate())

Kluczowe dania na wynos

Czystość jest ostatnim składnikiem wymaganym w paradygmacie programowania funkcjonalnego.

  • Uważaj na częściowe funkcje — mogą spowodować awarię Twojej aplikacji.
  • Składanie funkcji całkowitych nie jest deterministyczne; może powodować nieprzewidywalne zachowanie.
  • Jeśli to możliwe, pisz czyste funkcje. Skorzystasz ze zwiększonej stabilności kodu.

Po zakończeniu naszego przeglądu programowania funkcjonalnego przyjrzyjmy się kolejnemu komponentowi przyszłościowego kodu Androida: programowaniu reaktywnemu.

Programowanie reaktywne 101

Programowanie reaktywne to deklaratywny wzorzec programowania, w którym program reaguje na zmiany danych lub zdarzeń zamiast żądania informacji o zmianach.

Dwa główne niebieskie pola, „Obserwowalny” i „Stan”, mają między sobą dwie główne ścieżki. Pierwszy to „Obserwuje (nasłuchuje zmian)”. Drugi to „Powiadomienia (o najnowszym stanie)” do niebieskiego pola „UI (API w zapleczu)”, które przechodzi przez „Przekształca dane wprowadzone przez użytkownika na” do niebieskiego pola „Zdarzenie”, które przechodzi przez „Wyzwalacze” na niebieski pole „Kompozycja funkcji”, a na końcu przez „Produkty (nowy stan)”. Następnie „Stan” łączy się z powrotem z „Kompozycjami funkcji” poprzez „Działa jako dane wejściowe dla”.
Ogólny cykl programowania reaktywnego.

Podstawowymi elementami w reaktywnym cyklu programowania są zdarzenia, potok deklaratywny, stany i obserwowalne:

  • Zdarzenia to sygnały ze świata zewnętrznego, zwykle w postaci danych wejściowych użytkownika lub zdarzeń systemowych, które wyzwalają aktualizacje. Celem zdarzenia jest przekształcenie sygnału na wejście potoku.
  • Potok deklaratywny to kompozycja funkcji, która akceptuje (Event, State) jako dane wejściowe i przekształca je w nowy State (wyjście): (Event, State) -> f -> g -> … -> n -> State . Potoki muszą działać asynchronicznie, aby obsługiwać wiele zdarzeń bez blokowania innych potoków lub oczekiwania na ich zakończenie.
  • Stany są reprezentacją modelu danych aplikacji oprogramowania w danym momencie. Logika domeny używa stanu do obliczenia żądanego następnego stanu i dokonania odpowiednich aktualizacji.
  • Obserwable nasłuchują zmian stanu i aktualizują subskrybentów o tych zmianach. W systemie Android obserwable są zwykle implementowane przy użyciu Flow , LiveData lub RxJava i powiadamiają interfejs użytkownika o aktualizacjach stanu, aby mógł odpowiednio zareagować.

Istnieje wiele definicji i implementacji programowania reaktywnego. Tutaj przyjąłem pragmatyczne podejście skoncentrowane na zastosowaniu tych koncepcji w rzeczywistych projektach.

Łączenie kropek: funkcjonalne programowanie reaktywne

Programowanie funkcjonalne i reaktywne to dwa potężne paradygmaty. Te koncepcje wykraczają poza krótkotrwałą żywotność bibliotek i interfejsów API i będą zwiększać Twoje umiejętności programowania na nadchodzące lata.

Co więcej, moc FP i reaktywnego programowania mnoży się w połączeniu. Teraz, gdy mamy jasne definicje programowania funkcjonalnego i reaktywnego, możemy złożyć elementy razem. W części 2 tego samouczka zdefiniujemy paradygmat programowania reaktywnego funkcjonalnego (FRP) i zastosujemy go w praktyce za pomocą przykładowej implementacji aplikacji i odpowiednich bibliotek systemu Android.

Blog Toptal Engineering wyraża wdzięczność Tarunowi Goyalowi za przejrzenie próbek kodu przedstawionych w tym artykule.