Stork,第 3 部分:实现表达式和变量

已发表: 2022-03-11

在我们系列的第 3 部分中,我们的轻量级编程语言最终将运行。 它不会是图灵完备的,它不会很强大,但它将能够评估表达式,甚至调用用 C++ 编写的外部函数。

我将尝试尽可能详细地描述该过程,主要是因为这是本系列博客的目的,但也是为了我自己的文档,因为在这一部分中,事情变得有些复杂。

我在第二篇文章发表之前就开始为这部分编码,但后来发现表达式解析器应该是一个独立的组件,值得拥有自己的博客文章。

再加上一些臭名昭著的编程技术,这使得这部分可能不会太大,然而,一些读者很可能会指出上述编程技术并想知道为什么我必须使用它们。

为什么我们使用宏?

随着我在不同项目和不同人的工作中获得编程经验,我了解到开发人员往往非常教条——可能是因为这样更容易。

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<variable>; class variable: public std::enable_shared_from_this<variable> { private: variable(const variable&) = delete; void operator=(const variable&) = delete; protected: variable() = default; public: virtual ~variable() = default; virtual variable_ptr clone() const = 0; template <typename T> T static_pointer_downcast() { return std::static_pointer_cast< variable_impl<typename T::element_type::value_type> >(shared_from_this()); } };

如您所见,它相当简单,并且执行深层复制的函数clone是它除了析构函数之外的唯一虚成员函数。

由于我们将始终通过shared_ptr使用此类的对象,因此从std::enable_shared_from_this继承它是有意义的,这样我们就可以轻松地从中获取共享指针。 函数static_pointer_downcast在这里是为了方便,因为我们经常不得不从这个类向下转换到它的实现。

这个类的真正实现是variable_impl ,参数化了它所持有的类型。 它将为我们将使用的四种类型实例化:

 using number = double; using string = std::shared_ptr<std::string>; using array = std::deque<variable_ptr>; using function = std::function<void(runtime_context&)>;

我们将使用double作为我们的数字类型。 字符串是引用计数的,因为它们是不可变的,以便在按值传递它们时启用某些优化。 数组将是std::deque ,因为它是稳定的,我们只需注意runtime_context是在运行时保存有关程序内存的所有相关信息的类。 我们稍后会谈到。

以下定义也经常使用:

 using lvalue = variable_ptr; using lnumber = std::shared_ptr<variable_impl<number>>; using lstring = std::shared_ptr<variable_impl<string>>; using larray = std::shared_ptr<variable_impl<array>>; using lfunction = std::shared_ptr<variable_impl<function>>;

这里使用的“l”是“lvalue”的缩写。 每当我们有某种类型的左值时,我们将使用指向variable_impl的共享指针。

运行时上下文

在运行时,内存状态保存在类runtime_context

 class runtime_context{ private: std::vector<variable_ptr> _globals; std::deque<variable_ptr> _stack; std::stack<size_t> _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<lnumber>(); } lstring evaluate_lstring(runtime_context& context) const { return evaluate(context)->static_pointer_downcast<lstring>(); } 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<expression>;

然后我们将从这个类中继承每个操作,例如加法、连接、函数调用等。例如,这将是加法表达式的实现:

 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<variable_impl<number> >( _expr1->evaluate_number(context) + _expr2->evaluate_number(context) ); } ... };

所以我们需要评估两边( _expr1_expr2 ),将它们相加,然后构造variable_impl<number>

我们可以安全地向下转换变量,因为我们在编译时检查了它们的类型,所以这不是这里的问题。 然而,最大的问题是我们为返回对象的堆分配付出的性能损失,理论上,这是不需要的。 我们这样做是为了满足虚函数声明。 在 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<lvalue_expression>; 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<number_expression>; 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<lnumber_expression>;

这只是层次结构的一部分(仅适用于数字),我们已经遇到了菱形的问题(类继承了具有相同基类的两个类)。

幸运的是,C++ 提供了虚拟继承,它提供了从基类继承的能力,方法是将指向它的指针保留在被继承的类中。 因此,如果类 B 和 C 虚拟继承自 A,而类 D 继承自 B 和 C,则 D 中将只有一个 A 的副本。

不过,在这种情况下,我们必须付出一些代价——性能和无法从 A 向下转型,仅举几例——但这看起来仍然是我第一次使用虚拟继承的机会我的生活。

现在,加法表达式的实现看起来会更自然:

 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<string_expression>;

由于我们希望将数字转换为字符串,因此我们需要从string_expression继承number_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<number_expression>;

我们幸免于难,但我们必须重新覆盖evaluate虚拟方法,否则由于从数字到字符串的不必要转换,我们将面临严重的性能问题。

所以,事情显然变得丑陋了,我们的设计几乎没有幸免于难,因为我们没有两种类型的表达式应该相互转换(两种方式)。 如果是这种情况,或者如果我们尝试进行任何类型的循环转换,我们的层次结构将无法处理它。 毕竟,层次结构应该反映 is-a 关系,而不是 is-convertible-to 关系,后者更弱。

所有这些不成功的尝试都让我做出了一个复杂但——在我看来——合适的设计。 首先,拥有一个单一的基类对我们来说并不重要。 我们需要计算结果为 void 的表达式类,但如果我们可以在编译时区分 void 表达式和其他类型的表达式,则无需在运行时在它们之间进行转换。 因此,我们将使用表达式的返回类型参数化基类。

这是该类的完整实现:

 template <typename R> class expression { expression(const expression&) = delete; void operator=(const expression&) = delete; protected: expression() = default; public: using ptr = std::unique_ptr<const expression>; virtual R evaluate(runtime_context& context) const = 0; virtual ~expression() = default; };

每次表达式求值我们将只有一个虚函数调用(当然,我们将不得不递归调用它),并且由于我们不编译为二进制代码,所以这是一个很好的结果。 唯一要做的就是在允许的情况下进行类型之间的转换。

为此,我们将使用返回类型对每个表达式进行参数化,并从相应的基类继承它。 然后,在evaluate函数中,我们将评估结果转换为该函数的返回值。

例如,这是我们的加法表达式:

 template <typename R> class add_expression: public expression<R> { ... R evaluate(runtime_context& context) const override{ return convert<R>( _expr1->evaluate(context) + _expr2->evaluate(context) ); } ... };

要编写“转换”函数,我们需要一些基础设施:

 template<class V, typename T> struct is_boxed { static const bool value = false; }; template<typename T> struct is_boxed<std::shared_ptr<variable_impl<T> >, 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::string>(std::move(str)); } string convert_to_string(const lnumber& v) { return convert_to_string(v->value); }

结构is_boxed是一个类型特征,它有一个内部常量value ,当(且仅当)第一个参数是指向variable_impl参数化的第二个类型的共享指针时,它的计算结果为 true。

即使在旧版本的 C++ 中也可以实现convert函数,但是在 C++17 中有一个非常有用的语句,称为if constexpr ,它在编译时评估条件。 如果它评估为false ,它将完全删除该块,即使它会导致编译时错误。 否则,它将丢弃else块。

 template<typename To, typename From> auto convert(From&& from) { if constexpr(std::is_convertible<From, To>::value) { return std::forward<From>(from); } else if constexpr(is_boxed<From, To>::value) { return unbox(std::forward<From>(from)); } else if constexpr(std::is_same<To, string>::value) { return convert_to_string(from); } else { static_assert(std::is_void<To>::value); } }

尝试阅读此功能:

  • 如果它在 C++ 中是可转换的,则转换(这是用于variable_impl指针向上转换)。
  • 如果已装箱,请取消装箱。
  • 如果目标类型是字符串,则转换为字符串。
  • 什么都不做,检查目标是否无效。

在我看来,这比基于 SFINAE 的旧语法更具可读性。

我将提供表达式类型的简要概述并省略一些技术细节以使其保持合理的简短。

表达式树中存在三种类型的叶表达式:

  • 全局变量表达式
  • 局部变量表达式
  • 常量表达式
template<typename R, typename T> class global_variable_expression: public expression<R> { private: int _idx; public: global_variable_expression(int idx) : _idx(idx) { } R evaluate(runtime_context& context) const override { return convert<R>( context.global(_idx) ->template static_pointer_downcast<T>() ); } };

除了返回类型外,它还使用变量类型进行参数化。 局部变量的处理方式类似,这是常量类:

 template<typename R, typename T> class constant_expression: public expression<R> { private: T _c; public: constant_expression(T c) : _c(std::move(c)) { } R evaluate(runtime_context& context) const override { return convert<R>(_c); } };

在这种情况下,我们立即在构造函数中转换常量。

这被用作我们大多数表达式的基类:

 template<class O, typename R, typename... Ts> class generic_expression: public expression<R> { private: std::tuple<typename expression<Ts>::ptr...> _exprs; template<typename... Exprs> R evaluate_tuple( runtime_context& context, const Exprs&... exprs ) const { return convert<R>(O()( std::move(exprs->evaluate(context))...) ); } public: generic_expression(typename expression<Ts>::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 <typename T1> \ auto operator()(T1 t1) {\ code;\ }\ };\ template<typename R, typename T1>\ using name##_expression = generic_expression<name##_op, R, T1>; #define BINARY_EXPRESSION(name, code)\ struct name##_op {\ template <typename T1, typename T2>\ auto operator()(T1 t1, T2 t2) {\ code;\ }\ };\ template<typename R, typename T1, typename T2>\ using name##_expression = generic_expression<name##_op, R, T1, T2>; #define TERNARY_EXPRESSION(name, code)\ struct name##_op {\ template <typename T1, typename T2, typename T3>\ auto operator()(T1 t1, T2 t2, T3 t3) {\ code;\ }\ };\ template<typename R, typename T1, typename T2, typename T3>\ using name##_expression = generic_expression<name##_op, R, T1, T2, T3>;

请注意, operator()被定义为模板,尽管通常不必如此。 以相同的方式定义所有表达式而不是提供参数类型作为宏参数更容易。

现在,我们可以定义大部分表达式。 例如,这是/=的定义:

 BINARY_EXPRESSION(div_assign, t1->value /= t2; return t1; );

我们可以使用这些宏定义几乎所有的表达式。 例外是定义了参数求值顺序的运算符(逻辑&&|| 、三元 ( ? ) 和逗号 ( , ) 运算符)、数组索引、函数调用和param_expression ,它们克隆参数以将其传递给函数按价值。

这些的实现没有什么复杂的。 函数调用实现是最复杂的,所以我在这里解释一下:

 template<typename R, typename T> class call_expression: public expression<R>{ private: expression<function>::ptr _fexpr; std::vector<expression<lvalue>::ptr> _exprs; public: call_expression( expression<function>::ptr fexpr, std::vector<expression<lvalue>::ptr> exprs ): _fexpr(std::move(fexpr)), _exprs(std::move(exprs)) { } R evaluate(runtime_context& context) const override { std::vector<variable_ptr> params; params.reserve(_exprs.size()); for (size_t i = 0; i < _exprs.size(); ++i) { params.push_back(_exprs[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<R, void>::value) { context.end_function(_exprs.size()); } else { return convert<R>( context.end_function( _exprs.size() )->template static_pointer_downcast<T>() ); } } };

它通过将所有评估参数压入其堆栈并调用call函数来准备runtime_context 。 然后它调用评估的第一个参数(即函数本身)并返回end_function方法的返回值。 我们也可以在这里看到if constexpr语法的用法。 它使我们不必为返回void的函数编写整个类的特化。

现在,我们拥有了与运行时可用的表达式相关的所有内容。 唯一剩下的就是从解析的表达式树(在上一篇博客文章中描述)到表达式树的转换。

表达式生成器

为避免混淆,让我们命名语言开发周期的不同阶段:

编程语言开发周期的不同阶段
  • Meta-compile-time:C++编译器运行的阶段
  • Compile-time:Stork 编译器运行的阶段
  • 运行时:Stork 脚本运行的阶段

这是表达式生成器的伪代码:

 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 循环:

 for (expression1; expression2; expression3) ...

第一个和第三个表达式的返回类型为void ,因为我们不对它们的求值结果做任何事情。 然而,第二个表达式有一个类型number ,因为我们将它与零进行比较,以决定是否停止循环。

如果我们知道与节点操作相关的表达式的类型,它通常会确定其子表达式的类型。

例如,如果表达式(expression1) += (expression2)的类型为lnumber ,这意味着expression1也具有该类型,而expression2的类型number

但是,表达式(expression1) < (expression2)始终具有类型number ,但它们的子表达式可以具有类型number或类型string 。 在这个表达式的情况下,我们将检查两个节点是否都是数字。 如果是这样,我们将expression1expression2构建为expression<number> 。 否则,它们将是expression<string>类型。

还有一个问题是我们必须考虑和处理的。

想象一下,如果我们需要构建一个number类型的表达式。 然后,如果遇到连接运算符,我们将无法返回任何有效的东西。 我们知道这不可能发生,因为我们在构建表达式树时已经检查了类型(在上一部分中),但这意味着我们不能编写模板函数,用返回类型参数化,因为它会有无效的分支取决于在该返回类型上。

一种方法是使用if constexpr按返回类型拆分函数,但效率低下,因为如果相同的操作存在于多个分支中,我们将不得不重复其代码。 在这种情况下,我们可以编写单独的函数。

实现的解决方案根据节点类型拆分功能。 在每个分支中,我们将检查该分支类型是否可转换为函数返回类型。 如果不是,我们将抛出编译器错误,因为它不应该发生,但是代码对于如此强烈的声明来说太复杂了。 我可能犯了一个错误。

我们使用以下不言自明的类型特征结构来检查可转换性:

 template<typename From, typename To> struct is_convertible { static const bool value = std::is_convertible<From, To>::value || is_boxed<From, To>::value || ( std::is_same<To, string>::value && ( std::is_same<From, number>::value || std::is_same<From, lnumber>::value ) ); };

拆分之后,代码几乎很简单。 我们可以在语义上从原始表达式类型转换为我们想要构建的类型,并且元编译时没有错误。

然而,有很多样板代码,所以我严重依赖宏来减少它。

 template<typename R> class expression_builder{ private: using expression_ptr = typename expression<R>::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<T, R>::value) {\ return build_##T##_expression(np, context);\ } else {\ throw expression_builder_error();\ return expression_ptr();\ }

我们必须在 else-branch 中返回空指针,因为在不可能转换的情况下编译器无法知道函数的返回类型; 否则, std::visit要求所有重载函数具有相同的返回类型。

例如,有一个函数使用string作为返回类型来构建表达式:

 static expression_ptr build_string_expression( const node_ptr& np, compiler_context& context ) { if (std::holds_alternative<std::string>(np->get_value())) { return std::make_unique<constant_expression<R, string>>( std::make_shared<std::string>( std::get<std::string>(np->get_value()) ) ); } CHECK_IDENTIFIER(lstring); switch (std::get<node_operation>(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_IDENTIFIERCHECK_BINARY_OPERATION宏的实现:

 #define CHECK_IDENTIFIER(T1)\ if (std::holds_alternative<identifier>(np->get_value())) {\ const identifier& id = std::get<identifier>(np->get_value());\ const identifier_info* info = context.find(id.name);\ if (info->is_global()) {\ return std::make_unique<\ global_variable_expression<R, T1>\ >(info->index());\ } else {\ return std::make_unique<\ local_variable_expression<R, T1>\ >(info->index());\ }\ }
 #define CHECK_BINARY_OPERATION(name, T1, T2)\ case node_operation::name:\ return expression_ptr(\ std::make_unique<name##_expression<R, T1, T2> > (\ expression_builder<T1>::build_expression(\ np->get_children()[0], context\ ),\ expression_builder<T2>::build_expression(\ np->get_children()[1], context\ )\ )\ );

CHECK_IDENTIFIER宏必须参考compiler_context来构建具有适当索引的全局或局部变量表达式。 这是此结构中compiler_context的唯一用法。

您可以看到CHECK_BINARY_OPERATION递归地为子节点调用build_expression

包起来

在我的 GitHub 页面上,您可以获得完整的源代码,编译它,然后输入表达式并查看评估变量的结果。

我想,在人类创造力的所有分支中,都有一段时间作者意识到他们的产品在某种意义上是有生命的。 在编程语言的构建中,你可以看到语言“呼吸”的时刻。

在本系列的下一部分和最后一部分中,我们将实现其余的最小语言功能集,以使其实时运行。