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 local() idiom depend on this.

Model formulae (R FAQ 3.3.2)

To regress on a power you insulate it with I(): lm(y ~ I(x^3)), not lm(y ~ x^3). na.action defaults to "na.omit", and y ~ x + 0 is an alternative to y ~ x - 1.

T and F (R FAQ 3.3.3)

They are ordinary variables that happen to hold TRUE and FALSE, not reserved words as in S, so a user can overwrite them. The FAQ’s advice is to always write TRUE and FALSE.

Removing list elements (R FAQ 3.3.3)

x[i] <- NULL and x[[i]] <- NULL remove elements in R but are a no-op in S. Use x[i] <- list(NULL) to store a NULL.

The default environment of ls() (R FAQ 3.3.3)

ls() lists the current environment under R and the global one (.GlobalEnv) under S, so inside a function it lists the function’s locals.

Replacement functions (R FAQ 3.3.3)

The right-hand-side argument of a replacement function must be named value in R; S used the last argument whatever its name.

The for index (R FAQ 3.3.3)

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, .RData and .First()/.Last() found on the search path, not through functions in an S .Data directory.

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 function class<- 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.

flowchart LR subgraph step1["After y <- x: two names, one vector"] x1(["x"]) --> v1["c(1, 2, 3)"] y1(["y"]) --> v1 end subgraph step2["After y[1] <- 99: the vector is copied"] x2(["x"]) --> v2["c(1, 2, 3)"] y2(["y"]) --> v3["c(99, 2, 3)"] end step1 --> step2

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

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

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"

<<- 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 class attribute, and a generic function such as print(), format() or summary() dispatches on the class of its first argument by calling UseMethod(). Methods are plain functions named generic.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 the book() 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 methods package, derived from the Green Book. Classes are declared with setClass() with typed slots and validity checks, generics with setGeneric() and methods with setMethod(), 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 methods package. Objects are mutable environments with fields and methods, created with setRefClass(), 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 methods package. 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

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

0

1

not applicable

0

1; x[0] is an empty vector

Assignment

x = 1

x = 1;

SET @x = 1 (dialect)

int x = 1;, var x = 1;

x <- 1 (= also works at top level)

Boolean literals

True, False

true, false

TRUE, FALSE

true, false

TRUE, FALSE (never T and F)

Missing value

None, nan, pd.NA

NaN, []

NULL

null

NA (a typed missing value), NULL (no object), NaN

Comparing with a missing value

nan == nan is False

NaN == NaN is 0

NULL = NULL is unknown

null == null is true

NA == NA is NA

Length

len(x)

numel(x)

COUNT(*)

x.length, x.Length, list.size()

length(x), nrow(df)

Vector arithmetic

a + b (NumPy broadcasting)

a + b, a .* b

an expression evaluated per row

a loop or a stream

a + b, a * b (always element-wise, with recycling); %*% for the matrix product

Filter rows

df[df.pages > 300]

T(T.pages > 300, :)

WHERE pages > 300

Where(b => b.Pages > 300) (LINQ), filter() (streams)

subset(books, pages > 300), books[which(books$pages > 300), ]

Group and summarise

df.groupby("genre")["pages"].mean()

groupsummary(T, "genre", "mean", "pages")

GROUP BY genre

GroupBy(...), Collectors.groupingBy

tapply(), aggregate(); dplyr group_by() with summarise()

Join

df.merge(other, on="isbn")

join(A, B)

JOIN ... ON

Join(...)

merge(a, b, by = "isbn")

Define a function

def f(x):

function y = f(x)

CREATE FUNCTION

int f(int x) { ... }

f <- function(x) x + 1; the last expression is the value

Anonymous function

lambda x: x + 1

@(x) x + 1

none

x -> x + 1 (Java), x => x + 1 (C#)

\(x) x + 1 (R 4.1.0)

Join strings

a + b

[a b], strcat(a, b)

CONCAT(a, b)

a + b

paste0(a, b), paste(a, b, sep = " "); + on strings is an error

Integer division

7 // 2

floor(7 / 2)

7 DIV 2 (dialect)

7 / 2 on integers

7 %/% 2

Remainder

7 % 2

mod(7, 2)

MOD(7, 2)

7 % 2

7 %% 2; the result has the sign of the divisor, as in Python

Power

2 ** 10

2 ^ 10

POWER(2, 10)

Math.pow(2, 10), Math.Pow(2, 10)

2^10 (** is accepted as a synonym)

Loop

for i in range(n):

for i = 1:n

set-based, no loop

for (int i = 0; i < n; i++)

for (i in seq_len(n)); prefer a vector operation or vapply()

Call a method

obj.method(x)

method(obj, x)

none

obj.method(x)

method(obj, x) for S3 and S4 generics; obj$method(x) for Reference Classes and R6

Import

import numpy as np

import pkg.*, the path

schema.table

import a.b.C;, using X;

library(pkg) attaches a package; pkg::fn() does not

Print

print(x)

disp(x)

SELECT x

System.out.println(x), Console.WriteLine(x)

typing x (autoprint), print(x), cat(), message()

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

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 DataFrame and 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] becomes df[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 None that is also falsy: NA and NULL are different, and if (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 Rscript or source(); there is no path-based function discovery by file name as in MATLAB. Functions are defined with <- in a script or package.

  • MATLAB’s end index has no counterpart; use length(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.frame is the table, $ or [[ selects a column, and subset(), merge() and aggregate() correspond to WHERE, JOIN and GROUP BY. R is not declarative: the data is in memory and the operations run in the order you write them.

  • NULL in SQL is closest to NA in R, not to NULL. NA propagates (NA + 1 is NA), and NA == NA is NA, like NULL = NULL in 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, and for (i in 1:length(x)) is a classic trap when x is empty.

  • Exceptions are conditions, handled with tryCatch() and withCallingHandlers(); 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 parallel package (forking with mclapply(), which is not available on Windows unless mc.cores = 1, or socket clusters with makeCluster()), 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, graphics and stats packages at start-up (the list can be changed with the R_DEFAULT_PACKAGES environment variable, see the Startup help page), restores .RData if present and sources the profile files, all before the first line of your script. Large packages add to this when attached. Rscript --vanilla skips 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)

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"

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
Message What happened Since

attempt to select less than one element in integerOneIndex

x[[0]] or x[[0L]]. In 1-based indexing, position 0 does not exist for [[. With a double index, such as x[[0]], the message ends in get1index <real> instead.

always an error

the condition has length > 1

if or while was given a logical vector of length greater than one. Python and Java programmers hit it by comparing a vector.

error since R 4.2.0 (a warning before)

'length = 2' in coercion to 'logical(1)'

&& or || was given a vector. Use & and | for element-wise logic, all() or any() to collapse.

warning in R 4.2.0, error since R 4.3.0

missing value where TRUE/FALSE needed

if (NA). A comparison with a missing value produced NA.

always an error

argument is of length zero

if (logical(0)), often from comparing with NULL or an empty result.

always an error

object of type 'closure' is not subsettable

sd[1] or mean$x. A name was used as a vector but refers to a function (here sd). Common after forgetting to define a variable called data or df.

always an error

subscript out of bounds

x[[4]] past the end of a list or vector ([ returns NA instead).

always an error

non-numeric argument to binary operator

"1" + 1. R does not coerce strings in arithmetic, and + is not string concatenation.

always an error

longer object length is not a multiple of shorter object length

A warning from recycling: 1:3 + 1:2.

always a warning; R-devel (4.7.0, not yet released) also warns for a non-multiple logical subscript

NAs introduced by coercion

A warning from as.integer("12a") and as.numeric().

always a warning

NaNs produced

A warning from sqrt(-1), log(-1) and other functions outside their domain.

always a warning

object 'x' not found

A variable was never defined, or is out of scope. The console prints Error: object 'x' not found.

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 = FALSE to keep the matrix. The FAQ recommends drop = FALSE defensively 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] <- NULL and x[[i]] <- NULL remove the component, as they do in no other language; to store a NULL write x[i] <- list(NULL) (R FAQ 7.1).

  • FAQ 7.21: to turn a string into a variable, get() and assign() exist, but a named list is almost always better, and lapply() replaces the loop over result1, result2, …​ (R FAQ 7.21).

  • FAQ 7.10: a factor stores integer codes, so as.numeric(f) gives the codes, not the labels. Use as.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

T and F for logical values

Always write TRUE and FALSE

T and F are variables that anyone can overwrite, so mean(x, na.rm = T) can silently change meaning (R FAQ 3.3.3)

always

attach(df) to use column names bare

with(df, expr), subset(), or df$col

attach() puts a copy of the data frame on the search path: an assignment to a bare column name creates a masking variable in the workspace and the data frame is unchanged (R-intro 6.3.2)

always

Mixing = and <- for assignment

<- for assignment, = for arguments

= is accepted as assignment at statement level, but inside a call it names an argument, so mean(x = v) does not create x. The default lintr linter prefers <-

always

for (i in 1:length(x))

seq_along(x) or seq_len(n)

1:0 is c(1, 0), so the loop body runs with i = 1 and i = 0 when x is empty

always

sapply() everywhere

vapply() with a declared return type

sapply() returns a list when the input is empty and guesses the shape otherwise

always

stringsAsFactors = TRUE to read text

Strings stay character vectors; ask for factors explicitly

data.frame() and read.csv() default to stringsAsFactors = FALSE

R 4.0.0

Anonymous functions as function(x) only

\(x) shorthand

shorter, equivalent

R 4.1.0

%>% from magrittr for every chain

Native pipe |>

the base pipe needs no package, and rewrites the call at parse time

R 4.1.0 (with _ placeholder from 4.2.0)

if on a vector, && on a vector

Collapse with all(), any(), isTRUE()

the old behaviour silently used the first element

errors in R 4.2.0 and 4.3.0

is.atomic(NULL) is TRUE

FALSE

NULL is no longer classified as atomic

R 4.4.0

# 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

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

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