calculate_bmi <- function(weight, height) {
bmi <- weight / (height^2)
bmi
}
calculate_bmi(weight = 70, height = 1.75)[1] 22.85714
Remember our discussion about vectorization and nested data? We’ve been using functions all along - mean() and sum(). But sometimes, you need a function that doesn’t exist yet. That’s when you write your own!
Here are three signs you need a function:
Writing functions also helps you avoid mistakes. For example, if you need to change a number in multiple places, you may forget to change one of them (speaking from experience :-(
Let’s start with a simple function:
calculate_bmi <- function(weight, height) {
bmi <- weight / (height^2)
bmi
}
calculate_bmi(weight = 70, height = 1.75)[1] 22.85714
Let’s break down the components:
calculate_bmi: The function name (make it descriptive!)function(weight, height): The arguments your function needs{ and }: What your function doesbmi: The last line, which is what your function gives back (you could also write return(bmi))Importantly, the last line of your function is the value that is returned. So you can also write:
calculate_bmi <- function(weight, height) {
weight / (height^2)
}The tidyverse style guide recommends using return() only for early returns, so from here on our examples rely on the implicit return.
Write a function to convert Fahrenheit to Celsius.
Remember how we talked about the importance of vectorization? When writing your own functions, you want them to be vectorized too! The good news is that R makes this easy - if you use vectorized operations inside your function, your function will be vectorized automatically. For example, our function calculate_bmi() is vectorized automatically:
library(tidyverse)
tibble(
weight = c(70, 80, 90),
height = c(1.75, 1.80, 1.85)
) |>
mutate(bmi = calculate_bmi(weight, height))Sometimes it might not be obvious that a function is vectorized. Just checking that it runs inside mutate() is not enough: a function such as function(w, h) mean(w / h^2) “works” but silently returns the same single value for every row. So test two things: the output should have the same length as the input, and it should match what you get one element at a time.
weight <- c(70, 80, 90)
height <- c(1.75, 1.80, 1.85)
length(calculate_bmi(weight, height)) == length(weight)[1] TRUE
calculate_bmi(weight, height)[2] == calculate_bmi(weight[2], height[2])[1] TRUE
Write a function to calculate bill area (length × depth) that works with the penguins dataset.
Some functions work with an entire dataset (a tibble) rather than element by element. When the datasets sit in a list-column (nested data), the column is a list of tibbles, not a tibble, so we need rowwise() to hand one tibble at a time to the function:
# Function that works with a dataset
calculate_r2 <- function(data) {
model <- lm(bill_length_mm ~ bill_depth_mm, data = data)
summary(model)$r.squared
}
library(palmerpenguins)
# Need rowwise() so that `data` is one tibble (not a list of tibbles) in each call
penguins |>
group_by(species) |>
nest() |>
rowwise() |> # Important! Because calculate_r2 is not vectorized
mutate(
r2 = calculate_r2(data)
) |>
ungroup()dplyr: Tidy EvaluationWe now touch a (used to be scary) topic known as tidy evaluation. In tidyverse, a great feature is that we do not need to use "variable" to refer to a variable. Instead, we can use the variable itself without quoting. However, this feature can be a bit tricky when we want to write functions that uses variables as arguments.
I know it sounds abstract. Let’s imagine you’re analyzing the penguins dataset and want to calculate the mean bill_length_mm grouped by different groups. Your code looks like this:
# Group by species
penguins |>
group_by(species) |>
summarise(mean = mean(bill_length_mm, na.rm = TRUE))
# Group by sex
penguins |>
group_by(sex) |>
summarise(mean = mean(bill_length_mm, na.rm = TRUE))
# ... and so on for island, year, etc. 😩It gets a bit tedious to write the same code over and over again. Let’s write a function to automate this.
With what we know so far, you might try this:
grouped_mean <- function(data, variable, value) {
data |>
group_by(variable) |>
summarise(mean = mean(value, na.rm = TRUE))
}However, this function does not work:
grouped_mean(penguins, species, bill_length_mm)Error in `group_by()`:
! Must group by variables found in `.data`.
✖ Column `variable` is not found.
R thinks you’re literally grouping by a column named “variable” in the dataset (which doesn’t exist).
Luckily, it is super easy to fix this. To tell R, “Hey, use the variable I’m giving you, not the word ‘variable’!” wrap your argument in { }:
grouped_mean_corrected <- function(data, variable, value) {
data |>
group_by({{ variable }}) |>
summarise(mean = mean({{ value }}, na.rm = TRUE))
}
grouped_mean_corrected(penguins, species, bill_length_mm)
grouped_mean_corrected(penguins, island, body_mass_g)In other words, { } tells tidyverse: “Treat variable as the column I pass into the function, not as a literal name.”
The corrected function expects a bare column name. If you pass a string, e.g. grouped_mean_corrected(penguins, "species", bill_length_mm), R groups by the constant string "species" and you get a single group. To work with column names stored as strings, use group_by(.data[[variable]]) or group_by(across(all_of(variable))) inside your function instead.
{} vs. { }: When to use which?It is easy to get confused between these two in R:
Single { } (Code Block): This is standard R syntax. It is used to group multiple expressions together, such as the body of a function, inside if-else blocks, or inside loops.
my_fun <- function(x) {
# Single {} defines the function body / code block
y <- x + 1
y
}Double { } (Curly-Curly): This is a special tidyverse syntax (from rlang) for tidy evaluation. It is used specifically inside tidyverse functions (like group_by, mutate, filter, or summarise) to forward a function argument to a tidyverse function (this is called embracing). It only works inside arguments that use tidy evaluation (data-masking), such as those of group_by(), mutate(), filter() or summarise(), where the argument is meant to refer to a column of your data.
my_summary <- function(df, col) {
df |> summarise(mean_val = mean({{ col }})) # Double {{}} for tidy eval
}When writing functions, follow these guidelines:
calculate_bmi(), not bmi()# Good function design
calculate_bmi <- function(weight, height, units = "metric") {
if (units == "imperial") {
# Convert pounds to kg and inches to meters
weight <- weight * 0.453592
height <- height * 0.0254
}
weight / (height^2)
}
# Now works with both metric and imperial units
calculate_bmi(70, 1.75) # metric (default)[1] 22.85714
calculate_bmi(154, 69, units = "imperial") # imperial[1] 22.74157
It is a good idea to check the input validity of your function. This can be done by using stop() to stop the function and return an error message.
Be careful to keep the checks vectorized: if () needs a single TRUE or FALSE, so use any() (or all()) to collapse a vector of comparisons into one value. Writing if (weight <= 0 || height <= 0) would fail with vector input inside mutate().
calculate_bmi_checked <- function(weight, height, units = "metric") {
# Input validation (any() keeps this working for vectors)
if (any(weight <= 0, height <= 0)) {
stop("Weight and height must be positive numbers")
}
if (units == "imperial") {
weight <- weight * 0.453592
height <- height * 0.0254
} else if (units != "metric") {
stop('Units must be either "metric" or "imperial"')
}
weight / (height^2)
}
calculate_bmi_checked(c(70, 80), c(1.75, 1.80))[1] 22.85714 24.69136
Good functions need good documentation, especially if you want others (or even you in the future) what the function does.
In R, we use roxygen2 style comments to document our functions (they are just comments when you are not building a package, but they are a good habit):
#' Calculate Body Mass Index (BMI)
#'
#' @param weight Weight in kilograms
#' @param height Height in meters
#' @return BMI value (kg/m^2)
#' @examples
#' calculate_bmi(70, 1.75)
calculate_bmi <- function(weight, height) {
weight / (height^2)
}The documentation includes:
@param)@return)@examples)Writing functions is a crucial skill that builds on our understanding of vectorization and nested data:
Remember: A well-written function is like a good tool - it should do one thing, do it well, and be easy to use! 🛠️