Tracks
/
R
R
/
Syllabus
/
Functional Programming
Fu

Functional Programming in R

1 exercise

About Functional Programming

What is functional programming?

That is a big question deserving a long answer (Wikipedia provides one, Advanced R provides another).

For our purposes, these are some key features:

  • Pure functions, which map inputs to outputs consistently and with no side effects.
  • Immutable data.
  • First-class functions, which can be treated as values like any other type.
  • Higher-order functions, which take other functions as arguments or as return values.

However, R is not a pure functional language (in contrast to, for example, Haskell).

View R as a pragmatic language, which (increasingly) provides functional capabilities where these are a clean and easy way to get a job done. Generally, data analysis is the goal, and programming is just a means to that end.

Pipes

The use of pipes |> to connect functions is an increasingly common technique in several languages.

In brief:

  • A result from the left of the pipe becomes the first argument in the function to the right (where "right" ignores optional line breaks).
  • Any remaining arguments can be included in the function call.
  • In R, we must pipe to a function call, not just a name, so parentheses are always required (this differs from some other languages).

Some programmers find it helpful to pronounce the pipe as "then" when reviewing code: do step 1 THEN do step 2...

The example below illustrates the syntax.

library(stringr)

"Monday-25-February" |> 
  str_split("-") |>     
  unlist() |>               # [1] "Monday"   "25"       "February"
  str_sub(1, 3) |>          # [1] "Mon" "25"  "Feb"
  str_to_upper() |>         # [1] "MON" "25"  "FEB"
  str_flatten(collapse = ", ")
#> [1] "MON, 25, FEB"

This type of pipe was added to Base R in v4.1.0, and you may see it referred to as the "native pipe".

Previously, piping was done with %>% from the magrittr package (the name is a pun for the art lovers). The "magrittr pipe" is still available, but native pipes avoid the need for an external package, and the implementation is simpler in some details.

The RStudio editor (among others) uses Ctrl-Shift-M as a keyboard shortcut to add a pipe with suitable spaces.

Apply functions

We saw previously that many functions in R are "vectorized": they will operate across entire vectors, with no need to write explicit loops.

Functions using only vectorized functions are themselves likely to be automatically vectorized. In a trivial example:

triple <- function(x) return(3 * x)
triple(1:10)
 [1]  3  6  9 12 15 18 21 24 27 30

However, more complex functions may not accept vector input, for example if they contain loops or if-else blocks.

We need a more general way to apply functions to vectors (similar to map() in some other languages).

Because R has a rich variety of data structures in the base language, it also has a whole family of *apply() functions to operate on them. More technically, these are often called "higher-order functions" by programmers and "functionals" by mathematicians, because they take functions as arguments.

For now, consider lapply() and sapply().

lapply()

"List-apply" is designed to work on list inputs, but vectors will be silently coerced to lists as necessary.

The output is a list, so this may need unpacking to get a vector:

h <- function(size) {
    switch(size,
           "small" = 6,
           "medium" = 8,
           "large" = 10
    )
}

v <- c("small", "large", "medium")

h(v) # bad use of an unvectorized function
#> Error in switch(size, small = 6, medium = 8, large = 10) : 
#>   EXPR must be a length 1 vector

lapply(v, h)  # output is a list
#> [[1]]
#> [1] 6

#> [[2]]
#> [1] 10

#> [[3]]
#> [1] 8

lapply(v, h) |> unlist() # output is a vector
#> [1]  6 10  8

Students familiar with the Switch Concept may notice that we could alternatively replace scalar switch with vectorized recode_values() from the dplyr library. Vectorization is good, and modern R provides multiple ways to achieve it.

library(dplyr)

h <- function(size) {
  recode_values(size,
         "small" ~ 6,
         "medium" ~ 8,
         "large" ~ 10
  )
}

v <- c("small", "large", "medium")
h(v)
#> [1]  6 10  8

sapply()

"Simplified-apply" will do something similar to lapply, but also tries to simplify the return value: for example, a named vector instead of a list. Depending on context, use of unname() may be necessary:

sapply(v, h)
 small  large medium 
     6     10      8 

sapply(v, h) |> unname()
#> [1]  6 10  8

The purrr library

lapply and sapply are simple, familiar to most R users, and still quite popular for vectors and simple lists.

Base R also has several other apply functions:

  • vapply()is similar to sapply() but also has a required parameter to specify the output format.
  • apply() for matrices and higher-dimensional arrays.
  • mapply() for multiple list or vector arguments in parallel.
  • tapply() is harder to explain, but works with grouping operations and ragged arrays.
  • vectorize() is a rather mysterious function, which appears to be a wrapper for mapply().

Enough, already!

Providing a simpler and more consistent replacement for this sort of confusion is a key goal of the Tidyverse.

There is now the purrr package, which provides several additional options such as map(), while improving consistency in design, and clarity in error messages.

Less constrained by backwards compatibility than base R, this package moves R significantly closer to modern functional programming as found in other languages.

There are many and diverse functions in purrr, which defy easy summary. Resources to help navigate the library include:

Note

The purrr library operates almost entirely on 1-dimensional data: vectors and lists.

We will see in a later concept that the dplyr library provides equivalent functionality for 2-D dataframes.

The many map functions

The map() function applies a function to each element in the input, and always returns a list.

So far, this sounds like lapply(), just renamed, and maybe with better error messages.

However, map() is just one member of a large family.

Some questions to think about in picking a function:

  • What do you want to return? map() always returns a list, but if you want a particular type of atomic vector use map_chr() for strings, map_int() for integers, etc.
  • Do you need each positional index from the input? Preface the function names with i, such as imap().
  • Do you want to process multiple inputs in parallel?
    • Replace map with map2 in the name, as in map2_dbl() for two inputs (e.g. values and weights, to calculate a weighted mean)
    • Replace with pmap for a list of parallel inputs, as in pmap_chr(). And yes, hardware-level parallelism is possible.
  • Do you want to process all elements of the input? If not, think about map_if() to apply a predicate (boolean) filter, map_at() to specify positional index criteria, head_while() to process the input until an element fails a predicate.
  • Is the input data deeply nested? There are functions for that situation.
  • Do you want the return value to always be of the same type as the input? Use modify() functions instead of map, or modify_tree() for recursive application.

These functions have the input data as first argument whenever possible, to ensure they work well with pipes.

Also, purrr is (deliberately) more pedantic than Base R. If you ask for something specific, the functions will return exactly that or stop with an error message. Harsh, but very effective at reducing hard-to-find bugs.

Reduce functions

Map functions generally have a return value the same length as the input.

Some other functions reduce the dimensionality of the data, for example sum().

sum(1:100)
#> [1] 5050

In the above case, we convert a length-100 vector to a length-1 integer. We will see in a future Concept on matrices and arrays that the concept of dimension-reduction is more general.

The sum() function is built in (as are many other statistical functions). However, we need a way to apply arbitrary dimension-reducing functions across a data structure, using some higher-order function equivalent to map().

In several other languages, function names such as fold, foldl and foldr are used. In R, the relevant function is reduce(), corresponding to the well-known (to algorithm enthusiasts) MapReduce framework.

The first argument is, as usual, the input data. The second argument is a 2-argument function: often an anonymous function, but possibly as simple as an arithmetic operator such as +.

# surround an infix operator with backticks, to use as a function
reduce(1:100, `+`)
#> [1] 5050  # same as sum(1:100)

# anonymous function, alternately add and subtract
reduce(1:10, \(accum, nextval) ifelse(nextval %% 2 == 0, accum + nextval, accum - nextval))
#> [1] 7

Does it matter whether we apply the function from the beginning or the end of the input?

Not with associative operators such as + or *. Clearly, (1 + 2) + 3 == 1 + (2 + 3).

In contrast, - and / are non-associative:

(1 - 2) - 3
#> [1] -4
1 - (2 - 3)
#> [1] 2

For functions where direction matters, there is an optional .dir argument.

# default is .dir = "forward"
reduce(1:10, `-`)
#> [1] -53
reduce(1:10, `-`, .dir = "backward")
#> [1] -5

As with map() and map2(), there is also a reduce2() function to operate on two input vectors in parallel.

In R, we like to work with vectors. Suppose, instead of a single final value, you want to see all the intermediate steps.

For this, the corresponding functions are accumulate() and accumulate2(). The syntax is similar to reduce(), with some extra optional arguments to specify output type.

accumulate(1:10, `+`)
#> [1]  1  3  6 10 15 21 28 36 45 55

Some common types of accumulate have their own functions. The Vector Functions Concept discussed cumsum() and cumprod() in Base R, and the dplyr library has cummean() for numerical averages, cumall() and cumany() for logical vectors.

Filter functions

Commonly, we want to include (or exclude) elements of the input that match some predicate.

In the Vector Filtering Concept we saw one idiomatic way to do this.

v <- 1:3
v[v >= 2]
#> [1] 2 3

This is much-used in traditional R, but it fits poorly into modern functional pipelines.

Consequently, purrr provides a set of predicate functions, which follow the usual argument convention of f(input, func, ...) to work with pipes.

The names are a bit different from most other languages (names like filter and drop already mean something entirely different in R).

The corresponding purrr functions include keep() and discard(), plus several others.

Also, we have every(), some() and none(), to replace all() and any() in returning a single logical value.

Recursion

Most functional languages use recursion as their preferred alternative to loops.

R has other and often better options: vectorized functions and higher-order functions.

However, recursion is supported in R, and can be useful in contexts such as traversing tree structures.

The maximum recursion depth defaults to 5000, which is relatively generous (Python defaults to 1000). It is possible to override the default.

Tail-call optimization is not standard in R, though some workarounds have been developed.

Tradition demands that we show a factorial example at this point, as the classic example of a recursive definition.

fact <- function(n) ifelse(n == 0, 1, n * fact(n - 1))
fact(5)
#> [1] 120

# some more idiomatic R:
factorial(5)
#> [1] 120
prod(1:5)
#> [1] 120

You can use recursion in R, and sometimes it is valuable, but there are often simpler approaches.

Edit via GitHub The link opens in a new window or tab

Learn Functional Programming