[1] -1.2649111 -0.6324555 0.0000000 0.6324555 1.2649111
Maybe few people who use R have forgotten already that R is functional by heart. R has Python dogma OO system, thanks to Reference Class (RC) and R6. R functions can be treated as Lisp’s macros, where it can let you meddle the function and abstract syntax tree (AST) of the function call.
R has few ways to compose a function, divided by three (3) levels:
1 Level 1: Manual Composition
At the most basic level, we compose functions by manually writing them out. This is the approach most R users start with, and it’s perfectly valid for many use cases.
1.1 Creating Wrapper Functions
Sometimes we want to reuse a composition pattern. We can manually create a wrapper function with function keyword:
While this level of composition is explicit, straightforward, and easy to understand, it requires writing out each function definition manually, which can become repetitive when you have many similar transformations.
1.2 Application in functionals
Many base R and tidyverse functions are functionals: they take a function as input and apply it across data (e.g., sapply(), purrr::map(), integrate()).
To compose functions for functionals, instead of manually writing function from the outside of the call:
sqrt2 = function(x) x^0.5
sapply(1:5, sqrt2)[1] 1.000000 1.414214 1.732051 2.000000 2.236068
You can compose on the fly with anonymous functions (you can just refer it as an “unassigned function” if you want):
sapply(1:5, function(x) x^0.5)[1] 1.000000 1.414214 1.732051 2.000000 2.236068
Or use the modern shorthand \() (after 4.1):
sapply(1:5, \(x) x^0.5)[1] 1.000000 1.414214 1.732051 2.000000 2.236068
Even better — show that \() is just syntactic sugar for function():
quote(\(x) x^2)function(x) x^2
2 Level 2: Using Higher-order Functions
How about we comprises the existing functions and give birth to another function? We can easily make that with purrr::compose(). How about we create a function out of the function? That’s function operators in action.
Higher-order functions can return new functions by combining or modifying existing ones, reducing manual code writing.
2.1 Function Composition with purrr::compose()
The nice thing about this is that it takes multiple functions and creates a single new function that applies them in sequence (the default direction is backwards).
So, instead of writing this manually:
We can compose it through purrr::compose():
purrr::compose(sqrt, mean, log)(1:5)[1] 0.9785184
Furthermore, this is also (almost) as readable as using pipe:
I have a different blog mentioning on how bad can the nested function call go.
This function can be read from left to right (by default, it is read vice-versa):
purrr::compose(sqrt, mean, log, .dir = "forward")(1:5)[1] 0.5166883
Which is an equivalent of:
This function can take the inverse: can be read from left to right (by default, it is read vice-versa):
transform_forward = purrr::compose(sqrt, mean, log, .dir = "forward")
transform_forward(1:5)[1] 0.5166883
Which is an equivalent of:
2.2 Partial Application with purrr::partial()
Another function from purrr is the purrr::partial(), where the application is where you “pre-fill” some arguments of a function, creating a new function with fewer parameters. This is incredibly useful for creating specialized versions of general functions.
Consider this function, where a number argument x is divided by 100:
divide_by_100 = function(x) {
x / 100
}
divide_by_100(500)[1] 5
We can write as:
divide_by_100 = purrr::partial(`/`, e2 = 100)
divide_by_100(500)[1] 5
If you ask “what’s the point of partial application”, this can be particularly useful in conjunction with functionals and other function operators, and also supports rlang and NSE APIs — which means you can write it without “typing too much”.
2.3 Function Operators in general
In case you don’t know, decorators in Python are just “function operators” in general, except it is pretty much tied into Python and coated with syntactic sugars.
Function operators take functions as input and return modified functions as output. They’re like functions that operate on the “function space” rather than the data space.
2.3.1 Example 1:
Function operators can add behavior without changing the core logic:
Calling function with args: 16
Result: 4
[1] 4
If you want to do this with Python, here take a look:
def with_logging(f):
def call(*args):
args_str = ", ".join(str(a) for a in args)
print(f"Calling function with args: {args_str}")
res = f(*args)
print(f"Result: {res}")
return res
return call
@with_logging
def logged_sqrt(x):
return x ** 0.5
print(logged_sqrt(16))Calling function with args: 16
Result: 4.0
4.0
2.3.2 Example 2: Creating a Memoization Operator
Memoization caches function results to speed up repeated calls:
memoize = function(f) {
cache = new.env(parent = emptyenv())
function(...) {
key = paste(list(...), collapse = "_")
if (!exists(key, envir = cache)) {
cache[[key]] = f(...)
}
cache[[key]]
}
}
slow_function = function(x) {
Sys.sleep(1)
x^2
}
fast_function = memoize(slow_function)
system.time(fast_function(5)) user system elapsed
0.00 0.00 1.01
3 Level 3: Programmatic Approach
Swear, this is not an easy thing to do — you need at least better understanding on how to build / generate expressions in R, and this involves understanding metaprogramming in R — Yes, most of the part on manipulating ASTs.
The rlang::new_function() provides an API that programmatically constructs a function expression (yes, it does creates formals, body, and an environment — 3 main components of R functions). Keep in mind that the construction of the function expression with rlang::new_function() happens in runtime.
3.1 Start with the basic first
The new_function() function takes three arguments:
-
args: The formal arguments under that function -
body: An expression that takes the function body -
env: If supplied, this will be the parent environment of the function.
Let’s start with a function that squares the number:
[1] 25
Or instead of list() in args parameter, how about using pairlist2() instead?
box::use(rlang[pairlist2])
square2 = new_function(
args = pairlist2(x = ),
body = expr(x^2),
env = caller_env()
)
square2(5)[1] 25
Alright, you might be asking: What’s the point of using new_function() if we can just use function() instead? The purpose of this approach is that you can programmatically generate functions based on data or configuration.
In fact, this is how I automatically generate torch::nn_module() expression used in my new R package called kindling:
torch::nn_module("MyFFNN2", initialize = function ()
{
self$fc1 = torch::nn_linear(20, 128, bias = TRUE)
self$fc2 = torch::nn_linear(128, 64, bias = TRUE)
self$fc3 = torch::nn_linear(64, 32, bias = TRUE)
self$fc4 = torch::nn_linear(32, 15, bias = TRUE)
self$out = torch::nn_linear(15, 5, bias = TRUE)
}, forward = function (x)
{
x = self$fc1(x)
x = torch::nnf_relu(x)
x = self$fc2(x)
x = torch::nnf_selu(x)
x = self$fc3(x)
x = torch::nnf_softshrink(x, lambd = 0.5)
x = self$fc4(x)
x = torch::nnf_celu(x, alpha = 0.5)
x = self$out(x)
x
})
In which, this function is a reference from this blog post of mine quite a while ago, based on what I learned in Advanced R to automatically generate torch::nn_module().
4 When to Use Each Approach (here are some of my guides)
- Level 1 (Manual): When you’re prototyping, the logic is simple, or you need maximum clarity
- Level 2 (Higher-order): When you want to reuse composition patterns and keep code DRY (Don’t Repeat Yourself)
- Level 3 (Programmatic): When you’re building frameworks, DSLs, or need to generate many similar functions from specifications (my package kindling is one of the many example packages)