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:
    1. A function that returns a wrapped "normal" value as some type of special value. This should be the "setup" to manage the side effect.
    2. 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.