20 сентября

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

Блог компании RUVDS.comРазработка веб-сайтовJavaScript
Перевод
Автор оригинала: bitfish
Каждый программист знаком с функциями. В JavaScript функции отличаются множеством возможностей, что позволяет называть их «функциями высшего порядка». Но, даже если вы постоянно пользуетесь JavaScript-функциями, возможно, им есть чем вас удивить.



В этом материале я расскажу о некоторых продвинутых возможностях JavaScript-функций. Надеюсь, вам пригодится то, что вы сегодня узнаете.

Чистые функции


Функция, которая соответствует двум следующим требованиям, называется чистой:

  • Она всегда, при вызове её с одними и теми же аргументами, возвращает один и тот же результат.
  • При выполнении функции не происходит никаких побочных эффектов.

Рассмотрим пример:

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

Если этой функции передаётся одно и то же значение radius, она всегда возвращает один и тот же результат. При этом в ходе выполнения функции не изменяется ничего за её пределами, то есть — у неё нет побочных эффектов. Всё это значит, что перед нами — чистая функция.

Вот ещё один пример:

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

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


Испытание функции в консоли браузера

Как видно, функция 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) {    
    // Представим, что в этой функции проводятся ресурсозатратные вычисления
    console.log('2000s have passed')
      
    // Представим, что тут возвращается результат вычислений
    return 'a result'
}

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

Как оснастить функцию кешем? Для этого можно написать особую функцию, которую можно использовать в качестве обёртки для целевой функции. Этой особой функции мы дадим имя cached. Данная функция принимает целевую функцию в виде аргумента и возвращает новую функцию. В функции cached можно организовать кеширование результатов вызова оборачиваемой ей функции с использованием обычного объекта (Object) или с помощью объекта, представляющего собой структуру данных Map.

Вот как может выглядеть код функции cached:

function cached(fn){
  // Создаёт объект для хранения результатов, возвращаемых после каждого вызова функции fn.
  const cache = Object.create(null);

  // Возвращает функцию fn, обёрнутую в кеширующую функцию.
  return function cachedFn (str) {

    // Если в кеше нет нужного результата - вызывается функция fn
    if ( !cache[str] ) {
        let result = fn(str);

        // Результат, возвращённый функцией fn, сохраняется в кеше
        cache[str] = result;
    }

    return cache[str]
  }
}

Вот результаты экспериментов с этой функцией в консоли браузера.


Эксперименты с функцией, результаты работы которой кешируются

«Ленивые» функции


В телах функций обычно имеются некие инструкции для проверки каких-то условий. Иногда соответствующие им условия нужно проверить лишь один раз. Их нет смысла проверять при каждом вызове функции.

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

Предположим, нам нужно написать функцию 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 <= 1) return fn;
    const generator = (...args) => {
        if (fn.length === args.length) {

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

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

Вот результаты экспериментов с этой функцией в браузерной консоли.


Эксперименты с функцией curry в консоли браузера

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


Предположим, надо написать функцию, которая, принимая на вход, например, строку 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));
};

Поэкспериментируем с ней.


Испытание функции в консоли браузера

Эта задача включает в себя две подзадачи, оформленные в виде отдельных функций. В результате код функции 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-проектах какие-то особенные способы работы с функциями?



Теги:JavaScriptразработка
Хабы: Блог компании RUVDS.com Разработка веб-сайтов JavaScript
+8
16,1k 141
Комментарии 21
Похожие публикации
Лучшие публикации за сутки