суббота, 21 ноября 2015 г.

Феерическая расстановка точек над нестандартным вычислением в R

Феерическая расстановка точек над нестандартным вычислением в R
Попробуем разобраться с такой непростой темой, как нестандартное вычисление (NSE - non-standard evaluation) в R. Ярким примером его использования является пакет dplyr (см. также перевод виньетки): благодаря NSE мы можем, например, работать с именами столбцов в таблицах как с переменными. dplyr использует альтернативную реализацию из пакета lazyeval. Ниже рассматривается базовая реализация в R, изучив которую, можно без проблем понять и альтернативную. За основу взята глава Non-standard evaluation из книги Hadley Wickham-а “Advanced R”.

1. Функции quote(), substitute() и eval()

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

1.1. Использование quote(), substitute() и eval() в глобальном окружении

В глобальном окружении функции quote() и substitute() ведут себя одинаково:
# При передаче в качестве аргумента какого-либо значения 
# возвращается само это значение:
quote(5)
# [1] 5
str(quote(5))
# num 5

# При передаче в качестве аргумента переменной возвращается имя этой 
# переменной в виде объекта, имеющего тип "symbol" и класс "name":
quote(a)
# a
str(quote(a))
# symbol a
typeof(quote(a))
# [1] "symbol"
class(quote(a))
# [1] "name"

# При передаче в качестве аргумента выражения возвращается это 
# выражение в виде объекта, имеющего тип "language" и класс "call":
quote(mean(1:100)+a)
# mean(1:100) + a
str(quote(mean(1:100)+a))
# language mean(1:100) + a
typeof(quote(mean(1:100)+a))
# [1] "language"
class(quote(mean(1:100)+a))
# [1] "call"
# При передаче в качестве аргумента какого-либо значения 
# возвращается само это значение:
substitute(5)
# [1] 5
str(substitute(5))
# num 5

# При передаче в качестве аргумента переменной возвращается имя этой 
# переменной в виде объекта, имеющего тип "symbol" и класс "name":
substitute(a)
# a
str(substitute(a))
# symbol a
typeof(substitute(a))
# [1] "symbol"
class(substitute(a))
# [1] "name"

# При передаче в качестве аргумента выражения возвращается это 
# выражение в виде объекта, имеющего тип "language" и класс "call":
substitute(mean(1:100)+a)
# mean(1:100) + a
str(substitute(mean(1:100)+a))
# language mean(1:100) + a
typeof(substitute(mean(1:100)+a))
# [1] "language"
class(substitute(mean(1:100)+a))
# [1] "call"
Функция eval() по своему действию противоположна двух предыдущим и всегда возвращает результат вычисления переданного ей выражения:
a <- 1
eval(1)
# [1] 1
eval(a)
# [1] 1
eval(quote(a))
# [1] 1
eval(mean(1:100)+a)
# [1] 51.5
eval(quote(mean(1:100)+a))
# [1] 51.5
Подробнее про типы и классы см. в Classes Corresponding to Basic Data Types.

1.2. Использование quote(), substitute() и eval() внутри другой функции

Функция quote() внутри другой функции работает так же, как и в глобальном окружении, т.е. всегда возвращает объекты типа “symbol” или “language”.
Функция eval() - тоже, но тут уместно рассмотреть ее дополнительные аргументы envir и enclos. envir задает окружение, в котором выполняется выражение, причем в роли окружения может выступать список или таблица data.frame как частный случай списка. enclos задает окружение, в котором происходит поиск объектов, не найденных в envir, но только если в envir в качестве окружения задан список или таблица. Если в envir задано “настоящее” окружение, то enclos игнорируется.
sample_df <- data.frame(a = 1:5, b = 5:1, c = c(5, 3, 1, 4, 1))
d <- 11:20 # переменная, которой нет в sample_df

fun <- function(df, var) {
    var_call <- substitute(var)
    eval(var_call, df, parent.frame())
    # parent.frame() - окружение, из которого вызывается fun()
}

fun(df = sample_df, var = a)
# [1] 1 2 3 4 5
# a из sample_df
fun(df = sample_df, var = d)
# [1] 11 12 13 14 15 16 17 18 19 20
# d из глобального окружения, в котором происходит вызов fun()
При вызове substitute() внутри другой функции в возвращаемом выражении происходит замена переменных на их значения, если это возможно. Вначале ищутся переменные в теле функции; для переменных, которые не определены в теле функции, но чьи имена соответствуют именам формальных аргументов, берутся соответствующие значения аргументов; переменные, определенные вне функции, а также нигде не определенные, остаются в виде своих имен:
b <- 2 # значение b недоступно из fun()
fun <- function(x, y) {
    a <- 1 # значение a берется из тела функции
    y <- 100 # значение y берется из тела функции, а не из аргумента
    substitute(x+y+a+b+d) # значение x берется из аргумента
}
fun(3, 4)
# 3 + 100 + 1 + b + d

2. Представление аргументов функций: объекты типа “promise”

Функция substitute(), а также нестандартное вычисление и “ленивые” вычисления в целом работают благодаря специальному объекту типа “promise”, который создается при вызове функции, имеет имя формального аргумента и содержит в себе выражение, переданное в качестве значения этого аргумента. Формальное описание можно найти в R Language Definition - 2.1.8 Promise objects. Рассмотрим простейший пример в виде функции с двумя формальными аргументами:
fun <- function(x, y) {
    # При вызове функции fun(x = a, y = b + 1) создается:
    # объект "promise" с именем "x", содержащий
    # невычисленное выражение (имя переменной) "a";
    # объект "promise" с именем "y", содержащий
    # невычисленное выражение "b + 1".
    # Не происходит никакого сопоставления имен и проверки, 
    # существует ли вообще где-то "a" или "b + 1".
    
    z <- 23*24^2 # вычисления без обращений к
    z <- sqrt(z) # значениям аргументов
    
    substitute(x) # вернет "symbol" "a" (без кавычек), т.е. имя переменной
    
    quote(x) # вернет "symbol" "x" (без кавычек), т.е. имя аргумента
    # (что, очевидно, будет бесполезным для функции)
    
    x <- x^2 # только здесь происходит подстановка значения "a"
    # (разумеется, если переменная "a" будет найдена)
    
    y <- 100 # y определен в теле функции
    # (выражение "b + 1" останется невычисленным)
    
    return(c(x, y))
}

a <- 2
rm(b) # удаляем переменную b
fun (x = a, y = b)
# [1]   1 100
# Отсутствие переменной b на работе функции не сказывается.
Пример чуть посложнее (сокращенный вариант из “Advanced R”):
sample_df <- data.frame(a = 1:5, b = 5:1, c = c(5, 3, 1, 4, 1))
y <- 4
x <- 4
condition <- 4
condition_call <- 4

subset2 <- function(x, condition) {
  condition_call <- substitute(condition)
  r <- eval(condition_call, x)
  x[r, ]
}

subset2(sample_df, a == 4)
#>   a b c
#> 4 4 2 4

subset2(sample_df, a == y)
#>   a b c
#> 4 4 2 4

subset2(sample_df, a == x)
#>       a  b  c
#> 1     1  5  5
#> 2     2  4  3
#> 3     3  3  1
#> 4     4  2  4
#> 5     5  1  1
#> NA   NA NA NA
#> NA.1 NA NA NA

subset2(sample_df, a == condition)
#> Error in eval(expr, envir, enclos): object 'a' not found
Неожиданный результат вызова subset2(sample_df, a == x) объясняется просто: функция находит x внутри себя, потому что x является именем формального аргумента:
subset2(x = sample_df, a == x)
# Внутри происходит следующее:
condition_call <- substitute(a == x)
condition_call
# a == x
r <- eval(condition_call, x)
r
#         a     b     c
# [1,] TRUE FALSE FALSE
# [2,] TRUE FALSE FALSE
# [3,] TRUE  TRUE FALSE
# [4,] TRUE FALSE  TRUE
# [5,] TRUE FALSE FALSE

# Значение переменной "a" было найдено внутри "x" (x = sample_df,
# т.е. таблица sample_df выступает в роли окружения).
# Значение "x" не найдено внутри "x", но найдено в вышестоящем окружении:
# при вызове функции был создан объект типа "promise" с именем "x", содержащий
# имя переменной sample_df, а sample_df у нас есть в глобальном окружении.
# Таким образом, было выполнено сравнение x$a == x.
Теперь по аналогии легче разобраться с subset2(sample_df, a == condition), особенно если переписать вызов как subset2(x = sample_df, condition = a == condition).
Здесь функция eval() пытается выполнить выражение a == condition (оно присвоено переменной condition_call) в окружении x, а x у нас является таблицой sample_df. Т.е. ожидается, что a и condition будут именами столбцов.
Столбец с именем “a” будет найден, а столбец с именем “condition” - нет, поэтому поиск соответствия этому имени переменной будет продолжен в вышестоящем окружении, а именно - в окружении выполнения функции subset2().
Уточнение: окружение выполнения является “виртуальным” окружением, которое создается в момент вызова функции и обычно исчезает после окончания ее работы (но сохраняется при использовании замыканий, когда внутри функции создаются новые функции).
Итак, в этом “виртуальном” окружении есть объекты типа “promise”, подобные описанным выше, с именами “x” (соответствует присвоению x <- sample_df) и “condition” (соответствует присвоению condition <- a == condition).
Тогда при обращении к condition происходит попытка вычисления выражения a == condition, но выражение a == condition в данном окружении (“снаружи” таблицы sample_df) вычислить нельзя, поскольку там нет переменной а. Нет такой переменной и в еще более вышестоящих окружениях, в данном случае - в глобальном. Поэтому получаем ошибку.
Если создать в глобальном окружении переменную a (a <- 4), то функция будет работать, но возращать совсем не то, что нужно.

Вызов функции с нестандартным вычислением внутри другой функции

Вновь возьмем упрощенный пример из “Advanced R” и попробуем понять, почему первый вариант со вложенными функциями не работает, а второй - работает.
Первый вариант:
subset2 <- function(x, condition) {
    condition_call <- substitute(condition)
    r <- eval(condition_call, x, parent.frame())
    x[r, ]
}
subscramble <- function(x, condition) {
    subset2(x, condition)
}
subscramble(sample_df, a >= 4)
# Error in eval(expr, envir, enclos) : object 'a' not found
Второй вариант:
subset2_q <- function(x, condition) {
    r <- eval(condition, x, parent.frame())
    x[r, ]
}
subscramble <- function(x, condition) {
    condition <- substitute(condition)
    subset2_q(x, condition)
}
subscramble(sample_df, a >= 4)
#   a b c
# 4 4 2 4
# 5 5 1 1
Подробнейшее объяснение было найдено на StackOverflow в ответе Josh O’Brien-а: Non standard evaluation from another function in R. На мой взгляд, его сообщение является образцом исследования технических и скрытых от глаз пользователей (да и программистов, в большинстве случаев) особенностей функционирования языка. Ну а я попробую пересказать простыми словами.
В первом варианте мы вызываем функцию в виде subscramble(x = sample_df, condition = a >= 4). До того, как происходит вызов внутренней функции subset2(), создаются объекты типа “promise”: x (содержит невычисленное выражение sample_df) и condition (содержит невычисленное выражение a >= 4).
Именно эти объекты передаются внутренней функции в качестве значений: когда вызывается внутренняя функция subset2(), ее вызов выглядит как subset2(x = x, condition = condition), а не как subset2(x = sample_df, condition = a >= 4).
Это эквивалентно передаче функции позиционных аргументов, т.е. функция subset2() ищет в окружении, откуда она вызывается (и в вышестоящих), объекты с именами x и condition - и находит там оба наши объекта типа “promise”. Тогда substitute(condition) вернет просто condition, а не a >= 4. Переменной condition внутри sample_df, разумеется, нет, и начинается поиск соответствия в вышестоящем окружении.
Такое соответствие будет найдено внутри функции subscramble() (в “виртуальном” окружении, созданном в момент вызова функции). Дальше наконец-то происходит попытка вычисления выражений, хранящихся в объектах типа “promise”, и возникает закономерная ошибка, ведь a >= 4 вычислить снаружи функции subset2() невозможно, там нет переменной a.
Во втором варианте внутренняя функция subset2() имеет доступ к объекту сondition типа “language”, т.е. непосредственно к выражению, которое нужно вычислить; substitute(condition) здесь выполняется как substitute(a >= 4) и возвращает a >= 4. Фактически происходит нечто похожее:
# Используется создание новой переменной в теле внешней функции.
fun <- function(x, y) x+y
fun1 <- function(x, y) {
    y <- y^2 
    fun(x, y)
}
fun1(1,2)
# [1] 5

5 комментариев:

  1. Спасибо за статью, очень полезный разбор. Может быть, можно бы ещё про get() добавить хоть немножко?

    ОтветитьУдалить
    Ответы
    1. Пожалуйста :)
      Не знаю, чего бы такого интересного я мог бы написать про эту функцию. Мне кажется, встроенной справки достаточно.

      Удалить
    2. ну, я имел в виду не "интересного", а вообще про место и использование этой функции в контексте "нестандартных вычислений" - мне казалось, что для полноты картины она так же важна, как и остальные разобранные здесь функции.

      Удалить
  2. Если не сложно текст программ печатать в стиле bold/ На сером фоне светло серый текст сложновато читать.

    ОтветитьУдалить
    Ответы
    1. Пока что не придумал более удачной цветовой схемы, чем стандартная; для чтения можете взять исходники в репозитории https://github.com/statist-bhfz/r_exp/tree/master/nse

      Удалить