Треки
/
R
R
/
Салабус
/
Функціональне програмування
Фу

Функціональне програмування у R

1 вправа

Про концепцію Функціональне програмування

Що таке функціональне програмування?

Це велике питання, яке заслуговує на довгу відповідь (одну дає Wikipedia, іншу дає Advanced R).

Для наших цілей важливі такі ключові риси:

  • Чисті функції, які послідовно відображають вхідні дані у вихідні й не мають побічних ефектів.
  • Незмінні дані.
  • Функції першого класу, з якими можна поводитися як зі значеннями будь-якого іншого типу.
  • Функції вищого порядку, які приймають інші функції як аргументи або повертають їх як результат.

Однак R не є чистою функціональною мовою (на відміну, наприклад, від Haskell).

Розгляньмо R як прагматичну мову, яка (дедалі частіше) надає функціональні можливості там, де вони є чистим і простим способом виконати роботу. Зазвичай мета - аналіз даних, а програмування - лише засіб для досягнення цієї мети.

Конвеєри

Використання конвеєрів |> для зʼєднання функцій стає дедалі поширенішим прийомом у багатьох мовах.

Коротко:

  • Результат ліворуч від конвеєра стає першим аргументом функції праворуч (причому «праворуч» ігнорує необовʼязкові перенесення рядків).
  • Будь-які інші аргументи можна передати у виклику функції.
  • У 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 багато найрізноманітніших функцій, які нелегко описати коротко. Ось ресурси, які допоможуть зорієнтуватися в бібліотеці:

Note

Бібліотека 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 використовувати можна, і іноді вона справді цінна, але часто є простіші підходи.

Редагувати через GitHub Посилання відкривається в новому вікні або вкладці

Вивчити концепцію Функціональне програмування