コウノトリ、パート3:式と変数の実装
公開: 2022-03-11シリーズのパート3では、軽量プログラミング言語がついに実行されます。 チューリング完全ではなく、強力ではありませんが、式を評価したり、C++で記述された外部関数を呼び出したりすることもできます。
このブログシリーズの目的であることが主な理由ですが、この部分では物事が少し複雑になったため、私自身のドキュメントでも、プロセスを可能な限り詳細に説明しようと思います。
2番目の記事が公開される前にこの部分のコーディングを開始しましたが、式パーサーは独自のブログ投稿に値するスタンドアロンコンポーネントである必要があることがわかりました。
それは、いくつかの悪名高いプログラミング手法とともに、この部分が途方もなく大きくならないようにすることを可能にしましたが、それでも、一部の読者は、おそらく前述のプログラミング手法を指摘し、なぜ私がそれらを使用しなければならなかったのか疑問に思います。
なぜマクロを使用するのですか?
さまざまなプロジェクトやさまざまな人々とのプログラミング経験を積むにつれて、開発者は非常に独断的である傾向があることを学びました。おそらく、その方が簡単だからです。
プログラミングの最初の教義は、 goto
ステートメントが悪く、邪悪で、恐ろしいということです。 私はその感情がどこから来ているのか理解でき、誰かがgoto
ステートメントを使用するほとんどの場合その概念に同意します。 通常は回避でき、代わりにもっと読みやすいコードを書くことができます。
ただし、C++の内部ループからの離脱はgoto
ステートメントで簡単に実行できることを否定することはできません。 bool
変数または専用関数を必要とする代替案は、禁止されているプログラミング手法のバケツに独断的に分類されるコードよりも読みにくくなる可能性があります。
CおよびC++開発者にのみ関連する2番目の教義は、マクロが悪い、悪い、ひどい、そして基本的には災害が起こるのを待っているということです。 これには、ほとんどの場合、次の例が伴います。
#define max(a, b) ((a) > (b) ? (a) : (b)) ... int x = 3; int z = 2; int y = max(x++, z);
そして、質問があります。このコードの後のx
の値は何ですかx
は2回インクリメントされ、 ?
の両側に1つずつあるため、答えは5
です。 -オペレーター。
唯一の問題は、このシナリオでは誰もマクロを使用しないことです。 マクロは、通常の関数が正常に機能するシナリオで使用される場合、特に関数のふりをする場合は悪であるため、ユーザーはその副作用に気づきません。 ただし、関数としては使用せず、関数ではないことを明確にするために名前にブロック文字を使用します。 それらを適切にデバッグすることはできません。それは悪いことですが、同じコードを何十回もコピーアンドペーストする方法があり、マクロよりもエラーが発生しやすいため、これで問題ありません。 この問題の解決策の1つは、コードジェネレーターを作成することですが、C ++に既に組み込まれているのに、なぜコードジェネレーターを作成する必要があるのでしょうか。
プログラミングの教義はほとんどの場合悪いです。 設定したばかりのドグマトラップに再帰的に陥らないようにするために、ここでは「ほぼ」を慎重に使用しています。
この部分のコードとすべてのマクロはここにあります。
変数
前のパートで、Storkはバイナリまたはアセンブリ言語に類似したものにコンパイルされないことを述べましたが、静的に型付けされた言語になることも述べました。 したがって、コンパイルされますが、実行可能なC++オブジェクトになります。 後で明らかになりますが、今のところ、すべての変数がそれ自体でオブジェクトになることを述べましょう。
それらをグローバル変数コンテナまたはスタックに保持したいので、1つの便利なアプローチは、基本クラスを定義してそれから継承することです。
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
であり、保持する型でパラメーター化されます。 使用する4つのタイプに対してインスタンス化されます。
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
関数は、スタックのサイズを1つ変更し、_stackの最後の要素のインデックスを_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>;
これは階層の一部であり(数値のみ)、すでに菱形の問題(同じ基本クラスを持つ2つのクラスを継承するクラス)に遭遇しました。
幸いなことに、C ++は仮想継承を提供します。これにより、継承されたクラスで基本クラスへのポインターを保持することにより、基本クラスから継承することができます。 したがって、クラスBとCが実質的にAから継承し、クラスDがBとCから継承する場合、DにはAのコピーが1つだけ存在します。
その場合、支払う必要のあるペナルティがいくつかあります。たとえば、パフォーマンスと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); } ... };
構文的には、これ以上求めることはありません。これは当然のことです。 ただし、内部式のいずれかが左辺値式である場合、それを評価するには2つの仮想関数呼び出しが必要になります。 完璧ではありませんが、ひどいことでもありません。
このミックスに文字列を追加して、それがどこに到達するかを見てみましょう。
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
virtualメソッドを再度オーバーライドする必要があります。そうしないと、数値から文字列への不要な変換により、深刻なパフォーマンスの問題が発生します。
ですから、物事は明らかに醜くなりつつあり、お互いに(両方の方法で)変換する必要のある2種類の表現がないため、私たちのデザインはほとんど生き残っていません。 その場合、または何らかの循環変換を試みた場合、階層はそれを処理できませんでした。 結局のところ、階層は、より弱い関係に変換可能ではなく、関係を反映する必要があります。
これらすべての失敗した試みは、私を複雑でありながら-私の意見では-適切な設計に導きました。 まず、単一の基本クラスを持つことは私たちにとって重要ではありません。 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; };
式の評価ごとに1つの仮想関数呼び出しのみがあり(もちろん、再帰的に呼び出す必要があります)、バイナリコードにコンパイルしないため、非常に良い結果が得られます。 残されているのは、許可されている場合のタイプ間の変換だけです。
これを実現するために、各式を戻り型でパラメーター化し、対応する基本クラスから継承します。 次に、 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
は、最初のパラメーターが2番目の型でパラメーター化されたvariable_impl
への共有ポインターである場合にのみ、trueと評価される内部定数value
を持つ型特性です。
convert
関数の実装は、古いバージョンのC ++でも可能ですが、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に基づく古い構文よりもはるかに読みやすくなっています。

式の種類の概要を簡単に説明し、技術的な詳細は省略して、適度に簡潔にします。
式ツリーには、次の3種類のリーフ式があります。
- グローバル変数式
- ローカル変数式
- 定式
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 ); } };
最初の引数は、インスタンス化されて評価のために呼び出されるファンクタータイプです。 残りのタイプは、子式の戻りタイプです。
ボイラープレートコードを減らすために、次の3つのマクロを定義します。
#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
を返す関数のクラス全体の特殊化を作成する必要がなくなります。
これで、実行時に使用できる式に関連するすべてのものができました。 残っているのは、解析された式ツリー(前のブログ投稿で説明)から式ツリーへの変換だけです。
式ビルダー
混乱を避けるために、言語開発サイクルのさまざまなフェーズに名前を付けましょう。

- メタコンパイル時:C++コンパイラが実行されるフェーズ
- コンパイル時: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) ...
1番目と3番目の式は、評価結果に対して何も行わないため、 void
型になっています。 ただし、2番目の式は、ループを停止するかどうかを決定するためにゼロと比較しているため、 number
があります。
ノード操作に関連する式のタイプがわかっている場合、通常はその子式のタイプを決定します。
たとえば、式(expression1) += (expression2)
のタイプがlnumber
の場合、これは、 expression1
にもそのタイプがあり、 expression2
のタイプnumber
がであることを意味します。
ただし、式(expression1) < (expression2)
には常にnumber
がありますが、その子式にはnumber
または型string
を含めることができます。 この式の場合、両方のノードが数値であるかどうかを確認します。 その場合、 expression1
とexpression2
をexpression<number>
として作成します。 それ以外の場合は、 expression<string>
型になります。
考慮して対処しなければならない別の問題があります。
タイプnumber
の式を作成する必要がある場合を想像してみてください。 次に、連結演算子に遭遇した場合、有効なものを返すことはできません。 式ツリーを構築したときに型をチェックしたので(前の部分で)、それは起こり得ないことはわかっていますが、これは、戻り型でパラメーター化されたテンプレート関数を記述できないことを意味します。そのリターンタイプで。
1つのアプローチでは、 if constexpr
を使用して関数を戻り型で分割しますが、同じ操作が複数のブランチに存在する場合、そのコードを繰り返す必要があるため、非効率的です。 その場合、別々の関数を書くことができます。
実装されたソリューションは、ノードタイプに基づいて関数を分割します。 各ブランチで、そのブランチタイプが関数returnタイプに変換可能かどうかを確認します。 そうでない場合は、発生しないはずなのでコンパイラエラーをスローしますが、コードが複雑すぎて、このような強力な主張はできません。 エラーが発生した可能性があります。
兌換性をチェックするために、次の自明の型特性構造を使用しています。
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_IDENTIFIERマクロとCHECK_BINARY_OPERATION
マクロの実装は次のCHECK_IDENTIFIER
です。
#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ページでは、完全なソースコードを取得してコンパイルし、式を入力して、評価された変数の結果を確認できます。
人間の創造性のすべての分野で、ある意味で、作者が自分たちの製品が生きていることに気付く瞬間があると思います。 プログラミング言語の構築において、それは言語が「呼吸する」ことを見ることができる瞬間です。
このシリーズの次の最後のパートでは、残りの最小限の言語機能セットを実装して、ライブで実行されることを確認します。