Функции высших порядков в JS: курс молодого бойца +8


Данная статья рассчитана на человека, делающего свои первые робкие шаги на тернистой тропе изучения JavaScript. Несмотря на то, что на дворе 2018 год, я использую синтаксис ES5, дабы статья была понятной юным падаванам, проходящим курс «JavaScript, уровень 1» на HTML Academy.

Одной из особенностей, отличающих JS от многих других языков программирования, является то, что в этом языке функция — «объект первого класса». Или, говоря по-русски, функция — это значение. Такое же, как число, строка или объект. Мы можем записать функцию в переменную, можем положить её в массив или в свойство объекта. Мы даже можем сложить две функции. На самом деле, ничего осмысленного из этого не получится, но как факт — мы можем!

function hello(){};
function world(){};
console.log(hello + world);
// кто знает, что получится, тому печеньку
// кто не знает, пусть попробует в консоли

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

Pipeline


Допустим, у нас есть штука, с которой нужно сделать много штук. Скажем, пользователь загрузил текстовый файл, в котором хранятся данные в формате JSON, и мы хотим обработать его содержимое. Сначала нам надо обрезать лишние пробельные символы, которые могли «нарасти» по краям в результате действий пользователя или операционной системы. Потом проверить, что в тексте нет никакого вредоносного кода (кто их знает, этих пользователей). Потом превратить из текста в объект с помощью метода JSON.parse. Потом вынуть из этого объекта нужные нам данные. И в конце концов — отправить эти данные на сервер. Получится что-то в этом роде:

function trim(){/* опустим */};
function sanitize(){/* детали */};
function parse(){/* для */};
function extractData(){/* пущей */};
function send(){/* ясности */};

var textFromFile = getTextFromFile();
send(extractData(parse(sanitize(trim(testFromFile))));

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

textFromFile |> trim |> sanitize |> parse |> extractData |> send;

Оператор |> берёт свой левый операнд и передаёт его правому операнду в качестве аргумента. Например, "Hello" |> console.log равносильно console.log("Hello"). Это очень удобно именно для случаев, когда несколько функций вызываются по цепочке. Однако до внедрения этого оператора пройдёт ещё немало времени (если это предложение вообще примут), а жить как-то надо уже сейчас. Поэтому мы можем написать свою велосипед функцию, имитирующую данное поведение:

function pipe(){
  var args = Array.from(arguments);
  var result = args.shift();
  while(args.length){
    var f = args.shift();
    result = f(result);
  }
  return result;
}

pipe(
  textFromFile,
  trim,
  sanitize,
  parse,
  extractData,
  send
);

Если вы начинающий джаваскриптист (джаваскриптер? джаваскриптчик?), вам может показаться непонятной первая строчка функции. Всё просто: внутри функции мы используем ключевое слово arguments, чтобы получить доступ к массивоподобному объекту, содержащему все аргументы, переданные функции. Это очень удобно, когда мы не знаем заранее, сколько аргументов у неё будет. Массивоподобный объект — это как массив, но не совсем. Поэтому мы преобразуем его в нормальный массив с помощью метода Array.from. Дальнейший код, я надеюсь, уже достаточно читаемый: мы начинаем слева направо извлекать элементы из массива и применять их друг к другу аналогично тому, как это бы делал оператор |>.

Логирование


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

Конечно, мы можем при каждом вызове функции писать так:

var result = f(a, b);
console.log("Функция f была вызвана с аргументами " + a + " и " + b + 
  " и вернула " + result);
console.log("Штамп времени: " + Date.now());

Но, во-первых, это достаточно громоздко. А во-вторых, про это очень легко забыть. Однажды мы напишем просто f(a, b), и с тех пор тьма неведения поселится в наших умах. Она будет шириться с каждым новым вызовом f, о котором мы ничего не знаем.

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

function addLogger(f){
  return function(){
    var args = Array.from(arguments);
    var result = f.apply(null, args);
    console.log("Функция " + f.name + " была вызвана с аргументами " + 
      args.join() + " и вернула значение " + result + "\n" +
      "Штамп времени: " + Date.now());
    return result; 
  }
}

function sum(a, b){
  return a + b;
}

var sumWithLogging = addLogger(sum);
sum(1, 2); //в консоли тишина
sumWithLogging(1, 2) //тишина нарушена

Функция принимает функцию и возвращает функцию, вызывающую функцию, переданную функции при создании функции. Простите, я не мог удержаться от того, чтобы это написать. Теперь по-русски: функция addLogger создаёт вокруг функции, переданной ей в качестве аргумента, некую обёртку. Обёртка — это тоже функция. При вызове она собирает массив своих аргументов аналогично тому, как мы делали в предыдущем примере. Затем она с помощью метода apply вызывает «обёртываемую» функцию с теми же самыми аргументами и запоминает результат. После этого обёртка пишет всякое в консоль.

Здесь мы имеем классический случай атаки «человек-в-середине». Если вместо f использовать обёртку, то с точки зрения использующего её кода разницы практически никакой. Код может считать, что общается с f напрямую. А тем временем обёртка обо всём докладывает товарищу майору.

Eins, zwei, drei, vier...


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

var lastNumber = 0;
function getNewNumber(){
  return lastNumber++;
}

А потом у нас появляется новый вид сущностей. Скажем, до этого мы нумеровали зайчиков, а теперь появились ещё и кролики. Если пользоваться одной функцией и для тех, и для других, то каждый номер, выданный кроликам, проделает «дыру» в ряду номеров, выданных зайчикам. Значит, нам нужна вторая функция, а вместе с ней — и вторая переменная:

var lastHareNumber = 0;
function getNewHareNumber(){
  return lastHareNumber++;
}
var lastRabbitNumber = 0;
function getNewRabbitNumber(){
  return lastRabbitNumber++;
}

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

И тут врывается человек, знакомый с концепцией ООП, и говорит:
— Элементарно, Уотсон. Нужно генераторы номеров сделать не функциями, а объектами. Объекты как раз и предназначены для того, чтобы хранить функции, работающие с данными, вместе с этими самыми данными. Тогда мы смогли бы писать нечто вроде:

var numberGenerator = new NumberGenerator();
var n = numberGenerator.get();

На что я отвечу:
— Если честно, я с вами полностью согласен. И в принципе это более правильный подход, чем то, что я сейчас предложу. Но у нас тут статья про функции, а не про ООП. Так что не могли бы вы немного помолчать и дать мне закончить?

Здесь нам (сюрприз!) снова поможет функция высшего порядка.

function createNumberGenerator(){
  var n = 0;
  return function(){
    return n++;
  }
}

var getNewHareNumber = createNumberGenerator();
var getNewRabbitNumber = createNumberGenerator();

console.log(
  getNewHareNumber(),
  getNewHareNumber(),
  getNewHareNumber(),
  getNewRabbitNumber(),
  getNewRabbitNumber(),
); // в консоли будет 0, 1, 2, 0, 1

И тут у некоторых людей может возникнуть вопрос, возможно даже в нецензурной форме: что, чёрт подери, происходит? Зачем мы создаём переменную, которая в самой функции никак не используется? Как внутренняя функция к ней обращается, если внешняя давно завершила своё выполнение? Почему две созданные функции, обращаясь к одной и той же переменной получают разный результат? На все эти вопросы один ответ — замыкание.

Каждый раз, когда вызывается функция createNumberGenerator, интерпретатор JS создаёт волшебную штуку под названием «контекст выполнения». Грубо говоря, это такой объект, в котором хранятся все объявляемые в этой функции переменные. Мы не можем получить к нему доступ как к обычному джаваскриптовому объекту, но тем не менее он есть.

Если функция была «простая» (скажем, сложение чисел), то после окончания её работы контекст выполнения оказывается никому не нужен. А знаете, что происходит с ненужными данными в JS? Их пожирает ненасытный демон по имени Garbage Collector. Однако если функция была «непростая», может случиться так, что её контекст кому-то всё ещё нужен даже после того, как эта функция выполнилась. В таком случае Garbage Collector щадит его, и он остаётся висеть где-то в памяти, чтобы те, кому он нужен, по-прежнему могли иметь к нему доступ.

Таким образом, функция, возвращаемая createNumberGenerator, всегда будет иметь доступ к собственной копии переменной n. Можете думать об этом как о Bag of Holding из D&D. Засовываешь руку в сумку, попадаешь в личный межпространственный «карман», где можешь хранить всё, что пожелаешь.

Debounce


Есть такое понятие, как «устранение дребезга». Это когда мы не хотим, чтобы какая-то функция вызывалась слишком часто. Допустим, есть некая кнопка, нажатие на которую запускает «дорогостоящий» (долгий, или жрущий много памяти, или интернета, или приносящий в жертву девственниц) процесс. Может случиться так, что нетерпеливый пользователь начнёт кликать по этой кнопке с частотой более десяти герц. При этом вышеупомянутый процесс имеет такую природу, что запускать его десять раз подряд никакого смысла нет, потому что конечный результат не изменится. Именно тогда мы применяем «устранение дребезга».

Суть его очень проста: мы выполняем функцию не сразу, а спустя некоторое время. Если до того, как пройдёт это время, функция была вызвана ещё раз, мы «сбрасываем таймер». Таким образом, пользователь может хоть тысячу раз кликнуть по кнопке — девственница для жертвоприношения понадобится всего одна. Однако меньше слов, больше кода:

function debounce(f, delay){
  var lastTimeout;
  return function(){
    if(lastTimeout){
      clearTimeout(lastTimeout);
    }
    var args = Array.from(arguments);
    lastTimeout = setTimeout(function(){
      f.apply(null, args);
    }, delay);
  }
}

function sacrifice(name){
  console.log(name + " была принесена в жертву *демонический смех*");
}

function sacrificeDebounced = debounce(sacrifice, 500);

sacrificeDebounced("Катя");
sacrificeDebounced("Света");
sacrificeDebounced("Лена");

Через полсекунды Лена будет принесена в жертву, а Катя и Света останутся в живых благодаря нашей волшебной функции.

Если вы внимательно читали предыдущие примеры, то должны хорошо понимать, как тут всё работает. Функция-обёртка, создаваемая debounce, запускает отложенное выполнение исходной функции с помощью setTimeout. При этом идентификатор таймаута сохраняется в переменную lastTimeout, доступную обёртке благодаря замыканию. Если в этой переменной уже лежит идентификатор таймаута, обёртка отменяет этот таймаут с помощью clearTimeout. Если предыдущий таймаут уже успел выполниться, то ничего не происходит. Если нет, тем хуже для него.

На этом, пожалуй, я закончу. Надеюсь, сегодня вы узнали много нового, а главное — поняли всё, что узнали. До новых встреч.




К сожалению, не доступен сервер mySQL