Porquê usar monads (programação funcional)? Uma ilustração com o pacote {chronicler}

Quem sou eu?

Bruno André Rodrigues Coelho

Estatístico e programmador R no ministério do ensino superior e da pesquisa no Luxemburgo

Contacto: bruno.rodrigues@mesr.etat.lu, @brodriguesco, www.brodrigues.co

Link para descargar apresentação: rrr.is/monadINE

Algumas estatísticas sobre o Luxemburgo (1/2)

Algumas estatísticas sobre o Luxemburgo (2/2)

  • único grão-ducado do mundo, 3 línguas oficiais: luxemburguês, francês, alemão (Moien!, Bonjour!, Guten Morgen!)

  • População 643 941 (2021) 47,2% da população é estrangeira

  • os portugueses representam 14,5% da população total

Introdução

Uma definição “simples” (em inglês)

  • “A monad is a monoid in the category of endofunctors”
  • Não nos ajuda muito!
  • Vamos começar no início

Funções (1/4)

  • R é uma linguagem de programação funcional (mas não só)
  • Uma entrada (ou várias) -> f -> uma saída só

Funções (2/4)

head(mtcars)
                   mpg cyl disp  hp drat    wt  qsec vs am gear carb
Mazda RX4         21.0   6  160 110 3.90 2.620 16.46  0  1    4    4
Mazda RX4 Wag     21.0   6  160 110 3.90 2.875 17.02  0  1    4    4
Datsun 710        22.8   4  108  93 3.85 2.320 18.61  1  1    4    1
Hornet 4 Drive    21.4   6  258 110 3.08 3.215 19.44  1  0    3    1
Hornet Sportabout 18.7   8  360 175 3.15 3.440 17.02  0  0    3    2
Valiant           18.1   6  225 105 2.76 3.460 20.22  1  0    3    1

Funções (3/4)

library(dplyr)
starwars %>%
  filter(skin_color == "light") %>%
  select(species, sex, mass) %>%
  group_by(sex, species) %>%
  summarise(
    average = mean(mass, na.rm = TRUE),
    st_dev = sd(mass, na.rm = TRUE)
  ) %>%
  select(-species) %>%
  tidyr::pivot_longer(-sex, names_to = "statistic", values_to = "mass")
# A tibble: 4 × 3
# Groups:   sex [2]
  sex    statistic  mass
  <chr>  <chr>     <dbl>
1 female average    56.3
2 female st_dev     16.3
3 male   average    90.5
4 male   st_dev     19.8

Funções (4/4)

Resumindo

  • fácil de testar ({testthat})
  • fácil de documentar ({roxygen2})
  • fácil de empacotar ({devtools}, {usethis})
  • podem ser compostas (h |> f |> g)

Limites das funções (1/6)

  • Difícil de gerir efeitos colaterais (side-effects)
  • por exemplo: tempo de execução, logging

Limites das funções - exemplo de logging (2/6)

Logging com print?

# logging_sqrt(): resultado + print (efeito colateral)
logging_sqrt <- function(x){

  cat("Running sqrt with input ", x, "\n")

  sqrt(x)

}

logging_sqrt(16)
Running sqrt with input  16 
[1] 4

Mas: impossível de salvar e processar mensagens!

Limites das funções - exemplo de logging (3/6)

# logging_sqrt(): lista de resultados (sem mais efeito colateral)
logging_sqrt <- function(x, log = ""){

  list(sqrt(x),
       c(log,
         paste0("Running sqrt with input ", x)))

}

logging_sqrt(16)
[[1]]
[1] 4

[[2]]
[1] ""                           "Running sqrt with input 16"

Limites das funções (4/6)

  • temos de modificar todas as funções
  • composição de funções impossível

Limites das funções (5/6)

Composição de funções:

# OK!
16 |> sqrt() |> log()
[1] 1.386294

Limites das funções (6/6)

Definimos logging_log()

logging_log <- function(x, log = ""){

  list(log(x),
       c(log,
         paste0("Running log with input ", x)))

}

Mas composição impossível:

# Not ok!
16 |> logging_sqrt() |> logging_log()
Error in log(x) : non-numeric argument to mathematical function

Como resolver esses dois problemas?

  • Não queremos ter que modificar as funções “à mão”
  • Queremos poder compor funções

Soluçao 1: Function factories (1/3)

Solução para o primeiro problema: function factory

uma função que produz uma função:

log_it <- function(.f, ..., log = NULL){

  fstring <- deparse(substitute(.f))

  function(..., .log = log){

    list(result = .f(...),
         log = c(.log,
                 paste0("Running ", fstring, " with argument ", ...)))
  }
}

Soluçao 2: Function factories (2/3)

Fácil de definir novas funções embelezadas:

logging_sqrt <- log_it(sqrt)
logging_log <- log_it(log)
logging_head <- log_it(head)

Mas impossível de compor funções embelezadas:

# Not ok!
16 |> logging_sqrt() |> logging_log()
Error in log(x) : non-numeric argument to mathematical function

Solução 2: Compor funções embelezadas (3/3)

bind(): função com dois inputs: uma lista e uma função embelezada:

bind <- function(.l, .f, ...){

  .f(.l$result, ..., .log = .l$log)

}
16 |>
  logging_sqrt() |>
  bind(logging_log)
$result
[1] 1.386294

$log
[1] "Running sqrt with argument 16" "Running log with argument 4"  

Function factory + bind() = Monad!

Monads permitem gerir efeitos colaterais. Mas na prática?

Maybe monad

A maybe monad permite definir funções que não dão erro: se a computação resultar em erro, a função retorna Nothing:

library(maybe)
maybe_sqrt <- maybe(sqrt)

Se a operação é bem-sucedida; just a value:

maybe_sqrt(16)
Just
[1] 4

Se a operação resulta em erro; Nothing:

maybe_sqrt("Not ok!")
Nothing

Possível gerir situações que resultam em erro de maneira explícita!

chronicler monad (1/6)

A chronicler monad permite ter o log de operações:

library(chronicler)

r_group_by <- record(group_by)
r_select <- record(select)
r_summarise <- record(summarise)
r_filter <- record(filter)

output <- starwars %>%
  r_select(height, mass, species, sex) %>%
  bind_record(r_group_by, species, sex) %>%
  bind_record(r_filter, sex != "male") %>%
  bind_record(r_summarise,
              mass = mean(mass, na.rm = TRUE)
              )

chronicler monad (2/6)

output
OK! Value computed successfully:
---------------
Just
# A tibble: 9 × 3
# Groups:   species [9]
  species    sex              mass
  <chr>      <chr>           <dbl>
1 Clawdite   female           55  
2 Droid      none             69.8
3 Human      female           56.3
4 Hutt       hermaphroditic 1358  
5 Kaminoan   female          NaN  
6 Mirialan   female           53.1
7 Tholothian female           50  
8 Togruta    female           57  
9 Twi'lek    female           55  

---------------
This is an object of type `chronicle`.
Retrieve the value of this object with pick(.c, "value").
To read the log of this object, call read_log(.c).

chronicler monad (3/6)

read_log(output)
[1] "Complete log:"                                                                  
[2] "OK! select(height,mass,species,sex) ran successfully at 2023-06-07 12:14:00"    
[3] "OK! group_by(species,sex) ran successfully at 2023-06-07 12:14:00"              
[4] "OK! filter(sex != \"male\") ran successfully at 2023-06-07 12:14:00"            
[5] "OK! summarise(mean(mass, na.rm = TRUE)) ran successfully at 2023-06-07 12:14:00"
[6] "Total running time: 0.0813939571380615 secs"                                    

output agora contém o seu próprio histórico!

chronicler monad e erros (4/6)

A chronicler monad lida com error graciosamente:

output <- starwars %>%
  r_select(height, mass, species, sex) %>%
  bind_record(r_group_by, species, sex) %>%
  bind_record(r_filter, sex_wrong != "male") %>%
  bind_record(r_summarise,
              mass = mean(mass, na.rm = TRUE)
              )

chronicler monad (5/6)

output
NOK! Value computed unsuccessfully:
---------------
Nothing

---------------
This is an object of type `chronicle`.
Retrieve the value of this object with pick(.c, "value").
To read the log of this object, call read_log(.c).

chronicler monad (6/6)

read_log(output)
[1] "Complete log:"                                                                                                                                                                                                                                            
[2] "OK! select(height,mass,species,sex) ran successfully at 2023-06-07 12:14:01"                                                                                                                                                                              
[3] "OK! group_by(species,sex) ran successfully at 2023-06-07 12:14:01"                                                                                                                                                                                        
[4] "NOK! filter(sex_wrong != \"male\") ran unsuccessfully with following exception: ℹ In argument: `sex_wrong != \"male\"`.\nℹ In group 1: `species = \"Aleena\"`, `sex = \"male\"`.\nCaused by error:\n! object 'sex_wrong' not found at 2023-06-07 12:14:01"
[5] "NOK! summarise(mean(mass, na.rm = TRUE)) ran unsuccessfully with following exception: Pipeline failed upstream at 2023-06-07 12:14:01"                                                                                                                    
[6] "Total running time: 0.320563077926636 secs"                                                                                                                                                                                                               

Para saber mais

  • Artigo longo: https://www.brodrigues.co/blog/2022-04-11-monads/
  • https://b-rodrigues.github.io/chronicler/articles/advanced-topics.html
  • https://b-rodrigues.github.io/chronicler/articles/real-world-example.html
  • https://armcn.github.io/maybe/
  • O meu livro: Building reproducible analytical pipelines with R: https://raps-with-r.dev/