Взгляните на следующие фрагменты кода, решающие одну и ту же задачу, и подумайте о том, какой из них вам больше нравится.
Вот первый: Вот второй:
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
 .filter(int => isEven(int))
 .filter(int => isBiggerThan(3, int))
 .map(int => int + 1)
 .map(int => toChar(int))
 .filter(char => !isVowel(char))
 .join('')
// 'fhjl'
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
 .filter(isEven)
 .filter(isBiggerThan(3))
 .map(plus(1))
 .map(toChar)
 .filter(not(isVowel))
 .join('')
// 'fhjl'
«Готов поспорить, что второй вариант отличается гораздо лучшей читабельностью, чем первый», — говорит автор материала, перевод которого мы сегодня публикуем. По его словам — всё дело в аргументах методов filter() и map().



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

Простая функция


Рассмотрим простую функцию sum(), которая складывает переданные ей числа:

const sum = (a, b) => a + b
sum(1, 2)
// 3

Перепишем её, дав новой функции имя csum():

const csum = a => b => a + b
csum(1)(2)
// 3

Работает её новый вариант точно так же, как и исходный, единственная разница заключается в том, как вызывают эту новую функцию. А именно, функция sum() принимает сразу два параметра, а csum() принимает те же параметры по одному. Фактически, при обращении к csum() производится вызов двух функций. В частности, рассмотрим ситуацию, когда csum() вызывают, передав ей число 1 и больше ничего:

csum(1)
// b => 1 + b

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

const plusOne = csum(1)
plusOne(2)
// 3

Работа с массивами


В JavaScript с массивами можно работать с помощью множества специальных методов. Скажем, метод map() используется для применения переданной ему функции к каждому элементу массива.

Например, для того, чтобы увеличить на 1 каждый элемент целочисленного массива (точнее — сформировать новый массив, содержащий элементы исходного, увеличенные на 1), можно воспользоваться следующей конструкцией:

[1, 2, 3].map(x => x + 1)
// [2, 3, 4]

Другими словами, происходящее можно описать так: функция x => x + 1 принимает целое число и возвращает число, которое следует за ним в ряду целых чисел. Если воспользоваться рассмотренной выше функцией plusOne(), этот пример можно переписать так:

[1, 2, 3].map(x => plusOne(x))
// [2, 3, 4]

Тут стоит ненадолго притормозить и задуматься о происходящем. Если это сделать, то можно заметить, что в рассмотренном случае конструкции x => plusOne(x) и plusOne (обратите внимание — в данной ситуации после имени функции нет скобок) эквивалентны. Для того чтобы лучше с этим разобраться, рассмотрим функцию otherPlusOne():

const otherPlusOne = x => plusOne(x)
otherPlusOne(1)
// 2

Результатом работы этой функции будет то же самое, что получается при простом вызове уже известной нам plusOne():

plusOne(1)
// 2

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

[1, 2, 3].map(x => plusOne(x))
// [2, 3, 4]

Вот вторая:

[1, 2, 3].map(plusOne)
// [2, 3, 4]

Кроме того, вспомним о том, как была создана функция plusOne():

const plusOne = csum(1)

Это позволяет переписать нашу конструкцию с map() следующим образом:

[1, 2, 3].map(csum(1))
// [2, 3, 4]

Создадим теперь, используя ту же методику, функцию isBiggerThan(). Если хотите, попробуйте сделать это самостоятельно, а потом продолжайте читать. Это позволит отказаться от использования ненужных конструкций при использовании метода filter(). Сначала приведём код к такому виду:

const isBiggerThan = (threshold, int) => int > threshold
[1, 2, 3, 4].filter(int => isBiggerThan(3, int))

Потом, избавившись от всего лишнего, получим код, который вы уже видели в самом начале этого материала:

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
 .filter(isEven)
 .filter(isBiggerThan(3))
 .map(plus(1))
 .map(toChar)
 .filter(not(isVowel))
 .join('')
// 'fhjl'

Рассмотрим теперь два простых правила, которые позволяют писать код в рассматриваемом здесь стиле.

Правило №1


Две следующих конструкции эквивалентны:

[…].map(x => fnc(x))
[…].map(fnc)

Правило №2


Коллбэк всегда можно переписать так, чтобы сократить число используемых при его вызове аргументов:

const fnc = (x, y, z) => …
[…].map(x => fnc(x, y, z))
const fnc = (y, z) => x => …
[…].map(fnc(y, z))

Если вы самостоятельно написали функцию isBiggerThan(), то вы уже, наверняка, прибегали к подобной трансформации. Предположим, нам нужно, чтобы через фильтр прошли бы числа, которые больше 3. Это можно сделать так:

const isBiggerThan = (threshold, int) => int > threshold
[…].filter(int => isBiggerThan(3, int))

Теперь перепишем функцию isBiggerThan() так, чтобы её можно было бы использовать в методе filter() и не прибегать к конструкции int=>:

const isBiggerThan = threshold => int => int > threshold
[…].map(isBiggerThan(3))

Упражнение


Предположим, у нас есть следующий фрагмент кода:

const keepGreatestChar =
  (char1, char2) => char1 > char2 ? char1 : char2
keepGreatestChar('b', 'f') 
// 'f'
// так как 'f' идёт после 'b'

Теперь, на основе функции keepGreatestChar(), создайте функцию keepGreatestCharBetweenBAnd(). Нам нужно, чтобы, вызывая её, можно было бы передавать ей лишь один аргумент, при этом она будет сравнивать переданный ей символ с символом b. Выглядеть эта функция может так:

const keepGreatestChar = 
  (char1, char2) => char1 > char2 ? char1 : char2
const keepGreatestCharBetweenBAnd = char =>
  keepGreatestChar('b', char)
keepGreatestCharBetweenBAnd('a')
// 'b'
// так как 'b' идёт после 'a'

Теперь напишите функцию greatestCharInArray(), которая, с использованием функции keepGreatestChar() в методе массива reduce() позволяет выполнять поиск «наибольшего» символа и не нуждается в аргументах. Начнём с такого кода:

const keepGreatestChar =
  (char1, char2) => char1 > char2 ? char1 : char2
const greatestCharInArray =
  array => array.reduce((acc, char) => acc > char ? acc : char, 'a')
greatestCharInArray(['a', 'b', 'c', 'd'])
// 'd'

Для решения этой задачи реализуйте функцию creduce(), которую можно использовать в функции greatestCharInArray(), что позволит, при практическом применении этой функции, не передавать ей ничего кроме массива, в котором надо найти символ с наибольшим кодом.

Функция creduce() должна быть достаточно универсальной для того, чтобы её можно было применять для решения любой задачи, в которой требуется использовать возможности стандартного метода массивов reduce(). Другими словами, функция должна принимать коллбэк, начальное значение и массив, с которым нужно работать. В результате у вас должна получиться функция, с применением которой заработает следующий фрагмент кода:

const greatestCharInArray = creduce(keepGreatestChar, 'a')
greatestCharInArray(['a', 'b', 'c', 'd'])
// 'd'

Итоги


Возможно, сейчас у вас возник вопрос о том, почему методы, переработанные в соответствии с представленной здесь методикой, имеют имена, начинающиеся с символа c. Символ c — это сокращение от слова curried (каррированный) — и выше мы говорили о том, как каррированные функции помогают улучшить читабельность кода. Надо отметить, что мы тут не стремились к строгому соблюдению принципов функционального программирования, но, полагаем, что практическое применение того, о чём здесь шла речь, позволяет улучшить код. Если тема каррирования в JavaScript вам интересна — рекомендуется почитать 4 главу этой книги о функциональном программировании, а, в общем-то, раз уж вы дошли до этого места — прочтите всю эту книгу. Кроме того, если вы — новичок в функциональном программировании, обратите внимание на этот материал для начинающих.

Уважаемые читатели! Пользуетесь ли вы каррированием функций в JavaScript-разработке?

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


  1. Akuma
    25.06.2018 10:14
    +1

    С одной стороны вроде все хорошо, с другой кто-то и правда может подумать, что для x > 3 нужно писать отдельную функцию и затачивать ее под все случаи жизни.

    .filter(x => x > 3), все, конец. Просто и понятно.

    Я это к тому, что может быть будет лучше в конце статьи указывать более реальное применение всему сказанному? Найдутся ведь те, кто будет весь свой код переписывать на isBiggerThan(3)


    1. Vadem
      25.06.2018 13:18
      +2

      1. Akuma
        25.06.2018 14:06

        Стоп. И что, вы хотите сказать, что для этого не нужно подключать jQuery и Angular?
        К тому же у этой библиотеки 163 открытых Issue. Сразу видно, что это нестабильное нечто, сделанное на коленке.


  1. saggid
    25.06.2018 10:20

    Как раз недавно писал перевод статьи о каррировании в рамде. С нею всё становится ещё удобнее.


  1. maxzh83
    25.06.2018 10:58
    +1

    В первом примере есть лукавство, в правой части не показано, сколько нужно написать, чтобы добиться красоты. И понятно почему. С кучей дополнительных функций ответ уже будет не такой очевидный.


    1. JustDont
      25.06.2018 13:04

      Добро пожаловать в мир фронтэнда, тут все фреймворки, например, себя всегда точно так же пиарят :-)
      А потом проходит время, и вот ты уже недоуменно читаешь чьи-то дифирамбы про то, как же просто и быстро проходит обновление данных модели в ангуляре (просто — да, для пользователя фреймворка, но вообще-то нет; быстро — гкхммммм).

      Вообще автор транслирует разумную мысль (если можно улучшить читаемость нетривиального кода, не теряя других важных качеств — то её стоит улучшить), только конечно заданная тема статьи этому мешает. Во-первых, не всегда читаемость повышается именно через каррирование. Во-вторых, демонстрировать это стоит все же не на функциях а-ля plusOne(), ну серьезно, тут все взрослые люди.


  1. Desprit
    25.06.2018 13:01

    Сеньоры, по вашему опыту ведения крупных проектов, вы действительно поощряете переходы подобные этому: .filter(int => isEven(int)) -> .filter(isEven)? Разве это не усложняет разработку? Да, выглядит более емко, но как быть с const csum, вот это что вообще? Какие-то неоднозначные нейминги, опускание типов, где они, по сути, вообще не мешают, а лишь экономят драгоценные секунды чтения кода. Ради чего?


    1. JustDont
      25.06.2018 13:10

      Именно так, как в статье — конечно нет, это же детсад какой-то.
      Но если есть подходящий контекст, где подобные цепочки преобразований ожидаются часто и обильно, то вполне можно заняться делом уменьшения повторов. То есть, у нас в проекте, например, объектная обёртка над canvas написана, естественно, с возвратом самой себя везде (это не каррирование как таковое, но тоже уменьшает повторы и увеличивает читаемость), чтоб можно было писать canvas.то().сё().иЕщеВотЭто().


  1. shaukote
    25.06.2018 13:35
    +2

    Готов поспорить, что второй вариант отличается гораздо лучшей читабельностью, чем первый.

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


    Правило №1
    Две следующих конструкции эквивалентны:

    Вообще-то нет, по крайней мере в общем случае. ['1','2','3'].map(parseInt) отличный пример того, как можно напороться (и потратить долгие часы в отладке).


    P.S.
    Очень хочется верить, что однажды люди всех вероисповеданий языков узнают про The Zen of Python. В данном случае он весьма уместен, к примеру:


    Явное лучше, чем неявное.
    Простое лучше, чем сложное.
    Если реализацию сложно объяснить — идея плоха.


    1. nickolaym
      25.06.2018 15:15

      Кстати о питоне.
      Лямбды и генераторы связывают переменные по имени, что в сочетании с изменяемостью данных означает "штирлиц знал, что запоминается последнее".
      На это можно очень больно наступить:


      plusators = [(lambda x: x+d) for d in range(10)]
      print(plusators[5](100))  # 109, wtf?!
      
      # сразу выполнил тета-подстановку, чтобы не путаться между одноимёнными переменными
      plusators = [(lambda c: (lambda x: x+c))(d) for d in range(10)]
      print(plusators[5](100))  # 105
      # вышло страшненько, не правда ли?
      
      # вот так выглядит чуть менее страшно
      plusator = lambda c: lambda x: x+c
      plusators = [plusator(d) for d in range(10)]
      
      # а вот так выглядит в соответствие с PEP8
      # где сказано "даёшь функции вместо переменных с присвоенными лямбдами"
      def plusator(c):
          return lambda x: x+c
      
      plusators = [plusator(d) for d in range(10)]
      # четыре строчки вместо одной, больше читаемости богу читаемости


  1. argonavtt
    25.06.2018 15:15

    Готов поспорить, что второй вариант отличается гораздо лучшей читабельностью, чем первый», — говорит автор материала, перевод которого мы сегодня публикуем. По его словам — всё дело в аргументах методов filter() и map().


    В каком это мире данный код выглядит более читабельнее? Сейчас бы элементарные (даже элементарными назвать с натяжкой можно) арифметические выражения запихивать в отдельные функции. С какой целью это делается? Читабельнее код становится? Т.е. если человек посмотрит и увидит + 1 он не поймёт, а если прочитает plusOne ему всё сразу станет ясно? Где то логика умерла.


  1. kemsky
    25.06.2018 15:34

    Точно такой же подход я использовал в своей библиотечке для ActionScript, но причина была только в том, что там не было arrow-functions:


    // 1. using custom callback
    var result:Stream = s.filter(function(item:Item):Boolean{
       return item.price > 1;
    });
    
    // 2. using provided global functions(can compare Boolean, Number, Date, XML, String types)
    //    supports nested properties i.e. member("prop.prop.prop")
    var result:Stream = s.filter(gt(member("price"), 1));
    
    // 3. using Proxy magick and global functions
    var result:Stream = s.price(gt(_, 1)); 

    Имеет смысл так делать только для каких-нибудь сложных агрегатов или типовых операций над строками.


    Скажем .filter(not(isVowel)) лучше все же переписать как .filter(x => !isVowel(x)).


    1. argonavtt
      25.06.2018 16:06

      Со строками как правило и кода больше всегда, если мы конечно говорим не про очередное выражение вроде (string) => string + ", ".


  1. shybovycha
    26.06.2018 01:52

    Я приверженец первого варианта в плане "явности". Но есть два случая, когда второй вариант более уместен:


    1. в каждой функции-колбеке замешано большое количество логики
    2. вся вот эта цепочка вызывается тысячи раз — в таком случае банально экономится память, выделяемая под анонимные функции (как получается в первом варианте)

    Как можно понять, не существует серебряной пули — каждый вариант имеет право на существование; все дело в контексте.


  1. jt3k
    26.06.2018 11:21
    +1

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