A prova di futuro il tuo codice Android, parte 1: basi di programmazione funzionale e reattiva

Pubblicato: 2022-08-31

Scrivere codice pulito può essere impegnativo: librerie, framework e API sono temporanei e diventano rapidamente obsoleti. Ma i concetti ei paradigmi matematici sono duraturi; richiedono anni di ricerca accademica e potrebbero persino sopravvivere a noi.

Questo non è un tutorial per mostrarti come fare X con la libreria Y. Invece, ci concentriamo sui principi duraturi alla base della programmazione funzionale e reattiva in modo da poter costruire un'architettura Android a prova di futuro e affidabile, e ridimensionare e adattarsi ai cambiamenti senza scendere a compromessi efficienza.

Questo articolo getta le basi e, nella parte 2, ci addentreremo in un'implementazione della programmazione reattiva funzionale (FRP), che combina sia la programmazione funzionale che quella reattiva.

Questo articolo è stato scritto pensando agli sviluppatori Android, ma i concetti sono rilevanti e vantaggiosi per qualsiasi sviluppatore con esperienza nei linguaggi di programmazione generali.

Programmazione Funzionale 101

La programmazione funzionale (FP) è un modello in cui costruisci il tuo programma come una composizione di funzioni, trasformando i dati da $A$ a $B$, a $C$, ecc., fino a ottenere l'output desiderato. Nella programmazione orientata agli oggetti (OOP), dici al computer cosa fare istruzione per istruzione. La programmazione funzionale è diversa: rinunci al flusso di controllo e definisci una "ricetta di funzioni" per produrre invece il tuo risultato.

Un rettangolo verde a sinistra con il testo "Input: x" ha una freccia che punta a un rettangolo grigio chiaro etichettato "Funzione: f". All'interno del rettangolo grigio chiaro, ci sono tre cilindri con le frecce che puntano a destra: il primo è azzurro etichettato "A(x)," il secondo è blu scuro etichettato "B(x)" e il terzo è grigio scuro etichettato "C (X)." A destra del rettangolo grigio chiaro, c'è un rettangolo verde con il testo "Output: f(x).". La parte inferiore del rettangolo grigio chiaro ha una freccia che punta verso il basso verso il testo "Effetti collaterali".
Il modello di programmazione funzionale

FP ha origine dalla matematica, in particolare dal calcolo lambda, un sistema logico di astrazione di funzioni. Invece di concetti OOP come cicli, classi, polimorfismo o ereditarietà, FP si occupa rigorosamente di astrazione e funzioni di ordine superiore, funzioni matematiche che accettano altre funzioni come input.

In poche parole, FP ha due "giocatori" principali: i dati (il modello, o le informazioni richieste per il tuo problema) e le funzioni (rappresentazioni del comportamento e trasformazioni tra i dati). Al contrario, le classi OOP legano esplicitamente una particolare struttura dati specifica del dominio e i valori o lo stato associati a ciascuna istanza di classe a comportamenti (metodi) che devono essere utilizzati con essa.

Esamineremo più da vicino tre aspetti chiave della FP:

  • FP è dichiarativo.
  • FP utilizza la composizione della funzione.
  • Le funzioni FP sono pure.

Un buon punto di partenza per immergersi ulteriormente nel mondo FP è Haskell, un linguaggio fortemente tipizzato e puramente funzionale. Raccomando Learn You a Haskell for Great Good! tutorial interattivo come risorsa utile.

Ingrediente FP n. 1: Programmazione dichiarativa

La prima cosa che noterai di un programma FP è che è scritto in uno stile dichiarativo, anziché imperativo. In breve, la programmazione dichiarativa dice a un programma cosa deve essere fatto invece di come farlo. Basiamo questa definizione astratta con un esempio concreto di programmazione imperativa contro dichiarativa per risolvere il seguente problema: Data una lista di nomi, restituire una lista contenente solo i nomi con almeno tre vocali e con le vocali mostrate in lettere maiuscole.

Soluzione imperativa

Innanzitutto, esaminiamo la soluzione imperativa di questo problema in 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] }

Analizzeremo ora la nostra soluzione imperativa tenendo presenti alcuni fattori chiave di sviluppo:

  • Più efficiente: questa soluzione ha un utilizzo ottimale della memoria e si comporta bene nell'analisi Big O (basata su un numero minimo di confronti). In questo algoritmo, ha senso analizzare il numero di confronti tra caratteri perché questa è l'operazione predominante nel nostro algoritmo. Sia $n$ il numero di nomi e sia $k$ la lunghezza media dei nomi.

    • Numero di confronti nel caso peggiore: $n(10k)(10k) = 100nk^2$
    • Spiegazione: $n$ (loop 1) * $10k$ (per ogni carattere, confrontiamo con 10 possibili vocali) * $10k$ (eseguiamo di nuovo il controllo isVowel() per decidere se scrivere in maiuscolo il carattere, ancora una volta nel nel peggiore dei casi, questo confronta con 10 vocali).
    • Risultato: poiché la lunghezza media del nome non sarà superiore a 100 caratteri, possiamo dire che il nostro algoritmo viene eseguito in $O(n)$ tempo.
  • Complesso con scarsa leggibilità: rispetto alla soluzione dichiarativa che considereremo in seguito, questa soluzione è molto più lunga e più difficile da seguire.
  • Incline a errori: il codice muta il result , vowelsCount e transformedChar ; queste mutazioni di stato possono portare a errori sottili come dimenticare di reimpostare il conteggio delle vowelsCount su 0. Il flusso di esecuzione può anche diventare complicato ed è facile dimenticare di aggiungere l'istruzione break nel terzo ciclo.
  • Scarsa manutenibilità: poiché il nostro codice è complesso e soggetto a errori, il refactoring o la modifica del comportamento di questo codice potrebbe essere difficile. Ad esempio, se il problema venisse modificato per selezionare nomi con tre vocali e cinque consonanti, dovremmo introdurre nuove variabili e modificare i loop, lasciando molte opportunità di bug.

La nostra soluzione di esempio illustra l'aspetto complesso del codice imperativo, sebbene sia possibile migliorarlo rifattorizzandolo in funzioni più piccole.

Soluzione dichiarativa

Ora che capiamo cosa non è la programmazione dichiarativa, sveliamo la nostra soluzione dichiarativa in 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] }

Utilizzando gli stessi criteri che abbiamo utilizzato per valutare la nostra soluzione imperativa, vediamo come regge il codice dichiarativo:

  • Efficiente: le implementazioni imperativa e dichiarativa funzionano entrambe in tempo lineare, ma quella imperativa è un po' più efficiente perché ho usato name.count() qui, che continuerà a contare le vocali fino alla fine del nome (anche dopo aver trovato tre vocali ). Possiamo facilmente risolvere questo problema scrivendo una semplice hasThreeVowels(String): Boolean . Questa soluzione utilizza lo stesso algoritmo della soluzione imperativa, quindi la stessa analisi della complessità si applica qui: il nostro algoritmo viene eseguito in $O(n)$ tempo.
  • Conciso con buona leggibilità: la soluzione imperativa è di 44 righe con un grande rientro rispetto alla lunghezza della nostra soluzione dichiarativa di 16 righe con un piccolo rientro. Righe e tabulazioni non sono tutto, ma da uno sguardo ai due file è evidente che la nostra soluzione dichiarativa è molto più leggibile.
  • Meno soggetto a errori: in questo esempio, tutto è immutabile. Trasformiamo un List<String> di tutti i nomi in un List<String> di nomi con tre o più vocali e quindi trasformiamo ogni parola String in una parola String con vocali maiuscole. Nel complesso, non avere mutazioni, cicli annidati o interruzioni e rinunciare al flusso di controllo rende il codice più semplice con meno spazio per gli errori.
  • Buona manutenibilità: è possibile eseguire facilmente il refactoring del codice dichiarativo grazie alla sua leggibilità e robustezza. Nel nostro esempio precedente (diciamo che il problema è stato modificato per selezionare nomi con tre vocali e cinque consonanti), una soluzione semplice sarebbe quella di aggiungere le seguenti affermazioni nella condizione di filter : val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 .

Come ulteriore aspetto positivo, la nostra soluzione dichiarativa è puramente funzionale: ogni funzione in questo esempio è pura e non ha effetti collaterali. (Maggiori informazioni sulla purezza più avanti.)

Soluzione dichiarativa bonus

Diamo un'occhiata all'implementazione dichiarativa dello stesso problema in un linguaggio puramente funzionale come Haskell per dimostrare come si legge. Se non hai familiarità con Haskell, tieni presente che il . operatore in Haskell si legge come "dopo". Ad esempio, solution = map uppercaseVowels . filter hasThreeVowels solution = map uppercaseVowels . filter hasThreeVowels traduce in "mappa le vocali in maiuscolo dopo aver filtrato i nomi che hanno tre vocali".

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

Questa soluzione funziona in modo simile alla nostra soluzione dichiarativa Kotlin, con alcuni vantaggi aggiuntivi: è leggibile, semplice se capisci la sintassi di Haskell, puramente funzionale e pigra.

Da asporto chiave

La programmazione dichiarativa è utile sia per FP che per la programmazione reattiva (che tratteremo in una sezione successiva).

  • Descrive "cosa" vuoi ottenere, piuttosto che "come" raggiungerlo, con l'esatto ordine di esecuzione delle istruzioni.
  • Astrae il flusso di controllo di un programma e si concentra invece sul problema in termini di trasformazioni (cioè, $A \rightarrow B \rightarrow C \rightarrow D$).
  • Incoraggia un codice meno complesso, più conciso e più leggibile che è più facile da rifattorizzare e modificare. Se il tuo codice Android non si legge come una frase, probabilmente stai sbagliando qualcosa.

Se il tuo codice Android non si legge come una frase, probabilmente stai sbagliando qualcosa.

Twitta

Tuttavia, la programmazione dichiarativa presenta alcuni aspetti negativi. È possibile ritrovarsi con un codice inefficiente che consuma più RAM e ha prestazioni peggiori di un'implementazione imperativa. L'ordinamento, la backpropagation (nell'apprendimento automatico) e altri "algoritmi di mutazione" non sono adatti allo stile di programmazione dichiarativo e immutabile.

Ingrediente FP n. 2: Composizione della funzione

La composizione delle funzioni è il concetto matematico al centro della programmazione funzionale. Se la funzione $f$ accetta $A$ come input e produce $B$ come output ($f: A \rightarrow B$), e la funzione $g$ accetta $B$ e produce $C$ ($g: B \rightarrow C$), quindi puoi creare una terza funzione, $h$, che accetta $A$ e produce $C$ ($h: A \rightarrow C$). Possiamo definire questa terza funzione come la composizione di $g$ con $f$, indicata anche come $g \circ f$ o $g(f())$:

Una casella blu denominata "A" ha una freccia, "f", che punta a una casella blu denominata "B" che ha una freccia, "g", che punta a una casella blu denominata "C". La casella "A" ha anche una freccia parallela, "g of", che punta direttamente alla casella "C".
Funzioni f, g e h, la composizione di g con f.

Ogni soluzione imperativa può essere tradotta in una dichiarativa scomponendo il problema in problemi più piccoli, risolvendoli indipendentemente e ricomponendo le soluzioni più piccole nella soluzione finale attraverso la composizione della funzione. Diamo un'occhiata al problema dei nomi della sezione precedente per vedere questo concetto in azione. I nostri problemi più piccoli dalla soluzione imperativa sono:

  1. isVowel :: Char -> Bool : Dato un Char , restituisci se è una vocale o meno ( Bool ).
  2. countVowels :: String -> Int : data una String , restituisce il numero di vocali in essa contenute ( Int ).
  3. hasThreeVowels :: String -> Bool : Data una String , restituisci se ha almeno tre vocali ( Bool ).
  4. uppercaseVowels :: String -> String : Data una String , restituisce una nuova String con vocali maiuscole.

La nostra soluzione dichiarativa, ottenuta attraverso la composizione della funzione, è map uppercaseVowels . filter hasThreeVowels map uppercaseVowels . filter hasThreeVowels .

Un diagramma in alto ha tre caselle blu "[String]" collegate da frecce che puntano a destra. La prima freccia è etichettata "filter has3Vowels" e la seconda è etichettata "map uppercaseVowels". Sotto, un secondo diagramma ha due caselle blu a sinistra, "Char" in alto e "String" in basso, che puntano a una casella blu a destra, "Bool". La freccia da "Char" a "Bool" è etichettata "isVowel" e la freccia da "String" a "Bool" è etichettata "has3Vowels". La casella "Stringa" ha anche una freccia che punta a se stessa etichettata come "vocali maiuscole".
Un esempio di composizione di funzioni utilizzando il nostro problema dei nomi.

Questo esempio è un po' più complicato di una semplice formula $A \rightarrow B \rightarrow C$, ma mostra il principio alla base della composizione della funzione.

Da asporto chiave

La composizione delle funzioni è un concetto semplice ma potente.

  • Fornisce una strategia per risolvere problemi complessi in cui i problemi sono suddivisi in passaggi più piccoli e più semplici e combinati in un'unica soluzione.
  • Fornisce elementi costitutivi, consentendoti di aggiungere, rimuovere o modificare facilmente parti della soluzione finale senza preoccuparti di rompere qualcosa.
  • Puoi comporre $g(f())$ se l'output di $f$ corrisponde al tipo di input di $g$.

Quando si compongono le funzioni, è possibile passare non solo i dati ma anche le funzioni come input ad altre funzioni, un esempio di funzioni di ordine superiore.

Ingrediente FP n. 3: Purezza

C'è un altro elemento chiave per la composizione delle funzioni che dobbiamo affrontare: le funzioni che componi devono essere pure , un altro concetto derivato dalla matematica. In matematica, tutte le funzioni sono calcoli che producono sempre lo stesso output quando vengono chiamati con lo stesso input; questa è la base della purezza.

Diamo un'occhiata a un esempio di pseudocodice che utilizza funzioni matematiche. Supponiamo di avere una funzione, makeEven , che raddoppi un intero input per renderlo pari e che il nostro codice esegua la riga makeEven(x) + x usando l'input x = 2 . In matematica, questo calcolo si tradurrebbe sempre in un calcolo di $2x + x = 3x = 3(2) = 6$ ed è una funzione pura. Tuttavia, questo non è sempre vero nella programmazione: se la funzione makeEven(x) ha mutato x raddoppiandolo prima che il codice restituisse il nostro risultato, la nostra riga calcolerebbe $2x + (2x) = 4x = 4(2) = 8$ e, peggio ancora, il risultato cambierebbe ad ogni chiamata makeEven .

Esploriamo alcuni tipi di funzioni che non sono pure ma ci aiuteranno a definire la purezza in modo più specifico:

  • Funzioni parziali: queste sono funzioni che non sono definite per tutti i valori di input, come la divisione. Dal punto di vista della programmazione, queste sono funzioni che generano un'eccezione: fun divide(a: Int, b: Int): Float genererà ArithmeticException per l'input b = 0 causato dalla divisione per zero.
  • Funzioni totali: queste funzioni sono definite per tutti i valori di input ma possono produrre un output o effetti collaterali diversi quando vengono richiamate con lo stesso input. Il mondo Android è pieno di funzioni totali: Log.d , LocalDateTime.now e Locale.getDefault sono solo alcuni esempi.

Con queste definizioni in mente, possiamo definire le funzioni pure come funzioni totali senza effetti collaterali. Le composizioni di funzioni costruite utilizzando solo funzioni pure producono codice più affidabile, prevedibile e verificabile.

Suggerimento: per rendere pura una funzione totale, puoi astrarre i suoi effetti collaterali passandoli come parametro di funzione di ordine superiore. In questo modo, puoi facilmente testare le funzioni totali passando una funzione derisa di ordine superiore. Questo esempio utilizza l'annotazione @SideEffect da una libreria che esamineremo più avanti nel tutorial, Ivy FRP:

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

Da asporto chiave

La purezza è l'ingrediente finale richiesto per il paradigma della programmazione funzionale.

  • Fai attenzione con le funzioni parziali: possono arrestare in modo anomalo la tua app.
  • La composizione di funzioni totali non è deterministica; può produrre comportamenti imprevedibili.
  • Quando possibile, scrivi funzioni pure. Beneficerai di una maggiore stabilità del codice.

Con la nostra panoramica della programmazione funzionale completata, esaminiamo il prossimo componente del codice Android a prova di futuro: la programmazione reattiva.

Programmazione reattiva 101

La programmazione reattiva è un modello di programmazione dichiarativo in cui il programma reagisce ai dati o alle modifiche degli eventi invece di richiedere informazioni sulle modifiche.

Due caselle blu principali, "Osservabile" e "Stato", hanno due percorsi principali tra di loro. Il primo è tramite "Osserva (ascolta le modifiche)". Il secondo è tramite "Notifica (dell'ultimo stato)" nella casella blu "UI (API nel back-end)" che passa tramite "Trasforma l'input dell'utente in" nella casella blu "Evento", che passa tramite "Trigger" in blu box "Composizione della funzione" e infine tramite "Produce (nuovo stato)". "Stato" si ricollega quindi anche a "Composizione funzione" tramite "Atti come input per".
Il ciclo di programmazione reattiva generale.

Gli elementi di base in un ciclo di programmazione reattiva sono eventi, pipeline dichiarativa, stati e osservabili:

  • Gli eventi sono segnali provenienti dal mondo esterno, in genere sotto forma di input dell'utente o eventi di sistema, che attivano gli aggiornamenti. Lo scopo di un evento è trasformare un segnale in un input della pipeline.
  • La pipeline dichiarativa è una composizione di funzione che accetta (Event, State) come input e trasforma questo input in un nuovo State (l'output): (Event, State) -> f -> g -> … -> n -> State . Le pipeline devono essere eseguite in modo asincrono per gestire più eventi senza bloccare altre pipeline o attendere il loro completamento.
  • Gli stati sono la rappresentazione del modello di dati dell'applicazione software in un determinato momento. La logica di dominio utilizza lo stato per calcolare lo stato successivo desiderato ed effettuare gli aggiornamenti corrispondenti.
  • Gli osservabili ascoltano le modifiche di stato e aggiornano gli abbonati su tali modifiche. In Android, gli osservabili vengono in genere implementati utilizzando Flow , LiveData o RxJava e notificano all'interfaccia utente gli aggiornamenti di stato in modo che possa reagire di conseguenza.

Ci sono molte definizioni e implementazioni di programmazione reattiva. Qui, ho adottato un approccio pragmatico focalizzato sull'applicazione di questi concetti a progetti reali.

Collegamento dei punti: programmazione funzionale reattiva

La programmazione funzionale e reattiva sono due potenti paradigmi. Questi concetti vanno oltre la breve durata delle librerie e delle API e miglioreranno le tue capacità di programmazione negli anni a venire.

Inoltre, la potenza di FP e programmazione reattiva si moltiplica quando combinate. Ora che abbiamo definizioni chiare di programmazione funzionale e reattiva, possiamo mettere insieme i pezzi. Nella parte 2 di questo tutorial, definiamo il paradigma della programmazione reattiva funzionale (FRP) e lo mettiamo in pratica con un'implementazione di app di esempio e librerie Android pertinenti.

Il Toptal Engineering Blog estende la sua gratitudine a Tarun Goyal per aver esaminato gli esempi di codice presentati in questo articolo.