Functions, functions are everywhere: 8 принципів функціонального програмування на прикладах з JavaScript
Які концепції є в JavaScript із математики? Для когось відповідь буде максимально очевидна, а когось може здивувати — це функціональне програмування!
І здавалось би, що ця тема обговорювалась так часто, що всі повинні в ній розбиратись. А от досвід проведення інтерв’ю показав, що багато кандидатів не можуть назвати навіть найпопулярніші з них, на відміну від принципів ООП. Тому пропоную зануритись в тему і розібрати основні принципи функціонального програмування, які варто знати кожному хорошому інженеру.
Трішки про себе — мене звати Інна Іващук, займаю позицію Lead Software Engineer у GlobalLogic. У компанії я близько 9 років і більше 10 років у веброзробці. До того ж, я Trusted Interviewer в компанії і за останніх 5 років провела більше сотні технічних співбесід для різних рівнів (від Junior до Lead) та різних проєктів. А також являюсь частиною програмного комітету конференції Fwdays (JavaScript / React / TS).
У цій статті пропоную розглянути:
- Що таке функціональне програмування?
- Основні концепції (pure function, HOF, рекурсія та інші).
- Практичне застосування.
- Монади та функтори.
Інтро: що таке функціональне програмування
Перша асоціація, яку у більшості викликає термін «функціональне програмування» — це функції, функції всюди. При обговоренні із колегами більшість згадали про лямбди, монади, рекурсію та дехто — про математику.
Звичайно, що функціональне програмування — це парадигма, коріння якої сягають глибоко в математику, адже саме звідти до нас прийшла концепція компонування та застосування функцій.
Першим, хто це зробив, був американський математик Алонзо Черч у
Варто згадати і першу функціональну мову програмування — Lisp, що зʼявилась у 1958 році та була натхненна лямбда-численнями.
Ви, мабуть, також бачили багато мемів про Lisp і те, що там настільки багато дужок, що ти не можеш бути впевнений, чи закрив усі, які відкрив на початку. Тут пропоную глянути, а як виглядає реалізація знаходження n! (факторіал числа) з використанням рекурсії у Lisp:
(write-line "Please enter a number...") (setq x (read)) (defun factorial(n) (if (= n 1) (setq a 1) ) (if (> n 1) (setq a (* n (factorial (- n 1)))) ) (format t "~D! is ~D" n a) a ;; ) (factorial x)
Пізніше з’являється мова програмування Meta Language (і ні, вона немає нічого спільного із компанією Meta). Вона вже була розроблена для формальної верифікації програм і саме ця мова програмування зробила значний внесок у розвиток статичної типізації та інференцій типів у функціональних мовах. І давайте теж глянемо приклад реалізації n!, використовуючи рекурсію:
(fun fact n = let fun fac 0 acc = acc | fac n acc = fac (n - 1) (n * acc) in if (n < 0) then raise Domain else fac n 1 end
Починаючи з
Чому ж функціональне програмування стало таким популярним і знаходило все більше застосувань?
Чистота і передбачуваність. Ми оперуємо невеликими чистими функціями, відповідно відсутність побічних ефектів робить код більш зрозумілим і легшим для тестування.
Математична основа. Тісний звʼязок з математикою забезпечує строгу семантику та спрощує доведення коректності програм.
Високий рівень абстракції. Високий рівень абстракції став важливим фактором, так як функціональні мови дозволяють написати більш компактний виразний код. Тобто ми чітко бачимо, що взагалі вимагається від тієї чи іншої функції. На відміну від абстракції в ООП, тут вона базується на операціях, функціях, композиціях і тд.
Паралелізм і розподілення обчислень. В деяких функціональних мовах програмування ми можемо використовувати паралелізм і розподілення обчислень, що важливо для сучасних багатоядерних процесорів.
Основні концепції, які існують у функціональному програмуванні
Почнемо з того, що в нас є парадигми програмування — імперативні та декларативні. Якщо ми говоримо про імперативні, то це процедурні, більш структурні мови програмування. Якщо про декларативні — це якраз логічні та функціональні.
Декларативний підхід являє собою спосіб описати програму, де ми фокусуємся на тому, що програма має зробити, а не як саме вона це робить. Розглянемо на прикладі, коли нам потрібно відфільтрувати масив із чисел. Перший варіант коду — це імперативний підхід:
const numbers = [1, 2, 3, 4, 5]; const filteredNumbers = []; for (let i = 0; i < numbers.length; i++) { if (numbers[i] > 3) { filteredNumbers.push(numbers[i]); } } console.log(filteredNumbers); // [4, 5]
Другий варіант — це декларативний підхід:
const numbers = [1, 2, 3, 4, 5]; const filteredNumbers = numbers .filter(number => number > 3); console.log(filteredNumbers); // [4, 5]
В першому варіанті ми використовуємо цикл for, а в другому — метод для роботи з масивами filter. Декларативний стиль написання коду для мене особисто став настільки звичною справою, що коли доводиться пописати трішки циклів, то відчуваю, наче переходжу на темну сторону і отримую тотальний контроль над кодом 🤓
Ще один приклад із сортуванням масиву чисел в імперативному та декларативному стилях відповідно:
// Imperative // Bubble sort const numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]; for (let i = 0; i < numbers.length - 1; i++) { for (let j = 0; j < numbers.length - i - 1; j++) { if (numbers[j] > numbers[j + 1]) { // Міняємо місцями елементи const temp = numbers[j]; numbers[j] = numbers[j + 1]; numbers[j + 1] = temp; } } } console.log(numbers);
// Declarative const numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]; const sortedNumbers = numbers .sort((a, b) => a - b); console.log(sortedNumbers);
Варто зазначити, що існує набір основних концепцій декларативного стилю:
Якщо б ви прийшли до мене на інтерв’ю і назвали хоча б п’ять із них — це було б просто чудово. А якщо б ще розповіли, що кожен із цих принципів означає та могли б написати приклад на JS — це взагалі супер 🎉 Тому давайте розбиратись з ними на практиці.
Від теорії до практики
First Class function (first-class citizens)
Це функції, які мають ті ж властивості, що й інші об’єкти. Тобто ми їх можемо зберігати в змінних, передавати і повертати. І оскільки JavaScript досить гнучкий, ми можемо створити зміну, переприсвоїти іншій змінній, викликати її, можемо навіть створити масив із функціями, які ми будемо викликати. Приклад того, як це може виглядати:
// First-class function const greet = () => "Hello!"; const storeFunction = greet; // Saved in a variable storeFunction(); // Call fn using that variable // Stored in object const myUtils = { sayHi: greet, sayBye: () => "Bye!", } myUtils.sayBye(); // Stored in array const listOfFns = [greet, myUtils.sayBye]; listOfFns[0]();
Pure function (чиста функція)
Це функція, яка завжди повертає один і той самий результат для однакових вхідних даних і не має побічних ефектів. Розглянемо приклад коду, де у нас є дві функції — add() та increment():
// Pure function function add(a, b) { return a + b; } // Function with side effects (not a pure fn) let count = 0; function increment() { count++; return count; }
Функція add(), яка очікує отримати два аргумента, а і b і повертає суму вхідних даних, буде чистою, оскільки не використовує зовнішніх змінних і ніщо не впливає на результат. А ось функція increment() - використовує зовнішню змінну і ми не можемо бути впевненим в тому, що результат завжди буде передбачуваний без побічних ефектів (side effects).
Higher-order functions (функції вищого порядку)
Це функція, яка або приймає інші функції як аргументи, або повертає функцію як результат. Розглянемо на прикладах коду:
const numbers = [1, 2, 3, 4, 5]; // map const doubled = numbers.map(x => x * 2); // [2, 4, 6, 8, 10] // flatMap const arr = [[1, 2], [3, 4], [5]]; const flattened = arr.flatMap(subArr => subArr); // [1, 2, 3, 4, 5] // filter const evenNumbers = numbers.filter(x => x % 2 === 0); // [2, 4] // reduce const sum = numbers.reduce((total, x) => total + x, 0); // 15 // forEach numbers.forEach(x => console.log(x)); // 1 2 3 4 5
І як ви могли помітити, це все методи для роботи із масивами, де кожен із них приймає у якості аргумента іншу функцію, тобто є функціями вищого порядку.
Також ми можемо створювати свої власні HOF. Наприклад, у мене це буде функція для фільтування масиву, яка буде приймати масив та callback функцію (predictate):
// our HOF function myAwesomeFilter(array, predicate) { const result = []; for (let i = 0; i < array.length; i++) { if (predicate(array[i])) { result.push(array[i]); } } return result; } // given data const arr = [2, 55, 33, 1, 100, 99]; // trigger our HOF myAwesomeFilter(arr, (item) => item > 10); // result [55, 33, 100, 99]
Function composition (композиція функцій)
Коли ми говоримо про функціональне програмування, не можна не згадати про композицію функцій — це концепція, яка передбачає об’єднання двох або більше функцій в одну, де результат однієї функції стає вхідними даними для наступної.
Розглянемо приклад, як це може виглядати:
const double = x => x * 2; const increment = x => x + 1; // Example 1 const composedFunction = x => increment(double(x)); // Example 2 const numbers = [1, 2, 3, 4, 5]; const updated = numbers.map(x => increment(double(x)));
У нас є дві досить простенькі функції, перша double — збільшує значення на два, а друга increment — додає до значення плюс 1. Відповідно ми можемо робити компонування і об’єднати їх в функцію composedFunction.
Наприклад, у React складні компоненти можна будувати з більш простих, об’єднуючи їх за допомогою композиції.
Immutability (імутабельність)
Це одна із ключових концепцій функціонального програмування, і вона говорить нам про те, що всі дані після створення не повинні бути зміненими, а замість зміни існуючих даних ми створюємо нові копії і оперуємо саме ними.
Чому імутабельність важлива?
- Відсутність побічних ефектів. Оскільки об’єкти не змінюються після створення, відсутні непередбачувані зміни стану, що ускладнюють відстеження помилок.
- Простіша логіка. Код стає більш декларативним, оскільки ми описуємо, що потрібно отримати, а не як змінити стан.
- Оптимізація механізмів кешування. Імутабельні об’єкти легше кешувати, оскільки їх стан не змінюється з часом.
- Захист від непередбачених побічних ефектів. Зміни в одному місці коду не впливають на інші частини програми.
- Легше відстежувати зміни. Історія змін даних зберігається в нових об’єктах, що полегшує відновлення попередніх станів.
Давайте розберемо наступний приклад коду:
const moviesList = [{ title: 'The Lord of the Rings', genre: 'fantasy', director: 'Peter Jackson', year: 2001, }, { title: 'Dune', genre: 'Sci-Fi', director: 'Denis Villeneuve', year: 2021, }]; // Fn 1: filter by year function filterByYear(movies, year) { return movies.filter(item => item.year === year); } filterByYear(moviesList, 2021); // Fn 2: add field rating with value 10 function addRating(movies) { movies.forEach(movie => { movie.rating = 10; }); return 'Rating field is added'; } addRating(moviesList);
Функція filterByYear — є кращою з точки зору імутабельності:
- створює новий масив, який містить лише фільми, що відповідають заданому року;
- не модифікує оригінальний масив moviesList, що відповідає принципу імутабельності;
- більш безпечна, оскільки не впливає на інші частини програми, які можуть використовувати оригінальний масив.
Функція addRating:
- модифікує оригінальний масив moviesList, додаючи поле rating до кожного об’єкта.
- порушує принцип імутабельності, оскільки змінює стан існуючого об’єкта.
- може призвести до непередбачених побічних ефектів, якщо інші частини програми очікують, що оригінальний масив залишиться незмінним.
Recursion
Рекурсія — це метод програмування, при якому функція викликає сама себе всередині свого тіла.
На початку ми з вами розглянули як виглядає реалізація підрахунку n! іншими мовами програмування. Настав тепер час глянути на реалізацію з використанням JavaScript:
function factorial(n) { if (n === 0) { return 1; } else { return n * factorial(n - 1); } } console.log(factorial(5)); // 120
Тут функція факторіал викликає сама себе до моменту, поки ми не отримаємо необхідний результат. І до речі, це той принцип функціонального програмування, який найбільше бояться новачки, і 90% на інтерв’ю вважають, що це антипатерн 🙂
Дійсно, тут варто згадати про те, що розмір стеку викликів обмежений, тому рекурсивні функції слід використовувати з обережністю. Ітеративні підходи часто виявляються ефективнішими, оскільки вони не вимагають створення додаткових контекстів для кожного виклику.
Currying
Каррінг — це техніка, при якій функція з кількома аргументами перетворюється на послідовність функцій, кожна з яких приймає один аргумент. Зазвичай це виглядає таким чином:
function sum(a) { return (b) => { return (c) => { return a + b + c } } } console.log(sum(1)(2)(3)) // 6
Тобто в нас є функція sum, яка повертає наступну функцію, потім повертає ще одну функцію, і вже в результаті ми отримуємо суму всіх переданих аргументів. Для того, щоб викликати подібну функцію, нам потрібно зробити ось таку комбінацію sum(1)(2)(3), щоб викликати кожну з вкладених, передаючи необхідний аргумент.
Насправді така реалізація не дуже практична. Якщо нам потрібно отримати суму тільки двох аргументів, то в даній реалізації це не спрацює. Адже в нас повернеться просто функція, яка очікує ще один аргумент. Але це можна виправити наступним чином:
function sum(a){ let result = a; //closure return function inner(b){ if(!b) { return result; } result += b; //step 3 return inner; } } console.log(sum(1)(2)(3)(4)()) // 6
Давайте розберемо, що тут відбувається. Зовнішня функція sum(a) приймає перше число a і запам’ятовує його в змінній result всередині замикання та повертає внутрішню функцію inner.
Внутрішня функція inner(b) приймає наступне число b, і якщо b не визначено (тобто це останній виклик), повертає накопичений результат result, а інакше додає b до result і повертає себе ж. Це створює рекурсивний виклик.
Кожен рекурсивний виклик inner повертає нову функцію, яка пам’ятає про попередні додавання завдяки замиканню. Це дозволяє виклики sum(1)(2)(3)(4), де кожен наступний виклик додається до попереднього результату.
* Коли вас запитують на інтерв’ю, швидше за все очікують побачити саме простіший варіант — коли у вас функція, яка повертає функції. Просто побачити, чи ви розумієте, як можна застосувати каринг в JavaScript.
Lazy Evaluation
Ліниві обчислення — це коли значення не обчислюється до тих пір, поки воно дійсно не потрібне.
Деякі мови програмування, такі як Haskell, надають можливість використовувати ліниві обчислення, так би мовити, з коробки. А в JavaScript ми можемо реалізувати ліниві обчислення, використовуючи певні конструкції, такі як генератори або замикання. Розглянемо приклад:
function* lazyRange(start, end) { let current = start; while (current <= end) { yield current; current++; } } // Usages const numbers = lazyRange(1, 5); numbers.next().value; // 1 (evaluate only now) numbers.next().value; // 2 (evaluate only now) numbers.next().value; // 3 (and so on)
Функція-генератор lazyRange — чудовий спосіб генерувати послідовність значень за необхідності. У цьому прикладі вона повертає числа від 1 до 5. Кожен виклик next().value надає наступне число. Коли всі числа вичерпано, генератор повертає undefined. Генератори особливо корисні для тестування. Вони дозволяють легко симулювати складні сценарії, наприклад, послідовність асинхронних запитів, що значно спрощує процес розробки та відлагодження (перевірено на власному досвіді).
Map/Reduce
Операції map і reduce не є «принципами», як чисті функції, але вони є важливими інструментами функціонального програмування. Щоб пояснити дану концепцію, пропоную розібрати ось такий приклад:
У нас є вхідний масив — це чіабата, огірки, томати і шматок сиру. Ми застосовуємо map(), щоб виконати процес нарізання всіх цих інгредієнтів. Потім ми застосуємо reduce(), що зменшити та перетворити всю кількість інгредієнтів на сендвічі.
Для перетворення даної послідовності операцій в JavaScript-код я використала ChatGPT, і ось що отримала в результаті:
function makeSandwiches(ingredients) { return ingredients .reduce((acc, ingredient, index) => { // Group every two ingredients into a single sandwich if (index % 2 === 0) { // Start a new sandwich with the first ingredient acc.push([ingredient]); } else { // Add the second ingredient to the current sandwich acc[acc.length - 1].push(ingredient); } return acc; }, []) .map(filling => ({ bread: 'ciabatta', filling, // Add the grouped ingredients as filling })); } // Example usage const ingredients = ['ham', 'cheese', 'tomato', 'lettuce', 'turkey', 'avocado']; const sandwiches = makeSandwiches(ingredients); // result [{ bread: "ciabatta", filling: ['ham', 'cheese']}, ...]
ChatGPT пропонує трішки іншу послідовність: спочатку reduce(), потім map(), що є більш логічним.
Варто зазначити, що дану задачу можна виконати використовуючи тільки .reduce(), і тоді у нас буде одне проходження по масиву, тобто Big O(n) замість Big O(2n), і ефективніше по пам’яті, оскільки буде створений тільки один масив (імутабельні методи завжди повертають новий масив).
Монади і функтори
Не можна просто так взяти і не згадати про монади і функтори, коли говоримо про функціональне програмування. Давайте розбиратись 🙂
Функтор і монад — це концепції, запозичені з теорії категорій, тобто з математики, які знайшли широке застосування у функціональному програмуванні.
Функтор
Це об’єкт, який реалізує метод map (можлива інша назва). Тобто метод, який трансформує дані і може бути застосований до іншого об’єкта, трансформуючи його. Наприклад, функтори в Haskell і SKALA виглядатимуть так:
// Scala trait Functor[F[_]] { def map[A, B](fa: F[A])(f: A => B): F[B] }
# Haskell class Functor f where fmap :: (a -> b) -> f a -> f b
Як на рахунок JavaScript? Метод масиву map() є класичним прикладом функтора. Він приймає функцію як аргумент і застосовує її до кожного елемента масиву.
const numbers = [1, 2, 3, 4]; // increase every number by 42 numbers.map(n => n + 42)
Тобто масив можна вважати функтором, досить просто.
Але є один нюанс — в JavaScript, на відміну від Haskell і SKALA, метод map () в callback приймає два аргумента — саме значення і індекс, тобто це дещо порушує класичну реалізацію. Але, думаю, ми можемо зробити виключення і вважати, що це все-таки класичний функтор.
Якщо вам цікаво спробувати попрацювати із класичними функторами, то рекомендую спробувати таку бібліотеку як Ramda, тому що там map() реалізований по всіх канонах функціонального програмування.
import R from 'ramda'; // Функція для глибокого копіювання, // використовуючи функтор для обробки масивів та об'єктів const deepClone = R.map(x => { if (R.is(Object, x)) { return deepClone(x); } else if (R.is(Array, x)) { return deepClone(x); } else { return x; } }); const originalObject = { a: 1, b: [2, 3], c: { d: 4 } }; const clonedObject = deepClone(originalObject); console.log(clonedObject); // result { a: 1, b: [ 2, 3 ], c: { d: 4 } }
Або ж написати свою власну реалізацію за прикладом:
const Functor = value => ({ map: fn => Functor(fn(value)), chain: fn => fn(value), of: () => value }); // Usages function randomIntWrapper(max) { return Functor(max) .map(n => n + 1) // max + 1 .map(n => n * Math.random()) // Math.random() * (max + 1) .map(Math.floor) // Math.floor(Math.random() * (max + 1)) } const randomNumber = randomIntWrapper(333) randomNumber.of() // returns number between 0 and 333 randomNumber.chain(n => n - 1) // returns (number between 0 and 333) - 1
Монад
Це абстрактна концепція, яка використовується у функціональному програмуванні для структурування та управління обчисленнями. Її можна уявити як «контейнер», який може містити значення або не містити його взагалі. Цей «контейнер» також надає спеціальні методи для роботи зі значенням, незалежно від того, чи воно присутнє, чи відсутнє.
Концепція монади на прикладі доставки піци. Уявіть собі, що ви замовляєте піцу онлайн. Процес можна розбити на кілька етапів:
- Вибір піци — обираємо піцу з меню.
- Оформлення замовлення — вказуємо адресу доставки та інші деталі.
- Підтвердження замовлення — ресторан підтверджує замовлення.
- Доставка — кур’єр доставляє нам піцу.
Кожен з цих етапів може «провалитися». Наприклад, піци може не бути в наявності, ми можемо помилитися адресою, ресторан може бути закритий, або кур’єр може заблукати.
При використанні імперативного підходу нам потрібно постійно перевіряти, чи успішно пройшов кожен етап:
function deliverPizza(order) { const pizza = choosePizza(order.pizzaType); return "Піци немає в наявності"; } const orderConfirmation = confirmOrder(order); if (!orderConfirmation) { return "Помилка оформлення замовлення"; } const deliveryStatus = deliver(orderConfirmation.orderId); if (deliveryStatus === "Загублено") { return "Кур'єр загубився"; } else if (deliveryStatus === "Доставлено") { return "Піца доставлена!"; } }
Підхід з Maybe монадою (або схожою абстракцією). Ми можемо створити «контейнер» (монаду), який буде представляти результат кожного етапу. Цей «контейнер» може містити значення (успіх) або ні (невдача).
const Result = { Success: (value) => ({ map: (fn) => Result.Success(fn(value)), // Apply function to value bind: (fn) => fn(value), // Chain operations getOrElse: (defaultValue) => value, // Get value or default }), Failure: (error) => ({ map: (fn) => Result.Failure(error), // Propagate error bind: (fn) => Result.Failure(error), // Propagate error getOrElse: (defaultValue) => defaultValue, // Return default }), }; function deliverPizza(order) { return choosePizza(order.pizzaType) // Choose pizza .bind(pizza => confirmOrder(order) // Confirm order .map(orderConfirmation => ({ pizza, orderConfirmation }))) // Combine results .bind(({ pizza, orderConfirmation }) => deliver(orderConfirmation.orderId) // Deliver pizza .map(deliveryStatus => ({ pizza, orderConfirmation, deliveryStatus }))) // Combine results .map(({ pizza, orderConfirmation, deliveryStatus }) => { // Handle final result if (deliveryStatus === "Delivered") { return "Pizza delivered!"; } else { return `Error appear: ${deliveryStatus}`; // Handle error } }) .getOrElse("Something goes wrong"); // Handle any error } // Functions is use function choosePizza(pizzaType) { /* ... */ } function confirmOrder(order) { /* ... */ } function deliver(orderId) { /* ... */ }
Пояснення:
- Result.Success(value) — «контейнер» зі значенням (success).
- Result.Failure(error) — «контейнер» з помилкою (failure).
- map(fn) — застосовує функцію fn до значення, якщо воно є (success). Якщо «контейнер» містить помилку, map нічого не робить.
- bind(fn) — схожий на map, але дозволяє повертати інший «контейнер» (інший етап).
- getOrElse(defaultValue) — повертає значення з «контейнера», якщо воно є (успіх). Якщо «контейнер» містить помилку, повертає значення за замовчуванням.
Переваги:
- Код стає більш структурованим. Ми бачимо послідовність етапів, і нам не потрібно постійно перевіряти, чи кожен етап успішний.
- Легше обробляти помилки. Ми можемо обробити всі помилки в кінці, використовуючи getOrElse.
- Можливість комбінувати етапи. bind дозволяє нам «зв’язувати» результати різних етапів.
Тобто можна вважати, що монад — це коли функції поводяться добре навіть тоді, коли вони хочуть бути хуліганами.
Останній приклад, який ви точно зустрічали в роботі — це Promises. Вони, хоч і не повністю задовольняють критерії монад, явно демонструють вплив монадичних структур даних, що підтверджується їхнім широким застосуванням:
// Приклад 1 Promise.resolve(10) .then(x => x + 5) // 15 .then(x => x * 2) // 30 .then(console.log); // Output: 30 // Приклад 2 const fetchUser = userId => fetch(`https://api.example.com/users/${userId}`) .then(response => response.json()) .catch(error => Promise.reject(error)); fetchUser(123) .then(getUserAddress) .then(address => console.log(address)) .catch(error => console.error(error));
Висновки
Що ж, ми з вами здійснили невеличку подорож у світ функціонального програмування, починаючи з його математичного коріння. Ми розібрали 8 стовпів, на яких тримається функціональне програмування, і подивилися, як вони працюють на практиці в JavaScript — одній з найпопулярніших мов програмування. Гадаю, тепер навіть монади і функтори стали для вас трохи зрозумілішими, і ви переконалися, що це не така вже й страшна звірина.
Важливо пам’ятати, що функціональне програмування не є універсальним рішенням для кожного проєкту. Необхідно враховувати специфіку проєкту, існуючі стандарти та рівень підготовки команди. Однак розуміння принципів функціонального програмування значно розширить ваші можливості та стане цінним інструментом у багатьох ситуаціях.
І наостанок моя рекомендація — слідуйте прикладу сильного Doge і прокачуйте в собі справжнього інженера, якому цікаво досліджувати і розбиратись в OOP, FPP, алгоритмах, структурах даних, патернах проєктування та інших суміжних технологіях.
73 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів