Control Flow
|
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. This page explains how R chooses
between alternatives and how it repeats work: if and else (which are expressions that return a value), the vectorised
ifelse(), switch(), and the loops for, while and repeat with break and next. It also covers the habits that
go with them: seq_along() and seq_len() instead of 1:length(x), and preallocating the result of a loop. Every example
uses only base R, so no add-on package version needs to be stated; dplyr 1.2.1 is named in prose only, where its
if_else() and case_when() are the usual tidyverse alternatives to ifelse().
Every example below is a complete script that was run with Rscript on R 4.6.1 with warnings printed as they happen
(options(warn = 1)), and the lines starting with #> are what it printed. The scripts also pass the default lintr
linters; where an idiom being shown is exactly what a default linter dislikes (1:length(x), NA_character_ == "a"), the
line carries a # nolint: comment so that the linter does not hide the lesson. Errors and warnings that the page wants to
show are caught with tryCatch() and printed with conditionMessage(), which gives the message without the leading
Error in if (...) : that the console adds, and so that the script still ends normally. The names books, loans,
shelf_today, loan_days and the function is_valid_isbn13() come from the scenario.R file of the running
library-shelf scenario described on the section index; the scripts that use them
start with source("scenario.R"), which reads books.csv and loans.csv with read.csv(). The first page,
Getting Started, shows how to install R and run a script.
Control flow is made of expressions
R is an expression language. The introduction manual says that its only command type is a function or expression that
returns a result, that even an assignment is an expression, and that commands grouped in braces { ... } form a group
whose value is the result of the last expression evaluated in it
(An Introduction to R: Grouping, loops and conditional execution).
The language definition adds that computation consists of evaluating statements one after another, that a semicolon always
ends a statement while a newline may end one, and that a group of statements in braces is a block
(The R Language Definition: Control structures).
Two consequences shape the rest of this page.
-
if,for,while,repeat,break,nextandfunctionare reserved words, yet they behave like functions:ifreturns the value of the branch it took, a loop returnsNULL, and callingifas a function, as the last lines of the first script do, works. The help page forControlsays that these are the basic control-flow constructs of the language and that they work much like the control statements of any Algol-like language (R documentation: Control). -
The decisions R makes in a program are mostly vectorised rather than written as loops. A condition that is a single
TRUEorFALSEselects what to run (if); a condition with one answer per element selects values (ifelse(), logical subsetting); an explicit loop is the tool for work that really is sequential.
The rest of this page follows the diagram: the conditional constructs first, then the loops. The apply family in the last branch is covered in The apply Family and Functionals and the vectorised branch in Vectorisation and Iteration.
if and else
The language definition gives the formal syntax of the conditional statement as if (statement1) statement2 else
statement3: the condition is evaluated and, if it is TRUE, the first statement is evaluated, otherwise the second
(The R Language Definition: if). The else part is
optional, if statements can be nested through else if without any limit, and the statement returns, as its value, the
value of the branch that ran. The introduction manual states the requirement on the condition: it must evaluate to a
single logical value
(An Introduction to R: Conditional execution).
The section "The condition: what if accepts" below shows what that means in R 4.6.
if and else as an expression
Because if is an expression it can sit on the right-hand side of an assignment, as an argument, or as the last
expression of a function, which is how most R functions return one of several values. When no branch runs (the condition
is FALSE and there is no else) the value is NULL, and it is returned invisibly, so typing such an if at the console
prints nothing. A block in braces is an expression too, and its value is that of its last expression.
# if/else is an expression: it has a value, so it can be assigned or returned.
pages <- 312L
size <- if (pages > 300L) "long" else "short"
size
#> [1] "long"
sizes <- c(short = "under 100 pages", long = "300 pages or more")
sizes[[size]]
#> [1] "300 pages or more"
# The value of the branch that ran, or an invisible NULL when no branch ran.
res <- if (pages > 1000L) "huge"
is.null(res)
#> [1] TRUE
if (pages > 1000L) "huge"
withVisible(if (pages > 1000L) "huge")
#> $value
#> NULL
#>
#> $visible
#> [1] FALSE
#>
# A chain of else if: the conditions are tested in order, first TRUE wins.
classify_length <- function(pages) {
if (is.na(pages)) {
"unknown"
} else if (pages < 100L) {
"short"
} else if (pages < 300L) {
"medium"
} else {
"long"
}
}
vapply(c(80L, 250L, 312L, NA), classify_length, character(1L))
#> [1] "short" "medium" "long" "unknown"
# The value of a block is the value of its last expression.
label <- if (pages > 300L) {
n_words <- pages * 250L
paste("about", n_words, "words")
} else {
"fewer than 75000 words"
}
label
#> [1] "about 78000 words"
# if is a function: the call form shows the three arguments.
`if`(pages > 300L, "long", "short")
#> [1] "long"
Source: R documentation: Control
A few remarks on the script. In the else if chain only the first condition that is TRUE selects a branch, so the
order of the conditions matters, and the NA test goes first because every later comparison with NA would be NA. The
function-call form at the end of the script evaluates the same way as the keyword form, which is why code that rewrites
expressions (see Language Objects and Quoting) sees if
as a call with three arguments. The help page recommends, as a form of defensive programming, to always use braces around
the branches
(R documentation: Control). Without braces
the branch is a single expression, which is fine for short one-line forms such as if (cond) "a" else "b".
The else rule at the top level
The one layout rule that surprises people from other languages is that else must be on the same line as the end of the
if branch unless the whole if is inside braces. The language definition spells it out: when the if statement is not
in a block, the else, if present, must appear on the same line as the end of the first branch, because otherwise the
newline completes the if and yields a syntactically complete statement that is evaluated. The suggested solution is a
compound statement with the else on the same line as the closing brace
(The R Language Definition: if). The parser-level
explanation is in the The else rule section of
Syntax and Lexical Structure; the script below shows the
real parse errors.
# The same text, parsed at the top level and inside braces.
show_parse <- function(...) {
src <- paste(c(...), collapse = "\n")
msg <- tryCatch(
{
parse(text = src, keep.source = FALSE)
"parses"
},
error = conditionMessage
)
cat(msg, "\n")
}
# At the top level the first line is already a complete statement.
show_parse("if (TRUE) 1", "else 2")
#> <text>:2:1: unexpected 'else'
#> 1: if (TRUE) 1
#> 2: else
#> ^
show_parse("if (TRUE) 1 else 2")
#> parses
show_parse("if (TRUE) {", " 1", "} else {", " 2", "}")
#> parses
show_parse("if (TRUE) {", " 1", "}", "else {", " 2", "}")
#> <text>:4:1: unexpected 'else'
#> 3: }
#> 4: else
#> ^
# Inside braces the newline before else is discarded.
show_parse("{", " if (TRUE) 1", " else 2", "}")
#> parses
# A function body is a braced block, so the same layout is accepted there.
f <- function(x) {
if (x > 0)
"positive"
else
"not positive"
}
f(1)
#> [1] "positive"
f(-1)
#> [1] "not positive"
# At the top level too, a brace that is still open keeps the expression going.
y <- {
if (FALSE) "a"
else "b"
}
y
#> [1] "b"
Source: The R Language Definition: if
The message is unexpected 'else', with the offending line and a caret that points at the start of else. The same
text is accepted in three places: on one line, with } else { on the closing-brace line, and anywhere inside { ... },
including the body of a function, because inside braces the parser is in the middle of a compound expression and the
newline cannot end the statement. That is why indented if/else layouts that fail when pasted at the console work in a
function. It is also why the rule only bites at the top level of a script or the console, which is exactly where
beginners write their first if. The robust style is the braced form with } else { on one line.
A script file adds a second wrinkle. Rscript reads the file and evaluates one expression after another, so the lines
before the broken if run and print before the error. source() parses the whole file first, so nothing in it runs and
the error carries the file name and line number.
# The same layout in a script file: Rscript evaluates the file expression by
# expression, source() parses the whole file first.
script <- tempfile(fileext = ".R")
writeLines(c(
'cat("before\\n")',
"if (TRUE) 1",
"else 2",
'cat("after\\n")'
), script)
rscript <- file.path(R.home("bin"), "Rscript")
out <- suppressWarnings(system2(rscript, script, stdout = TRUE, stderr = TRUE))
cat(sub(script, "script.R", out, fixed = TRUE), sep = "\n")
#> before
#> [1] 1
#> Error: unexpected 'else' in "else"
#> Execution halted
res <- tryCatch(source(script), error = conditionMessage)
cat(sub(script, "script.R", res, fixed = TRUE), sep = "\n")
#> script.R:3:1: unexpected 'else'
#> 2: if (TRUE) 1
#> 3: else
#> ^
# With the else on the closing-brace line the file runs from start to end.
writeLines(c(
'cat("before\\n")',
"if (TRUE) {",
" cat('then\\n')",
"} else {",
" cat('else\\n')",
"}",
'cat("after\\n")'
), script)
cat(system2(rscript, script, stdout = TRUE, stderr = TRUE), sep = "\n")
#> before
#> then
#> after
unlink(script)
Source: R documentation: Control
The help page for Control gives the same advice: do not put a newline between } and else when typing at the
keyboard or sourcing, and consider always using braces
(R documentation: Control).
The condition: what if accepts
The help page for Control describes the condition as a length-one logical vector that is not NA, and says that other
types are coerced to logical if possible, ignoring any class, and that conditions of length greater than one are an error
(R documentation: Control). The script shows each way a
condition can go wrong, with the real message. The messages belong to if and while alike, so each case is shown for
both where it matters.
# What a condition may be, and what happens when it is not.
try_if <- function(expr) {
tryCatch(
{
expr
invisible("no error")
},
error = function(e) cat("error:", conditionMessage(e), "\n"),
warning = function(w) cat("warning:", conditionMessage(w), "\n")
)
}
# A condition of length greater than one is an error since R 4.2.0.
x <- c(TRUE, FALSE)
try_if(if (x) "yes")
#> error: the condition has length > 1
try_if(while (x) break)
#> error: the condition has length > 1
# NA is neither TRUE nor FALSE.
try_if(if (NA) "yes")
#> error: missing value where TRUE/FALSE needed
try_if(if (NA_integer_ > 1L) "yes")
#> error: missing value where TRUE/FALSE needed
try_if(if (NA_character_ == "a") "yes") # nolint: equals_na_linter.
#> error: missing value where TRUE/FALSE needed
# Length zero.
try_if(if (logical(0L)) "yes")
#> error: argument is of length zero
try_if(if (NULL) "yes")
#> error: argument is of length zero
try_if(if (character(0L) == "a") "yes")
#> error: argument is of length zero
# A string that is not a logical value.
try_if(if ("yes") "ok")
#> error: argument is not interpretable as logical
if ("TRUE") "string TRUE is accepted"
#> [1] "string TRUE is accepted"
if ("T") "string T is accepted"
#> [1] "string T is accepted"
if ("true") "lower-case true is accepted"
#> [1] "lower-case true is accepted"
# Numbers are coerced: zero is FALSE and every other non-NA number is TRUE.
if (0) "a" else "b"
#> [1] "b"
if (2.5) "a" else "b"
#> [1] "a"
if (-1L) "a" else "b"
#> [1] "a"
try_if(if (NaN) "a")
#> error: argument is not interpretable as logical
# Lists and functions are not coerced.
try_if(if (list(TRUE)) "a")
#> error: argument is not interpretable as logical
try_if(if (sum) "a")
#> error: argument is not interpretable as logical
Source: R documentation: Control
The table summarises the script.
| Condition | Result | Message or rule |
|---|---|---|
|
the branch runs |
the normal case |
a number |
|
|
a string such as |
coerced to the logical value |
a string that is not a logical spelling is an error |
|
error since R 4.2.0 |
|
|
error |
|
|
error |
|
|
error |
|
Source: R documentation: Control
The length-zero case deserves attention because it comes from code that looks safe. A comparison such as
character(0) == "a" has length zero, so if fails with argument is of length zero rather than treating the
result as FALSE. In real code the usual source is a lookup that finds nothing (x$missing_field == "a", or
which(...) returning integer(0)). The NA case is the same problem from missing data: the shelf books
table has a missing year and two missing pages, and if (books$pages[[6]] > 300) stops with
missing value where TRUE/FALSE needed.
The language definition has an older description in which only the first element of a longer condition is used and an error is raised for a type that is not logical or numeric. Neither statement matches R 4.6.1: a longer condition is an error, and a string spelled like a logical value is accepted. The help page quoted above is the current rule.
Conditions of length greater than one
Until R 4.1.x, if (c(TRUE, FALSE)) gave only a warning and used the first element. The NEWS for R 4.2.0 records the
change: calling if() or while() with a condition of length greater than one now gives an error rather than a warning,
and as a consequence the environment variable _R_CHECK_LENGTH_1_CONDITION_ no longer has any effect
(R NEWS: Changes in R 4.2.0). The R Internals manual lists that variable among the environment variables of the checking tools and now
describes it as no longer in use, because conditions of length greater than one in if or while statements are an error
(R Internals: Tools). Old code that relied on the
first-element behaviour now fails, usually with the first call of a function that was passed a vector; the fix is to say
what was meant (any(), all(), isTRUE(), an index like x[[1]], or ifelse() when a value per element is wanted).
The same release made && and || with an operand of length greater than one give a warning, and R 4.3.0 turned it into
an error: the NEWS for 4.3.0 says that it is now always an error, reported in the form 'length = 4' in coercion to 'logical(1)',
and that the environment variable _R_CHECK_LENGTH_1_LOGIC2_ no longer has any effect
(R NEWS: Changes in R 4.3.0).
Turning a vector or an NA into one condition
When the data really can be a vector or can contain NA, reduce it to one definite answer before the if. The helpers
are any() and all() (with na.rm), isTRUE() and isFALSE() (which are never NA and never longer than one), the
short-circuit operators && and ||, and an explicit length() guard. stopifnot() is the right tool when the
condition is a requirement rather than a branch. The help pages for the logical operators explain that & and | work
element by element and && and || take length-one operands and evaluate their right-hand side only if necessary
(R documentation: Logic).
# The usual ways to turn a vector or an NA into a single TRUE or FALSE.
x <- c(3, NA, 8)
any(x > 5)
#> [1] TRUE
any(x > 5, na.rm = TRUE)
#> [1] TRUE
all(x > 5)
#> [1] FALSE
all(x > 5, na.rm = TRUE)
#> [1] FALSE
isTRUE(NA)
#> [1] FALSE
isTRUE(c(TRUE, TRUE))
#> [1] FALSE
isFALSE(FALSE)
#> [1] TRUE
# isTRUE() and isFALSE() are never NA and never longer than one.
is_big <- function(v) {
if (isTRUE(v > 5)) "big" else "not clearly big"
}
is_big(8)
#> [1] "big"
is_big(NA)
#> [1] "not clearly big"
is_big(c(8, 9))
#> [1] "not clearly big"
# && and || take length-one operands; longer ones are an error since R 4.3.0.
r <- tryCatch(c(TRUE, FALSE) && TRUE, error = conditionMessage)
r
#> [1] "'length = 2' in coercion to 'logical(1)'"
r <- tryCatch(c(TRUE, FALSE) || TRUE, error = conditionMessage)
r
#> [1] "'length = 2' in coercion to 'logical(1)'"
# Short-circuit: the right-hand side is not evaluated when the left decides.
FALSE && stop("never evaluated")
#> [1] FALSE
TRUE || stop("never evaluated")
#> [1] TRUE
NA && FALSE
#> [1] FALSE
NA || TRUE
#> [1] TRUE
NA && TRUE
#> [1] NA
# The vector operators are element-wise and never short-circuit.
c(TRUE, FALSE, NA) & c(TRUE, TRUE, TRUE)
#> [1] TRUE FALSE NA
c(TRUE, FALSE, NA) | c(FALSE, FALSE, FALSE)
#> [1] TRUE FALSE NA
# Guard the length first so that the condition always has length one.
check <- function(v) {
if (length(v) == 1L && !is.na(v) && v > 5) "scalar above 5" else "no"
}
check(8)
#> [1] "scalar above 5"
check(NA)
#> [1] "no"
check(numeric(0L))
#> [1] "no"
check(c(8, 9))
#> [1] "no"
# stopifnot() states a requirement and stops with a readable message.
res <- tryCatch(stopifnot(length(x) == 1L), error = conditionMessage)
res
#> [1] "length(x) == 1L is not TRUE"
res <- tryCatch(
stopifnot("x must have length 1" = length(x) == 1L),
error = conditionMessage
)
res
#> [1] "x must have length 1"
Source: R documentation: Logic
Two details of the script are worth remembering. NA && FALSE is FALSE and NA || TRUE is TRUE: the result is NA
only when the answer depends on the missing value, which makes && and || safer than & and | as the guard of
a condition. And stopifnot() can carry a message in the name of the expression
(R documentation: stopifnot); the
Defensive Programming and Input Validation
page uses it as the standard way to validate arguments.
The %||% operator, added to base R in 4.4.0, is a one-line shorthand for the common branch
if (is.null(x)) y else x; it is defined in the Control help page
(R documentation: Control) and listed in the NEWS for
R 4.4.0 (R NEWS: Changes in R 4.4.0).
ifelse(): the vectorised conditional
ifelse(test, yes, no) is the vectorised form of if/else. The introduction manual describes it as a vectorised
version of the if/else construct that returns a vector of the same length as the condition, with an element of yes
where the condition is true and of no where it is false, both recycled as necessary
(An Introduction to R: Conditional execution).
The current help page puts it more precisely: ifelse returns a value with the same shape as test, filled with
elements selected from yes or no
(R documentation: ifelse). The details follow from that
sentence, and the script shows each of them.
-
Missing values in
testgive missing values in the result. -
yesandnoare recycled when they are too short, andyesis evaluated if and only if some element oftestis true (and likewise forno). -
The result has the length and the attributes of
test, includingnames,dim,dimnamesandclass, and it never takes them fromyesorno. -
The type of the result is the type of the logical vector
testcoerced to accommodate first the values taken fromyesand then those taken fromno, so it depends on the values intest, not only on the types of the branches. -
testalone decides the length of the result, so a length-onetestgives a length-one result however longyesandnoare.
# ifelse() is the vectorised if/else: the result has the shape of `test`.
x <- c(6, -1, 0, NA, 3)
ifelse(x > 0, "positive", "not positive")
#> [1] "positive" "not positive" "not positive" NA "positive"
# A missing value in test gives a missing value in the result.
ifelse(c(TRUE, NA, FALSE), 1L, 2L)
#> [1] 1 NA 2
# yes and no are recycled when they are too short.
ifelse(c(TRUE, FALSE, TRUE, FALSE), c("a", "b"), "z")
#> [1] "a" "z" "a" "z"
# The type of the result depends on which branches were used.
str(ifelse(c(TRUE, TRUE), 1L, "none"))
#> int [1:2] 1 1
str(ifelse(c(FALSE, FALSE), 1L, "none"))
#> chr [1:2] "none" "none"
str(ifelse(c(TRUE, FALSE), 1L, "none"))
#> chr [1:2] "1" "none"
str(ifelse(c(TRUE, TRUE), 1L, 2.5))
#> int [1:2] 1 1
str(ifelse(c(FALSE, FALSE), 1L, 2.5))
#> num [1:2] 2.5 2.5
# test alone determines the length: yes and no are not used for their length.
yes <- 1:3
no <- pi^(1:4)
str(ifelse(NA, yes, no))
#> logi NA
str(ifelse(TRUE, yes, no))
#> int 1
str(ifelse(FALSE, yes, no))
#> num 3.14
# Attributes come from test (names, dim, dimnames, class), not from yes or no.
named <- c(a = 1, b = -2, c = 3)
ifelse(named > 0, "up", "down")
#> a b c
#> "up" "down" "up"
m <- matrix(c(1, -1, 2, -2), nrow = 2L)
ifelse(m > 0, m, 0)
#> [,1] [,2]
#> [1,] 1 2
#> [2,] 0 0
f <- factor(c("x", "y", "x"))
ifelse(f == "x", 1, 2)
#> [1] 1 2 1
ifelse(f == "x", f, NA)
#> [1] 1 NA 1
# A named test gives a named result.
test <- c(first = TRUE, second = FALSE)
ifelse(test, "yes", "no")
#> first second
#> "yes" "no"
# yes is only evaluated if some element of test is TRUE.
ifelse(FALSE, stop("not evaluated"), "no")
#> [1] "no"
r <- tryCatch(
ifelse(c(TRUE, FALSE), stop("evaluated"), "no"),
error = conditionMessage
)
r
#> [1] "evaluated"
# ifelse() on dates drops the class: the pitfall from its help page.
d <- as.Date(c("2026-06-01", "2026-06-15", "2026-06-30"))
ifelse(d > as.Date("2026-06-10"), d, NA)
#> [1] NA 20619 20634
ifelse(d > as.Date("2026-06-10"), d, NA) |> as.Date()
#> [1] NA "2026-06-15" "2026-06-30"
d2 <- d
d2[d <= as.Date("2026-06-10")] <- NA
d2
#> [1] NA "2026-06-15" "2026-06-30"
Source: R documentation: ifelse
Three results in the script are traps that the help page warns about.
-
The type of the result depends on the data.
ifelse(c(TRUE, TRUE), 1L, "none")is an integer vector, but changing one value oftestgives a character vector, because only then is a string taken fromno. A function that returnsifelse()can therefore return different types on different inputs. -
The attributes come from
test. A factor passed asyesloses its levels and comes back as integer codes (ifelse(f == "x", f, NA)), and aDatepassed asyescomes back as a number of days. -
The
classoftestis kept. With atestof class factor orDatethe result would carry that class even if the values do not suit it;testis almost always a plain logical vector, so this rarely shows.
The help page works the date case through: restoring the class afterwards is one repair, and an assignment on a copy
(the last lines of the script) is the version it calls more efficient and still clear. It also notes that
if (test) yes else no is much more efficient than ifelse(test, yes, no) whenever test is a simple true/false result,
that is, when length(test) == 1. In that case ifelse() is simply the wrong tool, and if also evaluates only the branch
it uses.
ifelse() on the shelf: classifying overdue loans
A loan is overdue when it was kept more than loan_days (21) days; a loan that is still out is measured up to
shelf_today. The loan log has two rows that cannot be classified: one loan has the impossible date 2025-02-30, which
as.Date() turns into NA, and one was returned three days before it was made. Nested ifelse() calls express the three
outcomes; assigning in order on a copy gives the same answer. The first step of the script uses ifelse() on the return
date, so it meets the date pitfall itself and repairs it with as.Date(), which since R 4.3.0 no longer needs an origin
for a numeric argument
(R NEWS: Changes in R 4.3.0).
source("scenario.R")
# A loan is overdue when it was kept more than loan_days days. A loan that is
# still out is measured against shelf_today.
end_date <- ifelse(is.na(loans$return_date), shelf_today, loans$return_date)
end_date <- as.Date(end_date)
days_out <- as.integer(end_date - loans$loan_date)
# Some rows cannot be classified: the impossible loan date is NA, and one loan
# was returned before it was made.
status <- ifelse(
is.na(days_out) | days_out < 0L, "check",
ifelse(days_out > loan_days, "overdue", "on time")
)
table(status, useNA = "ifany")
#> status
#> check on time overdue
#> 2 26 32
# The same classification with one expression per case, assigned in order
# (the last assignment that matches wins, so the rare case goes last).
status2 <- rep("on time", nrow(loans))
status2[days_out > loan_days] <- "overdue"
status2[is.na(days_out) | days_out < 0L] <- "check"
identical(status, status2)
#> [1] TRUE
# Which loans need attention?
loans[status == "check", c("loan_id", "loan_date", "return_date")]
#> loan_id loan_date return_date
#> 12 L012 2025-04-23 2025-04-20
#> 20 L020 <NA> 2025-07-15
# An NA in days_out stays NA when there is no is.na() branch.
head(ifelse(days_out > loan_days, "overdue", "on time"), 12L)
#> [1] "on time" "on time" "on time" "overdue" "overdue" "on time" "overdue"
#> [8] "on time" "overdue" "on time" "overdue" "on time"
sum(is.na(ifelse(days_out > loan_days, "overdue", "on time")))
#> [1] 1
Source: R documentation: ifelse
Of the 60 loans, 26 were on time, 32 were overdue (25 returned late and the 7 still out are all past 21 days at
shelf_today) and two need a look. The NA loan date stays NA through the plain ifelse(), which is the behaviour the
help page documents; deciding what an unclassifiable loan means is a job for the code, and the script gives it its own
"check" level instead of letting NA flow through.
Alternatives to nested ifelse()
Deeply nested ifelse() is hard to read and evaluates every branch for the whole vector. R has simpler tools for the common
shapes.
# Alternatives to ifelse(): replace(), subsetting, cut() and findInterval().
x <- c(6, -1, 0, NA, 3)
replace(x, x < 0, 0)
#> [1] 6 0 0 NA 3
y <- x
y[!is.na(y) & y < 0] <- 0
y
#> [1] 6 0 0 NA 3
# A single TRUE/FALSE test: use if/else, which evaluates one branch only.
describe <- function(n) {
if (n == 1L) "one book" else paste(n, "books")
}
describe(1L)
#> [1] "one book"
describe(4L)
#> [1] "4 books"
# Several levels: cut() and findInterval() instead of nested ifelse().
pages <- c(80L, 250L, 312L, 701L)
ifelse(pages < 100L, "short", ifelse(pages < 300L, "medium", "long"))
#> [1] "short" "medium" "long" "long"
cut(pages, breaks = c(0, 99, 299, Inf), labels = c("short", "medium", "long"))
#> [1] short medium long long
#> Levels: short medium long
findInterval(pages, c(0, 100, 300))
#> [1] 1 2 3 3
# %in% for a membership test.
genres <- c("Fiction", "Mystery", "Poetry", "Classic")
ifelse(genres %in% c("Fiction", "Classic"), "novel shelf", "other shelf")
#> [1] "novel shelf" "other shelf" "other shelf" "novel shelf"
Source: R documentation: cut
replace() and logical subsetting change selected elements in place
(R documentation: replace); cut() turns a numeric vector into
a factor of intervals, and findInterval() returns the interval number
(R documentation: findInterval). In the tidyverse,
dplyr::if_else() is a stricter ifelse() that checks that yes and no have compatible types (so the type no longer
depends on the data), keeps the Date class, and has a missing argument for NA; dplyr::case_when() replaces a chain of
nested ifelse() calls. if_else() was checked against dplyr 1.2.1, and both are covered in
The tidyverse and dplyr; this page sticks to base R.
switch(): choosing among many cases
switch(EXPR, ...) evaluates EXPR and chooses one of the alternatives in .... The language definition notes that technically it is just another function, whose semantics are close to those of the control structures of other languages
(The R Language Definition: switch), and the help page
says that it is a primitive that evaluates only its first argument and the one alternative that is selected
(R documentation: switch). It works in two distinct ways,
depending on whether EXPR is a character string or a number.
-
Character selector. The string is matched exactly against the names of the alternatives, with no partial matching. The first matching name wins. If there is no match, the single unnamed alternative, if any, is the default; with no default the result is
NULL, invisibly. -
Fall-through. An alternative written with no value (
novel = ,) is missing, soswitch()evaluates the next non-missing alternative. The help page’s own example isswitch("cc", a = 1, cc =, cd =, d = 2), which gives2. -
Numeric selector. The number is coerced to an integer and picks the alternative by position (names are ignored). A number below 1 or above the number of alternatives selects nothing and the result is
NULL, invisibly. A numeric selector cannot have a default. -
Visibility. The result has the visibility of the alternative that was evaluated, so
switch("a", a = invisible("hidden"))prints nothing. -
Errors. More than one unnamed (default) alternative is an error, as is an
EXPRthat is not of length one. A call with no alternatives gives a warning, because it is usually a coding error.
source("scenario.R")
# switch() with a character selector: exact match on the names.
shelf_for <- function(genre) {
switch(genre,
Fiction = "A",
Mystery = "B",
Poetry = "C",
"unshelved"
)
}
shelf_for("Mystery")
#> [1] "B"
shelf_for("Cookery")
#> [1] "unshelved"
# Fall-through: an empty alternative takes the next non-empty one.
loan_period <- function(kind) {
switch(kind,
novel = ,
classic = ,
poetry = 21L,
reference = 0L,
stop("unknown kind: ", kind)
)
}
loan_period("novel")
#> [1] 21
loan_period("classic")
#> [1] 21
loan_period("reference")
#> [1] 0
res <- tryCatch(loan_period("map"), error = conditionMessage)
res
#> [1] "unknown kind: map"
# No match and no default: NULL, invisibly.
r <- switch("z", a = 1, b = 2)
is.null(r)
#> [1] TRUE
switch("z", a = 1, b = 2)
withVisible(switch("z", a = 1, b = 2))$visible
#> [1] FALSE
# The result has the visibility of the evaluated alternative.
switch("a", a = invisible("hidden"), b = "shown")
switch("b", a = invisible("hidden"), b = "shown")
#> [1] "shown"
# More than one default (unnamed alternative) is an error.
res <- tryCatch(switch("z", a = 1, 2, 3), error = conditionMessage)
res
#> [1] "duplicate 'switch' defaults: '2' and '3'"
# A numeric selector picks the alternative by position and ignores names.
switch(2L, "first", "second", "third")
#> [1] "second"
switch(3, "first", "second", "third")
#> [1] "third"
switch(2.9, "first", "second", "third")
#> [1] "second"
switch(4L, "first", "second", "third")
is.null(switch(0L, "first", "second", "third"))
#> [1] TRUE
is.null(switch(-1L, "first", "second", "third"))
#> [1] TRUE
for (i in c(-1:3, 9)) {
print(switch(i, 1, 2, 3, 4))
}
#> NULL
#> NULL
#> [1] 1
#> [1] 2
#> [1] 3
#> NULL
# A numeric selector has no default: an unnamed last alternative is simply the
# alternative at the last position.
switch(4, "first", "second", "third", "default?")
#> [1] "default?"
# A shelf example: the genre labels of the catalogue need trimming first, and
# the rare ones fall to the default.
genre_shelf <- vapply(
trimws(books$genre),
\(g) switch(g, Fiction = , fiction = "A", Mystery = "B", Poetry = "C", "Z"),
character(1L)
)
table(genre_shelf)
#> genre_shelf
#> A B C Z
#> 4 2 2 8
Source: R documentation: switch
The last block of the script puts switch() where it is most useful, the mapping of a small set of labels to values. The
genres in books.csv are untidy ("Fiction " with a trailing blank, "fiction" and "Fiction"), so the labels are
trimmed first and two spellings fall through to the same shelf, and anything else goes to the default. A named lookup
vector (c(Fiction = "A", Mystery = "B")[[genre]]) is often even better for data that is really a table, because it can be
extended without changing code and works on a whole vector with [; use switch() when the alternatives are code to
run or when fall-through expresses the rule.
Edge cases and warnings
The corner cases are worth knowing because each one has a real message.
# Corner cases of switch().
try_sw <- function(expr) {
tryCatch(expr, error = function(e) cat("error:", conditionMessage(e), "\n"),
warning = function(w) cat("warning:", conditionMessage(w), "\n"))
}
# EXPR must be a length-one vector.
try_sw(switch(c("a", "b"), a = 1, b = 2))
#> error: EXPR must be a length 1 vector
try_sw(switch(character(0L), a = 1, b = 2))
#> error: EXPR must be a length 1 vector
try_sw(switch(NULL, a = 1))
#> error: EXPR must be a length 1 vector
# NA as a selector.
is.null(switch(NA_character_, a = 1, 2))
#> [1] FALSE
switch(NA_character_, a = 1, "default")
#> [1] "default"
switch("NA", "NA" = "the string NA", "default")
#> [1] "the string NA"
is.null(switch(NA_integer_, 1, 2))
#> [1] TRUE
# No alternatives: a warning.
try_sw(switch("a"))
#> warning: 'switch' with no alternatives
# A factor is coerced to its integer codes, with a warning.
ff <- factor(c("B", "A"), levels = c("B", "A"))
try_sw(switch(ff[1L], A = "I am A", B = "I am B"))
#> warning: EXPR is a "factor", treated as integer.
#> Consider using 'switch(as.character( * ), ...)' instead.
suppressWarnings(switch(ff[1L], A = "I am A", B = "I am B"))
#> [1] "I am A"
switch(as.character(ff[1L]), A = "I am A", B = "I am B")
#> [1] "I am B"
# Partial matching of the names does not happen: the match is exact.
switch("pro", program = "p", "no match")
#> [1] "no match"
switch("program", program = "p", "no match")
#> [1] "p"
# Only the selected alternative is evaluated.
switch("a", a = "fine", b = stop("never evaluated"))
#> [1] "fine"
# A name written with backticks or quotes can hold any string.
switch("long name", "long name" = 1, `other name` = 2)
#> [1] 1
# If the selector is named it must match EXPR; after it the rest is `...`.
switch(EXPR = "b", a = 1, b = 2)
#> [1] 2
# switch() inside a function: the selector is usually an argument checked with
# match.arg(), which turns a typo into an error at the top of the function.
centre <- function(x, type = c("mean", "median")) {
type <- match.arg(type)
switch(type,
mean = mean(x),
median = median(x)
)
}
centre(c(1, 2, 10))
#> [1] 4.333333
centre(c(1, 2, 10), "median")
#> [1] 2
res <- tryCatch(centre(c(1, 2, 10), "mode"), error = conditionMessage)
res
#> [1] "'arg' should be one of “mean”, “median”"
Source: R documentation: switch
-
EXPR must be a length 1 vectorcovers a vector,character(0)andNULL;switch()is not vectorised, so usevapply()orifelse()for a vector of selectors. -
A
factorselector is treated as its integer codes, with a warning whose text recommendsswitch(as.character(*), ...). This is the classic bug: the first level is level 1, soswitch(ff[1], A = ..., B = ...)picks the alternative by position, not by name. Convert withas.character()first, as the last line of that block does. -
The selector and the names are compared exactly.
switch("pro", program = "p", "no match")does not match, in contrast with argument matching of function calls (see Functions). -
An
NA_character_selector matches no name and so takes the default; anNA_integer_selector selects nothing. Neither is an error, so a missing value is silently treated as "other" unless you test for it first. -
Always put
EXPRfirst and name it when partial matching is possible, and give the character form a single unnamed default after the named values: the help page recommends both for clarity and compatibility. -
match.arg()is the standard companion when the selector is an argument of a function: it checks the value against the allowed choices at the top of the function and gives a clear error (R documentation: match.arg). Its message uses curly quotes in a UTF-8 locale, as the last line of the script shows.
Loops
R has three statements for explicit looping, for, while and repeat, and two statements that control them, break
and next; the language definition points out that R also provides implicit looping through tapply, apply and lapply,
and that many operations, especially arithmetic ones, are vectorised, so a loop may not be needed
(The R Language Definition: Looping). The value of a
loop statement is always NULL, returned invisibly. All three loops take a single expression as the body, which in
practice is a braced block.
for
The syntax is for (name in vector) statement, where the vector can also be a list
(The R Language Definition: for). For each element the
variable is set to that element and the body is evaluated. The help page for Control adds the precise rules, which the
script confirms.
-
The sequence is evaluated once, at the start of the loop. Changing it afterwards does not affect the loop. If it has length zero the body is skipped.
-
The variable is assigned in turn the value of each element. Assigning to it inside the body does not affect the next iteration.
-
When the loop terminates the variable remains as a variable containing its latest value. After a loop over a sequence of length zero it is
NULL. The variable lives in the environment where the loop runs, not in a new scope: this is difference 3.3.3 between R and S in the R FAQ, where the index of aforloop is local to the loop (R FAQ 3.3.3: Others). -
The sequence is implicitly unclassed. For classed objects, the help page suggests
as.list()to prepare a list to iterate over, orseq_along()to get an index vector and then extract the elements explicitly. A factor is coerced to a character vector, and the sequence can be a long vector (supported inforsince R 4.0.0, see the NEWS entry in R NEWS: Changes in R 4.0.0).
source("scenario.R")
# for over a vector: the loop variable takes each element in turn.
for (n in c(2L, 5L, 10L)) {
cat(n, "squared is", n^2, "\n")
}
#> 2 squared is 4
#> 5 squared is 25
#> 10 squared is 100
# for over a list: each element, whatever its type.
shelf <- list(title = "The Quiet Streets", pages = 312L, in_stock = TRUE)
for (item in shelf) {
print(class(item))
}
#> [1] "character"
#> [1] "integer"
#> [1] "logical"
# for over names, then index the object by name.
for (nm in names(shelf)) {
cat(nm, "->", format(shelf[[nm]]), "\n")
}
#> title -> The Quiet Streets
#> pages -> 312
#> in_stock -> TRUE
# for over a position vector made by seq_along(): needed when the index matters.
titles <- c("The Quiet Streets", "Rivers of Glass", "Orchard Logic")
for (i in seq_along(titles)) {
cat(i, ":", titles[[i]], "\n")
}
#> 1 : The Quiet Streets
#> 2 : Rivers of Glass
#> 3 : Orchard Logic
# for over the first rows of a data frame.
for (i in seq_len(3L)) {
cat(books$title[[i]], "-", books$pages[[i]], "pages\n")
}
#> The Dispossessed - 387 pages
#> Pride and Prejudice - 432 pages
#> the quiet streets - 214 pages
# Zero iterations are fine.
for (i in seq_len(0L)) {
print("never printed")
}
for (x in NULL) {
print("never printed")
}
for (x in character(0L)) {
print("never printed")
}
# for evaluates its sequence once: changing the vector does not change the loop.
v <- 1:3
for (x in v) {
v <- c(v, x * 10L)
cat("x =", x, "length(v) =", length(v), "\n")
}
#> x = 1 length(v) = 4
#> x = 2 length(v) = 5
#> x = 3 length(v) = 6
v
#> [1] 1 2 3 10 20 30
# Assigning to the loop variable does not affect the next iteration.
for (i in 1:3) {
cat("i before:", i)
i <- i * 100L
cat(", i after:", i, "\n")
}
#> i before: 1, i after: 100
#> i before: 2, i after: 200
#> i before: 3, i after: 300
# After the loop the variable keeps the last value it had.
for (k in c("a", "b", "c")) {
last_seen <- k
}
k
#> [1] "c"
last_seen
#> [1] "c"
# After an empty loop the variable is NULL (the help page: "or to NULL if it was
# of length zero").
for (z in integer(0L)) {
NULL
}
z
#> NULL
Source: R documentation: Control
The script uses the idioms to remember. Looping over a vector gives the elements; looping over names(x) and indexing with
x[[nm]] gives name and value; looping over seq_along(x) gives positions when the index matters; and looping over
seq_len(nrow(df)) visits the rows of a data frame. The version of the same loops over 1:length(x) or 1:nrow(df) is
the next trap.
seq_along() and seq_len() versus 1:length(x)
1:length(x) is the idiom of older tutorials, and the introduction manual itself uses 1:length(yc) in its worked example
of for (An Introduction to R: Repetitive execution).
It is correct whenever x is known to be non-empty. When x is empty it counts down: 1:0 is c(1, 0), so a loop over
it runs twice, with i equal to 1 and then 0, indexing out of range and, if the body assigns, writing to the wrong
element. seq_along(x) and seq_len(n) return an empty integer vector for zero length, so the loop body is skipped. They are
described, together with seq(), in the seq help page
(R documentation: seq).
# The empty-vector trap: 1:length(x) counts down when x is empty.
x <- numeric(0L)
1:length(x) # nolint: seq_linter.
#> [1] 1 0
seq_along(x)
#> integer(0)
# With an empty vector the loop below runs twice and indexes out of range.
for (i in 1:length(x)) { # nolint: seq_linter.
cat("i =", i, " x[i] =", x[i], "\n")
}
#> i = 1 x[i] = NA
#> i = 0 x[i] =
for (i in seq_along(x)) {
cat("never printed\n")
}
# A data frame with zero rows.
empty <- data.frame(title = character(0L))
nrow(empty)
#> [1] 0
for (i in 1:nrow(empty)) { # nolint: seq_linter.
cat("row", i, "\n")
}
#> row 1
#> row 0
for (i in seq_len(nrow(empty))) {
cat("never printed\n")
}
# seq_len(), seq_along() and 1:n are integer; seq() with by = is double, and
# seq(x) with a length-one x means 1:x; with a longer x it is seq_along(x).
typeof(seq_len(3L))
#> [1] "integer"
typeof(seq_along(c("a", "b")))
#> [1] "integer"
typeof(1:3)
#> [1] "integer"
typeof(seq(2, 8, by = 2))
#> [1] "double"
seq(5)
#> [1] 1 2 3 4 5
seq(c(5))
#> [1] 1 2 3 4 5
seq(c(5, 6))
#> [1] 1 2
# for unclasses its sequence: dates become numbers and factors become character.
d <- as.Date(c("2026-06-01", "2026-06-15"))
for (day in d) {
print(day)
}
#> [1] 20605
#> [1] 20619
for (day in as.list(d)) {
print(day)
}
#> [1] "2026-06-01"
#> [1] "2026-06-15"
for (i in seq_along(d)) {
print(d[[i]])
}
#> [1] "2026-06-01"
#> [1] "2026-06-15"
f <- factor(c("low", "high", "low"), levels = c("low", "high"))
for (lev in f) {
print(lev)
}
#> [1] "low"
#> [1] "high"
#> [1] "low"
for (lev in levels(f)) {
print(lev)
}
#> [1] "low"
#> [1] "high"
for (lev in as.integer(f)) {
print(lev)
}
#> [1] 1
#> [1] 2
#> [1] 1
# A loop over a data frame iterates over its columns, not its rows.
df <- data.frame(a = 1:2, b = c("x", "y"))
for (col in df) {
print(col)
}
#> [1] 1 2
#> [1] "x" "y"
for (nm in names(df)) {
cat(nm, class(df[[nm]]), "\n")
}
#> a integer
#> b character
Source: R documentation: seq
Remember these rules from the script.
-
Use
seq_along(x)to loop over the positions of a vector or list,seq_len(n)to loopntimes, andseq_len(nrow(df))for the rows of a data frame. the defaultseq_linteroflintrreports1:length(x),1:nrow(x)andseq_len(length(x)). -
seq(x)is not a safe shortcut: with a length-onexit means1:x, soseq(n)withn = 0givesc(1, 0), the same trap. -
for (day in dates)gives numbers, not dates, because the sequence is unclassed. Loop overseq_along(dates)and index with[[, or overas.list(dates). -
for (col in df)visits the columns of a data frame, not its rows. -
A loop over a factor visits the elements as their character labels, not as integer codes; use
as.integer(f)for the codes.
while, repeat, break and next
while (cond) body tests the condition before each iteration, so the body may never run. repeat body has no condition
and runs until a break (or an error, or a return() inside a function); the language definition notes that you need to
be careful because of the danger of an infinite loop, and that the body of a repeat has to be a block, because you
usually need two statements, the computation and the test
(The R Language Definition: repeat and
The R Language Definition: while).
break leaves the innermost loop at once, and next abandons the rest of the current iteration and moves to the next one;
both apply only to the innermost of nested loops
(R documentation: Control).
# while: the condition is tested before every iteration.
copies <- 0L
while (copies < 3L) {
copies <- copies + 1L
cat("printed copy", copies, "\n")
}
#> printed copy 1
#> printed copy 2
#> printed copy 3
copies
#> [1] 3
# The body may never run.
n <- 10L
while (n < 5L) {
n <- n + 1L
}
n
#> [1] 10
# repeat: an endless loop that ends only with break (or an error or return()).
attempt <- 0L
repeat {
attempt <- attempt + 1L
cat("attempt", attempt, "\n")
if (attempt >= 3L) {
break
}
}
#> attempt 1
#> attempt 2
#> attempt 3
# next skips the rest of the body and goes to the next iteration.
for (i in 1:6) {
if (i %% 2L == 0L) {
next
}
cat(i, "")
}
#> 1 3 5
cat("\n")
#>
# break leaves only the innermost loop.
for (i in 1:3) {
for (j in 1:3) {
if (j == 2L) {
break
}
cat("i =", i, "j =", j, "\n")
}
}
#> i = 1 j = 1
#> i = 2 j = 1
#> i = 3 j = 1
# while (TRUE) and repeat are the same thing.
count <- 0L
while (TRUE) {
count <- count + 1L
if (count == 4L) {
break
}
}
count
#> [1] 4
# A while loop on floating-point numbers: ten additions of 0.1 are not exactly 1
# (R FAQ 7.31), so `!=` or `<` against 1 may run once too often or forever.
x <- 0
for (i in 1:10) {
x <- x + 0.1
}
x == 1
#> [1] FALSE
sprintf("%.17g", x)
#> [1] "0.99999999999999989"
isTRUE(all.equal(x, 1))
#> [1] TRUE
# A guard counter keeps a loop that tests `!=` from running forever.
y <- 0
guard <- 0L
while (y != 1 && guard < 15L) {
y <- y + 0.1
guard <- guard + 1L
}
guard
#> [1] 15
# Compare with a tolerance instead.
z <- 0
steps <- 0L
while (!isTRUE(all.equal(z, 1)) && steps < 15L) {
z <- z + 0.1
steps <- steps + 1L
}
steps
#> [1] 10
The last part of the script is a while loop on floating-point numbers. Ten additions of 0.1 do not give exactly 1, so a
loop that tests != against 1 never stops on its own and a loop that tests < against 1 runs once too often. The R FAQ
entry on this is
R FAQ 7.31: Why doesn’t R think these numbers are equal?
Compare with all.equal(), wrapped in isTRUE(), or use a tolerance, and give a while (TRUE) or repeat loop a counter
as a guard so that a mistake becomes an error instead of an endless loop.
A loop that stops at the first bad value
A search that stops at the first hit is where break earns its place, and next expresses "skip this one" without nesting.
In the shelf scenario, the loan log has one ISBN with a missing digit; is_valid_isbn13() is the check from the scenario.
source("scenario.R")
# Stop at the first invalid ISBN in the loan log.
first_bad <- NA_integer_
for (i in seq_len(nrow(loans))) {
if (!is_valid_isbn13(loans$isbn[[i]])) {
first_bad <- i
break
}
}
first_bad
#> [1] 5
loans[first_bad, c("loan_id", "isbn")]
#> loan_id isbn
#> 5 L005 978030640615
i
#> [1] 5
# The loop variable keeps its value after the break, so the search needs no
# extra variable: but the value is only meaningful if the loop did break.
found <- FALSE
for (i in seq_len(nrow(loans))) {
if (!is_valid_isbn13(loans$isbn[[i]])) {
found <- TRUE
break
}
}
if (found) cat("first invalid ISBN at row", i, "\n")
#> first invalid ISBN at row 5
# next skips loans that have been returned; the loop counts the ones still out.
still_out <- 0L
for (i in seq_len(nrow(loans))) {
if (!is.na(loans$return_date[[i]])) {
next
}
still_out <- still_out + 1L
}
still_out
#> [1] 7
# The vectorised answers to the same questions.
which(!is_valid_isbn13(loans$isbn))[1L]
#> [1] 5
sum(is.na(loans$return_date))
#> [1] 7
Source: R documentation: Control
The loop variable keeps the value it had at the break, which is what lets the script report row 5 without an extra
variable. Remember, though, that it also keeps its last value when the loop ends without a break, so the script
uses a found flag when "not found" is a possible answer. The last two lines show the vectorised answers to the same
questions: which(...)[1] is NA when nothing is found, and sum(is.na(...)) counts the missing dates. The loop earns
its keep when the test is expensive and stopping early saves work, or when the iterations depend on each other.
break, next and return
break and next are lexically tied to a loop. Used outside one, or inside a function called from a loop, they fail with
no loop for break/next, jumping to top level. That is why the body of the function passed to vapply() or lapply() cannot
use next: it is not a loop, it is a function. Return the value for the element instead (NA, an empty value, or a flag
you filter on). return() leaves the whole function, even from inside a loop (see
Functions), and a loop that is the last expression of a function makes the
function return NULL invisibly. Recall() is the base function that calls the function currently running, a way to write a
loop as recursion (R documentation: Recall).
# break and next are only meaningful inside a loop.
res <- tryCatch(eval(quote(break)), error = conditionMessage)
res
#> [1] "no loop for break/next, jumping to top level"
res <- tryCatch(eval(quote(next)), error = conditionMessage)
res
#> [1] "no loop for break/next, jumping to top level"
# They cannot cross a function boundary: the body of an anonymous function
# passed to a loop-like function is not a loop.
res <- tryCatch(
vapply(1:3, function(i) {
if (i == 2L) next
i
}, integer(1L)),
error = conditionMessage
)
res
#> [1] "no loop for break/next, jumping to top level"
# return() leaves the whole function, even from inside a loop.
first_long <- function(pages, limit) {
for (i in seq_along(pages)) {
if (!is.na(pages[[i]]) && pages[[i]] > limit) {
return(i)
}
}
NA_integer_
}
first_long(c(214L, NA, 387L, 432L), 300L)
#> [1] 3
first_long(c(214L, NA), 300L)
#> [1] NA
# A loop as the last expression of a function returns NULL, invisibly.
report <- function(x) {
for (v in x) {
cat("value", v, "\n")
}
}
out <- report(1:2)
#> value 1
#> value 2
is.null(out)
#> [1] TRUE
report(1:2)
#> value 1
#> value 2
# Recall() calls the function that is running, a way to loop with recursion.
countdown <- function(n) {
if (n == 0L) {
return("liftoff")
}
cat(n, "\n")
Recall(n - 1L)
}
countdown(3L)
#> 3
#> 2
#> 1
#> [1] "liftoff"
Source: R documentation: Recall
Loop values and preallocation
A loop is run for its side effects. Because its value is always NULL, invisibly, x <- for (...) ... stores NULL, and
a result has to be collected in a variable that is updated inside the loop. How it is collected decides the cost.
-
Growing with
c()(out <- c(out, value)) allocates a new vector and copies all the existing elements on every iteration, so the total work grows with the square of the length. -
Preallocating (
out <- numeric(n), thenout[[i]] <- value) allocates once and fills by position. The constructorsnumeric(n),integer(n),character(n)andlogical(n)give vectors of the right type filled with0,0L,""andFALSE;vector("list", n)gives a list ofnNULLvalues, andvector(mode, n)is the general form (R documentation: vector). -
Assigning past the end grows a vector, filling the gap with
NA. Since R 3.4.0 this over-allocates by a small fraction, so building a vector by assigning to the next element is cheaper thanc(), but the NEWS entry still says that preallocating is preferred (R NEWS: Changes in R 3.4.0). It also hides off-by-one bugs, as the last lines of the script show. -
For results that are data frames or other objects, fill a list with
vector("list", n)and combine once at the end withdo.call(rbind, parts)(R documentation: do.call), instead of callingrbind()inside the loop.
source("scenario.R")
# A loop returns NULL, invisibly: it cannot be used as an expression.
res <- for (i in 1:3) {
i
}
is.null(res)
#> [1] TRUE
withVisible(for (i in 1:3) i)
#> $value
#> NULL
#>
#> $visible
#> [1] FALSE
#>
res <- while (FALSE) 1
is.null(res)
#> [1] TRUE
# A loop is used for its side effects, so results must be stored explicitly.
# Growing a vector with c() copies it at every step:
squares <- c()
for (i in 1:5) {
squares <- c(squares, i^2)
}
squares
#> [1] 1 4 9 16 25
# Preallocate the result and fill it by position:
squares <- numeric(5L)
for (i in 1:5) {
squares[[i]] <- i^2
}
squares
#> [1] 1 4 9 16 25
# The constructors for the common types.
numeric(3L)
#> [1] 0 0 0
integer(3L)
#> [1] 0 0 0
character(3L)
#> [1] "" "" ""
logical(3L)
#> [1] FALSE FALSE FALSE
vector("list", 2L)
#> [[1]]
#> NULL
#>
#> [[2]]
#> NULL
#>
vector("numeric", 2L)
#> [1] 0 0
vector("character", 2L)
#> [1] "" ""
# Fill a list by position, then combine once at the end.
parts <- vector("list", 3L)
for (i in seq_along(parts)) {
parts[[i]] <- data.frame(id = i, square = i^2)
}
do.call(rbind, parts)
#> id square
#> 1 1 1
#> 2 2 4
#> 3 3 9
# A shelf example: days out for each loan, filled by position.
days_out <- integer(nrow(loans))
for (i in seq_len(nrow(loans))) {
returned <- loans$return_date[[i]]
end <- if (is.na(returned)) shelf_today else returned
days_out[[i]] <- as.integer(end - loans$loan_date[[i]])
}
head(days_out, 10L)
#> [1] 21 10 1 26 26 17 24 10 31 21
# The vectorised form gives the same numbers in one expression.
end_date <- loans$return_date
end_date[is.na(end_date)] <- shelf_today
days_vec <- as.integer(end_date - loans$loan_date)
identical(days_out, days_vec)
#> [1] TRUE
# The same with vapply(), which returns the values instead of assigning them.
days_apply <- vapply(seq_len(nrow(loans)), function(i) {
returned <- loans$return_date[[i]]
end <- if (is.na(returned)) shelf_today else returned
as.integer(end - loans$loan_date[[i]])
}, integer(1L))
identical(days_out, days_apply)
#> [1] TRUE
# An element past the end silently grows a vector, which hides off-by-one bugs.
v <- numeric(3L)
v[[5L]] <- 1
v
#> [1] 0 0 0 NA 1
length(v)
#> [1] 5
Source: R documentation: vector
The script computes the number of days each loan was out three ways: a preallocated loop, one vectorised expression, and
vapply(), and checks that they agree with identical(). Timing the variants is the job of
Performance, Profiling and Benchmarking, and
the copying behind the difference is explained in
Memory and Copy Semantics; the point here is that the
preallocated loop and the vectorised version both avoid the repeated copy.
When loops are fine
An explicit loop is not wrong in R; it is just not the first tool to reach for. The practical rule is the one the language
definition hints at: prefer a vectorised operation when one
exists (arithmetic, comparison, ifelse(), sum(), cumsum(), subsetting) and an apply-family function or a purrr-style
map when you only need the values, and use an explicit for loop when it is the clearer way to say it:
-
the iterations depend on each other (each step uses the previous result: a running balance, a simulation, a search that stops, a convergence test);
-
the work per iteration is large and not vectorisable (reading a file, calling an API, fitting a model), so the loop overhead is negligible;
-
the loop is run for its side effects (printing, writing files, drawing plots, updating a database);
-
the clarity matters more than the speed: a short loop over a handful of items is easier to read than a clever vectorised one.
When the loop does nothing but compute one value per element, vapply() is the safer replacement: it checks the type and
length of every result and returns a vector, which sapply() does not guarantee. See
The apply Family and Functionals and
Vectorisation and Iteration for the vectorised and
functional styles in depth, and the section on preallocation above for the cost of building a result in a loop.
Version notes
| Version | Change |
|---|---|
R 3.4.0 |
Assigning to an element past the end of a vector over-allocates, making growth by assignment cheaper; preallocation is still preferred. |
R 4.0.0 |
Long vectors are supported as the sequence of a |
R 4.2.0 |
|
R 4.3.0 |
|
R 4.4.0 |
The base operator |
Where older tutorials differ
Many pages on the web predate these changes. When one of them disagrees with this page, the table says why.
| Older tutorial | Current practice | Why | Version |
|---|---|---|---|
|
reduce the condition with |
it is an error: the condition has length greater than one |
R 4.2.0 |
|
use a length-one operand |
it is an error: |
R 4.2.0 warning, R 4.3.0 error |
|
|
the newline ends the |
always |
|
|
|
always |
|
loop over |
the sequence is unclassed, so |
always |
|
|
it is more efficient, evaluates one branch, and keeps the class and length of |
always |
|
assignment on a copy, or |
the result takes the attributes from |
always |
|
|
a factor is used through its integer codes, with a warning |
always |
|
|
growing by |
always |
|
|
|
always |
|
compare with |
floating-point results are not exact (R FAQ 7.31) |
always |
For the full list of changes by release see What’s New and Migrating Older Code.
Where to go next
-
Operators and Precedence for
&&,||,!,%in%and the precedence of the operators used in conditions. -
Functions for
return(), argument matching and thematch.arg()idiom used withswitch(). -
Missing and Special Values for
NA,NaN,NULLand why a comparison withNAisNA. -
Vectorisation and Iteration and The apply Family and Functionals for replacing loops.
-
Conditions and Error Handling for
tryCatch(), which this page uses to show error messages, and for signalling your own errors. -
Defensive Programming and Input Validation for
stopifnot()and argument checks before a branch.
References
-
The R Language Definition: Control structures (if, Looping, repeat, while, for, switch)
-
An Introduction to R: Grouping, loops and conditional execution
-
R Internals: Tools (environment variables of the checking tools)
-
R FAQ 3.3.3: Others (differences between R and S, including the
forindex) -
R FAQ 7.31: Why doesn’t R think these numbers are equal?
-
R NEWS: Changes in R 4.0.0, 4.2.0, 4.3.0 and 4.4.0
-
R NEWS (R 3.x): Changes in R 3.4.0
-
Control (if, for, while, repeat, break, next,
%||%) -
dplyr package documentation: if_else and case_when (named in prose only)