Диспетчеризация по типу. Аддитивность.

Для сохранения прогресса вступите в курс. Войти или зарегистрироваться.

Решаемая задача: реализовать диспетчеризацию по типу своими руками.

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

CAD

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

import { reduce } from 'js-pairs-data';
import * as circle from './circle';
import * as square from './square';
import * as triangle from './triangle';
import { typeTag } from './type';

const getTotalArea = figures => reduce((figure, total) => {
    let area;
    switch (typeTag(figure)) {
      case 'square':
        area = square.getArea(figure);
        break;
      case 'circle':
        area = circle.getArea(figure);
        break;
      case 'triangle':
        area = triangle.getArea(figure);
        break;
    };
    return area + total;
  }, 0, figures);

С наличием автоматического механизма диспетчеризации (не важно реализован он в самом языке или нами самостоятельно) код сокращается до следующего:

import * as circle from './circle';
import * as square from './square';
import { reduce, l } from 'js-pairs-data';
import { getArea } from './figures';

const getTotalArea = figures => reduce((figure, total) => getArea(figure) + total, 0, figures);

const figures = l(circle.make(2), square.make(3));
getTotalArea(figures);
// 12.57 + 9
// 21,57

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

Алгоритм диспетчеризации в примере выше следующий:

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

dispatch01

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

Виртуальная таблица

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

Регистрация

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

export const definer = type => (methodName, f) => { /* ... */ };

Тогда модуль, реализующий наш тип, будет выглядеть так:

// circle.js
import { definer } from './generic';
import { attach, contents } from './type';

const defmethod = definer('Circle');

export const make = radius => attach('Circle', radius)

// Так как для определения круга не нужно ничего кроме радиуса, сам круг и есть радиус,
// Код снаружи об этом не знает!
export const getRadius = circle => contents(circle);

export const getArea = circle => (getRadius(circle) ** 2) * Math.PI;
defmethod('getArea', getArea);

export const getPerimeter = circle => 2 * getRadius(circle) * Math.PI;
defmethod('getPerimeter', getPerimeter);

dispatch02

Как видно из примера выше, по большей части Circle является типичной абстракцией, за исключением пары моментов:

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

Наш модуль generic ничего не знает про Circle, да и вообще ничего не знает про тех, кто его использует. В общем случае, для регистрации функции ему нужно знать три значения: имя типа, имя функции и само тело функции, или, другими словами, мы имеем такой интерфейс: register('TypeName', 'funcName', funcBody). А код регистрации выглядел бы так:

register('Circle', 'getArea', getArea);
register('Circle', 'getPerimeter', getPerimeter);

Обратите внимание на то, что мы находимся внутри модуля Circle и нам приходится в каждом вызове register передавать его название. Это единственная причина, по которой существует функция defmethod. То есть мы сначала специфицируем имя типа для которого будем заполнять функции, а потом делаем это без повторений.

С точки зрения теории мы использовали так называемое частичное применение функции:

const defmethod = partial(register, 'Circle');

Что эквивалентно:

const defmethod = (funcName, funcBody) => register('Circle', funcName, funcBody);

Ну и самое главное, а где же происходит регистрация? Куда записываются все эти данные о типах? Ответ достаточно простой. Фактически в наш прекрасный чистый код мы вводим внешнее изменяемое состояние и заполняем его функцией с побочными эффектами (definer). Если открыть модуль generic, то можно увидеть:

let methods = l();

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

Получается, что methods наполняется в тот момент, когда загружаются типы (выполняется import), использующие модуль generic для регистрации своих функций. Например:

// Первый встреченный импорт модуля `circle` приведет к тому, что внутри него выполнятся все определения.
import * as circle from './circle';

Поиск

Вторая задача это, собственно, поиск этих функций:

// извлечение типа объекта происходит внутри с помощью typeTag
export const getMethod = (obj, funcName) => { /* ... */ };

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

// figures.js
import { getMethod } from './generic';
import { contents } from './type';

export const getArea = (figure) => {
  const realGetArea = getMethod(figure, 'getArea');
  // В случае с кругом эквивалентно:
  // circle.getArea(figure)
  return realGetArea(figure);
}
Мы учим программированию с нуля до стажировки и работы. Попробуйте наш бесплатный курс «Введение в программирование» или полные программы обучения по Javascript, PHP, Python и Java.

Хекслет

Подробнее о том, почему наше обучение работает →