- PVSM.RU - https://www.pvsm.ru -
[1]
В этой статье я расскажу о двухлетнем эксперименте, проводимом над моим пет-проектом, интерпретатором ЯП HydraScript. Почему к разработке из области системного программирования были применены промышленные практики, и зачем конструированию компиляторов нужен Domain Driver Design с чистой архитектурой?
Исходники проекта [2]доступны на GitHub.
Краткое описание репозитория на GitHub гласит:
TypeScript & Go inspired open-source public research project written in C#

Но это описание состояния проекта на момент написания статьи, поэтому сейчас откачусь немного в прошлое.
Шёл 2022-й год, и мой 4-й курс бакалавриата МГТУ им. Н.Э. Баумана. Я заканчивал кафедру ИУ-9 [3]. Её направленность — «Анализ, порождение и преобразование программного кода», то есть в специализации входит, например, «создание новых языков программирования». Кстати, у кафедры есть GitHub [4], на мой взгляд, выглядит очень достойно!
Так вот, по окончании университета, естественно, надо было писать ВКР, в рамках которой было бы реализовано некое ПО. Конструирование компиляторов, как дисциплина, очень заинтересовала меня ещё в процессе обучения. Лично мне нравится, как в этой прикладной деятельности сходится почти вся фундаментальная теоретика компьютерных наук.
И тогда мне была назначена тема разработки интерпретатора подмножества JavaScript согласно ECMA-262. Советую ознакомиться со стандартом, если у вас возникает вопрос к JavaScript в духе: «ну почему он такой??!?!!?»
Получившаяся программа работает следующим образом.
function abs(x: number) {
if (x < 0)
return -x
return x
}
print(abs(-10) as string)
Keyword (1, 1)-(1, 9): function
Ident (1, 10)-(1, 13): abs
LeftParen (1, 13)-(1, 14): (
Ident (1, 14)-(1, 15): x
Colon (1, 15)-(1, 16): :
Ident (1, 17)-(1, 23): number
RightParen (1, 23)-(1, 24): )
LeftCurl (1, 25)-(1, 26): {
Keyword (2, 5)-(2, 7): if
LeftParen (2, 8)-(2, 9): (
Ident (2, 9)-(2, 10): x
Operator (2, 11)-(2, 12): <
IntegerLiteral (2, 13)-(2, 14): 0
RightParen (2, 14)-(2, 15): )
Keyword (3, 9)-(3, 15): return
Operator (3, 16)-(3, 17): -
Ident (3, 17)-(3, 18): x
Keyword (4, 5)-(4, 11): return
Ident (4, 12)-(4, 13): x
RightCurl (5, 1)-(5, 2): }
Ident (7, 1)-(7, 6): print
LeftParen (7, 6)-(7, 7): (
Ident (7, 7)-(7, 10): abs
LeftParen (7, 10)-(7, 11): (
Operator (7, 11)-(7, 12): -
IntegerLiteral (7, 12)-(7, 14): 10
RightParen (7, 14)-(7, 15): )
Keyword (7, 16)-(7, 18): as
Ident (7, 19)-(7, 25): string
RightParen (7, 25)-(7, 26): )
EOP

Goto End_abs
Start_abs:
BeginFunction abs
PopParameter x
_t1648079328 = x < 0
IfNot _t1648079328 Goto End_if_else_53517805
_t1783762424 = -x
Return _t1783762424
Goto End_if_else_53517805
Start_if_else_53517805:
BeginCondition if_else_53517805
End_if_else_53517805:
EndCondition if_else_53517805
Return x
End_abs:
EndFunction abs
_t196877937 = -10
PushParameter _t196877937
_t3435491484 = Call abs
_t4127716996 = _t3435491484 as string
Print _t4127716996
End

Однако вернёмся к процессу и хронологии разработки интерпретатора HydraScipt.
Как уже было сказано, первая итерация проекта появилась в рамках написания дипломной работы.
Все мы знаем по каким двум правилам работает программист в условиях ограниченного времени:

И мой случай — не исключение...
Как нетрудно было догадаться, всё писалось на скорую руку, баги поверх багов, костыли и велосипеды, чтобы хоть как-то залатать и всё прочее подобное.
Изначально архитектуры не было вообще — сразу интерпретировалось AST, но это не позволяло добавить функции в язык, поэтому пришлось всё переписывать на минимальный кодген сценарий.
И в процессе у меня стали появляться амбиции, мол: «сейчас как напишу open-source убийцу JS, и все быстро перейдут на мой язык!»
И стал накидывать туда типизацию, базовые блоки, CFG, оптимизации — с некоторыми ограничениями оно работало!
Потом я успешно защитился и можно было немного причесать проект…
Однако стало тяжело совмещать это с работой, да и без реалити-чеков мне стало понятно, что JS не одолеть, и тогда проект снова пришлось двигать в другом направлении.
Я безусловно заинтересован в его поддержке и развитии, но сквозь время.
То есть, у меня должна быть возможность не заниматься им долго, потом прийти и не офигеть от того, насколько мне не понятно, что там написано.
И раз это уже в паблике, то было бы неплохо, чтобы другие люди тоже смогли прийти и разобраться, не обладая такими глубокими знаниями в сложной предметке компиляторов.
Поскольку планируется чистый код и всё такое, то конечно про перфоманс и реальную прикладную применимость такого интерпретируемого языка можно забыть)
Но учебная миссия, и задача публичного, понятного и доступного реверс-инжиниринга тоже важны!
Тогда он был в ходу на работе, и как-то затянуло. Начал копаться, какие там синие книжки, какие красные, доклад Мерсона и прочее.
Мне показалось: это то, что нужно: выстроив единый язык между указанной ранее предметной области и моим кодом задача по улучшению «усваиваемости» решится.
Тогда я начал описывать домен интерпретатора, и пришёл к выводу, что в моём случае он делится на три поддомена:
Забегая вперёд, такое деление прошло проверку временем, достаточно логичное со смысловой точки зрения, и наталкивало меня на правильные мысли в процессе доработки архитектуры.
Тогда я знал про неё очень мало и проект был обычным двухслойным приложением, где всё деление было исключительно по папкам…

Эту версию можно найти в ветке [5] before.
Оставил специально в виде напоминания как делать не надо.
И на данном этапе важно поделится одним из усвоенных архитектурных уроков, который я вынес из опыта ведения этого пет-проекта:
Правильность архитектуры проверяется при внедрении нового функционала. При правильно заданной траектории архитектурной эволюции реализация сложных фич и задач будет напрашиваться сама. При этом таким образом, что это будет не повторение существующего, а нечто новое, но укладывающееся в общую канву.
Это обусловлено предыдущим спичем про DDD в контексте интерпретатора, потому что это правильное решение в начале задало всю траекторию на последующие месяцы разработки и рефакторинга.
Казалось бы, вот сказано: «в проекте DDD».
Что может пойти не так? Да, в общем-то, всё…
Первой из них является выбор интрузивного подхода для работы c AST. То есть, реализация различных операций кладётся внутрь дерева виртуальным методом, который потом переопределяется вглубь иерархии. Нет, от виртуальных методов не стоит отказываться, но они подходят не для всех операций.
В данном случае идёт речь об операциях, реализация которых требует некоторого внешнего контекста. Тогда действительно стоит задуматься о функциональном подходе отделения операции от данных, потому что сущность не в состоянии инкапсулировать всё необходимое.
Попытка упаковать всё на свете внутрь иерархии привела в первую очередь к нескольким последствиям.
Ограниченность возможностей развития системы — сложность реализации новых фич росла экспоненциально.
Появились трудно исправляемые баги: статический анализ сваливался с ошибкой на корректных программах, и его пришлось отключить.
Кодогенерация присваивала инструкциям некорректный адрес, из-за чего виртуальная машина либо попадала в бесконечный цикл, либо создавался runtime exception.

Здесь очень помог паттерн Visitor, который создан для обработки структур, глобально попадающих в класс производных паттерна Composite.
Вот метод, генерирующий инструкции для унарного выражения до «Посетителя»:
public override List<Instruction> ToInstructions(int start, string temp)
{
var instructions = new List<Instruction>();
(IValue left, IValue right) right = (null, null);
if (_expression.Primary())
{
right.right = ((PrimaryExpression) _expression).ToValue();
}
else
{
instructions.AddRange(_expression.ToInstructions(start, temp));
if (_expression is MemberExpression member && member.Any())
{
var i = start + instructions.Count;
var dest = "_t" + i;
var src = instructions.Any()
? instructions.OfType<Simple>().Last().Left
: member.Id;
var instruction = member.AccessChain.Tail switch
{
DotAccess dot => new Simple(dest, (new Name(src), new Constant(dot.Id, dot.Id)), ".", i),
IndexAccess index => new Simple(dest, (new Name(src), index.Expression.ToValue()), "[]", i),
_ => throw new NotImplementedException()
};
instructions.Add(instruction);
}
right.right = new Name(instructions.OfType<Simple>().Last().Left);
}
var number = instructions.Any() ? instructions.Last().Number + 1 : start;
instructions.Add(new Simple(
temp + number, right, _operator, number
));
return instructions;
}
А вот после применения шаблона:
public AddressedInstructions Visit(UnaryExpression visitable)
{
if (visitable.Expression is PrimaryExpression primary)
return [new Simple(visitable.Operator, _valueDtoConverter.Convert(primary.ToValueDto()))];
var result = visitable.Expression.Accept(This);
var last = new Name(result.OfType<Simple>().Last().Left!);
result.Add(new Simple(visitable.Operator, last));
return result;
}
Честно сказать, сейчас не смогу рассказать, как работает первый вариант метода…
Но трудно не согласиться с тем, что второй выглядит вполне самоописываемым, практически набор предложений на английском языке.
Этого удалось добиться в том числе решением нестандартных и интересных, на мой взгляд, инженерных задач.
Главной проблемой перехода на паттерн Visitor и отделения операции от данных был переезд кодгена. Дело в том, что у старой версии метода был параметр int start, который хранил смещение в списке инструкции и позволял посчитать её целочисленный адрес.
Эта система дала сбой, в конце концов, да ещё и теперь параметр не передашь.
Хранить глобальный счётчик небезопасно, да и с точки зрения проектирования, написать класс визитора в функциональном стиле, более удачное решение с точки зрения читаемости кода.
В дебаге можно будет провалиться по цепочке вызовов и отследить, откуда пришёл невалидный результат.
В общем, систему адресации инструкций пришлось разрабатывать с нуля, и получилось интересно и неплохо.
Получилось изобрести велосипед структуру данных для адресации, которая при изменении набора элементов пересчитывает адреса за O(1).
Что это значит?
Раньше был список инструкций, где у каждой инструкции есть адрес, то есть, некоторое число:
0: a = 0
1: x = 2
2: y = a
3: print x
Мы видим, что инструкции с адресом 0 и 2 можно выкинуть, они являются мёртвым кодом. Тогда останется:
1: x = 2
3: print x
Но теперь виртуальная машина свалится с ошибкой, потому что старт у неё ноль, а правило вычисления следующего адреса по умолчанию это шаг плюс 1.
Она просто не сможет пройтись по оставшемуся набору, поэтому нужно пересчитать адреса:
0: x = 2
1: print x
И смысл в том, чтобы не бегать по массиву за линию или больше, переставляя индексы, а получать сразу корректные адреса при любом удалении/вставке/перестановке.
Для этого пришлось создать абстракцию понятия «адрес» [6] и реализовать структуру данных, удовлетворяющую требованиям [7].
В процессе переезда на паттерн «Посетитель» нужно было определиться с подходом к его реализации. Их на самом деле несколько. Если вы не понимаете о чём я, то посмотрите доклад Дмитрия Нестерука:
Мне был интересен ациклический вариант, он позволяет декларативно описать, какие элементы посещаются:
public class TypeSystemLoader : IVisitor,
IVisitor<ScriptBody>,
IVisitor<AbstractSyntaxTreeNode>,
IVisitor<TypeDeclaration>
{
// ...
}
Однако меня категорически не устраивает необходимость явного приведения типов при использовании:
public class ScriptBody : AbstractSyntaxTreeNode
{
public override void Accept(IVisitor visitor)
{
if (visitor is IVisitor<ScriptBody> typed)
typed.Visit(this);
}
}
Поэтому, я отказался от этой идеи и остался с классикой. Но такой компромисс тоже оказался неудачным.
Во-первых, на тот момент я не совсем правильно распределил сущности по поддоменам: узлы AST оказались в IR, хотя должны были быть во FrontEnd.
Во-вторых, классическая реализация принесла вместе с собой циклическую зависимость.
В-третьих, поддомены не были изолированы: например, FrontEnd знал про BackEnd, хотя бы потому, что этого требовала реализация паттерна.
Несмотря на все мои намерения писать чистый код, получилась какая-то спагетифицированная high-coupling-low-cohesion каша.
Пришлось переизобрести паттерн, чтобы создать типобезопасную ациклическую реализацию, которая удовлетворяет SOLID и не требует приведения типов вовсе.
www.nuget.org/packages/Visitor.NET [8]
Получилась библиотека Visitor.NET, о которой я расскажу на Стачке в Питере 27 сентября. [9]
А чуть позже напишу отдельную статью на Хабр, для тех, кто не смог посетить конференцию.
Если вкратце, то с помощью контравариантности удалось добиться желаемого поведения:
public interface IVisitor<in TVisitable, out TReturn>
where TVisitable : IVisitable<TVisitable>
{
TReturn Visit(TVisitable visitable);
}
public interface IVisitable<out TVisitable>
where TVisitable : IVisitable<TVisitable>
{
TReturn Accept<TReturn>(IVisitor<TVisitable, TReturn> visitor);
}
public record Operation(
char Symbol,
BinaryTreeNode Left,
BinaryTreeNode Right) : BinaryTreeNode, IVisitable<Operation>
{
public override TReturn Accept<TReturn>(
IVisitor<BinaryTreeNode, TReturn> visitor) =>
Accept(visitor);
public TReturn Accept<TReturn>(
IVisitor<Operation, TReturn> visitor) =>
visitor.Visit(this);
}
А бойлерплейт реализации потом отшлифовал инкрементальными сурс генераторами, и потом вообще хорошо стало.
www.nuget.org/packages/Visitor.NET.AutoVisitableGen [10]
[AutoVisitable<BinaryTreeNode>]
public partial record Operation(
char Symbol,
BinaryTreeNode Left,
BinaryTreeNode Right) : BinaryTreeNode;
Как уже писалось ранее, в дорефаченной версии проекта статический анализ приходилось отключать, потому что интрузивный подход породил странные и сложные баги.
Вдобавок к этому, в языке был недоступен forward reference, во многом из-за попытки заполнить таблицу символов во время работы парсера.
Снова пришлось изобретать и впервые заниматься реверс-инжинирингом, чтобы адекватно проверять семантику и получить расширяемый, устойчивый к изменениям код.
Это происходило в параллель с переосмыслением «Посетителя» и в момент, когда я понял, что мне понадобится несколько раз обходить дерево в рамках анализа, то осознал необходимость мини-фреймворка для того, чтобы клепать визиторы, как сервисы.
Получился многоступенчатый алгоритм с отдельными тонкостями реализации на каждом этапе.
Вот перечень проходов на момент написания статьи:
Благодаря такому глубокому подходу код, который в TypeScript падает в рантайме, мой интерпретатор бракует в момент проверки семантики:
let x = f()
function f() {
print(x as string)
return 5
}
Решения указанных выше задач и принятые архитектурные решения такие, как внедрение DDD, паттерна Visitor, проектирование сервисов с каждым коммитом всё яснее обрисовывали следующий шаг, надлежащий к исполнению. Окончательный переход на Clean Arhitecture.
Ядро предметной области, которым я оперировал: лексер, парсер, инструкция и так далее, нужно было превратить в набор строго изолированных друг от друга компонентов.
Статический анализ и кодогенерация — это фичи аппликационного слоя, реализованные с помощью визиторов в качестве сервисов.
Всякое дамп-логгирование, работа с конфигом и другая служебная история укладывалось в инфраструктурный слой, а обработчик CLI — хост, который всё это добро подключал.
Однако в процессе я неоднократно не смог проконтролировать ограничения слоёв и связей, и решил заставить компилятор контроливать ситуацию вместо тестов на ArchUnitNet.
Проекты вместо папок — это настоящая настоящая архитектурная статическая типизация

Если мы хотим, чтобы компоненты из одной папки были не зависимы от компонентов из другой, то можно заставить компилятор ругать за нарушение этого правила, путём размещения кода в разных проектах.
Кто-то может сказать, что разбив слишком мелкий, да и вообще: «в маленьком проекте как будто бы совсем не нужно, а в большом это превращается в мешанину»
Если вы так думаете, значит неправильно мыслите. Размер проекта — неправильный критерий применимости, надо смотреть на ясность предметной области. Если такой приём помогает очертить её структуру и улучшить «понимабельность», то вперёд и с песней.
После большого пройденного пути миллиона рефакторингов получился сильный проект с ООП, DDD, Clean Architecture, изолированными поддоменами, независимыми контрактами, слабосвязанными компонентами и сильносвязными модулями.
За время его ведения моя архитектурная компетенция несомненно выросла, и вот какими выводами я хочу с вами поделиться:
Приглашаю всех неравнодушных и желающих получить опыт contribution в open source внутрь issues репозитория hydrascript [11] — там всегда есть задачи для вас.
Ещё я веду Telegram-канал StepOne [12], куда выкладываю много интересного контента про коммерческую разработку на C#, даю карьерные советы, рассказываю истории из личного опыта и раскрываю все тайны IT-индустрии.
© 2024 ООО «МТ ФИНАНС»
Автор: Stefanio
Источник [13]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/open-source/395603
Ссылки в тексте:
[1] Image: https://habr.com/ru/companies/ruvds/articles/834626/
[2] Исходники проекта : https://github.com/Stepami/hydrascript
[3] кафедру ИУ-9: https://iu9.bmstu.ru/
[4] у кафедры есть GitHub: https://github.com/bmstu-iu9
[5] найти в ветке: https://github.com/Stepami/hydrascript/tree/before
[6] абстракцию понятия «адрес»: https://github.com/Stepami/hydrascript/blob/master/src/Domain/HydraScript.Domain.BackEnd/IAddress.cs
[7] структуру данных, удовлетворяющую требованиям: https://github.com/Stepami/hydrascript/blob/master/src/Domain/HydraScript.Domain.BackEnd/AddressedInstructions.cs
[8] www.nuget.org/packages/Visitor.NET: https://www.nuget.org/packages/Visitor.NET
[9] Получилась библиотека Visitor.NET, о которой я расскажу на Стачке в Питере 27 сентября.: https://spb24.nastachku.ru/%D1%82%D0%B0%D0%BA%D0%BE%D0%B3%D0%BE-%D0%BF%D0%BE%D1%81%D0%B5%D1%82%D0%B8%D1%82%D0%B5%D0%BB%D1%8F-%D0%B2%D1%8B-%D0%B5%D1%89%D1%91-%D0%BD%D0%B5-%D0%B2%D0%B8%D0%B4%D0%B5%D0%BB%D0%B8-visitor.net
[10] www.nuget.org/packages/Visitor.NET.AutoVisitableGen: https://www.nuget.org/packages/Visitor.NET.AutoVisitableGen
[11] внутрь issues репозитория hydrascript: https://github.com/Stepami/hydrascript/issues
[12] StepOne: https://t.me/+MgLRLb8ZSVAxNTY6
[13] Источник: https://habr.com/ru/companies/ruvds/articles/834626/?utm_source=habrahabr&utm_medium=rss&utm_campaign=834626
Нажмите здесь для печати.