Принципы функционального программирования в javascript

Оператор lambda

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

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

и

Но в отличие от стандартной функции, после определения лямбда-функции ее можно сразу же применить, к примеру, в интерактивном режиме:

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

Здесь в строке 1 определяется лямбда-функция и присваивается переменной, которая теперь ссылается на лямбда-функцию. В строке 2 она применяется с двумя аргументами. В строке 4 ссылка на эту функцию присваивается еще одной переменной, и затем пользуясь этой переменной данная функция вызывается еще раз. В строке 7 создается словарь, в котором в качестве значения задана ссылка на эту функцию, и затем, обратившись к этому значению по ключу, эта функция применяется в третий раз.

Нередко во время написания программы появляется необходимость преобразовать некую последовательность в другую. Для этих целей в Python имеется встроенная функция map.

Простая композиция

Итак, техника освоена, рассмотрим более сложный жизненный пример.

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

Пока что всё достаточно просто. Короткая и понятная лямбда-функция, всё хорошо. Но если логика отображения усложнится, то ситуация резко ухудшится.

Например, нужно взять не просто второй элемент пары, а вычислить квадратный корень из него, и результат округлить до ближайшего целого. Лямбда становится слишком длинной, придётся форматировать:

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

Внимание привлекает запись. Данный синтаксис следует понимать так: взять элемент кортежа с индексом 1, взять квадратный корень из него и округлить до ближайшего целого

В точности то, что и требовалось.

Схематично это можно изобразить так:

Реализовать функциональный объект можно следующим образом:

Функциональные объекты и предлагаю реализовать в качестве упражнения всем заинтересовавшимся.

Сам же механизм композиции реализуется посредством соответствующего функционального объекта:

Важно отметить, что в данной реализации функция применяет композицию справа налево, как принято в математике. Иначе говоря, эквивалентно

Оператор же меняет порядок композиции. То есть эквивалентно .

Это сделано для того, чтобы не вводить в заблуждение программиста, знакомого с такими решениями, как Boost Range Adaptors, range-v3, а также с консольным конвеером.

Общие слова́[править | править код]

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

Но даже ассемблеры не могли стать тем инструментом, которым смогли бы пользоваться многие люди, поскольку мнемокоды всё ещё оставались слишком сложными, а всякий ассемблер был жёстко связан с архитектурой, на которой исполнялся. Шагом после ассемблера стали так называемые императивные языки высокого уровня: Бейсик, Паскаль, Си, Ada и прочие, включая объектно-ориентированные. Императивными («предписывающими») такие языки названы потому, что ориентированы на последовательное исполнение инструкций, работающих с памятью (т. е. присваиваний), и итеративные циклы. Вызовы функций и процедур, даже рекурсивные, не избавляли такие языки от явной императивности.

В парадигме функционального программирования краеугольный камень, — это функция. Вспомнив историю математики, можно оценить возраст понятия «функция»: ему уже́ около четырёхсот лет, и математики придумали очень много теоретических и практических аппаратов для оперирования функциями, начиная от обыкновенных операций дифференцирования и интегрирования, заканчивая заумными функциональными анализами, теориями нечётких множеств и функций комплексных переменных.

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

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

Функциональное программирование

ФП — это стиль написания программ, при котором просто комбинируется набор функций. В частности, ФП подразумевает обёртывание в функции практически всего подряд. Приходится писать много маленьких многократно используемых функций и вызывать их одну за другой, чтобы получить результат вроде (func1.func2.func3) или комбинации типа func1(func2(func3())).

Но чтобы действительно писать программы в таком стиле, функции должны следовать определённым правилам и решать некоторые проблемы.

Проблемы ФП

Если всё можно сделать путём комбинирования набора функций, то…

  1. Как обрабатывать условие if-else? (Подсказка: монада Either)
  2. Как обрабатывать исключения Null? (Подсказка: монада Maybe)
  3. Как удостовериться, что функции действительно многократно используемые и могут использоваться везде? (Подсказка: чистые функции (Pure functions), ссылочная прозрачность (referential transparency))
  4. Как удостовериться, что данные, передаваемые нами в функции, не изменены и могут использоваться и в других местах? (Подсказка: чистые функции (Pure functions), неизменяемость)
  5. Если функция берёт несколько значений, но при объединении в цепочку (chaining) можно передавать только по одной функции, то как нам сделать эту функцию частью цепочки? (Подсказка: каррирование (currying) и функции высшего порядка)
  6. И многое другое <добавьте сюда ваш вопрос>.

ФП-решение

Для решения всех этих проблем полностью функциональные языки вроде Haskell из коробки предоставляют разные инструменты и математические концепции, например монады, функторы и т. д. JavaScript из коробки не даёт такого обилия инструментов, но, к счастью, у него достаточный набор ФП-свойств, позволяющих писать библиотеки.

Модульное программирование

Мо́дульное программи́рование — это организация программы как совокупности небольших независимых блоков, называемых модулями.

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

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

Термин «модуль» в программировании начал использоваться в связи с внедрением модульных принципов при создании программ.
В 1970-х годах под модулем понимали какую-либо процедуру или функцию, написанную в соответствии с определёнными правилами.
Например: «модуль должен быть простым, замкнутым (независимым), обозримым (от 50 до 100 строк),
реализующим только одну функцию задачи, имеющим одну входную и одну выходную точку».

Первым основные свойства программного модуля более-менее чётко сформулировал Д. Парнас (David Parnas) в 1972 году:
«Для написания одного модуля должно быть достаточно минимальных знаний о тексте другого».
Таким образом, в соответствии с определением, модулем могла быть любая отдельная процедура (функция)
как самого нижнего уровня иерархии (уровня реализации), так и самого верхнего уровня,
на котором происходят только вызовы других процедур-модулей.

Таким образом, Парнас первым выдвинул концепцию скрытия информации (англ. information hiding) в программировании.
Однако существовавшие в языках 70-х годов только такие синтаксические конструкции, как процедура и функция,
не могли обеспечить надёжного скрытия информации, из-за повсеместного применения глобальных переменных.

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

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

Впервые специализированная синтаксическая конструкция модуля была предложена Н. Виртом в 1975 г.
и включена в его новый язык Modula (а затем — Modula-2).

Минусы модульности: требуется большая аккуратность и больше времени на разработку. При выполнении требуется больше памяти.

Преимущества модульного программирования:
1) модули можно тестировать, отлаживать и поддерживать по отдельности;
2) можно повторно использовать модули из других проектов или купить их у сторонних производителей;
3) программу можно легче модернизировать простой заменой модулей.

Цели

Итак, какие цели стоят перед нами:

  1. Собственно реализация требуемой функциональности

    То есть реализация в каком-то виде частичного применения функции.

  2. Максимально возможная эффективность

    Нельзя забывать, что мы пишем на языке C++, одним из основных принципов которого является принцип «Абстракции без накладных расходов» (см. Stroustrup, Foundations of C++).

    В частности, не должно произойти ни одного лишнего копирования и ни одного лишнего переноса.

  3. Удобство в использовании

    Прикладному программисту должно быть легко создавать частично применённые функции.

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

Рекомендации по ФП на языке Python

Понятие ФП несколько различается по строгости формулировки. Одни понимают применение только функций, немутируемость и наведение мостов с периферией (вводом-выводом). Другие определяют ФП строже и наряду с немутируемостью говорят о применении только чистых функций. Но в любом случае программирование в функциональном стиле не тождественно функциональному программированию. Применение первоклассных функций, лямбд, итераторов, включений, каррирования и сопоставления с шаблонами вовсе не означает немутируемость и чистые функции.

Что делает функции нечистыми?

  • Глобальные мутации, т.е. внесение изменений в глобальное состояние,

  • Недетерминированность функций, т.е. которые для одинаковых входных значений могут возвращать разные результаты, и

  • Операции ввода-вывода.

Пример глобальной мутации:

Пример недетерминированности:

Пример операции ввода-вывода:

Из чистых функций вытекает ссылочная (референциальная) прозрачность. Говорят, что программа или математическое выражение ссылочно прозрачны, если любое подвыражение можно заменить его значением, и это не приведет к изменению значения целого, т. е. скрытые побочные эффекты отсутствуют. Математические рассуждения, преобразования и доказательства корректности могут быть справедливыми только для выражений, обладающих этим свойством. А программы, написанные на обычных императивных языках, не являются ссылочно прозрачными, так как присваивание значений глобальным переменным, в некоторых случаях и локальным, вызывает скрытые побочные эффекты.

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

Однако, ФП имеет свои недостатки, такие как новизна парадигмы и иногда ухудшение производительности программ. Но в нашем случае главный недостаток состоит в том, что язык Python, как таковой, не является языком функционального программирования. Например, в нем нет библиотеки по работе с неизменяемыми структурами данных и оптимизации стека под хвостовую рекурсию. Однако эффективное функциональное программирование на Python вполне возможно.

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

  • Правило композиции — строить программы так, чтобы иметь возможность легко их соединять с другими программами.

  • Правило модульности — писать простые части, которые можно соединять чистыми интерфейсами.

Указанные выше два простых правила делают ненужными архитектурные шаблоны и принципы ООП, заменяя их функциями! А что, спросите вы, и классы тоже? В Python использование классов не противоречит ФП, если в них отсутствует мутирующие интерфейсы.

Пример класса с мутирующим интерфейсом:

Пример класса без мутирующего интерфейса:

Но лучше использовать замороженные dataclasses и копирование, где необходимо. Иными словами, все классы должны быть замороженными dataclasses.

При всем при этом dataclasses могут быть вполне себе умными!

Также следует использовать сторонние функциональные библиотеки (например, toolz), которые обеспечивают более оптимальную композиционность функций.

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

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

Приступая к работе с F#

F# добавляет в Visual Studio новое окно — F# Interactive, которое позволяет интерактивно выполнять код на F#. Вы можете считать его более мощной версией окна Immediate, доступной даже в том случае, если вы не переключались в режим отладки. Если вы знаете Ruby или Python, то заметите, что F# Interactive выполняет цикл «чтение-оценка-вывод» (Read-Evaluate-Print Loop, REPL), который является полезным средством для освоения F# и возможностью быстро поэкспериментировать с кодом.

Я буду использовать F# Interactive, чтобы показать, что происходит при компиляции и выполнении кода. Если вы выделите какой-то код в Visual Studio и нажмете Alt+Enter, то отправите его в F# Interactive. Возьмем простой пример на F#:

Запустив этот код в F# Interactive, вы получите следующее:

Вероятно, вы уже догадались по ключевому слову val, что number и result являются неизменными значениями. Вы можете увидеть это, использовав оператор присваивания (<-) в F#:

Поскольку вы теперь знаете, что функциональное программирование основано на неизменяемости, эта ошибка должна быть вам понятной. Ключевое слово let используется для создания неизменяемых привязок между именами и значениями. В терминологии C# то же самое можно было сказать так:по умолчанию в F# все является константами. Вы можете сделать переменную изменяемой, но только явным образом. По умолчанию поведение прямо противоположно тому, к чему вы привыкли в императивных языках:

Реализация

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

Замечательный факт №2. В стандартной библиотеке (C++17) есть почти всё необходимое для реализации частичного применения по данной модели.

Нам придётся только доопределить одну операцию, которая, впрочем, тоже выражается через те же самые стандартные инструменты.

Итак, нам понадобятся (полный и исчерпывающий список):

  1. std::forward
  2. std::move
  3. std::forward_as_tuple
  4. std::apply
  5. std::invoke
  6. std::tuple_cat
  7. std::make_tuple

Когда я говорил, что имеется почти всё необходимое, я имел в виду, что нужно доопределить одну функцию:

Теперь можно с уверенностью говорить, что всё необходимое у нас уже имеется. Можно писать код.

  1. Функция

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

  2. Структура

    1. Хранит частично применённые объекты: функцию и первые её аргументов.

    2. Имеет оператор «скобочки», который принимает произвольное количество аргументов

      1. При вызове формируется кортеж ссылок на входные аргументы при помощи функции .
      2. Кортеж с сохранёнными ранее объектами также преобразуется в кортеж ссылок при помощи определённой нами функции .
      3. Оба кортежа ссылок склеиваются в один при помощи функции . Получается один большой кортеж ссылок.
      4. Склеенный кортеж разворачивается и передаётся в функцию при помощи функции .
      5. Вызов функции от полученных аргументов.

Всё.

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

С другой стороны, простота, элегантность решения и то, что оно собрано всего из нескольких стандартных «кубиков», косвенно свидетельствует о том, что оно вполне жизнеспособно и может быть легко интегрировано в прикладной код.

Использование

Благодаря вызову поддерживаются :

А благодаря использованию функции будут поддерживаться различные сложные случаи, такие как вызов метода класса (см. std::invoke) и т.п.

И всё это «из коробки» и без каких-либо специальных телодвижений с нашей стороны.

Объектно-ориентированное программирование

ООП – это парадигма, которая характеризуется наличием инкапсуляции, наследования и полиморфизма.

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

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

Наследование позволяет делать производные структуры на основе базовых, тем самым давая возможность осуществлять повторное использование этих структур. Наследование было реально сделать в языках до ООП, но в объектно-ориентированных языках оно стало значительно удобнее.

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

Полиморфизм – это ключевое свойство ООП для построения грамотной архитектуры. Он позволяет сделать модуль независимым от конкретной реализации (реализаций) интерфейса. Этот принцип называется инверсией зависимостей, на котором основаны все плагинные системы.

Инверсия зависимостей так называется, что она позволяет изменить направление зависимостей. Сначала мы начинаем писать в простом стиле, когда высокоуровневые функции зависят от низкоуровневых. Однако, когда программа начинает становиться слишком сложной, мы инвертируем эти зависимости в противоположную сторону: высокоуровневые функции теперь зависят не от конкретных реализаций, а от интерфейсов, а реализации теперь лежат в своих модулях.

Любая зависимость всегда может быть инвертирована. В этом и есть мощь ООП.

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

Функция map

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

Встроенная в Python функция – это функция более высокого порядка, которая предназначена для выполнения именно такой задачи. Она позволяет обрабатывать одну или несколько последовательностей с использованием заданной функции. Вот общий формат функции :

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

В приведенном выше интерактивном сеансе в строках 1 и 2 двум переменным, seq и seq2, присваиваются две итерируемые последовательности. В строке 3 переменной result присваивается результат применения функции map, в которую в качестве аргументов были переданы ранее определенная лямбда-функция и две последовательности

Обратите внимание, что функция map возвращает объект-последовательность map, о чем говорит строка 5. Особенность объекта-последовательности map состоит в том он может предоставлять свои элементы, только когда они требуются, используя ленивые вычисления

Ленивые вычисления – это стратегия вычисления, согласно которой вычисления следует откладывать до тех пор, пока не понадобится их результат. Программистам часто приходится обрабатывать последовательности, состоящие из десятков тысяч и даже миллионов элементов. Хранить их в оперативной памяти, когда в определенный момент нужен всего один элемент, не имеет никакого смысла. Ленивые вычисления позволяют генерировать ленивые последовательности, которые при обращении к ним предоставляют следующий элемент последовательности. Чтобы показать ленивую последовательность, в данном случае результат работы примера, необходимо эту последовательность «вычислить». В строке 6 объект map вычисляется во время преобразования в список.

Подводя итог: функциональное реактивное программирование

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

Наконец, рассмотрим эту цитату из первого издания Eloquent JavaScript: «Fu-Tzu написал небольшую программу, использующую глобальное состояние и сомнительные переплетения, и, прочитав ее, студент спросил:« Вы предупреждали нас против этих методов, но я нахожу их в вашей программе. Как такое могло случиться?». Фу-Цзы ответил: «Нет необходимости забирать водяной шланг, когда дом не горит». .

Дополнительную информацию о функциональном реактивном программировании можно найти на следующих ресурсах:

  • Функциональное реактивное программирование для начинающих
  • Функциональное реактивное заблуждение
  • Haskell — функциональное реактивное программирование
  • Создание реактивной анимации
  • Более элегантная спецификация для FRP
  • Elm — прощание с FRP
  • Ранние успехи и новые направления в функциональном реактивном программировании
  • Разрушение FRP
  • Rx не является FRP

Заключение

Мы закончим еще одной отличной цитатой из первого издания Eloquent JavaScript: «Студент долгое время сидел за своим компьютером, мрачно хмурился и пытался написать красивое решение сложной проблемы, но не мог найти правильный подход. Фу-Цу ударил его по затылку и крикнул: ‘Введите что-нибудь!’. Студент начал писать уродливое решение, и после того, как он закончил, он внезапно понял прекрасное решение».

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

Используя этот Справочник в качестве отправной точки, вы можете начать использовать представленные концепции и парадигмы программирования для повышения своего уровня владения JavaScript. Если по описанным темам что-либо еще неясно, пожалуйста, обратитесь к ссылкам в каждом разделе за дополнительными ресурсами. Позже мы рассмотрим новые концепции в следующей статье Справочника современных концепций JavaScript!