- PVSM.RU - https://www.pvsm.ru -
Здесь онлайн интерпретатор [1], здесь документация [2].
В сентябре 2020 года я учился на 2 курсе. В том же месяце я впервые написал программу, которая мне понравилась. Она создаёт svg изображения растений, здесь её можно потрогать [3].

Чуть позже я выяснил, что такие программы называют процедурными генераторами. Я увлекся этим, сделал ещё парочку (1 [4], 2 [5]).

Вот только на них я потратил куда больше времени. Долго работать над одной вещью мне не понравилось, особенно когда идея реализации уже придумана, и остаётся лишь написать код. Следовательно, нужно ускорить создание процедурных генераторов.
Пришло в голову создать систему для создания генераторов, a.k.a. "генератор генераторов". В этом решении было две проблемы. Первая: уже оглядываясь назад, мне понятно, что дело было не в том, что языки неудобные, а в том, что я каждый раз менял инструменты. Из-за этого уходило время на их изучение. Вторая: я поставил очень широкую задачу, для которой невозможно создать DSL-язык. Нужно было остановиться на генераторах SVG-изображений, для начала.


Также на языке сделана библиотека, чтобы классы преобразовывать в SVG-картинки (библиотека геометрии с ней плотно связана, получается tight coupling).
Это динамический язык, его синтаксис схож с питоном и котлином. Особенность — поля у инстансов создаются динамически, рекурсивно. То есть можно объявить класс так:
class T {
a = b + 1
b = 3
}
При создании получится экземпляр T, у которого a = 4, b = 3. Я думал, что такая система будет удобной для генераторов, потому что можно отдельно объявить какие-то компоненты как классы и связать их воедино сразу же внутри объявлений классов. Динамическое создание полей позволяет обращаться к ещё не созданным полям из родительских классов и менять их.
Более сложный пример:
class B {
nested = c.d.e
c = C()
}
class C {
d = D()
d.e = E()
}
class D {}
class E {}
fun main() {
b = B()
test(b.nested is E)
}
Получится такое дерево:

Как это сделано? В общем приближении, когда вызывается конструктор из функции, выполняется примерно следующий алгоритм:
Вызываем bfs от корня и ищем поля, которые ещё не проинициализированы (bfs идет по полям типа класс1),
Если полей не нашли, экземпляр класса создали.
Пока поля находим (пусть нашли поле a):
Создаем стек инициализации, добавляем найденное поле a,
Для всех полей в стеке:
Смотрим, есть ли справа от = не проинициализированные поля.
Если есть, ставим эти поля в стек. Если a = b + 1, а b ещё неизвестно, то ставим b в стек, стек будет [a, b].
Если не созданных полей справа от = нет, убираем последний элемент из стека, инициализируем его.
Я не сразу решил делать язык. Хотелось сделать более простую систему, которой могли бы пользоваться не только программисты. Система должна быть атомарной (не должно быть лишнего) и полной (можно создать много генераторов, формально задать "любой генератор" я не могу). Вот к какой системе я пришел2:
Есть два типа объектов: геометрические и контейнерные. Геометрические — это графические SVG-элементы: прямоугольник, эллипс, круг, отрезок, path и т.д. Контейнерные объекты содержат в себе геометрические и контейнерные. Интересно, что все контейнеры можно реализовать в языке. Есть три типа контейнерных объектов:
Простой контейнер. Представляет из себя набор объектов. По сути просто массив, или <group> в SVG.
class SimpleContainer {
// content - что лежит в контейнере
content = [SomeOtherContainer(), Circle(), Rect()]
}
Случайный контейнер. Выбирает случайный элемент внутри себя и всегда возвращает его. Это нужно для создания рандома.
import std.utils as utils
class RandomContainer {
content = [SomeOtherContainer(), Circle(), Rect()]
random = -1
fun getContent() {
if(random == -1)
random = randInt(0, content.size - 1)
return content[random]
}
}
Рекурсивный контейнер. Он в каком-то смысле ключевой и повторяет идею языка. Нужно передать число numberOfElements в конструктор для создания такого числа вложенных рекурсивных контейнеров.
class RecursiveContainer {
content = [...]
iter = if(parent == null) 0 else parent.iter + 1
child = if(iter < numberOfElements) RecursiveContainer(numberOfElements=numberOfElements) else null
}
В этой системе ещё можно менять параметры объектов, длину и ширину прямоугольника, радиус круга и т.д. Но хотелось добавить рандом и в эти параметры, чтобы они выглядели как формулы: rect.width = if(parent is Circle) randInt(10, 20) else 5. В этот момент я решил, что, пожалуй, нужно сразу делать язык, а не систему с контейнерами и формулами.
Как я показал абзацем выше, в языке можно реализовать то, что создано через первоначальную систему. Давайте посмотрим на настоящем примере, как можно задать простой цветок.
Стебель цветка упрощённо — это последовательность отрезков, начало которого является концом другого. То есть можно задать стебель как:
// задаем вспомогательные геометрические классы - они уже есть в библиотеке std.geometry2D
class Point {
x = 0
y = 0
}
class Segment {
p1 = Point()
p2 = Point()
}
// начинается значимый код
class FlowerSegment : Segment {
iter = parent.iter + 1
// говорим, что начало next - конец текущего, а конец next повернут относительно его начала
next = if(iter < 5) FlowerSegment(
p1=copy(p2),
p2 = p2.plus(0, -20).rotate(rndInt(-45,45), p2))
else null
}
// класс нужен, чтобы при вызове parent.iter у FlowerSegment не напороться на NullPointerException
class Root {
iter = 0
child = FlowerSegment()
}
Этот код должен давать нам что-то такое:

Но вместо этого, он может сказать: p2 not found . Это потому, что анализ зависимостей работает не полностью, он не видит, что нужно проинициализировать p2 перед next. Я не стал его дорабатывать, потому что в нем есть фатальная проблема: в вызываемых функциях могут быть нужны поля, которые ещё не созданы (в данном случае функция plus предполагает, что мы уже знаем, чему равны x и y). А анализировать всю функцию непросто3: нельзя вызывать интерпретатор, потому что он может поменять какие-то значения. Чтобы это исправить, придется писать громоздкую конструкцию в next:
next = if(iter < 5 && p1 != null && p2.x != null && p2.y != null) ...
Чтобы добавить цветок, нужно немного изменить код:
// класс из std.geometry2D
class Circle {...}
class FlowerHead : Circle {
r = 5
}
class FlowerSegment : Segment {
iter = parent.iter + 1
next = if(iter < 5) FlowerSegment(
p1=copy(p2),
p2 = p2.plus(0, -20).rotate(rndInt(-45,45), p2))
else FlowerHead(center=p2) // вместо null теперь цветок, остальной код тот же
}
...
Если для самого стебля мы использовали идею рекурсивного контейнера, то для его разделения используем случайный контейнер:
class DoubleFlowerSegment {
iter = parent.iter + 1
angle = rndInt(10, 45)
// тут уже код выглядит страшно.
// для s1.p2, параллельно переносим отрезок родитель,
// поворачиваем его
s1 = FlowerSegment(p1=copy(parent.p2),
p2=copy(parent.p2)
.translate(parent.p2.minus(parent.p1))
.rotate(angle, parent.p1))
s2 = FlowerSegment(p1=copy(parent.p2),
p2=copy(parent.p2)
.translate(parent.p2.minus(parent.p1))
.rotate(-angle, parent.p1))
}
class FlowerSegment : Segment {}
// остальной код не меняется

import std.utils as utils
import std.geometry2D as geom
import std.svg as svg
fun main() {
r = Root()
svgRes = svg.createSVG(r, 300, 400)
write(svgRes, "result.svg")
}
class FlowerHead : Circle {
r = 5
}
class FlowerSegment : Segment {
iter = parent.iter + 1
next = if(iter < 5 && p1 != null && p2.x != null && p2.y != null)
(if(rnd() > 0.7) DoubleFlowerSegment() else
FlowerSegment(
p1=copy(p2),
p2 = p2.plus(0, -20).rotate(rndInt(-45,45), p2)))
else FlowerHead(center=p2)
}
class DoubleFlowerSegment {
iter =parent.iter + 1
angle = rndInt(10, 45)
s1 = FlowerSegment(p1=copy(parent.p2),
p2=copy(parent.p2)
.translate(parent.p2.minus(parent.p1))
.rotate(this.angle, parent.p1))
s2 = FlowerSegment(p1=copy(parent.p2),
p2=copy(parent.p2)
.translate(parent.p2.minus(parent.p1))
.rotate(-this.angle, parent.p1))
}
class Root {
width = 400
height = 300
iter = 0
child = FlowerSegment(p1=Point(x=width/2,y=height),
p2=Point(x=width/2,y=height-10))
}
Можно вставить его в IDE [6] и запустить
Чтобы цветы приобрели завершенный вид, нужно ещё сделать несколько шагов, которые я не вижу ценности разбирать:
Стеблю и цветку нужно добавить цвета.
Чтобы добавить тень, нужно каждый из отрезков продублировать и сместить копии влево на 1. Цвет этих сегментов нужно изменить на более темный.
Цветкам нужно добавить лепестки, стеблям — листья.
Чтобы не было ощущения, что все цветки "смотрят" на зрителя, их нужно изменить через svg transform.
Для всех токенов4 объявлен метод evaluate(symbolTable: SymbolTable), который говорит, как токен должен интерпретироваться. Это очень удобно, но, наверное, никак иначе сделать нельзя :)
Можно интерпретировать параллельно несколько программ (дебажить нельзя).
Я использовал monaco editor, потому что у него есть удобный playground [7], в котором разобраны нужные кейсы.
Пожалуй, самое лучшее в IDE — это дебаггинг. Только это ненастоящий дебаг, потому что сперва вся программа прогоняется. "Дебаг" — это просто запись значений переменных. Зато с дебагом синхронизируется консольный вывод.
На втором месте подсвечивание ошибок.

Сделал ли я DSL специально для генераторов? Нет. В генераторе цветов на C# не сильно больше кода, чем на языке, который я сделал.
Понравилось ли мне это разрабатывать? Конечно, да!
Какие уроки я извлек из этого? Прежде чем создавать что-то, нужно проверить, как это примерно будет выглядеть в конечном итоге, и вообще стоит ли ради этого итога работать. Нужно было:
Набросать генератор цветов на моей языке,
Понять, что он не лучше, чем реализация на императивном языке,
Изменить подход / отказаться от реализации.
1есть ещё поля Int, Double, List, Dictionary. Class — это то, что мы объявляем (как и в других языках).
2когда я делаю генераторы, я представляю их через эту систему. Она как бы "большими мазками" описывает как будут строиться картинки, а детали уже описываются в коде. Мне кажется, эта система удобна при создании генераторов независимо от языка, контейнеры можно воспринимать как паттерны.
3вообще можно создать аннотацию @Requires() для функций класса и в ней писать, какие поля должны быть проинициализированы перед вызовом.
4токен — это любой элемент в языке, например while, {, "abc", 123, бинарные операторы (=, -, +, ||). Обычно токены разделены вайтспейсами.
Автор: Алексей Кононов
Источник [8]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/381851
Ссылки в тексте:
[1] Здесь онлайн интерпретатор: https://llesha.github.io/regina/ide
[2] здесь документация: https://llesha.github.io/regina/regina/
[3] здесь её можно потрогать: https://pleaseworkplant.azurewebsites.net/
[4] 1: https://llesha.itch.io/2d-house-generator
[5] 2: https://github.com/llesha/MapGen-KorGE
[6] IDE: https://llesha.github.io/regina/ide/
[7] playground: https://microsoft.github.io/monaco-editor/playground.html#creating-the-editor-hello-world
[8] Источник: https://habr.com/ru/post/709300/?utm_source=habrahabr&utm_medium=rss&utm_campaign=709300
Нажмите здесь для печати.