Ледачий, компонований та модульний JavaScript


Дізнайтесь більше про нові кар'єрні можливості в EchoUA. Цікаві проекти, ринкова оплата, гарний колектив. Надсилайте резюме та приєднуйтеся до нас.

Доброю практикою вважається робити код компонованим і модульним. Він спрощується і до його частин стає простіше звертатися кілька разів. Для JavaScript-розробників теж з’явилися інструменти для використання цього підходу.

 

Зупинимося на використанні чотирьох можливостей ECMAScript: ітераторах, генераторах, “жирних” стрілочних функціях і операторові for - of у поєднанні з функціями вищого порядку, композиціями функцій, відкладеними обчисленнями.

Перш ніж зануритися з головою в приклад, розглянемо деякі загальні поняття.

Функції вищого порядку

Функція вищого порядку – це функція, яка задовольняє хоч би одній з умов :

  • приймає в якості аргументів одну або більше функцій;
  • повертає функцію як результат.

Ви напевно стикалися з функціями вищого порядку, якщо писали обробник подій або застосовували Array.prototype.map ().

Наприклад, функція, потрапляючи в Array.prototype.map, нічого не знає про її структуру і методи. Єдине знання – механізм обробки своїх даних, що входять, тому може бути неодноразово застосована як для окремих значень, так і колекцій.

Композиції функцій

Композиція функцій – застосування однієї функції до результату інший. Внаслідок чого з простих функцій складаються складні.

Наприклад, є дві функції: f і g. Результат композиції (f, g) – функція f (g (x)), яку так само можна використати в композиції або передати як параметр функції вищого порядку.

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

У монолітному рішенні, представленому нижче, використовується новий оператор for - of замість звичного for - loop для перебору значень масиву. Ітератор – контейнер, який реалізує протокол послідовного перебору і повертає за допомогою оператора yield значення по одному (масиви, рядки, генератори і т. д.).

function vowelCount (file) { let contents = readFile (file) let lines = contents.split ('n')  // перетворюваний вміст в масив рядків. let result =[] // масив масивів, де кожен індекс відповідає рядку // і кожен індекс в межах масиву - к-ть голосних. for (let line of lines) { let temp =[] let words = line.split (/s+/) for (let word of words) { let vowelCount = 0 for (let char of word) { if (
'a' === char || 'e' === char || 'i' === char || 'o' === char || 'u' === char ) vowelCount++} temp.push (vowelCount) } result.push (temp) } return result}

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

// main.jsfunction vowelOccurrences (file) { return map (words => map (vowelCount, words), listOfWordsByLine (read (file)))}function vowelCount (word) { return reduce ((prev, char) => { if ( 'a' === char || 'e' === char || 'i' === char || 'o' === char || 'u' === char ) return ++prev else return prev }, 0, word)}function listOfWordsByLine (string) { return map (line => split (/s+/, line), split ('n', string))}
// повторно використовувані функції з бібліотеки util.jsfunction reduce (fn, accumulator, list) { return [].reduce.call (list, fn, accumulator)}function map (fn, list) { return [].map.call (list, fn)}function split (splitOn, string) { return string.split (splitOn)}

listOfWordsByLine повертає масив масивів, де кожен елемент відповідає масиву слів, що становлять рядок. Наприклад:

let input = 'line onenline two'listOfWordsByLine (input)  // [['line','one'],['line','two']]

У прикладі vowelCount підраховує кількість голосних в слові, vowelOccurrences використовує vowelCount на виході listOfWordsByLine для розрахунку голосних в кожному рядку.

Профіт другого способу – універсальні функції, які будуть корисні в подальшій роботі і комбінуватимуться разом для вирішення великих завдань.

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

Відкладені обчислення

Відкладені (“ледачі”) обчислення – операції, виконання яких відкладається до тих пір, поки не потрібний результат.

Розглянемо на прикладі “ледачу” обробку даних і побудову “ледачих” ланцюжків обчислень (pipelines).

Дан список цілих чисел. Необхідно звести в квадрат елементи списку і вивести суму перших чотирьох отриманих значень.

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

let squareAndSum =  (iterator, n)  => { let result = 0 while (n > 0) { try { result += Math.pow (iterator.next (), 2) n--} catch (_) { // довжина переліку менше 'n' отже // iterator.next повідомляє, що у нього немає значень break } } return result}let getIterator =  (arr)  => { let i = 0 return { next: function (){ if (i < arr.length) return arr[i++] else throw new Error ('Iteration done') } }}let squareAndSumFirst4 = (arr) => { let iterator = getIterator (arr) return squareAndSum (iterator, 4)}

Возводим у степень елементи тільки тоді, коли починається підсумовування. За рахунок контролю ітерації і yield обробляються тільки ті елементи, які братимуть участь у результаті. Ітерація реалізується таким чином, що елементи повертаються за допомогою оператора yield по одному аж до отримання сигналу про відсутність елементів для виведення. Протокол інкапсулюється в об’єкт ітератора, який містить одну функцію, – next, що набуває нульових значень. Наступний елемент повертається, тільки за наявності елементів.

Функція squareAndSum  в якості вхідних даних  ітератор і n (число елементів в сумі). За допомогою виклику методу .next () n раз отримує з ітератора n значень, зводить кожен з елементів в квадрат і підсумовує їх.

GetIterator повертає ітератор, сформований з нашого списку.

squareAndSumFirst4 використовує getIterator і squareAndSum, щоб повернути суму перших чотирьох чисел з вхідного списку, зведених в квадрат “ледачим” способом. Використання ітераторів дозволяє впроваджувати структури даних, які можуть повернути за допомогою оператора yield нескінченні значення.

Необхідність виконання описаних вище дій кожного разу, коли нам потрібний ітератор, ускладнює написання коду. На щастя, ES, починаючи з версії 6, пропонує простий спосіб опису ітераторів – генератори.

Генератор – функція, роботу якої можна призупинити, а потім відновити. Причому генератор може видати значення кілька разів в ході виконання за допомогою ключового слова yield. При виклиці повертає об’єкт-генератор. За допомогою методу .next виходить наступне значення. У JavaScript генератори створюються шляхом визначення функції з *.

// генератор, який повертає нескінченний список послідовних// чисел, починаючи з 0// знак "*" іспользуется, щоб повідомити обробника, що це генераторfunction* numbers (){ let i = 0 yield 'нескінченний список чисел' while (true) yield i++}let n = numbers () // отримати ітератор від генератораn.next () //{value: "нескінченний список чисел", done: false}n.next () //{value: 0, done: false}n.next () //{value: 1, done: false}
// і так далі.

Генератори підтримують обидва протоколи, тому отримати значення можна за допомогою оператора for - of.

for (let n of numbers()) console.log (n)  // друкувати нескінченний список чисел

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

Приклад: завдання і рішення

Початкові дані:

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

Завдання:

  1. Отримати імена користувачів, які починаються з ” A”, ” E” або ” M”.
  2. Виконати запит з використанням отриманих даних до сторінки http://jsonplaceholder.typicode.com/users?username=.
  3. Застосувати до перших чотирьох відповідей сервера заданий набір з чотирьох функцій.

Вміст файлу :

BretAntonetteSamanthaKarianneKamrenLeopoldo_CorkeryElwyn.SkilesMaxime_NienowDelphineMoriah.Stanton

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

  1. Перша повертає кожне ім’я (getNextUsername).
  2. Друга відбирає імена, які починаються з ” A”, ” E” або ” M” (filterIfStartsWithAEOrM).
  3. Третя робить мережевий запит і повертає Promise, об’єкт-заглушку для виведення результату обчислення (makeRequest).

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

  1. Перша вибирає елементи списку на основі заданих параметрів (filter).
  2. Друга застосовує функцію до кожного елементу списку (map).
  3. Третя застосовує функції з одного ітератора до даних іншого ітератора (zipWith c функцією упаковки).

“Ледачість” цього підходу може принести користь, оскільки мережеві запити робляться не для усіх імен, що підходять під критерії фільтрації.

Отже, у нас є масив функцій, які повинні виконуватися для обробки остаточних відповідей, і функція, яка повертає “ледачим” способом блоки даних. Напишемо функцію із застосуванням генераторів для отримання імен користувачів, використовуючи “ледачий” підхід.

// функції, які виконуються у відповідь на запросlet fnsToRunOnResponse =[f1, f2, f3, f4]// повертає наступний блок даних з файлу// символ * означає, що ця функція є генератором в JavaScriptfunction* getNextChunk (){ yield 'BretnAntonettenSamanthanKariannenKamrennLeopoldo_CorkerynElwyn.SkilesnMaxime_NienownDelphinenMoriah.Stantonn'}// getNextUsername приймає ітератор, який повертає наступний фрагмент, що закінчується перекладом рядка// він сам повертає ітератор, який повертає імена по одномуfunction* getNextUsername (getNextChunk) {
for (let chunk of getNextChunk()) { let lines = chunk.split ('n') for (let l of lines) if (l !== '') yield l }}

Тепер для роботи зі значеннями потрібні наступні функції:

  1. Перша повертає True, якщо значення задовольняє критеріям фільтру, False – інакше.
  2. Друга повертає URL-адресу при отриманні імені користувача.
  3. Третя при отриманні URL-адреси робить запит і повертає Promise для цього запиту.

Promise – “контейнер” для зберігання значення виконуваної операції, яке з’явиться в майбутньому. Інтерфейс Promise дозволяє визначити, які дії виконувати після успішного завершення операції або збою. Якщо операція пройде успішно, викликається обробник вдалого результату зі значенням операції. Інакше викликається обробник помилки.

// ця функція повертає True, якщо ім'я користувача відповідає нашим критеріям// і false в осоружному случаеlet filterIfStartsWithAEOrM = username => { let firstChar = username[0] return 'A' === firstChar || 'E' === firstChar || 'M' === firstChar}// makeRequest робить AJAX- запит до URL і повертає promise// він використовує новий API і fat arrows es6// це звичайна функція, не генераторlet makeRequest = url => fetch (url).then (response => response.json())
// makeUrl приймає ім'я користувача і генерує URL- адреса, до якої хочемо обратитьсяlet makeUrl = username => 'http://jsonplaceholder.typicode.com/users?username=' + username

Тепер напишемо функції вищого порядку, які забезпечать роботу з “ледачими” списками. Їх завдання – відкласти виконання до тих пір, поки не з’явиться запит. В цьому випадку потрібні значення на вимогу, тому на допомогу прийдуть генератори.

// функція filter приймає іншу функцію (предикат). Предикат же набуває значення і повертає// булеве значення і сам ітератор. Filter повертає ітератор, якщо предикат при обробці// вхідного значення повертає True.function* filter (p, a) { for (let x of a) if (p (x)) yield x}// map приймає на вході функцію і ітератор
// повертає новий ітератор, який повертає результат застосування функції до кожного значення// вхідного итератораfunction* map (f, a) { for (let x of a) yield f (x)}// zipWith приймає булеву функцію і два ітератори як вхідні дані// повертає ітератор, який у свою чергу застосовує задану функцію до значень з кожного// ітератора і видає результатfunction* zipWith (f, a, b) { let aIterator = a[Symbol.iterator]() let bIterator = b[Symbol.iterator]() let aObj, bObj while (true) { aObj = aIterator.next () if (aObj.done) break bObj = bIterator.next () if (bObj.done) break yield f (aObj.value, bObj.value)  }}// execute запускає відкладений ітератор// як правильно неодноразово звертається до '.next' ітератора// аж до виконання итератораfunction execute (iterator) { for (x of iterator)  ;; // витягаємо значення ітератора}

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

let filteredUsernames = filter (filterIfStartsWithAEOrM, getNextUsername (getNextChunk) let urls = map (makeUrl, filteredUsernames) let requestPromises = map (makeRequest, urls) let applyFnToPromiseResponse = (fn, promise) => promise.then (response => fn (response)) let lazyComposedResult = zipWith (applyFnToPromiseResponse, fnsToRunOnResponse, requestPromises) execute (lazyComposedResult)

lazyComposedResult – “ледачий” ланцюжок обчислень (pipeline), складений з композицій функцій. Жодна ланка не виконається, поки не запущений верхній блок композиції, тобто lazyComposedResult. Ми зробили тільки чотири виклики, хоча результат фільтрації може містити більш чотирьох значень.

У результаті отримали лаконічне рішення задачі, в якому з’явилися функції вищого рівня, композиції і повторно використовувані функції.

Переклад статті “Lazy, composable, and modular JavaScript”

Київ, Харків, Одеса, Дніпро, Запоріжжя, Кривий Ріг, Вінниця, Херсон, Черкаси, Житомир, Хмельницький, Чернівці, Рівне, Івано-Франківськ, Кременчук, Тернопіль, Луцьк, Ужгород, Кам'янець-Подільський, Стрий - за статистикою саме з цих міст програмісти найбільше переїжджають працювати до Львова. А Ви розглядаєте relocate?


Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *