--- title: "Functional programming in R with functionals" output: rmarkdown::pdf_document vignette: > %\VignetteIndexEntry{Functional programming in R with functionals} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) library(functionals) set.seed(1) ``` ## Why a functional style Functional programming builds a computation out of small, referentially transparent pieces: functions that take input, return output, and do not reach outside themselves to mutate state. In R this style is natural because functions are ordinary values: you can pass them, return them, and store them in lists. `functionals` is a small toolkit for that style. It gives one consistent family of *functionals* (functions that take a function as an argument), with the same four arguments everywhere: | argument | meaning | |---------------|----------------------------------------------------| | `.x` / `.df` / `.l` | the data to iterate over | | `.f` | the function to apply | | `ncores` | `NULL`/`1` sequential, `>= 2` parallel | | `pb` | show a completion-driven progress bar | and, from version 0.6.0, two more that make loops production-safe: | argument | meaning | |---------------|----------------------------------------------------| | `.on_error` | `"stop"` (default), `"pass"`, or `"fill"` | | `.seed` | reproducible per-task RNG streams | Nothing here depends on `rlang` or the tidyverse; the only import is `parallel`. ## The core: `fmap()` and friends `fmap()` is `lapply()` with the extra arguments above: ```{r} fmap(1:4, function(x) x^2) ``` The shape-specific mappers save you the `split()` / `as.list()` boilerplate: ```{r} # one call per data-frame row, row supplied as a named list fmapr(head(mtcars, 3), function(row) row$mpg / row$cyl) # one call per column, with the column name available fmapc(data.frame(a = 1:3, b = 4:6), function(x, nm) sprintf("%s: %.1f", nm, mean(x))) # one call per group fmapg(iris, function(d) mean(d$Sepal.Length), by = "Species") # one call per aligned tuple (list names are matched to .f's arguments) fmapn(list(x = 1:3, y = 4:6), function(x, y) x + y) ``` `fwalk()` is the side-effect sibling: it runs `.f` for its effect and returns `.x` invisibly. ```{r} invisible(fwalk(1:3, function(i) cat("row", i, "\n"))) ``` ### `floop()` is deprecated `floop()` did nothing that `fmap()` (collect results) and `fwalk()` (side effects only) do not. As of 0.6.0 it warns and forwards to them; use those directly. ## Programming *with* functionals ### Function factories A function that returns a function pairs naturally with `fmap()`: ```{r} power_of <- function(k) function(x) x^k powers <- fmap(1:4, power_of) # a list of functions powers[[3]](2) # 2^3 ``` ### Composition and reduction `fcompose()` chains single-argument functions right to left; `freduce()` folds a binary function over a sequence, optionally keeping the running accumulation. ```{r} standardise <- fcompose(function(x) round(x, 2), scale, as.numeric) standardise(c(1, 2, 3, 4)) freduce(1:5, `+`) freduce(1:5, `+`, .accumulate = TRUE) ``` A small pipeline built entirely from functionals, fitting nothing, just transforming: ```{r} pipeline <- fcompose( function(x) Filter(function(v) v > 0, x), function(x) fmap(x, function(v) v - mean(unlist(x))) ) str(pipeline(list(-2, 1, 4, 9))) ``` ### Parameter sweeps with `fmapn()` `fmapn()` iterates over aligned lists, so a grid of settings becomes a single call. `expand.grid()` builds the grid; `fmapn()` walks it. ```{r} grid <- expand.grid(n = c(10, 50), sd = c(1, 2)) res <- fmapn( list(n = grid$n, sd = grid$sd), function(n, sd) c(n = n, sd = sd, se = sd / sqrt(n)) ) do.call(rbind, res) ``` ### Safe iteration With `.on_error = "pass"`, one bad element does not abort the run; the failed slot holds a `functionals_error` you can inspect afterwards. ```{r} out <- fmap(list(1, "oops", 3), function(x) x * 10, .on_error = "pass") vapply(out, function(e) if (inherits(e, "functionals_error")) NA_real_ else e, numeric(1)) ``` `.on_error = "fill"` replaces failures with `.fill` directly. The behaviour is identical whether the run is sequential or parallel. ### Reproducible randomness `.seed` gives each task an independent L'Ecuyer-CMRG stream, so results do not depend on `ncores` or the order tasks finish, and your global RNG state is left untouched. ```{r} a <- fmap(1:5, function(i) round(rnorm(1), 3), .seed = 42) b <- fmap(1:5, function(i) round(rnorm(1), 3), .seed = 42, ncores = 1) identical(a, b) ``` ### Parallelism and progress Set `ncores` to fan work across processes (fork on Unix, PSOCK elsewhere or when you pass your own `cl`). `pb = TRUE` shows a bar that advances as individual tasks complete and reports elapsed time and ETA. ```{r eval = FALSE} slow <- function(x) { Sys.sleep(0.2); x^2 } fmap(1:20, slow, ncores = 4, pb = TRUE) ``` Objects that cannot survive a fork (external-pointer handles from `torch`, `xgboost`, database connections, ...) must be created *inside* `.f`, not captured from the caller. When that is impossible, run sequentially. ## `functionals` and `purrr` The vocabularies line up almost one to one. | Task | `functionals` | `purrr` | base R | |---------------------|----------------------------------------|-------------------------|------------------------------| | map | `fmap(x, f)` | `map(x, f)` | `lapply(x, f)` | | map over N inputs | `fmapn(list(x, y), f)` | `pmap(list(x, y), f)` | `Map(f, x, y)` | | map over rows | `fmapr(df, f)` | `pmap(df, f)` | `apply(df, 1, f)` | | map over columns | `fmapc(df, f)` | `imap(df, f)` | `Map(f, df, names(df))` | | grouped map | `fmapg(df, f, by = "g")` | `map(split(df, df$g), f)` | `lapply(split(df, df$g), f)` | | walk | `fwalk(x, f)` | `walk(x, f)` | `for (i in x) f(i)` | | reduce | `` freduce(x, `+`) `` | `` reduce(x, `+`) `` | `` Reduce(`+`, x) `` | | compose | `fcompose(f, g)` | `compose(f, g)` | `function(x) f(g(x))` | | repeat / simulate | `frepeat(times = n, expr = f)` | `map(seq_len(n), ~ f())`| `replicate(n, f())` | | parallel + progress | `fmap(x, f, ncores = 4, pb = TRUE)` | `furrr::future_map()` + `progressr` | `parLapply(cl, x, f)` | Differences that matter: * **Dependencies.** `functionals` imports only `parallel`. `purrr` pulls in `rlang`, `cli`, `vctrs`, and more. * **Anonymous functions.** `functionals` takes plain closures only, with no `~ .x + 1` formula shorthand. R's native `\(x) x + 1` covers most of that gap. * **Parallelism is built in.** In `purrr` you switch to `furrr` and set a `future` plan; here it is the `ncores` argument. * **Type-stable output.** `purrr` has `map_dbl()`, `map_chr()`, ... `functionals` always returns a list; simplify yourself with `unlist()`, `vapply()`, or `do.call(rbind, .)`. ## Performance versus `purrr` The script `system.file("benchmarks/bench-purrr.R", package = "functionals")` compares the engines **single-threaded** (`ncores = NULL`, `pb = FALSE`, `mc.cores = 1`), with an identical closure for every engine, across problem sizes and two payload weights. The results below are precomputed; re-run the script to reproduce them on your machine. ```{r bench-table, echo = FALSE} rds <- system.file("benchmarks/bench-purrr-results.rds", package = "functionals") if (nzchar(rds)) { b <- readRDS(rds) r <- b$results keep <- r$expression %in% c("functionals::fmap", "purrr::map", "base::lapply") wide <- reshape( r[keep, c("expression", "n", "payload", "median_ms")], idvar = c("n", "payload"), timevar = "expression", direction = "wide" ) names(wide) <- sub("median_ms\\.", "", names(wide)) wide[order(wide$payload, wide$n), ] } else { message("benchmark rds not installed; run inst/benchmarks/bench-purrr.R") } ``` ```{r bench-note, echo = FALSE, results = "asis"} if (nzchar(rds)) { cat(sprintf( "\n_Timings in milliseconds (median). %s, %s; functionals %s, purrr %s._\n", b$sysname, b$r_version, b$versions[["functionals"]], b$versions[["purrr"]] )) } ``` The takeaway is deliberately unglamorous: with one thread and a cheap `.f`, all three are within a small constant factor: `fmap()` is `lapply()` plus an argument check, and `purrr::map()` is also compiled C. Raw sequential speed is not why you reach for `functionals`. The reasons are the *uniform* interface, built-in `ncores` parallelism, the completion-driven progress bar, and the `.on_error` / `.seed` controls that the next vignette puts to work in a modeling pipeline.