Эта статья о том, как я переписал свою библиотеку vec-la в стиле функционального программирования. vec-la - это небольшая 2-мерная библиотека линейной алгебры, которая моделирует векторы и матрицы как простые массивы javascript. Эта статья не о математике и линейной алгебре - вам не нужно много знать, чтобы извлечь из этого пользу.

Ускоренный курс: что такое вектор?

Если вы не знаете, вектор - это, по сути, упорядоченный набор чисел. 2D вектор - это просто упорядоченный набор из двух чисел. Если вы знакомы с координатами типа (2, 3), то интуитивно знаете, что такое вектор.

Но вектор также можно рассматривать как стрелку в пространстве; У него есть направление (на которое указывает стрелка) и величину (длина стрелки). Это важно, потому что это означает, что вектор может представлять не только точки в пространстве, но и скорость, например.

Во многих отношениях векторы похожи на обычные числа. Вы можете складывать, вычитать и масштабировать их, но вы можете делать и другие интересные вещи, например вращать и отражать их. В этом суть vec-la - он дает вам инструменты для программного управления этими математическими объектами.

Я в основном использую vec-la для создания программной анимации и игр, хотя варианты использования векторов и матриц весьма широки.

Цель

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

Идея предотвращения мутации состояния становится все более и более распространенной в последние несколько лет. Redux, в частности, привлек к этому внимание в сообществе javascript.

Вместо этого vec-la использует чистые функции, которые всегда возвращают новую копию. Чтобы проиллюстрировать эти различия, позвольте мне сравнить с другой (гораздо более популярной) библиотекой, victor.js.

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

Victor.fromArray([15, 22])

Хотя на самом деле это оказывается более утомительным, чем создание векторов с помощью new.

Используя add, вы можете увидеть идею появления мутации. Результат добавления vic1 к vic2 на самом деле хранится в vic1. Это раздражает, если вы хотите и дальше использовать vic1. vec-la всегда будет возвращать новый вектор, поэтому вам никогда не придется беспокоиться о том, что произойдет с vec1 и vec2.

То же самое и с умножением в victor - он изменяет вектор, с которым вы работаете. Это часто может привести к неожиданным и трудным для поиска ошибкам. Чтобы понять, зачем нужен следующий код:

Victor также не позволяет использовать некоторые другие более продвинутые функции, чем vec-la, например использование матриц. Если вы не знаете, что такое матрица, вот простое, но неточное объяснение:

Матрица - это набор чисел, собранных в таблицу. Есть особый способ объединить эти числа вместе с вектором или матрицей, который дает вам новый вектор или матрицу. Эта операция называется матричным умножением. Я считаю полезным думать о матрице как о некой функции преобразования.

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

Функциональный уровень выше

Вы уже могли сказать, что vec-la довольно функциональна.

  • Он работает с функциями, а не с методами
  • Он никогда не изменяет аргументы
  • Функции, которые принимают или возвращают векторы, легко соединяются вместе - они составляют

Но есть и недостатки:

  • Функции не каррированы
  • Часто приводят аргументы в «неправильном» порядке.
  • matrixBuilder добавляет в микс специализированный объект, который не является простым массивом.

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

Давайте посмотрим, как исправить каждый из этих недостатков.

Функции не каррированы

Это довольно простое решение, но на самом деле его нужно сочетать с исправлением порядка аргументов. Как я уже говорил в статье, я только что упомянул:

Обычно правило помещает данные в качестве последнего аргумента.

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

Эта функция принимает в качестве аргументов вектор v и масштабный коэффициент sc. Из-за этого порядка вы не можете частично применить эту функцию полезным способом; Гораздо более вероятно, что вы захотите масштабировать разные векторы с помощью одного и того же масштабного коэффициента, чем то, что вы захотите масштабировать одинаковые вектор с помощью различных коэффициентов масштабирования. Если вы поменяете аргументы местами, вы сможете сделать более значимую функцию.

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

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

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

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

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

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

Это менее распространенный вариант использования, но наличие такой функции, как flip2, позволяет легко управлять, когда она появляется. Как и curry, вы найдете реализацию flip во всех основных функциональных библиотеках, таких как ramda или sanctuary.

Проблема matrixBuilder ()

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

Все функции в новой библиотеке были созданы для поддержки композиции функций, но свободный интерфейс просто не поддерживает; Вы должны продолжать вызывать методы до тех пор, пока матрица не будет готова, а затем вызвать get (), чтобы освободить значение матрицы. (если вы не знаете, что такое композиция функций, прочтите статью, которую я упоминал ранее)

Давайте посмотрим на исходный код matrixBuilder.

Функция vMatrixBuilder принимает матрицу (или ничего, что подразумевает единичную матрицу) и возвращает объект с некоторыми методами поворота, масштабирования и т. Д. Из каждого из этих методов он возвращает новый matrixBuilder, который составляет старую матрицу и новую, выполняющую заданную операцию.

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

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

Это, наверное, самый пугающий фрагмент кода во всей статье. Я показал функцию mCompose только для того, чтобы показать, что есть какой-то способ составления матриц, но понимание не важно (и, по сути, невозможно только из этой реализации). Если вы хотите знать, как на самом деле работает вся математика, я рекомендую серию 3blue1browns« Суть линейной алгебры ».

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

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

Резюме

Такое переосмысление библиотеки приводит к гораздо большей совместимости с другими библиотеками, особенно с функциональными, такими как ramda, с которыми мне нравится работать. Чтобы увидеть пример всего этого, посмотрите эту программную анимацию на codepen.

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

Вы можете увидеть как новый vec-la-fp, так и исходный vec -la на github. Оба они содержат около 150 строк реального кода, так что это довольно простой проект, чтобы разобраться в нем.

Если вам понравилась эта статья, дайте мне знать, оставив комментарий здесь или в твиттере.

Я начинаю серию статей под названием «Изучение ramda», в которой я буду писать о включении функционального стиля в javascript с помощью библиотеки ramda, так что подписывайтесь на меня здесь, если это вас интересует.