Functions

Applying R to Lifestyle and Brain Health Research

Ashlyn Barry

University of Wisconsin - Madison

September 9, 2026

What are functions?

A function is a machine that takes something in, does a job to it, and gives something back out. For example, think about a blender:

  • Input → “ingredients” you put into the function: fruit
  • Function → machine’s task: blend
  • Output → resulting product: smoothie
blender <- function(fruit) {
  smoothie <- blend(fruit)
  return(smoothie)
}

Functions have three main parts

A function is a machine (blender): input (fruit) goes in, the machine (blender) completes a task, and output (smoothie) comes out. But what actually happens inside the machine?

Every function is built from three parts: arguments, body, and environment

  • Arguments (formals()) → input, or ingredients you feed in (e.g., fruit)
  • Body (body()) → how the function works, or actual steps of how fruit gets blended
  • Environment (environment()) → everything else the function can access, or the ‘kitchen’ the blender sits in
# Environment example: adding milk to the smoothie

milk <- "Half cup of milk" 
# milk accessible to blender in environment (i.e., R knows what you mean by milk)

blender <- function(fruit) { # fruit assigned as an argument
  smoothie <- blend(fruit, milk) # milk is not an argument, but available in environment
  return(smoothie) # smoothie made from fruit and milk
}

Milk was never passed in as an argument, but the blender can ‘grab’ the milk because it is in the ‘blender’s kitchen’, or the ‘function’s environment’.

The function (blender) is assigned its environment (kitchen) automatically when it is built and never forgets it. R calls this lexical scoping (more on this later).

Attributes

Functions also have extra ‘labels’ of information called attributes attr(). In our example, think of sticky notes attached to the blender.

One particularly useful attribute is srcref (source reference), which is like a sticky note with the smoothie recipe.

blender <- function(fruit) {
  # Blend the fruit into a smoothie
  smoothie <- blend(fruit)
  return(smoothie)
}

attr(blender, "srcref")

#> function(fruit) {
#>   # Blend the fruit into a smoothie
#>   smoothie <- blend(fruit)
#>   return(smoothie)
#> }

Primitive functions

We just learned that every function is built from three parts: arguments, body, and environment. Primitive functions break this rule (sigh!)

  • Small set of functions that skip the traditional three-part structure
  • Instead of running R code, they connect to C code: language R itself is built on
  • Since primitive functions are not “built” the normal way, arguments, body, and environments are all NULL

Think of these as functions with no visible “recipe” at all; they are wired straight into R’s engine rather than written out explicitly.

Primitive functions come in two types: builtin or special

sum
#> function (..., na.rm = FALSE)  .Primitive("sum")
typeof(sum)
#> [1] "builtin"

`[`
#> .Primitive("[")
typeof(`[`)
#> [1] "special"

First-class functions: They are just objects!

So far we’ve treated functions like special machines. But in R, a function is just another type of object.

  • There’s no special syntax needed to “declare” a function
  • You create it with function(), then give it a name with <- — just like assigning a number to a name

f01 <- function(x) {
  sin(1 / x^2)
}

Because functions are just objects, they don’t need a name at all. Anonymous functions run the same way but are never assigned a name. This is useful if you only plan on running the analysis once, defeating the purpose of assigning it a long-term name.

unlist(lapply(mtcars, function(x) length(unique(x))))
#>   mpg  cyl disp  hp  drat  wt  qsec   vs  am  gear carb
#>   25    3   27   22   22   29   30    2    2    3    6

Functions can also be stored inside other objects, like a list.

funs <- list(
  half = function(x) x / 2,
  double = function(x) x * 2
)

Invoking a function

A function is usually called by placing the arguments inside parentheses next to the function name.

mean(1:10, na.rm = TRUE)
#> [1] 5.5

In some instances, the arguments are contained within an existing list or data structure. You can use do.call to get the arguments from a list instead of typing them all out by hand.

args <- list(1:10, na.rm = TRUE)

do.call(mean, args) # do.call(function, arguments list)
#> [1] 5.5

Function composition

Most analyses you conduct will likely involve more than one function. Composition refers to chaining functions together so the output of one becomes the input of the next.

Base R gives you two main ways to achieve this:

Option 1: Save each step as its own variable (Intermediate Results)

  • Pros: easy to read and write, especially if sharing code with others
  • Cons: can create a lot of unnecessary intermediate objects
square <- function(x) x^2
deviation <- function(x) x - mean(x)

x <- 1:20

# Saving intermediate results
out <- deviation(x)
out <- square(out)
out <- mean(out)
out <- sqrt(out)

Option 2: Nest the functions inside each other (Nested)

  • Pros: no extra variables
  • Cons: ‘inside out’ reading, which can be difficult to follow
# Nested
sqrt(mean(square(deviation(x))))

Both versions calculate the exact same thing; they’re just two different ways of writing the same sequence of steps.

Option 3: magrittr

The magrittr package uses binary operator called the pipe %>%, which read as “and then”. The basic idea is to take what is on the left and then feed it into the function on the right.

  • Pros: read code in straightforward fashion without intermediate objects
  • Cons: requires additional package and slightly more advanced understanding of R
library(magrittr)

x %>%
  deviation() %>%
  square() %>%
  mean() %>%
  {
    paste0("Square root of ", ., " is ", round(sqrt(.), 2))
  }

Lexical scoping

A function’s environment (its “kitchen”) is fixed the moment the function is built, not when it’s used. Lexical scoping is the rule that R looks up values based on where the function was written, not where it’s called from.

Lexical scoping follows four rules:

  1. Name masking
  2. Functions vs Variables
  3. Fresh start
  4. Dynamic lookup

Rule 1: Name masking

If a name exists both inside and outside a function, R uses the inside version. The inside version masks or hides the outside version.

x <- 10

f <- function() {
  x <- 1
  x
}

f()()
#> [1] 1 

If a name isn’t defined inside a function, R looks one level up to find it:

y <- 2

f <- function() {
  y
}

f()
#> [1] 2

Step outward until you find it: R keeps looking further out, all the way to the global environment, until it finds a match.

Applied to the kitchen analogy: if an ingredient isn’t right next to the blender, R will check the counter, then the pantry, then the kitchen, then the house…

Rule 2: Functions vs Variables

If a name is being used as a variable, R looks for a variable. If a name is being used as a function (i.e., followed by parentheses), R looks for a function, even if a variable with that name is closer (per Rule 1).

n <- function(x) x + 1

f <- function() {
  n <- 5
  n(10)
}

f()
#> [1] 11
  • Outside f(), ‘n’ is a function that adds 1 to whatever you give it
  • Inside f(), a second ‘n’ is created, where it is set equal to 5
  • The last line, n(10) uses parentheses, telling R you want the function version of n
  • R sees n(…) and knows it needs a function, so it skips over the variable n <– 5 and keeps looking outward until it finds a function version of n
  • f() thus is 10 + 1 = 11

The number 5 and function x + 1 are both named ‘n’, and they coexist without conflict because R checks how the name is being used (as a value or as a function).

Rule 3: Fresh start

Every time a function is called, R builds it a new, empty environment. Nothing from the previous call is remembered.

add_one <- function() {
  x <- 0
  x <- x + 1
  x
}

add_one()
#> [1] 1

add_one()
#> [1] 1
  • Each time add_one() runs, it starts fresh: x is set to 0, then 1 is added
  • The second call does not remember the first: instead of adding one to the previous answer, it starts fresh with 0 + 1
  • In other words, x from the first call is thrown away the moment the function finishes, so the second call builds x from scratch

Rule 4: Dynamic lookup

Lexical scoping tells R where to look for a value, but R doesn’t check where that value is until the function is run (not when it’s created).

f <- function() x + 1

x <- 15
f()
#> [1] 16

x <- 20
f()
#> [1] 21
  • F() doesn’t have its own ‘x’, so it looks one level out to find it (Rule 1)
  • Which value of ‘x’ depends on whatever ‘x’ is at the moment f() runs, not what ‘x’ was when f() was first written

Lazy Evaluation

Function arguments are lazy: they are not evaluated until they are actually used inside the function. If an argument is not used, it will not be evaluated.

f <- function(x) {
  10
}
# No error is generated because x is never used.
f(stop("This is an error!"))
#> [1] 10
  • f() never uses ‘x’ in its body; it returns “10” no matter what is passed in
  • ‘stop(“This is an error!”)’ would normally halt everything, but it is never triggered because R is not told to evaluate x in the body of its function

Why laziness matters

1) A value is calculated once, no matter how many times it is used

double <- function(x) {
  message("Calculating...")
  x * 2
}

f <- function(x) {
  c(x, x)
}

f(double(20))
#> Calculating...
#> [1] 40 40

“Calculating…” only prints once, even though x appears twice inside f(). R evaluates it the first time it’s needed, then reuses that same result.

2) Default argument values can refer to other arguments

f <- function(x = 1, y = x + 1) {
  c(x, y)
}

f()
#> [1] 1 2
  • y’s default is an expression of ‘x + 1’, not a fixed number
  • Since arguments are lazy, ‘y’ isn’t evaluated until it is needed. By the time y is needed, x is already assigned 1, so ‘y’ becomes 2

The … (dot-dot-dot)

... (pronounced dot-dot-dot) let’s a function accept any number of arguments without naming them ahead of time, which can be used directly or passed along to another function.

f <- function(...) {
  sum(...)
}

f(1, 2, 3)
#> [1] 6
  • ... uses argments given in f(): 1, 2, and 3
  • Those arguments are passed to sum()

Errors with …

Because ... accepts anything, a misspelled argument name won’t cause an error; it will just get ignored and disappear.

f <- function(x, ...) {
  mean(x, ...)
}

f(c(1, 2, NA), na.rm = TRUE)
#> [1] 1.5

f(c(1, 2, NA), na_rm = TRUE)
#> [1] NA
  • The second call misspells na.rm as na_rm
  • R doesn’t catch the error; it treats na_rm as an used argument in ...

... is helpful for writing flexible, reusable functions, but typos in arguments get ignored instead of flagged.

Exiting a function

A function exits in one of two ways: by returning a value or by throwing an error. A return can be implicit (R returns the last line) or explicit (you use return() yourself).

# Implicit -  the last evaluated expression is returned automatically
f <- function(x) {
  if (x < 10) {
    0
  } else {
    10
  }
}

f(9)
#> [1] 0

# Explicit - return() exits the function immediately with that value
f <- function(x) {
  if (x < 10) {
    return(0)
  }
  return(10)
}

f(9)
#> [1] 0

Both versions do the same thing: return() is optional but makes it explicit to what is being sent back and when.

Errors

Errors occur when a function cannot complete its assigned task. It uses stop() to stop the execution of the function.

double <- function(x) {
  if (!is.numeric(x)) {
    stop("x must be numeric")
  }
  x * 2
}

double(10)
#> [1] 20

double("cats")
#> Error in double("cats") : x must be numeric

A clear stop() halts the function and displays an error message telling user exactly what went wrong, instead of the function failing confusingly down the line.

Function forms

Functions can be written in four different styles, depending on where the function name sits relative to arguments.

  • Prefix: name comes before arguments, print(x)
  • Infix: name comes between its arguments, x + y
  • Replacement: modifies values by assignment, names(x) <- c("a", "b")
  • Special: built in language features like if, for, and [[

Prefix Functions

Most common functions in R and what you have been seeing throughout this presentation. Function name comes first, followed by its arguments in parentheses. R lets you supply those arguments in a few different ways.

f <- function(age, weight) {
  list(age = age, weight = weight)
}

# By position
f(30, 70)
#> $age
#> [1] 30
#>
#> $weight
#> [1] 70

# By name — order no longer matters
f(weight = 70, age = 30)
#> $age
#> [1] 30
#>
#> $weight
#> [1] 70

Arguments can be matched by their position (order listed) or name, which overrides their position.

Infix functions

Infix functions take two arguments with the function name between them, like + or -. This is calling the style you use everytime you write x + y.

x <- 5
y <- 2

x + y
#> [1] 7

`+`(x, y)
#> [1] 7

Infix functions are regular functions that you can call in a natural, readable way.

Replacement Functions

Replacement functions look like they’re modifying a value in place, but they’re just regular functions with a special name ending in <-.

`first<-` <- function(x, value) {
  x[1] <- value
  x
}

x <- c(1, 2, 3)
first(x) <- 100
x
#> [1] 100 2 3

Special forms

There are many language features in R that are written in special ways but also have prefix forms.

Special Prefix
(x) `(`(x)
{x} `{`(x)
x[i] `[`(x, i)
x[[i]] `[[`(x, i)
if (cond) true `if`(cond, true)
if (cond) true else false `if`(cond, true, false)
for(var in seq) action `for`(var, seq, action)
while(cond) action `while`(cond, action)
repeat expr `repeat`(expr)
next `next`()
break `break`()
function(arg1, arg2) {body} `function`(alist(arg1, arg2), body, env)

Practice creating your own functions!

Time to build a few functions! First try them on your own, and then we will walk through the solutions together.

1) Write a basic function

Write a function called square() that takes one number and returns its square (e.g., square(4) should return ‘16’)

2) Write a function with a default argument

Write a function called greet() that takes a name and returns a greeting with “Hello, [name]!”. Give name a default value of “friend”, so greet() with no input returns “Hello, friend!”.

3) Write a function using ...

Write a function called add_all() that takes any number of numeric arguments and returns their sum (e.g., add_all(1, 2, 3) should return ‘6’)

Hint: think back to how sum() was used inside the ... example!

Solutions

# 1. Write a basic function
square <- function(x) {
  x^2
}

square(4)
#> [1] 16

# 2. Write a function with a default argument
greet <- function(name = "friend") {
  paste0("Hello, ", name, "!")
}

greet()
#> [1] "Hello, friend!"

greet("Ashlyn")
#> [1] "Hello, Ashlyn!"

# 3. Write a function using '...'
add_all <- function(...) {
  sum(...)
}

add_all(1, 2, 3)
#> [1] 6