diff --git a/writing_functions/R_logo.png b/writing_functions/R_logo.png new file mode 100644 index 0000000..4b5505a Binary files /dev/null and b/writing_functions/R_logo.png differ diff --git a/writing_functions/index.qmd b/writing_functions/index.qmd new file mode 100644 index 0000000..c724729 --- /dev/null +++ b/writing_functions/index.qmd @@ -0,0 +1,343 @@ +--- +title: "Writing functions" +description: "How to re-use code while avoiding copy-pasting" +author: "Etienne Bacher" +date: "2026-06-18" +categories: [r, package development] +difficulty: Intermediate +image: R_logo.png +toc: true +format: + html: default + revealjs: + output-file: index-slides.html +execute: + warning: false + message: false + freeze: auto +editor: + markdown: + wrap: 72 +--- + +# Functions in R + +If you have performed any kind of analysis with R, you have used functions. +They are everywhere and you can't do anything without them. +R comes with hundreds of functions by default, and thousands more are available via user-written packages. + +It is possible to create entire R projects without ever needing to write your own functions. +However, knowing how to write a custom function can be extremely useful. + +# Demo: standardizing values + +Let's say that you want to [standardize values](https://en.wikipedia.org/wiki/Standard_score), i.e. run this formula on multiple columns in your data: + +$$z = \frac{x - mean(x)}{sd(x)}$$ + +We could run this by hand on a list of columns in our data: + +```{r} +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"]]) +``` + +This works, but what if we want to ignore missing values? We have to add 8 parameters: + +```{r} +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 in the previous two blocks? + +Look again... + +On the third line, we used `mean(iris[["Sepal.Width"]])` instead of `mean(iris[["Petal.Width"]])`! By duplicating lines of code with very small variations, we make it harder to notice mistakes like this one, small in size but with important consequences for our results. + +Let's write a function instead of repeating this formula four times. + + +## The most basic function + +We start with the template of a function: + +```{r} +my_std <- function() {} +``` + +- `my_std` is the **function name**, meaning that we can use it like `my_std()`; +- `function()` is the **function definition**. In the next steps we will add **function arguments** (or **function parameters**) in `()`; +- `{}` contains the **function body**. This is where we will add the code that runs every time we call the function. + +For now, this function is useless: + +```{r} +my_std() +``` + +Let's add a simple message: + +```{r} +my_std <- function() { + print("Hello from my_std()!") +} + +my_std() +``` + +This doesn't take any user input, so it always does the same thing, which is not very interesting for us. +Let's add function parameters! + + +## Introducing function parameters + +The body of a function never changes, the variation comes from the inputs passed by the user, *aka* function parameters. Therefore, to move existing code into a function, we need to identify its moving components and its "stable" components. + +In the code below, the parts in [red]{style="color:red;"} change across lines and the rest stays identical across lines: + +
+
+

iris[["Petal.Length"]] <- (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"]])

+
+
+ +These red parts indicate the column name used in the computation, which will become our function parameter: + +```{r} +my_std <- function(column) {} +``` + + +## Adding the function body + +We added the moving parts as function parameters, now we need to move the stable parts in the function body, replacing the moving parts by the name of the new function parameter: + +```{r} +my_std <- function(column) { + (iris[[column]] - mean(iris[[column]], na.rm = TRUE)) / sd(iris[[column]], na.rm = TRUE) +} +``` + +Note that 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: + +```{r} +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. + + +## Validating function parameters + +The user can now pass custom column names to the function, but this also means that they can pass wrong values! +This is what happens if they pass a column name that doesn't exist in the data: +```{r, warning = TRUE} +my_std("unknown_column") +``` + +Note that this doesn't error, it just throws a warning and returns a useless result. +To prevent that, we should add some validation steps in our function body to ensure that we don't run nonsensical code and that we clearly tell the user when they have given wrong inputs to the function. +It is good to first list all the requirements of the input so that we can then add one check per requirement. In our case: + +- 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. + +Now that we have clarified are requirements, we can add one `if()` condition for each of them: + +```{r} +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) +} +``` + +And now we have proper error messages and avoid nonsensical results! + +```{r, error = TRUE} +my_std(c("Sepal.Length", "Petal.Width")) +my_std(1) +my_std("column_1") +``` + +::: {.callout-note collapse="true" title="Packages for input checking"} +Several packages provide functions to easily check inputs and report useful error messages. +There are too many to list all of them here, but you could for instance look at [`rlang`](https://rlang.r-lib.org/reference/index.html#function-arguments) and [`checkmate`](https://mllg.github.io/checkmate/). + +#### With `rlang` + +```{r, error = TRUE} +### 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")) +my_std(1) +my_std("column_1") +``` + +#### With `checkmate` + +```{r, error = TRUE} +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")) +my_std(1) +my_std("column_1") + +``` + +See also [`dreamerr`](https://lrberge.github.io/dreamerr/), [`arg`](https://ngreifer.github.io/arg/), [`chk`](https://poissonconsulting.github.io/chk/), and more! + +::: + + +## Setting default value for parameters + +So far, we used `na.rm = TRUE` in the function body but maybe we also want to let the user determine this option. +We can add a function parameter `na.rm` and use its value in `mean()` and `sd()`: + + +```{r} +my_std <- function(column, na.rm) { + # [parameter checks] + + (iris[[column]] - mean(iris[[column]], na.rm = na.rm)) / sd(iris[[column]], na.rm = na.rm) +} +``` + +*For conciseness, I hid the parameter checks we have written above.* + +This works fine but it forces the user to explicitly pass the parameter when calling the function, no matter whether it is `TRUE` or `FALSE`: + +```{r} +head(my_std("Sepal.Length", na.rm = FALSE)) +``` + +This isn't a bad approach since it forces the user to be explicit, but let's say we want to follow the `mean()` function and never remove `NA` by default. +We can set the default value in the function definition: + +```{r} +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) +} +``` + +and then this default value is implicitly used (i.e. we don't need to write it in the call): +```{r} +head(my_std("Sepal.Length")) +``` + + +## Exiting a function early + +Sometimes, we want to return an object early on instead of executing the rest of the function. + +For example, suppose we have a function where we want to return `NA` if any of the input values is `NA`. +In this case, we could use `return()` in an `if` condition to skip the rest of the function: + +```{r} +f <- function(x) { + + if (anyNA(x)) { + return(NA) + } + + # if x doesn't contain NA, then we continue the function here... +} +``` + +Note that the last value in the function is implicitly returned, so you don't need to wrap it in `return()` (but you can if you want, it's just a matter of preferences). +Note that if the last statement in the function is an assignment, then the function returns the value of this assignment but doesn't print it when you call it: + +```{r} +# Returns the value of `x` and prints it +f <- function() { + x <- 1 + x +} +f() +result_1 <- f() + +# Also returns the value of `x`, but doesn't print it! +f <- function() { + x <- 1 +} +f() +result_2 <- f() + +identical(result_1, result_2) +``` + + +## Documenting functions + +[UNSURE: this could fit better in the package development module] + +If you plan to keep using custom functions in your project, or if you want to share those with colleagues, then it is important to write documentation. +The documentation should include: + +- an explanation of the function's purpose +- an explanation of each function parameter, including the expected type (e.g. "a character value of length 1") +- details on the expected output + +# Going further + +Designing functions and ensuring their robustness is not trivial. +There are many choices one can make, such as using positional or named arguments, harmonising parameter names, and whether to use default values. + +If you want to explore more, we recommend reading the [Tidyverse design](https://design.tidyverse.org/) book. +While the primary audience is package developers, a lot of advice there apply to any custom function. + +# Conclusion + +In this module, we have seen how to convert existing, duplicated code into a function. + +Now that you have a function that does something useful, maybe you want to share it with colleagues. +You could put it in an `.R` file and send it via email, but what happens if you notice a small mistake and want to fix it in the future? +You'd have to tell all the people to which you sent the original function. +This is fine if you shared it with a couple of colleagues, but what if they also shared it with other people? + +The best way to share functions with other people is to wrap them in an R package. +This might sound intimidating but don't worry, we have an [entire module dedicated to package development]()! \ No newline at end of file