Course contents

Part IChapter 8R foundations and data

Functions, Decisions, and Debugging

On this page
  1. 1. A custom function saves repetition
  2. 2. Use if to choose an action
  3. 3. A loop repeats instructions
  4. 4. A list holds things of different kinds
  5. 5. Apply a function to every element
  6. 6. Writing testable code
  7. 7. Debugging: five questions
  8. 8. Reproducible scripts and random seeds
  9. 9. Milestone: you finished Part I
  10. Try it
  11. Checkpoint

1. A custom function saves repetition

A function takes inputs, does work, and gives an output. You used mean() earlier; now you will define your own.

convert_cm_to_m <- function(cm) {
  meters <- cm / 100
  return(meters)
}
convert_cm_to_m(175)

Output

[1] 1.75
convert_cm_to_m(162)

Output

[1] 1.62

Read the first line as “create a function named convert_cm_to_m with an input called cm.” The braces contain its instructions. return() specifies what comes back. Here 175 cm prints as 1.75 and 162 cm as 1.62, the same heights in meters.

2. Use if to choose an action

describe_sbp <- function(sbp) {
  if (is.na(sbp)) {
    return("Unknown")
  }
  if (sbp >= 140) {
    return("At least 140")
  } else {
    return("Below 140")
  }
}
describe_sbp(150)

Output

[1] "At least 140"
describe_sbp(120)

Output

[1] "Below 140"
describe_sbp(NA_real_)

Output

[1] "Unknown"

Reading the output: 150 gives "At least 140", 120 gives "Below 140", and the missing value gives "Unknown" because the first if catches it before any comparison.

These thresholds illustrate programming, not a clinical diagnostic rule. A single reading should not be used to diagnose hypertension.

In a traditional if statement, the condition must be a single TRUE or FALSE. For an entire column, use vectorized functions like dplyr::case_when() or if_else().

3. A loop repeats instructions

ages <- c(25, 40, 62)
for (a in ages) {
  print(a + 1)
}

Output

[1] 26
[1] 41
[1] 63

A loop is useful when an operation genuinely must run repeatedly. Often vectorized R code is simpler:

ages + 1

Output

[1] 26 41 63

Both add one year to each age. The vector version is shorter and usually faster.

4. A list holds things of different kinds

A vector holds values of one type. A list can hold anything side by side: a number, some text, a whole vector. You need lists whenever one object has to carry mixed pieces, and many R functions return their results as a list.

patient <- list(id = "P001", age = 54, sbp_readings = c(128, 135, 131), smoker = FALSE)
str(patient)

Output

List of 4
 $ id          : chr "P001"
 $ age         : num 54
 $ sbp_readings: num [1:3] 128 135 131
 $ smoker      : logi FALSE
length(patient)

Output

[1] 4
names(patient)

Output

[1] "id"           "age"          "sbp_readings" "smoker"      

str() shows one line per element: its name, type and first values. The list has 4 elements, and names() lists their labels.

Three ways to pull pieces out look alike but return different things:

patient["age"]

Output

$age
[1] 54
patient[["age"]]

Output

[1] 54
patient$age

Output

[1] 54
str(patient["age"])

Output

List of 1
 $ age: num 54
str(patient[["age"]])

Output

 num 54
str(patient$age)

Output

 num 54

Single brackets [ ] return a smaller list (str() shows List of 1). Double brackets [[ ]] and $ return the element itself, here the number 54: str() of either shows just num 54, with no List of 1 line. Use [[ ]] or $ when you want the value to calculate with.

Lists grow and shrink by assignment:

patient$clinic <- "North"
patient$age <- 55
patient$smoker <- NULL
names(patient)

Output

[1] "id"           "age"          "sbp_readings" "clinic"      
patient$age

Output

[1] 55

Assigning to a new name adds an element, assigning to an existing name changes it, and assigning NULL removes it. The list now has id, age, sbp_readings and clinic, and age is 55.

Two functions convert between lists and vectors:

scores <- list(a = 1, b = 2, c = 3)
unlist(scores)

Output

a b c 
1 2 3 
as.list(c(x = 10, y = 20))

Output

$x
[1] 10

$y
[1] 20

unlist() flattens a list into one vector, keeping the names. as.list() does the reverse and makes each value its own element.

A data frame is a list of equal-length columns, which is why $ works on it. A model result from chapter 13 onward is also a list, so the same $ and [[ ]] tools open it.

library(readr)
ehr <- read_csv("data/synthetic_ehr.csv", show_col_types = FALSE)
is.list(ehr)

Output

[1] TRUE
length(ehr)

Output

[1] 10
head(ehr[["age"]])

Output

[1] 55 26 63 34 71 42

is.list(ehr) is TRUE, length(ehr) counts columns rather than rows, and ehr[["age"]] is the age column as an ordinary vector.

5. Apply a function to every element

A for loop works through a list one element at a time. The apply family does the same job in one call, and you will meet sapply() again in chapter 24.

readings <- list(a = c(120, 130, 125), b = c(140, 150), c = c(110, 115, 118, 112))
lapply(readings, mean)

Output

$a
[1] 125

$b
[1] 145

$c
[1] 113.75
sapply(readings, mean)

Output

     a      b      c 
125.00 145.00 113.75 

lapply() applies a function to each element and always returns a list. sapply() does the same and simplifies the result to a named vector when it can. vapply() is the stricter version: you state the type of each result, for example vapply(readings, mean, numeric(1)), and R stops if a result does not fit.

sapply(ehr[c("age", "bmi", "sbp")], mean, na.rm = TRUE)

Output

      age       bmi       sbp 
 50.48000  30.15982 122.21379 

A data frame is a list of columns, so sapply() visits one column at a time. Extra arguments such as na.rm = TRUE are passed on to the function. Each printed number is the mean of one column, with missing values dropped.

To walk through two or more lists together, use mapply():

weight_kg <- c(70, 82, 64)
height_m <- c(1.75, 1.80, 1.62)
mapply(function(w, h) w / h^2, weight_kg, height_m)

Output

[1] 22.85714 25.30864 24.38653

mapply() pairs the first weight with the first height, then the second with the second, and so on, giving three BMI values. Map() does the same but returns a list.

Compared with a for loop, the apply functions need no empty result vector set up first; the purrr::map() function from the purrr package works like lapply() with a more consistent interface. For plain arithmetic on a vector, vectorized code such as weight_kg / height_m^2 is still shorter than any of them.

6. Writing testable code

What happens when height is zero? Should a function quietly calculate Infinity? Guard against invalid input.

calculate_bmi <- function(weight_kg, height_m) {
  if (is.na(weight_kg) || is.na(height_m) || height_m <= 0) {
    return(NA_real_)
  }
  weight_kg / height_m^2
}
calculate_bmi(70, 1.7)

Output

[1] 24.22145
calculate_bmi(70, 0)

Output

[1] NA

Reading the output: a valid weight and height give about 24.2, while a height of 0 prints NA instead of dividing and returning Inf.

|| is “or” for two single conditions. | is its vector-friendly counterpart. This function expects a single measurement at a time; a vector-aware version requires different handling.

7. Debugging: five questions

  1. What was I trying to calculate?
  2. Which exact line failed? Read the first informative error, not just the last line.
  3. Does every object exist? Check names and capitalization.
  4. Are types correct? Use class(), str(), and is.na().
  5. Can I test a smaller example? Simplify the input before adding complexity.
x <- c(1, 2, NA)
class(x)

Output

[1] "numeric"
is.na(x)

Output

[1] FALSE FALSE  TRUE
mean(x, na.rm = TRUE)

Output

[1] 1.5

Reading it with the questions: class(x) says "numeric", so the type is fine; is.na(x) flags the third value as TRUE, which explains why mean(x) would return NA; and mean(x, na.rm = TRUE) drops that value and returns 1.5.

To see documentation for a function, use ?mean in the Console. Use traceback() just after an error to inspect where it occurred.

8. Reproducible scripts and random seeds

Random sampling gives different results each time unless you control the initial random state.

set.seed(500)
sample(1:10, size = 3)

Output

[1] 7 5 2
set.seed(500)
sample(1:10, size = 3)

Output

[1] 7 5 2

The two samples match because the seed was reset. A seed makes experiments reproducible but does not make a biased experiment valid.

9. Milestone: you finished Part I

Congratulations, you have finished the R foundations. You opened the practice project in chapter 2 and can now work with code, data and plots on your own.

You can now:

  • set up a project and run code from a script (chapters 1-3);
  • build and index vectors, handle missing values and categories (chapter 4);
  • read a table, inspect it and save it (chapter 5), and work with matrices and lists (chapters 5 and 8);
  • clean data by selecting, filtering, creating columns and summarizing by group (chapter 6);
  • draw histograms, box plots and scatter plots with ggplot2 (chapter 7);
  • write a function, make a decision with if, repeat work with a loop or sapply(), and reproduce random results (chapter 8).

Example: a Part I mini-workflow with a typed vector. Read the first line as a question and the rest as the tools that answer it.

heights_cm <- c(175, 162, NA, 180)      # a vector with one missing value (chapter 4)
to_m <- function(cm) cm / 100           # a function (chapter 8)
heights_m <- to_m(heights_cm)           # apply it to the whole vector
mean(heights_m, na.rm = TRUE)           # summarize, handling the NA (chapter 4)

Output

[1] 1.723333

The function works on every element at once, the NA stays NA, and na.rm = TRUE lets the mean use the three real values: (1.75 + 1.62 + 1.80) / 3 = 1.723. If you can follow each line, you have the Part I skills.

Foundations self-check. Try these without looking back, then open the answer under each question to check yourself.

  1. What is the difference between R and RStudio?
Show solution to question 1

Answer: R is the language and engine that runs code; RStudio is the editor and workspace around it.

Step by step

  1. R does the calculating. You could run it from a plain terminal.
  2. RStudio adds a script editor, a Console, a plot pane and a file browser that send your code to R.
  3. So closing RStudio does not remove R, and R code behaves the same in either place.
  1. What does c(2, 4, 6)[2] return?
Show solution to question 2

Answer: 4, the second element.

Step by step

  1. c(2, 4, 6) builds the vector.
  2. [2] asks for position 2, counting from 1.
c(2, 4, 6)[2]

Output

[1] 4

3. The output is 4, the value in position 2.

  1. What does mean(c(1, NA, 3)) return, and how do you get 2?
Show solution to question 3

Answer: NA, because one value is missing. mean(c(1, NA, 3), na.rm = TRUE) gives 2.

Step by step

  1. R cannot average an unknown value, so the result stays unknown.
mean(c(1, NA, 3))

Output

[1] NA
mean(c(1, NA, 3), na.rm = TRUE)

Output

[1] 2

2. The first line prints NA. With na.rm = TRUE the missing value is dropped and the mean of 1 and 3 is 2.

  1. Which function shows the type of each column of a data frame at a glance?
Show solution to question 4

Answer: str(); glimpse() from dplyr does the same.

Step by step

  1. Both functions print one line per column.
  2. Run one on the synthetic data (the ehr table from section 4).
str(ehr[, 1:4])

Output

tibble [600 x 4] (S3: tbl_df/tbl/data.frame)
 $ patient_id : chr [1:600] "S0001" "S0002" "S0003" "S0004" ...
 $ visit_date : Date[1:600], format: "2025-01-10" "2025-01-19" ...
 $ clinic_site: chr [1:600] "South" "Mobile" "North" "South" ...
 $ age        : num [1:600] 55 26 63 34 71 42 79 50 21 58 ...

3. Each line gives the column name, its type (chr, num, Date) and the first values.

  1. What does df[df$age > 60, ] select?
Show solution to question 5

Answer: The rows where age is over 60, with all columns. The comma leaves the column position empty.

Step by step

  1. df$age > 60 makes a TRUE/FALSE for each row.
  2. Before the comma means rows, so only TRUE rows are kept; after the comma is empty, so every column stays.
older <- ehr[ehr$age > 60, ]
dim(older)

Output

[1] 209  10

3. dim() prints rows then columns: the number of rows with age over 60, and all 10 columns.

  1. Which dplyr function creates a new column?
Show solution to question 6

Answer: mutate().

Step by step

  1. mutate(table, new_name = expression) returns the table with an extra column.
dplyr::mutate(ehr, age_in_months = age * 12)[1:3, c("age", "age_in_months")]

Output

# A tibble: 3 x 2
    age age_in_months
  <dbl>         <dbl>
1    55           660
2    26           312
3    63           756

2. mutate() adds the new column at the right end of the table; it appears next to age here only because the code picked those two columns. It holds age times 12.

  1. How does [[ ]] differ from [ ] on a list?
Show solution to question 7

Answer: [ ] returns a smaller list; [[ ]] returns the element itself.

Step by step

  1. Take a small list.
lst <- list(a = 5, b = "x")
class(lst["a"])

Output

[1] "list"
class(lst[["a"]])

Output

[1] "numeric"

2. lst["a"] is still a list. lst[["a"]] is the number 5, which you can calculate with.

  1. In ggplot(), what does aes() do?
Show solution to question 8

Answer: It maps columns to visual features such as x, y and color.

Step by step

  1. In aes(x = age, y = sbp), the left side is the visual feature and the right side is the column.
  2. A geom then draws using those mappings.
  3. A fixed look (the same for every point) goes outside aes(), as in geom_point(color = "blue").
  1. What does set.seed(1) do before sample()?
Show solution to question 9

Answer: It fixes the random starting point, so the same sample is drawn each time.

Step by step

  1. Set the seed, draw, set the same seed, draw again.
set.seed(1); sample(1:10, 3)

Output

[1] 9 4 7
set.seed(1); sample(1:10, 3)

Output

[1] 9 4 7

2. The two draws are identical. Without set.seed(), each run would differ.

  1. When does a function run: when you define it or when you call it?
Show solution to question 10

Answer: When you call it. Defining it only stores the instructions.

Step by step

  1. Define a function that prints a message.
say_hi <- function() print("running")
  1. Nothing printed. The definition only saved the instructions under the name say_hi.
say_hi()

Output

[1] "running"

3. Now the message prints, because the function was called.

What changes in Part II. Part I taught the tools. Part II uses them to answer one question with data: you will state a research question, describe the data with statistics, split it for honest testing, and fit and compare models, ending with a complete capstone project in chapter 18. Parts III to VI then add more supervised methods, unsupervised learning, other kinds of learning, and ways to choose among them.

Try it

  1. Write a function that converts Fahrenheit to Celsius: (F - 32) × 5 / 9.
Show solution to question 1

Answer: to_celsius <- function(f) (f - 32) * 5 / 9. Calling to_celsius(98.6) gives 37.

Step by step

  1. Name the function and give it one input, f.
  2. Put the formula in the body. The last value computed is returned, so return() is optional.
to_celsius <- function(f) (f - 32) * 5 / 9
to_celsius(98.6)

Output

[1] 37
to_celsius(c(32, 212))

Output

[1]   0 100

3. 98.6 F prints as 37, and the vector call shows 0 and 100, the freezing and boiling points of water. The formula works on a whole vector because arithmetic is vectorized.

  1. Change calculate_bmi() so negative weights return NA.
Show solution to question 2

Answer: Make a copy named calculate_bmi2() and add weight_kg <= 0 to its guard condition; a negative weight now returns NA.

Step by step

  1. The existing guard returns NA_real_ for missing input or a height of zero or less.
  2. Add a test for the weight, joined with ||. A weight of zero is not meaningful either, so this version uses <= 0.
calculate_bmi2 <- function(weight_kg, height_m) {
  if (is.na(weight_kg) || is.na(height_m) ||
      weight_kg <= 0 || height_m <= 0) {
    return(NA_real_)
  }
  weight_kg / height_m^2
}
calculate_bmi2(70, 1.7)

Output

[1] 24.22145
calculate_bmi2(-70, 1.7)

Output

[1] NA

3. A normal input prints 24.22145 (about 24.2) and the negative weight prints NA.

  1. Explain the difference between a function input and its output.
Show solution to question 3

Answer: Inputs are the values you supply when you call a function; the output is the value it returns.

Step by step

  1. In to_celsius(98.6), the input is 98.6, assigned to the argument f.
  2. The function calculates inside its braces. These steps are not visible to the caller.
  3. The returned value is what you can store or print.
temp_c <- to_celsius(98.6)
temp_c

Output

[1] 37

4. temp_c holds the output. Without storing it, R just prints it.

  1. Show how to reproduce the same random sample twice.
Show solution to question 4

Answer: Call set.seed() with the same number before each sample().

Step by step

  1. Set a seed and draw.
  2. Set the same seed and draw again.
set.seed(500)
sample(1:10, size = 3)

Output

[1] 7 5 2
set.seed(500)
sample(1:10, size = 3)

Output

[1] 7 5 2

3. The two draws match. Without the second set.seed(), the second draw would continue the random sequence and differ.

  1. Explain why code should stop rather than silently continue when a required patient ID is absent.
Show solution to question 5

Answer: A missing ID means the row cannot be linked to the right person. Carrying on would produce results that look fine but are wrong, so stopping loudly protects the work that follows.

Step by step

  1. Without an ID, you cannot tell whether two rows are the same patient or whether a result belongs to the right one.
  2. A silent default such as NA can flow into means and counts without any message.
  3. stop() halts at the first problem and tells you where:
check_id <- function(id) {
  if (is.na(id) || id == "") stop("patient ID is required")
  id
}
check_id("P001")

Output

[1] "P001"
tryCatch(check_id(NA), error = function(e) conditionMessage(e))

Output

[1] "patient ID is required"

4. The valid ID passes through. For the missing one, tryCatch() caught the error and returned its message as text. Without it, the error would stop the script, and the Console would show Error in check_id(NA) : patient ID is required and run nothing after it.

  1. Make a list with your name, an age and a vector of three pulse readings. Show the pulse vector with [[ ]], add a clinic element, then remove the age.
Show solution to question 6

Answer: After the edits the list holds name, pulse and clinic. [[ ]] returns the pulse vector itself.

Step by step

  1. Build the list and pull out the pulse with [[ ]].
me <- list(name = "Alex", age = 30, pulse = c(72, 75, 70))
me[["pulse"]]

Output

[1] 72 75 70

2. The output is the plain vector of three readings, not a list. Next, assign a new name to add an element and assign NULL to remove one.

me$clinic <- "North"
me$age <- NULL
names(me)

Output

[1] "name"   "pulse"  "clinic"

3. names() shows name, pulse and clinic: age is gone and clinic is new.

  1. Use sapply() to find the maximum of each of age, sbp and pulse in the synthetic data, ignoring missing values.
Show solution to question 7

Answer: sapply(ehr[c("age", "sbp", "pulse")], max, na.rm = TRUE) returns 83 for age, 154 for sbp and 92 for pulse.

Step by step

  1. A data frame is a list of columns, so ehr[c("age", "sbp", "pulse")] is a list of three columns.
  2. sapply() applies max to each column and passes na.rm = TRUE on to it.
sapply(ehr[c("age", "sbp", "pulse")], max, na.rm = TRUE)

Output

  age   sbp pulse 
   83   154    92 

3. The result is a named vector with one maximum per column.

Checkpoint

You can write and call a simple function, guard against missing inputs, store mixed values in a list, apply a function across elements, reproduce randomness, and interpret common errors.