Секрети JavaScript-функцій

Alex Alex 22 жовтня
Секрети JavaScript-функцій

Кожен програміст знайомий з функціями. В JavaScript функції відрізняються безліччю можливостей, що дозволяють називати їх «функціями вищого порядку». Але, чи дійсно ви добре знаєте як їх використовувати?

У цьому матеріалі я розповім про деякі просунуті можливості JavaScript-функцій, які, я сподіваюся, можуть стати вам в нагоді.

Чисті функції

Функція, яка відповідає цим двом вимогам, називається чистою:

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

Розглянемо приклад:

function circleArea(radius){
  return radius * radius * 3.14
}

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

Ось ще один приклад:

let counter = (function(){
  let initValue = 0
  return function(){
    initValue++;
    return initValue
  }
})()

Запустимо цю функцію в консолі браузера.

Секрети JavaScript-функцій

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

А ось - ще приклад:

let femaleCounter = 0;
let maleCounter = 0;
function isMale(user){
  if(user.sex == 'man'){
    maleCounter++;
    return true
  }
  return false
}

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

Навіщо потрібні чисті функції?

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

1. Код чистих функцій зрозуміліший, ніж код звичайних функцій, його легше читати

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

2. Чисті функції краще піддаються оптимізації при компіляції їх коду

Припустимо, є такий фрагмент коду:

for (int i = 0; i < 1000; i++){
    console.log(fun(10));
}

Якщо fun - це функція, яка не є чистою, то під час виконання цього коду цю функцію доведеться викликати у вигляді fun(10) 1000 разів.

А якщо fun - це чиста функція, то компілятор зможе оптимізувати код. Він може виглядати приблизно так:

let result = fun(10)
for (int i = 0; i < 1000; i++){
    console.log(result);
}

3. Чисті функції легше тестувати

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

Ось простий приклад. Чиста функція приймає у вигляді аргументу масив чисел і додає до кожного елементу цього масиву число 1, повертаючи новий масив. Ось її скорочене представлення:

const incrementNumbers = function(numbers){
  // ...
}

Для перевірки такої функції досить написати модульний тест:

let list = [1, 2, 3, 4, 5];
assert.equals(incrementNumbers(list), [2, 3, 4, 5, 6])

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

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

Функція вищого порядку - це функція, яка володіє як мінімум однією з наступних можливостей:

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

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

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

Якщо не користуватися можливостями функцій вищого порядку, то рішення цього завдання може виглядати так:

const arr1 = [1, 2, 3];
const arr2 = [];
for (let i = 0; i < arr1.length; i++) {
    arr2.push(arr1[i] * 2);
}

Якщо ж над завданням подумати, то виявиться, що у об'єктів типу Array в JavaScript є метод map(). Цей метод викликають у вигляді map(callback). Він створює новий масив, заповнений елементами масиву, для якого його викликають, обробленими за допомогою переданої йому функції callback.

Ось як виглядає рішення цього завдання з використанням методу map():

const arr1 = [1, 2, 3];
const arr2 = arr1.map(function(item) {
  return item * 2;
});
console.log(arr2);

Метод map() - це приклад функції вищого порядку.

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

Кешування результатів роботи функцій

Припустимо, є чиста функція, яка виглядає так:

function computed(str) {    
    // Suppose the calculation in the funtion is very time consuming 
    console.log('2000s have passed')
      
    // Suppose it is the result of the function
    return 'a result'
}

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

Як оснастити функцію кешем? Для цього можна написати особливу функцію, яку можна використовувати в якості обгортки для цільової функції. Цій особливій функції ми дамо ім'я cached. Ця функція приймає цільову функцію у вигляді аргументу і повертає нову функцію. У функції cached можна організувати кешування результатів виклику обертатися нею функції з використанням звичайного об'єкта Object або за допомогою об'єкта, що представляє собою структуру даних Map.

Ось як може виглядати код функції cached:

function cached(fn){
  // Create an object to store the results returned after each function execution.
  const cache = Object.create(null);

  // Returns the wrapped function
  return function cachedFn (str) {

    // If the cache is not hit, the function will be executed
    if ( !cache[str] ) {
        let result = fn(str);

        // Store the result of the function execution in the cache
        cache[str] = result;
    }

    return cache[str]
  }
}

Ось результати експериментів з цією функцією в консолі браузера.

Секрети JavaScript-функцій

«Ледачі» функції

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

Поліпшити продуктивність функції можна «видаляючи» ці інструкції після їх першого виконання. В результаті виявиться, що функції, при її наступних викликах, не доведеться проводити перевірки, які будуть уже непотрібними. Це і буде «лінива» функція.

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

Її код може виглядати так:

let fooFirstExecutedDate = null;
function foo() {
    if ( fooFirstExecutedDate != null) {
      return fooFirstExecutedDate;
    } else {
      fooFirstExecutedDate = new Date()
      return fooFirstExecutedDate;
    }
}

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

А саме, функцію ми можемо переписати ось так:

var foo = function() {
    var t = new Date();
    foo = function() {
        return t;
    };
    return foo();
}

Після першого виклику функції ми замінюємо вихідну функцію новою. Ця нова функція повертає значення t - створений при першому виклику функції  об'єкт Date. В результаті ніяких умов при виклику подібної функції перевіряти не потрібно. Такий підхід здатний поліпшити продуктивність коду.

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

При підключенні до елементів DOM обробників подій потрібно перевіряти наявність, що дозволяє забезпечити сумісність рішення з сучасними браузерами і з IE:

function addEvent (type, el, fn) {
    if (window.addEventListener) {
        el.addEventListener(type, fn, false);
    }
    else if(window.attachEvent){
        el.attachEvent('on' + type, fn);
    }
}

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

function addEvent (type, el, fn) {
  if (window.addEventListener) {
      addEvent = function (type, el, fn) {
          el.addEventListener(type, fn, false);
      }
  } else if(window.attachEvent){
      addEvent = function (type, el, fn) {
          el.attachEvent('on' + type, fn);
      }
  }
  addEvent(type, el, fn)
}

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

Каррування функцій (або Каррінг)

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

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

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

Розглянемо просту функцію яка складає передані їй числа. Назвемо її add. Вона приймає три операнда у вигляді аргументів і повертає їх суму:

function add(a,b,c){
 return a + b + c;
}

Таку функцію можна викликати, передавши їй меншу кількість аргументів, ніж їй потрібно (правда, це призведе до того, що поверне вона зовсім не те, чого від неї очікують). Її можна викликати і з більшою кількістю аргументів, ніж передбачено при її створенні. У подібній ситуації «зайві» аргументи будуть просто проігноровані. Ось як можуть виглядати експерименти з подібною функцією:

add(1,2,3) --> 6 
add(1,2) --> NaN
add(1,2,3,4) --> 6 //Додатковий параметр ігнорується.

Як карріровати таку функцію?

Ось - код функції curry, яка призначена для каррінгу інших функцій:

function curry(fn) {
    if (fn.length  {
        if (fn.length === args.length) {

            return fn(...args)
        } else {
            return (...args2) => {

                return generator(...args, ...args2)
            }
        }
    }
    return generator
}

Ось результати експериментів з цією функцією в консолі браузера.

Секрети JavaScript-функцій

Композиція функцій (compose)

Припустимо, треба написати функцію, яка, приймаючи на вхід, наприклад, рядок bitfish і повертає рядок HELLO, BITFISH.

Як видно, ця функція виконує два завдання:

  • Конкатенація рядків.
  • Перетворення символів результуючого рядка до верхнього регістру.

Ось як може виглядати код такої функції:

let toUpperCase = function(x) { return x.toUpperCase(); };
let hello = function(x) { return 'HELLO, ' + x; };
let greet = function(x){
    return hello(toUpperCase(x));
};

Поекспериментуємо з нею.

Секрети JavaScript-функцій

Це завдання включає в себе дві підзадачі, оформлені у вигляді окремих функцій. В результаті код функції greet вийшов досить простим. Якби потрібно було виконати більше операцій над рядками, то функція greet містила б в собі конструкцію на зразок fn3(fn2(fn1(fn0(x)))).

Спростимо рішення задачі і напишемо функцію, яка виконує композицію інших функцій. Назвемо її compose. Ось її код:

let compose = function(f,g) {
    return function(x) {
        return f(g(x));
    };
};

Тепер функцію greet можна створити, вдавшись до допомоги функції compose:

let greet = compose(hello, toUpperCase);
greet('kevin');

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

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

Подібна функція, здатна приймати будь-яку кількість параметрів, є в широко відомій бібліотеці underscore.

function compose() {
    var args = arguments;
    var start = args.length - 1;
    return function() {
        var i = start;
        var result = args[start].apply(this, arguments);
        while (i--) result = args[i].call(this, result);
        return result;
    };
};

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

Чи застосовуєте ви в своїх JavaScript-проектах якісь особливі способи роботи з функціями?

Джерело ENG: medium.com

Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Війти / Зареєструватися