What is functional programming?
For our purposes, these are some key features:
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.
The use of pipes |> to connect functions is an increasingly common technique in several languages.
In brief:
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".
The RStudio editor (among others) uses Ctrl-Shift-M as a keyboard shortcut to add a pipe with suitable spaces.
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
purrr librarylapply and sapply are simple, familiar to most R users, and still quite popular for vectors and simple lists.
Base R also has many other apply functions. 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:
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:
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.i, such as imap().map with map2 in the name, as in map2_dbl() for two inputs (e.g. values and weights, to calculate a weighted mean)pmap for a list of parallel inputs, as in pmap_chr(). And yes, hardware-level parallelism is possible.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.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.
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.
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.
We are starting up a cheese club, which will use ML to select new cheeses to offer our cheese-loving customers based on their histories and tastes.
New members are required to fill out an initial survey so we can gather some basic data to start with. Through this, it's been found that there is a subset of emphatic clients who lack nuance in their critiques. As this can end up irrevocably biasing a more nuanced algorithm, there is a separate algorithm set up to handle their needs. You are asked to provide some helper functions to wrangle their data.
While there may be different ways to solve the following tasks, each can be solved with no more than two higher order functions.
The rating system has a five star basis, which simply consists of integers 1:5.
The emphatic customers will only give ratings of 1 or 5, and we want to know if a customer exhibits this behavior.
Implement all_15() which takes a vector of ratings and returns true if all ratings are either 1 or 5, and false otherwise.
all_15(c(2, 3, 4, 4, 1))
#> [1] FALSE
all_15(c(1, 5, 5, 1, 5))
#> [1] TRUE
We need to associate the customer name (or ID) with their ratings.
Implement name_customers(), which takes a vector of names and a list of ratings, and "zips" them into a list of lists.
Inner list elements should have names name and rating.
names <- c("c1", "c2")
ratings <- list(c(2, 3, 5), c(1, 1, 5))
name_customers(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c1"
#> [[1]]$rating
#> [1] 2 3 5
#> [[2]]
#> [[2]]$name
#> [1] "c2"
#> [[2]]$rating
#> [1] 1 1 5
We need to separate the more emphatic customers from the others.
Implement emphatics() which takes customers and ratings.
Returns a list restricted to those who only use only 1 or 5 star ratings.`
# names and ratings as in the task 2 example
emphatics(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c2"
#> [[1]]$rating
#> [1] 1 1 5
Since the emphatic customers only use 1 and 5 ratings, it will more computationally convenient if these are changed these to 0 and 1.
Implement to_binary() which takes vector of emphatic ratings.
Returns binary ratings, where 1 has been changed to 0 and 5 has been changed to 1.
to_binary(c(1, 1, 5, 5, 1))
#> [1] 0 0 1 1 0
You want to keep improving recommendations for all individual customers (not just emphatics) over time, as you understand their tastes better.
Implement satisfactions() which takes a vector of ratings.
Returns a vector of cumulative average ratings to this point.
Round the results to 2 decimal places.
Hope that the numbers keep growing, for happy customers!
satisfactions(c(1, 2, 2, 3))
#> [1] 1.00 1.50 1.67 2.00
Sign up to Exercism to learn and master R with 21 concepts111 exercises, and real human mentoring, all for free.