Intro#
Осилил статью Бориса Баткина про templates в dascript. Мне нравится dascript тем, что в нём иногда встречается код, в который можно вчитываться много раз, и каждый раз находить в нём что-то новое. Это как раз такая статья, в которой стоит попробовать разобраться. Информация в ней сильно сжата, и приправлена сложным слогом, но оно того стоит.
В ней описан пример того, как с помощью синтаксических макросов добавить в язык обработчик темплейтов, похожий на C++. Его можно развивать и дальше в сторону C++, если хочется поупражняться в понимании того, как это работает. Но на самом деле, в большинстве случаев на dascript существуют другие, более элегантные решения для большинства проблем, которые бы решались в C++ через шаблоны.
Эта статья навела меня на мысль попробовать реализовать через макросы некоторые штуки, которые мне попадались в F# (-F# in imaginery world). Особенно с учетом того, что я вкрутил себе dascript в jupyter notebook и запихал туда возможность работы с макросами (- Daslang Jupyter - dastrudel, macro workbench).
В репозитории собрал несколько таких экспериментов. Я не уверен в их практичности, но сгодятся как академические примеры того, что можно делать с помощью макросов.
Type providers#
Type providers создают типы по внешнему описанию данных. Например, схема игрока хранится в JSON, а в коде хочется работать с обычными полями и получать ошибку компиляции при опечатке в имени или несовпадении типов.
typedef Player = provided_type("schemas/native", "Player")
def apply_damage(var player : Player; damage : int) {
player.health -= damage
}Здесь Player и его поля берутся из схемы. Получившийся тип можно передавать в функции, как обычную структуру. Провайдер читает только запрошенную схему и зависимости её полей. В библиотеке есть также простой вариант с аннотацией [json_provider] и вариант без генерации структур — с проверяемым доступом к уже разобранному JSON:
let health : int = erased(node, "schemas/world", "Player", _.health)
let leader : string = erased(node, "schemas/world", "Player", _.team.leader.name)Потенциально это полезно для игровых конфигов, описаний ресурсов и данных из внешних инструментов: не нужно поддерживать одинаковый набор полей в схеме и в исходниках. Проверка схемы при компиляции при этом не отменяет проверку самих данных при загрузке.
Active patterns#
Active patterns позволяют использовать функцию-распознаватель прямо в сопоставлении с образцом. Например, распознать в строке число и сразу использовать его в подходящей ветке. Отдельный макрос для этого не понадобился — нужные возможности уже есть в стандартном daslib/match.
let description = match (text) {
(integer(_) => found(n)) && n < 0 => "negative {n}"
(integer(_) => found(n)) => "integer {n}"
_ => "text"
}integer — функция из библиотеки, возвращающая found с числом или missing. _ внутри вызова обозначает исходную строку. Распознаватели могут принимать и дополнительные параметры:
let eligibility = match (value) {
(at_least(_, minimum) => found(level)) => "accepted {level}"
_ => "too low"
}Так удобно разбирать команды, классифицировать события и проверять игровые условия: детали распознавания живут в отдельных функциях, а варианты поведения видны рядом. Распознаватель может вызываться повторно для разных веток, поэтому лучше обходиться без побочных эффектов.
Тот же разбор без view patterns можно записать через обычную проверку variant. Распознаватель integer остаётся простой функцией, собственной библиотеки макросов ему не нужно:
def describe(text : string) : string {
let parsed = integer(text)
if (parsed is found) {
let n = parsed as found
if (n < 0) { return "negative {n}" }
return "integer {n}"
}
return "text"
}Computation expressions#
Computation expression — это блок последовательного кода, правила выполнения которого задаёт специальный объект, builder. Макрос преобразует блок в вызовы его операций. Builder решает, что делать после очередного шага: продолжить вычисление, вернуть ошибку, дождаться результата или выполнить оставшуюся часть для каждого элемента коллекции. Bind похож на “перегруженную точку с запятой”. Интересны тем, что с их помощью можно реализовать очень различные фичи языка, от сокращения избыточного кода, до выворачивания порядка выполнения наизнанку — ленивые вычисления, ранний выход, возвраты и повторное выполнения кода, асинхронные вычисления. Таким образом, практически любой код можно заставить выглядеть линейно (если бизнес-логика, которую описывает код линейная, то чтобы она и в коде выглядела линейной), спрятав всю нелинейность внутрь билдера.
let result = computation(builder) {
let a = first_step() |> bind
let b = second_step(a) |> bind
return combine(a, b)
}Остаток блока после bind становится продолжением — кодом, который получит успешное значение и выполнит следующие шаги. Упрощённо первый bind означает: «вот результат first_step() и функция, описывающая всё, что надо сделать дальше». Builder может вызвать эту функцию сразу, позже, несколько раз или вообще не вызывать.
Для знакомых с C++ здесь можно провести аналогию с promise_type, co_await и co_return: поведение привычной конструкции определяется пользовательским типом. Но CE охватывают и распространение ошибок, и перебор вариантов, и передачу состояния. Это универсальный протокол, через который реализуются разные способы связывать вычисления:
| Builder | Что происходит после bind |
|---|---|
| Result | Продолжить с успешным значением или вернуть ошибку |
| Option | Продолжить, если значение есть, иначе вернуть none |
| Task | Дождаться завершения операции и продолжить с её результатом |
| List | Выполнить продолжение для каждого элемента и собрать результаты |
| State | Передать состояние следующему шагу |
| Parser | Продолжить разбор с новой позиции во входном тексте |
Builder реализуется обычными типами и перегрузками функций протокола. Можно добавить собственный билдер отдельным модулем, не меняя сам макрос CE.
Для более подробного знакомства с идеей в F# есть серия Computation Expressions на F# for fun and profit.
Зачем это нужно для Result и Option
Сами Result и Option описывают результат операции: успешное значение или ошибку, наличие или отсутствие. CE сокращают код, который связывает несколько таких операций. Особенно когда следующий шаг зависит от предыдущего.
Возьмём функцию, которая проверяет число:
def checked_number(value : int) : $Result<int;string> {
if (value < 0) {
return result_error("negative number", type<int>)
}
return result_ok(value)
}Без CE нужно проверить каждый результат, выйти при ошибке и извлечь значения:
def remaining_plain() : $Result<int;string> {
let health = checked_number(100)
if (health is err) { return health }
let damage = checked_number(15)
if (damage is err) { return damage }
return result_ok((health as ok) - (damage as ok))
}Через CE остаются сами действия:
let result = computation(ResultBuilder()) {
let health = checked_number(100) |> bind
let damage = checked_number(15) |> bind
return health - damage
}Здесь health и damage внутри блока уже числа. Проверки is err, извлечение as ok и упаковку ответа берёт на себя builder. Если первая проверка не пройдёт, вторая даже не вызовется. В ручном примере все успешные значения имеют тип int, поэтому ошибку можно вернуть напрямую. Если шаги возвращают разные типы — например, сначала ID, затем структуру игрока, затем строку — пришлось бы ещё перепаковывать ошибку в Result нужного типа.
Для Option выигрыш хорошо виден на цепочке необязательных данных. Допустим, find_player, find_team и find_leader возвращают Option соответствующих структур. Ручная версия:
def leader_name_plain(id : int) : $Option<string> {
let player = find_player(id)
if (player is none) { return none(type<string>) }
let team = find_team((player as some).team_id)
if (team is none) { return none(type<string>) }
let leader = find_leader(team as some)
if (leader is none) { return none(type<string>) }
return some((leader as some).name)
}И та же последовательность через builder:
def leader_name(id : int) : $Option<string> {
return computation(OptionBuilder()) {
let player = find_player(id) |> bind
let team = find_team(player.team_id) |> bind
let leader = find_leader(team) |> bind
return leader.name
}
}Отсутствие любого звена превращает результат в none, а все последующие шаги пропускаются. Внутри блока можно читать код так, будто необходимые значения уже найдены. Для одного обращения короче обычный if, match, map или ??; удобство CE проявляется именно в длинной цепочке.
Где хватит обычного dascript
Универсальность механизма ещё не означает, что каждый builder нужен в повседневном коде. В dascript уже есть изменяемые переменные, генераторы, операции над коллекциями, корутины и async. Для многих задач они удобнее:
| Задача | С чего я бы начал |
|---|---|
| Изменять состояние | Обычный var и передача состояния по ссылке |
| Передавать окружение или вести журнал | Параметры функций, захваты, массив или logger |
| Преобразовать и отфильтровать коллекцию | for, генератор массива, daslib/linq или daslib/functional |
| Выдавать последовательность значений | Обычный генератор |
| Описать грамматику | dasPEG |
| Подождать следующий кадр | Стандартная корутина или [async] |
Builders становятся интереснее, когда управление хочется выразить именно ходом исполнения кода: между шагами есть зависимые вычисления, ветвления, раннее отсечение, ожидания или ресурсы. Например, следующее правило парсера зависит от уже прочитанного значения; поиск должен отсечь неудачную ветку.
Конкретные билдеры могут добавлять абстракцию и иметь накладные расходы, другие просто перестраивают код.
Task: общий API для задач
Task даёт типизированный результат, ожидание других задач, статусы выполнения, отмену, таймауты и композицию. TaskBuilder работает с задачами, возвращающими значение, а TaskResultBuilder дополнительно распространяет ошибки Result: после неудачного шага остаток блока пропускается.
Задача создаётся отложенной: её тело начинает выполняться при первом опросе. Если очередная операция ещё не готова, управление возвращается вызывающему коду. Когда она завершится, выполнение продолжится с места ожидания. Готовый результат сохраняется, повторный опрос не запускает тело заново.
Например, задержка без блокировки игрового цикла:
def cooldown(duration : int) {
return <- computation(TaskBuilder(type<int>)) {
task_delay_ms(duration) |> bind
return 100
}
}
var inscope scheduler <- task_scheduler(type<int>)
let id = task_spawn(scheduler, cooldown(30))
task_tick(scheduler, 1000) // Начали ожидание.
task_tick(scheduler, 1016) // Ещё ждём; игровой цикл может работать дальше.
task_tick(scheduler, 1030) // Задержка завершилась.
let value = task_value(scheduler, id) // 100В приложении task_tick вызывается каждый кадр с текущим временем в миллисекундах. Здесь время задано явно для наглядности. Типизированный scheduler обслуживает задачи одного типа результата; отдельный task_runtime позволяет запускать задачи разных типов и получать типизированные handles. Можно ограничивать число опросов за tick, задавать таймауты и читать статус каждой задачи.
Более содержательный пример — обмен по WebSocket через адаптер dasHV:
def exchange(url : string; text : string) {
return <- computation(TaskResultBuilder(type<string>)) {
var socket & = hv_websocket() |> use_resource
ws_connect(socket, url) |> bind
ws_send_text(socket, text) |> bind
let reply = ws_receive_text(socket) |> bind
return reply
}
}Подключиться, отправить сообщение, получить ответ — всё читается последовательно. Между этими шагами задача может ждать, не удерживая вызывающий поток. Ошибка подключения или отправки завершит цепочку. use_resource удерживает сокет через ожидания и закрывает его при завершении, ошибке, отмене или таймауте. Для своих ресурсов нужно определить операцию ce_dispose.
Два независимых обмена можно объединить:
def two_requests(urlA : string; urlB : string) {
return <- computation(TaskResultBuilder(type<string>)) {
let pair = exchange(urlA, "one") |> and_bind(exchange(urlB, "two")) |> bind
return "A={pair.first}; B={pair.second}"
}
}Обе операции продвигаются совместно: ожидание первой не мешает второй. Это конкурентное выполнение, для него не обязательно создавать два потока. Для набора задач есть when_all, для гонки — race, а для загрузки большого числа ресурсов — варианты с лимитом одновременно выполняемых задач. Лимит позволяет создавать новую работу по мере освобождения места.
Отмену и таймаут можно задать снаружи, не переписывая функцию обмена:
var inscope level <- cancellation_token()
var inscope scheduler <- task_scheduler(type<$Result<string;string>>)
let id = task_spawn(scheduler,
task_with_cancellation(two_requests(urlA, urlB), level),
TaskTimeout(after_ms=5000))
task_tick(scheduler, 0)
// Игрок покинул уровень — его операции больше не нужны.
cancellation_cancel(level)
task_tick(scheduler, 16)
// Задача отменена, вложенные ожидания остановлены, сокеты закрыты.Вокруг Task есть и другие полезные детали: каналы для обмена между задачами, ожидание событий, уведомления о прогрессе, семафоры, повторные попытки, адаптер стандартного [async], TCP и WebSocket. AsyncSequenceBuilder совмещает ожидание задач с выдачей элементов: потребитель запрашивает следующий элемент потока, а производитель может сначала дождаться данных.
Сам Task остаётся кооперативным: тяжёлый синхронный расчёт внутри него займёт вызывающий поток. Для фоновой работы есть мост к стандартному jobque, а также HTTP-адаптер.
Для асинхроенных задач всегда нужен планировщик - Async Rust: Where does the scheduler live?. В библиотеке есть пара простых.
Дальше уже не копии с F#, но еще библиотеки, переделанные с других языков.
ReactiveX#
ReactiveX описывает обработку потока событий цепочкой операций: отфильтровать, преобразовать, накопить состояние, объединить с другим потоком. Простой пример — получить два первых чётных числа, умноженных на десять:
var inscope numbers <- (rx_range(1, 10)
|> rx_filter(@(n:int) { return n % 2 == 0 })
|> rx_map(@(n:int) { return n * 10 })
|> rx_take(2))
var inscope subscription <- rx_subscribe(numbers,
@(value:int) { print("{value}\n") },
@(message:string) { print("error: {message}\n") },
@() { print("complete\n") })Derive#
Derive генерирует операции по описанию структуры. Кроме отладочного вывода, сравнения и хеширования, здесь есть builder, валидация и JSON-сериализация. Можно один раз описать поля и правила, а затем использовать их и при создании объекта в коде, и при загрузке данных.
Например, игрок с обязательным непустым именем и здоровьем от 0 до 100:
[derive(builder=true, validate=true, json=true)]
struct Player {
@builder_required
@validate_non_empty
@json_name="displayName"
name : string
@validate_min=0
@validate_max=100
@json_default=100
health : int = 100
}Builder позволяет заполнять объект по шагам, а затем получить готовое значение или список ошибок:
var inscope draft <- (derive_builder(type<Player>)
|> builder_name("Alice")
|> builder_health(85))
var inscope built <- derive_build(draft, "player")
if (built.ok) {
let player = built.value as some
print("{player.name}: {player.health}\n")
} else {
for (issue in built.errors) {
print("{issue.path}: {issue.message}\n")
}
}Сеттеры генерируются из имён полей. При derive_build проверяется наличие обязательных полей, применяются значения по умолчанию и выполняется валидация. Если не задать имя или передать здоровье 150, готового игрока не будет — результат содержит none и ошибки. Черновик можно исправить и собрать повторно. Если здоровье вообще не задавать, builder возьмёт 100 из инициализатора структуры.
Для маленькой структуры с заранее известными значениями обычный Player(name="Alice", health=85) проще. Builder полезнее для редактора или формы, где поля заполняются постепенно, а на выходе нужен проверенный объект с диагностикой.
Тот же тип уже умеет записываться в JSON и загружаться обратно:
if (built.ok) {
let text = derive_to_json(built.value as some)
print("{text}\n")
// JSON с полями "displayName": "Alice" и "health": 85.
var inscope loaded <- derive_from_json(text, type<Player>, "player")
if (loaded.ok) {
print("{loaded.value.name}: {loaded.value.health}\n")
} else {
for (issue in loaded.errors) {
print("{issue.path}: {issue.message}\n")
}
}
}При чтении проверяются форма данных и типы полей, затем правила валидации. Ошибки содержат путь к полю, например player.health. Успешное чтение возвращает структуру непосредственно в loaded.value; у builder значение обёрнуто в Option. Сама сериализация валидацию не запускает — здесь ей передаётся уже проверенный результат сборки.
@json_name задаёт внешнее имя поля. @json_default=100 позволяет пропустить здоровье в JSON; это отдельное правило от обычного health : int = 100, которое использует builder. Без JSON-настройки поле при чтении было бы обязательным даже с инициализатором в структуре.
Потенциальная польза — конфиги, сохранения, игровые редакторы и обмен данными с инструментами. Список полей и правила проверки остаются рядом с моделью, не приходится вручную повторять их в builder, загрузчике и сериализаторе.
Links#
Все библиотеки: dascript_macro_fsharp.