Przygotuj swój kod na Androida na przyszłość, część 1: Podstawy programowania funkcjonalnego i reaktywnego
Opublikowany: 2022-08-31Pisanie 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.
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
itransformedChar
; te mutacje stanu mogą prowadzić do subtelnych błędów, takich jak zapominanie o zresetowaniuvowelsCount
z powrotem do 0. Przebieg wykonywania może również stać się skomplikowany i łatwo jest zapomnieć o dodaniu instrukcjibreak
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 naList<String>
nazw z trzema lub więcej samogłoskami, a następnie przekształcamy każde słowoString
w słowoString
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())$:
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:
-
isVowel :: Char -> Bool
: Biorąc pod uwagęChar
, zwróć czy jest to samogłoska czy nie (Bool
). -
countVowels :: String -> Int
: PodanyString
, zwraca liczbę zawartych w nim samogłosek (Int
). -
hasThreeVowels :: String -> Bool
: PodanyString
, zwróć czy zawiera co najmniej trzy samogłoski (Bool
). -
uppercaseVowels :: String -> String
: PodanyString
, zwraca nowyString
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
.
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ątekArithmeticException
dla wejściab = 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
iLocale.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.
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 nowyState
(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
lubRxJava
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.