Chapter 3 Functional Programming
3.1 Introduction
3.1.1 Function definitions
As mentioned in the functional programming overview functional programming is one of the numerous ways to write code. In functional programming, you write functions that do the computations and then as the user, you call these functions to work for you.
You should be familiar with function definitions in R. For example, suppose you want to compute the square root of a number and want to do so using Newton’s algorithm:
sqrt_newton <- function(a, init, eps = 0.01){
while(abs(init**2 - a) > eps){
init <- 1/2 *(init + a/init)
}
return(init)
}
You can then use this function to get the square root of a number:
sqrt_newton(16, 2)
## [1] 4.00122
We are using a while
loop inside the body. The body of a function are the instructions that define the function. You can get the body of a function with body(some_func)
] of the function. In pure functional programming languages, like Haskell, you don’t have loops. How can you program without loops, you may ask? In functional programming, loops are replaced by recursion. Let’s rewrite our little example above with recursion:
sqrt_newton_recur <- function(a, init, eps = 0.01){
if(abs(init**2 - a) < eps){
result <- init
} else {
init <- 1/2 * (init + a/init)
result <- sqrt_newton_recur(a, init, eps)
}
return(result)
}
sqrt_newton_recur(16, 2)
## [1] 4.00122
R is not a pure functional programming language though, so we can still use loops (be it while
or for
loops) in the bodies of our functions. Actually, for R specifically, it is better, performance-wise, to use loops instead of recursion, because R is not tail-call optimized. I won’t got into the details of what tail-call optimization is but just remember that if performance is important a loop will be faster. However, sometimes, it is easier to write a function using recursion. I personally tend to avoid loops if performance is not important, because I find that code that avoids loops is easier to read and debug. However, knowing that you have can use loops is reassuring. In the coming sections I will show you some built-in function that make it possible to avoid writing loops and that don’t rely on recursion, so performance won’t be penalized.
3.1.2 Properties of functions
Mathematical functions have a nice property: we always get the same output for a given input. This is called referential transparency and we should aim to write our R functions in such a way.
For example, the following function:
increment <- function(x){
return(x + 1)
}
Is a referential transparent function. We always get the same result for any x
that we give to this function.
This:
increment(10)
## [1] 11
will always produce 11
.
However, this one:
increment_opaque <- function(x){
return(x + spam)
}
is not a referential transparent function, because its value depends on the global variable spam
.
spam <- 1
increment_opaque(10)
## [1] 11
will only produce 11
if spam = 1
. But what if spam = 19
?
spam <- 19
increment_opaque(10)
## [1] 29
To make increment_opaque()
a referential transparent function, it is enough to make spam
an argument:
increment_not_opaque <- function(x, spam){
return(x + spam)
}
Now even if there is a global variable called spam
, this will not influence our function:
spam <- 19
increment_not_opaque(10, 34)
## [1] 44
This is because the variable spam
defined in the body of the function is a local variable. It could have been called anything else, really. Avoiding opaque functions makes our life easier.
Another property that adepts of functional programming value is that functions should have no, or very limited, side-effects. This means that functions should not change the state of your program.
For example this function (which is not a referential transparent function):
count_iter <- 0
sqrt_newton_side_effect <- function(a, init, eps = 0.01){
while(abs(init**2 - a) > eps){
init <- 1/2 *(init + a/init)
count_iter <<- count_iter + 1 # The "<<-" symbol means that we assign the
} # RHS value in a variable in the global environment
return(init)
}
If you look in the environment pane, you will see that count_iter
equals 0. Now call this function with the following arguments:
sqrt_newton_side_effect(16000, 2)
## [1] 126.4911
print(count_iter)
## [1] 9
If you check the value of count_iter
now, you will see that it increased! This is a side effect, because the function changed something outside its scope. It changed a value in the global environment. In general, it is good practice to avoid side-effects. For example, we could make the above function not have any side effects like this:
sqrt_newton_count <- function(a, init, count_iter = 0, eps = 0.01){
while(abs(init**2 - a) > eps){
init <- 1/2 *(init + a/init)
count_iter <- count_iter + 1
}
return(c(init, count_iter))
}
Now, this function returns a list with two elements, the result, and the number of iterations it took to get the result:
sqrt_newton_count(16000, 2)
## [1] 126.4911 9.0000
Writing to disk is also considered a side effect, because the function changes something (a file) outside its scope. But this cannot be avoided (and it’s actually a good thing to have, functions that can write to disk) so just remember: try to avoid having functions changing variables in the global environment unless you have a very good reason of doing so.
Finally, another property of mathematical functions, is that they do one single thing. Functional programming purists also program their functions to do one single task. This has benefits, but can complicate things. The function we wrote previously does two things: it computes the square root of a number and also returns the number of iterations it took to compute the result. However, this is not a bad thing; the function is doing two tasks, but these tasks are related to each other and it makes sense to have them together. My piece of advice: avoid having functions that do too many unrelated things. This makes debugging harder.
In conclusion: you should strive for referential transparency, try to avoid side effects unless you have a good reason to have them and try to keep your functions short and do as little tasks as possible. This makes testing and debugging easier, as you will see.
3.2 Mapping and Reducing: the base way
No introduction to functional programming would be complete without some discussion about the functions Map()
(and the associated *apply()
family of functions) and Reduce()
. Map()
allows you to map your function to every element of a list of arguments and is easy to understand, while Reduce()
(sometimes called fold()
in other programming languages) reduces a list of values to a single value by successively applying a function. It’s a bit harder to understand, but with some examples it will become clear soon enough. In this section we will focus on how to do things using base
functions. In the next section we will take a look at the purrr
package which extends R’s functional programming capabilities tremendously.
3.2.1 Mapping with Map()
and the *apply()
family of functions
Now that we have our nice function that computes square roots using Newton’s algorithm, we would like to compute the square root of every element in the following list:
numbers <- c(16, 25, 36, 49, 64, 81)
sqrt_newton(numbers, init = rep(1, 6), eps = rep(0.001, 6))
## Warning in while (abs(init^2 - a) > eps) {: the condition has length > 1
## and only the first element will be used
## Warning in while (abs(init^2 - a) > eps) {: the condition has length > 1
## and only the first element will be used
## Warning in while (abs(init^2 - a) > eps) {: the condition has length > 1
## and only the first element will be used
## Warning in while (abs(init^2 - a) > eps) {: the condition has length > 1
## and only the first element will be used
## Warning in while (abs(init^2 - a) > eps) {: the condition has length > 1
## and only the first element will be used
## Warning in while (abs(init^2 - a) > eps) {: the condition has length > 1
## and only the first element will be used
## [1] 4.000001 5.000023 6.000253 7.001406 8.005148 9.014272
We get a whole bunch of nasty warning messages, but we do get the expected result. But you should not leave it like this. Who knows what may happen some time down the road, when you try to compose this function with another? Maybe you’ll get an error and you won’t understand why! Let’s rewrite the function properly.
We get these warnings because the condition (init^2 - a) > eps
does not make sense for vectors. Here, R tells the user that it only uses the first element and then does the computation anyways. I would prefer if R would stop the execution and print an error message. This would force the user to have to rewrite the function to explicitly take vectors into account. And there is a very simple way of doing it, by using the function Map()
:
Map(sqrt_newton, numbers, init = 1)
## [[1]]
## [1] 4.000001
##
## [[2]]
## [1] 5.000023
##
## [[3]]
## [1] 6.000253
##
## [[4]]
## [1] 7
##
## [[5]]
## [1] 8.000002
##
## [[6]]
## [1] 9.000011
Map()
applies a function to every element of a list and returns a list.
We could then write a wrapper around Map()
:
sqrt_newton_vec <- function(numbers, init, eps = 0.01){
return(Map(sqrt_newton, numbers, init, eps))
}
sqrt_newton_vec(numbers, 1)
## [[1]]
## [1] 4.000001
##
## [[2]]
## [1] 5.000023
##
## [[3]]
## [1] 6.000253
##
## [[4]]
## [1] 7
##
## [[5]]
## [1] 8.000002
##
## [[6]]
## [1] 9.000011
As you can see, we can give a function as an argument to another function. This makes Map()
a higher-order function. Higher-order functions are functions that take other functions as arguments and return either another function, or a value. This is another important concept in functional programming and encourages modularity. It makes your code easily reusable!
R has other higher-order functions that work like Map()
, such as apply(), lapply(), mapply(), sapply(), vapply()
and tapply()
. Depending on what you want to do, you will have to use one or the other. apply()
and ‘tapply()’ are different from the other *apply()
functions, because they work on arrays. You can apply a function on the rows or columns of an array, for example if you want a row-wise sum:
a <- cbind(c(1, 2, 3), c(4, 5, 6), c(7, 8, 9))
apply(a, 1, sum)
## [1] 12 15 18
We could use lapply()
instead of Map()
:
lapply(numbers, sqrt_newton, init = 1)
## [[1]]
## [1] 4.000001
##
## [[2]]
## [1] 5.000023
##
## [[3]]
## [1] 6.000253
##
## [[4]]
## [1] 7
##
## [[5]]
## [1] 8.000002
##
## [[6]]
## [1] 9.000011
or sapply()
:
sapply(numbers, sqrt_newton, init = 1)
## [1] 4.000001 5.000023 6.000253 7.000000 8.000002 9.000011
We could rewrite sqrt_newton_vec()
with sapply()
which would return a better looking result (a list of numbers instead of a list of lists):
sqrt_newton_vec <- function(numbers, init, eps = 0.01){
return(sapply(numbers, sqrt_newton, init, eps))
}
sqrt_newton_vec(numbers, 1)
## [1] 4.000001 5.000023 6.000253 7.000000 8.000002 9.000011
mapply()
is different from these two:
inits <- c(100, 20, 3212, 487, 5, 9888)
mapply(sqrt_newton, numbers, init = inits)
## [1] 4.000284 5.000001 6.000003 7.000006 8.000129 9.000006
What happens here is that sqrt_newton()
gets called with following arguments:
sqrt_newton(numbers[1], inits[1])
## [1] 4.000284
sqrt_newton(numbers[2], inits[2])
## [1] 5.000001
sqrt_newton(numbers[3], inits[3])
## [1] 6.000003
sqrt_newton(numbers[4], inits[4])
## [1] 7.000006
sqrt_newton(numbers[5], inits[5])
## [1] 8.000129
sqrt_newton(numbers[6], inits[6])
## [1] 9.000006
From the Map()
’s documentation, we learn that:
`Map()` is wrapper to `mapply()` which does not attempt to simplify the result...
All this behaviour can be replicated using loops, but once you get the gist of these functions, you can write code that is shorter and easier to read and unlike in the case of recursion, without any loss in performance (but without any gains either).
3.2.2 Reduce()
Reduce()
is another very useful higher-order function, especially if you want to avoid loops to make your code easier to read. In some programming languages, Reduce()
is called fold()
.
I think that the following example illustrates the power of Reduce()
well:
Reduce(`+`, numbers, init = 0)
## [1] 271
Can you guess what happens? Reduce()
takes a function as an argument, here the function +
1 and then does the following computation:
0 + numbers[1] + numbers[2] + numbers[3]...
It applies the user supplied function successively but has to start with something, so we give it the argument init
also. This argument is actually optional, but I show it here because in some cases it might be useful to start the computations at another value than 0
. This function generalizes functions that only take two arguments. If you were to write a function that returns the minimum between two numbers:
my_min <- function(a, b){
if(a < b){
return(a)
} else {
return(b)
}
}
You could use Reduce()
to get the minimum of a list of numbers:
print(numbers)
## [1] 16 25 36 49 64 81
Reduce(my_min, numbers)
## [1] 16
Here we don’t supply an init
because there is no need for it. Of course R’s built-in min()
function works on a list of values. But Reduce()
is a very powerful function that can make our life much easier and most importantly avoid writing clumsy loops.
3.3 Mapping and Reducing: the purrr
way
Hadley Wickham developed a package called purrr
which contains a lot of very useful functions. I will show some of them here, but in the next chapter, we are going to study purrr
in greater depth. Also, take the time to read purrr
’s documentation, and of course you can read more about purrr
in Wickham and Grolemund (2016).
3.3.1 The map*()
family of functions
In the previous section we saw how to map a function to each element of a list. Each version of an *apply()
function has a different purpose, but it is not very easy to remember which one returns a list, which other one returns an atomic vector and so on. If you’re working on data frames you can use apply()
to sum (for example) over columns or rows, because you can specify which MARGIN
you want to sum over. But you do not get a data frame back. In the purrr
package, each of the functions that do mapping have a similar name. The first part of these functions’ names all start with map_
and the second part tells you what this function is going to output. For example, if you want double
s out, you would use map_dbl()
. If you are working on data frames want a data frame back, you would use map_df()
. These are much more intuitive and easier to remember and we’re going to learn how to use them in the chapter about The Tidyverse. For now, let’s just focus on the basic functions, map()
and reduce()
(and some variants of reduce()
). To map a function to every element of a list, simply use map()
:
library("purrr")
map(numbers, sqrt_newton, init = 1)
## [[1]]
## [1] 4.000001
##
## [[2]]
## [1] 5.000023
##
## [[3]]
## [1] 6.000253
##
## [[4]]
## [1] 7
##
## [[5]]
## [1] 8.000002
##
## [[6]]
## [1] 9.000011
Compared to Map()
, the function and the list are given in the reverse order, but the result is the same, of course.
3.3.2 Reducing with purrr
In the purrr
package, you can find two more functions for folding: reduce()
and reduce_right()
. The difference between reduce()
and reduce_right()
is pretty obvious: reduce_right()
starts from the right!
a <- seq(1, 10)
reduce(a, `-`)
## [1] -53
reduce_right(a, `-`)
## [1] -35
For operations that are not commutative, this makes a difference. Other interesting folding functions are accumulate()
and accumulate_right()
:
a <- seq(1, 10)
accumulate(a, `-`)
## [1] 1 -1 -4 -8 -13 -19 -26 -34 -43 -53
accumulate_right(a, `-`)
## [1] -35 -34 -32 -29 -25 -20 -14 -7 1 10
These two functions keep the intermediary results.
3.4 Basic anonymous functions
One last very useful concept are anonymous functions. Suppose that you want to apply one of your own functions to a list of datasets. For instance, you want to have a histogram of a variable that is called the same accross a list of datasets. Maybe your datasets are yearly surveys and each year the survey was conducted is another .csv
file. For illustration purposes, let us use the mtcars
dataset with some minor changes:
data(mtcars)
mtcars2000 <- mtcars
mtcars2001 <- mtcars
mtcars2001$cyl <- mtcars2001$cyl+3
datasets <- list("mtcars2000" = mtcars2000,
"mtcars2001" = mtcars2001)
In the next chapters we will learn how to load a lot of datasets at once and store them in a list. So it is important to know how to work with datasets that are stored on lists. Now suppose you want to use purrr::map()
to plot a histogram of variable cyl
for each dataset that is contained in your list.
map(datasets, hist, cyl)
Error in hist.default(.x[[i]], ...) : 'x' must be numeric
Maybe try this:
map(datasets, hist(cyl))
Error in hist(cyl) : object 'cyl' not found
So how can we solve this issue? One way is to use an anonymous function. Anonymous functions are functions that get declared on the fly and do not have names. These are especially useful inside higher order functions such as purrr::map()
:
map(datasets, (function(x) hist(x$cyl)))
## $mtcars2000
## $breaks
## [1] 4.0 4.5 5.0 5.5 6.0 6.5 7.0 7.5 8.0
##
## $counts
## [1] 11 0 0 7 0 0 0 14
##
## $density
## [1] 0.6875 0.0000 0.0000 0.4375 0.0000 0.0000 0.0000 0.8750
##
## $mids
## [1] 4.25 4.75 5.25 5.75 6.25 6.75 7.25 7.75
##
## $xname
## [1] "x$cyl"
##
## $equidist
## [1] TRUE
##
## attr(,"class")
## [1] "histogram"
##
## $mtcars2001
## $breaks
## [1] 7.0 7.5 8.0 8.5 9.0 9.5 10.0 10.5 11.0
##
## $counts
## [1] 11 0 0 7 0 0 0 14
##
## $density
## [1] 0.6875 0.0000 0.0000 0.4375 0.0000 0.0000 0.0000 0.8750
##
## $mids
## [1] 7.25 7.75 8.25 8.75 9.25 9.75 10.25 10.75
##
## $xname
## [1] "x$cyl"
##
## $equidist
## [1] TRUE
##
## attr(,"class")
## [1] "histogram"
Here the function is enclosed between ()
and is not named. The function has a single argument x
, which is supposed to be a dataset. We then plot a histogram of the variable cyl
from this dataset. Then this function is mapped to every dataset contained in the list datasets
that we created above.
We can also write anonymous functions that are more complex:
map2(
.x = datasets, .y = names(datasets),
(function(.x, .y) hist(.x$cyl, main=paste("Histogram of cyl in", .y)))
)
## $mtcars2000
## $breaks
## [1] 4.0 4.5 5.0 5.5 6.0 6.5 7.0 7.5 8.0
##
## $counts
## [1] 11 0 0 7 0 0 0 14
##
## $density
## [1] 0.6875 0.0000 0.0000 0.4375 0.0000 0.0000 0.0000 0.8750
##
## $mids
## [1] 4.25 4.75 5.25 5.75 6.25 6.75 7.25 7.75
##
## $xname
## [1] ".x$cyl"
##
## $equidist
## [1] TRUE
##
## attr(,"class")
## [1] "histogram"
##
## $mtcars2001
## $breaks
## [1] 7.0 7.5 8.0 8.5 9.0 9.5 10.0 10.5 11.0
##
## $counts
## [1] 11 0 0 7 0 0 0 14
##
## $density
## [1] 0.6875 0.0000 0.0000 0.4375 0.0000 0.0000 0.0000 0.8750
##
## $mids
## [1] 7.25 7.75 8.25 8.75 9.25 9.75 10.25 10.75
##
## $xname
## [1] ".x$cyl"
##
## $equidist
## [1] TRUE
##
## attr(,"class")
## [1] "histogram"
Of course you could have defined the anonymous function as a regular function before using map()
. But sometimes it is faster to simply use an anonymous function as long as it does not hurt clarity.
This is the end of the introduction to functional programming. Entire books have been written on the subject, such as the upcoming book by Khan (2017) or Lipovaca (2011). If you’re curious about functional programming, you should read these books. For our purposes though, knowing how to write functions, and trying to make them referentially transparent as well as knowing about mapping and reducing is enough to get us going.
3.5 Wrap-up
- Make your functions referentially transparent.
- Avoid side effects (if possible).
- Make your functions do one thing (if possible).
- A function that takes another function as an argument is called an higher-order function. You can write your own higher-order functions and this is a way of having short and easily testable functions. Making these functions then work together is trivial and is what makes functional programming very powerful.
3.6 Exercises
For the following exercises, you will have to use any of the functions that we saw in this chapter. Reduce()
, Map()
or any function from the *apply()
family of functions. Do not use loops! If you don’t know how to solve these exercises wait for the next section, where we’ll learn how to write unit tests. Writing unit tests before the functions they’re supposed to test is called test-driven development and can help you write your functions.
- Create a function that returns the factorial of a number using
Reduce()
. Remember: no recursion nor loops allowed!
my_fact(5)
[1] 120
- Suppose you have a list of data set names. Create a function that removes “.csv” from each of these names. Start by creating a function that does so using
stri_split()
from the packagestringi
(you can also usestrsplit()
from base R). Below is an illustration of how it’s supposed to work:
dataset_names <- c("dataset1.csv", "dataset2.csv", "dataset3.csv")
remove_csv(dataset_names)
[1] "dataset1" "dataset2" dataset3"
- Create a function that takes a number
a
, and then returns either the sum of the numbers from 1 to this number that are divisible by another numberb
or the product of the numbers from 1 to this number that are divisible byb
. Your function should be a higher-order function with the following arguments:a
the number,divisible_func
the function that checks whether a number is divisible by some numberb
andreduce_op
the function that either sums or multiplies the numbers from 1 toa
that are divisible byb
.
reduce_some_numbers(a = 10, divisible_func = divisible, b = 2, reduce_op = `*`)
[1] 3840
References
Wickham, Hadley, and Garrett Grolemund. 2016. R for Data Science. 1st ed. O’Reilly. http://r4ds.had.co.nz/.
Khan, Aslam. 2017. Grokking Functional Programming. 1st ed. Manning Publications. https://www.manning.com/books/grokking-functional-programming.
Lipovaca, Miran. 2011. Learn You a Haskell for Great Good!: A Beginner’s Guide. no starch press. http://learnyouahaskell.com/.
This is simply the
+
operator you’re used to. Try this out:`+`(1, 5)
and you’ll see+
is a function like any other. You just have to write backticks around the plus symbol to make it work.↩