Решаемая задача: реализовать диспетчеризацию по типу своими руками.
Разложим весь процесс на примере библиотеки для работы с геометрическими фигурами. Предположим, что мы можем создавать разные фигуры, такие как треугольник, круг или квадрат. Кроме специфических свойств, у фигур есть и общие, например, периметр или площадь. А так как мы, гипотетически, хотим работать с фигурами единообразно, то реализуем диспетчеризацию по типу на примере функции, вычисляющей общую площадь фигур, размещенных на воображаемом холсте (так обычно называется область, на которой происходит рисование в графических редакторах)
При отсутствии готовой диспетчеризации нам придется делать ее руками в том месте, где требуется обобщенное поведение:
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
, это перенаправляет
запрос на расчет площади в соответствующую функцию.
Алгоритм диспетчеризации в примере выше следующий:
getArea
извлекает тип (его название) из фигуры.getArea
обращается к глобальному хранилищу (виртуальная таблица) для поиска нужной реализации настоящей функции вычисления площади.- Если реализация найдена, то
getArea
ее вызывает с нужными аргументами и возвращает результат наружу.
Важное следствие этого алгоритма в том, что для работы автоматической диспетчеризации необходимо, чтобы
реальные функции 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)
Как видно из примера выше, по большей части Circle
является типичной абстракцией, за исключением
пары моментов:
- Внутри создается привязка к типу. Соответственно все селекторы должны сначала извлечь данные и потом уже работать.
- С помощью
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)
}
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.