В част 3 от нашата поредица нашият лек език за програмиране най-накрая ще стартира. Той няма да бъде завършен по Тюринг, няма да бъде мощен, но ще може да оценява изрази и дори да извиква външни функции, написани на C ++.
Ще се опитам да опиша процеса възможно най-подробно, главно защото това е целта на тази поредица от блогове, но и за собствената ми документация, защото в тази част нещата се усложниха малко.
Започнах да кодирам тази част преди публикуването на втората статия, но след това се оказа, че анализаторът на изрази трябва да бъде самостоятелен компонент, който заслужава своя публикация в блога.
Това, заедно с някои скандални техники за програмиране, направи възможно тази част да не бъде чудовищно голяма, и все пак, някои читатели най-вероятно ще посочат споменатите техники за програмиране и ще се чудят защо трябваше да ги използвам.
Тъй като натрупах опит в програмирането, работейки по различни проекти и с различни хора, научих, че разработчиците са склонни да бъдат доста догматични - вероятно защото така е по-лесно.
Първата догма на програмирането е, че goto
твърдението е лошо, зло и ужасно. Мога да разбера откъде произхожда това чувство и съм съгласен с това понятие в по-голямата част от случаите, когато някой използва goto
изявление. Обикновено може да се избегне и вместо това може да се напише по-четлив код.
Не може обаче да се отрече, че прекъсването от вътрешния цикъл в C ++ може лесно да се извърши с goto
изявление. Алтернативата - която изисква bool
променлива или специална функция - може да бъде по-малко четима от кода, който догматично попада в групата забранени техники за програмиране.
Втората догма, отнасяща се изключително до C и Разработчици на C ++ , е, че макросите са лоши, зли, ужасни и основно бедствие, което чака да се случи. Това почти винаги е придружено от този пример:
#define max(a, b) ((a) > (b) ? (a) : (b)) ... int x = 3; int z = 2; int y = max(x++, z);
И тогава възниква въпрос: Каква е стойността на x
след този код и отговорът е 5
защото x
се увеличава два пъти, по един от всяка страна на ?
-оператора.
Единственият проблем е, че никой не използва макроси в този сценарий. Макросите са зли, ако се използват в сценарий, при който обикновените функции работят добре, особено ако се представят за функции, така че потребителят не е наясно с техните странични ефекти. Ние обаче няма да ги използваме като функции и ще използваме букви за техните имена, за да стане очевидно, че те не са функции. Няма да можем да ги отстраним правилно и това е лошо, но ще живеем с това, тъй като алтернативата е копиране на поставяне на същия код десетки пъти, което е много по-склонно към грешки от макросите. Едно от решенията на този проблем е писането на генератора на код, но защо да го пишем, когато вече имаме вграден в C ++?
Догмите в програмирането почти винаги са лоши. Тук предпазливо използвам „почти“, само за да избегна рекурсивно попадане в догматичния капан, който току-що създадох.
Можете да намерите кода и всички макроси за тази част тук .
В предишна част , Споменах, че Stork няма да бъде компилиран в двоичен файл или нещо подобно на асемблерния език, но също така казах, че това ще бъде статично типизиран език. Следователно той ще бъде компилиран, но в C ++ обект, който ще може да се изпълни. По-късно ще стане по-ясно, но засега нека просто заявим, че всички променливи ще бъдат обекти сами по себе си.
Тъй като искаме да ги запазим в контейнера на глобалната променлива или в стека, един удобен подход е да дефинираме базовия клас и да го наследим от него.
class variable; using variable_ptr = std::shared_ptr; class variable: public std::enable_shared_from_this { private: variable(const variable&) = delete; void operator=(const variable&) = delete; protected: variable() = default; public: virtual ~variable() = default; virtual variable_ptr clone() const = 0; template T static_pointer_downcast() { return std::static_pointer_cast(shared_from_this()); } };
Както можете да видите, той е доста прост и функцията clone
, която прави дълбокото копие, е единствената му функция за виртуален член освен деструктора.
Тъй като винаги ще използваме обекти от този клас чрез shared_ptr
, има смисъл да го наследим от std::enable_shared_from_this
, за да можем лесно да получим споделения указател от него. Функцията static_pointer_downcast
е тук за удобство, защото често ще трябва да се прехвърляме от този клас към неговото изпълнение.
Реалната реализация на този клас е variable_impl
, параметризирана с типа, който притежава. Ще бъде създаден екземпляр за четирите типа, които ще използваме:
using number = double; using string = std::shared_ptr; using array = std::deque; using function = std::function;
Ще използваме double
като нашия номер. Низовете се броят на референции, тъй като ще бъдат неизменни, за да се даде възможност за определени оптимизации при предаването им по стойност. Масивът ще бъде std::deque
, тъй като е стабилен и нека само отбележим, че runtime_context
е класът, който съдържа цялата подходяща информация за програмната памет по време на изпълнение. Ще стигнем до това по-късно.
Често се използват и следните дефиниции:
using lvalue = variable_ptr; using lnumber = std::shared_ptr; using lstring = std::shared_ptr; using larray = std::shared_ptr; using lfunction = std::shared_ptr;
Използваното тук „l“ е съкратено за „lvalue“. Винаги, когато имаме lvalue за някакъв тип, ще използваме споделения указател към variable_impl
.
По време на изпълнение състоянието на паметта се поддържа в класа runtime_context
.
class runtime_context{ private: std::vector _globals; std::deque _stack; std::stack _retval_idx; public: runtime_context(size_t globals); variable_ptr& global(int idx); variable_ptr& retval(); variable_ptr& local(int idx); void push(variable_ptr v); void end_scope(size_t scope_vars); void call(); variable_ptr end_function(size_t params); };
Инициализира се с броя на глобалните променливи.
_globals
съхранява всички глобални променливи. Те са достъпни с функцията член global
с абсолютния индекс._stack
поддържа локални променливи и аргументи на функциите и цялото число в горната част на _retval_idx
поддържа абсолютния индекс в _stack
на текущата възвръщаема стойност.retval
, докато локалните променливи и аргументите на функцията са достъпни с функцията local
чрез предаване на индекса спрямо текущата възвръщаема стойност. В този случай аргументите на функцията имат отрицателни индекси.push
функция добавя променливата към стека, докато end_scope
премахва подадения брой променливи от стека.call
функция ще преоразмери стека с единица и ще натисне индекса на последния елемент в _stack
до _retval_idx
.end_function
премахва връщаната стойност и предадения брой аргументи от стека и също връща премахнатата връщана стойност.Както можете да видите, ние няма да внедрим никакво управление на паметта на ниско ниво и ще се възползваме от управлението на собствената памет (C ++), което можем да приемем за даденост. Ние също няма да приложим никакви разпределения на купчини, поне засега.
С runtime_context
най-накрая имаме всички градивни елементи, необходими за централния и най-труден компонент на тази част.
За да обясня изцяло сложното решение, което ще представя тук, накратко ще ви запозная с няколко неуспешни опита, които направих, преди да се спра на този подход.
Най-лесният подход е да се оцени всеки израз като variable_ptr
и имаме този виртуален базов клас:
class expression { ... public: variable_ptr evaluate(runtime_context& context) const = 0; lnumber evaluate_lnumber(runtime_context& context) const { return evaluate(context)->static_pointer_downcast(); } lstring evaluate_lstring(runtime_context& context) const { return evaluate(context)->static_pointer_downcast(); } number evaluate_number(runtime_context& context) const { return evaluate_lnumber(context)->value; } string evaluate_string(runtime_context& context) const { return evaluate_lstring(context)->value; } ... }; using expression_ptr = std::unique_ptr;
След това бихме наследили от този клас за всяка операция, като добавяне, конкатенация, извикване на функция и т.н. Например, това би било изпълнението на израза за добавяне:
class add_expression: public expression { private: expression_ptr _expr1; expression_ptr _expr2; public: ... variable_ptr evaluate(runtime_context& context) const override{ return std::make_shared( _expr1->evaluate_number(context) + _expr2->evaluate_number(context) ); } ... };
Така че трябва да оценим двете страни (_expr1
и _expr2
), да ги добавим и след това да конструираме variable_impl
.
Можем безопасно да намалим променливите, защото проверихме техния тип по време на компилацията, така че тук не е проблемът. Големият проблем обаче е наказанието за производителност, което плащаме за разпределението на купчината на връщащия обект, което - на теория - не е необходимо. Правим това, за да задоволим декларацията за виртуална функция. В първата версия на Stork ще имаме това наказание, когато връщаме числа от функции. Мога да живея с това, но не и с простия израз преди инкремента, който прави разпределение на купчина.
След това се опитах със специфични за типа изрази, наследени от общата база:
class expression { ... public: virtual void evaluate(runtime_context& context) const = 0; ... }; class lvalue_expression: public virtual expression { ... public: virtual lvalue evaluate_lvalue(runtime_context& context) const = 0; void evaluate(runtime_context& context) const override { evaluate_lvalue(context); } ... }; using lvalue_expression_ptr = std::unique_ptr; class number_expression: public virtual expression { ... public: virtual number evaluate_number(runtime_context& context) const = 0; void evaluate(runtime_context& context) const override { evaluate_number(context); } ... }; using number_expression_ptr = std::unique_ptr; class lnumber_expression: public lvalue_expression, public number_expression { ... public: virtual lnumber evaluate_lnumber(runtime_context& context) const = 0; lvalue evaluate_lvalue(runtime_context& context) const override { return evaluate_lnumber(context); } number evaluate_number(runtime_context& context) const override { return evaluate_lnumber(context)->value; } void evaluate(runtime_context& context) const override { return evaluate_lnumber(context); } ... }; using lnumber_expression_ptr = std::unique_ptr;
Това е само частта от йерархията (само за числа) и вече се сблъскахме с проблеми с диамантена форма (класът, наследяващ два класа с един и същ основен клас).
За щастие C ++ предлага виртуално наследяване, което дава възможност за наследяване от базовия клас, като запазва указателя към него, в наследения клас. Следователно, ако класовете B и C наследяват на практика от A, а клас D наследява от B и C, ще има само едно копие на A в D.
В този случай обаче има редица наказания - производителност и невъзможност да се свали от А, да назовем само няколко - но това все пак изглеждаше като възможност за мен да използвам виртуалното наследство за първи път в живота ми.
Сега изпълнението на израза за добавяне ще изглежда по-естествено:
class add_expression: public number_expression { private: number_expression_ptr _expr1; number_expression_ptr _expr2; public: ... number evaluate_number(runtime_context& context) const override{ return _expr1->evaluate_number(context) + _expr2->evaluate_number(context); } ... };
Синтаксично, няма какво повече да се иска и това е толкова естествено, колкото става. Ако обаче някой от вътрешните изрази е израз на числово число, той ще изисква две извиквания на виртуална функция, за да го оцени. Не е перфектно, но не и ужасно.
Нека добавим низове в този микс и да видим докъде ще ни стигне:
class string_expression: public virtual expression { ... public: virtual string evaluate_string(runtime_context& context) const = 0; void evaluate(runtime_context& context) const override { evaluate_string(context); } ... }; using string_expression_ptr = std::unique_ptr;
Тъй като искаме числата да бъдат конвертируеми в низове, трябва да наследяваме number_expression
от string_expression
.
class number_expression: public string_expression { ... public: virtual number evaluate_number(runtime_context& context) const = 0; string evaluate_string(runtime_context& context) const override { return tostring(evaluate_number(context)); } void evaluate(runtime_context& context) const override { evaluate_number(context); } ... }; using number_expression_ptr = std::unique_ptr;
Преживяхме това, но трябва да заменим отново evaluate
виртуален метод, или ще се сблъскаме със сериозни проблеми с производителността поради ненужно преобразуване от число в низ.
И така, нещата очевидно стават грозни и нашият дизайн едва ги преживява, защото нямаме два вида изрази, които трябва да се преобразуват един в друг (и в двата начина). Ако случаят беше такъв или ако се опитахме да имаме някакъв вид кръгово преобразуване, нашата йерархия не можеше да се справи. В края на краищата йерархията трябва да отразява връзката is-a, а не-convertible-to, която е по-слаба.
Всички тези неуспешни опити ме доведоха до сложен, но според мен правилен дизайн. Първо, наличието на един основен клас не е от решаващо значение за нас. Нуждаем се от класа на израза, който би оценил на void, но ако можем да разграничим void изрази и изрази от друг вид по време на компилация, няма нужда да конвертираме между тях по време на изпълнение. Следователно ще параметризираме базовия клас с типа на връщане на израза.
Ето пълното изпълнение на този клас:
template class expression { expression(const expression&) = delete; void operator=(const expression&) = delete; protected: expression() = default; public: using ptr = std::unique_ptr; virtual R evaluate(runtime_context& context) const = 0; virtual ~expression() = default; };
Ще имаме само едно виртуално извикване на функция за оценка на израз (разбира се, ще трябва да го извикаме рекурсивно) и тъй като не компилираме в двоичен код, това е доста добър резултат. Единственото, което остава да направите, е преобразуването между типовете, когато е разрешено.
За да постигнем това, ще параметризираме всеки израз с типа return и ще го наследим от съответния базов клас. След това в evaluate
функция, ще преобразуваме резултата от оценката във връщаната стойност на тази функция.
Например това е нашият израз за добавяне:
template class add_expression: public expression { ... R evaluate(runtime_context& context) const override{ return convert( _expr1->evaluate(context) + _expr2->evaluate(context) ); } ... };
За да напишем функцията „конвертиране“, имаме нужда от инфраструктура:
template struct is_boxed { static const bool value = false; }; template struct is_boxed, T> { static const bool value = true; }; string convert_to_string(number n) { std::string str if (n == int(n)) { str = std::to_string(int(n)); } else { str = std::to_string(n); } return std::make_shared(std::move(str)); } string convert_to_string(const lnumber& v) { return convert_to_string(v->value); }
Структурата is_boxed
е черта на типа, която има вътрешна константа, value
, която изчислява на true, ако (и само ако) първият параметър е споделен указател към variable_impl
параметризиран с втория тип.
Изпълнението на convert
функция би била възможна дори в по-стари версии на C ++, но в C ++ 17 има много полезен израз, наречен if constexpr
, който оценява състоянието по време на компилиране. Ако изчисли на false
, ще изпусне блока изобщо, дори ако причинява грешка във времето на компилиране. В противен случай ще изпусне else
блок.
template auto convert(From&& from) { if constexpr(std::is_convertible::value) { return std::forward(from); } else if constexpr(is_boxed::value) { return unbox(std::forward(from)); } else if constexpr(std::is_same::value) { return convert_to_string(from); } else { static_assert(std::is_void::value); } }
Опитайте се да прочетете тази функция:
variable_impl
указател нагоре).Според мен това е много по-четимо от по-стария синтаксис, базиран на SFINAE.
Ще дам кратък преглед на типовете изрази и ще пропусна някои технически подробности, за да бъде достатъчно кратък.
Има три вида листови изрази в дървото на изразите:
template class global_variable_expression: public expression { private: int _idx; public: global_variable_expression(int idx) : _idx(idx) { } R evaluate(runtime_context& context) const override { return convert( context.global(_idx) ->template static_pointer_downcast() ); } };
Освен типа return, той се параметризира и с променливия тип. Локалните променливи се третират по подобен начин и това е класът за константи:
template class constant_expression: public expression { private: T _c; public: constant_expression(T c) : _c(std::move(c)) { } R evaluate(runtime_context& context) const override { return convert(_c); } };
В този случай ние преобразуваме константата незабавно в конструктора.
Това се използва като основен клас за повечето от нашите изрази:
template class generic_expression: public expression { private: std::tuple _exprs; template R evaluate_tuple( runtime_context& context, const Exprs&... exprs ) const { return convert(O()( std::move(exprs->evaluate(context))...) ); } public: generic_expression(typename expression::ptr... exprs) : _exprs(std::move(exprs)...) { } R evaluate(runtime_context& context) const override { return std::apply( [&](const auto&... exprs){ return this->evaluate_tuple(context, exprs...); }, _exprs ); } };
Първият аргумент е типът функтор, който ще бъде инстанциран и извикан за оценката. Останалите типове са върнати типове дъщерни изрази.
За да намалим кода на шаблона, дефинираме три макроса:
#define UNARY_EXPRESSION(name, code) struct name##_op { template auto operator()(T1 t1) { code; } }; template using name##_expression = generic_expression; #define BINARY_EXPRESSION(name, code) struct name##_op { template auto operator()(T1 t1, T2 t2) { code; } }; template using name##_expression = generic_expression; #define TERNARY_EXPRESSION(name, code) struct name##_op { template auto operator()(T1 t1, T2 t2, T3 t3) { code; } }; template using name##_expression = generic_expression;
Забележете, че operator()
се определя като шаблон, въпреки че обикновено не е задължително. По-лесно е да се дефинират всички изрази по един и същи начин, вместо да се предоставят типове аргументи като макро аргументи.
Сега можем да определим по-голямата част от изразите. Например това е дефиницията за /=
:
BINARY_EXPRESSION(div_assign, t1->value /= t2; return t1; );
Можем да дефинираме почти всички изрази, като използваме тези макроси. Изключенията са оператори, които имат дефиниран ред на оценяване на аргументи (логически &&
и ||
, троичен (?
) и оператор със запетая (,
)), индекс на масив, функция call и param_expression
, което клонира параметъра, за да го предаде на функцията по стойност.
Няма нищо сложно в изпълнението на тези. Изпълнението на извикването на функция е най-сложното, затова ще го обясня тук:
template class call_expression: public expression{ private: expression::ptr _fexpr; std::vector _exprs; public: call_expression( expression::ptr fexpr, std::vector exprs ): _fexpr(std::move(fexpr)), _exprs(std::move(exprs)) { } R evaluate(runtime_context& context) const override { std::vector params; params.reserve(_exprs.size()); for (size_t i = 0; i evaluate(context)); } function f = _fexpr->evaluate(context); for (size_t i = params.size(); i > 0; --i) { context.push(std::move(params[i-1])); } context.call(); f(context); if constexpr (std::is_same::value) { context.end_function(_exprs.size()); } else { return convert( context.end_function( _exprs.size() )->template static_pointer_downcast() ); } } };
Той подготвя runtime_context
чрез натискане на всички оценени аргументи в стека и извикване на call
функция. След това извиква оценения първи аргумент (който е самата функция) и връща връщаната стойност на end_function
метод. Можем да видим използването на if constexpr
синтаксис и тук. Спасява ни от написването на специализацията за целия клас за функции, които връщат void
.
Сега имаме всичко, свързано с изрази, налични по време на изпълнение. Остава само преобразуването от синтактичното дърво на израза (описано в предишната публикация в блога) в дървото на изразите.
За да избегнем объркване, нека назовем различни фази от нашия цикъл на езиково развитие:
Ето псевдокода за конструктора на изрази:
function build_expression(nodeptr n, compiler_context context) { if (n is constant) { return constant_expression(n.value); } else if (n is identifier) { id_info info = context.find(n.value); if (context.is_global(info)) { return global_variable_expression(info.index); } else { return local_variable_expression(info.index); } } else { //operation switch (n->value) { case preinc: return preinc_expression( build_expression(n->child[0]) ); ... case add: return add_expression( build_expression(n->child[0]), build_expression(n->child[1]) ); ... case call: return call_expression( n->child[0], //function n->child[1], //arg0 ... n->child[k+1], //argk ); } } }
Освен че трябва да се справя с всички операции, това изглежда като ясен алгоритъм.
Ако работи, би било чудесно, но не става. За начало трябва да посочим типа на връщане на функцията и той очевидно не е фиксиран тук, тъй като типът на връщане зависи от типа възел, който посещаваме. Типовете възли са известни по време на компилация, но типовете връщане трябва да бъдат известни по време на метакомпилация.
В предишен пост , Споменах, че не виждам предимството на езиците, които извършват динамична проверка на типа. В такива езици псевдокодът, показан по-горе, може да бъде реализиран почти буквално. Сега съм наясно с предимствата на езиците от динамичен тип. Мигновената карма в най-доброто.
За щастие знаем типа на израза от най-високо ниво - това зависи от контекста на компилацията, но знаем неговия тип, без да анализираме дървото на израза. Например, ако имаме for-loop:
for (expression1; expression2; expression3) ...
Първият и третият израз имат void
return тип, защото не правим нищо с резултата от тяхната оценка. Вторият израз обаче има тип number
защото го сравняваме с нула, за да решим дали да спрем цикъла или не.
Ако знаем типа на израза, който е свързан с операцията на възела, той обикновено ще определи вида на своя дъщерен израз.
Например, ако изразът (expression1) += (expression2)
има типа lnumber
, това означава, че expression1
има и този тип, и expression2
има типа number
.
Обаче изразът (expression1) <(expression2)
винаги има типа number
, но техните дъщерни изрази могат да имат тип number
или въведете string
. В случай на този израз ще проверим дали и двата възела са числа. Ако е така, ще изградим expression1
и expression2
като expression
. В противен случай те ще бъдат от типа expression
.
Има още един проблем, който трябва да вземем предвид и да се справим.
Представете си дали трябва да изградим израз от типа number
. След това не можем да върнем нищо валидно, ако се натъкнем на оператор за конкатенация. Знаем, че това не може да се случи, тъй като вече проверихме типовете, когато изградихме дървото на израза (в предишната част), но това означава, че не можем да напишем функцията на шаблон, параметризирана с типа return, тъй като тя ще има невалидни клонове в зависимост на този тип връщане.
Един подход би разделил функцията по тип на връщане, използвайки if constexpr
, но е неефективен, защото ако една и съща операция съществува в множество клонове, ще трябва да повторим нейния код. В този случай бихме могли да напишем отделни функции.
Реализираното решение разделя функцията въз основа на типа възел. Във всеки от клоновете ще проверим дали този тип клонове е конвертируем към типа връщане на функцията. Ако не е, ще изхвърлим грешката на компилатора, защото никога не бива да се случва, но кодът е твърде сложен за толкова силна претенция. Може да съм допуснал грешка.
За проверка на конвертируемостта използваме следната самообяснителна структура с характерни черти:
template struct is_convertible std::is_same::value ) ); ;
След това разделяне кодът е почти ясен. Можем да прехвърлим семантично от оригиналния тип израз към този, който искаме да изградим, и няма грешки по време на метакомпилация.
Има много примерни кодове обаче, така че разчитах силно на макроси, за да го намаля.
template class expression_builder{ private: using expression_ptr = typename expression::ptr; static expression_ptr build_void_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_number_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_lnumber_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_string_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_lstring_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_array_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_larray_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_function_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_lfunction_expression( const node_ptr& np, compiler_context& context ); public: static expression_ptr build_expression( const node_ptr& np, compiler_context& context ) { return std::visit(overloaded{ [&](simple_type st){ switch (st) { case simple_type::number: if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(lnumber); } else { RETURN_EXPRESSION_OF_TYPE(number); } case simple_type::string: if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(lstring); } else { RETURN_EXPRESSION_OF_TYPE(string); } case simple_type::nothing: RETURN_EXPRESSION_OF_TYPE(void); } }, [&](const function_type& ft) { if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(lfunction); } else { RETURN_EXPRESSION_OF_TYPE(function); } }, [&](const array_type& at) { if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(larray); } else { RETURN_EXPRESSION_OF_TYPE(array); } } }, *np->get_type_id()); } };
Функцията build_expression
е единствената обществена функция тук. Той извиква функцията std::visit
за типа възел. Тази функция прилага подадения функтор на variant
, отделяйки го в процеса. Можете да прочетете повече за него и за функтора overloaded
тук .
Макросът RETURN_EXPRESSION_OF_TYPE
извиква частни функции за изграждане на изрази и извежда изключение, ако преобразуването не е възможно:
#define RETURN_EXPRESSION_OF_TYPE(T) if constexpr(is_convertible::value) { return build_##T##_expression(np, context); } else { throw expression_builder_error(); return expression_ptr(); }
Трябва да върнем празния указател в клона else, тъй като компилаторът не може да знае типа връщане на функцията в случай на невъзможно преобразуване; в противен случай, std::visit
изисква всички претоварени функции да имат един и същ тип връщане.
Има например функцията, която изгражда изрази с string
като тип връщане:
static expression_ptr build_string_expression( const node_ptr& np, compiler_context& context ) { if (std::holds_alternative(np->get_value())) { return std::make_unique( std::make_shared( std::get(np->get_value()) ) ); } CHECK_IDENTIFIER(lstring); switch (std::get(np->get_value())) { CHECK_BINARY_OPERATION(concat, string, string); CHECK_BINARY_OPERATION(comma, void, string); CHECK_TERNARY_OPERATION(ternary, number, string, string); CHECK_INDEX_OPERATION(lstring); CHECK_CALL_OPERATION(lstring); default: throw expression_builder_error(); } }
Той проверява дали възелът държи константа константа и изгражда constant_expression
ако случаят е такъв
След това проверява дали възелът съдържа идентификатор и връща глобален или локален израз на променлива от тип lstring в този случай. Той може да съдържа идентификатор, ако реализираме константни променливи. В противен случай се приема, че възелът задържа операцията на възела и опитва всички операции, които могат да върнат string
.
Ето изпълненията на CHECK_IDENTIFIER
и CHECK_BINARY_OPERATION
макроси:
#define CHECK_IDENTIFIER(T1) if (std::holds_alternative(np->get_value())) { const identifier& id = std::get(np->get_value()); const identifier_info* info = context.find(id.name); if (info->is_global()) { return std::make_unique< global_variable_expression>(info->index()); } else { return std::make_unique< local_variable_expression>(info->index()); } }
#define CHECK_BINARY_OPERATION(name, T1, T2) case node_operation::name: return expression_ptr( std::make_unique ( expression_builder::build_expression( np->get_children()[0], context ), expression_builder::build_expression( np->get_children()[1], context ) ) );
CHECK_IDENTIFIER
макроса трябва да се консултира compiler_context
за изграждане на глобален или локален израз на променлива с правилния индекс. Това е единственото използване на compiler_context
в тази структура.
Виждате, че CHECK_BINARY_OPERATION
рекурсивно извиква build_expression
за дъщерните възли.
В моята страница в GitHub , можете да получите пълния изходен код, да го компилирате и след това да въведете изрази и да видите резултата от оценените променливи.
Представям си, че във всички клонове на човешкото творчество има момент, в който авторът осъзнава, че техният продукт е жив, в някакъв смисъл. При изграждането на език за програмиране е моментът, в който можете да видите, че езикът „диша“.
В следващата и последна част от тази поредица ще внедрим останалата част от минималния набор от езикови функции, за да я видим на живо.
Макросът е правило, което обяснява как даден вход може да бъде съотнесен към изхода или към определени действия. Може да се използва за автоматизиране на някои общи работи.
Макросите в C ++ са наречени подобни на функции преобразувания, които описват как техните входни параметри се преобразуват в C ++ код.
Машината на Тюринг е абстрактна машина, която може да направи всичко, което съвременният компютър може. Езикът за програмиране е пълен с Тюринг, ако може да емулира машината на Тюринг.
Операторите GOTO се считат за лоши, защото внезапно нарушават нормалния програмен поток, правейки кода по-малко четим.
Има някои редки ситуации, когато GOTO оператор може да направи кода по-четлив: например прекъсване от два или повече вложени цикъла.