Переосмысливая JavaScript: break и функциональный подход
- воскресенье, 4 июня 2017 г. в 03:15:02
Привет Хабр! Предлагаю вам статьи Rethinking JavaScript: Replace break by going functional.
В моей предыдущей статье Rethinking JavaScript: Death of the For Loop (есть перевод: Переосмысление JavaScript: Смерть for) я пытался убедить вас отказаться от for в пользу функционального подхода. И вы задали хороший вопрос "Что на счет break?".
break это GOTO циклов и его следует избегать
Нам следует отказаться от break
также, как мы когда-то отказались от GOTO
.
Вы можете думать, "Да ладно, Джо, ты преувеличиваешь. Как это break это GOTO
?"
// плохой код. не копируй!
outer:
for (var i in outerList) {
inner:
for (var j in innerList) {
break outer;
}
}
Рассмотрим метки (прим. labels) для доказательства утверждения. В других языках метки работают в паре с GOTO
. В JavaScript'e же метки работают вместе с break
и continue
, что сближает последних с GOTO
.
JavaScript'вые метка, break и continue это пережиток GOTO и неструктурированного программирования
"Но break никому не мешает, почему бы не оставить возможность его использовать?"
Это может звучать нелогично, но ограничения это хорошая вещь. Запрет GOTO
прекрасный тому пример. Мы также с удовольствием ограничиваем себя директивой "use strict", а иногда даже осуждаем игнорирующих её.
"Ограничения могут сделать вещи лучше. Намного лучше" — Чарльз Скалфани
Ограничения заставляют нас писать лучше.
Я не буду врать. Не существует простого и быстрого способа заменить break. Здесь нужен совершенно иной стиль программирования. Совершенно иной стиль мышления. Функциональный стиль мышления.
Хорошая новость в том, что существует много библиотек и инструментов, которые могут нам помочь, такие как Lodash, Ramda, lazy.js, рекурсия и другие.
Например, у нас есть коллекция котов и функция isKitten
:
const cats = [
{ name: 'Mojo', months: 84 },
{ name: 'Mao-Mao', months: 34 },
{ name: 'Waffles', months: 4 },
{ name: 'Pickles', months: 6 }
]
const isKitten = cat => cat.months < 7
Начнем со старого доброго цикла for
. Мы проитерируем наших котов и выйдем из цикла, когда найдем первого котенка.
var firstKitten
for (var i = 0; i < cats.length; i++) {
if (isKitten(cats[i])) {
firstKitten = cats[i]
break
}
}
Сравним с аналогичным lodash вариантом
const firstKitten = _.find(cats, isKitten)
Этот был довольно простой пример, давайте попробуем что-нибудь по-серьезнее. Будем перебирать наших котов пока не найдем 5 котят.
var first5Kittens = []
// старый добрый for
for (var i = 0; i < cats.length; i++) {
if (isKitten(cats[i])) {
first5Kittens.push(cats[i])
if (first5Kittens.length >= 5) {
break
}
}
}
Прим. переводчика: позволил себе немного вольности и дополнил размышления о легком пути недостающими, по моему мнению, частями.
Мы можем использовать стандартные методы массива JavaScript.
const result = cats.filter(isKitten)
.slice(0, 5);
Но это не очень функционально. Мы можем воспользоваться Lodash'ем.
const result = _.take(_.filter(cats, isKitten), 5)
Это достаточно хорошее решение пока вы ищете котят в небольшой коллекции котов.
Lodash великолепен и умеет делать массу хороших вещей, но сейчас нам нужно что-то более специфичное. Тут нам поможет lazy.js. Он "Как underscore, но ленивый". Его ленивость нам и нужна.
const result = Lazy(cats)
.filter(isKitten)
.take(5)
Дело в том, что ленивые последовательности (которые предоставляет lazy.js) сделают ровно столько преобразований (filter, map и тд) сколько элементов вы хотите получить в конце.
Библиотеки это весело, но иногда по настоящему весело сделать что-то самому!
Как на счет того, чтобы создать обобщенную (прим. generic) функцию, которая будет работать как filter
, но вдобавок будет уметь останавливаться при нахождении определенного количества элементов?
Сначала обернем наш старый добрый цикл в функцию.
const get5Kittens = () => {
const newList = []
// старый добрый for
for (var i = 0; i < cats.length; i++) {
if (isKitten(cats[i])) {
newList.push(cats[i])
if (newList.length >= 5) {
break
}
}
}
return newList
}
Теперь давайте обобщим функцию и вынесем всё котоспецифичное. Заменим 5
на limit
, isKitten
на predicate
и cats
на list
и вынесем их в параметры функции.
const takeFirst = (limit, predicate, list) => {
const newList = []
for (var i = 0; i < list.length; i++) {
if (predicate(list[i])) {
newList.push(list[i])
if (newList.length >= limit) {
break
}
}
}
return newList
}
В итоге у нас получилась готовая для повторного использования функция takeFirst
, которая полностью отделена от нашей кошачьей бизнес логики!
takeFirst
— чистая функция. Результат ее выполнения определяется только входными параметрами. Функция гарантированно вернет тот же результат получив те же параметры.
Функция до сих пор содержит противный for
, так что продолжим рефакторинг. Следующим шагом переместим i
и newList
в параметры функции.
const takeFirst = (limit, predicate, list, i = 0, newList = []) => {
// ...
}
Мы хотим закончить рекурсию (isDone
) когда limit
достигнет 0
(limit
будет уменьшаться во время рекурсии) или когда закончится list
.
Если мы не закончили, мы выполняем predicate
. Если результат predicate
истинен, мы вызываем takeFirst
, уменьшаем limit
и присоединяем элемент к newList
.
Иначе берем следующий элемент списка.
const takeFirst = (limit, predicate, list, i = 0, newList = []) => {
const isDone = limit <= 0 || i >= list.length
const isMatch = isDone ? undefined : predicate(list[i])
if (isDone) {
return newList
} else if (isMatch) {
return takeFirst(limit - 1, predicate, list, i + 1, [...newList, list[i]])
} else {
return takeFirst(limit, predicate, list, i + 1, newList)
}
}
Последний наш шаг замены if
на тернарный оператор объяснен в моей статье Rethinking Javascript: the If Statement.
/*
* takeFirst работает как `filter`, но поддерживает ограничение.
*
* @param {number} limit - Максимальное количество возвращаемых соответствий
* @param {function} predicate - Функция соответствия, принимает item и возвращает true или false
* @param {array} list - Список, который будет отфильтрован
* @param {number} [i] - Индекс, с которого начать фильтрацию (по умолчанию 0)
*/
const takeFirst = (limit, predicate, list, i = 0, newList = []) => {
const isDone = limit <= 0 || i >= list.length
const isMatch = isDone ? undefined : predicate(list[i])
return isDone ? newList :
isMatch ? takeFirst(limit - 1, predicate, list, i + 1, [...newList, list[i]])
: takeFirst(limit, predicate, list, i + 1, newList)
}
Теперь вызовем наш новый метод:
const first5Kittens = takeFirst(5, isKitten, cats)
Чтобы сделать takeFirst
ещё полезнее мы могли бы её каррировать (прим. currying) и использовать для создания других функций. (больше о карировании в другой статье)
const first5 = takeFirst(5)
const getFirst5Kittens = first5(isKitten)
const first5Kittens = getFirst5Kittens(cats)
Есть много хороших библиотек (например lodash, ramda, lazy.js), но будучи достаточно смелыми, мы можем воспользоваться силой рекурсии чтобы создавать собственные решения!
Я должен предупредить, что хотя takeFirst
невероятно крутая, с рекурсией приходит великая сила, но также и большая ответственность. Рекурсия в мире JavaScript может быть очень опасной и легко привести к ошибке переполнения стека Maximum call stack size exceeded
.
Я расскажу о рекурсии в JavaScript в следующей статьей.
Я знаю что это мелочь, но меня очень радует когда кто-то подписывается на меня на Медиуме и Твиттере @joelnet. Если же вы думаете что я дурак, скажите это мне в комментах ниже.
→ Functional JavaScript: Functional Composition For Every Day Use.
→ Rethinking JavaScript: Death of the For Loop
(есть перевод: Переосмысление JavaScript: Смерть for)
→ Rethinking JavaScript: Elliminate the switch statement for better code
→ Functional JavaScript: Resolving Promises Sequentially
Прим. переводчика: выражаю благодарность Глебу Фокину и Богдану Добровольскому в написании перевода, а также Джо Томсу, без которого перевод был бы невозможен.