Tracce
/
R
R
/
Programma
/
Programmazione funzionale
Pr

Programmazione funzionale in R

1 esercizio

Informazioni su Programmazione funzionale

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:

  • Funzioni pure, che mappano gli input agli output in modo coerente e senza effetti collaterali.
  • Dati immutabili.
  • Funzioni di prima classe, che possono essere trattate come valori al pari di qualsiasi altro tipo.
  • Funzioni di ordine superiore, che accettano altre funzioni come argomenti o come valori restituiti.

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.

Pipe

L'uso delle pipe |> per collegare le funzioni è una tecnica sempre più comune in diversi linguaggi.

In breve:

  • Un risultato a sinistra della pipe diventa il primo argomento della funzione a destra (dove «destra» ignora le interruzioni di riga opzionali).
  • Gli eventuali argomenti rimanenti possono essere inclusi nella chiamata alla funzione.
  • In R, dobbiamo inviare la pipe a una chiamata di funzione, non solo a un nome, quindi le parentesi sono sempre necessarie (questo è diverso da alcuni altri linguaggi).

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.

Le funzioni apply

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

La libreria 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:

Note

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.

Le tante funzioni map

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:

  • Cosa vuoi restituire? 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.
  • Ti serve ogni indice posizionale dell'input? Anteponi i ai nomi delle funzioni, come in imap().
  • Vuoi elaborare più input in parallelo?
    • Sostituisci map con map2 nel nome, come in map2_dbl() per due input (ad es. valori e pesi, per calcolare una media ponderata)
    • Sostituisci con pmap per una lista di input paralleli, come in pmap_chr(). E sì, è possibile il parallelismo a livello hardware.
  • Vuoi elaborare tutti gli elementi dell'input? In caso contrario, pensa a 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.
  • I dati di input sono profondamente annidati? Ci sono funzioni per quella situazione.
  • Vuoi che il valore restituito sia sempre dello stesso tipo dell'input? Usa le funzioni 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 reduce

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.

Le funzioni filter

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 ricorsione

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.

Modifica tramite GitHub Il collegamento si apre in una nuova finestra o scheda

Impara Programmazione funzionale