Protejează viitorul codul tău Android, Partea 1: Bazele programării funcționale și reactive

Publicat: 2022-08-31

Scrierea codului curat poate fi o provocare: bibliotecile, cadrele și API-urile sunt temporare și devin depășite rapid. Dar conceptele și paradigmele matematice sunt de durată; necesită ani de cercetare academică și poate chiar să ne supraviețuiască.

Acesta nu este un tutorial care să vă arate cum să faceți X cu Biblioteca Y. În schimb, ne concentrăm pe principiile de durată din spatele programării funcționale și reactive, astfel încât să puteți construi arhitectură Android sigură și sigură pentru viitor și să scalați și să vă adaptați la schimbări fără a face compromisuri. eficienţă.

Acest articol pune bazele, iar în partea a 2-a, ne vom scufunda într-o implementare a programării funcționale reactive (FRP), care combină atât programarea funcțională, cât și programarea reactivă.

Acest articol este scris având în vedere dezvoltatorii Android, dar conceptele sunt relevante și benefice pentru orice dezvoltator cu experiență în limbaje de programare generale.

Programare funcțională 101

Programarea funcțională (FP) este un model în care vă construiți programul ca o compoziție de funcții, transformând datele de la $A$ la $B$, la $C$ etc., până când se obține rezultatul dorit. În programarea orientată pe obiecte (OOP), îi spuneți computerului ce să facă instrucțiuni cu instrucțiuni. Programarea funcțională este diferită: renunțați la fluxul de control și definiți o „rețetă de funcții” pentru a produce rezultatul.

Un dreptunghi verde din stânga cu textul „Intrare: x” are o săgeată care indică un dreptunghi gri deschis etichetat „Funcție: f”. În interiorul dreptunghiului gri deschis, există trei cilindri cu săgeți îndreptate spre dreapta: primul este albastru deschis etichetat „A(x),” al doilea este albastru închis etichetat „B(x)”, iar al treilea este gri închis etichetat „C”. (X)." În dreapta dreptunghiului gri deschis, există un dreptunghi verde cu textul „Ieșire: f(x).” Partea de jos a dreptunghiului gri deschis are o săgeată îndreptată în jos către textul „Efecte secundare”.
Modelul de programare funcțional

FP provine din matematică, în special calculul lambda, un sistem logic de abstractizare a funcțiilor. În loc de concepte OOP, cum ar fi bucle, clase, polimorfism sau moștenire, FP se ocupă strict de abstractizare și funcții de ordin superior, funcții matematice care acceptă alte funcții ca intrare.

Pe scurt, FP are doi „jucători” majori: date (modelul sau informațiile necesare pentru problema dvs.) și funcții (reprezentări ale comportamentului și transformărilor dintre date). În schimb, clasele OOP leagă în mod explicit o anumită structură de date specifică domeniului - și valorile sau starea asociată fiecărei instanțe de clasă - de comportamente (metode) care sunt destinate să fie utilizate cu aceasta.

Vom examina mai îndeaproape trei aspecte cheie ale FP:

  • FP este declarativ.
  • FP folosește compoziția funcției.
  • Funcțiile FP sunt pure.

Un bun loc de plecare pentru a te scufunda mai departe în lumea FP este Haskell, un limbaj puternic tipizat, pur funcțional. Recomand Learn You a Haskell pentru un bine! tutorial interactiv ca resursă benefică.

FP Ingredient #1: Programare Declarativă

Primul lucru pe care îl veți observa despre un program FP este că este scris în stil declarativ, spre deosebire de imperativ. Pe scurt, programarea declarativă spune unui program ce trebuie făcut în loc de cum să o facă. Să întemeiem această definiție abstractă cu un exemplu concret de programare imperativă versus declarativă pentru a rezolva următoarea problemă: Având în vedere o listă de nume, returnați o listă care conține numai numele cu cel puțin trei vocale și cu vocalele afișate cu litere mari.

Soluție imperativă

Mai întâi, să examinăm soluția imperativă a acestei probleme în Kotlin:

 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] }

Acum vom analiza soluția noastră imperativă ținând cont de câțiva factori cheie de dezvoltare:

  • Cel mai eficient: această soluție are o utilizare optimă a memoriei și funcționează bine în analiza Big O (pe baza unui număr minim de comparații). În acest algoritm, are sens să analizăm numărul de comparații între caractere, deoarece aceasta este operația predominantă în algoritmul nostru. Fie $n$ numărul de nume și $k$ lungimea medie a numelor.

    • Numărul de comparații în cel mai rău caz: $n(10k)(10k) = 100nk^2$
    • Explicație: $n$ (bucla 1) * $10k$ (pentru fiecare caracter, comparăm cu 10 vocale posibile) * $10k$ (executăm din nou verificarea isVowel() pentru a decide dacă caracterul este cu majuscule — din nou, în cel mai rău caz, aceasta se compară cu 10 vocale).
    • Rezultat: Deoarece lungimea medie a numelui nu va fi mai mare de 100 de caractere, putem spune că algoritmul nostru rulează în $O(n)$ timp.
  • Complex cu lizibilitate slabă: în comparație cu soluția declarativă pe care o vom lua în considerare în continuare, această soluție este mult mai lungă și mai greu de urmărit.
  • predispus la erori: codul modifică result , vowelsCount și transformedChar ; aceste mutații de stare pot duce la erori subtile, cum ar fi uitarea de a reseta vowelsCount înapoi la 0. Fluxul de execuție poate deveni, de asemenea, complicat și este ușor să uitați să adăugați instrucțiunea break în a treia buclă.
  • Menținere slabă: deoarece codul nostru este complex și predispus la erori, refactorizarea sau modificarea comportamentului acestui cod poate fi dificilă. De exemplu, dacă problema a fost modificată pentru a selecta nume cu trei vocale și cinci consoane, ar trebui să introducem noi variabile și să schimbăm buclele, lăsând multe oportunități pentru bug-uri.

Exemplul nostru de soluție ilustrează cât de complex ar putea arăta codul imperativ, deși ați putea îmbunătăți codul prin refactorizarea lui în funcții mai mici.

Soluție declarativă

Acum că înțelegem ce nu este programarea declarativă, să dezvăluim soluția noastră declarativă în Kotlin:

 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] }

Folosind aceleași criterii pe care le-am folosit pentru a evalua soluția noastră imperativă, să vedem cum rezistă codul declarativ:

  • Eficient: Implementările imperative și declarative rulează ambele în timp liniar, dar cea imperativă este puțin mai eficientă, deoarece am folosit name.count() aici, care va continua să numere vocalele până la sfârșitul numelui (chiar și după găsirea a trei vocale). ). Putem rezolva cu ușurință această problemă scriind o funcție simplă hasThreeVowels(String): Boolean . Această soluție folosește același algoritm ca și soluția imperativă, astfel încât aceeași analiză de complexitate se aplică aici: Algoritmul nostru rulează în $O(n)$ timp.
  • Concis, cu o bună lizibilitate: Soluția imperativă este de 44 de linii cu indentare mare în comparație cu lungimea soluției noastre declarative de 16 rânduri cu indentație mică. Liniile și file-urile nu sunt totul, dar este evident dintr-o privire asupra celor două fișiere că soluția noastră declarativă este mult mai lizibilă.
  • Mai puțin predispus la erori: în acest eșantion, totul este imuabil. Transformăm o List<String> cu toate numele într-o List<String> de nume cu trei sau mai multe vocale și apoi transformăm fiecare cuvânt String într-un cuvânt String cu vocale majuscule. În general, fără mutații, bucle imbricate sau întreruperi și renunțarea la fluxul de control face codul mai simplu, cu mai puțin spațiu pentru erori.
  • Menținere bună: puteți refactoriza cu ușurință codul declarativ datorită lizibilității și robusteței sale. În exemplul nostru anterior (să presupunem că problema a fost modificată pentru a selecta nume cu trei vocale și cinci consoane), o soluție simplă ar fi adăugarea următoarelor afirmații în condiția de filter : val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 .

Ca un plus pozitiv, soluția noastră declarativă este pur funcțională: fiecare funcție din acest exemplu este pură și nu are efecte secundare. (Mai multe despre puritate mai târziu.)

Soluție declarativă bonus

Să aruncăm o privire la implementarea declarativă a aceleiași probleme într-un limbaj pur funcțional precum Haskell pentru a demonstra cum se citește. Dacă nu sunteți familiarizat cu Haskell, rețineți că . operatorul din Haskell se citește „după”. De exemplu, solution = map uppercaseVowels . filter hasThreeVowels solution = map uppercaseVowels . filter hasThreeVowels se traduce prin „mapează vocalele cu majuscule după filtrarea numelor care au trei vocale”.

 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"]

Această soluție funcționează similar cu soluția noastră declarativă Kotlin, cu câteva beneficii suplimentare: este lizibilă, simplă dacă înțelegeți sintaxa lui Haskell, pur funcțională și leneșă.

Recomandări cheie

Programarea declarativă este utilă atât pentru FP, cât și pentru programarea reactivă (pe care o vom acoperi într-o secțiune ulterioară).

  • Descrie „ce” vrei să obții – mai degrabă decât „cum” să obții, cu ordinea exactă de execuție a declarațiilor.
  • Abstrage fluxul de control al unui program și se concentrează în schimb asupra problemei în termeni de transformări (adică $A \rightarrow B \rightarrow C \rightarrow D$).
  • Încurajează un cod mai puțin complex, mai concis și mai ușor de citit, care este mai ușor de refactorizat și schimbat. Dacă codul tău Android nu se citește ca o propoziție, probabil că faci ceva greșit.

Dacă codul tău Android nu se citește ca o propoziție, probabil că faci ceva greșit.

Tweet

Totuși, programarea declarativă are anumite dezavantaje. Este posibil să ajungeți la un cod ineficient care consumă mai multă RAM și are performanțe mai slabe decât o implementare imperativă. Sortarea, propagarea inversă (în învățarea automată) și alți „algoritmi de mutare” nu sunt potrivite pentru stilul de programare imuabil și declarativ.

FP Ingredient #2: Compoziția funcției

Compoziția funcțiilor este conceptul matematic din centrul programării funcționale. Dacă funcția $f$ acceptă $A$ ca intrare și produce $B$ ca ieșire ($f: A \rightarrow B$), iar funcția $g$ acceptă $B$ și produce $C$ ($g: B \rightarrow C$), apoi puteți crea o a treia funcție, $h$, care acceptă $A$ și produce $C$ ($h: A \rightarrow C$). Putem defini această a treia funcție ca fiind compoziția lui $g$ cu $f$, notată și ca $g \circ f$ sau $g(f())$:

O casetă albastră etichetată „A” are o săgeată, „f”, care indică o casetă albastră etichetată „B” care are o săgeată, „g”, care indică către o casetă albastră etichetată „C”. Caseta „A” are, de asemenea, o săgeată paralelă, „g o f”, care arată direct către caseta „C”.
Funcțiile f, g și h, compoziția lui g cu f.

Fiecare soluție imperativă poate fi tradusă într-una declarativă prin descompunerea problemei în probleme mai mici, rezolvându-le independent și recompunând soluțiile mai mici în soluția finală prin compoziția funcției. Să ne uităm la problema numelor din secțiunea anterioară pentru a vedea acest concept în acțiune. Problemele noastre mai mici din soluția imperativă sunt:

  1. isVowel :: Char -> Bool : dat un Char , returnează dacă este sau nu o vocală ( Bool ).
  2. countVowels :: String -> Int : dat un String , returnează numărul de vocale din acesta ( Int ).
  3. hasThreeVowels :: String -> Bool : dat un String , returnează dacă are cel puțin trei vocale ( Bool ).
  4. uppercaseVowels :: String -> String : dat un String , returnați un String nou cu vocale majuscule.

Soluția noastră declarativă, obținută prin compoziția funcției, este map uppercaseVowels . filter hasThreeVowels map uppercaseVowels . filter hasThreeVowels .

O diagramă de sus are trei casete albastre „[String]” conectate prin săgeți îndreptate spre dreapta. Prima săgeată este etichetată „filtru are3Vowels”, iar a doua este etichetată „hartă majusculeVowels”. Mai jos, o a doua diagramă are două casete albastre în stânga, „Char” în partea de sus și „String” dedesubt, indicând o casetă albastră din dreapta, „Bool”. Săgeata de la „Char” la „Bool” este etichetată „isVowel”, iar săgeata de la „String” la „Bool” este etichetată „has3Vowels”. Caseta „Șir” are, de asemenea, o săgeată care indică spre ea însăși etichetată „Vocale majuscule”.
Un exemplu de compunere a funcțiilor folosind problema noastră de nume.

Acest exemplu este puțin mai complicat decât o simplă formulă $A \rightarrow B \rightarrow C$, dar demonstrează principiul din spatele compoziției funcției.

Recomandări cheie

Compoziția funcției este un concept simplu, dar puternic.

  • Acesta oferă o strategie pentru rezolvarea problemelor complexe în care problemele sunt împărțite în pași mai mici, mai simpli și combinate într-o singură soluție.
  • Oferă blocuri de construcție, permițându-vă să adăugați, să eliminați sau să schimbați cu ușurință părți ale soluției finale fără să vă faceți griji că nu spargeți ceva.
  • Puteți compune $g(f())$ dacă rezultatul lui $f$ se potrivește cu tipul de intrare $g$.

Când compuneți funcții, puteți transmite nu numai date, ci și funcții ca intrare către alte funcții - un exemplu de funcții de ordin superior.

FP Ingredient #3: Puritate

Mai există un element cheie al compoziției funcțiilor pe care trebuie să-l abordăm: funcțiile pe care le compuneți trebuie să fie pure , un alt concept derivat din matematică. În matematică, toate funcțiile sunt calcule care produc întotdeauna aceeași ieșire atunci când sunt apelate cu aceeași intrare; aceasta este baza purității.

Să ne uităm la un exemplu de pseudocod folosind funcții matematice. Să presupunem că avem o funcție, makeEven , care dublează o intrare întreagă pentru a o face pare și că codul nostru execută linia makeEven(x) + x folosind intrarea x = 2 . În matematică, acest calcul s-ar traduce întotdeauna într-un calcul de $2x + x = 3x = 3(2) = 6$ și este o funcție pură. Totuși, acest lucru nu este întotdeauna adevărat în programare - dacă funcția makeEven(x) a mutat x prin dublarea acestuia înainte ca codul să returneze rezultatul nostru, atunci linia noastră ar calcula $2x + (2x) = 4x = 4(2) = 8$ și, chiar mai rău, rezultatul s-ar schimba cu fiecare apel makeEven .

Să explorăm câteva tipuri de funcții care nu sunt pure, dar ne vor ajuta să definim puritatea mai precis:

  • Funcții parțiale: acestea sunt funcții care nu sunt definite pentru toate valorile de intrare, cum ar fi diviziunea. Din perspectiva programării, acestea sunt funcții care generează o excepție: fun divide(a: Int, b: Int): Float va arunca o ArithmeticException pentru intrarea b = 0 cauzată de împărțirea la zero.
  • Funcții totale: Aceste funcții sunt definite pentru toate valorile de intrare, dar pot produce o ieșire diferită sau efecte secundare atunci când sunt apelate cu aceeași intrare. Lumea Android este plină de funcții totale: Log.d , LocalDateTime.now și Locale.getDefault sunt doar câteva exemple.

Având în vedere aceste definiții, putem defini funcțiile pure ca funcții totale fără efecte secundare. Compozițiile de funcții create folosind numai funcții pure produc cod mai fiabil, previzibil și testabil.

Sfat: Pentru a face o funcție totală pură, puteți abstra efectele secundare trecându-le ca parametru de funcție de ordin superior. În acest fel, puteți testa cu ușurință funcțiile totale trecând o funcție de ordin superior batjocorită. Acest exemplu folosește adnotarea @SideEffect dintr-o bibliotecă pe care o examinăm mai târziu în tutorial, Ivy FRP:

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

Recomandări cheie

Puritatea este ingredientul final necesar pentru paradigma de programare funcțională.

  • Fiți atenți la funcțiile parțiale – acestea vă pot bloca aplicația.
  • Alcătuirea funcțiilor totale nu este deterministă; poate produce un comportament imprevizibil.
  • Ori de câte ori este posibil, scrieți funcții pure. Veți beneficia de o stabilitate sporită a codului.

Odată cu finalizarea prezentării noastre de ansamblu asupra programării funcționale, să examinăm următoarea componentă a codului Android de viitor: programarea reactivă.

Programare reactivă 101

Programarea reactivă este un model de programare declarativ în care programul reacționează la modificări de date sau evenimente în loc să solicite informații despre modificări.

Două casete albastre principale, „Observabil” și „Stat”, au două căi principale între ele. Primul este prin „Observă (ascultă modificări).” Al doilea este prin „Notifică (de ultima stare)” la caseta albastră „UI (API în back-end)”, care trece prin „Transformă intrarea utilizatorului în” la caseta albastră „Eveniment”, care trece prin „Declanșatoare” în albastru. caseta „Compoziția funcției” și, în sfârșit, prin „Produce (stare nouă).” „State” se conectează apoi înapoi la „Compoziția funcției” prin „Acţionează ca intrare pentru”.
Ciclul general de programare reactivă.

Elementele de bază într-un ciclu de programare reactivă sunt evenimentele, conducta declarativă, stările și observabilele:

  • Evenimentele sunt semnale din lumea exterioară, de obicei sub formă de intrare de utilizator sau evenimente de sistem, care declanșează actualizări. Scopul unui eveniment este de a transforma un semnal în intrare de conductă.
  • Conducta declarativă este o compoziție de funcție care acceptă (Event, State) ca intrare și transformă această intrare într-o State nouă (ieșire): (Event, State) -> f -> g -> … -> n -> State . Conductele trebuie să funcționeze asincron pentru a gestiona mai multe evenimente fără a bloca alte conducte sau a aștepta ca acestea să se termine.
  • Statele sunt reprezentarea modelului de date a aplicației software la un moment dat în timp. Logica domeniului folosește starea pentru a calcula următoarea stare dorită și pentru a face actualizările corespunzătoare.
  • Observatorii ascultă modificările de stare și informează abonații cu privire la aceste modificări. În Android, observabilele sunt de obicei implementate folosind Flow , LiveData sau RxJava și notifică interfața de utilizare cu privire la actualizările de stare, astfel încât să poată reacționa în consecință.

Există multe definiții și implementări ale programării reactive. Aici, am adoptat o abordare pragmatică concentrată pe aplicarea acestor concepte în proiecte reale.

Conectarea punctelor: programare reactivă funcțională

Programarea funcțională și reactivă sunt două paradigme puternice. Aceste concepte depășesc durata de viață scurtă a bibliotecilor și API-urilor și vă vor îmbunătăți abilitățile de programare pentru anii următori.

Mai mult, puterea FP și a programării reactive se înmulțește atunci când sunt combinate. Acum că avem definiții clare ale programării funcționale și reactive, putem pune piesele împreună. În partea 2 a acestui tutorial, definim paradigma de programare reactivă funcțională (FRP) și o punem în practică cu un exemplu de implementare a aplicației și biblioteci Android relevante.

Blogul Toptal Engineering își exprimă recunoștința lui Tarun Goyal pentru revizuirea mostrelor de cod prezentate în acest articol.