Переосмысливая JavaScript: break и функциональный подход

Привет Хабр! Предлагаю вам статьи Rethinking JavaScript: Replace break by going functional.


image


В моей предыдущей статье 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 и неструктурированного программирования


image


"Но break никому не мешает, почему бы не оставить возможность его использовать?"


Почему следует ограничивать себя при разработке ПО?


Это может звучать нелогично, но ограничения это хорошая вещь. Запрет GOTO прекрасный тому пример. Мы также с удовольствием ограничиваем себя директивой "use strict", а иногда даже осуждаем игнорирующих её.


"Ограничения могут сделать вещи лучше. Намного лучше" — Чарльз Скалфани


Ограничения заставляют нас писать лучше.


Why Programmers Need Limits


Какие альтернативы у break?


Я не буду врать. Не существует простого и быстрого способа заменить 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


Прим. переводчика: выражаю благодарность Глебу Фокину и Богдану Добровольскому в написании перевода, а также Джо Томсу, без которого перевод был бы невозможен.

Поделиться публикацией
Похожие публикации
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 21
  • +6
    А что если я хочу с очень-очень длинного списка отфильтровать элементов больше чем допустимая глубина стека?
    • +3
      Полностью согласен с комментарием, но думаю стоит раскрыть смысл сказанного, чтобы дочитавшие до комментариев смогли оценить весь вред написанного в статье подхода. Код приведенный выше порождает рекурсию, а значит каждая итерация подобного цикла поедает память помещая значения переменных в стек для вызова самой себя с новыми параметрами. Итого, при обработке 100 элементов в памяти может оказать 100 копий указателя на массив list, newList, значение инкрементируемой нами переменной i, указатель на функцию проверки predicate плюс системная информация создающая пространство имен для вызова новой функции.

          return isDone  ? newList :
                 isMatch ? takeFirst(limit - 1, predicate, list, i + 1, [...newList, list[i]]) // <<== вот тут рекурсивный вызов
                         : takeFirst(limit, predicate, list, i + 1, newList) // <<== и тут
      


      Константы isDone и isMatch также будут помещены в стек при каждом рекурсивном вызове, так как они каждый раз будут являться частью нового пространства имен.

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

      function takeFirst(limit, predicate, list, i = 0, newList = []) {
      	if (i >= 0 && i < list.length && newList.length < limit) {
      		if (predicate(list[i])) {
      			newList.push(list[i])
      		}
      		takeFirst(limit, predicate, list, i + 1, newList) // <<== РЕКУРСИЯ!!!!
      	}
      	return newList
      }
      
      var arr = [1, 2, 2, 3, 3, 2, 4, 5, 2, 2, 2, 2]
      var isTwo = function (value) {
      	return value == 2
      }
      
      console.log(takeFirst(5, isTwo, arr))
      

      • 0
        Движок должен допускать хвостовую рекурсию. Если для него это не гарантируется, использовать подобные приёмы нежелательно.
      • +10
        Пора придумать пометку «красивый код», который будет предупреждать солдата-разработчика, что это не боевые доспехи, а парадные. Последнее время так много говорят о получении элемента array[5] при помощи трехкратного клонирования, что это отдается эхом на всех форумах. И вот представьте картину, пришел новичок, который тольком не понимает что такое переменная и ему говорят что вот так нужно делать. Он верит и учится, а затем приходит на работу и встречает узкое место в коде. Что будет? А вспомните себя в самом начале, циклы и операции с массивами были всегда самыми сложными, после событий. Вот и получится, что уже джуниора нужно будет учить циклам, хотя должно быть все наоборот.
        Да и выглядящие красиво операции с массивами, в совокупности + функциональное программирование, могут и наверняка приводят к тому, что js приложения на мобильных все ругают за то что они «плывут», а на десктопе приводит к тому, что пару вкладок жрут памяти, как современная игра. И это в то время, когда сложность и интерактивность приложений растет, а развитие машин замедляется.
        • 0
          Наивные функциональщики думают, что array.filter/array.slice реализован через каррирование))

          Строго говоря, для новичка будет достаточно остановиться на
          const result = cats.filter(isKitten).slice(0, 5);
          

          Правило — если в языке есть стандартная реализация чего-то — используй её. (Если не используешь её — ты обязан понимать как она работает и суметь объяснить чем твоя реализация лучше)

          Писать ради этого for и уж тем более каррирование — глупость и баловство, да.

        • +3

          Какая-то странная статья. Да, лучше обходиться без break, но для этого не обязательно нужно писать такой наркоманский код, как в функции takeFirst.


          Достаточно просто вынести for-цикл в отдельную функцию, и заменить break на return


          function firstKittens(cats, maxNumber) {
            var result = []
            // старый добрый for
            for (var i = 0; i < cats.length; i++) {
              if (isKitten(cats[i])) {
                result.push(cats[i])
                if (result.length >= maxNumber) {
                  return result;
                }
              }
            }
            return result;
          }
          • +7
            заменить break на return

            очевидно, автор напишет: return это GOTO функций и его следует избегать!
            • +1
              Запрет break, continue, и return не последним оператором — это норма для «самого строгого» варианта принципов структурного программирования. Не слышал автора по этому поводу, но вообще такие нормы есть.

              На практике это превращается в создание пачки дополнительных булевских или целых флагов, которые контролируют продолжение цикла или вход в отдельные ветки исполнения. Такое преобразование может быть сделано и вручную, и автоматизированно анализатором.
              • +1
                Да. Конечно. Согласен.

                Но, всё же важнее выполнение конечной цели всех таких ограничений — надёжный, удобочитаемый код.
                Два return в функции на 15 строк, как мне кажется, понятнее, чем 5 дополнительных флагов.
          • +6
            Прочитал статью, еще больше полюбил цикл for (и goto).
            • +3
              Но это не очень функционально

              И что? ФП — не серебряная пуля, хоть автор и старается нам это так приподнести.
              • +1

                – ухудшилась читаемость кода;
                – производительность снизилась.


                А в чём плюсы описанного подхода?

                • +2

                  Вот начальная функция с for, но без break и лишних return:


                  const takeFirst = (limit, predicate, list) => {
                    const newList = []
                  
                    for (var i = 0; i < list.length && newList.length < limit; i++) {
                      if (predicate(list[i])) {
                        newList.push(list[i]);
                      }
                    }
                  
                    return newList
                  }
                • +2
                  /*
                   * 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)
                  }
                  

                  Открой меня!


                  • +1

                    Но ведь это же


                    Joel Thoms
                    Computer Scientist and Technology Evangelist with 21 years of experience with JavaScript!

                    Мы должны прислушаться к его словам ;)

                  • +5
                    Я вот никак не пойму для чего?
                    Объявляется, это хорошо, а это плохо. Без пояснений.
                    Почему наваянная конструкция лучше чем for с break?
                    Чего ради простую, понятную с первого взгляда вещь превращать в х.з. что, для понимания которого нужно напрягать мозг?
                    • +2
                      Это потому, что у вас нет бороды, вы не ходите в барбершоп и не пьёте утренний смузи, по утрам не читаете любимую газету «MainStream»
                    • +1
                      Странно как-то это все, если вам нужен цикл с выходом по условию, и вы не любите break, используйте while. Или с ним тоже какие-то заморочки?
                      • +3

                        УчОные всего мира встали и пошли улучшать JS. С нетерпением жду ещё одну историю из серии "сложно о простом"

                        • +4
                          Это похоже на утверждение «используйте один единственный return в функциях». В итоге получается абсолютно нечитаемая лапша.
                          Юзаю break и continue и ни разу не сталкивался с какими-либо проблемами.
                          • +1
                            Так и не понял, чем break; не угодил? Наговнокодить можно и с помощью любых других инструментов языка, что кстати отлично показано в итоговом решении от которого глаза кровоточат.

                            Более того, даже GOTO можно и нужно использовать, если умеете это делать без вреда для окружающих.

                            Вы же пользуетесь арифметикой? Может тоже объявим ее злом? Например, 42<<4 == 42*Math.pow(2,4), удобно, правда? И главное быстро! Math.pow — зло! Нужно использовать побитовый сдвиг!

                            Да, я понимаю, что Joel Thoms начинал изучать JS когда я начинал изучать горшок, но горшок остался тот же, а вот JS развивается и подобная ересь устарела лет на 10 уже.

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.