Що таке функціональне програмування?
Для наших цілей ось кілька ключових особливостей:
Однак R не є чисто функціональною мовою (на відміну, наприклад, від Haskell).
Розгляньмо R як прагматичну мову, яка (дедалі частіше) надає функціональні можливості там, де вони є чистим і простим способом виконати роботу. Загалом мета - аналіз даних, а програмування - лише засіб для її досягнення.
Використання пайпів |> для зʼєднання функцій стає дедалі поширенішим прийомом у багатьох мовах.
Коротко:
Деякі програмісти, читаючи код, подумки промовляють пайп як «тоді»: зробити крок 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, і його можна зустріти під назвою «рідний пайп».
Редактор RStudio (та деякі інші) використовує Ctrl-Shift-M як гарячу клавішу, щоб додати пайп із потрібними пробілами.
Раніше ми бачили, що багато функцій у 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.
Ключова мета Tidyverse - запропонувати простішу й послідовнішу заміну для цієї плутанини.
Тепер є пакет purrr, який пропонує кілька додаткових варіантів, як-от map(), і водночас робить дизайн послідовнішим, а повідомлення про помилки зрозумілішими.
Цей пакет, менш обмежений зворотною сумісністю, ніж Base R, значно наближає R до сучасного функціонального програмування, як у інших мовах.
У purrr багато найрізноманітніших функцій, які годі описати коротко.
Ось кілька ресурсів, які допоможуть орієнтуватися в бібліотеці:
map
Функція map() застосовує функцію до кожного елемента вхідних даних і завжди повертає список.
Поки що це звучить як lapply(), лише з іншою назвою і, можливо, з кращими повідомленнями про помилки.
Проте map() - лише один представник великої родини.
Кілька запитань, які варто обміркувати, добираючи функцію:
map() завжди повертає список, але якщо потрібен атомарний вектор певного типу, для рядків тексту (англ. string) використовують map_chr(), для цілих чисел map_int() тощо.i, як у imap().map на map2 у назві, як у map2_dbl() для двох входів (наприклад, значень і вагових коефіцієнтів, щоб обчислити зважене середнє)pmap для списку паралельних входів, як у pmap_chr(). І так, паралелізм на рівні обладнання можливий.map_if(), щоб застосувати предикатний (булевий, англ. Boolean) фільтр, map_at(), щоб задати критерії за позиційним індексом, і head_while(), щоб обробляти вхідні дані, доки якийсь елемент не перестане задовольняти предикат.map використовують функції modify() або modify_tree() для рекурсивного застосування.Ці функції, коли це можливо, приймають вхідні дані першим аргументом, щоб добре працювати з пайпами.
Крім того, purrr (навмисно) прискіпливіший за Base R. Якщо попросити щось конкретне, функції повернуть саме це або зупиняться з повідомленням про помилку.
Суворо, але дуже дієво для зменшення кількості bug, які важко знайти.
Функції map зазвичай мають повернене значення тієї самої довжини, що й вхідні дані.
Деякі інші функції зменшують розмірність даних, наприклад sum().
sum(1:100)
#> [1] 5050
У наведеному прикладі ми перетворюємо вектор довжини 100 на ціле число довжини 1. У майбутній концепції про матриці та масиви ми побачимо, що ідея зменшення розмірності загальніша.
Функція sum() вбудована (як і багато інших статистичних функцій).
Однак нам потрібен спосіб застосовувати довільні функції зменшення розмірності до всієї структури даних за допомогою якоїсь функції вищого порядку, аналогічної до map().
У багатьох інших мовах використовують такі назви функцій, як fold, foldl і foldr. У R відповідна функція - reduce(), що відповідає добре відомій (принаймні ентузіастам алгоритмів) моделі 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
Деякі поширені різновиди accumulate мають власні функції.
У концепції векторних функцій ішлося про 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(), повертаючи одне логічне значення.
Ми відкриваємо сирний клуб, який використовуватиме машинне навчання (ML), щоб добирати нові сири для наших любителів сиру на основі їхньої історії та смаків.
Нові учасники мають заповнити початкове опитування, щоб ми могли зібрати трохи базових даних для початку. Завдяки цьому зʼясувалося, що є частина категоричних клієнтів, чиїм відгукам бракує нюансів. Оскільки це може безповоротно викривити алгоритм, чутливіший до нюансів, для їхніх потреб налаштовано окремий алгоритм. Потрібно написати кілька допоміжних функцій, щоб упорядкувати їхні дані.
Хоча наведені нижче завдання можна розвʼязати різними способами, кожне з них розвʼязується не більше ніж двома функціями вищого порядку.
Система оцінювання має пʼятизіркову шкалу, яка складається з цілих чисел 1:5.
Категоричні клієнти ставлять лише оцінки 1 або 5, і ми хочемо знати, чи клієнт поводиться саме так.
Реалізуйте all_15(), яка приймає вектор оцінок і повертає true, якщо всі оцінки дорівнюють 1 або 5, і false в іншому разі.
all_15(c(2, 3, 4, 4, 1))
#> [1] FALSE
all_15(c(1, 5, 5, 1, 5))
#> [1] TRUE
Потрібно зіставити імʼя клієнта (або ID) з його оцінками.
Реалізуйте name_customers(), яка приймає вектор імен і список оцінок та «зшиває» їх у список списків.
Елементи внутрішнього списку повинні мати імена name і rating.
names <- c("c1", "c2")
ratings <- list(c(2, 3, 5), c(1, 1, 5))
name_customers(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c1"
#> [[1]]$rating
#> [1] 2 3 5
#> [[2]]
#> [[2]]$name
#> [1] "c2"
#> [[2]]$rating
#> [1] 1 1 5
Потрібно відокремити категоричніших клієнтів від решти.
Реалізуйте emphatics(), яка приймає клієнтів та оцінки.
Повертає список, до якого потрапляють лише ті, хто ставить оцінки 1 або 5.
# names and ratings as in the task 2 example
emphatics(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c2"
#> [[1]]$rating
#> [1] 1 1 5
Оскільки категоричні клієнти ставлять лише оцінки 1 і 5, обчислювально зручніше замінити їх на 0 і 1.
Реалізуйте to_binary(), яка приймає вектор категоричних оцінок.
Повертає двійкові оцінки, де 1 змінено на 0, а 5 - на 1.
to_binary(c(1, 1, 5, 5, 1))
#> [1] 0 0 1 1 0
Ми хочемо постійно покращувати рекомендації для кожного окремого клієнта (не лише для категоричних) з часом, у міру того як краще розуміємо їхні смаки.
Реалізуйте satisfactions(), яка приймає вектор оцінок.
Повертає вектор кумулятивних середніх оцінок на цей момент.
Округліть результати до 2 знаків після коми.
Сподіваємося, що ці числа й надалі будуть зростати, на радість щасливим клієнтам!
satisfactions(c(1, 2, 2, 3))
#> [1] 1.00 1.50 1.67 2.00
Зареєструйтеся на Exercism, щоб вивчати й опановувати R, а також 21 концепція111 вправ та справжнє наставництво від людей, і все це безкоштовно.