Writing functions

Etienne Bacher
Niklas Hohmann

2026-10-16

Functions in R

Functions in R


Functions are used everywhere:

  • library()

  • read.csv()

etc.


R comes with comes with hundreds of functions by default. Thousands more are available via user-written packages.

Demo: standardizing values

Demo: standardizing values


We want to standardize values on multiple columns:

\[z = \frac{x - mean(x)}{sd(x)}\]

Demo: standardizing values


Option 1: same code for several columns in our data:

iris[["Petal.Length_std"]] <- (iris[["Petal.Length"]] - 
  mean(iris[["Petal.Length"]])) /
  sd(iris[["Petal.Length"]])
iris[["Sepal.Length_std"]] <- (iris[["Sepal.Length"]] - 
  mean(iris[["Sepal.Length"]])) /
  sd(iris[["Sepal.Length"]])
iris[["Petal.Width_std"]] <- (iris[["Petal.Width"]] - 
  mean(iris[["Sepal.Width"]])) /
  sd(iris[["Petal.Width"]])
iris[["Sepal.Width_std"]] <- (iris[["Sepal.Width"]] - 
  mean(iris[["Sepal.Width"]])) /
  sd(iris[["Sepal.Width"]])

Demo: standardizing values


This works, but what if we want to ignore missing values?

iris[["Petal.Length_std"]] <- (iris[["Petal.Length"]] - 
  mean(iris[["Petal.Length"]], na.rm = TRUE)) /
  sd(iris[["Petal.Length"]], na.rm = TRUE)
iris[["Sepal.Length_std"]] <- (iris[["Sepal.Length"]] - 
  mean(iris[["Sepal.Length"]], na.rm = TRUE)) /
  sd(iris[["Sepal.Length"]], na.rm = TRUE)
iris[["Petal.Width_std"]] <- (iris[["Petal.Width"]] - 
  mean(iris[["Sepal.Width"]], na.rm = TRUE)) /
  sd(iris[["Petal.Width"]], na.rm = TRUE)
iris[["Sepal.Width_std"]] <- (iris[["Sepal.Width"]] - 
  mean(iris[["Sepal.Width"]], na.rm = TRUE)) /
  sd(iris[["Sepal.Width"]], na.rm = TRUE)

Do you notice anything weird about the code above?

Demo: standardizing values


A small typo with big consequences!

iris[["Petal.Length_std"]] <- (iris[["Petal.Length"]] - 
  mean(iris[["Petal.Length"]], na.rm = TRUE)) /
  sd(iris[["Petal.Length"]], na.rm = TRUE)
iris[["Sepal.Length_std"]] <- (iris[["Sepal.Length"]] - 
  mean(iris[["Sepal.Length"]], na.rm = TRUE)) /
  sd(iris[["Sepal.Length"]], na.rm = TRUE)
iris[["Petal.Width_std"]] <- (iris[["Petal.Width"]] - 
  mean(iris[["Sepal.Width"]], na.rm = TRUE)) /
  sd(iris[["Petal.Width"]], na.rm = TRUE)
iris[["Sepal.Width_std"]] <- (iris[["Sepal.Width"]] - 
  mean(iris[["Sepal.Width"]], na.rm = TRUE)) /
  sd(iris[["Sepal.Width"]], na.rm = TRUE)

Demo: standardizing values



Duplicating code makes it easy to introduce bugs.


Let’s write a function instead of repeating this formula four times.

Writing a function

The most basic function


my_std <- function() {}


  • my_std is the function name
  • function() is the function definition, that can take function arguments
  • {} contains the function body

The most basic function


For now, this function is useless:

my_std()
NULL

Let’s add a simple message:

my_std <- function() {
  print("Hello from my_std()!")
}

my_std()
[1] "Hello from my_std()!"

Introduce function parameters


Function parameters are inputs passed by the user.

They are the parts that the user can modify to change the output.


To move existing code into a function, we need to identify its “moving” components and its “stable” components.

Introduce function parameters


Parts in red are the parts that change across lines (the column name that is used):

iris[["Petal.Length_std"]] <- (iris[["Petal.Length"]] -
  mean(iris[["Petal.Length"]])) /
  sd(iris[["Petal.Length"]])
iris[["Sepal.Length_std"]] <- (iris[["Sepal.Length"]] -
  mean(iris[["Sepal.Length"]])) /
  sd(iris[["Sepal.Length"]])
iris[["Petal.Width_std"]] <- (iris[["Petal.Width"]] -
  mean(iris[["Petal.Width"]])) /
  sd(iris[["Petal.Width"]])
iris[["Sepal.Width_std"]] <- (iris[["Sepal.Width"]] -
  mean(iris[["Sepal.Width"]])) /
  sd(iris[["Sepal.Width"]])

Introduce function parameters


Therefore we can create a column parameter to specify which column to use in the computation:


my_std <- function(column) {}

Add the function body


The function body takes the code that doesn’t change across lines:

my_std <- function(column) {
  (iris[["Petal.Length"]] - 
    mean(iris[["Petal.Length"]], na.rm = TRUE)) /
    sd(iris[["Petal.Length"]], na.rm = TRUE)
}

Add the function body


The function body takes the code that doesn’t change across lines:

my_std <- function(column) {
  (iris[[column]] - 
    mean(iris[[column]], na.rm = TRUE)) /
    sd(iris[[column]], na.rm = TRUE)
}

and we can replace the moving parts by our new column parameter.

Add the function body



Note

We don’t assign the output inside the function: the function merely makes the computation and we assign its output to our object when we call the function.

Use our new function



iris[["Petal.Length_std"]] <- my_std("Petal.Length")
iris[["Sepal.Length_std"]] <- my_std("Sepal.Length")
iris[["Petal.Width_std"]] <- my_std("Petal.Width")
iris[["Sepal.Width_std"]] <- my_std("Sepal.Width")

Notice how it is much easier to check whether we made a typo in this code.

Validate function parameters


We can pass wrong values to my_std()!

If we pass a column name that doesn’t exist in the data:

my_std("unknown_column")
Warning in mean.default(iris[[column]], na.rm = TRUE): argument is not numeric
or logical: returning NA
numeric(0)

No error, just a warning and a useless result!


  we need to check user inputs

Validate function parameters


Step 1: list all input requirements.

  • the column should be a single value, e.g. my_std(c("Sepal.Length", "Petal.Width")) should fail;
  • the column should be a character value, e.g. my_std(1) should fail;
  • the column should exist in the data, e.g. my_std("column_1") should fail.

Validate function parameters


Step 2: check each of those.

my_std <- function(column) {
  if (length(column) != 1) {
    stop("The `column` parameter must be of length 1.")
  }
  if (!is.character(column)) {
    stop("The `column` parameter must be a character.")
  }
  if (!(column %in% names(iris))) {
    stop("Column '", column, "' doesn't exist in the data.")
  }

  (iris[[column]] - mean(iris[[column]], na.rm = TRUE)) / sd(iris[[column]], na.rm = TRUE)
}

Validate function parameters


And now we have proper error messages!

my_std(c("Sepal.Length", "Petal.Width"))
Error in `my_std()`:
! The `column` parameter must be of length 1.


my_std(1)
Error in `my_std()`:
! The `column` parameter must be a character.


my_std("column_1")
Error in `my_std()`:
! Column 'column_1' doesn't exist in the data.

Validate function parameters

Packages for input checking

Several packages provide functions to check inputs and report useful error messages, e.g. rlang and checkmate (see also dreamerr, arg, chk, and more!).

### With rlang
my_std <- function(column) {
  rlang::check_string(column)
  if (!(column %in% names(iris))) {
    stop("Column '", column, "' doesn't exist in the data.")
  }
  (iris[[column]] - mean(iris[[column]], na.rm = TRUE)) / sd(iris[[column]], na.rm = TRUE)
}
my_std(c("Sepal.Length", "Petal.Width"))
Error in `my_std()`:
! `column` must be a single string, not a character vector.
my_std <- function(column) {
  checkmate::assert_string(column)
  checkmate::assert_subset(column, names(iris))
  (iris[[column]] - mean(iris[[column]], na.rm = TRUE)) / sd(iris[[column]], na.rm = TRUE)
}
my_std(c("Sepal.Length", "Petal.Width"))
Error in `my_std()`:
! Assertion on 'column' failed: Must have length 1.

Set default value for parameters


Instead of setting na.rm = TRUE in the function body, we could let the user determine this option.

We can add a na.rm parameter and use its value in mean() and sd():

my_std <- function(column, na.rm) {
  # [parameter checks]

  (iris[[column]] - mean(iris[[column]], na.rm = na.rm)) / sd(iris[[column]], na.rm = na.rm)
}

Set default value for parameters


This works but forces the user to write na.rm = TRUE (or FALSE):

head(my_std("Sepal.Length", na.rm = FALSE))

We could set a default value instead:

my_std <- function(column, na.rm = FALSE) {
  # [parameter checks]

  (iris[[column]] - mean(iris[[column]], na.rm = na.rm)) / sd(iris[[column]], na.rm = na.rm)
}
head(my_std("Sepal.Length"))

Exit a function early


Sometimes, we want to return an object early on instead of executing the rest of the function.

We can use return() for this.

Example: return NA if the input contains at least one NA value:

f <- function(x) {

  if (anyNA(x)) {
    return(NA)
  }

  # if x doesn't contain NA, then we continue the function here...
}

Other ways to use and create functions

Functions as arguments


Functions are just another type of objects: they can be updated by the user, assigned to variables, and passed over to other functions:

my_variable <- mean


The absence of parentheses after mean means we are not evaluating the function, just refering to it.

Functions as arguments


The family of apply functions can use these function objects:

data_list <- list(a = rnorm(100), b = rnorm(100, mean = 3))
lapply(X = data_list, FUN = median)

Anonymous functions


So far, we have seen and used named functions.

Generally, it is good practice to give your variables and functions informative names.


But sometimes you just need a quick, short throwaway function that serves one purpose.

For this, you can use anonymous functions. These are defined on the spot and immediately evaluated without being assigned to a variable.

Anonymous functions


Once again, these can be very useful in *apply() functions (and in other places):

data_list <- list("a" = rnorm(100), "b" = rnorm(100, mean = 3))
lapply(X = data_list, FUN = function(x) abs(x[3] - x[5]))

Inspect the source code


It can be useful to see the inside of a function created by someone else.

Running the function name (without parentheses) in the console shows the source code:

my_std

Inspect the source code

Tip to see the source code of a function

In RStudio and Positron, you can press Ctrl (or Cmd on macOS) and click on a function name in a script to open a tab containing its source code:

Going further


Designing functions and ensuring their robustness is not trivial.

There are many choices one can make:

  • using positional or named arguments
  • harmonising parameter names
  • whether to use default values
  • and more


Explore more in the Tidyverse design book.