Che cos'è la programmazione funzionale?
È una domanda importante, che merita una risposta lunga (Wikipedia ne fornisce una, Advanced R un'altra).
Ai nostri fini, queste sono alcune caratteristiche fondamentali:
Tuttavia, R non è un linguaggio funzionale puro (a differenza, ad esempio, di Haskell).
Considera R un linguaggio pragmatico, che (sempre più spesso) fornisce capacità funzionali laddove queste rappresentano un modo pulito e semplice per portare a termine un compito. In generale, l'obiettivo è l'analisi dei dati, e la programmazione è solo un mezzo per raggiungerlo.
L'uso delle pipe |> per collegare le funzioni è una tecnica sempre più comune in diversi linguaggi.
In breve:
Alcuni programmatori trovano utile pronunciare la pipe come «poi» quando leggono il codice: fai il passo 1 POI fai il passo 2...
L'esempio seguente illustra la sintassi.
library(stringr)
"Monday-25-February" |>
str_split("-") |>
unlist() |> # [1] "Monday" "25" "February"
str_sub(1, 3) |> # [1] "Mon" "25" "Feb"
str_to_upper() |> # [1] "MON" "25" "FEB"
str_flatten(collapse = ", ")
#> [1] "MON, 25, FEB"
Questo tipo di pipe è stato aggiunto a Base R nella versione 4.1.0, e potresti vederla chiamata «native pipe».
In precedenza, le pipe si facevano con %>% del pacchetto magrittr (il nome è un gioco di parole per gli amanti dell'arte).
La «pipe magrittr» è ancora disponibile, ma le pipe native evitano la necessità di un pacchetto esterno, e l'implementazione è più semplice in alcuni dettagli.
L'editor RStudio (tra gli altri) usa Ctrl-Shift-M come scorciatoia da tastiera per aggiungere una pipe con gli spazi opportuni.
Abbiamo visto in precedenza che molte funzioni in R sono «vettorizzate»: operano su interi vettori, senza bisogno di scrivere cicli espliciti.
Le funzioni che usano solo funzioni vettorizzate sono a loro volta probabilmente vettorizzate automaticamente. In un esempio banale:
triple <- function(x) return(3 * x)
triple(1:10)
[1] 3 6 9 12 15 18 21 24 27 30
Tuttavia, funzioni più complesse potrebbero non accettare input vettoriali, per esempio se contengono cicli o blocchi if-else.
Ci serve un modo più generale per applicare le funzioni ai vettori (simile a map() in alcuni altri linguaggi).
Poiché R ha una ricca varietà di strutture dati nel linguaggio di base, ha anche un'intera famiglia di funzioni *apply() per operare su di esse.
Più tecnicamente, queste sono spesso chiamate «funzioni di ordine superiore» dai programmatori e «funzionali» dai matematici, perché accettano funzioni come argomenti.
Per ora, considera lapply() e sapply().
lapply()«List-apply» è progettata per lavorare su input di tipo lista, ma i vettori verranno convertiti in liste in modo silenzioso quando necessario.
L'output è una lista, quindi potrebbe servire spacchettarla per ottenere un vettore:
h <- function(size) {
switch(size,
"small" = 6,
"medium" = 8,
"large" = 10
)
}
v <- c("small", "large", "medium")
h(v) # bad use of an unvectorized function
#> Error in switch(size, small = 6, medium = 8, large = 10) :
#> EXPR must be a length 1 vector
lapply(v, h) # output is a list
#> [[1]]
#> [1] 6
#> [[2]]
#> [1] 10
#> [[3]]
#> [1] 8
lapply(v, h) |> unlist() # output is a vector
#> [1] 6 10 8
Chi ha familiarità con il concetto Switch potrebbe notare che potremmo in alternativa sostituire la switch scalare con la recode_values() vettorizzata della libreria dplyr.
La vettorizzazione è una buona cosa, e il R moderno fornisce diversi modi per ottenerla.
library(dplyr)
h <- function(size) {
recode_values(size,
"small" ~ 6,
"medium" ~ 8,
"large" ~ 10
)
}
v <- c("small", "large", "medium")
h(v)
#> [1] 6 10 8
sapply()«Simplified-apply» fa qualcosa di simile a lapply, ma cerca anche di semplificare il valore restituito: per esempio,
un vettore con nome invece di una lista.
A seconda del contesto, potrebbe essere necessario usare unname():
sapply(v, h)
small large medium
6 10 8
sapply(v, h) |> unname()
#> [1] 6 10 8
purrr
lapply e sapply sono semplici, familiari alla maggior parte degli utenti R, e ancora molto usate con vettori e liste semplici.
Il Base R ha anche diverse altre funzioni apply:
vapply() è simile a sapply() ma ha anche un parametro obbligatorio per specificare il formato dell'output.apply() per matrici e array multidimensionali.mapply() per più argomenti lista o vettore in parallelo.tapply() è più difficile da spiegare, ma funziona con operazioni di raggruppamento e array irregolari.vectorize() è una funzione piuttosto misteriosa, che sembra essere un wrapper per mapply().Basta così!
Fornire un'alternativa più semplice e più coerente a questo tipo di confusione è un obiettivo chiave del Tidyverse.
Ora c'è il pacchetto purrr, che fornisce diverse opzioni aggiuntive come map(), migliorando al contempo la coerenza nel design e la chiarezza nei messaggi di errore.
Meno vincolato della base R dalla retrocompatibilità, questo pacchetto avvicina R in modo significativo alla programmazione funzionale moderna che si trova in altri linguaggi.
Le funzioni di purrr sono molte e diverse, e sfuggono a una sintesi facile.
Tra le risorse che aiutano a orientarsi nella libreria ci sono:
La libreria purrr opera quasi interamente su dati monodimensionali: vettori e liste.
In un concetto successivo vedremo che la libreria dplyr fornisce funzionalità equivalenti per i dataframe bidimensionali.
La funzione map() applica una funzione a ciascun elemento dell'input e restituisce sempre una lista.
Fin qui, sembra proprio lapply(), solo rinominata, e magari con messaggi di errore migliori.
Tuttavia, map() è solo un membro di una grande famiglia.
Alcune domande da considerare nella scelta di una funzione:
map() restituisce sempre una lista, ma se vuoi un tipo particolare di vettore atomico usa map_chr() per le stringhe, map_int() per gli interi, ecc.i ai nomi delle funzioni, come in imap().map con map2 nel nome, come in map2_dbl() per due input (ad es. valori e pesi, per calcolare una media ponderata)pmap per una lista di input paralleli, come in pmap_chr(). E sì, è possibile il parallelismo a livello hardware.map_if() per applicare un filtro predicato (booleano), map_at() per specificare criteri di indice posizionale, head_while() per elaborare l'input finché un elemento non fallisce un predicato.modify() invece di map, oppure modify_tree() per l'applicazione ricorsiva.Queste funzioni hanno i dati di input come primo argomento quando possibile, per assicurarsi che funzionino bene con le pipe.
Inoltre, purrr è (deliberatamente) più pedante del Base R. Se chiedi qualcosa di specifico, le funzioni restituiranno esattamente quello oppure si fermeranno con un messaggio di errore.
Severo, ma molto efficace nel ridurre i bug difficili da trovare.
Le funzioni map hanno in genere un valore restituito della stessa lunghezza dell'input.
Alcune altre funzioni riducono la dimensionalità dei dati, per esempio sum().
sum(1:100)
#> [1] 5050
Nel caso precedente, convertiamo un vettore di lunghezza 100 in un intero di lunghezza 1. Vedremo in un concetto futuro su matrici e array che il concetto di riduzione di dimensionalità è più generale.
La funzione sum() è integrata (come molte altre funzioni statistiche).
Tuttavia, ci serve un modo per applicare funzioni arbitrarie di riduzione di dimensionalità su una struttura dati, usando una funzione di ordine superiore equivalente a map().
In diversi altri linguaggi si usano nomi di funzione come fold, foldl e foldr. In R, la funzione pertinente è reduce(), che corrisponde al noto (agli appassionati di algoritmi) framework MapReduce.
Il primo argomento è, come al solito, i dati di input. Il secondo argomento è una funzione a 2 argomenti: spesso una funzione anonima, ma anche semplice come un operatore aritmetico come +.
# surround an infix operator with backticks, to use as a function
reduce(1:100, `+`)
#> [1] 5050 # same as sum(1:100)
# anonymous function, alternately add and subtract
reduce(1:10, \(accum, nextval) ifelse(nextval %% 2 == 0, accum + nextval, accum - nextval))
#> [1] 7
Fa differenza se applichiamo la funzione dall'inizio o dalla fine dell'input?
No, con operatori associativi come + o *.
Chiaramente, (1 + 2) + 3 == 1 + (2 + 3).
Al contrario, - e / non sono associativi:
(1 - 2) - 3
#> [1] -4
1 - (2 - 3)
#> [1] 2
Per le funzioni in cui la direzione conta, c'è un argomento opzionale .dir.
# default is .dir = "forward"
reduce(1:10, `-`)
#> [1] -53
reduce(1:10, `-`, .dir = "backward")
#> [1] -5
Come con map() e map2(), c'è anche una funzione reduce2() per operare su due vettori di input in parallelo.
In R ci piace lavorare con i vettori. Immagina di voler vedere tutti i passaggi intermedi invece di un singolo valore finale.
Per questo, le funzioni corrispondenti sono accumulate() e accumulate2().
La sintassi è simile a reduce(), con alcuni argomenti opzionali extra per specificare il tipo di output.
accumulate(1:10, `+`)
#> [1] 1 3 6 10 15 21 28 36 45 55
Alcuni tipi comuni di accumulate hanno funzioni proprie.
Il concetto Funzioni vettoriali ha discusso cumsum() e cumprod() nel Base R, e la libreria dplyr ha cummean() per le medie numeriche, cumall() e cumany() per i vettori logici.
Spesso vogliamo includere (o escludere) gli elementi dell'input che soddisfano un certo predicato.
Nel concetto Filtro dei vettori abbiamo visto un modo idiomatico per farlo.
v <- 1:3
v[v >= 2]
#> [1] 2 3
È molto usato nel R tradizionale, ma si adatta male alle moderne pipeline funzionali.
Di conseguenza, purrr fornisce una serie di funzioni predicato, che seguono la consueta convenzione degli argomenti f(input, func, ...) per funzionare con le pipe.
I nomi sono un po' diversi dalla maggior parte degli altri linguaggi (nomi come filter e drop significano già qualcosa di completamente diverso in R).
Le funzioni purrr corrispondenti includono keep() e discard(), più diverse altre.
Inoltre, abbiamo every(), some() e none(), per sostituire all() e any() nel restituire un singolo valore logico.
La maggior parte dei linguaggi funzionali usa la ricorsione come alternativa preferita ai cicli.
R ha altre opzioni, spesso migliori: funzioni vettorizzate e funzioni di ordine superiore.
Tuttavia, la ricorsione è supportata in R, e può essere utile in contesti come l'attraversamento di strutture ad albero.
La profondità massima di ricorsione è impostata per impostazione predefinita a 5000, il che è relativamente generoso (in Python il valore predefinito è 1000). È possibile sovrascrivere il valore predefinito.
L'ottimizzazione delle chiamate in coda non è standard in R, anche se sono stati sviluppati alcuni espedienti.
La tradizione impone che a questo punto mostriamo un esempio di fattoriale, come classico esempio di definizione ricorsiva.
fact <- function(n) ifelse(n == 0, 1, n * fact(n - 1))
fact(5)
#> [1] 120
# some more idiomatic R:
factorial(5)
#> [1] 120
prod(1:5)
#> [1] 120
Puoi usare la ricorsione in R, ed a volte è preziosa, ma spesso ci sono approcci più semplici.