<- function(x, p) {
pow <- x ^ p
power return(power)
}pow(3, 2)
[1] 9
Поздравляю, сейчас мы выйдем на качественно новый уровень владения R. Вместо того, чтобы пользоваться теми функциями, которые уже написали за нас, мы можем сами создавать свои функции! В этом нет ничего сложного.
Синтаксис создания функции внешне похож на создание циклов или условных конструкций. Мы пишем ключевое слово function
, в круглых скобках обозначаем переменные, с которыми собираемся что-то делать. Внутри фигурных скобок пишем выражения, которые будут выполняться при запуске функции. У функции есть свое собственное окружение (environment) — место, где хранятся переменные. Именно те объекты, которые мы передаем в скобочках, и будут в окружении, так же как и “обычные” переменные для нас в глобальном окружении. Это означает, что функция будет искать переменные в первую очередь среди объектов, которые переданы в круглых скобочках. С ними функция и будет работать. На выходе функция выдаст то, что вычисляется внутри функции return()
. Если return()
появляется в теле функции несколько раз, то до результат будет возвращаться из той функции return()
, до которой выполнение дошло первым.
<- function(x, p) {
pow <- x ^ p
power return(power)
}pow(3, 2)
[1] 9
Если функция проработала до конца, а функция return()
так и не встретилась, то возвращается последнее посчитанное значение.
<- function(x, p) {
pow ^ p
x
}pow(3, 2)
[1] 9
Если в последней строчке будет присвоение, то функция ничего не вернет обратно. Это очень распространенная ошибка: функция вроде бы работает правильно, но ничего не возвращает. Нужно писать так, как будто бы в последней строчке результат выполнения выводится в консоль.
<- function(x, p) {
pow <- x ^ p #Функция ничего не вернет, потому что в последней строчке присвоение!
power
}pow(3, 2) #ничего не возвращается из функции
Если функция небольшая, то ее можно записать в одну строчку без фигурных скобок.
<- function(x, p) x ^ p
pow pow(3, 2)
[1] 9
Мы можем оставить в функции параметры по умолчанию.
<- function(x, p = 2) x ^ p
pow pow(3)
[1] 9
pow(3, 3)
[1] 27
Лучший способ не бояться ошибок и предупреждений — научиться прописывать их самостоятельно в собственных функциях. Это позволит понять, что за текстом предупреждений и ошибок, которые у вас возникают, стоит забота разработчиков о пользователях, которые хотят максимально обезопасить нас от наших непродуманных действий.
Хорошо написанные функции не только выдают правильный результат на все возможные адекватные данные на входе, но и не дают получить правдоподобные результаты при неадекватных входных данных. Как вы уже знаете, если на входе у вас имеются пропущенные значения, то многие функции будут в ответ тоже выдавать пропущенные значения. И это вполне осознанное решение, которое позволяет избегать ситуаций вроде той, когда около одной пятой научных статей по генетике содержало ошибки в приложенных данных и замечать пропущенные значения на ранней стадии. Кроме того, можно проводить проверки на адекватность входящих данных (sanity check).
Разберем проверку на адекватность входящих данных на примере самодельной функции imt()
, которая выдает индекс массы тела, если на входе задать вес (аргумент weight =
) в килограммах и рост (аргумент height =
) в метрах.
<- function(weight, height) weight / height ^ 2 imt
Проверим, что функция работает верно:
<- c(60, 80, 120)
w <- c(1.6, 1.7, 1.8)
h imt(weight = w, height = h)
[1] 23.43750 27.68166 37.03704
Очень легко перепутать и написать рост в сантиметрах. Было бы здорово предупредить об этом пользователя, показав ему предупреждающее сообщение, если рост больше, чем, например, 3. Это можно сделать с помощью функции warning()
.
<- function(weight, height) {
imt if (any(height > 3)) warning("Рост в аргументе height больше 3: возможно, указан рост в сантиметрах, а не в метрах\n")
/ height ^ 2
weight
}imt(78, 167)
Warning in imt(78, 167): Рост в аргументе height больше 3: возможно, указан рост в сантиметрах, а не в метрах
[1] 0.002796802
В некоторых случаях ответ будет совершенно точно некорректным, хотя функция все посчитает и выдаст ответ, как будто так и надо. Например, если какой-то из аргументов функции imt()
будет меньше или равен 0. В этом случае нужно прописать проверку на это условие. Если это действительно так, то можно поступить еще строже: выдать пользователю ошибку.
<- function(weight, height) {
imt if (any(weight <= 0 | height <= 0)) stop("Индекс массы тела не может быть посчитан для отрицательных значений")
if (any(height > 3)) warning("Рост в аргументе height больше 3: возможно, указан рост в сантиметрах, а не в метрах\n")
/ height ^ 2
weight
}imt(-78, 167)
Error in imt(-78, 167): Индекс массы тела не может быть посчитан для отрицательных значений
Когда вы попробуете самостоятельно прописывать предупреждения и ошибки в функциях, то быстро поймете, что ошибки - это вовсе не обязательно результат того, что где-то что-то сломалось и нужно паниковать. Совсем даже наоборот, прописанная ошибка - чья-то забота о пользователях, которых пытаются максимально проинформировать о том, что и почему пошло не так.
Это естественно в начале работы с R (и вообще с программированием) избегать ошибок и пугаться их. Конечно, в самом начале обучения большая часть из них остается непонятной. Но постарайтесь понять текст ошибки, вспомнить в каких случаях у вас возникала похожая ошибка. Очень часто этого оказывается достаточно чтобы понять причину ошибки даже если вы только-только начали изучать R.
Ну а в дальнейшем я советую ознакомиться со средствами отладки кода в R для того, чтобы научиться справляться с ошибками в своем коде на более продвинутом уровне.
Когда стоит создавать функции? Существует “правило трех” — если у вас есть три куска очень похожего кода, то самое время превратить код в функцию. Это очень условное правило, но, действительно, стоит избегать копипастинга в коде. В этом случае очень легко ошибиться, а сам код становится нечитаемым.
Есть и другой подход к созданию функций: их стоит создавать не столько для того, чтобы использовать тот же код снова, сколько для абстрагирования от того, что происходит в отдельных строчках кода. Если несколько строчек кода были написаны для того, чтобы решить одну задачу, которой можно дать понятное название (например, подсчет какой-то особенной метрики, для которой нет готовой функции в R), то этот код стоит обернуть в функцию. Если функция работает корректно, то теперь не нужно думать над тем, что происходит внутри нее. Вы ее можете мысленно представить как операцию, которая имеет определенный вход и выход — как и встроенные функции в R.
Отсюда следует важный вывод, что хорошее название для функции — это очень важно. Очень, очень, очень важно.
Ранее мы убедились, что арифметические операторы — это тоже функции. На самом деле, практически все в R — это функции. Даже function
— это функция function()
. Даже скобочки (
, {
— это функции!
А сами функции — это объекты первого порядка в R. Это означает, что с функциями вы можете делать практически все то же самое, что и с другими объектами в R (векторами, датафреймами и т.д.). Небольшой пример, который может взорвать ваш мозг:
list(mean, min, `{`)
[[1]]
function (x, ...)
UseMethod("mean")
<bytecode: 0x135762aa0>
<environment: namespace:base>
[[2]]
function (..., na.rm = FALSE) .Primitive("min")
[[3]]
.Primitive("{")
Мы можем создать список из функций! Зачем — это другой вопрос, но ведь можем же! Например, создавать списки из функций может быть удобным для продвинутых операций в across()
в пакете {dplyr}
(см. Глава 11.3).
Еще можно создавать функции внутри функций1, использовать функции в качестве аргументов функций, сохранять функции как переменные. Пожалуй, самое важное из этого всего - это то, что функция может быть аргументом в функции. Не просто название функции как строковая переменная, не результат выполнения функции, а именно сама функция как объект! Это лежит в основе использования семейства функций apply()
, о которых пойдет речь далее, и многих фишек tidyverse.
apply()
apply()
для матрицСемейство? Да, их целое множество: apply()
, lapply()
,sapply()
, vapply()
,tapply()
,mapply()
, rapply()
… Ладно, не пугайтесь, всех их знать не придется. Обычно достаточно первых двух-трех. Проще всего пояснить как они работают на простой матрице с числами:
<- matrix(1:12, 3, 4)
A A
[,1] [,2] [,3] [,4]
[1,] 1 4 7 10
[2,] 2 5 8 11
[3,] 3 6 9 12
Теперь представим, что нам нужно посчитать что-нибудь (например, сумму) по каждой из строк. С помощью функции apply()
вы можете в буквальном смысле “применить” функцию к матрице или датафрейму. Синтаксис такой: apply(X, MARGIN, FUN, ...)
, где X
— данные, MARGIN
это 1
(для строк), 2
(для колонок), c(1,2)
для строк и колонок (т.е. для каждого элемента по отдельности), а FUN
— это функция, которую вы хотите применить! apply()
будет брать строки/колонки из X
в качестве первого аргумента для функции.
Давайте разберем на примере:
apply(A, 1, sum) #сумма по каждой строчке
[1] 22 26 30
apply(A, 2, sum) #сумма по каждой колонке
[1] 6 15 24 33
apply(A, c(1,2), sum) #кхм... сумма каждого элемента
[,1] [,2] [,3] [,4]
[1,] 1 4 7 10
[2,] 2 5 8 11
[3,] 3 6 9 12
Если же мы хотим прописать дополнительные аргументы для функции, то их можно перечислить через запятую после функции:
2, 2] <- NA
A[ A
[,1] [,2] [,3] [,4]
[1,] 1 4 7 10
[2,] 2 NA 8 11
[3,] 3 6 9 12
apply(A, 1, sum)
[1] 22 NA 30
apply(A, 1, sum, na.rm = TRUE)
[1] 22 21 30
Что делать, если мы хотим сделать что-то более сложное, чем просто применить одну функцию? А если функция принимает не первым, а вторым аргументом данные из матрицы? В этом случае нам помогут анонимные функции (anonymous function).
Анонимные функции - это функции, которые будут использоваться один раз и без названия.
Например, мы можем посчитать общее количество знаков по строкам и столбцам без называния этой функции:
<- matrix(c("Всем", "привет", "Я", "строковая", "матрица", "и", "такое", "тоже", "бывает"), nrow = 3)
B apply(B, 1, function(x) sum(nchar(x)))
[1] 18 17 8
apply(B, 2, function(x) sum(nchar(x)))
[1] 11 17 15
Начиная с R 4.1.0 (май 2021) можно использовать сокращенный вариант написания анонимных функций с \
вместо ключевого слова function
:
apply(B, 1, \(x) sum(nchar(x)))
[1] 18 17 8
apply(B, 2, \(x) sum(nchar(x)))
[1] 11 17 15
apply()
ОК, с apply()
разобрались. А что с остальными? Некоторые из функций семейства *apply()
еще проще и не требуют индексов, например, lapply()
(для применения к каждому элементу списка) и sapply()
- упрощенная версия lapply()
, которая пытается по возможности “упростить” результат до вектора или матрицы.
<- list(some = 1:10, list = letters)
some_list lapply(some_list, length)
$some
[1] 10
$list
[1] 26
sapply(some_list, length)
some list
10 26
Использование sapply()
на векторе приводит к тем же результатам, что и просто применить векторизованную функцию обычным способом.
sapply(1:10, sqrt)
[1] 1.000000 1.414214 1.732051 2.000000 2.236068 2.449490 2.645751 2.828427
[9] 3.000000 3.162278
sqrt(1:10)
[1] 1.000000 1.414214 1.732051 2.000000 2.236068 2.449490 2.645751 2.828427
[9] 3.000000 3.162278
Зачем вообще тогда нужен sapply()
, если мы можем просто применить векторизованную функцию? Ключевое слово здесь векторизованная функция. Если функция не векторизована, то sapply()
становится удобным вариантом для того, чтобы избежать итерирования с помощью циклов for
.
Можно применять функции lapply()
и sapply()
на датафреймах. Поскольку фактически датафрейм - это список из векторов одинаковой длины (см. Глава 4.4), то итерироваться эти функции будут по колонкам:
<- read.csv("https://raw.githubusercontent.com/Pozdniakov/tidy_stats/master/data/heroes_information.csv",
heroes na.strings = c("-", "-99"))
sapply(heroes, class)
X name Gender Eye.color Race Hair.color
"integer" "character" "character" "character" "character" "character"
Height Publisher Skin.color Alignment Weight
"numeric" "character" "character" "character" "integer"
Еще одна функция из семейства apply()
- функция replicate()
- самый простой способ повторить одну и ту же операцию много раз. Обычно эта функция используется при симуляции данных и моделировании. Например, давайте сделаем выборку из логнормального распределения (подробнее про распределения см. в Глава 17.2):
<- rlnorm(30)
samp hist(samp)
А теперь давайте сделаем 1000 таких выборок и из каждой возьмем среднее:
<- replicate(1000, mean(rlnorm(30)))
sampdist hist(sampdist)
Про функции для генерации случайных чисел и про визуализацию будет в следующих главах: Глава 17.2 и Глава 13 соответственно.
В заключение стоит сказать, что семейство функций apply()
— это очень сильное колдунство, но в tidyverse оно практически полностью перекрывается функциями из пакета {purrr}
, за исключением самого apply()
и некоторых других функций, которые работают с матрицами и массивами (tidyverse с ними принципиально не дружит). Впрочем, если вы поняли логику apply()
, то при желании вы легко сможете переключиться на альтернативы из пакета {purrr}
(см. Глава 11.4).
Функция, которая создает другие функции, называется фабрикой функций.↩︎