Design Philosophy and Coming from Other Languages
|
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 why R looks
the way it does and then maps the concepts a programmer already knows from Python, MATLAB, SQL, Java or C# onto their R
counterparts. The other pages of this reference go into each topic in depth, and each section below names the page that
does. Every example uses only base R and the packages that ship with it (base, stats, utils, methods and
parallel), so no add-on package version needs to be stated. Packages such as R6, S7, dplyr, data.table and future are
described in prose and linked, not run.
Every example below, except the one fragment that is marked as such, 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), = for assignment,
NA == NA), the line carries a # nolint: comment so that the linter does not hide the lesson. The names books,
loans, shelf_today, loan_days and the book class come from the scenario.R file of the running library-shelf
scenario described on the section index; the scripts that use them either start with
source("scenario.R"), which reads books.csv and loans.csv with read.csv(), or read books.csv directly. The first page,
Getting Started, shows how to install R and run a script.
Where R comes from
R is two things at once: a language very close in appearance to S, and an implementation whose semantics come from Scheme. The R FAQ says exactly that: the design of R was heavily influenced by Becker, Chambers & Wilks' S and by Sussman’s Scheme, and R was initially written by Ross Ihaka and Robert Gentleman at the University of Auckland. Since mid-1997 a core group (the R Core Team) can modify the source archive, and John Chambers, the principal designer of S, is a member of it (R FAQ 2.1).
S is a very high level language and an environment for data analysis and graphics. The R FAQ lists the four books that mark its evolution, which are also the best map of where R’s vocabulary comes from (R FAQ 3.1).
| Book | Nickname | What it added |
|---|---|---|
Becker and Chambers (1984), S: An Interactive Environment for Data Analysis and Graphics |
Brown Book |
The original interactive system (of historical interest only) |
Becker, Chambers and Wilks (1988), The New S Language |
Blue Book |
What is now known as S version 2; R follows the Blue and White Books as far as possible |
Chambers and Hastie (1992), Statistical Models in S |
White Book |
S version 3, which added structures to facilitate statistical modelling |
Chambers (1998), Programming with Data |
Green Book |
S version 4, a major revision whose formal classes and methods R implements as S4 |
The FAQ treats S as a language with several "engines" (the old S engine, the new S engine and R), so that "the differences between R and S" really means the differences between the engines (R FAQ 3.3). The ones that still matter to a newcomer are these.
| Difference | What R does |
|---|---|
Lexical scoping (R FAQ 3.3.1) |
Free variables in a function are looked up in the environment where the function was
created, as in Scheme. In S they were looked up among global variables. Closures, function factories and the
|
Model formulae (R FAQ 3.3.2) |
To regress on a power you insulate it with |
|
They are ordinary variables that happen to hold |
Removing list elements (R FAQ 3.3.3) |
|
The default environment of |
|
Replacement functions (R FAQ 3.3.3) |
The right-hand-side argument of a replacement function must be named |
The |
The loop variable lives in the environment where the loop runs, not just inside the loop. |
Locale (R FAQ 3.3.3) |
R uses the operating system’s locale to decide which characters are alphanumeric and how strings sort; S used the C locale. |
Startup (R FAQ 3.3.3) |
Customisation happens through site and user profile files, |
Source: R FAQ 3.3.3: Others
Two consequences shape everything that follows. First, because the semantics are Scheme’s, R functions are first-class
values with lexical scope, and the language is built from a small number of ideas applied everywhere. Second, because
the surface syntax is S’s, code that looks familiar to a C or Python programmer often means something different:
assignment is <-, a single number is a vector, and x[1] is the first element rather than the second.
Everything is an object, everything that happens is a function call
John Chambers, the designer of S, summarised the language in two slogans in his book Extending R (2016): everything that exists in R is an object, and everything that happens in R is a function call. The official manuals ground both halves.
-
The R Language Definition chapter on objects explains that R does not give direct access to memory but provides a number of specialised data structures called objects, and that even the symbols that name them are objects that can be manipulated like any other. That is different from many languages and has wide-ranging effects, as the metaprogramming pages show (R-lang: Objects).
-
The same manual’s section on function calls explains that the left-hand side of an assignment such as
class(x) <- "foo"is itself a call: it calls the functionclass<-with the original object and the right-hand side, then stores the result back in the variable (R-lang: Function calls). Operators are function calls too (R-lang: Operators).
The consequence is that nothing in R is syntax in the way +, [ or if are syntax in Java. Each of them is a
function with a name you can call, pass around or redefine, and the infix and bracket forms are only a convenient way of
writing the call. That is why a function name can stand where a value is expected (the script calls vapply() with the
function - as an argument), and why S3 and S4 methods can be written for operators, [, $ and length().
# Operators and indexing are ordinary function calls.
`+`(1, 2)
#> [1] 3
x <- c(10, 20, 30)
`[`(x, 2L)
#> [1] 20
vapply(1:3, `-`, numeric(1), 1)
#> [1] 0 1 2
x[2L] <- 99
x
#> [1] 10 99 30
x <- `[<-`(x, 3L, 0)
x
#> [1] 10 99 0
`if`(length(x) > 2L, "long", "short")
#> [1] "long"
The backquotes protect a name that is not a syntactic name. The assignment x[2L] <- 99 and the explicit call to the
replacement function [<- in the script are the same operation: the replacement function receives the object and
the new value and returns the modified object, which is then assigned back to x. That is why the right-hand-side
argument of a replacement function has to be called value (R FAQ 3.3.3). Even if is a function here, which
explains why if returns a value and can be used inside an expression. See
Functions for replacement functions you write yourself and
Object-Oriented Programming Overview and Base Types for
how every object has a type that typeof() reports.
Vectorisation first
The basic way to compute in R is to write one expression over a whole vector. The manual introduces arithmetic on
vectors as element-by-element operations, and recycling extends this to operands of different lengths: the shorter
vector is reused until it matches the longer, with a warning when the longer length is not a multiple of the shorter
(R-lang: Recycling rules and
R-intro: Vector arithmetic).
Comparison, logical operators, ifelse(), sum(), mean() and most mathematical functions are vectorised the same way.
The loop still exists, but it runs inside compiled code, so the idiomatic R program has far fewer visible loops than the
equivalent Java, C# or Python without NumPy.
books <- read.csv("books.csv")
# One expression works on the whole column; there is no loop to write.
books$pages_in_hundreds <- books$pages / 100
head(books[c("title", "pages", "pages_in_hundreds")], 4L)
#> title pages pages_in_hundreds
#> 1 The Dispossessed 387 3.87
#> 2 Pride and Prejudice 432 4.32
#> 3 the quiet streets 214 2.14
#> 4 Loud Roofs 198 1.98
# The same work with an explicit loop, for comparison.
hundreds <- numeric(nrow(books))
for (i in seq_len(nrow(books))) {
hundreds[[i]] <- books$pages[[i]] / 100
}
identical(hundreds, books$pages_in_hundreds)
#> [1] TRUE
# Comparison, logic and summaries are vectorised too.
long <- books$pages > 300
sum(long, na.rm = TRUE)
#> [1] 6
books$title[which(long)]
#> [1] "The Dispossessed" "Pride and Prejudice" "Midnight Ledger"
#> [4] "Rivers of Glass" "Notes on Gravity" "The Slow Comet"
# Missing values propagate through the arithmetic.
mean(books$pages)
#> [1] NA
mean(books$pages, na.rm = TRUE)
#> [1] 268.2143
# A shorter vector is recycled when its length divides the longer one.
1:6 + c(10, 20)
#> [1] 11 22 13 24 15 26
Three things in this output are worth noticing. The explicit loop and the vectorised line give an identical() result, so
the loop adds nothing but length. mean(books$pages) returns NA because two books have a missing page count and
missing values propagate through arithmetic; na.rm = TRUE is the explicit opt-in to ignore them (see
Missing and Special Values). And books$pages > 300
contains NA for those two books too, which is why the example wraps sum() in na.rm = TRUE and selects titles with
which(), which drops NA positions. Indexing with a logical vector that contains NA returns NA rows, a trap taken
up below.
Vectorisation also explains why the most common R performance advice is "do not grow a vector in a loop" and "replace
the loop with a vector operation". The pages Vectorisation
and Iteration and The apply Family and Functionals cover
the cases that cannot be vectorised (vapply(), Map(), Reduce()).
No scalars and one-based indexing
R has no scalar type. A single number such as 4.2 or a string such as "four point two" is a vector of length one, and
the manual states there are no more basic types than the vector types
(R-lang: Vectors). Vectors of length zero
are possible and useful: numeric(0) is the result of selecting nothing, and NULL is the length-zero object that
stands for "no value" (see below). The six atomic vector types are logical, integer, double, complex, character and raw.
Indexing starts at 1, as it does in MATLAB, Fortran and Julia and unlike Java, C#, C and Python. A positive index selects
that position, a negative index drops it, 0 selects nothing, and an index past the end yields NA for [ and an error
for [[ (R-intro: Index vectors).
x <- 42
length(x)
#> [1] 1
is.vector(x)
#> [1] TRUE
x[[1L]]
#> [1] 42
x[1L]
#> [1] 42
identical(x, c(42))
#> [1] TRUE
# Indexing starts at 1; position 0 selects nothing.
y <- c(10, 20, 30)
y[1L]
#> [1] 10
y[0L]
#> numeric(0)
y[4L]
#> [1] NA
y[-1L]
#> [1] 20 30
# Strings are length-1 character vectors, not sequences of characters.
s <- "shelf"
length(s)
#> [1] 1
nchar(s)
#> [1] 5
substr(s, 1L, 1L)
#> [1] "s"
strsplit(s, "")[[1L]]
#> [1] "s" "h" "e" "l" "f"
# Even NULL is a vector-like object of length zero.
length(NULL)
#> [1] 0
length(NA)
#> [1] 1
A string is one element of a character vector, so length("shelf") is 1; nchar() counts characters, and strsplit()
or substr() take a string apart. Programmers from Java or Python often write length(s) and are surprised by the 1.
See Atomic Vectors and Types and
Subsetting and Indexing for the full rules ([, [[, $,
negative and logical indices, drop).
Copy-on-modify value semantics
Assigning a vector to a second name does not copy it, and modifying one of the names does not change the other. From the
programmer’s side R has value semantics: the manual describes argument passing as call-by-value, so changing a supplied
argument inside a function does not affect the variable in the calling frame
(R-lang: Argument evaluation), and it
adds that some extra effort is made to avoid unnecessary duplication of data
(R-lang: Function calls). Internally R
shares the storage between names until one of them is modified, and copies at that moment. R 4.0.0 replaced the older
NAMED mechanism by reference counting to decide when an object can safely be mutated, which, according to the
NEWS for R 4.0.0, reduces the need for copying in some cases.
tracemem() marks an object so that a message is printed whenever the internal code copies it. The message holds the
addresses of the old and the new object in hex, followed by a stack trace (empty at the top level); tracemem() itself
returns the address enclosed in angle brackets. The addresses are different on every run, so the output below shows
0x... in their place. tracemem() is available only when R was
compiled with memory profiling; the help page says it is enabled in the CRAN macOS and Windows builds, and it is in the
Linux build used for these examples.
x <- c(1, 2, 3)
invisible(tracemem(x))
y <- x
cat("after y <- x: no copy yet\n")
#> after y <- x: no copy yet
y[[1L]] <- 99
#> tracemem[0x... -> 0x...]:
cat("after modifying y: x is", x, "and y is", y, "\n")
#> after modifying y: x is 1 2 3 and y is 99 2 3
untracemem(x)
# A function receives a promise to the same vector; modifying it creates
# a local copy and leaves the caller's vector alone.
bump <- function(v) {
v[[1L]] <- v[[1L]] + 1
v
}
a <- c(1, 2, 3)
b <- bump(a)
a
#> [1] 1 2 3
b
#> [1] 2 2 3
# Environments are the exception: they have reference semantics.
counter <- new.env()
counter$n <- 0L
increment <- function(e) {
e$n <- e$n + 1L
invisible(NULL)
}
increment(counter)
increment(counter)
counter$n
#> [1] 2
Source: R documentation: tracemem
No message appeared for y <- x, because no copy was made. The message appeared at y[[1L]] <- 99, when the shared
vector had to be duplicated, and afterwards x still held its original values. The same thing happens when a function
modifies its argument: bump() works on a private copy and returns it, so the caller keeps both a and b.
The exception is the environment. The language definition says that environments are not copied when passed to
functions or assigned, so if you assign the same environment to several names and change one, the others change too
(R-lang: Environments); counter
above is modified in place by increment(). Reference Classes and R6 objects are environments, which is why they have
the reference semantics that S3 lists do not.
Copy-on-modify is the cost side of the design. Growing a vector one element at a time, or modifying a large data frame
inside a loop, copies the data many times. The Writing R Extensions manual describes tracemem() as one of three
memory-profiling methods
(R-exts: Tracing copies of an object).
The page Memory and Copy Semantics goes further (reference
counting, ALTREP, in-place modification and object.size()).
Lazy evaluation and promises
When a function is called, its arguments are matched and each formal argument is bound to a promise, an object that holds the expression the caller wrote and the environment to evaluate it in. The promise is evaluated the first time the function needs the value, and the result is saved, so it is evaluated at most once (R-lang: Promise objects). An argument that is never used is never evaluated. Default arguments are promises whose environment is the function’s own frame, which is why a default can refer to another argument or to a local variable created later (R-lang: Argument evaluation).
# An argument that is never used is never evaluated.
lazy <- function(used, unused) {
used * 2
}
lazy(21, stop("this is never evaluated"))
#> [1] 42
# A default argument is evaluated inside the function, when first needed.
loan_end <- function(start, days = 21L, end = start + days) {
days <- days * 2L
end
}
loan_end(as.Date("2026-06-01"))
#> [1] "2026-07-13"
# missing() asks whether the caller supplied the argument.
describe <- function(title, author) {
if (missing(author)) {
paste(title, "(author unknown)")
} else {
paste0(title, " by ", author)
}
}
describe("Loud Roofs")
#> [1] "Loud Roofs (author unknown)"
describe("Loud Roofs", "Mara Quill")
#> [1] "Loud Roofs by Mara Quill"
# A promise is evaluated once, and its value is kept.
once <- function(x) {
c(x, x)
}
once({
cat("evaluating the argument\n")
1
})
#> evaluating the argument
#> [1] 1 1
# force() evaluates an argument now. Closures made in a loop need it.
make_adder <- function(n) {
force(n)
\(x) x + n
}
adders <- lapply(1:3, make_adder)
adders[[2L]](10)
#> [1] 12
# delayedAssign() creates a promise explicitly.
delayedAssign("later", {
cat("computing later\n")
42
})
cat("before use\n")
#> before use
later
#> computing later
#> [1] 42
later
#> [1] 42
Source: R documentation: force
The default end = start + days in loan_end() was not evaluated when the function was entered. By the time end was
returned, the line days <- days * 2L had run, so the result is 42 days after the start, not 21. missing() tells a
function whether the caller supplied an argument, which is how R expresses optional parameters without overloading. force() evaluates an
argument immediately; it matters in function factories, where a promise left unevaluated would capture a variable that
later changes. delayedAssign() makes a promise by hand. Because the promise remembers the caller’s expression, substitute()
can recover it, which is the basis of non-standard evaluation, covered in
Lazy Evaluation and Promises and
Evaluation and Non-Standard Evaluation.
The practical consequences for a programmer from a strict language are: do not rely on side effects in arguments (the
manual calls it bad style to use arguments to cause side effects, since there is no guarantee they are evaluated), an
error in an argument can surface far from the call, and checking the arguments up front with stopifnot() or an
explicit validation call is a common defensive habit.
First-class functions and environments
Functions are ordinary values. They can be stored in lists, passed to other functions, returned from them and created
anywhere. Since R 4.1.0 the shorthand \(x) is an alternative to function(x), which makes short anonymous
functions as compact as a Python lambda or a Java x -> ... expression. A function created inside another function is a closure: it
carries a pointer to the environment where it was created, and free variables are looked up there first. That is the
lexical scoping the R FAQ lists as the major difference from S
(R-lang: Lexical environment).
# Functions are values: store them, pass them, return them.
ops <- list(sum = sum, mean = mean, max = max)
vapply(ops, \(f) f(c(2, 4, 9)), numeric(1L))
#> sum mean max
#> 15 5 9
compose <- \(f, g) \(x) g(f(x))
double_then_sqrt <- compose(\(x) x * 2, sqrt)
double_then_sqrt(8)
#> [1] 4
# A closure remembers the environment where it was created.
make_counter <- function() {
count <- 0L
\() {
count <<- count + 1L # nolint: assignment_linter.
count
}
}
next_loan_id <- make_counter()
next_loan_id()
#> [1] 1
next_loan_id()
#> [1] 2
ls(environment(next_loan_id))
#> [1] "count"
environment(next_loan_id)$count
#> [1] 2
# local() gives a throw-away scope without writing a function.
total <- local({
a <- 5
b <- 6
a * b
})
total
#> [1] 30
exists("a")
#> [1] FALSE
# The global environment is where interactive assignments land.
environmentName(globalenv())
#> [1] "R_GlobalEnv"
environmentName(environment(sd))
#> [1] "stats"
Source: R documentation: environment
<<- assigns to a variable in an enclosing environment, which is how make_counter() keeps state between calls; the
default lintr style flags it, as the # nolint comment shows, because it is easy to overuse. local() evaluates its
body in a new environment, so a and b never reach the workspace. The last two lines show that the global environment
is where interactive assignments land, and that a function from a package, such as sd(), has the package namespace as its
environment. The R FAQ separates the two environments a function can see: the enclosing environment, fixed when
the function is created and returned by environment(f), and the parent environment, which is where the function was
called from and is returned by parent.frame()
(R FAQ 7.12).
Variables are resolved through the enclosing chain, while things like model formulae are evaluated in the caller’s frame.
The page Scoping, Environments and Closures covers
the search path, sys.function() and the call stack, and the page
Function Factories and Higher-Order
Functions covers the patterns built on them.
Several object systems
R has more than one way to define classes, because the language grew them in layers. The choice is a design decision, and a newcomer meets all of them quickly because the standard library uses S3 and S4 and popular packages use R6 and S7.
-
S3 is the original, informal system. An object is a vector or list with a
classattribute, and a generic function such asprint(),format()orsummary()dispatches on the class of its first argument by callingUseMethod(). Methods are plain functions namedgeneric.class. There is no declaration, no formal fields and no validation, only conventions: a low-level constructor, a validator and a user-facing helper, as thebook()class of the scenario does. Most of base R and the tidyverse are S3. See S3 Classes and Generics. -
S4 is the formal system of the
methodspackage, derived from the Green Book. Classes are declared withsetClass()with typed slots and validity checks, generics withsetGeneric()and methods withsetMethod(), and dispatch can use more than one argument (multiple dispatch). Bioconductor is built on it. See S4 Classes and Methods. -
Reference Classes (RC, also called "R5") are part of the
methodspackage. Objects are mutable environments with fields and methods, created withsetRefClass(), and have reference semantics: two names refer to one object. They are not a separate "R5" package. -
R6 (documentation) is a CRAN package that offers similar reference semantics with a lighter implementation and without the
methodspackage. It is widely used in Shiny and in packages that need mutable state. -
S7 (documentation) is a CRAN package from the R Consortium object-orientation working group, which includes members of R Core. It is intended as a successor to S3 and S4, with formal classes, properties and validators, and its stated long-term goal is eventual inclusion in base R. It is not part of base R today, and its own documentation calls it somewhat experimental (the CRAN release is 0.2.2). See S7 Object System and Reference Classes and R6.
The example creates the same domain concept three ways with base R only. The book class is the S3 class of the
scenario; Loan is an S4 class with a generic and a method; and shelf_class is a Reference Class whose methods can be
chained.
source("scenario.R")
# S3: a class attribute and functions named generic.class.
b <- book(
"Loud Roofs", "Mara Quill", "978-0-00-200029-1",
year = 2021L, genre = "Fiction", pages = 198L
)
b
#> <book> Loud Roofs (Mara Quill, 2021)
class(b)
#> [1] "book"
is.list(b)
#> [1] TRUE
summary(b)
#> Book: Loud Roofs
#> ISBN known: TRUE
#> Pages: 198
# S4: formal class definitions checked by setClass() and validity.
setClass("Loan", slots = c(isbn = "character", days = "numeric"))
setGeneric("overdue", \(x, ...) standardGeneric("overdue"))
#> [1] "overdue"
setMethod("overdue", "Loan", \(x, ...) x@days > 21)
loan <- new("Loan", isbn = "9780141439518", days = 31)
overdue(loan)
#> [1] TRUE
isVirtualClass("Loan")
#> [1] FALSE
# Reference classes: methods belong to the object and it is mutable.
shelf_class <- setRefClass("Shelf",
fields = list(titles = "character"),
methods = list(
add = function(title) {
titles <<- c(titles, title)
invisible(.self)
}
)
)
s <- shelf_class$new()
s$add("Loud Roofs")$add("Paper Moons")
s$titles
#> [1] "Loud Roofs" "Paper Moons"
t2 <- s
t2$add("Ten Winters")
length(s$titles)
#> [1] 3
Source: R documentation: setClass
Note how b is a list underneath (is.list(b) is TRUE) with a class attribute "book", which is why S3 objects can
be inspected with unclass() and why nothing prevents a user from modifying a field directly. The S4 object has typed
slots read with @. The Reference Class object s was changed through t2 as well, because t2 <- s copies the
reference, not the object. See Object-Oriented
Programming Overview and Base Types for how to choose between them.
Coming from other languages
The mapping below pairs one concept per row with the form it takes in each language and in R. It is a lookup table, not a translation guide: the right-hand column is the R way of expressing the idea, which is not always the literal translation of the left-hand ones. Entries marked "dialect" differ between database products. Each R construct is explained on the page named in the section it belongs to.
| Concept | Python (NumPy, pandas) | MATLAB | SQL | Java / C# | R |
|---|---|---|---|---|---|
First index |
|
|
not applicable |
|
|
Assignment |
|
|
|
|
|
Boolean literals |
|
|
|
|
|
Missing value |
|
|
|
|
|
Comparing with a missing value |
|
|
|
|
|
Length |
|
|
|
|
|
Vector arithmetic |
|
|
an expression evaluated per row |
a loop or a stream |
|
Filter rows |
|
|
|
|
|
Group and summarise |
|
|
|
|
|
Join |
|
|
|
|
|
Define a function |
|
|
|
|
|
Anonymous function |
|
|
none |
|
|
Join strings |
|
|
|
|
|
Integer division |
|
|
|
|
|
Remainder |
|
|
|
|
|
Power |
|
|
|
|
|
Loop |
|
|
set-based, no loop |
|
|
Call a method |
|
|
none |
|
|
Import |
|
|
|
|
|
|
|
|
|
typing |
|
Are values shared or copied |
names share mutable objects |
value semantics, copy on write |
not applicable |
reference types are shared |
value semantics, copy on modify; environments are shared |
The next two scripts run the R column against the shelf data. The first filters, groups and joins with base R; the second shows the arithmetic and string operators that differ most from the other languages.
source("scenario.R")
# Rows: books longer than 300 pages (SQL WHERE, pandas boolean mask).
long_books <- books[!is.na(books$pages) & books$pages > 300, ]
long_books[c("title", "pages")]
#> title pages
#> 1 The Dispossessed 387
#> 2 Pride and Prejudice 432
#> 5 Midnight Ledger 301
#> 9 Rivers of Glass 352
#> 12 Notes on Gravity 410
#> 14 The Slow Comet 322
# subset() drops the NA rows for you and allows bare column names.
subset(books, pages > 300, select = c(title, pages))
#> title pages
#> 1 The Dispossessed 387
#> 2 Pride and Prejudice 432
#> 5 Midnight Ledger 301
#> 9 Rivers of Glass 352
#> 12 Notes on Gravity 410
#> 14 The Slow Comet 322
# Group by: count books per genre (SQL GROUP BY, pandas groupby).
genre <- tolower(trimws(books$genre))
table(genre)
#> genre
#> classic fiction mystery non-fiction poetry
#> 2 4 2 3 2
#> science fiction
#> 3
by_genre <- data.frame(genre, pages = books$pages)
aggregate(pages ~ genre, data = by_genre, FUN = mean)
#> genre pages
#> 1 classic 356.0000
#> 2 fiction 222.6667
#> 3 mystery 272.5000
#> 4 non-fiction 263.5000
#> 5 poetry 92.0000
#> 6 science fiction 373.0000
tapply(books$pages, genre, mean, na.rm = TRUE)
#> classic fiction mystery non-fiction poetry
#> 356.0000 222.6667 272.5000 263.5000 92.0000
#> science fiction
#> 373.0000
# Join: loans with the title (SQL JOIN, pandas merge).
loans$isbn13 <- loans$isbn
books$isbn13 <- normalise_isbn(books$isbn)
joined <- merge(loans, books[c("isbn13", "title")], by = "isbn13")
nrow(joined)
#> [1] 59
head(joined[c("loan_id", "title")], 3L)
#> loan_id title
#> 1 L008 the quiet streets
#> 2 L050 the quiet streets
#> 3 L009 the quiet streets
# Sort, pipe and anonymous function.
books$year |> range(na.rm = TRUE)
#> [1] 1813 2023
books |>
subset(year > 2020, select = c(title, year)) |>
head(3L)
#> title year
#> 4 Loud Roofs 2021
#> 10 THE LONG TABLE 2023
#> 15 Ten Winters 2022
sort(c(3, 1, 2), decreasing = TRUE)
#> [1] 3 2 1
# The three oldest books, ignoring the missing year.
order(books$year, na.last = NA)[1:3]
#> [1] 2 1 8
Source: R documentation: subset
Two details of this output repay attention. books[!is.na(books$pages) & books$pages > 300, ] has to exclude the missing
page counts explicitly, because indexing with NA returns an all-NA row; subset() removes them for you and lets you
write bare column names. And merge() produced 59 rows from 60 loans because one loan carries a malformed ISBN (the
scenario has deliberately dirty data); a SQL inner join behaves the same way. The chain written with |>, the native pipe
added in R 4.1.0, passes the left-hand side as the first argument of the next call; see
Pipes and the Native Pipe. The script uses tapply() and
aggregate() from base R and mentions dplyr only in the table: see
The Tidyverse and dplyr and
data.table for the two big alternatives, and
Data Frames for the base-R data frame.
# Arithmetic operators that differ from other languages.
7 %/% 2
#> [1] 3
7 %% 2
#> [1] 1
-7 %/% 2
#> [1] -4
-7 %% 2
#> [1] 1
2^10
#> [1] 1024
5L / 2L
#> [1] 2.5
class(5L %/% 2L)
#> [1] "integer"
paste("loan", 1:3, sep = "-")
#> [1] "loan-1" "loan-2" "loan-3"
paste0("L", sprintf("%03d", 7L))
#> [1] "L007"
(\(x) x^2)(4)
#> [1] 16
/ on two integers is always floating point (5L / 2L is 2.5), and %/% on two integers stays an integer. %/% and
%% round toward negative infinity, so -7 %/% 2 is -4 and -7 %% 2 is 1, matching Python and unlike Java and C#,
where % takes the sign of the dividend. See Numbers and
Arithmetic and Operators and Precedence for the details.
Python, NumPy and pandas
-
NumPy arrays and R vectors both compute element-wise, but R’s recycling is more permissive than NumPy’s broadcasting: where NumPy raises an error for incompatible shapes, R silently recycles unless the lengths are not multiples, in which case it warns.
-
A pandas
DataFrameand an R data frame are close relatives. R’s data frame is a list of equal-length columns, its row names are optional, and there is no index object to maintain.df.loc[mask]becomesdf[mask, ]with a mandatory trailing comma to select rows. -
Python’s mutable objects are shared by reference; R’s are copied on modification, so a function cannot change its caller’s data unless it returns the new value.
-
There is no
Nonethat is also falsy:NAandNULLare different, andif (NA)is an error (see below). -
The reticulate package lets R call Python and the other way round; see Python Interoperability with reticulate. For the other direction, see Python Reference.
MATLAB
-
Indexing is also 1-based, and both languages are vector-oriented. The key difference in operators is that
*is always element-wise in R (MATLAB’s.*), and the matrix product is%*%(MATLAB’s*). -
R code lives in packages and a script is run with
Rscriptorsource(); there is no path-based function discovery by file name as in MATLAB. Functions are defined with<-in a script or package. -
MATLAB’s
endindex has no counterpart; uselength(x),tail()or a negative index (x[-1]drops the first). -
Statistics and data-frame work is part of R itself and of CRAN packages, with no separate toolbox to license.
SQL
-
A
data.frameis the table,$or[[selects a column, andsubset(),merge()andaggregate()correspond toWHERE,JOINandGROUP BY. R is not declarative: the data is in memory and the operations run in the order you write them. -
NULLin SQL is closest toNAin R, not toNULL.NApropagates (NA + 1isNA), andNA == NAisNA, likeNULL = NULLin SQL. -
To keep the data in the database and write R, the DBI interface and the dbplyr translation of dplyr code do that; see Databases with DBI.
Java and C#
-
There are no primitive types, no class required for a function and no static typing: a variable can hold a vector of any type, and a function is not tied to a class. Overloading is replaced by dispatch on the class of the first argument (S3) or several arguments (S4).
-
Collections are vectors and lists, not
ArrayList<T>; a loop over indices is rarely the right tool, andfor (i in 1:length(x))is a classic trap whenxis empty. -
Exceptions are conditions, handled with
tryCatch()andwithCallingHandlers(); see Conditions and Error Handling. -
Object references are the exception, not the rule: assignment copies the value semantically, as the copy-on-modify section above showed.
Strengths and limits
R was designed for data analysis, and it shows in both directions. The following list is a summary of design consequences, each with the page that covers it.
-
Strength: statistics and graphics are built in. Models, tests, distributions and plots ship with the language, and CRAN, the comprehensive archive of packages, extends them. See the pages on statistics and graphics.
-
Strength: the data frame and formula interfaces. Tabular data and model specification are first-class.
-
Strength: interactive exploration. Autoprinting, lazy evaluation and the read-eval-print loop make exploratory work fast.
-
Limit: the interpreter is single-threaded. One R process runs one thread of R code. Parallelism is explicit and comes from the
parallelpackage (forking withmclapply(), which is not available on Windows unlessmc.cores = 1, or socket clusters withmakeCluster()), from the future and mirai packages, and from multithreaded BLAS and compiled code. Forked workers share the parent’s memory copy-on-write at the operating-system level, but each worker has its own state. -
Limit: data lives in memory. A vector is a contiguous block, an object is held entirely in RAM, and copy-on-modify can double the footprint. Answers are data.table (reference semantics for updates), the Arrow package (columnar, on-disk data), database back ends through DBI, and chunked reading.
-
Limit: copy semantics cost time and memory. Modifying a large object in a loop copies it.
-
Limit: start-up and package loading. R loads the
base,methods,datasets,utils,grDevices,graphicsandstatspackages at start-up (the list can be changed with theR_DEFAULT_PACKAGESenvironment variable, see the Startup help page), restores.RDataif present and sources the profile files, all before the first line of your script. Large packages add to this when attached.Rscript --vanillaskips the profile and workspace files. -
Limit: no built-in IDE. R ships a console, and on Windows and macOS a minimal GUI. IDEs such as RStudio and Positron are separate products; see IDEs and the R Console.
Parallelism
# Parallelism is opt-in: fork workers (not on Windows) or a socket cluster.
squares <- parallel::mclapply(1:4, \(i) i^2, mc.cores = 2L)
unlist(squares)
#> [1] 1 4 9 16
cl <- parallel::makeCluster(2L)
unlist(parallel::parLapply(cl, 1:4, \(i) i + 1))
#> [1] 2 3 4 5
parallel::stopCluster(cl)
Source: R documentation: mclapply
The first call forks two child processes; the second starts two separate R sessions that communicate by sockets and work
on every platform, but have to be told which objects and packages they need. The number of cores to use is usually
chosen with parallel::detectCores(), which is not shown because its value depends on the machine. See
Parallel and Asynchronous Computing.
Memory
# Memory-resident data: size is predictable (about 8 bytes per double).
x <- numeric(1e6)
format(object.size(x), units = "auto")
#> [1] "7.6 Mb"
y <- x # shares the memory of x, but object.size() cannot see that
format(object.size(list(x, y)), units = "auto")
#> [1] "15.3 Mb"
Source: R documentation: object.size
A double vector of one million elements takes about 8 million bytes, which format() renders in the legacy units that R
uses (1 Mb is 1024 squared bytes). The second size is a warning sign, not a measurement: object.size() does not detect
that the two list elements share one vector, so it counts it twice, as the help page notes. Real sharing is visible with
tracemem() and with profiling (Performance,
Profiling and Benchmarking). The R FAQ also explains why top may keep showing a large process after rm() and gc():
the operating system may not take back all the memory R has released
(R FAQ 7.42).
Errors and warnings a converted programmer meets
Most first-week errors in R are consequences of the design above. The messages below were produced by the script, with
try(silent = TRUE) capturing what the console would have printed, so the text is the real text of R 4.6.1.
show_error <- function(expr) {
cat(try(expr, silent = TRUE))
}
show_warning <- function(expr) {
tryCatch(
expr,
warning = \(w) {
cat("Warning message:\nIn ", deparse(conditionCall(w)), " : ",
conditionMessage(w), "\n",
sep = ""
)
}
)
}
x <- c(10, 20, 30)
show_error(x[[0L]])
#> Error in x[[0L]] :
#> attempt to select less than one element in integerOneIndex
show_error(x[[4L]])
#> Error in x[[4L]] : subscript out of bounds
show_error(if (c(TRUE, FALSE)) "yes")
#> Error in if (c(TRUE, FALSE)) "yes" : the condition has length > 1
show_error(c(TRUE, TRUE) && TRUE)
#> Error in c(TRUE, TRUE) && TRUE : 'length = 2' in coercion to 'logical(1)'
show_error(if (NA) "yes")
#> Error in if (NA) "yes" : missing value where TRUE/FALSE needed
show_error(if (logical(0L)) "yes")
#> Error in if (logical(0L)) "yes" : argument is of length zero
show_error(sd[1L])
#> Error in sd[1L] : object of type 'closure' is not subsettable
show_error("1" + 1)
#> Error in "1" + 1 : non-numeric argument to binary operator
show_warning(1:3 + 1:2)
#> Warning message:
#> In 1:3 + 1:2 : longer object length is not a multiple of shorter object length
tryCatch(as.integer("12a"), warning = \(w) conditionMessage(w))
#> [1] "NAs introduced by coercion"
show_warning(sqrt(-1))
#> Warning message:
#> In sqrt(-1) : NaNs produced
Source: R documentation: Control flow
| Message | What happened | Since |
|---|---|---|
|
|
always an error |
|
|
error since R 4.2.0 (a warning before) |
|
|
warning in R 4.2.0, error since R 4.3.0 |
|
|
always an error |
|
|
always an error |
|
|
always an error |
|
|
always an error |
|
|
always an error |
|
A warning from recycling: |
always a warning; R-devel (4.7.0, not yet released) also warns for a non-multiple logical subscript |
|
A warning from |
always a warning |
|
A warning from |
always a warning |
|
A variable was never defined, or is out of scope. The console prints |
always an error; R-devel adds a condition class for it |
The if and && rows are the most visible change for people who learned R from older material. Until R 4.1 an if on a
length-greater-than-one condition warned and used the first element; the NEWS entry for R 4.2.0 turned it into an error
and made &&/|| with a longer operand a warning; R 4.3.0 turned that into an error with the "length = 4 in coercion"
message
(NEWS: Changes in R 4.2.0 and R 4.3.0). See
Control Flow and
Conditions and Error Handling for the machinery.
Pitfalls from the R FAQ
The R FAQ’s chapter 7 collects questions that bite newcomers. The ones below follow directly from the design choices described earlier.
Floating point and integers
FAQ 7.31 explains that only integers and fractions whose denominator is a power of two are exactly representable in R’s
numeric type, so two floating-point numbers are not reliably equal unless the same algorithm computed them.
all.equal() compares with a tolerance, and isTRUE() turns its result into a single logical.
1 == 1L is TRUE but identical(1, 1L) is FALSE because the types differ, and the L suffix is what makes an
integer literal.
# FAQ 7.31: most decimals are not exactly representable.
0.1 + 0.2 == 0.3
#> [1] FALSE
0.1 + 0.2 - 0.3
#> [1] 5.551115e-17
isTRUE(all.equal(0.1 + 0.2, 0.3))
#> [1] TRUE
print(0.1 + 0.2, digits = 17L)
#> [1] 0.30000000000000004
# 1 and 1L are equal but not identical.
1 == 1L
#> [1] TRUE
identical(1, 1L)
#> [1] FALSE
typeof(1)
#> [1] "double"
typeof(1L)
#> [1] "integer"
typeof(1:3)
#> [1] "integer"
typeof(5L / 2L)
#> [1] "double"
# NA is a missing value, NULL is the absence of a value.
length(NA)
#> [1] 1
length(NULL)
#> [1] 0
c(1, NULL, 3)
#> [1] 1 3
c(1, NA, 3)
#> [1] 1 NA 3
NA == NA # nolint: equals_na_linter.
#> [1] NA
NULL == 1
#> logical(0)
is.na(NA)
#> [1] TRUE
is.null(NULL)
#> [1] TRUE
sum(c(1, NA, 3))
#> [1] NA
sum(c(1, NA, 3), na.rm = TRUE)
#> [1] 4
NA and NULL look alike to newcomers but are opposites. NA is a value that is missing, so length(NA) is 1, it
propagates through arithmetic, and NA == NA is NA. NULL is the empty object: length(NULL) is 0, c(1, NULL, 3)
silently drops it, and NULL == 1 is logical(0), which makes an if fail with "argument is of length zero". See
Missing and Special Values.
Dimensions, NULL in lists, strings and factors
# FAQ 7.5: subsetting drops dimensions unless told not to.
m <- matrix(1:6, nrow = 2L)
m[2L, ]
#> [1] 2 4 6
m[2L, , drop = FALSE]
#> [,1] [,2] [,3]
#> [1,] 2 4 6
# FAQ 7.1: assigning NULL to a list element removes it.
shelf <- list(a = 1, b = 2, c = 3)
shelf["b"] <- list(NULL)
str(shelf)
#> List of 3
#> $ a: num 1
#> $ b: NULL
#> $ c: num 3
shelf[["a"]] <- NULL
names(shelf)
#> [1] "b" "c"
# FAQ 7.21: keep related values in a list instead of assign()/get().
results <- lapply(1:3, \(i) i^2)
names(results) <- paste0("result", 1:3)
results[["result2"]]
#> [1] 4
# FAQ 7.10: a factor stores integer codes.
f <- factor(c("10", "20", "10"))
as.numeric(f)
#> [1] 1 2 1
as.numeric(as.character(f))
#> [1] 10 20 10
-
FAQ 7.5: subsetting one row or column returns a vector; add
drop = FALSEto keep the matrix. The FAQ recommendsdrop = FALSEdefensively in code, because a function that works for many rows can break when only one is selected (R FAQ 7.5). -
FAQ 7.1:
x[i] <- NULLandx[[i]] <- NULLremove the component, as they do in no other language; to store aNULLwritex[i] <- list(NULL)(R FAQ 7.1). -
FAQ 7.21: to turn a string into a variable,
get()andassign()exist, but a named list is almost always better, andlapply()replaces the loop overresult1,result2, … (R FAQ 7.21). -
FAQ 7.10: a factor stores integer codes, so
as.numeric(f)gives the codes, not the labels. Useas.numeric(as.character(f))(R FAQ 7.10). -
FAQ 7.14: a syntactic name consists of letters, digits, dot and underscore and starts with a letter or a dot not followed by a digit, and reserved words are not syntactic names; any other name has to be quoted, with backquotes in code (R FAQ 7.14).
The page Idioms and Common Pitfalls collects the whole chapter.
Where older tutorials differ
A large part of the R material on the web predates R 4.0 and shows habits that are now discouraged or invalid. When a tutorial and this reference disagree, the version in the table is the one that explains why.
| Older tutorial | Current practice | Why | Version |
|---|---|---|---|
|
Always write |
|
always |
|
|
|
always |
Mixing |
|
|
always |
|
|
|
always |
|
|
|
always |
|
Strings stay character vectors; ask for factors explicitly |
|
R 4.0.0 |
Anonymous functions as |
|
shorter, equivalent |
R 4.1.0 |
|
Native pipe |
the base pipe needs no package, and rewrites the call at parse time |
R 4.1.0 (with |
|
Collapse with |
the old behaviour silently used the first element |
errors in R 4.2.0 and 4.3.0 |
|
|
|
R 4.4.0 |
Source: R documentation: data.frame
# TRUE and FALSE are reserved words, so they cannot be reassigned.
tryCatch(
eval(parse(text = "TRUE <- 0")),
error = \(e) conditionMessage(e)
)
#> [1] "invalid (do_set) left-hand side to assignment"
# 1:length(x) misbehaves on an empty vector; seq_along() does not.
titles <- character(0L)
1:length(titles) # nolint: seq_linter.
#> [1] 1 0
seq_along(titles)
#> integer(0)
for (i in seq_along(titles)) {
cat("never printed", i, "\n")
}
# sapply() guesses the return type; vapply() declares it.
sapply(titles, nchar)
#> named list()
vapply(titles, nchar, integer(1L))
#> named integer(0)
sapply(list(), length)
#> list()
vapply(list(), length, integer(1L))
#> integer(0)
# Strings stay strings since R 4.0.0.
df <- data.frame(genre = c("Poetry", "Mystery"))
class(df$genre)
#> [1] "character"
class(data.frame(genre = c("Poetry", "Mystery"), stringsAsFactors = TRUE)$genre)
#> [1] "factor"
# = also assigns at the top level, but inside a call it names an argument.
y = 6 # nolint: assignment_linter.
mean(x = c(1, 2, 3))
#> [1] 2
exists("x")
#> [1] FALSE
mean(x <- c(1, 2, 3))
#> [1] 2
exists("x")
#> [1] TRUE
The fragment below is not a runnable script; it shows what a leftover T does in the older style. With T still bound to
TRUE the call works. After an unrelated line assigns T, the same call silently changes meaning, because na.rm = 0 is
treated as FALSE. With the explicit TRUE, the problem cannot occur, which is why the FAQ tells writers to always spell
the constants out.
mean(c(1, NA, 3), na.rm = T)
#> [1] 2
T <- 0
mean(c(1, NA, 3), na.rm = T)
#> [1] NA
Source: R FAQ 3.3.3: Others
The page What’s New and Migrating Older Code
collects every change by release, from stringsAsFactors in 4.0.0 to the upcoming features of R-devel.
Where to go next
-
Atomic Vectors and Types and Subsetting and Indexing for vectors, no scalars and one-based indexing.
-
Scoping, Environments and Closures and Lazy Evaluation and Promises for the evaluation model.
-
Memory and Copy Semantics for what copy-on-modify costs.
-
S3 Classes and Generics, S4 Classes and Methods, Reference Classes and R6 and S7 Object System for the object systems.
-
Idioms and Common Pitfalls for the FAQ chapter 7 in full.
For the same question asked in other languages of this site, see the Ruby page on design philosophy and Fortran for Programmers of Other Languages, which has a similar structure.
References
-
R FAQ: 2.1 What is R, 3 R and S, 7.1, 7.5, 7.10, 7.12, 7.14, 7.21, 7.31, 7.42
-
The R Language Definition: Objects, Vectors, Promise objects, Environments, Function calls, Operators, Recycling rules, Lexical environment, Argument evaluation
-
An Introduction to R: Vector arithmetic, Index vectors
-
Writing R Extensions: Tracing copies of an object
-
R NEWS: Changes in R 4.0.0 to 4.4.0