Chapter 10 Functions and control flow statements
10.1 Writing your own functions
So far we’ve been using a variety of built in functions in R. However the real power of a programming language is the ability to write your own functions. Functions are a mechanism for organizing and abstracting a set of related computations. We usually write functions to represent sets of computations that we apply frequently, or to represent some conceptually coherent set of manipulations to data.
The general form of an R function is as follows:
funcname <- function(arg1, arg2) {
# one or more expressions that operate on the fxn arguments
# last expression is the object returned
# or you can explicitly return an object
}
To make this concrete, here’s an example where we define a function to calculate the area of a circle:
Since R returns the value of the last expression in the function, the return
call is optional and we could have simply written:
Very short and concise functions are often written as a single line. In practice I’d probably write the above function as:
The area.of.circle
function takes one argument, r
, and calculates the area of a circle with radius r. Having defined the function we can immediately put it to use:
If you type a function name without parentheses R shows you the
function’s definition. This works for built-in functions as well
(thought sometimes these functions are defined in C code in which case R
will tell you that the function is a .Primitive
).
10.1.1 Function arguments
Function arguments can specify the data that a function operates on or parameters that the function uses. Function arguments can be either required or optional. In the case of optional arguments, a default value is assigned if the argument is not given.
Take for example the log
function. If you examine the help file for the log
function (type ?log
now) you’ll see that it takes two arguments, refered to as x
and base
. The
argument x
represents the numeric vector you pass to the function and is a required argument (see what happens when you type log()
without giving an argument). The argument base
is optional. By default the value of base
is \(e = 2.71828\ldots\). Therefore by default the log
function returns natural logarithms. If you want logarithms to a different base you can change the base
argument as in the following examples:
log(2) # log of 2, base e
#> [1] 0.6931472
log(2,2) # log of 2, base 2
#> [1] 1
log(2, 4) # log of 2, base 4
#> [1] 0.5
Because base 2 and base 10 logarithms are fairly commonly used, there are convenient aliases for calling log
with these bases.
10.1.2 Writing functions with optional arguments
To write a function that has an optional argument, you can simply specify the optional argument and its default value in the function definition as so:
# a function to substitute missing values in a vector
sub.missing <- function(x, sub.value = -99){
x[is.na(x)] <- sub.value
return(x)
}
You can then use this function as so:
m <- c(1, 2, NA, 4)
sub.missing(m, -999) # explicitly define sub.value
#> [1] 1 2 -999 4
sub.missing(m, sub.value = -333) # more explicit syntax
#> [1] 1 2 -333 4
sub.missing(m) # use default sub.value
#> [1] 1 2 -99 4
m # notice that m wasn't modified within the function
#> [1] 1 2 NA 4
Notice that when we called sub.missing
with our vector m
, the vector did not get modified in the function body. Rather a new vector, x
was created within the function and returned. However, if you did the missing value subsitute outside of a function call, then the vector would be modified:
10.1.3 Putting R functions in Scripts
When you define a function at the interactive prompt and then close the interpreter your function definition will be lost. The simple way around this is to define your R functions in a script that you can than access at any time.
In RStudio choose File > New File > R Script
. This will bring up a blank editor window. Type your function(s) into the editor. Everything in this file will be interpretted as R code, so you should not use the code block notation that is used in Markdown notebooks. Save the source file in your R working directory with a name like
myfxns.R
.
# functions defined in myfxns.R
area.of.circle <- function(r) {pi * r^2}
area.of.rectangle <- function(l, w) {l * w}
area.of.triangle <- function(b, h) {0.5 * b * h }
Once your functions are in a script file you can make them accesible by using the source
function, which reads the named file as input and evaluates any definitions or statements in the input file (See also the Source
button in the R Studio GUI):
Having sourced the file you can now use your functions like so:
radius <- 3
len <- 4
width <- 5
base <- 6
height <- 7
area.of.circle(radius)
#> [1] 28.27433
area.of.rectangle(len, width)
#> [1] 20
area.of.triangle(base, height)
#> [1] 21
Note that if you change the source file, such as correcting a mistake or adding a new function, you need to call the source
function again to make those changes available.
10.2 Control flow statements
Control flow statements control the order of execution of different pieces of code. They can be used to do things like make sure code is only run when certain conditions are met, to iterate through data structures, to repeat something until a specified event happens, etc. Control flow statements are frequently used when writing functions or carrying out complex data transformation.
10.2.1 if
and if-else
statements
if
and if-else
blocks allow you to structure the flow of execution so that certain expressions are executed only if particular conditions are met.
The general form of an if
expression is:
if (Boolean expression) {
Code to execute if
Boolean expression is true
}
Here’s a simple if
expression in which we check whether a number is less than 0.5, and if so assign a values to a variable.
x <- runif(1) # runif generates a random number between 0 and 1
face <- NULL # set face to a NULL value
if (x < 0.5) {
face <- "heads"
}
face
#> [1] "heads"
The else
clause specifies what to do in the event that the if
statement is not true. The combined general for of an if-else
expression is:
if (Boolean expression) {
Code to execute if
Boolean expression is true
} else {
Code to execute if
Boolean expression is false
}
Our previous example makes more sense if we include an else
clause.
With the addition of the else
statement, this simple code block can be thought of as simulating the toss of a coin.
10.2.1.1 if-else
in a function
Let’s take our “if-else” example above and turn it into a function we’ll call coin.flip
. A literal re-interpretation of our previous code in the context of a function is something like this:
# coin.flip.literal takes no arguments
coin.flip.literal <- function() {
x <- runif(1)
if (x < 0.5) {
face <- "heads"
} else {
face <- "tails"
}
face
}
coin.flip.literal
is pretty long for what it does — we created a temporary variable x
that is only used once, and we created the variable face
to hold the results of our if-else
statement, but then immediately returned the result. This is inefficient and decreases readability of our function. A much more compact implementation of this function is as follows:
Note that in our new version of coin.flip
we don’t bother to create temporary the variables x
and face
and we immediately return the results within the if-else
statement.
10.2.1.2 Multiple if-else
statements
When there are more than two possible outcomes of interest, multiple if-else
statements can be chained together. Here is an example with three outcomes:
10.2.2 for loops
A for
statement iterates over the elements of a sequence (such as vectors or lists). A common use of for statements is to carry out a calculation on each element of a sequence (but see the discussion of map
below) or to make a calculation that involves all the elements of a sequence.
The general form of a for loop is:
for (elem in sequence) {
Do some calculations or
Evaluate one or more expressions
}
As an example, say we wanted to call our coin.flip
function multiple times. We could use a for loop to do so as follows:
flips <- c() # empty vector to hold outcomes of coin flips
for (i in 1:20) {
flips <- c(flips, coin.flip()) # flip coin and add to our vector
}
flips
#> [1] "tails" "tails" "tails" "tails" "tails" "heads" "heads" "tails"
#> [9] "tails" "heads" "tails" "tails" "heads" "heads" "heads" "heads"
#> [17] "tails" "tails" "heads" "heads"
Let’s use a for
loop to create a multi.coin.flip
function thats accepts an optional argument n
that specifies the number of coin flips to carry out:
multi.coin.flip <- function(n = 1) {
# create an empty character vector of length n
flips <- vector(mode="character", length=n)
for (i in 1:n) {
flips[i] <- coin.flip()
}
flips
}
With this new definition, a single call of coin.flip
returns a single outcome:
And calling multi.coin.flip
with a numeric argument returns multiple coin flips:
multi.coin.flip(n=10)
#> [1] "heads" "tails" "heads" "tails" "tails" "heads" "heads" "heads"
#> [9] "heads" "tails"
10.2.2.1 Efficiency tip
An alternate way to write the multi.coin.flip
function above would be:
## This is inefficient, see description eblow
multi.coin.flip.alt <- function(n = 1) {
flips <- c()
for (i in 1:n) {
flips <- c(flips, coin.flip())
}
flips
}
If you know the final length of your vector, it is much faster to create an empty vector of the needed length:
e.g., vector(mode="character", length=n) # runs fast
than it is to create an empty vector of zero elength, and then extend it sequentially:
e.g., flips <- c(flips, coin.flip()) # runs slow
10.2.3 break
statement
A break
statement allows you to exit a loop even if it hasn’t completed. This is useful for ending a control statement when some criteria has been satisfied. break
statements are usually nested in if
statements.
In the following example we use a break
statement inside a for
loop. In this example, we pick random real numbers between 0 and 1, accumulating them in a vector (random.numbers
). The for
loop insures that we never pick more than 20 random numbers before the loop ends. However, the break
statement allows the loop to end prematurely if the number picked is greater than 0.95.
random.numbers <- c()
for (i in 1:20) {
x <- runif(1)
random.numbers <- c(random.numbers, x)
if (x > 0.95) {
break
}
}
random.numbers
#> [1] 0.346144271 0.886941766 0.319471383 0.817354602 0.627596058
#> [6] 0.840769497 0.448983685 0.006001374 0.830100021 0.863958652
#> [11] 0.489005927 0.847016982 0.727580304 0.029593085 0.440570396
#> [16] 0.105275129 0.473030424 0.745306079 0.141316468 0.339277409
10.2.4 repeat
loops
A repeat
loop will loop indefinitely until we explicitly break out of the loop with a break
statement. For example, here’s an example of how we can use repeat
and break
to simulate flipping coins until we get a head:
10.2.5 next
statement
A next
satement allows you to halt the processing of the current iteration of a loop and immediately move to the next item of the loop. This is useful when you want to skip calculations for certain elements of a sequence:
10.2.6 while statements
A while
statement iterates as long as the condition statement it contains is true. In the following example, the while
loop calls coin.flip
until “heads” is the result, and keeps track of the number of flips. Note that this represents the same logic as the repeat-break
example we saw earlier, but in a a more compact form.
10.2.7 ifelse
The ifelse
function is equivalent to a for
-loop with a nested if-else
statement. ifelse
applies the specified test to each element of a vector, and returns different values depending on if the test is true or false.
Here’s an example of using ifelse
to replace NA
elements in a vector with zeros.
x <- c(3, 1, 4, 5, 9, NA, 2, 6, 5, 4)
newx <- ifelse(is.na(x), 0, x)
newx
#> [1] 3 1 4 5 9 0 2 6 5 4
The equivalent for-loop could be written as:
x <- c(3, 1, 4, 5, 9, NA, 2, 6, 5, 4)
newx <- c() # create an empty vector
for (elem in x) {
if (is.na(elem)) {
newx <- c(newx, 0) # append zero to newx
} else {
newx <- c(newx, elem) # append elem to newx
}
}
newx
#> [1] 3 1 4 5 9 0 2 6 5 4
The ifelse
function is clearly a more compact and readable way to accomplish this.