Основные типы и структуры данных

Векторы

Атомарные типы

Вектор — простой набор элементов одного типа данных. В R для создания векторов используется шесть базовых (их иногда ещё называют атомарными) типов. Подробнее о базовых типах см. в главе ООП:

  • character — используется для текстовых значений. Любой набор знаков, заключённый в кавычки, в R воспринимается как текстовое значение. Два нюанса — знак #, заключённый в кавычки, перестаёт быть символом, обозначающим комментарий. А обратный слэш \ всё так же остается знаком экранирования, что позволяет использовать в текстовых строках такие специальные символы, как, например, \n.
  • complex — используется при работе с комплексными числами.
  • numeric (или double) — используется для числовых значений (с плавающей точкой).
  • integer — целочисленные значения (по форме хранения, а не математическое множество целых).
  • logical — логические значения TRUE/FALSE.
  • raw — достаточно редко используемый тип данных, побайтовая запись в шестнадцатеричном формате.

Особенности числовых типов

Тип numeric в R хранится в виде чисел с плавающей запятой и двойной точностью. Числа с одинарной точностью можно симулировать с помощью функции single() или as.single(), приписав numeric-объекту атрибут Csingle. Однако это не настоящий хранимый формат, и его использование имеет смысл только при обращении к некоторым функциям, написанным на C или Fortran. Хранение чисел в формате с плавающей запятой может приводить к некоторым казусам, когда требуется каким-то образом обрабатывать числовые значения, которые не умещаются в 32 бита. Максимально возможное целочисленное значение можно определить с помощью .Machine$integer.max, всё, что больше него, будет преобразовано в double с плавающей запятой.

Значения, превышающие .Machine$integer.max, достаточно редко используются как числовые. Чаще всего это просто длинные идентификаторы (пользователей, например), поэтому есть смысл их импортировать и хранить в виде строковых значений, иначе можно натолкнуться на ошибки округлений при работе с числами с плавающей точкой. Пример некорректной обработки целочисленных значений, превышающих .Machine$integer.max:

15280137481015627194 - 15280137481015627776
## [1] 0

Создание векторов

Векторы создаются с помощью функции c() (c — от combine). Например:

x <- c(1, 3, 5)
print(x)
## [1] 1 3 5

В редких случаях используются векторы с именованными элементами (аналоги словарей в некоторых других языках программирования). В повседневной практике встречается достаточно редко. Как правило, именованные векторы можно получить в результате функции sapply() и некоторых подобных:

x <- c(var1 = 12, var2 = 23, var3 = 34)
print(x)
## var1 var2 var3 
##   12   23   34

Простую неименованную последовательность атомарного типа можно задать также другими командами, наиболее часто используемые из них:

  • seq() — создание последовательности значений в указанном интервале с заданным шагом (seq от sequence). Например:
x <- seq(from = 10, to = 2, by = -2)
print(x)
## [1] 10  8  6  4  2

Первые два аргумента задают первое и последнее значения последовательности, а аргумент by — шаг последовательности (по умолчанию равен единице). Знак - для значения аргумента by маркирует, что последовательность убывающая.

  • Выражение : — аналог функции seq(), используется для создания последовательности целых чисел, где последующее отличается от предыдущего на 1. Так, выражение 5:1 тождественно выражению seq(5, 1, -1).

  • rep() — повтор какого-либо элемента заданное число раз. В качестве элемента может выступать практически любой объект R (в зависимости от осмысленности в целом подобного выражения). Аргументы times или each задают, какое количество раз повторять весь объект или каждый элемент объекта соответственно:

x <- c('a', 'b', 'c')
rep(x, times = 2)
## [1] "a" "b" "c" "a" "b" "c"
rep(x, each = 2)
## [1] "a" "a" "b" "b" "c" "c"

Если значения аргументов задавать позиционно, то первый аргумент — это итерируемый объект, а второй аргумент — аргумент times:

rep(x, 2)
## [1] "a" "b" "c" "a" "b" "c"
  • sample() — создание последовательности случайно выбранных значений из какого-то заданного вектора значений. Первый аргумент задаёт выборку, из которой необходимо извлечь подвыборку, второй аргумент (size) определяет объём извлекаемой выборки. Аргумент replace определяет, выборка извлекается с возвращением элементов или нет:
sample(x = 1:5, size = 5, replace = FALSE)
## [1] 1 2 5 4 3
sample(x = 1:5, size = 5, replace = TRUE)
## [1] 3 5 1 3 3
  • vector() — функция создания вектора значений определённого типа (обычно атомарного, но можно создавать и более сложные объекты, например, списки), где первый аргумент задаёт тип, а второй — длину вектора (количество элементов в последовательности). По умолчанию созданная последовательность заполнена значениями FALSE, если вектор логический, 0 и '', если вектор числовой или текстовый соответственно. Одно из самых простых применений подобной конструкции — предварительное выделение памяти:
x <- vector('character', 5)
x[2] <- 'second'
print(x)
## [1] ""       "second" ""       ""       ""
  • raw(), logical(), integer(), numeric(), complex(), character() — функции создания векторов, аналогичные vector(), для каждого из атомарных типов отдельно. В качестве аргумента эти функции принимают требуемую длину создаваемого вектора и создают вектор значений по умолчанию (00 для raw-векторов, FALSE для логических векторов, 0 — для числовых и "" — для текстовых):
character(length = 5)
## [1] "" "" "" "" ""

Вектор единичной длины

Простое присвоение x <- 'alpha' также будет созданием вектора. В R в принципе отсутствуют скалярные значения и любое единичное значение/константа — это всё тот же вектор, просто единичной длины.

x <- 'alpha'
length(x)
## [1] 1

Неявные преобразования

В R используется достаточно простая система автоматического преобразования атомарных типов по логике от самого строгого к самому гибкому, то есть по цепочке logical -> integer -> numeric -> complex -> character (тип raw не включается в эту систему). Если попытаться создать вектор из последовательности элементов разных атомарных типов, то все элементы будут преобразованы к самому общему типу в заданной последовательности:

print(c(TRUE, 1, 2, FALSE, FALSE))
## [1] 1 1 2 0 0
print(c(1, 3.6, 2, 5.1))
## [1] 1.0 3.6 2.0 5.1
print(c('a', 3.6, 'b', 5.1))
## [1] "a"   "3.6" "b"   "5.1"
print(c(TRUE, 3.6, 'b', 2))
## [1] "TRUE" "3.6"  "b"    "2"

Второе следствие неявных преобразований — возможность использовать конструкции вида sum(5 == 5), потому что логический результат сравнения (TRUE) будет преобразован в целое числовое значение (1, так как TRUE преобразовывается в 1, а FALSE — в 0). Следовательно, можно вычислить количество верных утверждений. Подобные процедуры крайне часто используются в ситуациях, когда надо посчитать количество определённых значений в векторе или в колонке таблицы. Вообще во избежание неожиданных результатов рекомендуется остерегаться неявных преобразований и контролировать типы самостоятельно.

Массивы

В R, помимо подобных одномерных массивов (векторов), также можно создавать и многомерные массивы — двумерные массивы называются матрицами, многомерные соответственно многомерными массивами или многомерными матрицами. Все элементы массивов также должны быть элементами одного типа.

Матрицы создаются с помощью функции matrix(), где первым аргументом задаётся набор значений, а вторым и третьим аргументами — количество строк и столбцов в матрице. Заполнение ячеек массива по умолчанию происходит по колонкам: сначала заполняется построчно первая колонка, потом вторая (если есть) и т. д. Название строк и колонок при желании можно задать с помощью аргумента dimnames в виде списка векторов названий:

x <- matrix(data = 1:4, 
            nrow = 2, ncol = 2, 
            dimnames = list(c('r1', 'r2'), c('c1', 'c2')))
print(x)
##    c1 c2
## r1  1  3
## r2  2  4

Многомерные массивы задаются аналогичным образом, только вместо аргументов количества строк и колонок используется аргумент dim (от dimension), задающий размерность массива. При dim = c(2, 2) будет создан двумерный массив 2*2, матрица, как и в примере выше.

x <- array(data = 1:4,
           dim = c(2, 2),
           dimnames = list(c('r1', 'r2'), c('c1', 'c2')))

Если создать и вывести на печать трёхмерный массив, который можно назвать стопкой двумерных массивов, то будут выведены матрицы для каждого элемента третьего измерения, для n-мерных массивов аналогично. Например, ниже мы создаём и выводим трёхмерный массив размером 2*2*2. В результате мы получаем две матрицы — [, , v1] и [, , v2]:

array(data = 1:8, 
      dim = c(2, 2, 2), 
      dimnames = list(c('r1', 'r2'), c('c1', 'c2'), c('v1', 'v2')))
## , , v1
## 
##    c1 c2
## r1  1  3
## r2  2  4
## 
## , , v2
## 
##    c1 c2
## r1  5  7
## r2  6  8

Следует помнить, что массивы — это всё те же векторы (наборы элементов атомарных типов), просто с атрибутом размерности:

x <- 1:10
dim(x) <- c(2, 5)
print(x)
##      [,1] [,2] [,3] [,4] [,5]
## [1,]    1    3    5    7    9
## [2,]    2    4    6    8   10

Списки

Списки — это коллекции объектов любых типов (в том числе и другие списки) любого уровня вложенности. По сути, списки представляют собой наборы ссылок на другие объекты. Для создания списка используется команда list(), где в аргументах через запятую перечисляются все элементы, которые необходимо включить в список. Например:

x <- seq(from = 13, to = 0, by = -3)
y <- rep(x = 'c', times = 3)
z <- TRUE

my_list <- list(x, y, z)
print(my_list)
## [[1]]
## [1] 13 10  7  4  1
## 
## [[2]]
## [1] "c" "c" "c"
## 
## [[3]]
## [1] TRUE

Помимо простого объединения объектов в списки с последующим вызовом элементов по номеру в списке, можно создавать именованные списки, где каждый элемент будет иметь заданное название. Например:

x <- seq(from = 13, to = 0, by = -3)
y <- rep(x = 'c', times = 3)
z <- TRUE

my_list <- list(seq_example = x, 
                rep_example = y, 
                atomic_example = z)
print(my_list)
## $seq_example
## [1] 13 10  7  4  1
## 
## $rep_example
## [1] "c" "c" "c"
## 
## $atomic_example
## [1] TRUE

Таблицы (датафреймы)

data.frame — базовый для R формат, который используется для представления таблиц. В R поддерживается классическая концепция таблиц, характерная для социальных наук и data science, когда по строкам расположены наблюдения, а колонки образуют пространство признаков этих наблюдений. На уровне структуры data.frame — это всё те же списки, в которых могут храниться разные по типу объекты, однако с требованием равенства длины объектов. Важно: все значения одной колонки могут быть только одного типа, а не как в Excel, OpenOffice или другом табличном процессоре.

Таблицы можно создать с помощью функции data.frame(), где аналогично созданию списка через запятую перечисляются векторы, значения которых будут представлены в виде колонок (использование списков для формирования колонок возможно, но встречается реже и несколько сложнее в последующем использовании). Так как все колонки должны быть одной длины, то и длины векторов соответственно должны быть одинаковы. Возможны и другие варианты создания data.frame-объектов путем преобразования из других объектов с помощью функции as.data.frame().

# создаем data.frame-объект
x <- seq(from = 10, to = 0, by = -2)
y <- rep(x = 'a', times = 6)
z <- sample(x = 1:20, size = 6, replace = FALSE)

dataset <- data.frame(column1 = x,
                      column2 = y,
                      column3 = z)

str(dataset)
## 'data.frame':    6 obs. of  3 variables:
##  $ column1: num  10 8 6 4 2 0
##  $ column2: chr  "a" "a" "a" "a" ...
##  $ column3: int  12 14 5 19 3 11

data.frame — это базовый класс для таблиц в R, однако в настоящее время используется, как правило, один из двух других подходов — tibble (из экосистемы tidyverse) или data.table. Оба эти класса наследуются от data.frame, поэтому в целом поддерживают базовые принципы и идеи data.frame.

Факторы

Факторы (factor) — специфичный для R формат, в других языках аналогом будет тип enum (enumerated type). Это упорядоченный вектор номинальных или интервальных значений, аналог категориальных шкал. При этом объект класса factor содержит как собственно значения, так и уровни, по которым эти значения упорядочены. Обычно факторы используют для упорядочивания номинальных, категориальных или текстовых данных, а также в различных статистических моделях и некоторых графиках.

Создадим вектор значений low, medium, high и преобразуем его в фактор, где high — значение с самым низким рангом, а low — с самым высоким:

quality <- c("low", "high", "medium", "high", "low", "medium", "high")
quality_f <- factor(quality, levels = c("high", "medium", "low"))
print(quality_f)
## [1] low    high   medium high   low    medium high  
## Levels: high medium low

При выводе на печать, помимо собственно значений фактора, выводятся также его уровни в порядке возрастания (если не задавать уровни при конвертации в фактор, то уровни задаются в соответствии с алфавитным порядком, лексикографически). Следовательно, если отсортировать фактор, то значения будут выведены в соответствии с уровнями, в то время как простой вектор значений, на основе которых был создан фактор, будет отсортирован в алфавитном порядке:

sort(quality)
## [1] "high"   "high"   "high"   "low"    "low"    "medium" "medium"
sort(quality_f)
## [1] high   high   high   medium medium low    low   
## Levels: high medium low

Факторы можно конвертировать в числа, в результате будет получен вектор значений, в котором значения фактора преобразованы в номер его уровня (ранг категориальной переменной):

print(quality_f)
## [1] low    high   medium high   low    medium high  
## Levels: high medium low
as.numeric(quality_f)
## [1] 3 1 2 1 3 2 1

Есть два нюанса при работе с факторами, которые могут существенно затруднить работу. Первый — уровни фактора хранятся в атрибутах объекта. А второй — до недавнего времени (до релиза R 4.0.0) большинство базовых функций импорта автоматически, если не указано обратное, конвертировали текстовые значения в факторы. В результате легко можно было попасть в ситуацию, когда из базы данных импортируется несколько миллионов уникальных строк, но при импорте они конвертируются в факторы. А так как уровни фактора хранятся в атрибутах, то хранение таблицы удорожается в два раза, а при выборке из датасета десятка строк объект с этими десятью строками будет содержать ещё и все значения уровней из исходного массива.

# удалим все значения medium и law, но в уровнях они останутся
quality_f <- quality_f[quality_f == 'high']
print(quality_f)
## [1] high high high
## Levels: high medium low

Для предотвращения подобных ситуаций, если вдруг возникла необходимость работать с версией R ниже 4.0.0, рекомендуется либо в аргументах функций импорта явно указывать stringsAsFactors = TRUE (если аргумент stringsAsFactors есть, конечно же), либо в опциях сессии или в .RProfile проверять и указывать options(stringsAsFactors = FALSE).

Дата и время

Работа с датами и временем традиционно вызывает много сложностей, в частности, из-за формата представления данных или из-за необходимости учитывать временную зону. В R можно работать как просто с датами, так и с более сложными сущностями в виде даты и времени. Самое базовое правило при работе с датами и временем — стараться выбирать наиболее простой и общий формат. Как правило, это формат даты.

Date

Класс Date используется при работе с датами. Date-объект не содержит информацию о времени или временной зоне и представляет собой количество дней с 1 января 1970 года (1970-01-01), представленное в человекочитаемом формате дат. В базовом R получить объект класса Date можно преобразованием других объектов с помощью функции as.Date():

# преобразование текстового значения
x <- as.Date('2018-04-01')
class(x)
## [1] "Date"
# преобразование количества дней
x <- as.Date(17622, origin = '1970-01-01')
class(x)
## [1] "Date"
# преобразование POSIXct-значения
x <- as.POSIXct(1522540800, origin = '1970-01-01')
x <- as.Date(x)
class(x)
## [1] "Date"

Origin — это дата и время, от которых отсчитывается количество дней или секунд до настоящего момента. По умолчанию это таймстамп 1970-01-01 00:00:00, который служит точкой отсчета в Unix-системе (и прочих POSIX-совместимых операционных системах) описания моментов времени:

# снимаем атрибут класса Date, в результате получаем количество дней
unclass(as.Date('2018-04-01'))
## [1] 17622
# вычитаем полученное количество из исходной даты
as.Date('2018-04-01') - 17622
## [1] "1970-01-01"

Наиболее часто используемый формат представления дат — это формат yyyy-mm-dd, однако нередко встречаются иные варианты, особенно если импортируются данные из табличных процессоров типа Excel или Google Spreadsheet, например, mm/dd/yy или dd.mm.yyyy. В таких случаях в функции as.Date() необходимо указывать используемый формат представления дат (подробнее в манипуляциях с датами):

x <- as.Date('4/1/18', format = '%m/%d/%y')
str(x)
##  Date[1:1], format: "2018-04-01"

Так как формат Date — это в неявном виде количество дней, то при неаккуратном использовании можно столкнуться с ситуацией, когда объект даты конвертируется и используется в числовом виде. Например, если добавить объект даты к числовому вектору, то он будет представлен в виде числового значения:

# добавляем к числовому вектору
c(1, 4, 9, as.Date('2018-04-01'))
## [1]     1     4     9 17622

POSIXct, POSIXlt

Для работы с данными даты и времени используют функции as.POSIXct() и as.POSIXlt() (cl от calendar time, lt от local time). Обе функции работают с данными в Unix-формате (количество секунд с 1970-01-01 00:00:00) и в качестве дополнительного аргумента требуют указать временную зону:

# создаём as.POSIXct-объект
x_ct <- as.POSIXct(1522571987, origin = '1970-01-01', tz = 'UTC')
str(x_ct)
##  POSIXct[1:1], format: "2018-04-01 08:39:47"
# создаём as.POSIXlt-объект
x_lt <- as.POSIXlt(1522571987, origin = '1970-01-01', tz = 'UTC')
str(x_lt)
##  POSIXlt[1:1], format: "2018-04-01 08:39:47"

Основное различие между функциями as.POSIXct() и as.POSIXlt() — первая аналогично Date принимает информацию о временной метке в числовом виде и представляет результат в текстовом виде, а также содержит метку временной зоны в атрибуте tz. Вторая возвращает именованный список параметров даты (день, месяц, год, час и т. д.), который также представляет собой таймстамп в указанной временной зоне:

# снимаем атрибут POSIXct-класса с объекта
unclass(x_ct)
## [1] 1522571987
## attr(,"tzone")
## [1] "UTC"
# снимаем POSIXlt-класс с объекта и разворачиваем список
unlist(unclass(x_lt))
##   sec   min  hour  mday   mon  year  wday  yday isdst 
##    47    39     8     1     3   118     0    90     0

Следует учитывать, что время в Unix-формате — это всего лишь длина временного интервала в секундах и информацию о временной зоне она не содержит. Функции as.POSIXct() и as.POSIXlt() по умолчанию используют временную зону локали, если не указано обратное. Как следствие, могут быть ситуации, когда в базе данных хранятся данные времени в Unix-формате (как правило, это так) и время на сервере выставлено по Гринвичу (GMT/UTC), а при импорте на локальный компьютер дата корректируется с учётом локальной временной зоны (несмотря на то, что используется одно и то же числовое представление даты в Unix-формате):

psql_server=> select to_timestamp(1522540800) as server_timestamp;
      server_timestamp      
------------------------
 2018-04-01 00:00:00+00
# этот же таймстамп без указания временной зоны локали
as.POSIXct(1522540800, origin = '1970-01-01')
## [1] "2018-04-01 03:00:00 MSK"

Формулы

На данный момент формулы — специальный объект, который описывает схему взаимодействия зависимой переменной (таргета) и независимых переменных (предикторов), а также взаимодействие предикторов между собой. Формульная нотация для записи регрессионной модели предложена Роджерсом и Вилкинсоном в 1973 году (G. Wilkinson и Rogers 1973), позднее её включили в язык S (Chambers и Hastie 1991) и, следовательно, она попала в R. Простой пример формулы, описывающий одну зависимую переменную и две независимые:

y ~ x + z 

Слева от знака тильды находится зависимая переменная (LHS, left-hand side). В стандартном использовании слева только одна переменная, если же необходимо указать несколько зависимых и/или их взаимодействие, то следует воспользоваться соответствующими пакетами. Справа от тильды, right-hand side (RHS), — две независимые переменные. Знак + в формулах означает независимое влияние переменных. По сути, формульная нотация — это своего рода DSL внутри R, так как в нотации привычные знаки и операторы имеют собственное значение:

  • + используется для того, чтобы перечислить переменные-предикторы;

  • - используется, чтобы указать, влияние какого фактора необходимо исключить из модели;

  • : передаёт в качестве предиктора модели только взаимодействие факторов (аналогично x1 * x2 - x1 - x2);

  • * задаёт как изолированное влияние каждого фактора, так и их взаимодействие (аналогично x1 + x2 + x1:x2);

  • | используется в тех ситуациях, когда необходимо ввести в модель случайные эффекты;

  • -1 или 0 в формуле указывает, что из модели следует исключить случайный член (intercept);

  • . позволяет указывать в качестве предикторов все колонки в таблице, помимо зависимой переменной.

Легче всего показать разницу в формулах с помощью простейшей линейной регрессии. Создадим несколько переменных, из которых разными методами сконструируем зависимую переменную:

# создаём три предиктора
x1 <- rnorm(1000, 0, 1)
x2 <- rnorm(1000, 3, 5)
x3 <- rnorm(1000, -2, 2)

Создадим зависимую исходя из возможных вариантов взаимодействия предикторов. Следует помнить, что знак * в обычном выражении и в формуле интерпретируется по-разному.

y <- 3 * x1 + 2 * x2 + 6 * (x2 * x3) + 7

Зададим и применим модель (: означает взаимодействие факторов). Модель должна вычислить заданные нами коэффициенты, которые мы использовали при создании вектора y:

lm(y ~ x1 + x2 + x2:x3)
## 
## Call:
## lm(formula = y ~ x1 + x2 + x2:x3)
## 
## Coefficients:
## (Intercept)           x1           x2        x2:x3  
##           7            3            2            6

Аналогичный результат можно получить, если задать модель не через :, а через * и -:

lm(y ~ x1 + x2 + x2*x3 - x3)
## 
## Call:
## lm(formula = y ~ x1 + x2 + x2 * x3 - x3)
## 
## Coefficients:
## (Intercept)           x1           x2        x2:x3  
##           7            3            2            6

Так как поведение операторов в формулах иное, чем в обычных выражениях, то при необходимости использовать в формуле собственно сложение или другие операции, следует воспользоваться функцией I() (от Inhibit):

y <- 4 * (x1 * x2)

# неправильная формула, так как * задаёт взаимодействие и независимое влияние, а не перемножение
lm(y ~ x1 * x2)
## 
## Call:
## lm(formula = y ~ x1 * x2)
## 
## Coefficients:
## (Intercept)           x1           x2        x1:x2  
##   7.177e-17    1.863e-15    1.140e-17    4.000e+00
# правильная формула, I() позволяет воспринимать * как умножение
lm(y ~ I(x1 * x2))
## 
## Call:
## lm(formula = y ~ I(x1 * x2))
## 
## Coefficients:
## (Intercept)   I(x1 * x2)  
##   2.247e-16    4.000e+00

Формулу можно создать несколькими путями: просто создать текстовую переменную либо же воспользоваться функцией formula(), в конце концов, можно просто написать my_formula = y ~ x. Результаты потом можно использовать в соответствующих функциях. Текстовое описание модели в формуле позволяет создавать генераторы моделей (например, при переборе комбинаций предикторов в машинном обучении). Пример случайной генерации моделей, сочетающих два независимых предиктора и взаимодействие ещё двух предикторов:

# создаём вектор текстовых значений имён предикторов
predictors <- c('x1', 'x2', 'x3', 'x4')
for (i in 1:5) {
  tmp <- paste(
    # выбираем независимые факторы
    paste(sample(predictors, 2), collapse = ' + '),
    # выбираем взаимодействующие факторы
    paste(sample(predictors, 2), collapse = ':'),
    sep = ' + ')
  tmp <- paste('y ~', tmp)
  print(tmp)
}
## [1] "y ~ x2 + x3 + x1:x2"
## [1] "y ~ x4 + x3 + x2:x1"
## [1] "y ~ x2 + x4 + x4:x3"
## [1] "y ~ x4 + x3 + x1:x2"
## [1] "y ~ x3 + x4 + x4:x2"

Формулы обычно используются в линейных моделях, случайных лесах и многих других функциях, в которых требуется описание модели взаимосвязи нескольких переменных. Так как тильда в формулах обозначает, что ничего не надо вычислять и надо просто запомнить выражение и его окружение, возможны и другие варианты использования формул. Например, помимо соотношения таргета и предикторов, тильда и формульная нотация может использоваться для определения формы взаимодействия колонок при решейпинге таблиц. Подобное использование ~ встречается и в других пакетах:

  • пакеты lattice, plotly используют тильду для указания переменных, по которым строится график;

  • в пакете ggplot2 тильда используется для создания отдельных панелей графиков (фасет) и их взаимного расположения по осям;

  • пакет dplyr и ряд других пакетов tidyverse используют ~ в некоторых функциях, где важно работать с окружением объектов, например, dplyr::case_when().

Выражения и вызовы функции

Выражения в узком смысле объект класса expression, который содержит запись операции (операций) на R, чтобы можно было выполнить записанную операцию не сразу, а в необходимом месте (так называемое unevaluated expression, подробнее см. Выполнение выражений). В базовом R выражения можно создать с помощью функции expression() по аналогии с созданием вектора. При использовании таких выражений необходимо их явно выполнять с помощью функции eval():

# создаём выражение из двух операций
my_exp <- expression(x <- 6, x ^ 2)
print(my_exp)
## expression(x <- 6, x^2)
# выполняем выражение
eval(my_exp)
## [1] 36

Второй вариант создания выражения — это парсинг текстовой строки, в котором также записана операция на языке R. Для подобного парсинга используется функция parse(), где строковая запись передаётся в аргумент text (подробнее см. Парсинг выражений из строки). Также в функции parse() можно указывать кодировку записи, что может быть полезно, когда скрипт был написан и сохранен в одной кодировке, а вызывается в рабочей среде с другой кодировкой. В примере ниже мы хотим записать две операции в одну строку, поэтому их необходимо разделить ;:

# записываем операции в строковый объект
my_exp <- "x <- 6; x ^ 2"
class(my_exp)
## [1] "character"
# парсим строковую запись операций
my_exp <- parse(text = my_exp)
class(my_exp)
## [1] "expression"
# выполняем результат парсинга
eval(my_exp)
## [1] 36

Функция call() создаёт объект, несколько похожий на объект класса expression, только вместо записи какой-либо операции содержит запись вызова функции. Первым аргументом в call() передаётся название функции, последующими аргументами — значения, которые должны быть переданы в аргументы этой функции:

# создаём вызов функции возведения в степень
my_call <- call('^', 3, 2)
class(my_call)
## [1] "call"
# выполняем объект вызова функции
eval(my_call)
## [1] 9

На практике нередко встречается другой вариант работы с вызовами функции, когда с помощью do.call() вызывают функцию со списком аргументов и сразу получают результат. Самый наглядный пример — это вызов функции merge():

df1 <- data.frame(var1 = letters[1:3], df1_col = sample(5, 3))
df2 <- data.frame(var1 = letters[1:3], df2_col = sample(month.abb, 3))
df3 <- data.frame(var1 = letters[1:3], df3_col = rnorm(3))
list_df <- list(df1, df2, df3)

do.call('merge', c(list_df[1:2], list(by = 'var1', all.x = TRUE)))
##   var1 df1_col df2_col
## 1    a       3     May
## 2    b       1     Dec
## 3    c       2     Sep

NULL, NA, Inf, NaN

Ряд специальных значений или объектов используется в ситуациях, когда значения или объекта нет, значение недоступно или некорректно:

  • NAnot available. Ситуация, когда значение пропущено или не определено. Например, для какой-то строки в колонке таблицы нет значения или текстовое значение пытаются преобразовать в числовое или логическое. Следует учитывать, что NA — это в первую очередь логическое значение, маркирующее, что значение пропущено и будет ошибкой приравнять его к нулю, например, или к NULL. Когда известно, какого типа значения вектора, можно использовать специализированные вариации NANA_character_, NA_integer_, NA_real_ или NA_complex_.
  • NULL — пустой (null) объект с собственным одноимённым типом. Объект NULL не может быть использован одновременно с другими объектами, например, при создании последовательности, а присвоение какому-то элементу объекта значения NULL удаляет этот элемент. Чаще всего это встречается при работе со списками или таблицами, так как присвоение элементу значения NULL удаляет из списка ссылку на заданный объект.
  • NaN, Infnot a number и infinite соответственно. Используются при работе с числовыми значениями и появляются при некорректных с математической точки зрения операциях — 0/0 и 1/0 соответственно. Значение Inf может быть как положительным, так и отрицательным.

Окружения

Окружения — это хеш-таблицы, то есть пары ключ-значение. Значениями обычно являются объявленные функции, данные, другие окружения. Окружения похожи на именованные списки, но сходство только внешнее, так как окружения, в отличие от списков, не имеют порядок объектов (соответственно, объекты окружения нельзя вызвать по их номеру, как в листе) и ансамбль окружений может быть представлен в виде дерева — практически у любого окружения есть родительское окружение.

За пределами функций использование окружений достаточно ограничено. Как правило, это либо ситуативные модификации функций пакетов, либо эзотерические практики организации объектов в интерактивном рабочем пространстве. Однако у окружений есть одна нечасто используемая характеристика: можно использовать окружения именно как хеш-таблицы, в которых операции поиска, вставки или удаления по ключам значений (названию) осуществляются в среднем быстрее, чем по обычным таблицам.

Например, создадим два объекта — таблицу, в которой в одной колонке будет вектор имён — текстовых значений, а во второй — числовое значение (0L). Второй объект — хешированное окружение с таким же количеством объектов, как строк в таблице. Каждый объект этого окружения имеет название, соответствующее значениям в первой колонке таблицы, а значения объектов — значениям из второй колонки таблицы (0L для всех объектов, как и для всех строк таблицы).

# создаём вектор уникальных "имён"
vector_letters <- paste(
  sample(letters, 10e4, replace = TRUE),
  sample(10e4, replace = FALSE), 
  sep = '_')

# создаём таблицу
simple_df <- data.frame(var1 = vector_letters, var2 = 0L)

# создаём хешированное окружение
hash_env <- new.env(hash = TRUE)
for (i in vector_letters) hash_env[[i]] <- 0L
ls(hash_env)[1:3]
## [1] "a_10012" "a_10031" "a_1004"

Как можно увидеть по бенчмаркам, поиск значения определённого объекта в хешированном окружении существенно быстрее поиска соответствующих значений в таблице:

item <- vector_letters[999]
microbenchmark::microbenchmark(
  df_search = simple_df[simple_df$var1 == item, 'var2'],
  hash_env_search = hash_env[[item]], 
  times = 1000, 
  unit = 'ms'
)
## Unit: milliseconds
##             expr      min       lq        mean    median       uq       max
##        df_search 1.137430 1.319790 1.664632644 1.4144280 1.578067 13.486095
##  hash_env_search 0.000156 0.000264 0.001897494 0.0017455 0.003175  0.013404
##  neval cld
##   1000   b
##   1000  a

Функции

Функции — такие же объекты R, как и списки, окружения и т. д. Функции состоят из трёх основных элементов: списка аргументов, тела функции и её окружения. Объявим простейшую функцию:

new_fun <- function(x) {
  # комментарий в функции
  x <- sqrt(x)
  x
}

new_fun(3)
## [1] 1.732051

Список аргументов является одной из форм списков, унаследованных от S, pairlist, и может быть вызван с помощью функции formals() или в более удобной для чтения форме — функции args(). Так как список аргументов — это отдельный объект, то при желании можно извлечь названия аргументов и/или модифицировать список аргументов, например, добавить аргументу значение по умолчанию. Стоит отметить, что изменение аргументов подобным образом — крайне редкая практика, притом потенциально опасная, так как снижает прозрачность и понятность кода.

# смотрим аргументы функции
args(new_fun)
## function (x) 
## NULL
formals(new_fun)
## $x
# смотрим класс списка аргументов
class(formals(new_fun))
## [1] "pairlist"
# добавляем аргументу значение по умолчанию
formals(new_fun) <- list(x = 9)

# вызываем функцию с обновленным списком аргументов
new_fun()
## [1] 3

Тело функции — код, который находится в фигурных скобках и описывает все действия, что выполняются при вызове функции. Код тела функции можно вызвать с помощью функции body(), однако возвращён будет только исполняемый код, т. е. комментарии будут опущены. В том случае, когда хочется посмотреть весь код функции, можно просто вывести на печать объект функции. Впрочем, оба метода не работают для примитивных функций (см. ниже про .Primitive). Как и список аргументов, тело функции можно модифицировать, передав ему новое значение в виде выражения (см. также NSE), и точно так же по возможности следует избегать подобных практик:

# смотрим тело функции
body(new_fun)
## {
##     x <- sqrt(x)
##     x
## }
# выводим на печать всю функцию
print(new_fun)
## function (x = 9) 
## {
##     x <- sqrt(x)
##     x
## }
# модифицируем тело функции и вызываем обновленную функцию
body(new_fun) <- expression(x ^ 3)
new_fun()
## [1] 729

Третий элемент функций — это рабочее окружение функции (другими словами, область видимости функции, подробнее см. Окружения функций). В окружениях функций возможно как локальное, так и глобальное присвоение, есть механизмы поиска используемых объектов по дереву родительских окружений, если объект не найден в текущем окружении. Функция environment() возвращает, в зависимости от того, как и в какой момент вызвана, либо окружение, в котором объявлена функция, либо адрес объекта окружения функции в памяти. В последнем случае объект окружения можно сохранить и использовать, в том числе и в родительском окружении:

# объявляем функцию, в которой есть вывод на печать списка объектов и адрес окружения функции
new_fun2 <- function(x) {
  print(environment())
  print(ls())
  x <- sqrt(x)
  return(x)
}

# смотрим, в каком окружении объявлена функция
environment(new_fun2)
## <environment: R_GlobalEnv>
# смотрим, как работает вызов environment() в теле функции
new_fun2(3)
## <environment: 0x560a4c0668e0>
## [1] "x"
## [1] 1.732051

Стоит учесть, что ряд функций напрямую обращается к коду на C и не имеет кода на R. Такие функции называют примитивными, их относительно немного, и все они принадлежат базовому пакету (base). В силу того, что примитивные функции обращаются к C-коду напрямую, их поведение может иногда отличаться от прочих функций, написанных на R. В том числе эти функции возвращают NULL при попытке увидеть их pairlist-список аргументов (через formals()), тело и окружение функции. Пример примитивной функции:

print(sqrt)
## function (x)  .Primitive("sqrt")
args(sqrt)
## function (x) 
## NULL
formals(sqrt)
## NULL
body(sqrt)
## NULL
## NULL