Фильтрация и создание цепочек в функциональном JavaScript

Original author: David Green
  • Translation


Предлагаем перевод статьи, которая позволит освежить свои знания по теме, а также будет полезна новичкам в JavaScript, пока ещё осваивающим этот язык.

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

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

Фильтрация для ограничения датасетов


С появлением ES5 массивы в JS унаследовали несколько методов, делающих функциональное программирование ещё удобнее. Теперь массивы нативно поддерживают map, reduce и filter. Каждый метод проходит по всем элементам массива, и выполняет анализ без использования циклов и изменения локальных состояний. Результат может быть возвращён для немедленного использования, или оставлен для последующей обработки.

В этой статье мы рассмотрим процедуру фильтрации. Она позволяет вычислять каждый элемент массива. На основе передаваемого тестового условия (test condition) определяется, нужно ли возвращать новый массив, содержащий результаты вычисления. При использовании метода filter вы получаете в ответ ещё один массив, той же длины или меньше исходного. Он содержит подмножество элементов из исходного массива, удовлетворяющие заданным условиям.

Использование цикла для демонстрации фильтрации


Пример проблемы, решить которую поможет фильтрация — ограничение массива, содержащего строковые значения, только теми, что состоят из трёх символов. Задача не сложная, и решить её можно довольно искусно с помощью ванильных JS-циклов for, без использования filter. Например:

var animals = ["cat","dog","fish"];
var threeLetterAnimals = [];
for (let count = 0; count < animals.length; count++){
  if (animals[count].length === 3) {
    threeLetterAnimals.push(animals[count]);
  }
}
console.log(threeLetterAnimals); // ["cat", "dog"]

Определили массив, содержащий три строковых значения. Создали пустой массив для хранения только строковых из трёх символов. Определили переменную-счётчик для цикла for, используемую по мере итерирования массива. Каждый раз, когда цикл находит строковое значение из трёх символов, он помещает его во второй массив. По завершении работы результат журналируется.

Ничто не мешает менять исходный массив в цикле. Но если мы это сделаем, то потеряем исходные значения. Лучше создать новый массив, а исходный не трогать.

Использование метода Filter


Предыдущее решение технически корректно. Но использование метода filter позволяет сделать код гораздо чище и проще. Например:

var animals = ["cat","dog","fish"];
var threeLetterAnimals = animals.filter(function(animal) {
  return animal.length === 3;
});
console.log(threeLetterAnimals); // ["cat", "dog"]

Здесь мы тоже начали с переменной, содержащей исходный массив. Определили новую переменную для массива, куда будем класть строковые из трёх символов. Но применив метод filter, мы напрямую связали результаты фильтрации со вторым массивом. Передаём filter анонимной встроенной (in-line) функции, возвращающей true, если длина оперируемого значения равна трём.

Метод filter работает так: проходит по каждому элементу массива и применяет к нему тестовую функцию (test function). Если функция возвращает true, то метод filter возвращает массив, содержащий этот элемент. Другие элементы пропускаются.

Код получился гораздо чище. Даже не зная заранее, что делает filter, вы из кода можете понять его принцип действия.

Чистота кода — один из приятных побочных продуктов функционального программирования. Это следствие ограничения преобразования внешних переменных из функций и необходимости хранить меньше локальных состояний. Переменная count и разные состояния, принимаемые массивом threeLetterAnimals при прохождении циклов по исходному массиву, это дополнительные состояния, которые надо отслеживать. Метод filter избавил нас от цикла и переменной count. И мы не меняем многократно значение для нового массива, как в первом случае. Мы определили его один раз и связали со значением, получаемым в результате применения условия filter к исходному массиву.

Другие способы форматирования Filter


Можно написать ещё короче. Воспользуемся объявлениями const и анонимными встроенными стрелочными функциями (inline arrow functions). Это благодаря EcmaScript 6 (ES6), который нативно поддерживается большинством браузеров и JavaScript-движков.

const animals = ["cat","dog","fish"];
const threeLetterAnimals = animals.filter(item => item.length === 3);
console.log(threeLetterAnimals); // ["cat", "dog"]

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

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

Чтобы сделать код читабельнее и гибче, можно сделать так. Взять анонимную встроенную стрелочную функцию, превратить в традиционную именованную и передать прямо в метод filter. Это может выглядеть так:

const animals = ["cat","dog","fish"];
function exactlyThree(word) {
  return word.length === 3;
}
const threeLetterAnimals = animals.filter(exactlyThree);
console.log(threeLetterAnimals); // ["cat", "dog"]

Здесь мы просто извлекли анонимную встроенную стрелочную функцию, определённую до этого, и превратили в отдельную именованную. Мы определили чистую функцию (pure function). Она получает соответствующий тип-значение для элементов массива, и возвращает такой же тип. Можем в качестве условия просто передать в filter имя этой функции.

Быстрый обзор Map и Reduce


Фильтрация работает рука об руку с двумя другими функциональными методами ES5 — map и reduce. Создавая цепочки методов, можно использовать эту комбинацию для написания очень чистого кода, выполняющего довольно сложные функции.

Напомним: метод map проходит по каждому элементу массива, преобразует его в соответствии с функцией и возвращает новый массив той же длины, но с преобразованными значениями.

const animals = ["cat","dog","fish"];
const lengths = animals.map(getLength);
function getLength(word) {
  return word.length;
}
console.log(lengths); //[3, 3, 4]

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

const animals = ["cat","dog","fish"];
const total = animals.reduce(addLength, 0);
function addLength(sum, word) {
  return sum + word.length;
}
console.log(total); //10

Все три метода оставляют нетронутым исходный массив, в соответствии с практикой функционального программирования.

Создание цепочек из Map, Reduce и Filter


Рассмотрим простейший пример. Допустим, вам нужно взять массив из строковых значений, и вернуть одно, состоящее из трёх символов. Но при этом отформатировать его в стиле StudlyCaps. Без map, reduce и filter это будет выглядеть примерно так:

const animals = ["cat","dog","fish"];
let threeLetterAnimalsArray = [];
let threeLetterAnimals;
let item;
for (let count = 0; count < animals.length; count++){
  item = animals[count];
  if (item.length === 3) {
    item = item.charAt(0).toUpperCase() + item.slice(1);
    threeLetterAnimalsArray.push(item);
  }
}
threeLetterAnimals = threeLetterAnimalsArray.join("");
console.log(threeLetterAnimals); // "CatDog"

Да, это работает. Но мы создали кучу лишних переменных, и поддерживаем состояние массива, который меняется по мере прохождения через разные циклы. Можно сделать лучше.

Объявлять целевой пустой массив можно с помощью let или const.

Создадим чистые функции, берущие и возвращающие строковые значения. Затем используем их в цепочках методов map, reduce и filter, передавая результаты от одного к другому:

const animals = ["cat","dog","fish"];
function studlyCaps(words, word) {
  return words + word;
}
function exactlyThree(word) {
  return (word.length === 3);
}
function capitalize(word) {
  return word.charAt(0).toUpperCase() + word.slice(1);
}
const threeLetterAnimals = animals
  .filter(exactlyThree)
  .map(capitalize)
  .reduce(studlyCaps);
console.log(threeLetterAnimals); // "CatDog"

Три чистые функции: studlyCaps, exactlyThree и capitalize. Можно передавать их напрямую в map, reduce и filter в пределах одной неразрывной цепочки. Сначала с помощью exactlyThree фильтруем исходный массив. Передаём результат в capitalize. А уже её результат обрабатываем с помощью studlyCaps. Финальный результат присваиваем напрямую переменной threeLetterAnimals. Без циклов и промежуточных состояний, не трогая исходный массив.

Получили очень понятный и легко тестируемый код. Чистые функции могут быть использованы в других контекстах или преобразованы.

Фильтрация и производительность


Не забывайте, что метод filter наверняка будет работать чуть медленнее, чем цикл for, пока браузеры и JS-движки не будут оптимизированы под новые методы работы с массивами (jsPerf).

Можно в любом случае порекомендовать использовать эти методы вместо циклов. Очень незначительное падение производительности окупается более чистым кодом, удобным в сопровождении. А оптимизировать лучше под реальные ситуации, когда действительно необходимо повысить скорость работы. В большинстве веб-приложений метод filter вряд ли будет узким местом. Но единственный способ убедиться в этом — попробовать самим.

Если же окажется, что filter в реальной ситуации работает значительно медленнее цикла, если это влияет на пользователей, то вы знаете, где и как можно оптимизировать. А по мере допиливания JS-движков производительность будет только расти.

Не бойтесь начать использовать фильтрацию. В ES5 эта функциональность нативна и поддерживается почти везде. Ваш код будет чище и проще в сопровождении. Благодаря методу filter вы не будете менять состояние массива по мере вычислений. Каждый раз вы будете возвращать новый массив, а исходный останется нетронутым.
NIX
Company

Comments 32

    0
    Здесь мы просто извлекли анонимную встроенную стрелочную функцию, определённую до этого, и превратили в отдельную именованную.

    Это бред. Не выдумывайте свои определения

      0

      Это перевод.

        0
        А подскажите, какое бредовое определение выдумал автор оригинального поста?

        extract the anonymous in-line arrow function we defined above and turn it into a separate named function
          +1
          Под «встроенными» в русском языке понимаются отнюдь не «инлайновые».
            0
            «Встроенная» никак не тянет на определение. Я согласен, впрочем, что обратный перевод скорее превращается в embedded.
            Вопрос на засыпку — а как перевести «inline» на русский язык?
              0
              Я думаю в данном контексте вполне уместно было бы перевести как анонимная однострочная стрелочная ф-ция. По крайней мере такие определения встречаются довольно часто. Я не переводчик, но лично мне так было бы понятней.
                +1

                Некоторое не переводится, например "инлайн стили" уже устоялись, было бы странно переводить как "встроенные стили". Если в контексте статьи и конкретной фарзы, то перевод слова инлайн избыточен, поэтому достаточно было написать "стрелочаня функция".

          0
          последний пример с редюсом какой-то надуманный, там вполне хватило-бы .join('')
          на мой вкус редюс лучше подходит для преобразования массивов в объект

          var dict = ['Cat', 'Dog', 'Birg'].reduce((result, an, id) => { result[an] = id; return result;});
          

            +1

            Только ваш пример работать не будет, вы забыли инициализировать начальное значение.


            var dict = ['Cat', 'Dog', 'Birg'].reduce((result, an, id) => { result[an] = id; return result;}, {});
              0
              Каюсь, поспешил и забыл элементарно проверить ( в своем глазу бревна не увидел :)
              0
              var dict = {} ; [ 'Cat', 'Dog', 'Birg' ].forEach( ( an , id )=> dict[ an ] = id )
              0
              Не забывайте, что метод filter наверняка будет работать чуть медленнее, чем цикл for, пока браузеры и JS-движки не будут оптимизированы под новые методы работы с массивами.


              Парой абзацев ниже

              Не бойтесь начать использовать фильтрацию. В ES5 эта функциональность нативна и поддерживается почти везде.


              АААААА, как быть то? Получается, не бояться использовать чтобы тормозило?
                0
                Консоль Google Chrome выдает «Array.filter (native)»
                Полагаю хоть немного, но все же оптимизированы.
                Хотя можно и проверить какой способ и насколько быстрее.
                  0
                  В IE9+ — уже не тормозит. Не говоря уж про другие браузеры.
                  0

                  А разве в основе filter/map/reduce не лежат простые циклы?

                    0
                    Согласно документации там в основе лежат обычные циклы с кучей проверок, но нативные методы могут должны быть оптимизированы.
                      0

                      Не совсем понятно в чем суть оптимизации если результат все равно будет O(n). Разве сейчас это уже не нативные методы? Во всяком случае в chrome Array.prototype.filter очень похож на нативный.

                        0
                        Нативный метод может быть реализован на javascript или еще хуже — смеси c++ и javascript, сейчас почти все методы массива не оптимизированы на типизированных массивах. O(n) тоже бывает разным.
                          0

                          Мне кажется это уже попахивает фанатизмом, конечно O(n) бывает разным, на разных поколениях машин. Давайте теперь не будем пользоваться умножением и делением, особенно внутри циклов, ведь это очень накладно. Если вы пишете компилятор C++ на JavaScript, то наверное вы что то делаете не так, а для повседневного использования, отрисовки списков или обработки асинхронных сообщений на стороне сервера вам должно за глаза хватить всех операций стоимостью O(n). Про корень зла не забыли?

                            0
                            Я про внутреннюю реализацию в браузере, сейчас в v8 эти методы переводят на с++ и они будут инлайнится компилятором чтоб убрать расходы на переключение между с++ и js и повысить производительность. Про O(n) — сравните 100 n и n ** 2 при небольших n — или n и 10000 n, все имеет значение, для этого и используют комбинированные алгоритмы. У меня повседневное использование это счет и отрисовка графики, компилятор — нет, хотя не вижу в этом ничего дурного, но кодеки писали, не сказал бы, что мы что-то делаем не так.
                              0

                              Неужели в счете и отрисовке вы используете filter/map/reduce? Хотя мы уходим немного в сторону. Вот смотрите кусочек Linq#Where кода аналога filter под Mono:


                                      static IEnumerable<TSource> CreateWhereIterator<TSource> (IEnumerable<TSource> source, Func<TSource, bool> predicate)
                                      {
                                          foreach (TSource element in source)
                                              if (predicate (element))
                                                  yield return element;
                                      }

                              Там есть еще один такой же для массивов с циклом for. Filter версия должна быть очень схожа. Я не вижу разницы буть то написано даже на js или прямиком на c++. Метод прост как трусы за рубль двадцать. Вот что здесь оптимизировать, какие тут мега сложные проверки, которые нужно устранить?

                                0
                                А почему я их не должен использовать? Я и Foldable монады использую с их методом reduce там где это помогает читабельности. Оптимизация — это уже второй вопрос, где нужно можно и через цикл переписать, но обычно до этого не доходит, тем более на этом много не выиграешь.

                                В моно у вас компилятор перед выполнением все проверки сделает и выдаст вам идеальный машинный код, а в жс это все будет делать JIT ему прийдется многое угадывать и про ваш элемент и про ваш предикат. А если код который будет выполняться в компиляторе написан на смеси жс и с++ (да v8, это как раз компилятор с++ на жс и с++) то у вас будет теряться время на переход, так что не все так просто.
                                  0
                                  Ну хотя метод и прост на первый взгляд, под использованием foreach (если это C#) спрятаны вызовы GetEnumerator, Next, Current — которые могут быть переопределены и сделаны вообще специально для того чтобы помогать вам разбираться с коллизиями в духе

                                  var a = ['a']
                                  a.filter(i => { a.push('b'); return Math.random() > 0.5 })
                                  


                                  Трусы за рубль двадцать будут несколько проще таки. И оптимизировать тут есть что.
                      0
                      Я правильно понимаю что
                      const threeLetterAnimals = animals
                      .filter(exactlyThree)
                      .map(capitalize)
                      .reduce(studlyCaps);

                      сначала создаст отфильтрованный массив, потом ещё дополнительно два раза по нему пробежится?
                      Если так, то выглядит, конечно, красиво, но как то не сильно производительно.
                      Ясное дело, это пример, но всё равно выглядит как то не очень.
                        0

                        Добро пожаловать в жестокий мир. Не совсем понятно почему это не сильно производительно, сложность тут линейная O(n). Но к счастью если вас заботит раздутие конечного автомата вы всегда можете использовать трансдьюсeры

                          0
                          сложность тут линейная O(n)
                          Это не отменяет факта, что по массиву пробежимся два раза.
                          За трансдьюсеры спасибо, не знал про такое. Тогда да, можно красоту наводить.
                            0
                            оверхед самого цикла не большой потому разница между
                            for(const el of arr) {f(el); g(el);}
                            

                            и
                            for(const el of arr) f(el);
                            for(const el of arr) g(el);
                            

                            не значительна
                              0
                              Если массив большой, разница времени выполнения будет различаться в два раза. Если массив маленький, то не сильно страшно, конечно.
                              Но всё равно мне не нравится такое увеличение на пустом месте ради красоты. Наверное, опыт программирования микроконтроллеров даёт такие страшные побочные эффекты ))).
                                0
                                Для тежелых операций f, g разница не может быть в два раза, если операции очень дешевые, то оверхед может быть заметен и как раз и будет давать разницу в два раза.

                                Как правило, если проект большой, лучше писать красивее и понятнее, а уже потом думать о скорости. Часто узкие места появляются совсем не там где их ждешь.
                                  0
                                  При таких условиях, да, согласен.
                                  0
                                  Просто в жс быстрый код — это обычно уродский код, а его не так просто поддерживать. Понятно, что стоит выделять критические места типа работы с многомерными массивами в библиотеки и их уже максимально оптимизировать. Но проект лучше держать в читабельном виде максимально долго.
                          +1
                          Сколько можно уже мусолить эту тройку {map, filter, reduce} (первые 2 легко выводятся из свертки(катаморфизма) reduce)?

                          Only users with full accounts can post comments. Log in, please.