💻 🏦
Code You Can Bank On

Miscellaneous

Amelia McNamara

“Recent” R developments

R core

Native pipes

“Ceci n’est pas un pipe”

| is often thought of as “the pipe” (as in unix), but we use that symbol to mean “or” in R.

dplyr::filter(penguins, species == "Adelie" | bill_length_mm > 50)

In 2014, the {magrittr} package added piping functionality to R.

This package name was a reference to René Magritte’s The Treachery of Images,

Native R pipe

The {magrittr} pipe was wildly popular, and was likely one of the things that made the tidyverse take off.


In 2021, R 4.1.0 introduced the native pipe operator, |>. Looking back at my own repos, I made the jump in 2023.


If you’re just doing data wrangling, you can basically do a find-and-replace for %>% with |>. Both pipes will pass the previous object in as the first argument of the function they’re piping into. However, there are some differences with the more complex behavior.

Native vs. magrittr pipe

  • the native pipe always requires parentheses for the function you are piping into. The magrittr pipe does not.
x |> mean()

x %>% mean
  • different placeholders. The magrittr pipe used . as the placeholder, the base pipe (as of 4.2.0) uses _. For the base pipe, the argument you are passing the placeholder to must be named
x %>% f(1, .) # is equivalent to
f(1, x)

x |> f(1, y = _) # is equvalent to
f(1, y = x)

Dependency implications

Many R purists like the base pipe because it “doesn’t require any dependencies.” But actually it has a big dependency– you need R > 4.1.0!


This used to be a bigger deal– when the native pipe was introduced, the tidyverse versioning policy said they needed to support back to 3.5.0. Now, the versioning policy says they only need to support back to 4.2.0.


Still, something to consider.

Strings as factors

stringsAsFactors = TRUE

Back in the day, the Global option stringsAsFactors was set as TRUE. As with many idiosyncrasies of R, this was ‘for backward compatibility with S.’


With stringAsFactors = TRUE, all character strings got read into R as factor variables. This makes sense for variables like race, gender, and other standard categorical variables with just a few levels. But if you’re working with text data that has many unique strings, it does not!

stringsAsFactors = TRUE

Factors behave in somewhat unexpected ways, so it’s also easy to wreck your data while using them.

x <- c(20, 20, 10, 40, 10)
x
[1] 20 20 10 40 10
xf <- factor(x)
xf
[1] 20 20 10 40 10
Levels: 10 20 40
as.numeric(xf)
[1] 2 2 1 3 1

stringsAsFactors = HELLNO

This was… a big deal in the R community for a long time

Wrangling Categorical Data in R without Losing Your Mind

I actually wrote a paper on this (with Nick Horton) and gave a talk about it at rstudio::conf 2019.

Wrangling Categorical Data in R without Losing Your Mind

I actually wrote a paper on this (with Nick Horton) and gave a talk about it at rstudio::conf 2019.

Wrangling Categorical Data in R without Losing Your Mind

I think the example that broke for me most-embarassingly (was on a take-home exam) did the following:

  • had a categorical variable that wasn’t originally coded as a factor (but as character)
  • data was split into test/training. Some of the training had levels that weren’t in the test data and vice versa! So let’s say for argument’s sake that both test and training had 4 different levels for the variable, but only 3 intersecting levels. E.g., test: red, blue, green, brown; training: red, blue, green, yellow.
  • the variable was made into a factor to do the classification (I think I even used as.factor() in the classification algorithm / sample code).
  • then with the test data, everything worked perfectly (read: no R errors), but it was totally WRONG

external reviewer for paper

stringsAsFactors = FALSE

If you have never run into issues with factors, thank the tidyverse, specifically {readr} and {forcats}.

 

And then thank R Core, because in R 4.0.0 they decided to set stringsAsFactors = FALSE by default!

Why use factors?

Factors are mostly useful when it comes to modeling (lm() and its family of functions use factors to set reference levels) and plotting (if you want to perfect your ggplot you will probably eventually need to reorder some factor levels).

Package states

Package states

There are five states a package can be in:

  • source

  • bundled

  • binary

  • installed

  • in-memory

Package states

schematic of package states and the functions that move them between states. One the horizontal axis: source, bundle, binary, installed, in memory. One the vertical axis the functions install.packages, R CMD install, install, build, install_github

Schematic of methods for moving between package states

Package states

  • source

  • bundled

  • binary

  • installed

  • in-memory

What you create and work on.

Specific directory structure with some particular components e.g., DESCRIPTION, an R/ directory.

Package states

  • source

  • bundled

  • binary

  • installed

  • in-memory

Also known as “source tarballs”.

Package files compressed to single file.

Conventionally .tar.gz

You don’t normally need to make one.

Unpacked it looks very like the source package

Package states

  • source

  • bundled

  • binary

  • installed

  • in-memory

Package distribution for users w/o dev tools

Also a single file

Platform specific: .tgz (Mac) .zip (Windows)

Package developers submit a bundle to CRAN; CRAN makes and distributes binaries

install.packages()

Package states

  • source

  • bundled

  • binary

  • installed

  • in-memory

A binary package that’s been decompressed into a package library

Command line tool R CMD INSTALL powers all package installation

Package states

  • source

  • bundled

  • binary

  • installed

  • in-memory

If a package is installed, library() makes its function available by loading the package into memory and attaching it to the search path.

We do not use library() for packages we are working on

devtools::load_all() loads a source package directly into memory.

Tidy design philosophy

Tidy design philosophy

Not as fleshed-out as the tidyverse style guide, but also developed as a book.

Implementation

Minimal names

In R, you can have unnamed objects

x <- letters[1:3]
names(x)
NULL

but, this means that sometimes the length(names()) is 0, and other times it is the same length as the length of x. We strive for more consistency!

{rlang} offers a solution,

rlang::names2(x)
[1] "" "" ""

These are minimal names– even though they are all empty strings, the length of names() will always be the same length as x.

Unique names

In base R, there is a function make.unique(), which can be used to create unique names (no duplicates).

For the tidyverse, there are low-level functions vctrs::vec_names() and vctrs::vec_names2(), used by things like the .names_repair argument in tibble() and names_repair in read_csv(). Some options are minimal, unique, and universal (unique and syntactic).

## Original Unique names     Result of
##    names  (tidyverse) make.unique()
##       ""         ...1            ""
##        x        x...2             x
##       ""         ...3            .1
##      ...         ...4           ...
##        y            y             y
##        x        x...6           x.1

The reason for avoiding ..j is it already has syntactic meaning in R, as a way to refer to arguments passed down from a calling function.

Why dots?

The underscore _ was also considered when choosing the tidyverse suffix strategy, but was rejected.

Why? Because syntactic names can’t start with an underscore and we want the suffix itself to be syntactic. Also, the dot . is already used by base R’s make.names() to replace invalid characters. It seems simpler and, therefore, better to use the same character, in the same way, as much as possible in name repair. We use the dot ., we put it at the front, as many times as necessary.

Syntactic names

You have probably encountered non-syntactic names before. If a variable name has a space in it, for example, it becomes non-syntactic.

x <- tibble::tibble(`Physicians per 100,000 Population` = 514)
x |>
  dplyr::pull(Physicians per 100,000 Population)
Error in parse(text = input): <text>:3:26: unexpected symbol
2: x |>
3:   dplyr::pull(Physicians per
                            ^
x <- tibble::tibble(`Physicians per 100,000 Population` = 514)
x |>
  dplyr::pull(`Physicians per 100,000 Population`)
[1] 514

Name all but the most important arguments

When calling a function, you should name all but the most important arguments. For example:

y <- c(1:10, NA)
mean(y, na.rm = TRUE)
[1] 5.5

Never use partial matching,

mean(y, n = TRUE)
[1] 5.5

Teaching exception

When you are teaching, it is best practice to start by naming all arguments, and then relax into the other standard.

At the beginning of a course:

ggplot(data = mpg, mapping = aes(x = displ, y = hwy)) + 
  geom_point()

By the end:

ggplot(mpg, aes(displ, hwy)) + 
  geom_point()

Scannable specs

Hidden arguments

A common source of hidden arguments is the use of global options:

  • stringsAsFactors. We’ve talked about this one.

  • na.action. This continues to be an issue with functions like lm(), whose handling of missing values depends on the global option of na.action. The default is na.omit which drops the missing values prior to fitting the model (which is inconvenient because then the results of predict() don’t line up with the input data.

  • system locale. Check with Sys.getlocale()

Make inputs explicit

The way to avoid issues with these common hidden arguments is to make them explicit.

Here’s a wrapper for as.POSIXct() that just shows how the arguments in the original function work:

as.POSIXct <- function(x, tz = "") {
  base::as.POSIXct(x, tz = tz)
}
as.POSIXct("2026-08-17 09:00")

There is a tz argument, but it’s not clear that "" means our local time zone. It would be better practice to be more explicit,

as.POSIXct <- function(x, tz = Sys.timezone()) {
  base::as.POSIXct(x, tz = tz)
}
as.POSIXct("2026-08-17 09:00")

Put the most important arguments first

Most functions get this right!

Some that get it wrong (according to Hadley)

  • grepl(), gsub() and similar– regex comes first
  • lm() – formula comes first, rather than data
  • ggplot()data() comes first, but it many geom_s it would make more sense to have mapping first

Scannable specs

  • make inputs explicit
  • put the most important arguments first
  • required arguments shouldn’t have defaults
  • put … after required arguments
  • keep defaults short and sweet

Enumerate possible options

rank2 <- function(
    x,
    ties.method = c("average", "first", "last", "random", "max", "min")
) {
  ties.method <- rlang::arg_match(ties.method)
  rank(x, ties.method = ties.method)
}

Scannable specs

  • make inputs explicit
  • put the most important arguments first
  • required arguments shouldn’t have defaults
  • put … after required arguments
  • keep defaults short and sweet
  • enumerate possible options
  • reduce argument clutter with an options object
  • argument meaning should be independent

Strategies

Three functions in a trenchcoat

Sometimes a function that implements multiple strategies might be better off as independent functions. Two signs that this might be the case:

  • You’re struggling to document how the arguments interact. Maybe you can set a and b and a and c but not b and c.
  • The implementation of your function has a couple of big if branches that share relatively little code.

Three functions in a trenchcoat

Examples:

  • forcats::fct_lump() chooses between one of three lumping strategies depending on whether you supply just n, just prop, or neither, while supplying both n and prop is an error. Additionally, the ties.method argument only does anything if you supply only n. fct_lump() is hard to understand and document because it’s really three smaller functions.

  • Depending on the arguments used library() can load a package, it can list all installed packages, or display the help for a package.

  • diag() is used to both extract the diagonal of a matrix and construct a matrix from a vector of diagonal values. This combination of purposes makes its arguments hard to understand: if x is a matrix, you can use names but not nrow and ncol; if x is a vector, you can use nrow and ncol but not names.

  • sample() is used to both randomly reorder a vector and generate a random vector of specified length. This function is particularly troublesome because it picks between the two strategies based on the length of the first argument.

  • rep() is used to both repeat each element of a vector and to repeat the complete vector. There is a full case study of rep() in the tidy design “book.”

Function arguments

Function arguments

  • Avoid magical defaults
  • Explain important defaults

Outputs

Type stability

The less you need to know about a function’s inputs to predict the type of its output, the better. Ideally, a function should either always return the same type of thing, or return something that can be trivially computed from its inputs.

If a function is type-stable it satisfies two conditions:

  • You can predict the output type based only on the input types (not their values).

  • If the function uses ..., the order of arguments in does not affect the output type.

What didn’t we talk about?

We did not cover how to manage change.

Change is a necessary (and frustrating) part of success:

  • {plyr} -> {dplyr}
  • {reshape} -> {reshape2} -> {tidyr}

Different levels of change:

  • deprecating functions, arguments
  • breaking changes
  • used to be: add-a-2, becoming: editions

Summary

  • Naming: be consistent, concise, yet evocative.
  • Argument order: data, descriptors, dots, details.
  • Return value type: be consistent, predictable.
  • Easy to remember data (first) argument and return value:
    • easy to use pipe, |>.
  • Be mindful of side effects.

Resources