R Logging Monad
Explore managing side effects with Monads through a logging example
Motivation
- Want to record transformations (i.e. function calls) that have happened
- Want to do this in a non-intrusive manner
From what I've read, Monads enable managing side effects (like logging) in a functional manner.
Setup
Use docker for a clean environment
FROM rocker/rstudio:latest # rocker/tidyverse didn't seem to have a prebuilt arm64 image, so # we can build our own RUN Rscript -e "install.packages(c('rlang', 'tidyverse', 'lobstr'))"
docker build . -t r_logging_monad:latest
docker run --rm --name r_logging_monady -dti r_logging_monad
3992f93122ec3621fa892ff295d002a88c52be950847fdaffe1712b1986831aa
The Monad
Technically a monad meets the three monad laws. I don't know if the below does that, but I'll continue calling it a monad anyway.
- We need two functions:
- A function that returns a wrapped "normal" value as some type of special value. This should be the "setup" to manage the side effect.
- A function that enables "normal" functions to act on those special values
Wrapping Values
# don't use ansi escape characters options(cli.num_colors = 1) logged_fn <- function(normal_fn, ..., logs = list()) { function(..., .logs = logs) { # capture what function was called. note we have to be sure to # look for `normal_fn` in this function's enclosing environment fn_name <- substitute(normal_fn, env = parent.env(environment())) # log it .logs <- append(fn_name, .logs) # call the function result <- normal_fn(...) # wrap up the result list(value = result, logs = .logs) } }
Acting on Wrapped Values
The function that lets normal functions act on our special values is usually called "bind".
bind <- function(logged, normal_fn, ...) { normal_fn(logged$value, ..., .logs = logged$logs) }
add1 <- function(x) x + 1 logged_add1 <- logged_fn(add1) logged_sum <- logged_fn(sum) logged_add1(c(1, 2, 3)) |> bind(logged_sum) |> # friendlier nested list visualization lobstr::tree()
<list> ├─value: 9 └─logs: <list> ├─<symbol> sum └─<symbol> add1
Neat. Prior operations are captured but everything is still pure and functional.
Infix Bind
Repeatedly calling bind looks ugly.
logged_add1(c(1, 2, 3)) |> bind(logged_add1) |> bind(logged_add1) |> bind(logged_sum) |> lobstr::tree()
<list> ├─value: 15 └─logs: <list> ├─<symbol> sum ├─<symbol> add1 ├─<symbol> add1 └─<symbol> add1
Other languages (e.g. F# and Haskell) use an in-fix operator for bind. We can do that here too.
`%>>=%` <- function(lhs, rhs) { bind(lhs, rhs) } logged_add1(c(1, 2, 3)) %>>=% logged_add1 %>>=% logged_add1 %>>=% logged_sum |> lobstr::tree()
<list> ├─value: 15 └─logs: <list> ├─<symbol> sum ├─<symbol> add1 ├─<symbol> add1 └─<symbol> add1
I think that looks better, though the `%` make the operator long.