Мышление в стиле Ramda: Декларативное программирование

в 10:09, , рубрики: javascript, JS, ramda, функциональное программирование

1. Первые шаги
2. Сочетаем функции
3. Частичное применение (каррирование)
4. Декларативное программирование
5. Бесточечная нотация
6. Неизменяемость и объекты
7. Неизменяемость и массивы
8. Линзы
9. Заключение

Данный пост является четвёртой частью серии о функциональном програмировании под названием «Мышление в стиле Ramda».

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

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

Но сначала, небольшое введение.

Императивность vs Декларативность

Есть множество различных путей для разделения языков программирования и стилей написания. Это статическая типизация против динамической типизаци, интерпретируемые языки и компилируемые языки, высокоуровневые и низкоуровные, и так далее.

Другое подобное разделение заключается в императивном програмировании против декларативного.

Без погружения вглубь этого, императивное программирование — это стиль программирования, в котором программисты говорят компьютеру, что нужно сделать, объясняя ему, как это нужно сделать. Императивное программирование даёт множество конструкций, которые мы используем каждый день: управление потоком (if-then-else синтаксис и циклы), арифметические операторы (+, -, *, /), операторы сравнения (===, >, <, и т.д.), и логические операторы (&&, ||, !).

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

Один из классических декларативных языков — это Prolog. В Prolog програма состоит из набора фактов и набора правил вывода. Вы начинаете программу, задавая вопрос, и набор правил вывода Prolog'а использует факты и правила для ответа на ваш вопрос.

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

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

Декларативные заменители

Поскольку мы программируем на JavaScript, императивном языке, это нормально — использовать стандартные императивные конструкции при написании «нормального» JavaScript кода.

Но когда мы пишем функциональные трансформации, используя конвееры и подобные им конструкции, императивные конструкции перестают вписываться с создаваемую структуру кода.

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

Арифметика

Во второй части мы реализовали серию арифметических трансформаций для демонстрации конвеера:

const multiply = (a, b) => a * b
const addOne = x => x + 1
const square = x => x * x
 
const operate = pipe(
  multiply,
  addOne,
  square
)
 
operate(3, 4) // => ((3 * 4) + 1)^2 => (12 + 1)^2 => 13^2 => 169

Обратите внимание, как мы пишем функции для всех базовых строительных блоков, которые мы желаем использовать.

Рамда предоставляет функции add, subtract, multiply и divide для использования в местах стандартных арифметических операций. Так что мы можем использовать рамдовскую multiply там, где мы использовали самописную функцию, мы можем взять преимущество каррированной функции add для замены нашей addOne, и мы также можем написать squade с помощью multiply.

const square = x => multiply(x, x)
 
const operate = pipe(
  multiply,
  add(1),
  square
)

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

Мы можем использовать add(1) и subtract(1) для увеличения и уменьшения, но так как эти две операции такие распространённые, Ramda предоставляет inc и dec вместо них.

Так что мы можем ещё немного упростить наш конвеер:

const square = x => multiply(x, x)
 
const operate = pipe(
  multiply,
  inc,
  square
)

subtract является заменой бинарного оператора -, но у нас ещё имеется унарный оператор - для отрицания значения. Мы также можем использовать multiply(-1), но Ramda предоставляет функцию negate для выполнения этой задачи.

Сравнение

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

const wasBornInCountry = person => person.birthCountry === OUR_COUNTRY
const wasNaturalized = person => Boolean(person.naturalizationDate)
const isOver18 = person => person.age >= 18
 
const isCitizen = either(wasBornInCountry, wasNaturalized)
 
const isEligibleToVote = both(isOver18, isCitizen)

Обратите внимание, что некоторые из наших функций использут стандартные операторы сравнения (=== и >= в данном случае). Как вы можете предположить сейчас, Ramda также предоставляет заменители для всего этого.

Давайте преобразуем наш код на использование equals вместо === и gte вместо >=.

const wasBornInCountry = person => equals(person.birthCountry, OUR_COUNTRY)
const wasNaturalized = person => Boolean(person.naturalizationDate)
const isOver18 = person => gte(person.age, 18)
 
const isCitizen = either(wasBornInCountry, wasNaturalized)
 
const isEligibleToVote = both(isOver18, isCitizen)

Ramda также предоставляет gt для >, lt для < и lte для <=

Обратите внимание, что эти функции, как кажется, принимают свои аргументы в нормальном порядке (первый аргумент больше второго?). Это имеет смысл, когда мы используем их в изоляции, но может сбивать с толку при объединении функций. Эти функции нарушают принцип «данные идут последними», так что нам нужно быть осторожными, когда мы используем иих в наших конвеерах и подобных им ситуациях. И это место, когда flip и заполнитель (__) становятся полезными.

В дополнение к equals есть ещё identical для определения, являются ли два значения ссылками на то же пространство в памяти.

Существует набор случаев основных применений для ===: проверка, что строка или массив являются пустыми (str === '' или arr.length === 0) и проверка, является ли переменная равной null или undefined. Ramda предоставляет удобные функции для обоих случаев: isEmpty и isNil.

Логика

Во второй части (и чуть выше), мы использовали функции both и either в местах операторов && и ||. Мы также говорили о complement для мест с !.

Эти комбинированные функции работают прекрасно, когда функции объединяют операцию над тем же значением. Написанные выше wasBornInCountry, wasNaturalized и isOver18 все применялись к объекту персоны.

Но иногда нам нужно применить &&, || и ! к различным значениям. Для подбоных случаев Ramda предоставляет нам функции and, or и not. Я думаю следующим образом: and, or и not работают со значениями, в то время как both, either и complement работают с функциями.

В основном, || используется для получения значений по умолчанию. К примеру, мы можем написать что-нибудь вроде этого:

const lineWidth = settings.lineWidth || 80

Это распространённая идиома, и чаще всего работающая, но полагающаяся на JavaScript логику определения «ложности». Что если 0 является валидным параметром? Так как 0 является ложным значением, мы получим значение линии равное 80.

Мы можем использовать функцию isNil, о которой мы только что узнали выше, но Ramda снова имеет более логичный вариант для нас: defaultTo.

const lineWidth = defaultTo(80, settings.lineWidth)

defaultTo проверяет второй аргумент на isNil. Если проверка провалилась он вернёт полученное значение, в ином случае вернёт первый аргумент, переданный ей.

Условия

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

isElse

Давайте напишем функцию, forever21, которая получает год и возвращает следующий. Но, как нам указывает её имя, начиная с 21 года, он будет оставаться в этом значении.

const forever21 = age => age >= 21 ? 21 : age + 1

Обратите внимание, что наше условие (age >= 21) и вторая ветвь (age + 1) могут быть обе написаны как функции age. Мы можем переписать первую ветвь (21) как функцию-константу (() => 21). Теперь у нас будет три функции, которые принимают (или игнорируют) age.

Теперь мы на позиции, когда мы можем использовать функцию isElse из Ramda, которая является эквивалентом структуры if...then..else или её более короткого кузена, тернарного оператора (?:).

const forever21 = age => ifElse(gte(__, 21), () => 21, inc)(age)

Как мы упомянули выше, функции сравнения не работают подобно функциям объединения, так что здесь нам нужно начать использовать заполнитель (__). Мы также можем применить lte вместо этого:

const forever21 = age => ifElse(lte(21), () => 21, inc)(age)

В данном случае, мы должны читать это как «21 меньше или равно age». Я собираюсь придерживаться версии с заменителем в оставшейся части поста, так как я нахожу это более читабельным и менее запутывающим.

Константы

Функции-константы весьма полезны в ситуациях, подобных этой. Как вы можете предположить, Ramda предоставляет нам сокращение. В данном случае, сокращение называется always.

const forever21 = age => ifElse(gte(__, 21), always(21), inc)(age)

Ramda также предоставляет T и F в качестве дальнейших сокращений для always(true) и always(false)

Тождественность

Давайте попробуем написать другую функцию, alwaysDrivingAge. Эта функция принимает age и возвращает его, если его значение gte 16. Если же оно меньше 16, то она вернёт 16. Это позволяет любому притвориться, что он управляет возрастом, даже если это не так:

const alwaysDrivingAge = age => ifElse(lt(__, 16), always(16), a => a)(age)

Вторая ветвь сравнения (a => a) — это другой типичный паттерн в функциональном программировании. Это известно как «тождественность» (не знаю точного перевода термина «identity function», просто выберу этот — прим. пер.). То есть, это функция, которая просто возвращает тот аргумент, который она получила.

Как вы уже можете ожидать, Ramda предоставляет нам функцию identity:

const alwaysDrivingAge = age => ifElse(lt(__, 16), always(16), identity)(age)

identity может принять больше одного аргумента, но всегда вернёт только первый. Если мы хотим вернуть что-то другое, отличное от первого аргумента, для этого существует более общая функция nthArg. Это гораздо менее распространённая ситуация, чем использование identity.

«when» и «unless»

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

Если, как в нашем случае, вторая ветвь является тождественностью, мы можем использовать when вместо ifElse:

const alwaysDrivingAge = age => when(lt(__, 16), always(16))(age)

Если первая ветвь условия является тождественностью, мы можем использовать unless. Если мы перевернём наше условие на использование gte(__, 16), мы можем использовать unless.

const alwaysDrivingAge = age => unless(gte(__, 16), always(16))(age)

Cond

Ramda также предоставляет функцию cond, которая может заменить выражение switch или цепочку выражений if...then...else.

const water = temperature => cond([
  [equals(0),   always('water freezes at 0°C')],
  [equals(100), always('water boils at 100°C')],
  [T,           temp => `nothing special happens at ${temp}°C`]
])(temperature)

Мне не понадобилось использовать cond в моём коде с Ramda, но я писал подобный код на Lisp много лет назад, так что cond чувствуется старым другом.

Заключение

Мы рассмотрели набор функций, которые Ramda предоставляет нам для превращения нашего императивного кода в декларативный функциональный код.

Далее

Вы могли заметить, что последние несколько функций, которые мы написали (forever21, drivingAge и water) все принимают параметры, создают новую функцию и далее применяют эту функцию к параметру.

Это распространённый паттерн, и вновь Ramda предоставляем нам инструменты для того чтобы привести всё это к более чистому виду. Следующий пост, «Бесточечная нотация» рассматривает способы, позволяющие упростить функции, следующие подобному паттерну.

Автор: Роман Ахмадуллин

Источник

Поделиться

* - обязательные к заполнению поля