Привет Хабр! Предлагаю вам статьи 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


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

Поделиться с друзьями
-->

Комментарии (21)


  1. oxidmod
    02.06.2017 14:55
    +6

    А что если я хочу с очень-очень длинного списка отфильтровать элементов больше чем допустимая глубина стека?


    1. MrGobus
      02.06.2017 23:03
      +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))
      


    1. netch80
      04.06.2017 12:07

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


  1. vasIvas
    02.06.2017 15:01
    +10

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


    1. arandomic
      02.06.2017 17:05

      Наивные функциональщики думают, что array.filter/array.slice реализован через каррирование))

      Строго говоря, для новичка будет достаточно остановиться на

      const result = cats.filter(isKitten).slice(0, 5);
      

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

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


  1. justboris
    02.06.2017 15:22
    +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;
    }


    1. 4dmonster
      02.06.2017 15:38
      +7

      заменить break на return

      очевидно, автор напишет: return это GOTO функций и его следует избегать!


      1. netch80
        04.06.2017 12:11
        +1

        Запрет break, continue, и return не последним оператором — это норма для «самого строгого» варианта принципов структурного программирования. Не слышал автора по этому поводу, но вообще такие нормы есть.

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


        1. 4dmonster
          05.06.2017 08:12
          +1

          Да. Конечно. Согласен.

          Но, всё же важнее выполнение конечной цели всех таких ограничений — надёжный, удобочитаемый код.
          Два return в функции на 15 строк, как мне кажется, понятнее, чем 5 дополнительных флагов.


  1. amaksr
    02.06.2017 16:09
    +6

    Прочитал статью, еще больше полюбил цикл for (и goto).


  1. inoyakaigor
    02.06.2017 16:59
    +3

    Но это не очень функционально

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


  1. Finesse
    02.06.2017 17:01
    +1

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


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


    1. Finesse
      02.06.2017 17:07
      +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
      }


  1. raveclassic
    02.06.2017 17:11
    +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. faiwer
      05.06.2017 11:20
      +1

      Но ведь это же


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

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


  1. VMichael
    02.06.2017 17:21
    +5

    Я вот никак не пойму для чего?
    Объявляется, это хорошо, а это плохо. Без пояснений.
    Почему наваянная конструкция лучше чем for с break?
    Чего ради простую, понятную с первого взгляда вещь превращать в х.з. что, для понимания которого нужно напрягать мозг?


    1. romy4
      05.06.2017 02:50
      +2

      Это потому, что у вас нет бороды, вы не ходите в барбершоп и не пьёте утренний смузи, по утрам не читаете любимую газету «MainStream»


  1. Juma
    02.06.2017 20:36
    +1

    Странно как-то это все, если вам нужен цикл с выходом по условию, и вы не любите break, используйте while. Или с ним тоже какие-то заморочки?


  1. pawlo16
    03.06.2017 00:16
    +3

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


  1. vtvz_ru
    03.06.2017 11:41
    +4

    Это похоже на утверждение «используйте один единственный return в функциях». В итоге получается абсолютно нечитаемая лапша.
    Юзаю break и continue и ни разу не сталкивался с какими-либо проблемами.


  1. Akuma
    05.06.2017 21:21
    +1

    Так и не понял, чем break; не угодил? Наговнокодить можно и с помощью любых других инструментов языка, что кстати отлично показано в итоговом решении от которого глаза кровоточат.

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

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

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