Idioms and Common Pitfalls
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. Everything on this page is base R (the base, stats and utils packages that ship with R) except two add-on packages that are named where they appear: withr 3.0.3, with one call in the pitfall about the working directory, and lintr 3.4.0, which also checked every script and is shown linting a few lines of old-style code. R is forgiving in ways that are convenient in an interactive session and costly in a program: a name is completed for you, a vector is silently recycled, a one-column result becomes a vector, a loop over nothing runs twice. Most of the bugs that come from this are not exotic. They are the same few, in the same places, and the R FAQ, the manuals and the style guides have been describing them for decades. This page is the short list of habits that avoid them (ten idioms) and a catalogue of sixteen pitfalls, each shown failing with R’s own output, fixed, tied to its R FAQ entry (or marked as having none) and linked to the page of this reference that explains the concept in depth.
Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters; a # nolint: comment marks a line where the bad idiom on show is exactly what a linter dislikes, so the linter does not hide the lesson. Errors and warnings that a demonstration provokes are caught with tryCatch() and only the message is shown, so every message quoted here is R’s own text, with the typographic quotes it prints in a UTF-8 locale. The names books, loans, shelf_today, loan_days and the book() constructor come from the running library-shelf scenario described on the section index: a script that needs them starts with source("scenario.R"), and books.csv is read with read.csv("books.csv"). Paths are never printed, and the one place where a script changes the working directory, in the pitfall about global state, restores it before the script ends.
This is a summary page: it repeats nothing that a concept page explains, and every section ends with the link to the page that does. Three further conventions hold. The page never recommends a bad idiom: T, F, 1:length(x), sapply(), attach() and the working-directory setter appear only inside the demonstrations of the pitfalls they cause. The pitfalls are in the order of the outline, not of importance. And chapter 7 of the R FAQ ("R Miscellanea") is the official list of the traps that users ask about; several pages of this reference quote it by number, so the section R FAQ chapter 7 at a glance near the end lists all 44 entries with the page that treats each. The changes between R 4.0.0 and 4.6.x that make some of these traps disappear are collected on What’s New and Migrating Older Code.
The idioms
An idiom is a way of writing something that other R programmers recognise, that does the right thing at the edges, and that a linter can check. Each of the ten below is paired with a pitfall that is what happens without it, and each has an example you can paste into a session.
Vectorise
Arithmetic, comparison and most mathematical functions in R work on whole vectors, and the loop that applies them runs in compiled code. Writing days <- return_date - loan_date instead of a loop over the rows is shorter, has no index to get wrong, and is the form that the other idioms below build on. The script computes the loan lengths of the shelf scenario both ways and shows that they agree, then counts the loans longer than the 21-day period; the count is the reference result of the scenario (25 returned loans exceed 21 days), and na.rm = TRUE leaves out the unreturned loans and the impossible date of row 20, which are NA. A loop is still the right tool when each step depends on the previous one; Vectorisation and Iteration says when, and why the vectorised form is faster.
source("scenario.R")
# Two ways to compute how many days each loan lasted.
days_loop <- numeric(nrow(loans))
for (i in seq_len(nrow(loans))) {
days_loop[i] <- as.numeric(loans$return_date[i] - loans$loan_date[i])
}
# One expression: subtraction of two Date vectors works on whole vectors.
days <- as.numeric(loans$return_date - loans$loan_date)
identical(days_loop, days)
#> [1] TRUE
head(days, 6L)
#> [1] 21 10 1 26 26 17
# Comparisons and sum() or mean() also work on whole vectors.
sum(days > loan_days, na.rm = TRUE)
#> [1] 25
round(mean(days > loan_days, na.rm = TRUE), 3L)
#> [1] 0.481
seq_along() and seq_len()
When a loop is needed, its index comes from seq_along(x) (the positions of x) or seq_len(n) (the numbers 1 to n), never from 1:length(x) or 1:n. The two functions return integer(0) for an empty input, so the loop body does not run, and the colon operator does not (the first pitfall below). The seq help page says that seq_along and seq_len are very fast primitives for these two common cases and that using them is much preferred to the one-argument form of seq(). The function below numbers a vector of titles; it gives the right answer for three titles and for none, and the same two lines work for the rows of a data frame.
source("scenario.R")
number_titles <- function(x) {
out <- character(length(x))
for (i in seq_along(x)) {
out[[i]] <- sprintf("%d. %s", i, x[[i]])
}
out
}
titles <- trimws(books$title)
number_titles(titles[1:3])
#> [1] "1. The Dispossessed" "2. Pride and Prejudice" "3. the quiet streets"
# An empty input gives an empty result and the loop body never runs.
number_titles(character(0))
#> character(0)
# seq_len() is the same idea for a count such as the rows of a data frame.
seq_len(nrow(books))[1:4]
#> [1] 1 2 3 4
seq_len(0L)
#> integer(0)
Source: R documentation: seq
The loop styles are compared in Control Flow, and lintr flags the colon form with seq_linter.
vapply() with a declared FUN.VALUE
To apply a function to every element and collect the results in a vector, use vapply(X, FUN, FUN.VALUE). The third argument is a template: the type and length every call must return. If one call returns something else, R stops with a message that names the element. If the input is empty, the result is an empty vector of the declared type. sapply() has neither guarantee and its result changes shape with the data (the sapply pitfall below). A template with names, such as c(min = 0, max = 0), makes vapply() return a matrix whose rows carry the names. When the result is a list on purpose, lapply() says so.
source("scenario.R")
genre <- tolower(trimws(books$genre))
pages_by_genre <- split(books$pages, genre)
# FUN.VALUE says what each call returns: one number.
vapply(pages_by_genre, \(p) mean(p, na.rm = TRUE), numeric(1L))
#> classic fiction mystery non-fiction poetry
#> 356.0000 222.6667 272.5000 263.5000 92.0000
#> science fiction
#> 373.0000
# A named template gives a matrix with named rows.
vapply(
pages_by_genre,
\(p) range(p, na.rm = TRUE),
c(min = 0, max = 0)
)
#> classic fiction mystery non-fiction poetry science fiction
#> min 280 198 244 175 88 322
#> max 432 256 301 352 96 410
# Empty input gives an empty vector of the declared type.
vapply(list(), length, integer(1L))
#> integer(0)
vapply(character(0), nchar, integer(1L))
#> named integer(0)
Source: R documentation: lapply
When X is a character vector, USE.NAMES = TRUE names the result after it, which is why the empty character vector above gives named integer(0); pass USE.NAMES = FALSE when the names are not wanted. The family is described in The apply Family and Functionals.
stopifnot() with named messages
stopifnot() checks that every one of its arguments is TRUE and stops with an error otherwise. Since R 4.0.0 an argument can be named, and the name is used instead of the default message, which is the expression followed by "is not TRUE" (this is in the R 4.0.0 NEWS: the function allows customizing error messages via argument names). The first lines of a function that state what it accepts are the cheapest documentation and the earliest failure. Use stop() with a message of your own, or a classed condition, for errors that the caller is meant to handle.
source("scenario.R")
loan_length <- function(loan_date, return_date) {
stopifnot(
"loan_date must be a Date" = inherits(loan_date, "Date"),
"return_date must be a Date" = inherits(return_date, "Date"),
"the two dates must have the same length" =
length(loan_date) == length(return_date)
)
as.numeric(return_date - loan_date)
}
loan_length(loans$loan_date[1:3], loans$return_date[1:3])
#> [1] 21 10 1
tryCatch(
loan_length("2025-01-14", loans$return_date[1L]),
error = \(e) conditionMessage(e)
)
#> [1] "loan_date must be a Date"
tryCatch(
loan_length(loans$loan_date[1:3], loans$return_date[1:2]),
error = \(e) conditionMessage(e)
)
#> [1] "the two dates must have the same length"
Source: R documentation: stopifnot
More forms (exprs, the checks for type and length, the checkmate package) are in Defensive Programming and Input Validation.
on.exit(add = TRUE)
on.exit() registers an expression to run when the function ends, whether it ends normally or with an error. By default a second call replaces the first; add = TRUE appends, and since R 3.5.0 after = FALSE puts the new expression in front, so that cleanup runs last in, first out. Since R 3.5.0 all the registered expressions also run even if one of them signals an error. The idiom is to write the on.exit() line straight after the line that acquires the resource, with add = TRUE every time, so that a later edit cannot silently cancel it. The same pattern puts back the options a function changed, which is what R FAQ 7.7 shows with options().
# A second on.exit() replaces the first one unless add = TRUE.
replaced <- function() {
on.exit(cat("first\n"))
on.exit(cat("second\n"))
invisible(NULL)
}
replaced()
#> second
added <- function() {
on.exit(cat("first\n"), add = TRUE)
on.exit(cat("second\n"), add = TRUE)
invisible(NULL)
}
added()
#> first
#> second
# after = FALSE puts the new expression first (last in, first out).
lifo <- function() {
on.exit(cat("opened the file\n"), add = TRUE)
on.exit(cat("closed the file\n"), add = TRUE, after = FALSE)
invisible(NULL)
}
lifo()
#> closed the file
#> opened the file
# The cleanup also runs when the function stops with an error.
count_rows <- function(path) {
con <- file(path, "r")
on.exit(close(con), add = TRUE)
stop("something went wrong after opening")
}
connections_before <- nrow(showConnections())
tryCatch(count_rows("books.csv"), error = \(e) conditionMessage(e))
#> [1] "something went wrong after opening"
nrow(showConnections()) == connections_before
#> [1] TRUE
# Options changed inside a function are restored on the way out.
show_pi <- function(digits) {
old <- options(digits = digits)
on.exit(options(old), add = TRUE)
print(pi)
}
show_pi(3L)
#> [1] 3.14
print(pi)
#> [1] 3.141593
Source: R documentation: on.exit
The first function prints only second because the second call replaced the first; the second prints first and second in the order of the calls; the third, with after = FALSE, prints closed the file before opened the file. The connection counter compares the number of open connections before and after the failing call, and it is TRUE: the file was closed although the function stopped. See Functions and Conditions and Error Handling.
Explicit namespaces
Write pkg::fun when you use a function from a package, in scripts as well as in package code. A function defined in the global environment (or attached later on the search path) with the same name as a package function hides it, and pkg::fun goes to the package whatever the search path holds. In a package the same intent is written in the NAMESPACE file with importFrom(). The :: operator loads the namespace without attaching it, so it does not change what other code sees.
source("scenario.R")
# A global function with the name of a package function masks it.
head <- function(x, ...) "my own head"
head(books$title)
#> [1] "my own head"
find("head")
#> [1] ".GlobalEnv" "package:utils"
# pkg::fun says which function is meant, whatever the search path holds.
utils::head(books$title, 2L)
#> [1] "The Dispossessed" "Pride and Prejudice"
stats::median(books$pages, na.rm = TRUE)
#> [1] 268
# rm() removes the mask again.
rm(head)
find("head")
#> [1] "package:utils"
Source: R documentation: ::
find("head") lists the places where a name is found, in search order: after the global definition it names .GlobalEnv first. See Packages, Libraries and Namespaces for :: and :::, and masking and conflicts.
TRUE and FALSE
TRUE and FALSE are reserved words, so they cannot be changed. They are written in full everywhere, including the arguments na.rm, decreasing, drop and stringsAsFactors. A logical vector is produced by a comparison and counted with sum() or mean(), because TRUE counts as 1 and FALSE as 0. The pitfall about T and F below is the reason for the rule.
source("scenario.R")
# TRUE and FALSE are written out in full: na.rm = TRUE, decreasing = TRUE.
mean(books$pages, na.rm = TRUE)
#> [1] 268.2143
newest <- books[order(books$year, decreasing = TRUE), "title"]
head(trimws(newest), 3L)
#> [1] "THE LONG TABLE" "Ten Winters" "Loud Roofs"
# A logical vector is made by a comparison, and counted with sum() or mean().
long <- books$pages > 400
class(long)
#> [1] "logical"
sum(long, na.rm = TRUE)
#> [1] 2
# TRUE and FALSE cannot be reassigned; the next line is an error.
tryCatch(FALSE <- 1, error = \(e) conditionMessage(e))
#> [1] "invalid (do_set) left-hand side to assignment"
Source: R documentation: logical
An Introduction to R says it in one sentence (An Introduction to R: Logical vectors): T and F are variables that can be overwritten, so you should always use TRUE and FALSE. The styles are in Style, Linting and Static Analysis.
identical(), isTRUE() and all.equal()
Three functions answer "are these the same?", and they answer different questions. identical(x, y) is TRUE only when the two objects are the same in every respect, attributes and type included, and it always returns one TRUE or FALSE. all.equal(x, y) compares numbers with a tolerance (about 1.5e-8) and returns either TRUE or a character string that describes the difference; it can therefore not be used directly in if. isTRUE(x) is TRUE only for a single TRUE, so isTRUE(all.equal(x, y)) is the idiom for "equal within the tolerance", and isTRUE(NA) is a plain FALSE. Since R 3.5.0 isTRUE() also accepts a named logical of length one, and isFALSE() was added at the same time.
x <- c(a = 1, b = 2)
y <- c(a = 1, b = 2)
# identical() compares whole objects, attributes included, and returns one
# TRUE or FALSE.
identical(x, y)
#> [1] TRUE
identical(1L, 1)
#> [1] FALSE
identical(x, unname(y))
#> [1] FALSE
# all.equal() compares with a tolerance, but returns a message when it differs.
all.equal(1L, 1)
#> [1] TRUE
all.equal(1, 1.1)
#> [1] "Mean relative difference: 0.1"
# isTRUE() makes it a condition that is TRUE or FALSE, never a string or NA.
isTRUE(all.equal(1, 1 + 1e-10))
#> [1] TRUE
isTRUE(all.equal(1, 1.1))
#> [1] FALSE
isTRUE(NA)
#> [1] FALSE
isFALSE(NA)
#> [1] FALSE
if (isTRUE(all.equal(0.1 + 0.2, 0.3))) {
cat("equal within the tolerance\n")
}
#> equal within the tolerance
Source: R documentation: all.equal
Never use == for the comparison of computed doubles (see the pitfall below), and never test a condition with if (x == NA); is.na() is the question. See Defensive Programming and Input Validation.
Early returns
A function that checks its special cases first and returns at once reads top to bottom and has no staircase of nested if and else. Each return() handles one case; the last expression of the function is the ordinary case, and it is not written with return() (the return_linter of lintr flags that). The function below classifies a loan from its two dates, NA meaning "not returned", and the table counts the 60 loans of the scenario by status: the impossible date, the impossible return before the loan and the unreturned loans each have their own early exit.
source("scenario.R")
loan_status <- function(loan_date,
return_date,
today = shelf_today) { # nolint: object_usage_linter.
if (is.na(loan_date)) {
return("invalid date")
}
if (is.na(return_date)) {
if (today - loan_date > loan_days) { # nolint: object_usage_linter.
return("out and overdue")
}
return("out")
}
if (return_date < loan_date) {
return("returned before it was loaned")
}
if (return_date - loan_date > loan_days) { # nolint: object_usage_linter.
return("returned late")
}
"returned on time"
}
status <- vapply(
seq_len(nrow(loans)),
\(i) loan_status(loans$loan_date[[i]], loans$return_date[[i]]),
character(1L)
)
table(status)
#> status
#> invalid date out and overdue
#> 1 7
#> returned before it was loaned returned late
#> 1 25
#> returned on time
#> 26
Source: R documentation: function
Two cautions. A function that returns early must return a value of the same type on every path; the vapply() above enforces it. And on.exit() is the right way to clean up on every path, not a copy of the cleanup before each return(). See Functions and Control Flow.
Small pure functions
A pure function depends only on its arguments and changes nothing outside itself: the same arguments give the same result, a test is three lines, and the function works inside vapply(), in parallel and in a package. The impure version below reads and writes a variable of the global environment with <<-, so calling it twice with the same argument gives two different answers; the pure version returns the count and leaves the accumulating to the caller. stopifnot() is a fine unit test for a function this small (the testing framework is in Testing with testthat).
# Impure: reads and changes a variable outside the function.
total_late <- 0
count_late <- function(days) {
total_late <<- total_late + sum(days > 21) # nolint: assignment_linter.
total_late
}
count_late(c(10, 30, 25))
#> [1] 2
count_late(c(10, 30, 25))
#> [1] 4
# Pure: the result depends only on the arguments, and nothing else changes.
count_late_pure <- function(days, limit = 21) {
sum(days > limit)
}
count_late_pure(c(10, 30, 25))
#> [1] 2
count_late_pure(c(10, 30, 25))
#> [1] 2
# Small enough to test with plain vectors.
stopifnot(
count_late_pure(numeric(0)) == 0,
count_late_pure(c(21, 22)) == 1,
count_late_pure(c(10, 30), limit = 5) == 2
)
Source: R documentation: assignOps
The assignment in the impure function carries a nolint because lintr asks for an explicit environment instead of <<-. Closures are the legitimate use of <<- and are explained in Scoping, Environments and Closures; Functions covers how to write them.
The pitfalls at a glance
The table lists the sixteen pitfalls of the catalogue. The first column links to the section of this page; the last names the page of the reference that explains the concept. The R FAQ column gives the entry of chapter 7 where one exists; "no entry" means that the FAQ has no question about it, and then the documentation that does cover it is named in the section.
| Pitfall | Symptom | Fix | R FAQ | In depth |
|---|---|---|---|---|
The loop runs twice, for |
|
no entry |
||
|
Full names, |
no entry (3.3.3 mentions it) |
||
After |
|
no entry (3.3.3 mentions it) |
||
|
|
7.10 |
||
|
|
7.31 |
||
The result is a vector, a matrix or a list depending on the data; empty input gives |
|
no entry |
||
|
|
7.5 |
||
|
|
7.1 |
||
"missing value where TRUE/FALSE needed"; (an error since R 4.2.0). |
|
no entry |
||
A function moves the session to another directory or leaves an option changed. |
Paths with |
7.3, 7.7, 7.8 (related) |
||
Time grows with the square of the length when |
Preallocate, |
no entry |
||
The attached copy goes stale, global variables hide columns, a second |
|
no entry |
||
A sourced file shows no output for |
|
7.16 |
||
Old code expects factors from |
|
no entry |
||
|
Doubles, or keep identifiers as strings |
no entry |
||
|
|
no entry |
The pitfalls
Each section shows the bug first, with the output R printed, then the repair in the same script. Where a function appears with the suffix _bad or _old it is the code to avoid; where it is called _safe or _fixed it is the repair.
1:length(x) on empty input
The colon operator counts down when its second operand is smaller than the first, so 1:length(x) for an empty x is 1:0, which is the two numbers 1 and 0. A loop over it runs twice. In the first run x[1] is NA, an index past the end of the vector; in the second x[0] is an empty vector, which cat() prints as nothing. With [[ the first run is an error. The bug is invisible while the data has elements and appears the first time a filter selects nothing, which is why it is found in production. The same trap sits in 1:nrow(df), 1:ncol(m) and 1:n whenever the count can be zero. The script selects the titles of books newer than 2100 (there are none), shows the sequence, the two failures and a data frame version, and then the repair.
source("scenario.R")
# No book in the catalogue is newer than 2100, so this selection is empty.
future <- books$title[which(books$year > 2100)]
length(future)
#> [1] 0
# 1:length(x) is not an empty sequence when x is empty: it counts down.
1:length(future) # nolint: seq_linter.
#> [1] 1 0
describe_all <- function(x) {
for (i in 1:length(x)) { # nolint: seq_linter.
cat(i, ":", x[i], "\n")
}
}
describe_all(future)
#> 1 : NA
#> 0 :
tryCatch(
for (i in 1:length(future)) print(future[[i]]), # nolint: seq_linter.
error = \(e) conditionMessage(e)
)
#> [1] "subscript out of bounds"
# The same trap with the rows of a data frame that has none.
none <- books[which(books$year > 2100), ]
nrow(none)
#> [1] 0
1:nrow(none) # nolint: seq_linter.
#> [1] 1 0
# seq_along() and seq_len() are empty when the input is empty.
seq_along(future)
#> integer(0)
seq_len(nrow(none))
#> integer(0)
describe_all_fixed <- function(x) {
for (i in seq_along(x)) {
cat(i, ":", x[i], "\n")
}
}
describe_all_fixed(future)
describe_all_fixed(books$title[1:2])
#> 1 : The Dispossessed
#> 2 : Pride and Prejudice
Source: R documentation: seq
The repair is seq_along(x) for the positions of a vector and seq_len(n) for a count; both return integer(0) for nothing, so the loop body does not run and the function returns what it should for an empty input. When the position is not needed, loop over the values: for (title in titles). lintr flags the colon forms with seq_linter and its message names the empty edge case. R FAQ: no entry. The seq help page recommends seq_along and seq_len over the one-argument seq() and says they are very fast primitives. In depth: Control Flow and Defensive Programming and Input Validation.
Partial matching with $
x$name is x[["name", exact = FALSE]], as the Extract help page says: the $ operator completes a prefix of a name, on lists, on data frames and on S3 objects built on lists, as long as the prefix is unambiguous. [[ requires the exact name unless exact = FALSE is given (and exact = NA allows a match with a warning), and [ never matches partially. The convenience hides typos: rec$pag is accepted for pages. It also makes the meaning of code depend on what else the object contains: when a second field with the same prefix is added, the prefix is ambiguous and $ returns NULL, with no message. Environments do not match partially. The option warnPartialMatchDollar (default FALSE) turns the silence into a warning for both lists and data frames; the R 3.1.1 NEWS records that the warning for data frames is only given when the option is TRUE.
source("scenario.R")
# $ completes an unambiguous prefix of a name, silently.
rec <- list(title = "Loud Roofs", pages = 198L)
rec$pag
#> [1] 198
rec$ti
#> [1] "Loud Roofs"
# [[ ]] needs the whole name, unless exact = FALSE is given.
rec[["pag"]]
#> NULL
rec[["pag", exact = FALSE]]
#> [1] 198
# A data frame column is completed the same way.
books$pag[1:3]
#> [1] 387 432 214
# The option turns the silence into a warning (restore it afterwards).
getOption("warnPartialMatchDollar")
#> [1] FALSE
op <- options(warnPartialMatchDollar = TRUE)
tryCatch(rec$pag, warning = \(w) conditionMessage(w))
#> [1] "partial match of 'pag' to 'pages'"
tryCatch(books$pag, warning = \(w) conditionMessage(w))
#> [1] "partial match of 'pag' to 'pages'"
options(op)
# An ambiguous prefix gives NULL, so adding a field changes the answer.
rec$pagination <- "A5"
rec$pag
#> NULL
rec$pages
#> [1] 198
# An S3 object built on a list behaves like a list.
bk <- book(
"Loud Roofs", "Mara Quill", "978-0-00-200029-1", 2021, "fiction", 198
)
bk$pag
#> [1] 198
bk$pages
#> [1] 198
Source: R documentation: Extract
The repair is to write the full name, to use [["name"]] when the name is computed, and to switch the option on while developing and in tests, where the warning is caught by tryCatch() as above (it is a warning, so with options(warn = 2) it becomes an error). The scenario’s book objects are lists, so bk$pag completes to pages in the same way. R FAQ: no chapter 7 entry. Section 3.3.3 ("Others") of the FAQ, which lists differences between R and S, says that indexing with [ and a character vector looks only for exact matches whereas [[ and $ allow partial matches. In depth: Subsetting and Indexing, Lists and Defensive Programming and Input Validation.
T and F overwritten
T and F are not reserved words. They are ordinary variables in the base package whose values are TRUE and FALSE, and any code can assign to them. The logical help page states the difference: TRUE and FALSE are reserved words, while T and F are global variables that start out with those values. If a script, a package or a console session assigns T (a temperature, a time, a transposed matrix), every na.rm = T after it silently means something else: 0 is FALSE, so mean(x, na.rm = T) stops removing the missing values and returns NA, with no error and no warning. A function whose default is T looks T up where the function was defined, so it is affected too. The script defines such a function, breaks T on purpose, shows three wrong answers and removes the broken variable to uncover the real one.
# T and F are ordinary variables that hold TRUE and FALSE.
T # nolint: T_and_F_symbol_linter.
#> [1] TRUE
f <- function(x, na = T) mean(x, na.rm = na) # nolint: T_and_F_symbol_linter.
f(c(1, NA, 3))
#> [1] 2
# Anything can assign to them, and the code above changes meaning.
T <- 0 # nolint: T_and_F_symbol_linter, object_name_linter.
if (T) "yes" else "no" # nolint: T_and_F_symbol_linter.
#> [1] "no"
f(c(1, NA, 3))
#> [1] NA
mean(c(1, NA, 3), na.rm = T) # nolint: T_and_F_symbol_linter.
#> [1] NA
# Removing the global T uncovers the one in the base package again.
rm(T) # nolint: T_and_F_symbol_linter.
T # nolint: T_and_F_symbol_linter.
#> [1] TRUE
# TRUE and FALSE are reserved words and cannot be assigned.
tryCatch(TRUE <- 0, error = \(e) conditionMessage(e))
#> [1] "invalid (do_set) left-hand side to assignment"
Source: R documentation: logical
The repair is to write TRUE and FALSE, always; lintr has T_and_F_symbol_linter in its default set. R FAQ: no entry in chapter 7; section 3.3.3 states that T and F are just variables, that they can be overwritten and that code should always use TRUE and FALSE, and An Introduction to R (An Introduction to R: Logical vectors) says the same. In depth: Style, Linting and Static Analysis, Atomic Vectors and Types and Assignment, Names and Values (shadowing base names).
Factor to numeric
A factor stores small integer codes and a set of labels, the levels. as.numeric(f) returns the codes, not the numbers the labels spell. The levels are sorted, so the codes look plausible and the wrong answer is not noticed: in the output below the year 1974 becomes 2 because 1813 is the first level. The pitfall used to be common because read.csv() turned a column into a factor whenever one stray value ("n/a") made it text and stringsAsFactors was TRUE (the default before R 4.0.0, see the pitfall on stringsAsFactors). It still happens when a factor is made on purpose and then used as a number. R FAQ 7.10 gives the two repairs: as.numeric(as.character(f)), which converts every element, and as.numeric(levels(f))[as.integer(f)], which converts each level once and is more efficient, "but harder to remember"; and it says not to call as.numeric() or unclass() on the factor for this, as both give the internal codes.
source("scenario.R")
# The years as a factor, the way old read.csv() calls produced them.
year_f <- factor(books$year)
head(year_f, 3L)
#> [1] 1974 1813 2019
#> 15 Levels: 1813 1974 1999 2005 2008 2012 2014 2016 2017 2018 2019 2020 ... 2023
nlevels(year_f)
#> [1] 15
# Wrong: as.numeric() returns the internal codes, not the years.
as.numeric(year_f)[1:5]
#> [1] 2 1 11 13 8
# Right: go through the labels (R FAQ 7.10) ...
as.numeric(as.character(year_f))[1:5]
#> [1] 1974 1813 2019 2021 2016
# ... or index the numeric levels by the codes: one conversion per level.
as.numeric(levels(year_f))[year_f][1:5]
#> [1] 1974 1813 2019 2021 2016
identical(
as.numeric(as.character(year_f)),
as.numeric(levels(year_f))[year_f]
)
#> [1] TRUE
# Better still: do not create the factor until a factor is needed.
class(books$year)
#> [1] "integer"
The best repair is not to have the factor: books$year is an integer column already. A missing value stays NA through either conversion. R FAQ: 7.10. In depth: Factors and Atomic Vectors and Types.
Floating-point equality
Doubles are stored in binary with 53 significant bits. The only numbers that are exact are integers and fractions whose denominator is a power of two, so 0.1 is not exact and 3 * 0.1 is 0.30000000000000004, which == correctly reports as different from 0.3. R FAQ 7.31 is the question that asks it, with sqrt(2)^2 == 2 as the example, and answers with all.equal(), which compares with a tolerance of .Machine$double.eps ^ 0.5, about 1.5e-8. The comparison with == also fails inside lookups: unique(), table(), match(), %in% and which(x == value) treat two doubles that print the same as different values. The script shows the late fee of three days at 0.1 per day, the FAQ’s example, the lookup failures and the repairs.
fee_per_day <- 0.1
fee <- 3 * fee_per_day
fee == 0.3
#> [1] FALSE
fee - 0.3
#> [1] 5.551115e-17
print(fee, digits = 17L)
#> [1] 0.30000000000000004
# all.equal() compares with a tolerance (about 1.5e-8) ...
all.equal(fee, 0.3)
#> [1] TRUE
.Machine$double.eps^0.5
#> [1] 1.490116e-08
# ... but returns a message, not FALSE, when the numbers differ.
all.equal(fee, 0.31)
#> [1] "Mean relative difference: 0.03333333"
isTRUE(all.equal(fee, 0.31))
#> [1] FALSE
tryCatch(
if (all.equal(fee, 0.31)) "same",
error = \(e) conditionMessage(e)
)
#> [1] "argument is not interpretable as logical"
# The example of R FAQ 7.31.
a <- sqrt(2)
a * a == 2
#> [1] FALSE
a * a - 2
#> [1] 4.440892e-16
isTRUE(all.equal(a * a, 2))
#> [1] TRUE
# == in a lookup misses values that print the same.
x <- c(0.1 + 0.2, 0.3)
x
#> [1] 0.3 0.3
x == 0.3
#> [1] FALSE TRUE
length(unique(x))
#> [1] 2
length(unique(round(x, 10L)))
#> [1] 1
grid <- seq(0, 1, by = 0.1)
which(grid == 0.3)
#> integer(0)
which(abs(grid - 0.3) < 1e-9)
#> [1] 4
# Integers and values that are exact in binary are safe to compare.
5L == 5
#> [1] TRUE
0.5 + 0.25 == 0.75
#> [1] TRUE
all.equal() returns TRUE or a character string such as "Mean relative difference: 0.03333333", never FALSE, so if (all.equal(a, b)) fails with "argument is not interpretable as logical" when the numbers differ: wrap it in isTRUE(). For a lookup, round both sides to a fixed number of digits first, or compare with an explicit tolerance as abs(grid - 0.3) < 1e-9. Money is better kept in whole units (cents as integers) than in doubles; integers and values like 0.5 are exact, as the last two lines show. R FAQ: 7.31. In depth: Numbers and Arithmetic and Defensive Programming and Input Validation.
sapply simplification
sapply() is lapply() followed by simplify2array(), and what that does depends on the lengths of the answers: all of length one gives a vector, all of the same length greater than one gives a matrix, anything else gives a list. So the same call returns different shapes for different data, and for empty input it returns a list (list(), or named list() for a character vector), not an empty vector. Code tested on three elements fails on none. The script shows the three shapes, a longest() helper that works for three titles and fails for none with "invalid 'type' (list) of argument", and the vapply() version that returns 0 for none; the two vapply() calls at the end show its checks with R’s own text.
source("scenario.R")
# The same call returns a vector, a matrix or a list, depending on the data.
sapply(1:3, \(n) n^2)
#> [1] 1 4 9
sapply(1:3, \(n) c(n, n^2))
#> [,1] [,2] [,3]
#> [1,] 1 2 3
#> [2,] 1 4 9
sapply(1:3, \(n) seq_len(n))
#> [[1]]
#> [1] 1
#>
#> [[2]]
#> [1] 1 2
#>
#> [[3]]
#> [1] 1 2 3
#>
# Empty input gives a list, not an empty vector.
none <- books$title[which(books$year > 2100)]
sapply(none, nchar)
#> named list()
longest <- function(titles) max(sapply(titles, nchar))
longest(books$title[1:3])
#> [1] 20
tryCatch(longest(none), error = \(e) conditionMessage(e))
#> [1] "invalid 'type' (list) of argument"
# vapply() states the type and length of every result.
longest_safe <- function(titles) max(vapply(titles, nchar, integer(1L)), 0L)
longest_safe(books$title[1:3])
#> [1] 20
longest_safe(none)
#> [1] 0
tryCatch(
vapply(1:3, \(n) c(n, n^2), numeric(1L)),
error = \(e) conditionMessage(e)
)
#> [1] "values must be length 1,\n but FUN(X[[1]]) result is length 2"
tryCatch(
vapply(1:3, \(n) "a", numeric(1L)),
error = \(e) conditionMessage(e)
)
#> [1] "values must be type 'double',\n but FUN(X[[1]]) result is type 'character'"
Source: R documentation: lapply
The repair is vapply() with a FUN.VALUE whenever the result is meant to be a vector or a matrix, and lapply() whenever it is meant to be a list. lintr has sapply in the list of the functions that undesirable_function_linter reports, with the advice to use vapply(). R FAQ: no entry (FAQ 7.17 is about outer() and shape, a different function). The lapply help page describes vapply as similar to sapply but "safer". In depth: The apply Family and Functionals and Defensive Programming and Input Validation.
drop on matrices and data frames
R drops dimensions of length one when it can. Selecting a single row or column of a matrix gives a plain vector, with the dim attribute gone and the other dimnames turned into names; selecting one column of a data frame with a comma gives a vector too. A function written for a matrix of any number of rows then works for two rows and fails for one. R FAQ 7.5 says this was determined, after much discussion, to be a feature, that drop = FALSE prevents it, and that drop = FALSE should be used defensively when programming, because mat[index, ] returns a vector rather than a matrix when index has length one. The first script shows it on a matrix of years and page counts, with the failure message of apply().
source("scenario.R")
m <- as.matrix(books[1:4, c("year", "pages")])
m
#> year pages
#> 1 1974 387
#> 2 1813 432
#> 3 2019 214
#> 4 2021 198
# One row selected: the matrix becomes a named vector.
m[2, ]
#> year pages
#> 1813 432
dim(m[2, ])
#> NULL
m[2, , drop = FALSE]
#> year pages
#> 2 1813 432
row_totals <- function(m, rows) apply(m[rows, ], 1L, sum)
row_totals(m, 1:2)
#> 1 2
#> 2361 2245
tryCatch(row_totals(m, 1L), error = \(e) conditionMessage(e))
#> [1] "dim(X) must have a positive length"
# drop = FALSE keeps the shape whatever the length of the index.
row_totals_safe <- function(m, rows) apply(m[rows, , drop = FALSE], 1L, sum)
row_totals_safe(m, 1:2)
#> 1 2
#> 2361 2245
row_totals_safe(m, 1L)
#> 1
#> 2361
row_totals_safe(m, integer(0))
#> integer(0)
For data frames the rule is slightly different: with a comma, df[, "col"] drops to a vector when one column is left; df["col"] (a single index, no comma) never drops, and neither does df[1, ] (one row). A helper that selects columns by name and passes the result to a function that needs a data frame or matrix, such as colMeans(), fails for exactly one column, with a message about arrays of at least two dimensions.
source("scenario.R")
# [ with a comma drops a one-column result to a vector; a single index does not.
class(books[, "pages"])
#> [1] "integer"
class(books[, "pages", drop = FALSE])
#> [1] "data.frame"
class(books["pages"])
#> [1] "data.frame"
class(books[1, ])
#> [1] "data.frame"
col_means <- function(df, cols) colMeans(df[, cols], na.rm = TRUE)
col_means(books, c("year", "pages"))
#> year pages
#> 1998.7333 268.2143
tryCatch(col_means(books, "pages"), error = \(e) conditionMessage(e))
#> [1] "'x' must be an array of at least two dimensions"
col_means_safe <- function(df, cols) {
colMeans(df[, cols, drop = FALSE], na.rm = TRUE)
}
col_means_safe(books, "pages")
#> pages
#> 268.2143
The repair is drop = FALSE in every subscript whose index length is not known in advance, or df[cols] for data frames. Tibbles and data.table do not drop, which is one reason people choose them; this page does not run either package. R FAQ: 7.5 for matrices and arrays; no entry for data frames. In depth: Matrices and Arrays, Subsetting and Indexing and Data Frames.
NULL in lists
Assigning NULL to a list element removes the element. rec$isbn <- NULL, rec[["isbn"]] <- NULL and rec["isbn"] <- NULL all delete the field, so a book without an ISBN loses the isbn entry and names(rec) shrinks. R FAQ 7.1 gives the repair: use x[i] <- list(NULL) to set component i of the list x to NULL, similarly for named components, and do not set x[i] or x[[i]] to NULL, because that removes the component. The same two-sided behaviour shows up elsewhere: list(1, NULL, 3) has three elements but c(list(1), NULL, list(3)) has two, because c() drops a bare NULL; and assigning NULL to an element of a result list in a loop shortens the list so later positions shift.
rec <- list(title = "Loud Roofs", isbn = "9780002000291", pages = 198L)
# Assigning NULL removes the element.
rec$isbn <- NULL
names(rec)
#> [1] "title" "pages"
# R FAQ 7.1: assign list(NULL) to store a NULL instead.
rec <- list(title = "Loud Roofs", isbn = "9780002000291", pages = 198L)
rec["isbn"] <- list(NULL)
names(rec)
#> [1] "title" "isbn" "pages"
str(rec)
#> List of 3
#> $ title: chr "Loud Roofs"
#> $ isbn : NULL
#> $ pages: int 198
is.null(rec$isbn)
#> [1] TRUE
"isbn" %in% names(rec)
#> [1] TRUE
# Several elements at once.
x <- list(a = 1, b = 2, c = 3)
x[c("a", "b")] <- list(NULL)
str(x)
#> List of 3
#> $ a: NULL
#> $ b: NULL
#> $ c: num 3
x <- list(a = 1, b = 2, c = 3)
x[c("a", "b")] <- NULL
str(x)
#> List of 1
#> $ c: num 3
# list(NULL) elements count, but c() drops a bare NULL.
length(list(1, NULL, 3))
#> [1] 3
length(c(list(1), NULL, list(3)))
#> [1] 2
# A result list that has a NULL: lapply() keeps it, assigning NULL deletes it.
res <- lapply(1:3, \(i) if (i == 2L) NULL else i)
length(res)
#> [1] 3
res[[2]] <- NULL
length(res)
#> [1] 2
# Missing values are better stored as NA than as NULL.
rec <- list(title = "Loud Roofs", isbn = NA_character_, pages = 198L)
str(rec)
#> List of 3
#> $ title: chr "Loud Roofs"
#> $ isbn : chr NA
#> $ pages: int 198
Often what is really meant is "unknown", which is better stored as a typed NA such as NA_character_: it keeps the field, the type and the length. is.null(rec$isbn) cannot tell a field that is NULL from a field that does not exist, so use "isbn" %in% names(rec) for that. R FAQ: 7.1. In depth: Lists, Subsetting and Indexing and Missing and Special Values.
if (NA) and conditions longer than one
if and while need a condition that is exactly one TRUE or FALSE. The conditions that are not give an error each, with a message of its own: NA gives "missing value where TRUE/FALSE needed" (and if (year > 2000L) is NA when year is, as it is for the book in row 7), NULL or a zero-length vector give "argument is of length zero", a string that is not a logical gives "argument is not interpretable as logical". A condition of length greater than one used to take the first element with a warning; it has been an error since R 4.2.0 (), and && and || with an operand of length greater than one warned in 4.2.0 and are an error since R 4.3.0 ("'length = 2' in coercion to 'logical(1)'"). The first script is about NA.
source("scenario.R")
year <- books$year[7L]
year
#> [1] NA
tryCatch(
if (year > 2000L) "new" else "old",
error = \(e) conditionMessage(e)
)
#> [1] "missing value where TRUE/FALSE needed"
tryCatch(if (NA) 1, error = \(e) conditionMessage(e))
#> [1] "missing value where TRUE/FALSE needed"
tryCatch(while (NA) 1, error = \(e) conditionMessage(e))
#> [1] "missing value where TRUE/FALSE needed"
# Other conditions that are not one TRUE or FALSE.
tryCatch(if (NULL) 1, error = \(e) conditionMessage(e))
#> [1] "argument is of length zero"
tryCatch(if (logical(0)) 1, error = \(e) conditionMessage(e))
#> [1] "argument is of length zero"
tryCatch(if ("yes") 1, error = \(e) conditionMessage(e))
#> [1] "argument is not interpretable as logical"
# Decide what NA means, then test for it.
if (isTRUE(year > 2000L)) "new" else "old or unknown"
#> [1] "old or unknown"
if (is.na(year)) "unknown" else if (year > 2000L) "new" else "old"
#> [1] "unknown"
!is.na(year) && year > 2000L
#> [1] FALSE
Source: R documentation: Control
The decision belongs to the programmer: decide what NA means and write it. isTRUE(cond) turns NA into FALSE; is.na(x) first, then the comparison, makes the third case explicit; !is.na(x) && x > 2000L short-circuits. The second script is about the length of the condition, with the fix: any() and all() (with na.rm = TRUE when NA is possible) say whether some or every element qualifies, and ifelse() computes a value per element.
source("scenario.R")
tryCatch(
if (books$year > 2000L) "some are new",
error = \(e) conditionMessage(e)
)
#> [1] "the condition has length > 1"
tryCatch(
c(TRUE, FALSE) && TRUE,
error = \(e) conditionMessage(e)
)
#> [1] "'length = 2' in coercion to 'logical(1)'"
tryCatch(
books$year > 2000L || TRUE,
error = \(e) conditionMessage(e)
)
#> [1] "'length = 16' in coercion to 'logical(1)'"
# Say what is meant: any(), all(), or a vectorised ifelse().
any(books$year > 2000L, na.rm = TRUE)
#> [1] TRUE
all(books$year > 1800L, na.rm = TRUE)
#> [1] TRUE
age <- ifelse(is.na(books$year), "unknown",
ifelse(books$year > 2000L, "new", "old")
)
table(age)
#> age
#> new old unknown
#> 12 3 1
Source: R documentation: Logic
The rule of thumb that goes with it is that && and || are for conditions of length one and & and | are for vectors; the vector_logic_linter of lintr reports the vector forms inside if. R FAQ: no entry. The changes are in the R NEWS of 4.2.0 and 4.3.0 (R NEWS: Changes in R 4.2.0) and An Introduction to R describes the conditional (An Introduction to R: Conditional execution). In depth: Control Flow, conditions of length greater than one and Missing and Special Values.
Global state and setwd
Global state is anything a function can change that outlives the call: the working directory, the options, the search path (library(), attach()), environment variables, the locale, the random seed, graphics parameters, and objects in the global environment (<<-, assign()). Code that changes it without putting it back is correct when run once, from a clean session, in the author’s folder, and wrong afterwards. Two cases are common enough to deserve a demonstration. The first is the working directory. A script that starts by moving to a folder of the author’s machine cannot run anywhere else, and a function that moves the session leaves the caller in a directory it did not choose: the next relative path fails or, worse, reads another file with the same name. The script below makes a scratch folder with a copy of the catalogue, shows the bad function, the failure it causes, and three repairs. R FAQ 7.8 is the related entry for file names on Windows (use forward slashes or escape the backslash).
source("scenario.R")
# A scratch folder with a copy of the catalogue, as a stand-in for a project.
folder <- tempfile("shelf")
dir.create(folder)
invisible(file.copy("books.csv", folder))
start <- getwd()
# Bad: the function changes the working directory of the whole session.
read_books_bad <- function(dir) {
setwd(dir)
read.csv("books.csv")
}
nrow(read_books_bad(folder))
#> [1] 16
identical(getwd(), start)
#> [1] FALSE
# Later code that expects the old directory now fails.
tryCatch(
read.csv("loans.csv"),
warning = \(w) conditionMessage(w)
)
#> [1] "cannot open file 'loans.csv': No such file or directory"
setwd(start)
# Fix 1: build the path instead of moving; nothing global changes.
read_books <- function(dir) read.csv(file.path(dir, "books.csv"))
nrow(read_books(folder))
#> [1] 16
identical(getwd(), start)
#> [1] TRUE
# Fix 2: if a function must change directory, it puts the old one back.
read_books_in <- function(dir) {
old <- setwd(dir)
on.exit(setwd(old), add = TRUE)
read.csv("books.csv")
}
nrow(read_books_in(folder))
#> [1] 16
identical(getwd(), start)
#> [1] TRUE
# Fix 3: withr::with_dir() does the same in one call.
nrow(withr::with_dir(folder, read.csv("books.csv")))
#> [1] 16
identical(getwd(), start)
#> [1] TRUE
unlink(folder, recursive = TRUE)
file.exists(folder)
#> [1] FALSE
Source: R documentation: setwd
The repair in order of preference is to move nothing: build the path (file.path(dir, "books.csv")) from a root that the project knows (an RStudio or Positron project, or Rscript started in the project folder, so that relative paths are relative to it); then, if a function must run in another directory, to restore it with on.exit() using the value that the setter returns; or to let withr 3.0.3 do the same in one call with withr::with_dir(). The getwd help page documents that the setter returns the old directory, invisibly. The second case is options. A function that calls options() and does not restore it changes every later printout in the session, and rm(list = ls()), the line at the top of many scripts "to start clean", does not reset it, because it removes objects and nothing else; a fresh process does (see the next script). R FAQ 7.7 recommends the pattern of the repair, taking the old values from options() and putting them back with on.exit().
# A function that sets an option and never puts it back.
set_digits <- function() {
options(digits = 3L)
invisible(NULL)
}
print(pi)
#> [1] 3.141593
set_digits()
print(pi)
#> [1] 3.14
# rm(list = ls()) removes objects. It does not reset options.
rm(list = ls())
print(pi)
#> [1] 3.14
getOption("digits")
#> [1] 3
# Restore the option by hand here; inside a function use on.exit().
options(digits = 7L)
print(pi)
#> [1] 3.141593
# R FAQ 7.7: take the old values, put them back on the way out.
print_pi <- function(digits) {
old <- options(digits = digits)
on.exit(options(old), add = TRUE)
print(pi)
}
print_pi(3L)
#> [1] 3.14
print(pi)
#> [1] 3.141593
R FAQ: none for the working directory itself; 7.3 ("How can I clean up my workspace?" answers with rm(list = ls(all.names = TRUE)), which removes objects only, and does not claim more), 7.7 for options and 7.8 for file names. In depth: Sessions, Workspaces and Startup, options(), Connections and the File System and Functions.
Growing vectors
out <- c(out, value) in a loop allocates a new vector of length n + 1 and copies the n elements already there on every iteration, so the time grows with the square of the final length: doubling the number of iterations makes the loop about four times slower. Assigning past the end (out[[length(out) + 1]] <- value) is cheaper since R 3.4.0, whose NEWS records that assigning beyond the length now over-allocates by a small fraction so that building a vector this way is more efficient, "though pre-allocating is still preferred" and that the implementation is subject to change. The same holds for lists and, much worse, for rbind(df, row) on a data frame in a loop. The script computes 30000 squares four ways, checks that they agree for ten of them, times the first three and prints only comparisons, because seconds depend on the machine, the BLAS and what else is running: the first two comparisons are TRUE when the growing loop is more than five times slower than the preallocated loop and than vapply(); the third doubles the length and is TRUE when the time grows by more than a factor of 2.5, which a quadratic loop does and a linear one does not.
# Elapsed seconds for an expression: the absolute numbers differ by machine,
# so only comparisons are printed below.
elapsed <- function(expr) system.time(expr)[["elapsed"]]
n <- 30000L
# Growing: every c() allocates a new vector and copies the old one.
grow <- function(n) {
out <- c()
for (i in seq_len(n)) {
out <- c(out, i^2)
}
out
}
# Preallocating: one allocation, then the loop fills it in place.
prealloc <- function(n) {
out <- numeric(n)
for (i in seq_len(n)) {
out[[i]] <- i^2
}
out
}
# vapply(): the loop and the allocation are done for you.
mapped <- function(n) {
vapply(seq_len(n), \(i) i^2, numeric(1L))
}
# Fully vectorised: no loop at all.
direct <- function(n) seq_len(n)^2
identical(grow(10L), prealloc(10L))
#> [1] TRUE
identical(mapped(10L), prealloc(10L))
#> [1] TRUE
identical(direct(10L), prealloc(10L))
#> [1] TRUE
t_grow <- elapsed(grow(n))
t_pre <- elapsed(prealloc(n))
t_map <- elapsed(mapped(n))
# Printed as comparisons, not as seconds.
t_grow > 5 * t_pre
#> [1] TRUE
t_grow > 5 * t_map
#> [1] TRUE
# Doubling n roughly quadruples the time of the growing loop.
t_grow2 <- elapsed(grow(2L * n))
t_grow2 / t_grow > 2.5
#> [1] TRUE
Source: R NEWS: Changes in R 3.4.0
The repairs, in order: vectorise (seq_len(n)^2), vapply() (the loop and the allocation done once), or preallocate with numeric(n), integer(n), character(n) or vector("list", n) and fill by position; when the final length is unknown, collect the pieces in a list and combine them once with unlist() or do.call(rbind, parts). R FAQ: no entry. In depth: Vectorisation and Iteration, Control Flow and Memory and Copy Semantics.
attach
attach(df) puts a copy of a data frame on the search path so that its columns can be used without df$. The copy is the problem. Later changes to the data frame do not reach it; a global variable with a column’s name hides the column (R prints a message when an attach causes masking); attaching again stacks a second copy; and every function called in between sees the extra entry, so the meaning of a name depends on what happened earlier in the session. An Introduction to R describes the first of these itself: after an assignment the new value is not visible until the data frame is detached and attached again. The script reproduces each effect and ends with the repair.
source("scenario.R")
attach(books)
mean(pages, na.rm = TRUE)
#> [1] 268.2143
# The attached copy does not follow later changes to the data frame.
books$pages[1] <- 1000L
mean(books$pages, na.rm = TRUE)
#> [1] 312
mean(pages, na.rm = TRUE) # nolint: object_usage_linter.
#> [1] 268.2143
# Attaching twice puts two copies on the search path.
attach(books)
#> The following objects are masked from books (pos = 3):
#>
#> author, genre, isbn, pages, title, year
search()[1:4]
#> [1] ".GlobalEnv" "books" "books" "package:stats"
detach(books)
detach(books)
# A global variable with a column's name hides the column.
pages <- 1:3
attach(books)
#> The following object is masked _by_ .GlobalEnv:
#>
#> pages
mean(pages)
#> [1] 2
detach(books)
rm(pages)
# Fix: say where the column comes from.
books <- read.csv("books.csv")
with(books, mean(pages, na.rm = TRUE))
#> [1] 268.2143
mean(books$pages, na.rm = TRUE)
#> [1] 268.2143
After the first attach(books), mean(pages) is 268.2143. The data frame then changes (books$pages[1] <- 1000L) and its own mean is 312, but the attached pages still gives 268.2143. The second attach(books) prints a message that the objects of the new entry mask those of the first, and search() shows books twice. With pages <- 1:3 in the workspace, mean(pages) is 2: the workspace comes before every attached entry, and the message says that the object is masked by .GlobalEnv. The repair is with(books, mean(pages, na.rm = TRUE)), books$pages, or the data = argument of the modelling functions; use() (R 4.4.0) attaches only the names you list. lintr reports attach in the same list as sapply and the working-directory setter. R FAQ: no entry on its misuse; section 3.3.3 only notes that attach() works for lists and data frames. In depth: Data Frames, Scoping, Environments and Closures and Packages, Libraries and Namespaces.
source() printing nothing
At the console a visible value is printed automatically. In a file that is run with source(), inside a function and in the body of a loop it is not: only an explicit print() or cat() shows anything. That is the content of R FAQ 7.16, which gives the repairs: print(1+1) instead of 1+1, or source(file, echo = TRUE). A file run with Rscript is read as a script and does print its visible top-level values, which is why the same file can look different under the two. The source help page documents print.eval, which defaults to the value of echo and can be set alone, and withAutoprint() for code that is already in the session; the value that source() returns is a list with the value and its visibility.
script <- tempfile(fileext = ".R")
writeLines(
c(
"x <- c(2, 4, 6)",
"mean(x)",
"print(sum(x))",
"invisible(99)",
"1 + 1"
),
script
)
# Only the explicit print() produces output.
source(script)
#> [1] 12
# echo = TRUE shows each expression and prints what it returns (R FAQ 7.16).
source(script, echo = TRUE)
#>
#> > x <- c(2, 4, 6)
#>
#> > mean(x)
#> [1] 4
#>
#> > print(sum(x))
#> [1] 12
#>
#> > invisible(99)
#>
#> > 1 + 1
#> [1] 2
# print.eval = TRUE prints the visible values without echoing the code.
source(script, print.eval = TRUE)
#> [1] 4
#> [1] 12
#> [1] 2
# source() returns the value of the last expression and its visibility.
res <- source(script)
#> [1] 12
res$value
#> [1] 2
res$visible
#> [1] TRUE
# withAutoprint() does the same for code that is already in the session.
withAutoprint({
x <- 1:3
x * 2
})
#> > x <- 1:3
#> > x * 2
#> [1] 2 4 6
unlink(script)
file.exists(script)
#> [1] FALSE
The block shows, in order, the output of source(script) (only the print(sum(x)) line of the file), echo = TRUE (the code, each expression after a prompt, and the visible values; invisible(99) prints nothing) and print.eval = TRUE (the values alone). The script file is a temporary file, deleted at the end, and its path is never shown. R FAQ: 7.16. In depth: Scripts and the Command Line and Functions for visibility.
stringsAsFactors
Until R 4.0.0 data.frame() and read.table() (so read.csv()) turned character columns into factors by default. R 4.0.0 changed the default to FALSE, and the data.frame help page says so: the old factory-fresh default was TRUE and it became FALSE in R 4.0.0. The helper default.stringsAsFactors() was deprecated in R 4.2.0 and is defunct since R 4.3.0, and options(stringsAsFactors = TRUE) is deprecated and warns. Code from before 2020 therefore breaks in both directions: scripts that expected factors (levels(df$genre) is now NULL) and scripts that begin with options(stringsAsFactors = FALSE) or end with as.character() calls to undo the conversion, which are now noise. The script shows today’s default, the old behaviour on request, the deprecation and the defunct function with their messages.
source("scenario.R")
# Since R 4.0.0 strings stay strings.
str(data.frame(genre = c("Fiction", "Poetry"), n = 1:2))
#> 'data.frame': 2 obs. of 2 variables:
#> $ genre: chr "Fiction" "Poetry"
#> $ n : int 1 2
str(books$genre)
#> chr [1:16] "Science fiction" "Classic" "Fiction" "fiction" "Mystery" ...
# The old behaviour is still available when it is asked for.
str(data.frame(
genre = c("Fiction", "Poetry"), n = 1:2, stringsAsFactors = TRUE
))
#> 'data.frame': 2 obs. of 2 variables:
#> $ genre: Factor w/ 2 levels "Fiction","Poetry": 1 2
#> $ n : int 1 2
str(read.csv("books.csv", stringsAsFactors = TRUE)$genre)
#> Factor w/ 9 levels "Classic","fiction",..: 9 1 3 2 5 6 7 1 6 4 ...
# The global switch is gone: setting it to TRUE warns ...
tryCatch(
options(stringsAsFactors = TRUE),
warning = \(w) conditionMessage(w)
)
#> [1] "'options(stringsAsFactors = TRUE)' is deprecated and will be disabled"
# ... and the helper is defunct.
tryCatch(
default.stringsAsFactors(),
error = \(e) conditionMessage(e)
)
#> [1] "'default.stringsAsFactors' is defunct.\nUse '`stringsAsFactors = FALSE`' instead.\nSee help(\"Defunct\")"
getOption("stringsAsFactors")
#> NULL
# Make a factor on purpose, after cleaning the text.
genre <- factor(tolower(trimws(books$genre)))
levels(genre)
#> [1] "classic" "fiction" "mystery" "non-fiction"
#> [5] "poetry" "science fiction"
Source: R documentation: data.frame
The repair is to make a factor on purpose, after cleaning the text (the last lines trim and lower-case the nine spellings of the six genres of the scenario, then call factor()), and to pass stringsAsFactors = TRUE as an argument when the old conversion is wanted. R FAQ: no entry about this change; 7.10 is the related one for the conversion back. The R NEWS 4.0.0 entry says the default is stringsAsFactors = FALSE and that "a large number of packages relied on the previous behaviour". In depth: Data Frames and Factors.
Integer overflow
R’s integer type has 32 bits, so the largest integer is .Machine$integer.max, 2147483647. Arithmetic on integers that goes beyond it does not wrap or switch to doubles: the Arithmetic help page says the result is NA_integer_ with a warning. Mixing in a double (2 or 1.0, not 2L) gives a double and no problem. sum() of integers has not overflowed since R 3.5.0 (the sum help page: "Integer overflow should no longer happen since R version 3.5.0"), but cumsum() still does, with its own message. The scenario has a natural victim: an ISBN-13 is a 13-digit number, larger than any integer, and as.integer("9780306406157") is NA. Doubles hold integers exactly up to 2 to the power 53, so 13 digits survive as doubles; but an identifier is not a number (you never add two ISBNs, ISBN-10 has an X, and a leading zero would be lost), so the scenario keeps ISBNs as strings.
.Machine$integer.max
#> [1] 2147483647
tryCatch(
.Machine$integer.max + 1L,
warning = \(w) conditionMessage(w)
)
#> [1] "NAs produced by integer overflow"
.Machine$integer.max + 1
#> [1] 2147483648
big <- 100000L
tryCatch(big * big, warning = \(w) conditionMessage(w))
#> [1] "NAs produced by integer overflow"
as.numeric(big) * big
#> [1] 1e+10
class(big * 2)
#> [1] "numeric"
# sum() has not overflowed since R 3.5.0, cumsum() still does.
sum(c(.Machine$integer.max, 1L))
#> [1] 2147483648
tryCatch(
cumsum(c(.Machine$integer.max, 1L)),
warning = \(w) conditionMessage(w)
)
#> [1] "integer overflow in 'cumsum'; use 'cumsum(as.numeric(.))'"
cumsum(as.numeric(c(.Machine$integer.max, 1L)))
#> [1] 2147483647 2147483648
# An ISBN-13 does not fit in an integer, a double holds it exactly.
isbn <- "9780306406157"
tryCatch(as.integer(isbn), warning = \(w) conditionMessage(w))
#> [1] "NAs introduced by coercion to integer range"
format(as.numeric(isbn), scientific = FALSE)
#> [1] "9780306406157"
nchar(isbn)
#> [1] 13
Source: R documentation: Arithmetic
For integers that must exceed 32 bits and stay integers, the CRAN package bit64 provides a 64-bit type; it is not used or run on this page. In base R the repair is to use doubles: write 1 or 1e5 rather than 1L, convert with as.numeric() before multiplying, and keep identifiers as character. R FAQ: no entry. In depth: Numbers and Arithmetic and Atomic Vectors and Types.
sample(x) with a length-1 x
sample(x) has a convenience that is a trap: if x has length one, is numeric and is at least 1, it samples from 1:x instead of from x. So sample(280L) is a random permutation of the numbers 1 to 280 and sample(280L, 1L) is one of them, not 280. It is harmless when x is a typed-in vector and a bug when x comes from a computation whose length varies, such as the rows that match a filter: with two matching rows sample(rows, 1L) picks one of them; with one matching row it picks a random row number between 1 and that row. The sample help page warns about it and ends its examples with the repair. Here which(books$pages > 420) is the single row 2 ("Pride and Prejudice", 432 pages), and drawing 200 times from it gives row 1 about half the time.
source("scenario.R")
set.seed(282)
pick <- function(x) sample(x, 1L)
pick(c(432L, 280L))
#> [1] 280
# With a single number, sample() draws from 1:x, not from x.
pick(280L)
#> [1] 166
# The books with more than 420 pages: here exactly one, so the trap is live.
long <- which(books$pages > 420)
long
#> [1] 2
draws <- replicate(200L, sample(long, 1L))
table(draws)
#> draws
#> 1 2
#> 91 109
# The helper from the help page of sample() always indexes.
resample <- function(x, ...) x[sample.int(length(x), ...)]
draws <- replicate(200L, resample(long, 1L))
table(draws)
#> draws
#> 2
#> 200
# sample.int(length(x), size) is the underlying call.
resample(c(432L, 280L), 2L)
#> [1] 280 432
tryCatch(
resample(integer(0), 1L),
error = \(e) conditionMessage(e)
)
#> [1] "invalid first argument"
Source: R documentation: sample
The repair is the resample() function from the help page, x[sample.int(length(x), ...)], which always indexes; sample.int() takes the number of items and never reinterprets its first argument. Character vectors are not affected (sample("a") is "a"), which hides the bug in tests that use titles and shows it with row numbers. With nothing to sample from both functions fail with "invalid first argument", so a caller that can get an empty set checks length(x) first. Results of sample() also changed in R 3.6.0, whose default became rejection sampling, so seeds from older scripts give other draws. R FAQ: no entry. In depth: Distributions and Random Numbers and the R 3.6.0 change.
Letting lintr find them
Several of the sixteen pitfalls leave a trace in the source that a linter can see before the code runs: the colon sequence (seq_linter), T and F (T_and_F_symbol_linter), a comparison with NA (equals_na_linter), & in a condition (vector_logic_linter), a redundant return() (return_linter), and, once undesirable_function_linter is added to the default set, attach, options and sapply. The script lints nine lines of old-style code from a string, so nothing is run, and prints the line and the name of every linter that fired. The default list of undesirable_function_linter also names the working-directory setter, source, library, par, Sys.setenv, Sys.setlocale and mapply, which is how the global-state pitfall shows up in a report; the last expression of the script confirms five of the names against the list that the installed lintr 3.4.0 carries.
# Nine lines of older-tutorial R, linted from a string (nothing is run).
code <- c(
"f <- function(x, rm_na = T) {",
" for (i in 1:length(x)) print(i)",
" if (x == NA) 1",
" if (rm_na & TRUE) 2",
" attach(x)",
" options(digits = 3)",
" y <- sapply(x, nchar)",
" return(y)",
"}"
)
linters <- lintr::linters_with_defaults(lintr::undesirable_function_linter())
found <- lintr::lint(text = paste(code, collapse = "\n"), linters = linters)
for (lint in found) {
cat(sprintf("line %d: %s\n", lint$line_number, lint$linter))
}
#> line 1: T_and_F_symbol_linter
#> line 2: seq_linter
#> line 3: equals_na_linter
#> line 4: vector_logic_linter
#> line 5: undesirable_function_linter
#> line 6: undesirable_function_linter
#> line 7: undesirable_function_linter
#> line 8: return_linter
# The default list of undesirable functions includes these.
intersect(
c("attach", "setwd", "sapply", "options", "source"),
names(lintr::default_undesirable_functions)
)
#> [1] "attach" "setwd" "sapply" "options" "source"
Line 1 is T, line 2 the colon sequence, line 3 the comparison with NA, line 4 the &, lines 5 to 7 attach, options and sapply from the undesirable list, line 8 the explicit return(). The other linters of the default set (names, spacing, quotes, line length) are about style and are described in Style, Linting and Static Analysis; a line that shows a bad idiom on purpose, as the demonstrations on this page do, carries # nolint: <linter>. as explained in excluding code. The reference pages of the individual linters are seq_linter, T_and_F_symbol_linter, equals_na_linter, vector_logic_linter and return_linter. A linter finds the pattern, not the data: it cannot know that a filter may return nothing, so the habits above remain necessary.
Before and after
The last script puts four of the pitfalls into one small function and repairs it with the idioms. The task: the average page count per genre of the books published after a given year. The older-tutorial version loops with 1:length(), grows a vector, writes na.rm = T and does not think about the case with no books; the repaired version selects with which(), returns early on an empty selection, splits by genre and uses vapply(). Both give the same numbers for a normal call (the genre order differs, so the comparison indexes the old result by the new names). For a year after the last book, the old version answers with NaN for every genre, and for a catalogue with no rows the loop 1:length(kinds) runs for 1 and for 0, so it answers with two NaN values whose names are NA; the new version returns an empty numeric vector both times.
source("scenario.R")
# Older-tutorial style: the empty case, T, a growing vector and a loop index.
average_pages_old <- function(books, after) {
genre <- tolower(trimws(books$genre))
kinds <- unique(genre)
result <- c()
for (i in 1:length(kinds)) { # nolint: seq_linter.
rows <- genre == kinds[i] & books$year > after
pages <- books$pages[rows]
average <- mean(pages, na.rm = T) # nolint: T_and_F_symbol_linter.
result <- c(result, average)
}
names(result) <- kinds
result
}
# The same task with the idioms of this page.
average_pages <- function(books, after) {
recent <- books[which(books$year > after), ]
if (nrow(recent) == 0L) {
return(numeric(0))
}
genre <- tolower(trimws(recent$genre))
vapply(
split(recent$pages, genre),
\(p) mean(p, na.rm = TRUE),
numeric(1L)
)
}
old <- average_pages_old(books, 2000L)
new <- average_pages(books, 2000L)
round(new, 1L)
#> fiction mystery non-fiction poetry science fiction
#> 222.7 272.5 263.5 88.0 366.0
all.equal(old[names(new)], new)
#> [1] TRUE
# On the empty cases the old version answers with noise, or fails.
average_pages_old(books, 2100L)
#> science fiction classic fiction mystery non-fiction
#> NaN NaN NaN NaN NaN
#> poetry
#> NaN
tryCatch(
average_pages_old(books[0, ], 2000L),
error = \(e) conditionMessage(e)
)
#> <NA> <NA>
#> NaN NaN
average_pages(books, 2100L)
#> numeric(0)
average_pages(books[0, ], 2000L)
#> numeric(0)
Source: R documentation: lapply
The old function is not wrong on the usual input, which is the reason it survives: it is wrong on the edges, and R gives no message at any point. The repaired version has a nrow(recent) == 0L guard because mean() of nothing is NaN and a table of NaN would be silently meaningful to nobody. Both functions are small, take their data as an argument and read no global variable, as the last idiom asks.
R FAQ chapter 7 at a glance
Chapter 7 of the R FAQ, "R Miscellanea", has 44 entries (the numbers are those of the live FAQ, 7.1 to 7.44). Several of them are the pitfalls of this page; the others are the questions on the pages of this reference listed in the last column. Entries that concern graphics devices of particular platforms or add-on packages are not treated in a section of their own, and the table says so instead of pointing to a page that does not cover them. The FAQ is at R FAQ; the anchors of its entries are used in the Source: lines above.
| R FAQ | Question | Where in this reference |
|---|---|---|
7.1 |
How can I set components of a list to NULL? |
NULL in lists and Lists |
7.2 |
How can I save my workspace? |
|
7.3 |
How can I clean up my workspace? |
|
7.4 |
How can I get eval() and D() to work? |
Evaluation and Non-Standard Evaluation for the environment in which the first argument is evaluated; |
7.5 |
Why do my matrices lose dimensions? |
|
7.6 |
How does autoloading work? |
the search path at startup (the |
7.7 |
How should I set options? |
|
7.8 |
How do file names work in Windows? |
|
7.9 |
Why does plotting give a color allocation error? |
Not treated: an X11-specific problem; see Base Graphics and Devices |
7.10 |
How do I convert factors to numeric? |
|
7.11 |
Are Trellis displays implemented in R? |
|
7.12 |
What are the enclosing and parent environments? |
|
7.13 |
How can I substitute into a plot label? |
|
7.14 |
What are valid names? |
|
7.15 |
Are GAMs implemented in R? |
Not treated: it names the gam and mgcv packages; see Statistical Models for the modelling functions of base R |
7.16 |
Why is the output not printed when I source() a file? |
|
7.17 |
Why does outer() behave strangely with my function? |
|
7.18 |
Why does the output from anova() depend on the order of factors in the model? |
|
7.19 |
How do I produce PNG graphics in batch mode? |
|
7.20 |
How can I get command line editing to work? |
|
7.21 |
How can I turn a string into a variable? |
Evaluation and Non-Standard Evaluation and Language Objects and Quoting |
7.22 |
Why do lattice/trellis graphics not work? |
|
7.23 |
How can I sort the rows of a data frame? |
|
7.24 |
Why does the help.start() search engine not work? |
|
7.25 |
Why did my .Rprofile stop working when I updated R? |
|
7.26 |
Where have all the methods gone? |
|
7.27 |
How can I create rotated axis labels? |
Not treated in a section; see Base Graphics and Devices for |
7.28 |
Why is read.table() so inefficient? |
|
7.29 |
What is the difference between package and library? |
|
7.30 |
I installed a package but the functions are not there |
|
7.31 |
Why doesn’t R think these numbers are equal? |
|
7.32 |
How can I capture or ignore errors in a long simulation? |
|
7.33 |
Why are powers of negative numbers wrong? |
|
7.34 |
How can I save the result of each iteration in a loop into a separate file? |
|
7.35 |
Why are p-values not displayed when using lmer()? |
|
7.36 |
Why are there unwanted borders, lines or grid-like artifacts when viewing a plot saved to a PS or PDF file? |
Not treated: a viewer problem of PostScript and PDF output; see Base Graphics and Devices |
7.37 |
Why does backslash behave strangely inside strings? |
|
7.38 |
How can I put error bars or confidence bands on my plot? |
Not treated: it names add-on packages; see Base Graphics and Devices and ggplot2 and the Grammar of Graphics |
7.39 |
How do I create a plot with two y-axes? |
|
7.40 |
How do I access the source code for a function? |
|
7.41 |
Why does summary() report strange results for the R^2 estimate when I fit a linear model with no intercept? |
|
7.42 |
Why is R apparently not releasing memory? |
|
7.43 |
How can I enable secure https downloads in R? |
|
7.44 |
How can I get CRAN package binaries for outdated versions of R? |
Not treated in a section; the |
Source: R FAQ 7: R Miscellanea
Version notes
The behaviour of the pitfalls changed over the years, and the changes explain which advice is old. The entries come from the R NEWS files of the releases (R NEWS); those before R 4.0.0 are in the R 3.x NEWS (R NEWS: Changes in R 3.x). The changes from R 4.0.0 on, with before and after code for each, are collected on What’s New and Migrating Older Code.
| Version | Change |
|---|---|
R 3.1.1 |
The warning for partial matching with |
R 3.4.0 |
Assigning to an element of a vector beyond its length over-allocates by a small fraction, so building a vector element by element that way is cheaper; pre-allocating is still preferred and the implementation is subject to change. |
R 3.5.0 |
|
R 3.6.0 |
The default method of generating a discrete uniform sample, as used by |
R 4.0.0 |
|
R 4.2.0 |
|
R 4.3.0 |
|
R 4.4.0 |
|
Where older tutorials differ
The advice below is still found in books and posts from before 2020. The right-hand columns say what to do now.
| Older tutorial writes | Current practice | Why | Since |
|---|---|---|---|
|
|
the colon counts down when |
always |
|
|
|
always |
|
nothing; |
the default is |
R 4.0.0 |
|
keep the column numeric or character; |
|
always |
|
|
the attached copy goes stale and hides or is hidden by other names; |
always; |
A |
a project or |
a path of one machine; global state |
always |
|
a fresh R process |
it removes objects only, not options, packages or attached data (R FAQ 7.3 documents what it does) |
always |
|
|
type and shape depend on the data |
always |
|
preallocate, |
each step copies the vector |
R 3.4.0 made |
|
|
the condition must be one |
R 4.2.0 (error) |
|
|
binary fractions (R FAQ 7.31) |
always |
|
|
|
always |
|
|
R FAQ 7.16 |
always |
|
the value as the last expression |
the style guide; |
always |
Source: R FAQ 7: R Miscellanea
The tidyverse style guide (https://style.tidyverse.org/) and Advanced R (https://adv-r.hadley.nz/) are community references that cover the same ground from the point of view of style and of the language; they are named here and not used as sources of the facts on this page.
Where to go next
-
What’s New and Migrating Older Code for the version-by-version list of what changed in R 4.0.0 to 4.6.x and how to update old code.
-
Defensive Programming and Input Validation for
stopifnot(),match.arg(), classed errors and the idioms of this page applied to function arguments. -
Style, Linting and Static Analysis for the style guide,
lintrandstyler, which check most of the idioms automatically. -
Conditions and Error Handling for
tryCatch()andwithCallingHandlers(), used in every demonstration above. -
Vectorisation and Iteration, Control Flow and The apply Family and Functionals for loops, vectorising and
vapply(). -
Subsetting and Indexing, Lists, Data Frames and Factors for
$,drop,NULLand factors. -
Numbers and Arithmetic and Missing and Special Values for doubles, integers and
NA. -
Sessions, Workspaces and Startup and Scripts and the Command Line for the working directory, options and running scripts.
-
Design Philosophy and Coming from Other Languages for why R behaves this way.
References
-
R FAQ 7: R Miscellanea, in particular 7.1, 7.3, 7.5, 7.7, 7.8, 7.10, 7.16 and 7.31
-
R FAQ 3.3.3: Others (the differences between R and S that mention
TandF,attach()and partial matching) -
An Introduction to R: Logical vectors; Vector arithmetic; attach() and detach(); Conditional execution: if statements
-
R NEWS: Changes in R 4.0.0 to 4.6.1; R 3.x NEWS