R Function Operators: Compose, Negate & Partial Application
Function operators take one or more functions as input and return a modified function as output. They let you compose, negate, partially apply, and otherwise transform functions without rewriting them.
A function factory creates functions from parameters. A function operator creates functions from other functions. The distinction is simple: if the input is data, it's a factory. If the input is a function, it's an operator.
Function Composition
Function composition chains two functions together so the output of one feeds directly into the input of the next. Instead of writing f(g(x)), you create a single combined function.
Base R: Compose with the Pipe
R 4.1+ introduced the native pipe |>, which passes the left side as the first argument of the right side.
# Without composition: nested calls (read inside-out)
result <- round(mean(abs(c(-3, 5, -1, 8, -2))), 2)
cat("Nested:", result, "\n")
# With pipe: read left to right
result <- c(-3, 5, -1, 8, -2) |> abs() |> mean() |> round(2)
cat("Piped:", result, "\n")
purrr::compose()
compose() creates a new function by chaining multiple functions. The last function listed runs first (right-to-left by default).
library(purrr)
# compose() chains functions into one
abs_mean <- compose(mean, abs)
cat("abs_mean(c(-3, 5, -1)):", abs_mean(c(-3, 5, -1)), "\n")
# .dir = "forward" for left-to-right order
clean_string <- compose(trimws, tolower, .dir = "forward")
cat("clean_string(' HELLO '):", clean_string(" HELLO "), "\n")
library(purrr)
# Practical: create a data cleaning pipeline as a single function
clean_vector <- compose(
\(x) x[!is.na(x)], # remove NAs
\(x) round(x, 2), # round
sort, # sort
.dir = "forward"
)
messy <- c(3.14159, NA, 1.41421, 2.71828, NA, 0.57722)
cat("Cleaned:", clean_vector(messy), "\n")
Negate: Flipping TRUE to FALSE
Negate() (base R) or negate() (purrr) takes a predicate function and returns a new function that returns the opposite logical value.
# is.na returns TRUE for NAs
x <- c(1, NA, 3, NA, 5)
# Negate flips it: TRUE becomes FALSE and vice versa
is_not_na <- Negate(is.na)
cat("is.na: ", is.na(x), "\n")
cat("is_not_na:", is_not_na(x), "\n")
# Use with Filter
cat("Non-NA values:", Filter(Negate(is.na), x), "\n")
library(purrr)
# purrr's negate() works the same way
is_even <- \(x) x %% 2 == 0
is_odd <- negate(is_even)
numbers <- 1:10
cat("Even:", numbers[map_lgl(numbers, is_even)], "\n")
cat("Odd: ", numbers[map_lgl(numbers, is_odd)], "\n")
Partial Application
Partial application fixes some arguments of a function, returning a new function that takes the remaining arguments. It's like pre-filling a form — you lock in some values and leave the rest blank.
purrr::partial()
library(purrr)
# Fix the 'na.rm' argument of mean
safe_mean <- partial(mean, na.rm = TRUE)
data_with_na <- c(10, 20, NA, 40, 50)
cat("mean (default):", mean(data_with_na), "\n") # NA
cat("safe_mean: ", safe_mean(data_with_na), "\n") # 30
These purrr operators wrap a function to handle errors gracefully.
library(purrr)
# safely() returns a list with $result and $error
safe_log <- safely(log)
cat("safe_log(10):\n")
str(safe_log(10))
cat("\nsafe_log('text'):\n")
str(safe_log("text"))
library(purrr)
# possibly() returns a default value on error
maybe_log <- possibly(log, otherwise = NA)
inputs <- list(10, "text", 100, NULL, 1)
results <- map_dbl(inputs, maybe_log)
cat("Results:", results, "\n")
Use safely() when you need to inspect errors. Use possibly() when you just want a default fallback. Both are function operators — they take a function and return a modified function.
Combining Operators
Function operators compose naturally. Chain them to build sophisticated behavior.
**Explanation:** `compose(.dir = "forward")` applies functions left-to-right: trim first, then lowercase, then collapse spaces.
Exercise 2: Safe Map Pipeline
Use possibly() to safely parse a mix of valid and invalid numbers.
library(purrr)
raw <- c("42", "3.14", "abc", "100", "xyz", "0.5")
# Convert to numbers safely, using NA for failures
# Then calculate mean of successful conversions
**Explanation:** `possibly(as.numeric, NA)` wraps `as.numeric` so it returns `NA` instead of throwing a warning on non-numeric input. Combine with `map_dbl` for type-safe output.
FAQ
What is the difference between compose() and the pipe |>?
The pipe |> applies functions to data immediately: x |> f() |> g() runs now. compose(g, f) creates a new function that you can call later, pass to map(), or assign to a variable. Compose creates tools; pipes use them.
When should I use partial() vs writing a wrapper function?
Use partial() for simple argument pre-filling — it's concise and self-documenting. Write a wrapper when you need conditional logic, validation, or transformation of arguments before passing them to the inner function.
Does Negate() work with functions that return vectors?
Yes. Negate(f) applies ! to whatever f returns. If f returns a logical vector, Negate(f) returns the element-wise negation.