
Тема специального языка для моделирования многокомпонентных динамических систем давно меня зацепила и хотелось написать свою реализацию для него, так как было жгучее желание сделать лучше - чтобы работало надёжнее, быстрее и эффективнее, чем у авторов языка (MVL - ниже подробнее про язык), и к тому же ещё и кроссплатформенно.
Кроме того, это довольно увлекательно и познавательно, а заодно позволяет прокачать свои навыки - как в проблематике построения компиляторов, так и в области реализации языка с численным моделированием. А так как я в конечном счёте выбрал писать на относительно новом для себя языке программирования (Rust, что начал изучать лет пять назад), то заодно ещё позволило дополнительно освоиться и в нём.
Всем привет! Меня зовут Алексей Миклин, и я неприлично долго писал в основном на C++, а в последние несколько лет покатился по наклонной использую Rust. Не чужд математики и физики, так что теме трудно было меня миновать.
В статье расскажу про заходы к рассматриваемой задаче на C++, почему перешёл в итоге к Rust - что приобрёл, где потерял - поделюсь деталями и самой реализации, которые, надеюсь, будут интересны и растаманам, и плюсовикам, и всем прочим доморощенным компиляторостроителям, а также тем, кого привлекают темы реализации языков, DSL или численного моделирования.
Сначала идёт пара глав про сам язык, далее с увеличением технических подробностей и идей по реализации - получилось немало, можете свободн�� перескакивать на те, которые вам более интересны (а если времени совсем в обрез, то хотя бы загляните в главы Промежуточный язык и Лексер и парсер):
Особенности и применение языка MVL
Промежуточный язык - пример исходного и промежуточного кода
Лексер и парсер - простой пример грамматики с использованием шаблонов и приёмы для слабых ключевых слов, а также предыстория разных попыток парсинга, включая и на C++
Сложности с Rust относительно C++
Особенности и применение языка MVL
Исходный язык моделирования MVL используется в IDE AnyDynamics (прежние названия MVS (Model Vision Studium), Rand Model Designer) - я не имею к коллективу авторов отношения, просто отдельных ссылок на язык нет. Укажу лишь, что авторы предоставляют IDE по ссылке бесплатно - в которой можно экспортировать и импортировать текстовое MVL-представление программы, что и принимает мой компилятор.
Язык сам по себе достаточно наворочен в плане языковых конструкций, среди которых есть как распространённые в языках широкого применения сущности, такие как классы с наследованием и интерфейсами (полный ООП), функции и переменные, структуры и исключения, так и специфичные для предметной области - т.н. карты поведения с их состояниями и переходами по событиям (расширение диаграмм состояний - UML state machine), алгебро-дифференциальные системы уравнений и соединения объектов через связи (как направленные, так и ненаправленные - подробнее ниже). Причем наследование классов позволяет переопределять не только функции, но и элементы карт поведения и уравнения в наследниках. Пример кода на языке можно посмотреть в главе Промежуточный язык.
Язык предназначен для моделирования многокомпонентных динамических систем - то есть иерархичных систем взаимосвязанных объектов с динамическим поведением. Кроме очевидных систем управления позволяет моделировать системы объектов, связанных в сеть - такие как гидравлические, электрические и механические си��темы. Например, трубопроводную сеть можно смоделировать соединением труб, насосов, клапанов и баков.
Где можно применить язык и сравнение с близким аналогом
Всё это можно использовать, к примеру, для построения тренажёра-симулятора технологических операций на танкере, для которого в реальном или ускоренном времени моделируются перекачка балласта, газа или нефти, системы управления и электрики, двигательная установка.
Стоит также упомянуть более известный язык Modelica, предназначенный для тех же задач моделирования. MVL по сравнению с ним более выразительный и обладает большей функциональностью - в частности, используя т.н. карты поведения позволяет в явно выделенных разных состояниях объекта описывать его разное поведение с помощью разных же алгебро-дифференциальных систем уравнений. Переходы между состояниями происходят по событиям связанным со значением переменных, по времени и по сигналам.
Дополнительным отличием MVL является возможность создавать объекты динамически (и динамически же их соединять), что позволяет, в частности, решать задачи имитационного моделирования, в том числе для систем массового обслуживания.
Как работает движок языка
Система состоит из множества объектов, соединённых через свои внешние переменные - где направленные связи служат для однонаправленной передачи значений и посылки сигналов, а ненап��авленные ("физические") связи соединяют либо "контакты", что неявно добавляют уравнение эквивалентности соединённых переменных, либо "потоки" - добавляющих уравнение, где сумма соединённых переменных равна нулю. Примером "контакта" является давление и потенциал, а "потока" - поток жидкости и сила тока.
Каждый объект при этом находится в одном из своих состояний с соответствующими активными уравнениями. Движок собирает совокупную систему уравнений во время исполнения и решает её, производя интегрирование и/или дифференцирование по времени. Эта совокупная система переформируется каждый раз, когда происходит переключение состояний объектов по различным событиям.
Транслятор крупным планом
В общем, здесь есть много сложных особенностей и есть где себя проявить, поэтому чтобы не возиться ещё и с машинным кодом или LLVM, был выбран распространённый для специальных языков подход: транслируем исходный код в код на промежуточном языке, который уже и скармливаем настоящему компилятору широкого применения для получения исполняемого кода - в моём случае в виде динамически погружаемой библиотеки как конечного артефакта.
Т.о. нужно написать эту трансляцию, которая начинается с фаз лексического и синтаксического анализа, после которых получается т.н. абстрактное синтаксическое дерево (AST), в котором далее на этапе семантического анализа разрешаются имена идентификаторов, в результате чего строится т.н. абстрактный семантический граф (ASG) - который и представляет нашу модель в виде развесистой структуры. (Почему-то нередко ошибочно продолжают называть это деревом (AST) - хотя это именно граф, в котором несколько узлов могут ссылаться на один - например, узлы определения и использования переменной ссылаются на её описание, содержащее её имя и тип). Ещё нужно проверить совместимость типов при преобразованиях, провести дополнительный анализ и аналитическую оптимизацию уравнений. И вот тогда наконец можно генерировать код на промежуточном языке.
Промежуточный язык
Что касается выбора промежуточного языка - здесь случилось прямо-таки замечательное открытие. Изначально, в самый первый подход к задаче ещё на C++ лет двенадцать назад (в рамках работы в одной компании, занимающейся симуляторами корабельных систем, когда получилось сделать прототип компилятора с урезанным функционалом) я решил выбрать в качестве промежуточного не собственно C++ в чистом виде, а код на макросах препроцессора, скрывающих под собой настоящую реализацию на плюсах - в результате программа на этом промежуточном языке почти повторяет исходную, имея похожую иерархию конструкций.
Тогда генератор кода упрощается и изменения в реализации макросов его не касаются. А ещё это позволяет писать тесты на этом промежуточном языке в виде макро-кода в отрыве от транслятора и исходного кода. Кроме того, можно гибко проинструментировать в будущем этот макро-код, например, задействуя по ключам отладочные проверки, трассировку операторов и даже позволяя делать точки останова в отладчике исходного языка - и всё это не меняя генератор и саму сгенерированную или рукописную макро-программу.
При переходе на Rust в части рантайма (т.е. реализации) языка эта замечательная идея привела к ещё большей органичности промежуточного языка - ведь макросы Rust на несколько порядков гибче и удобнее, чем в C(++), что позволило промежуточному языку гораздо больше походить на исходный - на нём теперь даже можно привычно писать руками - ну почти :). А также позволило повысить эффективность реализации, обеспечив значительно больше структурных построений во время компиляции. И всё это при задействовании только декларативных макросов, на процедурных визуальный эффект можно было бы, наверное, ещё улучшить, но то, что уже есть, вполне устраивает.
Приведу для примера код модели на языке MVL и следом макро-код в который первый транслируется:
Исходный код модели на языке MVL
Ниже приводится код (без графической информации) упрощённой модели простой гидравлической сети из двух больших баков (танков), соединённых снизу трубой с вентилем. Если открыть вентиль, вода из первого бака потечёт во второй - по принципу сообщающихся сосудов. В модели используется наследование класса вентиля (Valve) от трубы (Pipe) и карта поведения для трубы имеет разные системы уравнений для открытого и закрытого состояния вентиля на трубе.
Обратите внимание на то, как задаётся совокупность физических контактов - в строках 2-6 описан коннектор Liquid включающий поток жидкости и давление в виде контакта. В строке 69 задано дифференциальное уравнение: `mass' = outlet.Q` - скорость изменения массы равна поступающему внутрь потоку. В строке 28 задаётся уравнение для ламинарного потока жидкости в трубе: поток пропорционален перепаду давления на концах трубы. А соединяются объекты с помощью связей в строках 79-80.
package HydroTiny is
public type Liquid is
connector
flow Q: double; -- liquid flow [kg/c]
contact p: double; -- pressure [Pa]
end connector;
private constant g: double := 9.81;
parameter liquid_density: double := 1000; -- [kg/m3]
parameter liquid_viscosity: double := 0.001; -- [Pa*s]
hybrid opened class Pipe is
parameter diam: double := 0.1; -- [m]
parameter len: double := 10; -- [m]
connector inlet: Liquid := {Q=>0,p=>0};
connector outlet: Liquid := {Q=>0,p=>0};
discrete flow_coef: double := (16*8*liquid_viscosity*len)/(liquid_density*pi()*diam**4);
function open_coef() return double is
begin
return 1;
end open_coef;
local continuous class hydro_flow is
equations {
inlet.Q+outlet.Q = 0;
-- Hagen Poiseuille equation for laminar flow
inlet.p-outlet.p = inlet.Q*flow_coef/open_coef()**4;
};
end hydro_flow;
local continuous class no_flow is
equations {
inlet.Q = 0;
outlet.Q = 0;
};
end no_flow;
bchart {
initial state _InitState;
state S1 do no_flow;
state S2 do hydro_flow;
transition _T_51 from _InitState to S1;
transition _T_3 from S1 to S2 when open_coef()>=0.01;
transition _T_5 from S2 to S1 when open_coef()<0.01;
};
end Pipe;
hybrid class Valve extends Pipe is
input open: double := 0; -- [%]
override function open_coef() return double is
begin
return open/100;
end open_coef;
end Valve;
continuous opened class Tank is
parameter bottom_area: double := 100; -- bottom area [m2]
parameter depth: double := 10;
connector outlet: Liquid := {Q=>0,p=>0};
level: double := 0; -- [m]
mass: double := liquid_density*level*bottom_area; -- mass [kg]
equations {
mass' = outlet.Q;
level = mass/(liquid_density*bottom_area);
outlet.p = liquid_density*g*level;
};
end Tank;
continuous private compound class Model is
static Tank_1 Tank(level=5);
static Valve_1 Valve;
static Tank_2 Tank(depth=20,bottom_area=200);
link(L_52) Tank_1.outlet, Valve_1.inlet;
link(L_53) Valve_1.outlet, Tank_2.outlet;
equations {
unknown ;
};
end Model;
static model: Model;
end HydroTiny;Кроме такого Ada-подобного синтаксиса для языка MVL среда AnyDymanics поддерживает и C#-подобный, но мой компилятор пока принимает только первый. Но синтаксис это дело наживное - можно наверняка придумать более удобный и современный.
Сгенерированный макро-код на Rust
Посмотрите, насколько повторяется структура и элементы исходного кода (что было на MVL) - читать такой сгенерённый код удобно и писать руками тесты вполне приемлемо, а можно даже и целевые модели. По-сути это DSL на макросах - при этом можно было загнать вообще всё под один макрос вместо нескольких верхнего уровня (mo_package, mo_class, mo_connector, ...), но это уже чревато чуть более сложной отладкой при проблемах.
Оцените, насколько естественно описано наследование в строке 68: `Valve extends Pipe`, а также как натурально описана карта поведения с состояниями и переходами в строках 36-47 - даже лучше, чем в исходном языке:
mo_package! { HydroTiny:
objects {
model: #obj Model {}
}
vars {
g: Float, kind: Constant {9.81}
liquid_density: Float, kind: Parameter {1000}
liquid_viscosity: Float, kind: Parameter {0.001}
}
}
mo_connector! { Liquid:
Q: Float, kind: Flow;
p: Float, kind: Contact;
}
mo_class! { Pipe:
members {
diam: Float, kind: Parameter {0.1}
len: Float, kind: Parameter {10}
inlet: #struct Liquid, kind: Connector {
Q {0}
p {0}
}
outlet: #struct Liquid, kind: Connector {
Q {0}
p {0}
}
flow_coef: Float {((((16) * (8)) * (_g.liquid_viscosity)) * (_m.len)) / (((_g.liquid_density) * (_sf.pi())) * (_sf.pow((_m.diam), (4))))}
}
functions {
open_coef(): Float {
return { 1 }
}
}
bchart {
state -initial _InitState:
transition _T_51 to S1
state S1:
activity _u_no_flow_1 {}
transition _T_3 to S2
on_condition { (_mf.open_coef()) >= (0.01) }
state S2:
activity _u_hydro_flow_0 {}
transition _T_5 to S1
on_condition { (_mf.open_coef()) < (0.01) }
}
}
mo_class! { _u_hydro_flow_0 "hydro_flow" in Pipe:
equations {
default {
alg { { (_o.inlet.Q) + (_o.outlet.Q) } = { 0 } }
alg { { (_o.inlet.p) - (_o.outlet.p) } = { ((_o.inlet.Q) * (_o.flow_coef)) / (_sf.pow((_of.open_coef()), (4))) } }
}
}
}
mo_class! { _u_no_flow_1 "no_flow" in Pipe:
equations {
default {
alg { { _o.inlet.Q } = { 0 } }
alg { { _o.outlet.Q } = { 0 } }
}
}
}
mo_class! { Valve extends Pipe:
members {
open: Float, kind: Input {0}
}
functions {
_b.open_coef(): Float {
return { (_m.open) / (100) }
}
}
}
mo_class! { Tank:
members {
bottom_area: Float, kind: Parameter {100}
depth: Float, kind: Parameter {10}
outlet: #struct Liquid, kind: Connector {
Q {0}
p {0}
}
level: Float {0}
mass: Float {((_g.liquid_density) * (_m.level)) * (_m.bottom_area)}
_dif0 "mass'": Float {}
}
equations {
default {
dif { _m.mass, _m._dif0 }
alg { { _m._dif0 } = { _m.outlet.Q } }
alg { { _m.level } = { (_m.mass) / ((_g.liquid_density) * (_m.bottom_area)) } }
alg { { _m.outlet.p } = { ((_g.liquid_density) * (_g.g)) * (_m.level) } }
}
}
}
mo_class! { Model:
members {
Tank_1: #obj Tank {
level {5}
}
Valve_1: #obj Valve {}
Tank_2: #obj Tank {
depth {20}
bottom_area {200}
}
}
links {
contact (_m.Tank_1.outlet.p, _m.Valve_1._b.inlet.p)
contact (_m.Valve_1._b.outlet.p, _m.Tank_2.outlet.p)
flow (_m.Tank_1.outlet.Q, _m.Valve_1._b.inlet.Q)
flow (_m.Valve_1._b.outlet.Q, _m.Tank_2.outlet.Q)
}
}Сгенерированный макро-код на C++
А теперь сравните это с тем, что было на C++ - всё гораздо хуже читается из-за негибкости сишных макросов и приходится дублировать переменные для их инициализации отдельно от описания (см. например строки 16-25):
// Project HydroTiny
MS_BEGIN_EQUATION_BLOCK_LIST()
MS_END_EQUATION_BLOCK_LIST()
MS_BEGIN_EQUATION_BLOCKS_DEPS()
MS_END_EQUATION_BLOCKS_DEPS()
// Types
MS_BEGIN_TYPE_CONNECTOR(Liquid)
MS_FIELD_EX(Q, "Q", TypeDouble, vk_flow, "")
MS_FIELD_EX(p, "p", TypeDouble, vk_contact, "")
MS_END_TYPE_CONNECTOR()
MS_BEGIN_CLASS_ROOT_GLOBALS()
MS_BEGIN_MEMBERS()
MS_VAR_EX(g, "g", TypeDouble, vk_constant, "")
MS_VAR_EX(liquid_density, "liquid_density", TypeDouble, vk_parameter, "")
MS_VAR_EX(liquid_viscosity, "liquid_viscosity", TypeDouble, vk_parameter, "")
MS_END_MEMBERS()
MS_BEGIN_MEMBERS_INIT()
MS_VAR_INIT(VAR1(g), 9.81)
MS_VAR_INIT(VAR1(liquid_density), 1000)
MS_VAR_INIT(VAR1(liquid_viscosity), 0.001)
MS_END_MEMBERS_INIT()
MS_END_CLASS_ROOT_GLOBALS()
// Classes
MS_BEGIN_CLASS(Pipe)
MS_BEGIN_CLASS_LOCAL(hydro_flow)
MS_BEGIN_EQUATION_SYSTEM()
MS_BEGIN_EQUATION_BLOCK_DEFAULT()
MS_EQUATION_ALG((VAL(OWNER(VAR2(inlet,Q))) + VAL(OWNER(VAR2(outlet,Q)))) - 0)
MS_EQUATION_ALG((VAL(OWNER(VAR2(inlet,p))) - VAL(OWNER(VAR2(outlet,p)))) - MvsOp::Divide((VAL(OWNER(VAR2(inlet,Q))) * VAL(OWNER(VAR1(flow_coef)))), MvsOp::Pow(OWNER(FUNC(open_coef))(), 4)))
MS_END_EQUATION_BLOCK()
MS_END_EQUATION_SYSTEM()
MS_END_CLASS_LOCAL()
MS_BEGIN_CLASS_LOCAL(no_flow)
MS_BEGIN_EQUATION_SYSTEM()
MS_BEGIN_EQUATION_BLOCK_DEFAULT()
MS_EQUATION_ALG(VAL(OWNER(VAR2(inlet,Q))) - 0)
MS_EQUATION_ALG(VAL(OWNER(VAR2(outlet,Q))) - 0)
MS_END_EQUATION_BLOCK()
MS_END_EQUATION_SYSTEM()
MS_END_CLASS_LOCAL()
MS_BEGIN_MEMBERS()
MS_VAR_EX(diam, "diam", TypeDouble, vk_parameter, "")
MS_VAR_EX(flow_coef, "flow_coef", TypeDouble, vk_internal, "")
MS_VAR_EX(inlet, "inlet", Liquid, vk_connector, "")
MS_VAR_EX(len, "len", TypeDouble, vk_parameter, "")
MS_VAR_EX(outlet, "outlet", Liquid, vk_connector, "")
MS_END_MEMBERS()
MS_BEGIN_MEMBERS_INIT()
MS_VAR_INIT(VAR1(diam), 0.1)
MS_VAR_INIT(VAR1(flow_coef), MvsOp::Divide((((16 * 8) * VAL(GLOBAL(VAR1(liquid_viscosity)))) * VAL(VAR1(len))), ((VAL(GLOBAL(VAR1(liquid_density))) * INTR_FUNC(pi)()) * MvsOp::Pow(VAL(VAR1(diam)), 4))))
MS_VAR_INIT(VAR2(inlet,Q), 0)
MS_VAR_INIT(VAR2(inlet,p), 0)
MS_VAR_INIT(VAR1(len), 10)
MS_VAR_INIT(VAR2(outlet,Q), 0)
MS_VAR_INIT(VAR2(outlet,p), 0)
MS_END_MEMBERS_INIT()
MS_BEGIN_FUNCTION(open_coef, TypeDouble, (
)
)
return 1;
MS_END_FUNCTION()
MS_BEGIN_STATE_CHART()
MS_BEGIN_STATE_ACTIVE(S1, no_flow,
)
MS_BEGIN_STATE_TRANSITIONS()
MS_BEGIN_TRANSITION(_T_3, S2)
MS_TRIGGER_ON_CONDITION((OWNER(FUNC(open_coef))() >= 0.01))
MS_END_TRANSITION()
MS_END_STATE_TRANSITIONS()
MS_END_STATE()
MS_BEGIN_STATE_ACTIVE(S2, hydro_flow,
)
MS_BEGIN_STATE_TRANSITIONS()
MS_BEGIN_TRANSITION(_T_5, S1)
MS_TRIGGER_ON_CONDITION((OWNER(FUNC(open_coef))() < 0.01))
MS_END_TRANSITION()
MS_END_STATE_TRANSITIONS()
MS_END_STATE()
MS_BEGIN_STATE_INITIAL()
MS_BEGIN_STATE_TRANSITIONS()
MS_BEGIN_TRANSITION(_T_51, S1)
MS_END_TRANSITION()
MS_END_STATE_TRANSITIONS()
MS_END_STATE()
MS_END_STATE_CHART()
MS_END_CLASS()
MS_BEGIN_CLASS_DERIVED(Valve, Pipe)
MS_BEGIN_MEMBERS()
MS_VAR_EX(open, "open", TypeDouble, vk_input, "")
MS_END_MEMBERS()
MS_BEGIN_MEMBERS_INIT()
MS_VAR_INIT(VAR1(open), 0)
MS_END_MEMBERS_INIT()
MS_BEGIN_FUNCTION(open_coef, TypeDouble, (
)
)
return MvsOp::Divide(VAL(VAR1(open)), 100);
MS_END_FUNCTION()
MS_BEGIN_STATE_CHART()
MS_END_STATE_CHART()
MS_END_CLASS()
MS_BEGIN_CLASS(Tank)
MS_BEGIN_MEMBERS()
MS_VAR_EX(bottom_area, "bottom_area", TypeDouble, vk_parameter, "")
MS_VAR_EX(depth, "depth", TypeDouble, vk_parameter, "")
MS_VAR_EX(level, "level", TypeDouble, vk_internal, "")
MS_VAR_EX(mass, "mass", TypeDouble, vk_internal, "")
MS_VAR_EX(outlet, "outlet", Liquid, vk_connector, "")
MS_VAR_EX(_dif0, "mass'", TypeDouble, vk_internal, "")
MS_END_MEMBERS()
MS_BEGIN_MEMBERS_INIT()
MS_VAR_INIT(VAR1(bottom_area), 100)
MS_VAR_INIT(VAR1(depth), 10)
MS_VAR_INIT(VAR1(level), 0)
MS_VAR_INIT(VAR1(mass), ((VAL(GLOBAL(VAR1(liquid_density))) * VAL(VAR1(level))) * VAL(VAR1(bottom_area))))
MS_VAR_INIT(VAR2(outlet,Q), 0)
MS_VAR_INIT(VAR2(outlet,p), 0)
MS_VAR_INIT_DEFAULT(VAR1(_dif0))
MS_END_MEMBERS_INIT()
MS_BEGIN_EQUATION_SYSTEM()
MS_BEGIN_EQUATION_BLOCK_DEFAULT()
MS_EQUATION_DIF(VAR1(mass), VAR1(_dif0))
MS_EQUATION_ALG(VAL(VAR1(_dif0)) - VAL(VAR2(outlet,Q)))
MS_EQUATION_ALG(VAL(VAR1(level)) - MvsOp::Divide(VAL(VAR1(mass)), (VAL(GLOBAL(VAR1(liquid_density))) * VAL(VAR1(bottom_area)))))
MS_EQUATION_ALG(VAL(VAR2(outlet,p)) - ((VAL(GLOBAL(VAR1(liquid_density))) * VAL(GLOBAL(VAR1(g)))) * VAL(VAR1(level))))
MS_END_EQUATION_BLOCK()
MS_END_EQUATION_SYSTEM()
MS_END_CLASS()
MS_BEGIN_CLASS(Model)
MS_BEGIN_MEMBERS()
MS_SUBOBJECT_EX(Tank_1, "Tank_1", Tank, "")
MS_SUBOBJECT_EX(Tank_2, "Tank_2", Tank, "")
MS_SUBOBJECT_EX(Valve_1, "Valve_1", Valve, "")
MS_END_MEMBERS()
MS_BEGIN_MEMBERS_INIT()
MS_SUBOBJECT_INIT(VAR1(Tank_1),
MS_SUBOBJECT_INIT_VAR(VAR2(Tank_1,level), 5)
)
MS_SUBOBJECT_INIT(VAR1(Tank_2),
MS_SUBOBJECT_INIT_VAR(VAR2(Tank_2,depth), 20)
MS_SUBOBJECT_INIT_VAR(VAR2(Tank_2,bottom_area), 200)
)
MS_SUBOBJECT_INIT_DEFAULT(VAR1(Valve_1))
MS_END_MEMBERS_INIT()
MS_BEGIN_LINKS()
MS_LINK_CONTACT(
MS_LINK_VAR(VAR3(Tank_1,outlet,p))
MS_LINK_VAR(VAR3(Valve_1,inlet,p))
)
MS_LINK_CONTACT(
MS_LINK_VAR(VAR3(Valve_1,outlet,p))
MS_LINK_VAR(VAR3(Tank_2,outlet,p))
)
MS_LINK_FLOW(
MS_LINK_VAR(VAR3(Tank_1,outlet,Q))
MS_LINK_VAR(VAR3(Valve_1,inlet,Q))
)
MS_LINK_FLOW(
MS_LINK_VAR(VAR3(Valve_1,outlet,Q))
MS_LINK_VAR(VAR3(Tank_2,outlet,Q))
)
MS_END_LINKS()
MS_BEGIN_EQUATION_SYSTEM()
MS_BEGIN_EQUATION_BLOCK_DEFAULT()
MS_END_EQUATION_BLOCK()
MS_END_EQUATION_SYSTEM()
MS_END_CLASS()
MS_BEGIN_CLASS_ROOT()
MS_BEGIN_MEMBERS()
MS_SUBOBJECT_EX(model, "model", Model, "")
MS_END_MEMBERS()
MS_BEGIN_MEMBERS_INIT()
MS_SUBOBJECT_INIT_DEFAULT(VAR1(model))
MS_END_MEMBERS_INIT()
MS_END_CLASS_ROOT()Я доволен как слон, насколько натурально удалось представить код модели в макро-коде на Rust! - Лишь из-за этого уже стоило на него переходить, так как только красивая программа может правильно работать! :)
Лексер и парсер
В части транслятора довольно удобно было воспользоваться библиотекой lalrpop для генерации парсера - она позволяет описать грамматику (LA)LR(1) парсера в похожем на EBNF формате.
Так как глубина предпросмотра следующих токенов (lookahead) грамматики равна 1, то пришлось тщательно комбинировать правила и выносить общие части, чтобы исключить неоднозначности.
Макросы-шмакросы
Библиотека lalrpop имеет довольно замечательную возможность описывать шаблоны правил (что называются макросами в документации), позволяя выделять общие части правил и их обработчиков и параметризовывать их другими правилами - например, вот как можно компактно задать правило для матричного литерала (возьмём только целые числа для простоты, пример в исходном коде матрицы 2x2: [11,12; 21,22]) через общие шаблоны в файле грамматики grammar.lalrpop:
use super::ast::*; // MatrixLit, make_int
grammar;
extern {
type Error = String;
}
// rules
pub matrix_lit: MatrixLit
= "[" <semicolon_sep_list<comma_sep_list<int>>> "]" => MatrixLit(<>);
int: i32 = r"[0-9]+" =>? make_int(<>).map_err(Into::into);
// templates
comma_sep_list<T> = sep_list<T, ",">;
semicolon_sep_list<T> = sep_list<T, ";">;
sep_list<T, Separator>: Vec<T> = <mut ts:(<T> Separator)*> <t:T> => { ts.push(t); ts };- Где `matrix_lit` - имя правила для матричного литерала, `MatrixLit` - соответствующая структура в AST, которую возвращает обработчик правила, `int` - правило для целого числа, с возможностью вернуть кастомную ошибку при парсинге строки, `sep_list` - шаблонное правило для списка элементов, разделённых абстрактным разделителем, `comma_sep_list` и `semicolon_sep_list` - шаблонные правила для списка элементов, разделённых запятой и точкой с запятой соответственно, определённые через более обобщённый `sep_list`.
Файл ast.rs содержит определение `MatrixLit` и хелперной функции `make_int` (здесь простой вариант без диапазона позиций в исходной строке):
use std::str::FromStr;
pub struct MatrixLit(pub Vec<Vec<i32>>);
pub fn make_int(s: &str) -> Result<i32, String> {
i32::from_str(s).map_err(|_| format!("Integer number is too big: {s}"))
}Борьба со слабыми ключевыми словами
Описываемая в файле грамматика должна быть контекстно-свободной, а исходный язык конечно же не такой - в частности, имеет т.н. "слабые" (weak, или "мягкие"-soft) ключевые слова - которые могут быть либо ключевым словом, либо идентификатором в зависимости от контекста. Далее приведу несколько способов борьбы с таким хамелеоном.
Первый способ в лоб: предварительно прогоняем исходный текст через простой препроцессор на регулярных выражениях, заменяющий некоторые слабые ключевые слова языка в некотором контексте на те, что не могут быть использованы в качестве идентификаторов. Конкретнее, первая буква таких слов в строке исходного кода заменяется на специальный символ - чтобы не сдвинуть позиции токенов в сообщении об ошибке от парсера и заодно чтобы не вызвать переаллокацию исходной строки. Например, ищём `name` с помощью регулярного выражения `external\s+"[^"]*"\s+(name)` (как одного из альтернатив в составе группы выражений, чтобы пройти текст за один проход для всех искомых слов) в исходном тексте (показана хвостовая часть определения внешней функции):
... external "MyLib.dll" name "MyFunc";и заменяем на `@ame`:
... external "MyLib.dll" @ame "MyFunc";При использовании встроенного лексера в файле грамматики при этом нужно использовать это изменённое `@ame`:
... "external" <dll_path:string> "@ame" <dll_func_name:string> ";" => ExtFunc {<>};а при возникновении сообщения об ошибке от парсера (например, "Unexpected token 'abracadabra' found, expected '@ame'"), в этом сообщении нужно восстановить испорченное слово, т.е. обратно заменить `@ame` на `name`.
Кроме препроцессинга есть и другой способ - а именно, указание в грамматике на месте ожидаемого слабого ключевого слова правила для идентификатора с дальнейшей ручной проверкой на соответствие слову. Т.е. для того же примера с `name` это могло бы быть следующим образом (в файле грамматики):
... "external" <dll_path:string> <kw_name:id> <dll_func_name:string> ";" => {
if kw_name == "name" {
Ok(ExtFunc { ..., dll_path, dll_func_name })
} else {
Err(format!("Unexpected token '{kw_name}' found, expected 'name'"))
}
};где переменная `kw_name` (результат правила `id`) руками проверяется на равенство "name" и выдаётся кастомная ошибка в случае неуспеха.
И хотя на встроенном в lalrpop простом лексере удалось довольно далеко продвинуться, в какой-то момент потребовалось управление его состоянием - в том числе для некоторых других слабых ключевых слов, для которых указанные выше методы применять было неудобно или невозможно - что и привело к внедрению генератора лексера lexgen. Это избавило от необходимости восстанавливать изменённые на этапе препроцессинга слова, которые ещё остались обрабатываться таким образом - так как лексер поставляет в парсер токены, которые отображаются как первоначальные слова, даже если были препроцессированы, т.е. для нашего примера грамматика уже будет содержать первоначальное слово-токен `name` (если оно препроцессируется):
... "external" <dll_path:string> "name" <dll_func_name:string> ";" => ExtFunc {<>};Управление состоянием лексера нужно для переключения контекста, что можно производить как изнутри из лексера, так и снаружи из парсера. Парсер может переключать контекст из обработчиков правил, при этом нужно помнить про lookahead=1, т.е. иметь зазор между токенами в разных контекстах в один токен, который не должен быть идентификатором, а может быть, например, каким-нибудь разделителем или сильным ключевым словом. Лаг от вызова команды переключения режима при редукции правила ("нетерминального символа") до начала действия режима в лексере возникает как раз из-за lookahead (хорошо, что он небольшой). Т.о. получаем третий способ работы со слабыми ключевыми словами, на всё том же примере в файле грамматики (уже без препроцессинга):
use super::lexer::LexerSharedMode;
grammar(mode: &LexerSharedMode);
... external <dll_path:string> "name" <dll_func_name:dll_func_name> ";" => ExtFunc {<>};
external: () = "external" => mode.enter_mode_kw_name();
dll_func_name: String = string => { mode.leave_mode_kw_name(); <> };где `mode` это передаваемый в парсер общий объект, что служит для переключения режимов лексера из парсера: переключение в режим "name это ключевое слово" происходит после редукции `external`, для чего перед "name" имеется необходимый зазор в виде `<dll_path:string>`, представляющий строковый литерал, а обратное переключение в режим "name это идентификатор" происходит сразу после редукции `dll_func_name`, что тоже определяется строковым литералом, а в качестве зазора для последующих токенов работает ";" на конце. Лексер же, в зависимости от текущего режима, сохранённого в `mode`, выдаёт разные токены (Kw_name или Identifier("name")) для одного и того же исходного слова "name".
Следует отметить, что даже если вышерассмотренный пример усложнить, сделав `dll_func_name` опциональным (как это и есть в реальности), то всё равно все три приёма будут работать. Для последнего сработает уже такая грамматика:
use super::lexer::LexerSharedMode;
grammar(mode: &LexerSharedMode);
... external <dll_path:string> <dll_func_name_opt:dll_func_name_opt> ";" => ExtFunc2 {<>};
external: () = "external" => mode.enter_mode_kw_name();
dll_func_name_opt: Option<String> = ("name" <string>)? => { mode.leave_mode_kw_name(); <> };Я использую все три способа, т.к. для каждого есть ситуация, где он единственный практично-возможный.
Результаты по lalrpop+lexgen
Этот внешний лексер дополнительно ускорил парсинг (вместе с лексингом) в пять раз, а трансляции в целом в три (на боевом проекте под сотню исходных файлов весящих суммарно 1.5MB стало занимать порядка 100ms на моём лаптопе с Core i7 и это всё в один поток) - что даже обескуражило, насколько встроенный лексер оказался неэффективен - но для относительно простых вещей он вполне годится.
В целом, библиотекой lalrpop я остался доволен - т.к. она (в паре c lexgen или без) предлагает гибкое описание грамматики на достаточно высоком уровне и одновременно позволяет легко и безопасно поэффективничать - в частности, избежать выделения памяти для идентификаторов на этапе лексического и синтаксического разбора, донеся ссылки на подстроки из исходного текста на стадию семантического анализа (lifetime-ы рулят!).
Предыстория по разным попыткам парсинга
Перед тем, как выбрать библиотеку lalrpop для парсинга, я пробовал pest, но в ней отсутствует возможность писать обработчики правил прямо в описании грамматики, что приводит к дублированию ссылок на правила уже в рукописном коде для их обработки и в целом, кажется, применяется для более простых грамматик.
А ещё ранее на C++ пытался использовать связку flex-bison - и хотя это довольно мощный инструмент, нетривиальное подключение и настройка, в том числе необходимость рассовывать куски кода вперемешку с опциями по разным секциям в lexer.l и parser.yy файлах, а также слишком низкоуровневые конструкции на C, которые требуется оборачивать - все эти yytext, yyleng, yylval, yylloc, yylex, которые хочется развидеть - привели в итоге к Rust, где управление внешними зависимостями работает из коробки (что является важным фактором для инди-разработчика - до свидания, cmake!), и легко подключить высокоуровневую и одновременно гибкую библиотеку на выбор, не говоря уже о нативной поддержке UTF-8 в самом языке.
Ещё до кучи можно вспомнить первую пробу темы более десяти лет назад при работе в одной компании - мы тогда для парсера по неопытности взяли boost-spirit и чертыхались от бесконечных портянок сообщений об ошибках в шаблонном коде, кроме того, пришлось втягивать огромный boost только из-за этой его части.
Сложности с Rust относительно C++
Стоит отметить, что переход на Rust в части рантайма языка кое в чём и усложнил его - а именно, из-за более строгой типизации и отсутствия неявного приведения элементарных типов в Rust, пришлось делать обёртки вокруг них и перегружать бинарные операторы для обеспечения более мягких правил приведения типов и бинарной операбельности в исходном языке, что не требовалось в C++, так как там эти правила были такими же. Но зато это привело в итоге к возможности более гибкой настройки проверок во время исполнения, и ту же проверку переполнения целых чисел теперь можно включать опционально.
Другой момент усложнения в связи с выбором Rust был вызван необходимостью поддержать в исходном языке одиночное наследование классов с переопределяемыми в наследниках виртуальными функциями, а также динамическое приведение объектных ссылок - то есть полный ООП. А так как в Rust нет наследования структур, пришлось переизобрести это наследование классов, таблицы виртуальных функций и RTTI для динамического приведения (то, что в плюсах делается с помощью std::dynamic_cast). Но это оказалось даже интересно и познавательно :)
Ещё одним велосипедом была реализация try-catch-finally а также throw-rethrow функциональности для exceptions. В Rust в чистом виде этого добра нет, но зато есть примитивы catch_unwind и resume_unwind, что позволило реализовать требуемые высокоуровневые конструкции, заиспользовав уже выше-сделанное кустарное наследование и приведение типов чтобы проверять типы исключений в catch блоке. На плюсах же потребовалось бы специально реализовывать только finally блок.
Наверное, стоит рассказать об этих решениях подробнее в отдельной статье.
Rust vs C++ - текущие выводы
Для этой задачи Rust оказался в целом для меня удобнее, чем C++. Отмечу некоторые пункты из того, что мне нравится в Rust (это лично мой опыт и мнение, не претендующее на полноту, так что надеюсь не вызвать жёсткий холивар):
Стандартное описание проекта с зависимостями - это то, что сильно не хватает в плюсах. Стандартный же пакетный менеджер cargo для сборки - позволяет легко управлять зависимостями и использовать множество библиотек из публичного репозитория (и из частных тоже). Есть хорошие решения для разных сфер, например, удобно писать CLI утилиты используя clap, а парсеры формальных языков - используя lalrpop.
Макросы очень мощные и гибкие - хорошо годятся для DSL, а в моём случае отлично подошли для промежуточного языка.
Move операция встроена в язык и деструктивна - то есть нет проблемы use-after-move, как в плюсах. Очень правильно, что move неявный, а более дорогую операцию клонирования нужно явно вызывать - что прямо противоположно тому, как это происходит в плюсах (из-за наследия старой парадигмы). Замечательная идея поглощения (consume) объекта базируется на этом же move, что позволяет строить грамотный интерфейс.
Гармонично сведено воедино то, что разрозненно в С++ - нет зоопарка всех этих разных ссылок (lvalue, rvalue, universal), а также xvalue и prvalue, избыточного разнообразия инициализаторов, особых функций (таких как конструктор). Грамотно введена концепция трейтов - они используются как для статической, так и для динамической диспетчеризации (при этом последнее не влияет на размер структуры, как встроенные указатели на vtable в плюсовый класс), а в дженериках они же одновременно определяют ограничения, для чего в плюсах пришлось ввести новую сущность - концепты.
Т.н. алгебраические типы, т.е. enum, в паре с паттерн-матчингом - просто космос. Это то, что в плюсах std::variant с std::visit, но на порядок гибче и удобнее - поэтому на этих типах часто делают рантайм-полиморфизм.
Можно расширять своими методами чужую структуру. Добавочные адаптеры для итераторов вызываются поэтому так же удобно, как и стандартные - как методы - в отличие от громоздкого синтаксиса ranges в плюсах.
Маркировка unsafe части, чтобы отделить от безопасного кода.
В целом симпатичен современный синтаксис.
Чего мне не хватает в Rust после C++:
Частичная специализация дженериков к сожалению не стабилизирована и есть только в nightly версии. Мне хватило бы min_specialization и я бы уже был бы счастлив - без этого приходится перечислять в макросе все задетые специализацией типы.
Мотивация и положение дел
Одним из самых сложных оказалось поддерживать мотивацию - при соло-разработке вдолгую вовлечённость меняется волнообразно - на протяжении уже двух с половиной лет работы "в стол" без какой-либо гарантии, что потраченное время хоть как-то отобьётся, далеко не просто поддерживать стабильный темп.
Меня поддерживает в основном изначальная жажда всё-таки сделать этот проект, причём сделать хорошо. А чтобы дополнительно взбодриться и "поддержать огонь", периодически контактирую с заинтересованными для обсуждения функциональных деталей. Так что эта публикация это тоже способ для подобной энергетической подкачки.
Рад поделиться, что по текущему состоянию сейчас мой компилятор и рантайм уже можно использовать для несложных моделей, основной функционал уже почти весь есть, остаётся его добить и оптимизация - алгоритмическая и аналитическая. Пожелайте мне удачи в продуктовом продвижении моего поделия!
Продолжение следует
Спасибо, что прочитали и надеюсь, что было полезно! Какие вопросы были бы интересны в продолжение темы? Не могу создать опрос, так хотя бы в комментариях черканите, что из следующего стоит осветить:
Полный ООП, включая динамическое приведение типов, для макро-кода на Rust
try-catch-finally там же
Как же всё-таки эффективно работать с развесистыми графоподобными структурами - на примере типизированной структуры модели в трансляторе
Метапрограммирование в квадрате: определяем макрос в макросе для реальных нужд
Как использовать в сборке зависимости через rlib-файлы без исходников
Всего доброго!
