daScript - скриптовый язык для игр от Gaijin Entertaiment.
После нескольких дней изучения понял, что испытываю интерес и хороший страх по отношению к этому языку. Такой, как если бы нашёл лазерную указку и решил поиграть с котом, но понял, что этой указкой можно легко резать металл.
Гайдзины делают не замену Lua, они делают замену C++! Точнее даже, не “делают”, а практически “сделали”, язык сейчас находится в версии 0.2, но полноценно используется ими в продакшене в Warthunder.
Доклад и документация акцентируют внимание на быстродействии языка и возможности серьёзных изменений без перезапуска игры, но только вскользь упоминают о мощных возможностях макросов. Так что я решил попробовать решить задачку, требующую их использования. Это моя первая программа на daScript, после трёх дней изучения, так что где-то возможны и неизбежны косяки.
Формулировка проблемы из видео про язык Jai — пример кода, в котором тривиальное объявление медленнее сложного, но быстрого.
Второй вариант требует написания рутинного кода вручную при добавлении новых полей в структуру, а также ёмкий в плане количества переписывания кода из существующего. Если захочется переделать в таком стиле для ускорения несколько структур — для каждой потребуется ручная работа. Из-за этого большинство структур навсегда останутся описанными в первой, медленной форме.
Джонатан Блоу предлагает в качестве решения проблемы вариант синтаксиса своего языка, который позволит легко “переключить” структуру из медленной формы в быструю.
Я захотел попробовать реализовать подобную подсказку компилятору daScript с помощью макросов.
Live Stream Coding on daScript - Breakoid - стрим с примером реализации арканоида, можно посмотреть на макросы и синтаксис системы ECS, реализованной с помощью макросов
Серия туториалов - примеры привязки скриптов к C++, Ahead-of-Time компиляции, реализация генерации кода через cmake, архитектура игрового объекта с возможностью hot-reloading кода из скриптов.
Стандартная библиотека языка и модули, можно использовать поиск по коду с гитхаба, чтобы найти пример использования той или иной фичи
Макросы являются достаточно сложной темой, и освещены в документации daScript-а достаточно кратко, поэтому приведу несколько ссылок на туториалы из других языков: Fear of macros - Racket Макросы в Nemerle - Nemerle Nim Tutorial Part 3 - Nim
Прежде, чем начинать шаманить с макросами, нужно набросать наивную реализацию “быстрой” версии класса, хранящего свои данные в одном блоке памяти. Для этого можно использовать онлайн компилятор tio.
Несколько замечаний про язык:
Питоноподобный синтаксис с отступами. Для тех, кто не переносит такой формы синтаксиса, есть вариант без оступов, со скобочками и точками-с-запятыми.
Пайпы (|> и <|) как синтаксический сахар различных записи вызова функции (UFCS).
Более строгая типизация, чем в C/C++, из-за чего сложнее изучать язык и играться с ним — иногда приходится отвлекаться на то, чтобы разобраться, что именно компилятор от тебя хочет. По идее, должно помогать ловить в компайл-тайм больше того, что поймалось бы только в рантайме.
unsafe, чтобы сказать компилятору “дай мне играться с указателями, как мне хочется, я знаю, что делаю”.
print умеет выводить внутренности структуры, можно убедиться, что данные действительно лежат в одном блоке памяти, как задумывалось.
Отсутствие семантики “=” для типов, для которых нет её однозначного определения. Вместо этого компилятор явно предлагает выбрать между клонированием (“:=”) и перемещением (“<-“). В языке есть generic-функции, в них вроде можно попросить у компилятора данные о типах так, чтобы выбрать желаемое поведение.
Попробуем для начала написать простейший макрос, который выведет на экран список полей структуры. С этого момента не получится использовать онлайн компилятор, так как он не поддерживает загрузку кода из нескольких файлов, а макрос должен располагаться в отдельном модуле.
Причина того, что макрос не может находиться в том же модуле, что и структура, которую он обрабатывает, понятна — чтобы выполнить какой-либо код, обрабатывающий структуру на этапе компиляции, необходимо, чтобы сам этот код к этому моменту был уже скомпилирован.
В репозитории языка валяется готовый пример такого макроса, но в учебных целях немного перепишем его:
Макрос наследуется от класса AstStructureAnnotation, определённого в модуле ast, который представляет собой шаблон для аннотации структуры. К самому классу также применяется аннотация structure_macro, которая регистрирует данный макрос для применения к каждой структуре, отмеченной аннотацией memblock.
Интерфейс макроса позволяет переопределить три функции, чтобы “вклиниться” в процесс того, как компилятор обрабатывает определения структуры, на различных этапах. Документация по фазам компиляции. Apply - наиболее подходящий момент, чтобы попробовать изменить поля структуры или сгенерировать код.
Если теперь отметить описание структуры аннотацией memblock, то компилятор “пропустит” её определение через макрос, который выведет названия полей на экран. В момент обработки информация о создаваемой структуре хранится в классе StructurePtr, определение которого можно найти поиском по C++ коду. На данном этапе макрос просто проходит по всем полям структуры и выводит информацию о каждом из них на экран. describe_cpp - это функция, которая выводит определения типа, как если бы он был объявлен в C++.
Следующим шагом попробуем убрать поле mem из исходной структуры, и создать его из макроса.
функция resize изменяет размер массива, документация функций для работы с контейерами.
можно изменять поля определяемой структуры прямо на месте - макрос выполняется в сам момент её определения. Создание новой структуры с другим именем сделано для наглядности примера.
генерация структур компилятора для определения поля сперва выглядит немного “космически”, дальше будет пример использования функции quote, которая позволяет перевести код как его писал бы человек, в выражение, которое сгенерирует компилятор при парсинге этого кода. Но полезно разобраться с таким способом генерации кода, чтобы привыкнуть к нему. Также стоит найти определения TypeDecl и FieldDeclaration в исходном коде компилятора, это самая надёжная документация.
“оператор” обращения к конкретному значению перечисления — пробел (Type tArray - значение tArray перечисления Type). Очень необычное решение.
при кодогенерации необходимо заботиться о том, чтобы сгенерированное имя не пересекалось с тем, которое может захотеть использовать программист. В этом смысле mem — плохое имя для сгенерированного поля, в реальном коде необходимо было бы какое-либо соглашение об именах, авторы языка используют символ ‘`’ (гравис, backquote) для отметки генерированного кода.
Таким же образом можно перенести поля aCount и bCount в генерирующий макрос:
Разумеется, в реальном мире бывают структуры, которые могут содержать одновременно как указатели на память, которую должна выделять и освобождать сама структура, так и те, которые указывают на память, которую структура трогать не должна. Пока что мы считаем, что структура, отмеченная как memblock, содержит только указатели на память, которую выделяет и освобождает сама. Забегая наперёд, реализовать поддержку обоих типов указателей можно с помощью аннотаций типа (я не нашёл ссылок на аннотации в документации, но их можно найти в коде (пример)). Аннотации типа будут рассмотрены сильно дальше, при реализации наследования от memblock-структур.
На данном этапе мы получили возможность описать структуру с любым количеством полей, и с помощью макроса сгенерировать по этому описанию недостающие поля. К сожалению, функцию initMemblock (конструктор структуры), по прежнему необходимо писать руками, что очень неприятно.
Для начала стоит немного переписать код initMemblock, чтобы отделить часть инициализации структуры, которую нужно сгенерировать. Также добавлено третье поле, для того, чтобы увидеть, какие изменения потребуется сейчас внести в код initMemblock
Прежде, чем приступать к написанию макроса, генерирующего функцию initMemblock, стоит разобраться с парой примеров:
gen_field.das — генерация функции, логгирующей создание полей структуры.
Приведены два макроса DumpFields и Dump2Fields, работающие на различных стадиях (apply/finish), во время генерации структуры, и после окончания.
На момент написание заметки пример был сломан, для фикса необходимо убрать код, касающийся типа EntityId — видимо, пример выдран из какого-то более масштабного кода, и опредения то ли забыли перенести, то ли забыли добавить модуль, содержащий их.
флаг функции “fn.flags |= FunctionFlags init“ говорит компилятору о том, что функция должна быть вызвана сразу после генерации (я какое-то время тупил с тем, чтобы понять, на какой стадии компиляции, кто и почему её вызывает).
ast_print.das - макрос, выводящий исходный текст на dascript переданного ему выражения на daScript.
этот пример в репозитории тоже немного сломан (Fixed version)
Теперь применим немного рекурсивной магии.
Этот макрос может быть очень полезным, если переделать его так, чтобы он печатал не исходный текст переданного ему выражения, а исходный текст МАКРОСА, генерирующего при выполнении само это выражение.
Это очень важная для упрощения метапрограммирования часть, поэтому повторю ещё раз. Вместо написания макроса, генерирующего функцию initMemblock, мы сначала напишем макрос ast_print_expression, который выведет на экран текст, который поможет нам написать макрос generateInitMemblockFunction, генерирующий функцию initMemblock (или любую другую функцию). Звучит запутанно, но это не так сложно, как кажется. (всё, больше не буду повторять, язык сломать можно)
Модифицируем функцию ast_print так, чтобы она не просто печатала исходный текст переданного ей выражения, а дополнительно печатала тип каждого подвыражения (Subexpression) этого выражения. Вот такая функция: ast_print_expression (конечно, немного “наколенная”)
Теперь можно начать последовательно копировать типы подвыражений в макрос, генерирующий функцию initMemblock, постепенно модифицируя его так, чтобы добавлять параметры, позволяющие генерировать различные варианты этой функции.
Рабочий процесс на этом этапе удобно зациклить так:
Модифицируем текст initMemblock
Перезапускаем компиляцию функции, чтобы макрос ast_print_expression применился к этой функции и вывел на экран выражение, в которое преобразуется функция
Копируем часть выражения в макрос generateInitMemblockFunction, который должен сгенерировать новую функцию initMemblock_generated, идентичную самой функции initMemblock
Модифицируем макрос generateInitMemblockFunction, чтобы добавить в него код, генерирующий переменную часть функции
Перекомпилируем функцию initMemblock_generated, чтобы применить к ней макрос ast_print, для того, чтобы получить исходный код функции и сверить его с исходный кодом initMemblock
Выглядит запутанно, но это необходимо для того, чтобы иметь возможность выполнить шаг 4 - “переменная часть функции” — те строчки, которые изменяются в функции initMemblock после того, как мы изменяем какое-либо поле структуры Memblock (посмотрите исходный код функции initMemblock и отметки //новое, это строчки, которые добавились после добавления поля с именем c — это как раз эта “переменная часть функции”).
Самое интересное, что перевести программу в режим REPL практически элементарно, достаточно зациклить C++ часть самого первого туториала из репозитория daScript, перекомпилируя daScript-ы заново при вводе любого символа:
Рабочий процесс выглядит примерно так:
Слева код в Visual Studio Code, справа программа, выполняющая этот код без необходимости перезапуска. После подготовки можно переходить и к генерации кода, с Repl-режимом это будет значительно проще.
Макрос ast_print_expression применённый к функции initMemblock, выводит на экран:
Здесь перед каждым daScript выражением показан тип этого выражения. Почти все типы выражений объявлены в заголовочных файлах ast_*.h.
С определенного момента начинаешь ценить простые языки — вместо того, что изучать сложные и разрастающиеся правила языка, можно упростить их, чтобы ускорить изучение языка и сделать его более понятным, но при этом дать возможность задавать более сложные правила только в тех местах, где они действительно нужны программисту.
Работа с макросами сначала кажется сложной, но с какого-то момента понимаешь, что типичная программа содержит почти все распространённые типы выражений, и понимаешь, что вот они, все перед тобой, других, скрытых мелким шрифтом в примечаниях на 666-й странице стандарта, нет.
Можно было бы пойти ещё дальше, и написать макрос, который выводит код, создающий выражение, но в образовательных целях можно попробовать для начала составить выражения вручную.
Для генерации функции нужно научиться генерировать строки 5 типов:
Декларация функции def initMemblock ( var memblock : Memblock, ...)
Присваивание memblock.acount = aCount`
Объвление переменной let aSize = typeinfo(sizeof *memblock.a) * aCount
Вызов функции memblock.mem |> resize(aSize + bSize + cSize)
Ещё одно присвание - memblock.a = reinterpret<int?> addr(memblock.mem[0])
Декларация функции
Практически готовый пример нужного кода есть в примерах из daScript-a gen_field.das.
Теперь если сгенерировать макросом функцию, и передать эту функцию в макрос, который возвращает исходный текст функции, то получится такой результат:
Присваивание и объявление переменной
Тут всё достаточно тривиально, пока составляем только первую строку “memblock.a`count = aCount”
При генерации следующей строки можно заметить, что:
макрос ast_print_expression не описал выражение “typeinfo(sizeof *memblock.a)”, а вместо этого вывел его результат. Это происходит потому, что макрос был применён к уже сгенерированной и оптимизированной функции, для которой часть выражений может быть вычислена компилятором.
макрос ast_print, применённый к сгенерированной функции, не показывает неиспользуемые переменные, так как они были выброшены компилятором, так что увидеть их можно, если добавить использование (print”{aSize}” в данном случае).
Кроме генерации выражений, здесь показано использование макроса quote, который превращает код в выражение:
Вот пример (Fixed version) более продвинутого использования цитирования, с возможностью задать правила переписывания выражения. Это можно использовать для того, чтобы перейти от явного указания названия поля структуры к переменной, в которую можно передать любое имя или выражение:
Половина кода генерации написана, нужно двигаться дальше.
Вызов функции memblock.mem |> resize(aSize + bSize + cSize)
Здесь есть небольшая хитрость. Строка aSize + bSize + cSize, трансформируется в выражение:
У выражения есть определённый шаблон:
Подобные выражения удобно сгенерировать с помощью функции свёртки). Я зачём-то использовал правостороннюю свёртку, но для ассоциативных операторов конечный результат будет одинаковым (не нашёл готовой в стандартной библиотеке):
(__::builtin`resize здесь просто замангленное название встроенной функции resize)
Можно немного изменить функцию foldr, чтобы она могла генерировать частичные суммы для получения кода вида:
Все части генерации, составленные вместе, генерируют теперь такую функцию:
Сгенерированная функция делает то же, что и написанная руками. Но разница в том, что для любых новых типов структур больше не нужно писать код объявления полей и инициализации руками совсем!
Теперь можно использовать макрос, чтобы сгенерировать поля структуры и конструктор:
Всё работает как задумано (реальный код потребовал бы ещё некоторых доработок, вроде добавления паддинга для выравнивания полей в блоке памяти, а также обёрток для того, чтобы скрыть необходимость unsafe доступа к полям, раз уж границы массивов известны).
Но что будет, если отнаследоваться от такой структуры?
Ауч, все поля в наследнике продублированы. Обработка объявления наследования в языке реализована как копирование деклараций всех полей структуры-предка в дочернюю (можно порыться в документации, или написать макрос, который покажет, что происходит).
Возможно реализовать пару стратегий правильного размещения полей наследника в памяти:
каким-либо образом пометить, что базовая структура имеет сгенерированные поля, и добавить только новые
каким-либо образом пометить поля структуры-предка, удалить их, и перестроить всю структуру заново
С данным макросом можно продолжать играться, делая его всё круче, но примерно на этом этапе можно сказать, что исходная задача решена.
Макросы добавляют в язык способ серьёзно модифицировать код и данные по правилам, которые захочет реализовать программист. При этом разница между тривиальной структурой и “продвинутой” с точки зрения клиентского кода — всего одна аннотация.
Для сравнения с C++, например доклад Louis Dionne про реализацию библиотеки, позволяющей переопределить способ реализации полиморфизма. Без макросов невозможно реализовать идею синтаксически так, чтобы это выглядело как наследование — в компиляторе жёстко прошиты правила того, что сгенерирует комплиятор при наследовании одной структуры от другой.
Естественно, что макросы — это не средство повседневного решения задач. Скорее это средство для того, чтобы лучше “подстроить” язык под предметную область, и уменьшить количество рукописного рутинного кода, не относящего непосредственно к решаемой задаче.
Для daScript предметная область — это разработка игр, и, возможно, макросы позволят ему уйти далеко вперед по сравнению с тем, что умели делать любые другие скриптовые языки для игр раньше.
https://github.com/spiiin/dascript_macro_tutorial - репозиторий с кодом из заметки: /src_dirty — можно посмотреть диффами между парами файлов эволюцию примера из статьи. Хостом для выполнения может послужить tutorial01 - базовый пример работы с языком из официального репозитория. В остальном — непричёсанный код. /examples — пофикшенные примеры макросов из официального репозитория.