6 концепцій функціонального програмування. Користь і приклади використання

Доброго часу доби! Мене звуть Іван Смолін, я розробник мобільних додатків на платформі iOS. Сьогодні пропоную вам поринути у світ функціонального програмування. Стаття носить здебільшого теоретичний характер, ніж практичний. У ній я спробую дати визначення основним поняттям функціонального програмування і покажу приклади реалізації на C, Objective-C, Swift, Haskell.

Функціональне програмування — це парадигма програмування, яка акцентується на обчисленні через функції в математичному стилі, незмінюваність, виразність та зменшення використання змінних і станів (посилання).

Існує 6 основних концепцій:

  • концепція першого класу і функцій вищого порядку
  • концепція чистих функцій
  • концепція незмінного стану
  • концепція опциональности і зіставлення з образом
  • концепція ліниво і нескінченних структур даних
  • концепція лямбда-числення

Функція першого класу
Це сутність, яка підтримує операції, зазвичай доступні для інших сутностей. Ці операції, як правило. включають в себе: передачу сутності як аргумент, повернення сутності функції і призначення оной в змінну.

Чим корисна

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

Приклади використання

typedef void (*callback_func_t) (char*);

void list_files(char* path, callback_func_t callback) {
// recursive read directory structure ...
}

void print_file_path(char* file_path) {
printf("%s\n", file_path);
}

callback_func_t get_print_func() {
callback_func_t callback_var = &print_file_path;

return callback_var;
}

list_files("/tmp/", get_print_func());

У наведеному прикладі функція get_print_func створює змінну, в якій зберігає посилання на функцію, а потім повертає її. А нижче за кодом ми передаємо результат, повернений нам функцією get_print_func в іншу функцію. Це і є операції, доступні нам завдяки функціям першого класу.

Функція вищого порядку
Це функція, яка оперує іншими функціями. Оперує, отримуючи їх в якості параметра або повертаючи їх.

Чим корисна

Як і у випадку функцій першого порядку, ця концепція дає нам більше можливостей для роботи з функціями. Також ця концепція дає нам можливість використання функцій як обробників подій, про яких система або будь-яка бібліотека може нам повідомляти допомогою виклику переданої їй функції першого класу.

Приклади використання

См. попередній приклад. Тут є функція, яка читає директорію. І рекурсивно читає всі підпапки. Для кожного знайденого файлу викликає функцію, яку ми передали — callback.

Приклад на С показує, що ще в 70-их роках минулого століття можна було оперувати функціями першого класу і вищого порядку. В Objective-C з'явилися блоки. На відміну від функцій вони можуть захоплювати змінні і якийсь стан. В Swift з'явилися Closure. По суті, це те ж саме, що блоки на Objective-C.

Чиста функція
Це функція, яка виконує дві умови. Функція завжди повертає один і той же результат при одних і тих же вхідних параметрах. І обчислення результату не викликає видимих семантичних побічних ефектів або виведення у поза.

Чим корисна

Чиста функція, зазвичай, є показником добре написаного коду, так як такі функції легко покривати тестами, їх можна легко переносити і повторно використовувати.

Приклади використання

У наведеному нижче фрагменті показані приклади чистих функцій (вони позначені коментарем «pure»).

func quad1(x: Int) -> Int { // pure
func square() -> Int {
return x * x
}

return square() * square()
}

func quad2(x: Int) -> Int { // pure
func square(y: Int) -> Int { // pure
return y * y
}

return square(x) * square(x)
}

func square(x: Int) -> Int { // pure
return x * x
}

func cube(x: Int) -> Int {
return square(x) * x
}

func printSquareOf(x: Int) {
print(square(x))
}

let screenScale = 2.0

func pixelsToScreenPixels(pixels: Int) -> Int {
return pixels * Int(screenScale)
}

Використовуються повсюдно. Наприклад, стандартні математичні бібліотеки майже всіх мов програмування містять в основному тільки чисті функції.

Незмінне стан
Незмінне стан — стан об'єкта, яке не може бути змінене після того, як об'єкт був створений. Під станом об'єкта тут розуміється набір значень його властивостей.

корисним

Так як незмінні об'єкти гарантують нам, що протягом свого життєвого циклу вони не можуть змінювати свій стан, то ми можемо бути впевнені, що використання або передача таких об'єктів в інші місця програми не призведе до якихось непередбачених наслідків. Це особливо важливо при роботі в багатопотоковому оточенні.

У мові C «з коробки» немає можливості створювати незмінні об'єкти. Ключове слово const забороняє змінювати значення тільки в поточному контексті, однак, якщо ми передаємо посилання на це значення у функцію, ця функція зможе змінити дані, розташовані за цим посиланням. Можна вирішити цю проблему через інкапсуляцію (через публічні та приватні відмінності файли). Однак, у цьому випадку ми повинні самостійно реалізувати механізм «захисту даних від змін.

В Objective-C теж нічого нового не прийшло. Додалися тільки базові класи, які не дають змінювати свій внутрішній стан і їх змінні (мутабельні) аналоги.

В Swift у нас з'явилося ключове слово let, яке гарантує нам, що змінна або структура не може бути змінена після створення.

Приклади використання

Приклад використання незмінних значень в Swift:

let one = 1
one = 2 // compile error

let hello = "привіт"
hello = "bye" // compile error

let argv = ["uptime", "--help"]
argv = ["man", "reboot"] // compile error
argv[0] = "man" // compile error

Опціональний тип
Опціональний тип — узагальнений (generic) тип, який представляє інкапсуляцію опціонального значення. Такий тип містить у собі або певне значення, або порожній (null) значення.

Чим корисний

Виносить поняття про нульовий (null) значенні на більш високий рівень. Дозволяє працювати з опціональними значеннями за допомогою синтаксичних конструкцій мови.

Приклади використання

Практично у всіх сучасних, особливо молодих, мовах є поняття опціонального типу та синтаксичні конструкції для роботи з ним. В Swift це конструкція if let, або switch case:

let some: String? = nil

switch (some) {
case .None:
print("no string")
case .Some(let str):
print("string is: \(str)")
}

Pattern Matching
Pattern Matching — акт перевірки послідовності токенів на збіг з певним шаблоном.

Чим корисний

Дозволяє нам писати більш короткий, зосереджений на вирішенні проблеми код.

Приклади використання

Ось приклад на Haskell. На мій погляд, найкращий приклад Pattern Matching.

sum :: (Num a) => [a] -> a 

sum [] = 0 -- no elements
sum (x:[]) = x -- one element
sum (x:xs) = x + sum xs -- many elements

Функція sum приймає на вхід масив об'єктів. Якщо функція sum отримує порожній масив, сума елементів буде 0. Якщо масив містить один об'єкт, просто отримуємо цей об'єкт Якщо більше об'єктів, то складаємо перший об'єкт і хвіст масиву, потім операцію повторюємо рекурсивно поки у нас є елементи в хвості масиву. Цю функцію ми описали як патерн. Це означає, що ми описуємо всі можливі (або необхідні нам у даний момент) варіанти роботи цієї функції залежно від вхідних значень. Без if та інших умовних операторів.

addOne :: Maybe Int -> Int Maybe

addOne (Just a) = Just (a + 1) -- not empty value
addOne Nothing = Nothing -- empty value

Функція addOne додає до числа одиницю. На вхід вона приймає аргумент типу Maybe Int і на виході повертає значення аналогічного типу. Maybe — це монада, яка містить в собі значення (Just a), або нічого (Nothing). Функція addOne працює наступним чином: якщо аргумент функції є значення, (Just a) додаємо одиницю і повертаємо аргумент, якщо нічого немає (Nothing), то повертаємо нічого (Nothing).

В Swift pattern-matching виглядає так:

let somePoint = (1, 1)

switch somePoint {
case (0, 0):
print("point at the origin")
case (_, 0):
print("(point on the x-axis")
case (0, _):
print("point on the y-axis")
case (-2...2, -2...2):
print("point inside the box")
default:
print("point outside of the box")
}

Pattern Matching, на мій погляд, досить обмежений у Swift, можна тільки перевірити кейси в операторі switch, однак, робити це можна досить гнучко.

Ленивость або ліниві обчислення
Ліниве обчислення — стратегія обчислення, яка відкладає обчислення виразу до моменту, коли значення цього виразу буде необхідно.

Чим корисно

Дозволяє відкласти обчислення деякого коду до заздалегідь визначеного або невизначеного часу.

Приклад

let dateFormatter = NSDateFormatter()

struct Post {

let id: Int
let title: String
let creationDate: String

lazy var createdAt: NSDate? = {
return dateFormatter.dateFromString(self.creationDate)

}()

}

Може застосовуватися для ініціалізації полів у класу, після його ініціалізації. Цей прийом дозволяє уникнути дублювання коду ініціалізації поля в декількох конструкторів класу і відкласти ініціалізацію цього поля до моменту, коли в ньому виникне необхідність. У наведеному вище прикладі значення поля createdAt обчислюється в момент першого звернення до нього.

Нескінченна структура даних
Нескінченна структура даних — структура, чиє визначення дано в термінах нескінченних діапазонів або безперервної рекурсії, але реальні значення обчислюються лише в момент, коли вони необхідні.

Чим корисна

Дозволяє нам визначати структури даних нескінченної або величезної величини, не витрачаючи ресурси на обчислення значень цієї структури.

Приклади використання

Ось приклад на Swift. Беремо Range від одного до більйона. Робимо map на цей Range — перетворюємо більйон значень рядка. Така кількість рядків чи вміститься в оперативній пам'яті персонального комп'ютера. Але, тим не менше ми можемо спокійно це зробити і взяти потрібні значення. У прикладі лямбда, передана в функцію map, викликається тільки два рази. Все виконується дуже ліниво.

let bigRange = 1...1_000_000_000_000 // from one to one billion
let lazyResult = bigRange.lazy.map { "Number \($0)" } // called 3 times

let fourHundredItem = lazyResult[400] // "Number 400"
let lazySlice = lazyResult[401...450] // Slice<LazyMapCollection<Range<Int>, String>>
let fiveHundredItem = lazyResult[500] // "Number 500"

В Swift ми завжди обмежені Range'їм. Ми не може створити нескінченний ряд значень. Можна умудритись зробити це інакше, але „з коробки“ цього немає. Зате в Haskell є.

Список можна від одного до нескінченності. Робимо map до всіх елементів (номер і число, які перетворяться в рядок). Потім беремо будь-які елементи або зріз або список. Зріз теж буде повернутий ледачим списком.

infiniteList = [1..]
mappedList = map (\x -> "Number " ++ show x) infiniteList -- called 2 times

fourHundredItem = mappedList !! 400 -- "Number 400"
lazySlice = take (450 - 400) (drop 400 mappedList) -- [401..450]
fiveHundredItem = mappedList !! 500 -- "Number 500"

lazyArray = [2+1, 3*2, 1/0, 5-4] -- item values not evaluated
lengthOfArray = length lazyArray -- still not evaluated

Haskell — самий ледачий мову з усіх, які я бачив. У ньому масиви можуть містити boxed (запаковані) і unboxed (розпаковані) елементи. У випадку, коли елементи масиву упаковані (ще не обчислені) при операціях з масивом, які не потребують здобуття значення елементів, ці значення обчислені не будуть. Прикладом такої операції може служити метод length.

Лямбда-числення
Лямбда числення — формальна система в математичній логіці для вираження обчислення на основі операцій аплікації та абстракції функцій за допомогою зв'язування та заміни змінних.

Чим корисні

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

Приклад

Нижче — приклад на Swift, де ми використовуємо лямбду замість іменованих функцій.

let numbers = [0,1,2,3,4,5,6,7,8,9,10]
let stringNumbers = numbers.map { String($0) } / / ["0","1","2","3","4","5","6","7","8","9","10"]
let sum = numbers.reduce(0, combine: { $0 + $1 }) // 55
let avg = numbers.reduce(0.0, combine: { $0 + Double($1) / Double(numbers.count) }) // 5.0
let from4To7SquareNumbers = numbers.filter { $0 > 3 }.filter { $0 < 7 }.map { $0 * $0 } // [16, 25, 36]

Тут показано, як обчислити суму або середнє в одну сходинку. І профільтрувати.

Також концепція ламбда-обчислення привносить в мови програмування поняття каррирования. Каррирование дозволяє нам розбити функцію з кількома параметрами на кілька функцій з одним параметром. Це дає нам можливість отримувати результат обчислення проміжних функцій і застосовувати до цих функцій різні аргументи для одержання декількох результатів.

Приклад використання каррирования

func raiseToPowerThenAdd(array: [Double], power: Double) -> ((Double) -> [Double]) {
let poweredArray = array.map { pow($0, power) }

return { value in
return poweredArray.map { $0 + value }
}
}

let array = [3.0, 4.0, 5.0]

let intermediateResult = raiseToPowerThenAdd(array, power: 3)

intermediateResult(0) // [27, 64, 125]
intermediateResult(5) // [32, 69, 230]
intermediateResult(10) // [37, 74, 135]

Тут ми отримуємо результат обчислення ступеня чисел в масиві і потім додаємо до цього результату певне число. Важливо звернути увагу на те, що обчислення ступенів відбувається тільки один раз при виклику функції raiseToPowerThenAdd.

Висновок
На мій погляд, найважливішими концепціями для розробки мобільного (у плані якості коду) є: концепція чистих функцій і концепція опциональности. Перша — дає нам зрозумілу і просту ідею як зробити наш код більш стерпним, якісним і тестованим. Друга — змушує нас думати про можливі крайніх випадках і помилки, які можуть прийти ззовні, і обробляти їх коректно.

Сподіваюся, матеріал буде корисний і ваш код стане ще якісніше.

Іван Смолін, iOS-розробник.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.