Що таке функціональне програмування?
Це велике питання, яке заслуговує на довгу відповідь (одну дає Wikipedia, іншу дає Advanced R).
Для наших цілей важливі такі ключові риси:
Однак R не є чистою функціональною мовою (на відміну, наприклад, від Haskell).
Розгляньмо R як прагматичну мову, яка (дедалі частіше) надає функціональні можливості там, де вони є чистим і простим способом виконати роботу. Зазвичай мета - аналіз даних, а програмування - лише засіб для досягнення цієї мети.
Використання конвеєрів |> для зʼєднання функцій стає дедалі поширенішим прийомом у багатьох мовах.
Коротко:
Деяким програмістам допомагає промовляти конвеєр як «then», коли вони переглядають код: виконати крок 1, а тоді крок 2...
Приклад нижче ілюструє синтаксис.
library(stringr)
"Monday-25-February" |>
str_split("-") |>
unlist() |> # [1] "Monday" "25" "February"
str_sub(1, 3) |> # [1] "Mon" "25" "Feb"
str_to_upper() |> # [1] "MON" "25" "FEB"
str_flatten(collapse = ", ")
#> [1] "MON, 25, FEB"
Цей тип конвеєра додали до Base R у версії 4.1.0, і його можна зустріти під назвою «нативний конвеєр».
Раніше конвеєри будували за допомогою %>% із пакета magrittr (назва - це каламбур для любителів мистецтва).
«Конвеєр magrittr» досі доступний, але нативні конвеєри позбавляють потреби в зовнішньому пакеті, а їхня реалізація в деяких деталях простіша.
Редактор RStudio (серед інших) використовує Ctrl-Shift-M як комбінацію клавіш, щоб додати конвеєр із потрібними пробілами.
apply
Раніше ми бачили, що багато функцій в R «векторизовані»: вони працюють з цілими векторами, і писати явні цикли не потрібно.
Функції, які використовують лише векторизовані функції, і самі, ймовірно, будуть автоматично векторизовані. Простий приклад:
triple <- function(x) return(3 * x)
triple(1:10)
[1] 3 6 9 12 15 18 21 24 27 30
Однак складніші функції можуть не приймати вектор на вході, наприклад, якщо вони містять цикли або блоки if-else.
Нам потрібен загальніший спосіб застосовувати функції до векторів (подібно до map() у деяких інших мовах).
Оскільки R має багато різноманітних структур даних у базовій мові, у ній є й ціла родина функцій *apply() для роботи з ними.
Технічніше кажучи, програмісти часто називають їх «функціями вищого порядку», а математики - «функціоналами», бо вони приймають функції як аргументи.
Поки що розгляньмо lapply() і sapply().
lapply()«List-apply» призначена для роботи зі списками на вході, але вектори за потреби непомітно зведуться до списків.
Результат - список, тож його, можливо, доведеться розгорнути, щоб отримати вектор:
h <- function(size) {
switch(size,
"small" = 6,
"medium" = 8,
"large" = 10
)
}
v <- c("small", "large", "medium")
h(v) # bad use of an unvectorized function
#> Error in switch(size, small = 6, medium = 8, large = 10) :
#> EXPR must be a length 1 vector
lapply(v, h) # output is a list
#> [[1]]
#> [1] 6
#> [[2]]
#> [1] 10
#> [[3]]
#> [1] 8
lapply(v, h) |> unlist() # output is a vector
#> [1] 6 10 8
Ті, хто вже знайомий із концепцією Switch, можуть помітити, що скалярний switch можна натомість замінити векторизованою функцією recode_values() із бібліотеки dplyr.
Векторизація - це добре, і сучасний R дає кілька способів її досягти.
library(dplyr)
h <- function(size) {
recode_values(size,
"small" ~ 6,
"medium" ~ 8,
"large" ~ 10
)
}
v <- c("small", "large", "medium")
h(v)
#> [1] 6 10 8
sapply()«Simplified-apply» робить щось подібне до lapply, але ще й намагається спростити повернене значення: наприклад, дати іменований вектор замість списку.
Залежно від контексту може знадобитися unname():
sapply(v, h)
small large medium
6 10 8
sapply(v, h) |> unname()
#> [1] 6 10 8
purrr
lapply і sapply прості, знайомі більшості користувачів R і досі досить популярні для векторів і простих списків.
У Base R є також кілька інших функцій родини apply:
vapply() схожа на sapply(), але має ще й обовʼязковий параметр, який задає формат вихідних даних.apply() для матриць і багатовимірних масивів.mapply() для кількох списків або векторів-аргументів, які обробляються паралельно.tapply() пояснити складніше, але вона працює з групуванням і нерівномірними масивами.vectorize() - досить загадкова функція, яка, схоже, є обгорткою для mapply().Досить уже!
Ключова мета Tidyverse - дати простішу й послідовнішу заміну цій плутанині.
Тепер є пакет purrr, який пропонує кілька додаткових можливостей, як-от map(), а також послідовніший дизайн і зрозуміліші повідомлення про помилки.
Цей пакет менше обмежений зворотною сумісністю, ніж base R, і значно наближає R до сучасного функціонального програмування, яке ми бачимо в інших мовах.
У purrr багато найрізноманітніших функцій, які нелегко описати коротко.
Ось ресурси, які допоможуть зорієнтуватися в бібліотеці:
Бібліотека purrr працює майже виключно з одновимірними даними: векторами та списками.
У пізнішій концепції ми побачимо, що бібліотека dplyr надає таку саму функціональність для двовимірних датафреймів.
map
Функція map() застосовує функцію до кожного елемента вхідних даних і завжди повертає список.
Поки що це звучить як lapply(), лише під новою назвою, можливо, з кращими повідомленнями про помилки.
Однак map() - лише один представник великої родини.
Кілька питань, про які варто подумати, обираючи функцію:
map() завжди повертає список, але якщо потрібен атомарний вектор певного типу, використаймо map_chr() для рядків тексту (англ. string), map_int() для цілих чисел тощо.i, як-от imap().map на map2 у назві, як-от map2_dbl() для двох входів (наприклад, значень і вагових коефіцієнтів, щоб обчислити зважене середнє)pmap для списку паралельних входів, як-от pmap_chr(). І так, паралелізм на рівні апаратного забезпечення можливий.map_if(), щоб застосувати фільтр за булевим значенням (англ. Boolean), map_at(), щоб задати критерій за позиційним індексом, head_while(), щоб обробляти вхід, доки якийсь елемент не перестане задовольняти предикат.modify() замість map або modify_tree() для рекурсивного застосування.Ці функції, коли це можливо, приймають вхідні дані першим аргументом, щоб добре працювати з конвеєрами.
До того ж purrr (свідомо) прискіпливіший за Base R. Якщо ми просимо щось конкретне, функції повернуть саме це або зупиняться з повідомленням про помилку.
Суворо, але це дуже ефективно зменшує кількість важковловимих помилок.
reduce
Функції map зазвичай повертають значення тієї самої довжини, що й вхідні дані.
Деякі інші функції зменшують розмірність даних, наприклад sum().
sum(1:100)
#> [1] 5050
У наведеному прикладі ми перетворюємо вектор довжиною 100 на ціле число довжиною 1. У майбутній концепції про матриці та масиви ми побачимо, що зменшення розмірності - загальніша ідея.
Функція sum() вбудована (як і багато інших статистичних функцій).
Однак нам потрібен спосіб застосовувати довільні функції зменшення розмірності до структури даних за допомогою якоїсь функції вищого порядку, аналогічної до map().
У багатьох інших мовах використовують назви функцій, як-от fold, foldl і foldr. В R відповідна функція - reduce(), яка відповідає добре відомому (ентузіастам алгоритмів) framework MapReduce.
Перший аргумент, як завжди, - це вхідні дані. Другий аргумент - функція з двома аргументами: часто анонімна функція, але іноді щось таке просте, як арифметичний оператор, наприклад +.
# surround an infix operator with backticks, to use as a function
reduce(1:100, `+`)
#> [1] 5050 # same as sum(1:100)
# anonymous function, alternately add and subtract
reduce(1:10, \(accum, nextval) ifelse(nextval %% 2 == 0, accum + nextval, accum - nextval))
#> [1] 7
Чи має значення, з якого кінця вхідних даних ми застосовуємо функцію: з початку чи з кінця?
Ні, якщо оператори асоціативні, як-от + або *.
Очевидно, що (1 + 2) + 3 == 1 + (2 + 3).
Натомість - і / неасоціативні:
(1 - 2) - 3
#> [1] -4
1 - (2 - 3)
#> [1] 2
Для функцій, де напрямок має значення, є необовʼязковий аргумент .dir.
# default is .dir = "forward"
reduce(1:10, `-`)
#> [1] -53
reduce(1:10, `-`, .dir = "backward")
#> [1] -5
Як і з map() і map2(), є також функція reduce2() для паралельної роботи з двома вхідними векторами.
В R ми любимо працювати з векторами. Уявімо, що замість одного підсумкового значення ми хочемо побачити всі проміжні кроки.
Для цього є відповідні функції accumulate() і accumulate2().
Синтаксис подібний до reduce(), але з кількома додатковими необовʼязковими аргументами, які задають тип вихідних даних.
accumulate(1:10, `+`)
#> [1] 1 3 6 10 15 21 28 36 45 55
Деякі поширені види накопичення мають власні функції.
У концепції функцій для векторів ішлося про cumsum() і cumprod() у Base R, а в бібліотеці dplyr є cummean() для числових середніх, cumall() і cumany() для булевих векторів.
Часто ми хочемо включити (або виключити) елементи входу, які відповідають якомусь предикату.
У концепції фільтрування векторів ми бачили один ідіоматичний спосіб зробити це.
v <- 1:3
v[v >= 2]
#> [1] 2 3
Це дуже поширено в традиційному R, але погано вписується в сучасні функціональні конвеєри.
Тому purrr надає набір функцій-предикатів, які дотримуються звичного порядку аргументів f(input, func, ...), щоб працювати з конвеєрами.
Назви трохи відрізняються від більшості інших мов (такі назви, як filter і drop, в R уже означають щось зовсім інше).
Відповідні функції purrr включають keep() і discard() та ще кілька інших.
Також є every(), some() і none(), які замінюють all() і any(), коли треба повернути одне булеве значення.
Більшість функціональних мов використовують рекурсію як бажану альтернативу циклам.
В R є інші, часто кращі варіанти: векторизовані функції та функції вищого порядку.
Однак рекурсія підтримується в R і може бути корисною, наприклад, під час обходу деревоподібних структур.
Максимальна глибина рекурсії типово становить 5000, що досить щедро (в Python типовим є 1000). Типове значення можна перевизначити.
Оптимізація хвостових викликів у R не є стандартною, хоча для неї розробили деякі обхідні шляхи.
Традиція вимагає, щоб ми показали тут приклад із факторіалом як класичний приклад рекурсивного визначення.
fact <- function(n) ifelse(n == 0, 1, n * fact(n - 1))
fact(5)
#> [1] 120
# some more idiomatic R:
factorial(5)
#> [1] 120
prod(1:5)
#> [1] 120
Рекурсію в R використовувати можна, і іноді вона справді цінна, але часто є простіші підходи.