Вам не нужен lodash — но не там, где вы думаете
«Перепишем lodash на flang» — предложение, которое звучит разумно. Во flang
есть списки и функции над ними: отобразить вместо map, отфильтровать
вместо filter, свёртка вместо reduce, разбор списка на пусто и
голова и хвост, записи и типы с вариантами. Технически большую часть
lodash выразить можно. Вопрос не в том, можно ли, а в том, что из этого стоит
переносить.
Откройте список реальных вызовов lodash в своём проекте и разделите его на две
группы. В первой окажутся clamp, inRange, round в расчёте цены,
defaultTo, sumBy с условием, maxBy по порогу, цепочки get с запасным
значением. Это не работа с данными — это бизнес-правила, записанные
аргументами функций. Во второй группе окажется всё остальное, и оно останется в
JavaScript. Тезис главы: lodash уходит из проекта не потому, что появился
flang, а потому, что половина его вызовов была логикой без модели, а вторая
половина давно есть в стандартной библиотеке языка. flang забирает первую
половину — и забирать её стоит, потому что она обязана быть проверяемой.
Минимальный пример
Списание бонусов при оплате заказа. В прикладном коде это обычно одна строка:
clamp(defaultTo(customer.bonus, 0), 0, round(order.amount * 0.3, 2)).
модуль «Ценообразование»
объект «Списание бонусов»
«сумма заказа»: неотрицательное
«баланс бонусов»: число
тотальная функция «Предел списания»
принимает заказ: неотрицательное
возвращает число
обеспечивает «крупный заказ упирается в потолок» если заказ больше 100000 то результат равен 30000 иначе да
пример «треть от двух тысяч»
дано заказ равно 2000
ожидается 600
если заказ больше 100000
то 30000
иначе (заказ умножить на 30) делить на 100
тотальная функция «Списать бонусы»
принимает списание: «Списание бонусов»
возвращает число
обеспечивает «списание не превышает потолка» результат не больше 30000
обеспечивает «мелкий заказ бонусами не оплачивается» если списание.«сумма заказа» меньше 500 то результат равен 0 иначе да
пример «баланс ниже предела»
дано списание равно запись «Списание бонусов» с «сумма заказа» равным 2000 и «баланс бонусов» равным 300
ожидается 300
пример «баланс ровно на пределе»
дано списание равно запись «Списание бонусов» с «сумма заказа» равным 2000 и «баланс бонусов» равным 600
ожидается 600
пример «баланс выше предела»
дано списание равно запись «Списание бонусов» с «сумма заказа» равным 2000 и «баланс бонусов» равным 1000
ожидается 600
пример «мелкий заказ»
дано списание равно запись «Списание бонусов» с «сумма заказа» равным 400 и «баланс бонусов» равным 1000
ожидается 0
пример «отрицательный баланс не списывается»
дано списание равно запись «Списание бонусов» с «сумма заказа» равным 2000 и «баланс бонусов» равным -50
ожидается 0
пример «заказ ровно на потолке списания»
дано списание равно запись «Списание бонусов» с «сумма заказа» равным 100000 и «баланс бонусов» равным 50000
ожидается 30000
пример «крупный заказ упирается в потолок»
дано списание равно запись «Списание бонусов» с «сумма заказа» равным 200000 и «баланс бонусов» равным 100000
ожидается 30000
пусть предел равно «Предел списания» от списание.«сумма заказа»
если списание.«сумма заказа» меньше 500
то 0
иначе если списание.«баланс бонусов» не больше 0
то 0
иначе если списание.«баланс бонусов» больше предел
то предел
иначе списание.«баланс бонусов»
Строка из lodash развернулась в две функции, три обещания и восемь примеров.
Это не многословность: clamp не отвечал на вопрос, что делать с
отрицательным балансом, а defaultTo не отвечал, откуда взялось решение
подставить ноль. Теперь оба ответа записаны и проверяются: отрицательный
баланс — ветвь не больше 0, а баланса, которого нет, не бывает вовсе — поле
«баланс бонусов» обязательное.
Обе границы clamp стали ветвями тела, а предел — отдельной функцией с
именем. Обещания при этом отвечают на вопрос, что гарантировано: потолок в
30 000, ноль для мелкого заказа и то, что крупный заказ упирается ровно в
потолок.
Что делает компилятор
- Ветви
если … иначе еслипроверяются сверху вниз, и срабатывает первая истинная. Порядок ветвей — часть правила: мелкий заказ отсекается раньше, чем считается предел. - Обещание «крупный заказ упирается в потолок» доказано для всех входов: условие обещания совпадает с условием ветви, и ядро сводит цель правилом «разбор цели по условию». То же с обещанием «мелкий заказ бонусами не оплачивается».
- Обещание «списание не превышает потолка» не доказано, и отчёт говорит об
этом прямо: «сетка 7 значений (примеры функции) … Это не доказательство».
Ядро не выводит, что тридцать процентов от суммы не больше 30 000, когда
сумма не больше 100 000: арифметику с процентами оно не доказывает. Поэтому
flang check --proofвыходит кодом 3, а напечатанный код проверяет это обещание при каждом вызове. - Нарушенное обещание останавливает вызов с
FLANG_PROPERTY; значение наружу не уходит и не подгоняется. - Примеры выполняются на каждой проверке, а в сервисе считает функция,
напечатанная
flang emitиз того же файла. Расхождение между главой, песочницей и кодом приложения невозможно.
постусловие «крупный заказ упирается в потолок» функции «Предел списания» — доказано сведением цели с телом функции: правило «разбор цели по условию», объявленные типы аргументов не понадобились — утверждение обо ВСЕХ входах, а не о написанных; теоремы при нём нет и не нужно
постусловие «списание не превышает потолка» функции «Списать бонусы» — сетка 7 значений (примеры функции): нарушений НЕ ИСКАЛИ — прогона примеров не было, посчитано только их число. Это не доказательство — теоремы при утверждении нет
постусловие «мелкий заказ бонусами не оплачивается» функции «Списать бонусы» — доказано сведением цели с телом функции: правило «разбор цели по условию», объявленные типы аргументов не понадобились — утверждение обо ВСЕХ входах, а не о написанных; теоремы при нём нет и не нужно
static/spec/models/pricing-rules.flang: НЕ ДОКАЗАНО — ОПОРА НЕ СУДИЛАСЬ — утверждений 3: доказано 2, условно 0, сетка 1, объявлено, не доказано 0, отвергнуто 0, нарушено 0; законов на сетке 0, на веру 0 — код возврата 3
Что уезжает в модель, а что остаётся в JavaScript
| Вызов lodash | Что это на самом деле | Куда девается |
|---|---|---|
clamp(x, lo, hi) над входным полем |
нижняя и верхняя границы правила | ветви если в модели + обещание с абсолютным потолком |
inRange(x, a, b) |
диапазон-условие | условие ветви через и притом |
round/ceil/floor в расчёте цены |
порог правила + денежное представление | порог — в модель, округление — в приложение |
defaultTo(v, d) |
решение «чем заменить отсутствие» | обязательное поле объекта + нормализация на границе |
sumBy(items, fn) с условием внутри fn |
условие — правило, суммирование — перебор | условие в модель, суммирование там, где удобнее: свёртка или reduce |
maxBy(items, fn) по порогу |
порог — правило, поиск максимума — перебор | порог в модель, перебор в модель или в JS |
get(o, 'a.b', d) |
отсутствие контракта данных | объект модели + одна функция нормализации |
map, filter над правилом |
правило применяется к каждой позиции | отобразить, отфильтровать в модели, если обещание касается всего списка |
groupBy, chunk, zip |
работа с данными | остаются в JS |
debounce, throttle |
управление временем | остаются в JS |
cloneDeep, merge, isEqual |
работа со структурами | остаются в JS (structuredClone, Object.assign) |
Верхняя часть таблицы — то, что в модели становится читаемым и проверяемым. Нижняя — то, что в модели стало бы хуже, даже если язык это умеет. Середина — перебор: его стоит переносить, только когда вместе с ним переезжает обещание о списке, например «у каждой позиции своя скидка».
get(order, 'customer.tier', 'basic') — не правило, а симптом
Эта цепочка встречается в любом зрелом фронтенде, и в ней спрятаны три разных утверждения, ни одно из которых не записано.
Первое: «поля customer может не быть». Второе: «если его нет, клиент
считается базовым». Третье, самое дорогое: «разница между отсутствующим
профилем и профилем базового уровня для расчёта несущественна». Первое — про
формат данных, второе — решение бизнеса, третье — гипотеза, которую никто не
проверял. get записывает все три одним аргументом по умолчанию, в
четырнадцати местах кода, каждое из которых можно поправить независимо.
Модель этот вопрос не решает — она делает его обязательным. Поле
«уровень клиента» в examples/spec/lodash/pricing.flang имеет тип с тремя
вариантами: «Базовый», «Серебро», «Золото». Строку «платина» в модель не
передать, а отсутствующий уровень — тем более. Решение «чем заменить
отсутствие» приходится вынести на границу и назвать:
export function normalizeTier(cart) {
const raw = cart?.customer?.tier;
if (typeof raw === 'string' && TIERS.has(raw)) return { tier: raw, assumed: false };
return { tier: 'базовый', assumed: true };
}
Функция возвращает не только уровень, но и признак assumed. Это дешёвая
строчка, но она превращает молчаливое допущение в измеримое: теперь можно
посчитать, сколько корзин посчиталось по угаданному уровню. Пока решение жило
третьим аргументом get, такой вопрос было некому задать.
Если отсутствие поля — законное состояние, а не дыра в данных, во flang для
этого есть иногда является: поле становится необязательным, и это видно в
модели. Подставлять значение по умолчанию модель при этом всё равно не будет.
Округление: правило есть, round нет
Для обычных чисел (число) округления во flang нет. Встроенная форма
целая часть есть, но только у точных дробей, а не у чисел с плавающей
точкой. Попытка округлить ею число (файл round.flang) не проходит проверку:
модуль «Копейки»
тотальная функция «До копеек»
принимает рубли: число
возвращает число
пример «одиннадцать и двадцать пять тысячных»
дано рубли равно 11.025
ожидается 11.03
(целая часть ((рубли умножить на 100) плюс 0.5)) делить на 100
FLANG_BUILTIN_ARGS в файле round.flang, строка 9, столбец 41: аргумент 1 формы «целая часть»: ожидался точное дробное, получен число
Отсюда честный вывод, а не обходной путь. Модель считает точное значение:
7 процентов от 157,50 — это 11,025, и никакое другое число. До копеек значение
доводит приложение — ровно один раз, на границе, тем же round, каким
пользовалось раньше. Правило «сколько процентов» уезжает в модель, правило «в
какой момент деньги становятся копейками» остаётся в коде, потому что это
свойство представления, а не тарифа.
Деньги целым числом копеек во flang записываются типом сотых, и сумма двух
таких значений не теряет копейку. Но процент от суммы всё равно даёт дробь, и
решать, куда её округлять, по-прежнему должен кто-то, кто знает валюту,
направление (в чью пользу) и место в цепочке расчёта. Модель этого не знает и
знать не должна.
Разбор: before.mjs → pricing.flang → after.mjs
Комплект лежит в examples/spec/lodash/. Считается скидка на позицию корзины:
процент за уровень клиента, надбавка за опт от десяти штук, промокод и верхний
предел в 13 процентов.
before.mjs — lodash-стиль (сама библиотека не подключена, пять её функций
воспроизведены локально, round — дословно, вместе со сдвигом экспоненты):
let discount = 0;
if (percent > 0) discount += round((base * percent) / 100, 2);
if (quantity >= 10) discount += round((base * 5) / 100, 2);
if (line.promo === 'ЛЕТО') discount += round((base * 2) / 100, 2);
discount = round(clamp(discount, 0, round((base * MAX_DISCOUNT_PERCENT) / 100, 2)), 2);
pricing.flang — те же решения функциями, предел обещанием:
модуль «Ценообразование корзины»
тип «Уровень клиента»
вариант «Базовый»
вариант «Серебро»
вариант «Золото»
объект «Позиция корзины»
«стоимость»: неотрицательное
«количество»: неотрицательное
«уровень клиента»: «Уровень клиента»
«промокод»: строка
тотальная функция «Процент уровня»
принимает уровень: «Уровень клиента»
возвращает неотрицательное
обеспечивает «процент уровня не больше семи» результат не больше 7
пример «серебро — четыре процента»
дано уровень равно вариант «Серебро»
ожидается 4
разбор уровень
случай «Базовый»
то 0
случай «Серебро»
то 4
случай «Золото»
то 7
тотальная функция «Процент скидки»
принимает позиция: «Позиция корзины»
возвращает число
пример «золото на границе опта»
дано позиция равно запись «Позиция корзины» с «стоимость» равным 1000 и «количество» равным 10 и «уровень клиента» равным (вариант «Золото») и «промокод» равным ""
ожидается 12
пусть опт равно (если позиция.«количество» не меньше 10 то 5 иначе 0)
пусть промо равно (если позиция.«промокод» равен "ЛЕТО" то 2 иначе 0)
(«Процент уровня» от позиция.«уровень клиента») плюс опт плюс промо
тотальная функция «Скидка на позицию»
принимает позиция: «Позиция корзины»
возвращает число
обеспечивает «скидка не превышает 13 процентов» результат не больше ((позиция.«стоимость» умножить на 13) делить на 100)
пример «базовый уровень, розничная позиция»
дано позиция равно запись «Позиция корзины» с «стоимость» равным 1000 и «количество» равным 1 и «уровень клиента» равным (вариант «Базовый») и «промокод» равным ""
ожидается 0
пример «серебро без опта»
дано позиция равно запись «Позиция корзины» с «стоимость» равным 1000 и «количество» равным 1 и «уровень клиента» равным (вариант «Серебро») и «промокод» равным ""
ожидается 40
пример «опт без уровня»
дано позиция равно запись «Позиция корзины» с «стоимость» равным 1000 и «количество» равным 10 и «уровень клиента» равным (вариант «Базовый») и «промокод» равным ""
ожидается 50
пример «серебро, опт и промокод»
дано позиция равно запись «Позиция корзины» с «стоимость» равным 1000 и «количество» равным 10 и «уровень клиента» равным (вариант «Серебро») и «промокод» равным "ЛЕТО"
ожидается 110
(позиция.«стоимость» умножить на («Процент скидки» от позиция)) делить на 100
тотальная функция «Скидки корзины»
принимает позиции: список «Позиция корзины»
возвращает список числа
обеспечивает «у каждой позиции своя скидка» (длина результат) равен (длина позиции)
пример «пустая корзина»
дано позиции равно пустой список
ожидается пустой список
отобразить позиции как позиция → «Скидка на позицию» от позиция
Здесь потолок намеренно записан процентом от поля: весь смысл разбора в том,
что старый clamp обрезал результат по той же формуле, и сравнение реализаций
должно идти по ней же. Ядро это обещание не доказывает (сетка 4 значения), а
обещание о проценте уровня доказывает индукцией по трём вариантам типа.
Последняя функция показывает, что корзину можно перебрать и в модели: обещание
«у каждой позиции своя скидка» доказано для любого списка.
after.mjs — модель печатается в JavaScript один раз, дальше правило —
обычная функция, а перебор позиций — обычный map:
const printed = await importModel(MODEL_FILE);
const discountFor = printed.functions.get('Скидка на позицию');
const variant = printed.module.$PROGRAM.variant;
export function lineDiscount({ base, quantity, tier, promo }) {
const exact = discountFor({
стоимость: base,
количество: quantity,
'уровень клиента': variant(TIERS.get(tier)),
промокод: promo,
});
return round(exact, 2);
}
importModel из examples/spec/lib/flang.mjs зовёт flang emit --target js
и подключает напечатанный модуль; функции в нём ищутся по именам из модели.
equivalence.test.mjs прогоняет обе реализации на детерминированной сетке из
350 входов — семь цен с копейками, пять количеств вокруг порога, пять значений
уровня (включая отсутствующий профиль и незнакомую «платину») и два промокода:
входов: 350; совпало: 318; расхождений: 32 (Потолок вместо отказа — 14; Двойное округление процентов — 18)
позиций в корзине: 65; итог: 815563.4; скидка: 76715.39
✔ модель проходит проверку, и её собственные примеры сходятся
✔ lodash-версия и модель совпадают на всей сетке, кроме объявленных расхождений
✔ каждое объявленное расхождение подтверждается сеткой и объяснено
✔ перебор корзины в JavaScript: многопозиционная корзина считается одинаково
✔ модель отказывается считать без поля, а не подставляет умолчание
ℹ tests 5
ℹ pass 5
ℹ fail 0
Тридцать два расхождения — не дефект переноса, а две находки, и обе описаны
предикатом в KNOWN_DIVERGENCES, а не подогнаны.
Потолок вместо отказа (14 входов). Золото плюс опт плюс промокод дают
14 процентов при объявленном пределе 13. clamp молча обрезал результат: на
цене 1234.55 за десять штук старый код отдаёт скидку 1604.92 — число, которого
не описывает ни одно правило. Напечатанная модель на том же входе
останавливается ошибкой FLANG_PROPERTY. Это и есть разница между «предел»
как аргументом функции и «предел» как обещанием: во втором случае кто-то
обязан решить, можно ли вообще выдавать такое сочетание, а не молча продавать
по 13.
Двойное округление процентов (18 входов). В lodash-версии round стоял
внутри каждого слагаемого. База 157.50: 7 процентов — это 11.025, 5 процентов —
7.875, обе половинки копейки округляются вверх, и сумма выходит 18.91. Модель
складывает проценты и считает 157.50 · 12 / 100 = 18.9, приложение округляет
один раз — 18.90. Копейка разницы взялась не из бизнес-правила: количество
вызовов round было следствием того, как написан if. Правильный ответ здесь
у новой версии, но узнать об этом можно было только сравнив реализации на
сетке.
Чего flang не заменит
- Управление временем.
debounceиthrottle— про часы и планировщик. Функция flang не читает часы и не хранит состояние между вызовами; будь иначе, её примеры перестали бы быть тестами. Действия с миром во flang описываютсяпланом, но это другая задача. - Данные неизвестной формы.
getпо произвольному пути иisEqualпо структуре неизвестной глубины работают с JSON, который ещё никто не описал. Во flang у каждой записи объявлены поля и их типы, поэтому такая работа остаётся на границе — там, где данные приводятся к форме модели. - Округление чисел. Для
числоформы округления нет; копейки — работа приложения. - Проверку входа внутри напечатанной функции. Функция из
flang emitтипы входа сама не сверяет: это делает прогонщикflang_cli.jsна границе процесса иflang run. Вызывая функцию прямо из JavaScript, нормализуйте вход до вызова — какnormalizeTier.
Перебор списков в этот перечень не входит: во flang он есть. Переносить его стоит тогда, когда вместе с ним переезжает обещание о результате.
Практика в песочнице
- Добавьте в «Списать бонусы» пример: заказ на 2000 и баланс 900. Сколько ожидать? Проверьте — ожидание 600 сойдётся: срабатывает предел.
- Поставьте ветвь
если списание.«баланс бонусов» больше пределпервой, выше ветви мелкого заказа, и проверьте. Пример «мелкий заказ» нарушит обещание «мелкий заказ бонусами не оплачивается»: компилятор ответитFLANG_EXAMPLE … FLANG_PROPERTYи кодом 1. Порядок ветвей — часть правила, и обещание это стережёт. - Ужесточите обещание до
результат не больше 500и проверьте. Четыре примера сразу покажутFLANG_PROPERTY— там, гдеclampпоказал бы правдоподобное число.
Типичные ошибки
- Прятать предел в теле вместо обещания. Ветвь «если больше потолка — то
потолок» выглядит как правило, но делает то же, что
clamp: молча отдаёт число, которое не описывает ни одно правило. Если предел — требование, а не решение бизнеса «обрезать», запишите его обещаниемобеспечивает, и нарушение остановит расчёт. - Переносить
roundв модель. Длячислоокругления нет, ацелая частьпринимает только точные дроби. Модель считает точное значение; копейки — работа приложения. - Оставлять
defaultToв адаптере молча. Подстановка значения на границе допустима, но она обязана быть видимой: отдельная функция, имя, признак «значение подставлено». Иначе умолчание просто переехало из lodash в адаптер. - Держать уровень клиента строкой. Строка пропустит «платину» и опечатку.
Тип с вариантами не пропустит, а
разборпо нему обязан назвать все варианты — забытый уровень компилятор отвергнет. - Считать
FLANG_PROPERTYрегрессом. Обещание сработало ровно там, где старый код тихо обрезал результат. Это не поломка расчёта, а первый раз, когда его спросили. - Считать код 3 провалом или успехом.
flang check --proofс кодом 3 говорит: модель проверена, но часть обещаний держится на примерах. Это нормальное состояние модели с процентами — и это же причина, по которой напечатанный код проверяет такие обещания при каждом вызове.
Чек-лист
- Список вызовов lodash разделён на два: бизнес-правила и работа с данными — до того, как написана первая строка модели.
- В модель уехали пороги, проценты, границы и условия; в коде остались клонирование, сравнение, таймеры и разбор данных неизвестной формы.
- Ни одна цепочка
get(o, 'a.b', d)не переехала в модель как есть: поле стало обязательным, а решение об умолчании — именованной функцией на границе. - Значения из закрытого списка (уровни, статусы) — тип с вариантами, а не строка.
- Округление вызывается один раз на границе, а не внутри каждого слагаемого.
- Верхние пределы записаны обещаниями, и известно, что нарушение обещания останавливает расчёт, а не исправляет его.
- Есть тест эквивалентности на детерминированной сетке, а найденные расхождения объявлены списком с объяснениями, а не подогнаны под совпадение.
- Старый код выключается только после того, как список расхождений разобран владельцем правила.