MQLによるMQLの構文解析
はじめに
プログラミングとは基本的に、汎用または特殊目的の言語を使用して一部のプロセスを形式化し自動化することです。MetaTrader取引プラットフォームでは、トレーダーのさまざまな問題を解決するために、組み込みMQL言語を使用したプログラミングを適用することができます。通常、コーディングのプロセスは、ソースコードで指定された規則に従ったアプリケーションデータの分析および処理に基づいています。しかしながら、ソースコード自体を分析し処理する必要性が時々生じます。下記は例です。
最も一貫性があり一般的なタスクの1つは、ソースコードベースでのコンテキスト検索とセマンティック検索です。確かに、ソースコード内の文字列は通常のテキストのように検索できますが、この場合、求められている意味が失われてしまいます。結局のところ、ソースコードの場合、それぞれの特定の場合での部分文字列の使用の詳細を区別することが望ましいものです。プログラマが「notification」のように特定の変数が使用されている場所を見つけたい場合、名前による単純検索では、この文字列のメソッド名やリテラルなどの他の値、またはコメント内での使用が必要以上に多く返されることがあります。
大規模プロジェクトでは原則としてコード構造、依存関係、クラス階層の特定がより複雑でありより求められています。それはコードのリファクタリングや改善およびコード生成を可能にするメタプログラミングと密接に関係しています。MetaEditorには、コード生成、特にウィザードを使用してエキスパートアドバイザーのソースコード作成したりソースコードでヘッダファイルを作成したりする機会がありますが、このテクノロジの可能性ははるかに強力なものです。
コード構造分析を使用すると、さまざまな品質指標および統計情報を計算したり、コンパイラでは検出できないランタイムエラーの典型的な原因を特定したりできます。実際、言うまでもなく、コンパイラ自体はソースコードを分析する最初のツールであり、さまざまな種類の警告を返します。しかし、すべての潜在的なエラーの確認は通常コンパイラには組み込まれていません。このタスクは大きすぎるので、通常それは別のプログラムに割り当てられています。
さらに、ソースコードの解析は、スタイル設定(フォーマット)と難読化に使用されます。
上記の問題を実装する多くのツールは産業用プログラミング言語では利用可能ですが、MQLではその選択肢は限られています。ここでは、微調整によってMQLをC ++と同じレベルに置き、利用可能な手段でMQLの分析を試みます。これはDoxygenなどのいくつかのツールでは非常に簡単に機能しますが、結果的にMQLはC ++ではないため、lintなどのより強力な手段を使うためにはより多くの適応が必要です。
本稿では静的コード分析についてのみ説明していますが、動的アナライザーを使用すると、メモリ操作エラー、ワークフローのロック、変数値の正確さなど多数の事項を仮想環境で動的追跡することができます。
ソースコードの静的分析を実行するためには様々な手法を使用することができます。MQLプログラムの入力変数の検索などの単純な問題については、正規表現ライブラリを使用すれば十分です。より一般的に言えば、分析はMQL文法を考慮したパーサに基づいていなければなりません。本稿で検討するのはこのアプローチで、その実用的な適用を試みます。
言い換えれば、MQLでMQLパーサを書いてソースコードのメタデータを取得することになります。これによって上記の問題が解決され、今後のいくつかの他の素晴らしい挑戦も提供されるでしょう。したがって、完全に正しいパーサがあれば、MQLインタプリタを開発したり、MQLと他の取引言語間を自動的に変換したりできます(いわゆるトランスパイル)。しかし、私が「素晴らしい」という言葉を使用したのには理由があります。これらのテクニックはすべて他の分野ですでに広く使用されていますが、MetaTraderプラットフォームでアプローチするには、まずその基礎についての洞察を得る必要があります。
技術レビュー
パーサにはさまざまなものがあります。ここでは技術的な詳細については説明しませんが、Wikipediaには紹介的な情報があり、高度な研究のためには膨大な量のリソースが開発されています。
パーサがいわゆる言語記述文法に基づいて機能することには留意するべきです。文法を記述する最も一般的な形式の1つはBackus-Naur(BNF)です。BNFを修正したものは多数ありますが、ここではそれほど多くの詳細には触れず、基本的な点だけを検討します。
BNFでは、すべての言語構造はいわゆる非終端記号によって定義されますが、分割不可能なエンティティは終端記号です。ここで、「終端」とは、テキスト、すなわち、「そのまま」のソースコードの断片を含み、全体として解釈されるトークンを解析する際の最終点を意味します。コンマの文字、大括弧、または単一の単語などが例です。文法での終端のリスト(すなわちアルファベット)は自分で定義します。いくつかの規則に基づいて、プログラムの他のすべてのコンポーネントは終端で作られています。
例えば、プログラムが演算子で構成されることは単純化されたBNF表記法で次のように指定できます。
program ::= operator more_operators
more_operators ::= program | {empty}
ここでは、非終端の「program」は1つ以上の演算子で構成され、後続の演算子は「program」への再帰的リンクを使用して記述されていることが示されています。文字「|」(BNFでは鍵括弧なし)は論理和を意味し、オプションの1つが選択されます。再帰を完了するために、特別な終端「{empty}」が上記のエントリで使用され、空の文字列または規則をスキップするオプションとして表すことができます。
文字「operator」も非終端記号であり、他の非終端記号および終端記号を介した「展開」を必要とします。例:
operator ::= name '=' expression ';'
このエントリは、各演算子が変数名で始まり、記号「=」と式が続いて文字「;」で終わることを定義します。文字「=」と「;」は終端です。名前は文字で構成されています。
name ::= letter more_letters more_letters ::= name | {empty} letter ::= [A-Z]
ここでは「A」から「Z」までの任意の文字を文字として使用できます(それらのセットは角括弧で示されています)。
式が演算対象(オペランド)と算術演算子で構成されているとします。
expression ::= operand more_operands more_operands ::= operation expression | {empty}
最も単純な式は単一のオペランドだけで構成されています。ただし、それ以上の演算対象(more_operands)がある可能性があり、それらは演算文字を介して副次式として追加されます。演算対象を変数または数値への参照にできるようにする一方、演算は「+」または「-」にすることができます。
operand ::= name | number number ::= digit more_digits more_digits ::= number | {empty} digit ::= [0-9] operation ::= '+' | '-'
以上のように、数値と変数を使用して計算を行うことができる単純な言語の文法について説明しました。
A = 10; X = A - 5;
テキストの解析を開始することは、実際には、どの規則が機能し、どの規則が機能しないのかを確認することです。機能した規則は遅かれ早かれ「production」を生成するはずです。テキストの現在の位置にあるコンテンツと一致する終端記号を見つけ、カーソルを次の位置に移動します。このプロセスは、テキスト全体がフラグメントごとに非終端記号に関連付けられ、文法で許可されているシーケンスが形成されるまで繰り返されます。
上記の例では、入力に文字「A」を持つパーサは、次に概説するようは規則の表示を開始します。
program operator name letter 'A'
そして、最初の一致を見つけます。カーソルが次の文字「=」に移動します。「letter」が1文字なので、パーサは規則「name」に戻ります。「name」は文字のみで構成できるため、オプションmore_lettersは機能せず({empty}と等しくなるように選択されます)、パーサは規則「operator」に戻ります。ここでは終端の「=」がnameに続きます。これは2番目の一致になります。次に、規則「expression」を展開して、パーサは演算対象(整数10(2桁のシーケンスとして))を見つけ、最後にセミコロンで最初の文字列の構文解析が完了します。その結果に続いて、実際には、変数名、式の内容、つまりそれが1つの数字で構成されているという事実、およびその値がわかります。2番目の文字列も同様に解析されます。
同じ言語の文法は異なる方法で記録することができ、後者は形式的に互いに一致することに注意することが重要です。ただし、場合によっては、文字通り規則に従うと特定の問題が発生する可能性があります。例えば、数字は次のように表すことができます。
number ::= number digit | {empty}
このエントリの形式は左再帰と呼ばれています。非終端記号の「number」が左側と右側の両方にあり、その「production」の規則を決定し、stringの一番初め(左)にあります(したがって「左」再帰)。これは最も単純で明示的な左再帰です。しかし、非終端記号がいくつかの中間規則の後で文字列に展開される場合は、その非終端記号で始まることが暗黙的になることがあります。
左再帰は、プログラミング言語の文法の正式なBNF表記法でしばしば発生します。ただし、実装によっては、一部の種類のパーサが同様の規則でループに詰まることがあります。実際、規則をアクションのガイドライン(構文解析アルゴリズム)と見なした場合、このエントリは入力ストリームから新しい終端記号を読み込まずに何度も何度も再帰的に「number」に入ることになります。これは、理論的には、非終端記号の「digit」が展開されてから起こるべきです。
ここでは、MQL文法をゼロからではなくできる限りC ++文法のBNF記法を使って作成しようとしているため左再帰に注意を払う必要があり、規則は別の方法で書き直されることになります。同時に、無限ループに対する保護を実装する必要があります。さらに詳しく説明するように、C ++言語またはMQL型言語の文法は非常に分枝しているため、手動で正しさをチェックすることは不可能であるように思われます。
ここで、パーサを書くことは本当の科学であることに注意することは適切です。そして、このドメインを単純なものから複雑なものに徐々にマスターしていくようお勧めします。最も単純なものは再帰降下パーサです。入力テキスト全体が、文法の最初の非終端記号に対応すると見なされます。上記の例では、それは非終端の「program」でした。それぞれの適切な規則に従って、パーサは終端と一致するために入力文字のシーケンスをチェックし、一致を見つけるとテキストに沿って移動します。構文解析中に不一致を見つけた場合は、代替手段が指定されていた規則にロールバックして、考えられるすべての言語構造をチェックします。このアルゴリズムは、上の例で純粋に理論的に実行した操作を完全に繰り返します。
「ロールバック」操作は「バックトラッキング」と呼ばれ、応答速度に悪影響を及ぼす可能性があります。したがって、最悪のシナリオでは、古典的な降下パーサはテキストを見るとき指数関数的に増加する数のオプションを生成します。この問題を解決するには、バックトラッキングを必要としない予測パーサなどのさまざまな選択肢があります。その操作時間は線形です。
ただし、これは文法の場合にのみ可能です。文法の場合、定義された数の後続文字kによって「production」の規則が明確に選択されます。このようなより高度なパーサは、文法のすべての規則に基づいて事前に計算される特別な遷移表に基づいています。これらには、LLパーサおよびLRパーサが含まれますが、これらに限定されません。
LLは左から右への「左端の導出」を表します。これは、テキストと規則が左から右へと表示されることを意味します。これは、トップダウンの結論(一般的なものから特定のものへ)に相当します。この意味では、LLは降下パーサと同類です。
LRは左から右への「右端の導出」を表します。テキストは以前と同様に左から右に表示されますが、規則は右から左に表示されます。これは、ボトムアップ、すなわち、単一の文字からより大きな非終端文字に向かって言語構造を形成することに相当します。その上、LRは左再帰に関する問題が少ないです。
パーサLL(k)とLR(k)では、通常、文字数kが先読みとして指定されており、それを超えるとテキストが前方に表示されます。ほとんどの場合、k = 1を選択すれば十分です。ただし、この十分さは大まかです。問題は、C ++やMQLを含む現代のプログラミング言語の多くは、文脈自由文法を持つ言語ではないということです。言い換えれば、テキストの同じ断片が文脈に応じて異なって解釈される可能性があります。そのような場合、書かれているもののメッセージに関して決定を下すには、プリプロセッサやシンボルテーブル(すでに識別されている識別子とその意味)などの他のツールとパーサを結びつけなければならないので、いかなる文字数でも十分ではありません。
C ++言語には、あいまいさの原型的なケースがあります(MQLにも当てはまります)。下の式はどういう意味ですか。
x * y;
これは変数xとyの積かもしれませんが、x型ポインターとしての変数yの記述である可能性があります。乗算の演算は多重定義されて副作用が生じる可能性があるため、もしこれが乗算である場合、乗算の積がどこにも保存されないことを恥じてはいけません。
過去にほとんどのC ++コンパイラが抱えてきたもう1つの問題は、2つの連続した文字「>」の解釈があいまいなことです。問題は、テンプレートの紹介によって次のタイプの構造がソースコードに現れ始めたことです。
vector<pair<int,int>> v;
シーケンス「>>」は、最初はシフト演算子として定義されていました。しばらくの間、そのような特定のケースのためのより良いプロセスが導入されるまで、同様の式にはスペースを入れなければなりませんでした。
vector<pair<int,int> > v;
この問題はここでのパーサでも回避しなければなりません。
一般的に、この簡単な紹介からでさえも、高度なパーサの記述と実装は、範囲の概説とそれらを習得するのにかかる時間の両方の観点から、より多くの努力を必要とすることが明らかです。したがって、本稿では、最も単純な再帰降下パーサに焦点を絞ります。計画
したがって、パーサの仕事は、入力に与えられたテキストを読み、それを不可分のフラグメント(トークン)のストリームに分割し、BNF記法またはそれに似た記法でMQL文法を使って記述された許可言語構造と比較することです。
まず始めに、ファイルを読み込むクラスが必要になります。これをFileReaderと名付けます。MQLプロジェクトはディレクティブ#includeを使用してメインのファイルからインクルードされたいくつかのファイルで構成されることがあるので、多くのFileReaderインスタンスを持つ必要があるかもしれません。
大まかに言って、処理されるファイルからのテキストは標準的な文字列です。しかし、残念ながらMQLは文字列ポインタを許可していませんが、異なるクラス間で転送する必要があります(参照については覚えていますが、クラスメンバーの宣言には使用できません。残る唯一の方法は入力を介してすべてのメソッドに参照を渡すことですが、これは難しいです)。したがって、文字列の折り返しを表す別のクラス「Source」を作成します。それは別の重要な機能を実行します。
問題は、「includes」を接続した結果(したがって、依存関係からヘッダファイルを再帰的に読み取った場合)、コントローラの出力ですべてのファイルから統合テキストが取得されることです。エラーを検出するには、統合されたソースコードのシフトを使用して、テキストが取得された元のファイルの名前と文字列を取得する必要があります。ファイル内のソースコードの場所のこの「マップ」は、Sourceクラスによってもサポートおよび保存されます。
ここで、ソースコードを結合するのではなく各ファイルを個別に処理することは不可能であったかという、適切な質問が起こります。個別の処理はおそらくより正しかったでしょう。ただし、その場合、ファイルごとにパーサインスタンスを作成してから、出力でパーサによって生成された構文木を何らかの形でクロスリンクする必要があります。したがって、私はソースコードを組み合わせて単一のパーサに送ることにしました。お望みならば、代わりの方法をお試しください。
FileReaderControllerが#includeディレクティブを見つけられるようにするには、ファイルからテキストを読み取るだけでなく、それらのディレクティブを検索する際にプレビューを実行する必要もあります。したがって、一種のプリプロセッサが必要です。MQLでは、これは他にも役立ちます。特に、マクロを識別してから実際の式に置き換えることができます(さらに、マクロからマクロを呼び出すという再帰の可能性も考慮されています)。しかし、最初のMQL解析プロジェクトで手を広げすぎないようにすることをお勧めします。したがって、マクロをプリプロセッサで処理することはありません。マクロの文法を追加で記述するだけでなく、実行中にそれらを解釈して、ソースコードで正しい式を代入する必要もあります。インタプリタについて序論でお話ししたことを覚えていらっしゃれば、ここで役に立つでしょう。これがなぜ重要であるのかは後で明らかになるでしょう。これは独立した第2の実験の分野です。
プリプロセッサはクラスPreprocessorに実装されます。そのレベルでのプロセスは、かなり物議を醸すものです。ファイルを読み込んでその中の#includeディレクティブを検索している間、テキスト内での構文解析と移動は、文字ごとに最も低いレベルで実行されます。ただし、プリプロセッサは、ディレクティブではないものすべてを「透過的に」通過し、出力時に最大のブロック(ディレクティブ間のファイル全体またはファイルフラグメント)を処理します。それからは、構文解析は中間レベルで継続しますが、これを説明するために、いくつかの用語を紹介する必要があります。
まず第一に、字句単位とは、字句解析の抽象最小単位で、長さがゼロ以外の部分文字列です。別の用語がしばしばそれと一緒に使用されます。抽象的ではなく具体的な、別の分析単位であるトークンです。どちらも、個々の文字、単語、さらにはコメントブロックなど、テキストの断片を表します。両者の細かい違いは、トークンレベルではフラグメントにマークを付けることです。例えば、テキストに「int」という語が含まれている場合、それはMQLの字句単位です。これは、MQL言語で許可されているすべてのトークンの列挙内の要素で、トークンINTとして表します。言い換えれば、字句単位のセットは、トークンタイプに対応する文字列の辞書を意味するものとします。
トークンの長所の1つは、テキストを文字よりも大きいフラグメントに分割できることです。その結果、テキストは2つのパスで解析されます。最初に、高レベルのトークンが一連の文字から形成され、次にそれらに基づいて言語構造が解析されます。これにより、言語の文法とパーサの操作をかなり簡単にすることができます。
特別クラス「Scanner」はテキストの中のトークンを強調表示します。これは、事前定義されたハードワイヤード文法でテキストを文字単位で処理する、低レベルのパーサと見なすことができます。必要とされるであろうトークンの正確なタイプは以下で考察されます。第1の実験(各ファイルを専用のパーサにロードする)を試みるならば、ここではプリプロセッサをスキャナと組み合わせることができて、トークン「#include <何か>」見つかったらすぐに、新しいFileReader、スキャナ、パーサを作成し、制御を渡します。
句読点や演算の記号だけでなく、すべてのキーワード(予約語)はMQLのトークンになります。MQLキーワードの全リストはファイルreserved.txtに添付されており、スキャナのソースコードに含まれています。
識別子、数字、文字列、リテラル、日付などの他の定数も独立したトークンになります。
テキストをトークンに解析するとき、すべてのスペース、改行、タブは無視されます。改行は例外として特別に処理されるべきです。なぜなら、改行を数えればエラーがあった場合にそれを含む行を指摘することができるからです。
したがって、スキャナの入力に統合されたテキストを入力すると、出力にトークンのリストが表示されます。Parserクラスで実装するのは、パーサによって処理されるトークンのリストです。
トークンをMQL規則で解釈するには、BNF表記で文法をパーサに渡す必要があります。文法を説明するために、パーサboost::spirit で使用されているアプローチを単純化した形で繰り返してみましょう。基本的に、文法規則は、いくつかの演算子を多重定義するため、MQL式の式を使用して記述されるものとします。
この目的のために、Terminalクラス、NonTerminalクラス、およびそれらの派生クラスの階層を紹介しましょう。Terminalは、デフォルトでユニットトークンに対応する基本クラスになります。理論部分で述べたように、終端記号は規則を解析するための有限の不可分の要素です。文字が現在のテキストの位置に見つかった場合、それは終端トークンと一致して、それはその文字が文法と一致することを意味します。それを読んで先に進むことができます。
終端記号と他の非終端記号をさまざまな組み合わせでできる複雑な構造には、NonTerminalを使用します。これは例で示すことができます。
整数と演算の「plus」(「+」)と「multiply」(「*」)だけが利用できる、式を計算するための単純な文法を記述する必要があるとします。簡潔化のために、ここでは10+1や5*6のように演算対象が2つしかないシナリオのみに的を絞ります。
このタスクに基づいて、まず第一に、整数に対応する終端記号を識別することが必要です。式の中の有効な演算対象と比較されるのは、この終端記号です。スキャナがテキスト内で整数を見つけるたびに、関連するトークンCONST_INTEGERが生成されるので、そのトークンを参照してTerminalクラスのオブジェクトを定義しましょう。疑似コードでは、これは次のようになります。
Terminal value = CONST_INTEGER;
このエントリは、トークン「integer」に関連付けられたTerminalクラスのvalueオブジェクトを作成したことを意味します。
演算の記号は、単一の文字「+」と「*」のためにスキャナによって生成された、関連するトークンPLUSとSTARを持つ終端記号でもあります。
Terminal plus = PLUS; Terminal star = STAR;
式の中でいずれかを使用できるようにするために、2つの操作をORで結合した非終端記号を導入しましょう。
NonTerminal operation = plus | star;
ここで、演算子の多重定義が問題になります。Terminalクラス(およびそのすべての子孫)では、operator「 |」は親オブジェクト(この場合は「operation」)から子孫オブジェクト(「plus」および「star」)への参照を作成し、それらを使用して演算の種類を論理和に指定する必要があります。
パーサがカーソル下のテキストと一致するかどうか非終端の「operation」のチェックを開始すると、それはさらなるチェック(「depth」)をオブジェクト「operation」に委譲し、このオブジェクトはループで子孫要素「plus」と「star」のチェックを呼び出します(ORであるため最初の一致まで)。それらは終端記号なので、単にそれらのトークンをパーサに返し、後者はテキストの中の文字が操作の一つと一致するかどうかがわかることになります。
式は、それらの間のいくつかの値と演算で構成されます。したがって、式も非終端であり、それは終端記号と非終端記号を介して「展開」される必要があります。
NonTerminal expression = value + operation + value;
ここでは、演算子+を多重定義しています。つまり、演算対象は指定された順序で互いを従わなければなりません。繰り返しになりますが、演算タイプが論理積であるため、この関数の実装は、親の非終端記号である「expression」が、子孫オブジェクト「value」、「operation」、および別の「value」への参照を保存する必要があることを意味します。実際、この場合、すべての構成要素が利用可能である場合に限って規則に従うものとします。
テキストが正しい式に対応するかどうかをパーサでチェックするには、まず参照の「expression」配列、次にオブジェクト「value」と「operation」(後者は再帰的に「plus」と「minus」を参照します)、そして最後にもう一度「value」をチェックすることが必要です。いずれの段階でも、チェックが終端レベルまで下がると、トークンの値がテキスト内の現在の文字と比較され、それらが一致すると、カーソルは次のトークンに移動します。 そうでない場合は、代替を検索する必要があります。例えば、この場合、plus演算のチェックに失敗した場合、「star」のチェックは続行されます。すべての選択肢が使い尽くされ、一致が見つからなかった場合、これは文法規則に違反していることを意味します。
クラス内で多重定義する演算子は「|」と「+」だけではありません。実装の章ではそれらを完全に説明します。
Terminalクラスとその派生クラスへの参照を含む、より小さなオブジェクトを宣言すると、事前定義文法の抽象構文木(AST)が形成されます。入力テキストの特定のトークンとは関連付けられていないため、これは抽象的です。つまり、理論的には、文法は無限の有効な文字列、つまりMQLコードを表しています。
結果として、一般的にプロジェクトの主なクラスを検討しました。全体像の想像を容易にするために、それらをクラスのUML図で要約しましょう。
MQL構文解析クラスのUML図
TreeNodeなど、一部のクラスはまだ考慮されていません。パーサは、入力テキストの構文解析中にそのオブジェクトを使用して、見つかったすべての一致「terminal = token」を保存します。その結果、出力でいわゆる具象構文木(CST)が得られます。そこでは、すべてのトークンが文法の終端と非終端に階層的に含まれています。
実際のソースコードの木は大きすぎる可能性があるため、原則として、木の作成はオプションです。構文解析出力を木として取得する代わりに、コールバックインターフェイス(Callback)を提供します。このインターフェイスを実装するオブジェクトを作成したら、それをパーサに渡して、生成された各「production」、つまり機能した各文法規則に関する通知を受け取ることができます。このようにして、完全な木を待たずに、構文と意味を「on-the-go(移動している状態)」で分析することができます。
接頭辞が「Hidden」である非終端クラスは、文法オブジェクトの中間グループを自動的に作成するために使用されます。これについては次のセクションで詳しく説明します。
実装
ファイルの読み込み
Source
Sourceクラスはまず、処理されるテキストを含む文字列の格納場所です。基本的に、これは次のようになります。
#define SOURCE_LENGTH 100000 class Source { private: string source; public: Source(const uint length = SOURCE_LENGTH) { StringInit(source, length); } Source *operator+=(const string &x) { source += x; return &this; } Source *operator+=(const ushort x) { source += ShortToString(x); return &this; } ushort operator[](uint i) const { return source[i]; } string get(uint start = 0, uint length = -1) const { return StringSubstr(source, start, length); } uint length() const { return StringLen(source); } };
このクラスには、テキスト用のsource変数と、最も頻繁な文字列を使用した演算用に上書きされた演算子があります。このクラスの2番目の役割は。格納されている文字列を組み立てる元になるファイルのリストを管理することですが、今のところ、これはブラックボックスのままにしましょう。入力テキストをこのように「折り返す」ことで、1つのファイルから書き入れることができます。クラスFileReaderがこのタスクを担当します。
FileReader
開発を始める前に、ファイルを開いて読み取る方法を定義しておく必要があります。テキストを処理しているので、FILE_TXTモードを選択するのは論理的です。これで、他のすべてに加えて、異なるエディタで異なる方法でコーディングできる改行文字(通常、CR LFの2つの記号ですが、公に利用可能なMQLソースコードでは CRのみ、LFのみなどの選択肢が見られます)を手動で管理しないで済みます。テキストモードでは、ファイルは文字列ごとに読み込まれることに注意してください。
考慮すべきもう1つの問題は、さまざまなエンコーディングのテキストをサポートすることです。読み込まれるファイルの中には、シングルバイト文字列(ANSI)として保存されているものとより広いダブルバイト文字列(UNICODE)として保存されているものが両方ある場合があるので、システムにファイルごとに正しいモードを選択させる方が良いでしょう。さらに、ファイルはUTF-8エンコーディングで保存される場合もあります。
以下の入力が関数FileOpenに設定されている場合、MQLはさまざまなテキストファイルを正しいエンコーディングで自動的に読み取ることができます。
FileOpen(filename, FILE_READ | FILE_TXT | FILE_ANSI, 0, CP_UTF8);
その後、デフォルトでFILE_SHARE_READ | FILE_SHARE_WRITEフラグを追加したこの組み合わせを使用します。
FileReaderクラスでは、ファイル名(「filename」)、開いているファイルの記述子(「handle」)、現在のテキスト行(「line」)を格納するためのメンバを追加します。
class FileReader { protected: const string filename; int handle; string line;
さらに、現在の行番号と行内のカーソル位置(列)を追跡します。
int linenumber; int cursor;
読み取り行をSourceオブジェクトのインスタンスに保存します。
Source *text;
データを受け取るためのファイル名、フラグ、準備完了のSourceオブジェクトをコンストラクタに渡します。
public: FileReader(const string _filename, Source *container = NULL, const int flags = FILE_READ | FILE_TXT | FILE_ANSI | FILE_SHARE_READ | FILE_SHARE_WRITE, const uint codepage = CP_UTF8): filename(_filename) { handle = FileOpen(filename, flags, 0, codepage); if(handle == INVALID_HANDLE) { Print("FileOpen failed ", _filename, " ", GetLastError()); } line = NULL; cursor = 0; linenumber = 0; text = container; } string pathname() const { return filename; }
ファイルが正常に開かれたかどうかを確認し、デストラクタでディスクリプタを閉じます。
bool isOK() { return (handle > 0); } ~FileReader() { FileClose(handle); }
ファイルからデータを文字単位で読み取るには、getCharメソッドを使用します。
ushort getChar(const bool autonextline = true) { if(cursor >= StringLen(line)) { if(autonextline) { if(!scanLine()) return 0; cursor = 0; } else { return 0; } } return StringGetCharacter(line, cursor++); }
テキスト「line」を含む文字列が空の場合、または最後まで読み取られた場合、このメソッドはscanLineメソッドを使用して次の文字列の読み取りを試みます。line文字列に未処理の文字が含まれている場合、getCharは単にカーソル下の文字を返してから、カーソルを次の位置に移動します。
scanLineメソッドは明白な方法で定義されます。
bool scanLine() { if(!FileIsEnding(handle)) { line = FileReadString(handle); linenumber++; cursor = 0; if(text != NULL) { text += line; text += '\n'; } return true; } return false; }
ファイルはテキストモードで開かれているので、改行は返されません。 ただし、1行コメントなど、いくつかの言語構造の最終的な兆候として、行数を数える必要があります。そのため、記号「\n」を追加します。
そのようにファイルからデータを読み込むのと同時に、FileReaderクラスはカーソル下の入力データを字句単位と比較できるようにしなければなりません。そのために、以下のメソッドを追加しましょう。
bool probe(const string lexeme) const { return StringFind(line, lexeme, cursor) == cursor; } bool match(const string lexeme) const { ushort c = StringGetCharacter(line, cursor + StringLen(lexeme)); return probe(lexeme) && (c == ' ' || c == '\t' || c == 0); } bool consume(const string lexeme) { if(match(lexeme)) { advance(StringLen(lexeme)); return true; } return false; } void advance(const int next) { cursor += next; if(cursor > StringLen(line)) { error(StringFormat("line is out of bounds [%d+%d]", cursor, next)); } }
probeメソッドは、テキストと渡された字句単位を比較します。matchメソッドは実質的に同じですが、さらに字句単位が単一の単語として言及されていることを確認します。つまり、スペース、タブ、改行などの区切り文字が後に続かなければなりません。consumeメソッドは、渡された字句単位/単語を「消費」し、すなわち、入力テキストが事前定義テキストと一致することを確認し、成功した場合は、カーソルを字句単位の末尾に移動させます。失敗した場合、カーソルは移動しないで、メソッドは「false」を返します。advanceメソッドは単にカーソルを事前定義された文字数だけ前方に移動させます。
最後に、ファイル終了フラグを返す小さなメソッドを考えてみましょう。
bool isEOF() { return FileIsEnding(handle) && cursor >= StringLen(line); }
このクラスのフィールドを読むためには他のヘルパーメソッドがあります。これらは、添付のソースコードで見つけることができます。
FileReaderクラスのオブジェクトはどこかで作成する必要があります。それをFileReaderControllerクラスに委譲しましょう。FileReaderController
FileReaderControllerクラスでは、インクルードファイルのスタック(「includes」)、既にインクルードされているファイルのマップ(「files」)、現在処理中のファイルへのポインタ(「current」)、および入力テキスト(「source」)を維持する必要があります。
class FileReaderController { protected: Stack<FileReader *> includes; Map<string, FileReader *> files; FileReader *current; const int flags; const uint codepage; ushort lastChar; Source *source;
リスト、スタック、BaseArrayなどの配列、およびソースコードで使用されるマップ(「Map」) は、以前の記事で既に使用しているので、ここでは説明しません。しかしながら、完全なアーカイブはもちろんここに添付されています。
コントローラはそのコンストラクタに空のsourceオブジェクトを作成します。
public: FileReaderController(const int _flags = FILE_READ | FILE_TXT | FILE_ANSI | FILE_SHARE_READ | FILE_SHARE_WRITE, const uint _codepage = CP_UTF8, const uint _length = SOURCE_LENGTH): flags(_flags), codepage(_codepage) { current = NULL; lastChar = 0; source = new Source(_length); }
FileReaderの「source」とその下位オブジェクトは、デストラクタのマップから解放されます。
#define CLEAR(P) if(CheckPointer(P) == POINTER_DYNAMIC) delete P; ~FileReaderController() { for(int i = 0; i < files.getSize(); i++) { CLEAR(files[i]); } delete source; }
「mq5」拡張子を持つ一番最初のプロジェクトファイルを含め、1つまたは別のファイルを処理に含むために、includeメソッドを指定しましょう。
bool include(const string _filename) { Print((current != NULL ?"Including " : "Processing "), _filename); if(files.containsKey(_filename)) return true; if(current != NULL) { includes.push(current); } current = new FileReader(_filename, source, flags, codepage); source.mark(source.length(), current.pathname()); files.put(_filename, current); return current.isOK(); }
マップの「files」で定義済みファイルがすでに処理されているかどうかを確認し、ファイルが使用可能であれば即座に「true」を返します。そうでなければ、プロセスは継続します。これが一番最初のファイルであれば、FileReaderオブジェクトを作成し、それを「current」にして、マップの「files」に保存します。これが最初のファイルではない、つまりこの時点で他のファイルがすでに処理されている場合は、スタック 「includes」に保存する必要があります。インクルードファイルが完全に処理されたらすぐに、ファイルがインクルードされたところから始めて、現在のファイルの処理に戻ります。
このincludeメソッドの1行はまだコンパイルされません。
source.mark(source.length(), current.pathname());
sourceクラスにはまだmarkメソッドが含まれていません。文脈から明らかなように、この時点でファイルを切り替えます、よって、どこかで結合されたテキストでソースとシフトをマークするべきです。これがmarkメソッドの仕事です。どの時点においても、入力テキストの現在の長さは、新しいファイルのデータが追加される時点です。Sourceクラスに戻ってファイルのマップを追加しましょう。
class Source { private: Map<uint,string> files; public: void mark(const uint offset, const string file) { files.put(offset, file); }
FileReaderControllerクラスのファイルから文字を読み取る主なタスクは、作業の一部を現在のFileReaderオブジェクトに委譲するメソッドgetCharによって実行されます。
ushort getChar(const bool autonextline = true) { if(current == NULL) return 0; if(!current.isEOF()) { lastChar = current.getChar(autonextline); return lastChar; } else { while(includes.size() > 0) { current = includes.pop(); source.mark(source.length(), current.pathname()); if(!current.isEOF()) { lastChar = current.getChar(); return lastChar; } } } return 0; }
現在のファイルがあり、それが最後まで読み込まれていない場合は、そのgetCharメソッドを呼び出して、取得した文字を返します。現在のファイルが最後まで読み込まれた場合、スタック「includes」に他のファイルを含めるためのディレクティブがあるかどうかを確認します。ファイルがある場合は、上のファイルを抽出して「current」に設定し、そこから文字の読み取りを続けます。さらに、データソースが古いファイルに切り替えられたことをsourceオブジェクトでメモしておく必要があります。
FileReaderControllerクラスは読み取り終了のフラグを返すこともできます。
bool isAtEnd() { return current == NULL || (current.isEOF() && includes.size() == 0); }
特に、現在のファイルとテキストを取得するためのメソッドを提供しましょう。
const Source *text() const { return source; } FileReader *reader() { return current; }
これでファイルを前処理する準備が整いました。
Preprocessor
プリプロセッサは、FileReaderControllerクラス(controller)の唯一のインスタンスを管理し、ヘッダファイルをロードする必要があるかどうかを決定します(loadIncludesフラグ)。
class Preprocessor { protected: FileReaderController *controller; const string includes; bool loadIncludes;
重要なのは、デバッグや作業時間の短縮などを目的として、一部のファイルを依存関係なしで処理したい場合があるかもしれないということです。ヘッダファイル用のデフォルトフォルダを文字列変数「includes」に保存します。
コンストラクタは、これらすべての値と初期ファイルの名前(およびそのパス)をユーザから受け取り、コントローラを作成して、そのファイルに対してincludeメソッドを呼び出します。
public: Preprocessor(const string _filename, const string _includes, const bool _loadIncludes = false, const int _flags = FILE_READ | FILE_TXT | FILE_ANSI | FILE_SHARE_READ | FILE_SHARE_WRITE, const uint _codepage = CP_UTF8, const uint _length = SOURCE_LENGTH): includes(_includes) { controller = new FileReaderController(_flags, _codepage, _length); controller.include(_filename); loadIncludes = _loadIncludes; }
ここで、1つ以上のファイルの処理を開始するためにクライアントによって直接呼び出されるrunメソッドを書きましょう。
bool run() { while(!controller.isAtEnd()) { if(!scanLexeme()) return false; } return true; }
コントローラがデータの終わりに達するまで、字句単位を読みます。
これがscanLexemeメソッドです。
bool scanLexeme() { ushort c = controller.getChar(); switch(c) { case '#': if(controller.reader().consume("include")) { if(!include()) { controller.reader().error("bad include"); return false; } } break; ... } return true; // 消費された記号 }
文字「#」を見つけた場合、プログラムは次の単語「include」を「吸収」しようとします。「include」がない場合は、「#」は単一の文字でありスキップされます(getCharがカーソルを1ポジション移動します)。「include」という単語が見つかった場合は、ディレクティブを処理する必要があります。これは、includeメソッドによって行われます。
bool include() { ushort c = skipWhitespace(); if(c == '"' || c == '<') { ushort q = c; if(q == '<') q = '>'; int start = controller.reader().column(); do { c = controller.getChar(); } while(c != q && c != 0); if(c == q) { if(loadIncludes) { Print(controller.reader().source()); int stop = controller.reader().column(); string name = StringSubstr(controller.reader().source(), start, stop - start - 1); string path = ""; if(q == '"') { path = controller.reader().pathname(); StringReplace(path, "\\", "/"); string parts[]; int n = StringSplit(path, '/', parts); if(n > 0) { ArrayResize(parts, n - 1); } else { Print("Path is empty: ", path); return false; } int upfolder = 0; while(StringFind(name, "../") == 0) { name = StringSubstr(name, 3); upfolder++; } if(upfolder > 0 && upfolder < ArraySize(parts)) { ArrayResize(parts, ArraySize(parts) - upfolder); } path = StringImplodeExt(parts, CharToString('/')) + "/"; } else // '<' '>' { path = includes; // フォルダ } return controller.include(path + name); } else { return true; } } else { Print("Incomplete include"); } } return false; }
このメソッドは「include」の後のすべてのスペースをスキップするために「skipWhitespace」(ここではカバーされていない)を使用して、開始引用符または文字「<」を見つけ、次の終了引用符または終了文字「>」までテキストをスキャンします。 最後に、パスとヘッダファイルの名前を含む文字列を抽出します。その後、オプションが処理されて、同じフォルダまたはヘッダファイルの標準フォルダからファイルが読み込まれます。その結果、読み込むための新しいパスと新しい名前が形成され、それに続いてファイルを処理するためにコントローラが割り当てられます。
#includeディレクティブの処理と同時に、字句単位「#include」が内側にある場合は、コメントブロックと文字列を命令として解釈しないようにスキップする必要があります。したがって、scanLexemeメソッドのswitch演算子に関連オプションを追加しましょう。
case '/': if(controller.reader().probe("*")) { controller.reader().advance(1); if(!blockcomment()) { controller.reader().error("bad block comment"); return false; } } else if(controller.reader().probe("/")) { controller.reader().advance(1); linecomment(); } break; case '"': if(!literal()) { controller.reader().error("unterminated string"); return false; } break;
以下はコメントブロックをスキップする例です。
bool blockcomment() { ushort c = 0, c_; do { c_ = c; c = controller.getChar(); if(c == '/' && c_ == '*') return true; } while(!controller.reader().isEOF()); return false; }
他のヘルパーメソッドも同様に実装されています。
したがって、Preprocessorクラスおよび他のクラスを持つことで、理論的には、次のように、作業ファイルからテキストをすでに読み込むことができます。
#property script_show_inputs input string SourceFile = "filename.txt"; input string IncludesFolder = ""; input bool LoadIncludes = false; void OnStart() { Preprocessor loader(SourceFile, IncludesFolder, LoadIncludes); if(!loader.run()) { Print("Loader failed"); return; } // 1つ以上のファイルから組み立てられたデータ全体を出力する int handle = FileOpen("dump.txt", FILE_WRITE | FILE_TXT | FILE_ANSI, 0, CP_UTF8); FileWriteString(handle, loader.text().get()); FileClose(handle); }
何故「理論的」なのでしょうか。問題は、MetaTraderでは「サンドボックス」ファイル、すなわちMQL5/Filesディレクトリだけで作業できるということです。しかし、ここでの目標は、MQL5/Include、MQL5/Scripts、MQL5/Experts、MQL5/Indicators各フォルダに含まれるソースコードを処理することです。
この制限を回避するために、Windowsの機能を使用してソフトリンクをファイルシステムオブジェクトに割り当てましょう。ここでの場合、いわゆる「ジャンクション」が、ローカルコンピュータのフォルダにアクセスを転送するのに最適です。これは次のコマンドを使用して作成されます。
mklink /J new_name existing_target
new_nameパラメータは、実際にあるexisting_targetフォルダを指す新しい仮想「folder」の名前です。
ソースコードを含む指定されたフォルダへのジャンクションを作成するには、MQL5/Filesフォルダを開き、その中にSourcesサブフォルダを作成して、そのサブフォルダに移動します。それから、添付のmakelink.batファイルをコピーします。このコマンドスクリプトには、実際には1つの文字列が含まれています。
mklink /J %1 "..\..\%1\"
入力は1つで(%1)、 MQL5フォルダ内の「Include」のようなアプリケーションフォルダの名前です。相対パス「.. \ .. \」は、コマンドファイルが上記のMQL5/Files/Sourcesフォルダにあり、ターゲットフォルダ(existing_target)がMQL5/%1として形成されることを示唆しています。例えば、Sourcesフォルダでは次のコマンドを実行します。
makelink Include
Sourcesフォルダに、Includeという仮想フォルダが表示されます。このフォルダに移動してからMQL5/Includeに移動します。同様に、Experts、Scriptsなどのフォルダに「双子」を作成することもできます。下の図にはエクスプローラが表示されており、MQL5/Files/Sourcesフォルダで利用可能な標準ヘッダファイルを含むMQL5/Include/Expertフォルダが開かれています。
MQL5ソースコードのフォルダ用のWindowsソフトリンク
ソフトリンクは必要に応じて通常のファイル同様に削除できます(もちろん、元のフォルダではなく、左下隅に小さな矢印があるフォルダを削除するようにしてください)。
MQL5のルート作業フォルダに直接ジャンクションを作成することもできますが、ときどきアクセスを開くことをお勧めします。すべてのMQLプログラムがこのリンクを使用して、そこに格納されているログイン、パスワード、極秘取引を含むソースコードを読み取ることができます。リンクを作成すると、上記のスクリプトのパラメータ「IncludesFolder」が実際に機能します。Sources/Include/の値は、実際のMQL5/Includeフォルダを指します。パラメータ「SourceFile」では、Sources/Scripts/test.mq5のようなスクリプトのソースコードによる分析を例示することができます。
トークン化
MQLで区別しなければならないトークンタイプは、同義語ヘッダファイル(添付)の列挙「TokenType」で結合されます。本稿では説明しませんが、それらのトークンには、さまざまな括弧(「(」、「[」、「{」)、等号「=」、符号のプラス「+」、マイナス「 - 」などの単一文字のものと「==」、「!=」などの2文字のものがあることに注意しましょう。その上、数字、文字列、日付(すなわちサポートされている型の定数)、MQLで予約されているすべての単語(演算子、型、「this」、「input」、「const」などの修飾子など)や識別子(その他)もまた別のトークンです。さらに、入力データの終わりを示すためのEOFトークンがあります。
Token
テキストを表示するときに、スキャナは特別なアルゴリズム(後述)によって後続の各トークンの種類を識別し、クラスTokenのオブジェクトを作成します。これはとても単純なクラスです。
class Token { private: TokenType type; int line; int offset; int length; public: Token(const TokenType _type, const int _line, const int _offset, const int _length = 0) { type = _type; line = _line; offset = _offset; length = _length; } TokenType getType() const { return type; } int getLine() const { return line; } ... string content(const Source *source) const { return source.get(offset, length); } };
オブジェクトには、トークンの種類、テキスト内でのトークンのシフト、および長さが格納されています。トークンの文字列値が必要な場合は、contentメソッドにsource文字列へのポインタを渡し、そこから関連するフラグメントを切り出します。
さて、「トークナイザ」とも呼ばれるスキャナについてお話しする時が来ました。
スキャナ (トークナイザ)
Scannerクラスでは、MQLキーワードを使って静的配列を記述します。
class Scanner { private: static string reserved[];
それからテキストファイルをインクルードしてソースコードで初期化します。
static string Scanner::reserved[] = { #include "reserved.txt" };
この配列に、文字列表現と各トークンの型の対応関係の静的マップを追加しましょう。
static Map<string, TokenType> keywords;
コンストラクタのマップに書き込みましょう(以下を参照)。
スキャナでは、入力へのポインタ、結果として得られるトークンのリスト、および複数のカウンタも必要になります。
const Source *source; // 折り返された文字列 List<Token *> *tokens; int start; int current; int line;
start変数は常に、処理される次のトークンの先頭を指します。current変数はカーソルでテキスト内を移動します。現在の文字がトークンに対応しているかどうかが確認されているので、常に「'start」から「forward」に進み、一致が見つかるとすぐに「start」から「current」までの部分文字列が新しいトークンになります。line変数は、テキスト全体における現在の行の番号です。
以下はScannerクラスのコンストラクタです。
public: Scanner(const Source *_source): line(0), current(0) { tokens = new List<Token *>(); if(keywords.getSize() == 0) { for(int i = 0; i < ArraySize(reserved); i++) { keywords.put(reserved[i], TokenType(BREAK + i)); } } source = _source; }
ここで、BREAKはアルファベット順で最初の予約語のトークンタイプ識別子です。ファイル「reserved.txt」内の文字列の順序とTokenType列挙体内の識別子は一致する必要があります。例えば、列挙体の要素BREAKは明らかに「break」に対応しています。
scanTokensメソッドはクラスの中心的な位置を占めます。
List<Token *> *scanTokens() { while(!isAtEnd()) { // 次の語彙素の始まり start = current; scanToken(); } start = current; addToken(EOF); return tokens; }
そのループで、ますます多くの新しいトークンが生成されます。isAtEndメソッドとaddTokenメソッドは簡単です。
bool isAtEnd() const { return (uint)current >= source.length(); } void addToken(TokenType type) { tokens.add(new Token(type, line, start, current - start)); }
すべての大変な作業はscanTokenメソッドによって行われます。しかし、それを提示する前に、いくつかの単純なヘルパーメソッドについて学ぶ必要があります。これらは、Preprocessorクラスですでに見たものと似ているため、それらの目的についての説明は必要ないでしょう。
bool match(ushort expected) { if(isAtEnd()) return false; if(source[current] != expected) return false; current++; return true; } ushort previous() const { if(current > 0) return source[current - 1]; return 0; } ushort peek() const { if(isAtEnd()) return '\0'; return source[current]; } ushort peekNext() const { if((uint)(current + 1) >= source.length()) return '\0'; return source[current + 1]; } ushort advance() { current++; return source[current - 1]; }
ここで、scanTokenメソッドに戻ります。
void scanToken() { ushort c = advance(); switch(c) { case '(': addToken(LEFT_PAREN); break; case ')': addToken(RIGHT_PAREN); break; ...
これは次の文字を読み取り、そのコードに応じてトークンを作成します。1文字のトークンはすべて同じように作成されるので、ここでは提供しません。
トークンが2文字のものであることが示唆された場合、処理は複雑になります。
case '-': addToken(match('-') ?DEC : (match('=') ?MINUS_EQUAL : MINUS)); break; case '+': addToken(match('+') ?INC : (match('=') ?PLUS_EQUAL : PLUS)); break; ...
語彙素「-」、「-=」、「++」、「+=」のトークンの形成を以下に示します。
現在のバージョンのスキャナではコメントはスキップされます。
case '/': if(match('/')) { // コメントは行の終わりまで続く while(peek() != '\n' && !isAtEnd()) advance(); }
必要に応じて、それらを特別なトークンに保存することができます。
文字列、リテラル、プリプロセッサディレクティブなどのブロック構造は、割り当てられたヘルパーメソッドで処理されます。詳細については考察しません。
case '"': _string(); break; case '\'': literal(); break; case '#': preprocessor(); break;
これは、文字列がどのようにスキャンされるかの例です。
void _string() { while(!(peek() == '"' && previous() != '\\') && !isAtEnd()) { if(peek() == '\n') { line++; } advance(); } if(isAtEnd()) { error("Unterminated string"); return; } advance(); // 閉じる " addToken(CONST_STRING); }
トークンタイプがトリガーされていない場合は、デフォルトのテストが実行され、そこで数字、識別子、キーワードがチェックされます。
default: if(isDigit(c)) { number(); } else if(isAlpha(c)) { identifier(); } else { error("Unexpected character `" + ShortToString(c) + "` 0x" + StringFormat("%X", c) + " @ " + (string)current + ":" + source.get(MathMax(current - 10, 0), 20)); } break;
isDigitとisAlphaの実装は明らかです。ここでは、identifierメソッドのみが示されています。
void identifier() { while(isAlphaNumeric(peek())) advance(); // 識別子が予約語かどうかを調べる string text = source.get(start, current - start); TokenType type = keywords.get(text); if(type == null) type = IDENTIFIER; addToken(type); }
すべてのメソッドの完全な実装は、添付されているソースコードに記載されています。車輪の再発明を避けるために、『Crafting Interpreters』のコードの一部を修正しました。
基本的に、それはスキャナ全体です。エラーがない場合、scanTokensメソッドはユーザにトークンのリストを返します。これはパーサに渡すことができます。ただし、パーサにはトークンのリストを解析するときに参照する文法が必要です。したがって、パーサに進む前に、文法記述を考慮する必要があります。これはTerminalクラスとその派生のオブジェクトから形成します。文法の説明
まず、MQLの文法ではなく、算術式を計算するための特定の単純な言語、つまり計算機の文法を記述する必要があるとします。以下が許容される計算式です。
(10 + 1) * 2
優先順位を付けずに、整数と演算の「+」、「-」、「*」、「/」だけを許可します。優先順位付けには 「(」と「)」を使用します。
文法の入り口点は、式全体を記述する非終端記号でなければなりません。次のように書くだけで十分だとします。
NonTerminal expression;
式は、演算対象(すなわち整数値)と演算子記号で構成されています。上記はすべて終端記号で、スキャナによってサポートされるトークンに基づいて作成することができます。
それらを以下のように記述するとします。
Terminal plus(PLUS), star(STAR), minus(MINUS), slash(SLASH); Terminal value(CONST_INTEGER);
見てのとおり、終端記号のコンストラクタはトークン型をパラメータとして渡すことができなければなりません。
最も単純な表現は単なる数字です。それを次のように示すのは論理的です。
expression = value;
これによって代入演算子が再起動されます。その中で、valueオブジェクトへのリンク(「等価」から「eq」と名付けましょう)を「expression」内の変数に保存する必要があります。入力された文字列と照合するためにパーサが「expression」をチェックするように割り当てられるとすぐに、チェックを非終端記号に委譲します。後者は「value」へのリンクを「見て」パーサに「value」をチェックするよう委譲します。そしてチェックは最終的にちょうど一致するトークンが起こる終端に到達します。これは、終端と入力ストリームにあるトークンです。
ただし、expressionには演算と2番目の演算対象が追加されている場合があります。 したがって、規則「expression」を拡張する必要があります。この目的のために、新しい非終端記号について予備的に説明します。
NonTerminal operation; operation = (plus | star | minus | slash) + value;
ここでは、舞台裏で多くの興味深いことが起こります。要素を論理和でグループ化するために、クラス内で演算子「|」を多重定義する必要があります。ただし、演算子が終端記号、つまり単純な文字に対して呼び出されますが、要素のグループが必要です。したがって、実行環境が演算子(この場合は「plus」)を呼び出すグループの最初の要素は、それがグループのメンバであるかどうかを確認し、まだグループがない場合はHiddenNonTerminalORクラスのオブジェクトとして動的に作成します。次に、多重定義された演算子の実装は、新しく作成されたグループに「this」と隣接する右端の「star」終端記号を追加する必要があります(演算子関数に引数として渡される)。演算子は、後続の(連鎖)演算子「|」に対してこのグループへのリンクを返します。これは、HiddenNonTerminalORのために呼ばれます。
グループメンバを含む配列を維持するために、クラスの中でnext配列を確実に提供します。その名前は文法要素の次の詳細レベルを意味します。この子ノードの配列に追加する要素ごとに、親ノードへのバックリンクを設定する必要があります。これを「parent」と呼びます。ゼロ以外の「parent」は正確にグループ内のメンバーシップを意味します。角括弧内のコードの実行結果として、4つすべての演算記号を含む配列を持つHiddenNonTerminalORが得られます。
ここで、多重定義された演算子「+」が効力を発揮します。これは演算子「|」と同じように機能して、暗黙的な要素のグループも作成しなければなりません。しかし今回はHiddenNonTerminalANDクラスのものです。そして、それらは構文解析段階で論理積の規則によってチェックされなければなりません。
終端と非終端の依存関係階層が形成されていることに注意してください。この場合、HiddenNonTerminalANDオブジェクトには新しく作成されたHiddenNonTerminalORグループと「value」の2つの子要素が含まれます。HiddenNonTerminalANDは、非終端の「operation」に依存しています。
「|」と「+」の演算優先順位によって、大括弧がない場合には、ANDが最初に処理された後にORが処理されます。それがまさに「operation」内のすべてのバージョンの文字を大括弧で囲まなければならなかった理由です。
非終端の「operation」の記述があれば、式の文法を修正できます。
expression = value + operation;
これは、AとBは整数であるが@は作用である、A @ Bとして表される表現を記述するとされています。しかしここにこだわりがあります。
valueオブジェクトを含む規則は既に2つあります。これは、最初の規則で設定された親へのリンクが2番目の規則で書き直されることを意味します。書き直しを防ぐためには、規則にはオブジェクトのかわりにコピーを挿入するべきです。
この目的のために「~」と「^」の2つの演算子を多重定義します。「~」は単項式で、演算対象の前に置かれます。該当する演算子関数の呼び出しを受けたオブジェクトでは、プロキシオブジェクトを動的に作成して呼び出し元のコードに返します。2番目の「^」は二項演算子です。オブジェクトと一緒に、文法ソースコード内の現在の文字列番号、つまりMQLコンパイラによって事前定義された定数__LINE__を渡します。したがって、暗黙的に定義されたオブジェクトインスタンスを、それらが作成された行数で区別することができます。 これは複雑な文法のデバッグに役立ちます。別の言い方をすれば、演算子「~」と「^」は同じ作業を実行しますが、1番目のものはリリースモードで、2番目のものはデバッグモードです。
プロキシインスタンスは、HiddenNonTerminalクラスのオブジェクトを表します。この場合、上記の変数「eq」は元のオブジェクトを表します。
したがって、プロキシオブジェクトを作成することを考慮して、式の文法を書き直すことにします。
operation = (plus | star | minus | slash) + ~value; expression = ~value + operation;
「operation」は1回しか使用されていないので、コピーを作成する必要はありません。各論理参照は、式を拡張するときに再帰を1つ増加させます。ただし、大規模な文法でのエラーを避けるために、どこでも参照を作成することをお勧めします。非終端記号が今は一度だけ使用されているとしても、後で文法の別の部分で再び使用される可能性があります。親ノードの上書きをチェックしてエラーメッセージを表示するソースコードを提供します。
これで、私たちの文法は「10 + 1」を処理することができますが、別の数字を読む能力を失いました。実際、終端以外の「operation」はオプションであるべきです。この目的のために、演算子「*」の多重定義を実装しましょう。文法要素に0を掛けた場合、チェックを実行するときに文法要素を省略してもかまいません。文法要素がなくてもエラーにはならないからです。
expression = ~value + operation*0;
乗算演算子を多重定義すると、要素を必要なだけ繰り返すという、あと一つの重要なことを実装できます。この場合、要素に1を掛けます。終端クラスでは、この特性、すなわち多重度または選択性は変数「mult」に格納されます。要素が両方ともオプションであり、何度も繰り返すことができる場合は、2つのリンクによって簡単に実装されます。1つ目はオプションでなければならず(オプション* 0)、2つ目は倍数です(オプション= element * 1)。
現在の電卓の文法にはもう1つ弱点があります。これは、1+2+3+4+5のように、いくつかの演算を伴う長い式には適していないということです。これを修正するには、非終端の「operation」を変更する必要があります。
operation = (plus | star | minus | slash) + ~expression;
「value」を「expression」自体に置き換えることによって、式のますます新しい末尾の循環構文解析が可能になります。
最後の仕上げは、角かっこで囲まれた表現のサポートです。それらが単位値( 「value」)と同じ役割を果たすと推測するのは難しくありません。したがって、整数とかっこ内の部分式の2つのオプションからの代替として、それを再定義してみましょう。文法全体は次のようになります。
NonTerminal expression; NonTerminal value; NonTerminal operation; Terminal number(CONST_INTEGER); Terminal left(LEFT_PAREN); Terminal right(RIGHT_PAREN); Terminal plus(PLUS), star(STAR), minus(MINUS), slash(SLASH); value = number | left + expression^__LINE__ + right; operation = (plus | star | minus | slash) + expression^__LINE__; expression = value + operation*0;
上記のクラスがどのように内部的に配置されているかを詳しく見てみましょう。
Terminal
Terminalクラスでは、トークンタイプ(「me」)、多重度プロパティ(「mult」)、オプションの名前(ログ内の非終端を識別する「name」)、プロダクション(「eq」)と親(「parent」)へのリンク、下位要素(配列「next」)を記述します。
class Terminal { protected: TokenType me; int mult; string name; Terminal *eq; BaseArray<Terminal *> next; Terminal *parent;
フィールドはコンストラクタとsetterメソッドで書き入れられてgetterメソッドを使用して読み込まれます。簡潔化のためにここでは説明を省きます。
次の原則に従って、演算子を多重定義します。
virtual Terminal *operator|(Terminal &t) { Terminal *p = &t; if(dynamic_cast<HiddenNonTerminalOR *>(p.parent) != NULL) { p = p.parent; } if(dynamic_cast<HiddenNonTerminalOR *>(parent) != NULL) { parent.next << p; p.setParent(parent); } else { if(parent != NULL) { Print("Bad OR parent: ", parent.toString(), " in ", toString()); ... error } else { parent = new HiddenNonTerminalOR("hiddenOR"); p.setParent(parent); parent.next << &this; parent.next << p; } } return parent; }
ここでは、ORによるグループ分けを示しています。ANDについてもすべて同様です。
多重度の特徴を設定することは演算子「*」にあります。
virtual Terminal *operator*(const int times) { mult = times; return &this; }
デストラクタでは、作成されたインスタンスを正しく削除します。
~Terminal() { Terminal *p = dynamic_cast<HiddenNonTerminal *>(parent); while(CheckPointer(p) != POINTER_INVALID) { Terminal *d = p; if(CheckPointer(p.parent) == POINTER_DYNAMIC) { p = dynamic_cast<HiddenNonTerminal *>(p.parent); } else { p = NULL; } CLEAR(d); } }
最後に、以下は構文解析を担当するTerminalクラスのメインメソッドです。
virtual bool parse(Parser *parser) { Token *token = parser.getToken(); bool eqResult = true;
ここでは、パーサへの参照を受け取り、そこから現在のトークンを読み取ります(パーサクラスについては後述します)。
if(token.getType() == EOF && mult == 0) return true;
トークンがEOFで、現在の要素がオプションの場合、正しいテキストの終わりが見つかったことを意味します。
次に、コピー内にある場合は、多重定義された演算子「=」から要素の元のインスタンスへの参照があるかどうかを確認します。参照がある場合は、チェックのためにそれをパーサのmatchメソッドに送信します。
if(eq != NULL) // リダイレクト { eqResult = parser.match(eq); bool lastResult = eqResult; // 複数のトークンが予想され、次のトークンが正常に消費されている間 while(eqResult && eq.mult == 1 && parser.getToken() != token && parser.getToken().getType() != EOF) { token = parser.getToken(); eqResult = parser.match(eq); } eqResult = lastResult || (mult == 0); return eqResult; // リダイレクトが完了した }
さらに、ここでは要素が繰り返される状況(mult' = 1)が処理されます。matchメソッドが成功を返している間にパーサが何度も呼び出されます。
成功フラグ(trueまたはfalse)は、この分岐および終端などの他の状況の両方で、parseメソッドから返されます。
if(token.getType() == me) // トークンの一致 { parser.advance(parent); return true; }
終端記号の場合は、そのmeトークンを入力の現在のトークンと比較し、一致した場合は、advanceメソッドを使用して、カーソルを次の入力トークンに移動するようにパーサを割り当てます。同じメソッドで、パーサは結果が非終端の「parent」で生成されたことをクライアントプログラムに通知します。
要素のグループでは、すべてがもう少し複雑です。論理積を考えてみましょう。論理和のバージョンは似ています。hasAnd仮想メソッド(Terminalクラスではfalseを返し、下位クラスで多重定義 )を使用して、下位要素を持つ配列が論理積によるチェックのために書き入れられているかどうかを調べます。
else if(hasAnd()) // AND条件をチェック { parser.pushState(); for(int i = 0; i < getNext().size(); i++) { if(!parser.match(getNext()[i])) { if(mult == 0) { parser.popState(); return true; } else { parser.popState(); return false; } } } parser.commitState(parent); return true; }
この非終端記号は、そのすべての構成要素が文法と一致した場合に正しいと見なされるので、ループ内で、それらすべてに対してパーサのmatchメソッドを呼び出します。少なくとも1つの否定的な結果が生じると、チェック全体が失敗します。ただし、例外があります。非終端記号がオプションである場合、たとえmatchメソッドから「false」が返されても文法規則に従われます。
ループの前の現在の状態(pushState)をパーサに保存し、早期終了時にそれを復元し(popState)、チェックが正常に完了した場合は新しい状態(commitState)を確認します。これは、文法規則全体が完全に機能するまで、新しい「production」でクライアントコードの通知を遅らせるために必要です。「state」という単語は、単に入力トークンのストリーム内の現在のカーソル位置を意味します。
トークンも従属要素のグループもparseメソッド内で発動していない場合、残っているのは現在のオブジェクトの選択性をチェックすることだけです。
else if(mult == 0) // 最後のチャンス { // parser.advance(); - トークンを消費しないで次の結果に進む return true; }
そうでなければ、エラーを意味するメソッドの終わりに「陥り」ます。つまり、テキストは文法と一致しません。
if(dynamic_cast<HiddenNonTerminal *>(&this) == NULL) { parser.trackError(&this); } return false; }
それでは、Terminalクラスから派生したクラスについて説明しましょう。
非終端記号、非表示および明示
HiddenNonTerminalクラスの主なタスクは、オブジェクトの動的インスタンスを作成してガベージコレクションすることです。
class HiddenNonTerminal: public Terminal { private: static List<Terminal *> dynamic; // ガベージコレクション public: HiddenNonTerminal(const string id = NULL): Terminal(id) { } HiddenNonTerminal(HiddenNonTerminal &ref) { eq = &ref; } virtual HiddenNonTerminal *operator~() { HiddenNonTerminal *p = new HiddenNonTerminal(this); dynamic.add(p); return p; } ... };
HiddenNonTerminalORクラスは、演算子「|」多重定義を保証します。(HiddenNonTerminalOR自体が「コンテナ」、つまり下位の文法要素のグループの所有者であるため、Terminalクラスよりも単純です。)
class HiddenNonTerminalOR: public HiddenNonTerminal { public: virtual Terminal *operator|(Terminal &t) override { Terminal *p = &t; next << p; p.setParent(&this); return &this; } ... };
HiddenNonTerminalANDクラスは同じように実装されています。
NonTerminalクラスは「=」演算子の多重定義を保証します(規則の「production」)。
class NonTerminal: public HiddenNonTerminal { public: NonTerminal(const string id = NULL): HiddenNonTerminal(id) { } virtual Terminal *operator=(Terminal &t) { Terminal *p = &t; while(p.getParent() != NULL) { p = p.getParent(); if(p == &t) { Print("Cyclic dependency in assignment: ", toString(), " <<== ", t.toString()); p = &t; break; } } if(dynamic_cast<HiddenNonTerminal *>(p) != NULL) { eq = p; } else { eq = &t; } eq.setParent(this); return &this; } };
最後に、NonTerminalの下位クラスのRuleクラスがあります。ただし、その全体的な役割は、文法を説明するときに、いくつかの規則を一次(Ruleオブジェクトを生成する場合)または二次(非終端になる場合)としてマークすることです。
非終端を説明するために、以下のマクロが作成されています。
// デバッグ #define R(Y) (Y^__LINE__) // リリース #define R(Y) (~Y) #define _DECLARE(Cls) NonTerminal Cls(#Cls); Cls #define DECLARE(Cls) Rule Cls(#Cls); Cls #define _FORWARD(Cls) NonTerminal Cls(#Cls); #define FORWARD(Cls) Rule Cls(#Cls);
その行は一意の名前で、マクロの引数として指定されています。非終端が互いに参照する場合には、前方宣言が必要になります。これは計算機の文法に存在します。
さらに、トークンを使って終端を生成するために、ガベージコレクションをサポートする特別なKeywordsクラスが実装されています。
class Keywords { private: static List<Terminal *> keywords; public: static Terminal *get(const TokenType t, const string value = NULL) { Terminal *p = new Terminal(t, value); keywords.add(p); return p; } };
文法の記述に使用するために、以下のマクロが作成されました。
#define T(X) Keywords::get(X) #define TC(X,Y) Keywords::get(X,Y)
上で考察した計算機文法が、実装されたプログラムインターフェイスを使用してどのように記述されるかを見てみましょう。
FORWARD(expression);
_DECLARE(value) = T(CONST_INTEGER) | T(LEFT_PAREN) + R(expression) + T(RIGHT_PAREN);
_DECLARE(operation) = (T(PLUS) | T(STAR) | T(MINUS) | T(SLASH)) + R(expression);
expression = R(value) + R(operation)*0;
やっとParserクラスを研究する準備が整いました。
Parser
Parserクラスには、トークンの入力リスト( 「tokens」)、その現在位置(「cursor」)、最も遠い既知の位置(エラー診断で使用される「maxcursor」)、入れ子になった要素のグループを呼び出す前の位置のスタック(「states」ロールバックのため。「backtracking」を記憶)、入力テキストへのリンク(「source」ログの出力およびその他の目的のため)があります。
class Parser { private: BaseArray<Token *> *tokens; // 入力ストリーム int cursor; // 現在のトークン int maxcursor; BaseArray<int> states; const Source *source;
また、パーサは「stack」を使用して、文法要素ごとに呼び出しの階層全体を追跡します。このテンプレートで使用されているクラスTreeNodeはペア(終端、トークン)の単純なコンテナであり、そのソースコードは添付のアーカイブにあります。エラーは別のスタック(「errors」)に診断用に蓄積されます。
// 現在のスタック、文法の巻き戻し方法 Stack<TreeNode *> stack; // 問題のあるすべての箇所で現在のスタックを保持する Stack<Stack<TreeNode *> *> errors;
パーサのコンストラクタは、トークンのリスト、ソーステキスト、および構文解析中に構文木の形成を有効にするためのオプションのフラグを受け取ります。
public: Parser(BaseArray<Token *> *_tokens, const Source *text, const bool _buildTree = false)
ツリーモードが有効になっている場合、TreeNodeのオブジェクトとしてスタックに追加されたすべての成功した「production」は、木の根(tree変数)に配置されます。
TreeNode *tree; // 具象構文木(任意の結果)
このため、TreeNodeクラスは子ノードの配列をサポートしています。パーサがその作業を終えた後、それが有効にされているという条件で、以下の方法を使って木を得ることができます。
const TreeNode *getCST() const { return tree; }
パーサの主なメソッドである「match」は、その簡略化された形式では、次のようになります。
bool match(Terminal *p) { TreeNode *node = new TreeNode(p, getToken()); stack.push(node); int previous = cursor; bool result = p.parse(&this); stack.pop(); if(result) // 成功 { if(stack.size() > 0) // バインドできるホルダがある { if(cursor > previous) // トークンが消費された { stack.top().bind(node); } else { delete node; } } } else { delete node; } return result; }
終端記号のクラスを紹介しいているときに見たメソッド「advance」と「commitState」は、このように実装されています(一部詳細はスキップします)。
void advance(const Terminal *p) { production(p, cursor, tokens[cursor], stack.size()); if(cursor < tokens.size() - 1) cursor++; if(cursor > maxcursor) { maxcursor = cursor; errors.clear(); } } void commitState(const Terminal *p) { int x = states.pop(); for(int i = x; i < cursor; i++) { production(p, i, tokens[i], stack.size()); } }
「advance」は、トークンのリストに沿ってカーソルを移動します。エラーはチェックされるたびに記録されるため、ポジションが最大値を超えたら累積されたエラーを削除できます。
productionメソッドは、コールバックインタフェイスを使用してパーサのユーザに「production」について通知します。テストではこれをさらに使用します。
void production(const Terminal *p, const int index, const Token *t, const int level) { if(callback) callback.produce(&this, p, index, t, source, level); }
インターフェイスは以下のように定義されていまあす。
interface Callback { void produce(const Parser *parser, const Terminal *, const int index, const Token *, const Source *context, const int level); void backtrack(const int index); };
クライアント側でこのインターフェイスを実装しているオブジェクトは、setCallbackメソッドを使ってパーサに接続し、それぞれの「production」で呼ばれます。あるいは、そのようなオブジェクトは、多重定義演算子[Callback *]のために、任意の終端記号に個別に接続することができます。特定の文法ポイントにブレークポイントを配置すると、デバッグに役立ちます。
実際にパーサを試してみましょう。演習1: 電卓
電卓の文法はすでにカバーしました。デバッグ用のスクリプトを作成しましょう。このスクリプトは後でMQL文法によるテストのために補完します。
#property script_show_inputs enum TEST_GRAMMAR {Expression, MQL}; input TEST_GRAMMAR TestMode = Expression;; input string SourceFile = "Sources/calc.txt";; input string IncludesFolder = "Sources/Include/";; input bool LoadIncludes = false; input bool PrintCST = false; #include <mql5/scanner.mqh> #include <mql5/prsr.mqh> void OnStart() { Preprocessor loader(SourceFile, IncludesFolder, LoadIncludes); if(!loader.run()) { Print("Loader failed"); return; } Scanner scanner(loader.text()); List<Token *> *tokens = scanner.scanTokens(); if(!scanner.isSuccess()) { Print("Tokenizer failed"); delete tokens; return; } Parser parser(tokens, loader.text(), PrintCST); if(TestMode == Expression) { testExpressionGrammar(&parser); } else { //... } delete tokens; } void testExpressionGrammar(Parser *p) { _FORWARD(expression); _DECLARE(value) = T(CONST_INTEGER) | T(LEFT_PAREN) + R(expression) + T(RIGHT_PAREN); _DECLARE(operation) = (T(PLUS) | T(STAR) | T(MINUS) | T(SLASH)) + R(expression); expression = R(value) + R(operation)*0; while(p.match(&expression) && !p.isAtEnd()) { Print("", "Unexpected end"); break; } if(p.isAtEnd()) { Print("Success"); } else { p.printState(); } if(PrintCST) { Print("Concrete Syntax Tree:"); TreePrinter printer(p); printer.printTree(); } Comment(""); }
スクリプトの目的は、渡されたファイルをプリプロセッサで読み取り、それをスキャナを使用してトークンのストリームに変換し、指定された文法をパーサで確認することです。チェックは、matchメソッドを呼び出してルート文法規則「expression」を渡すことによって実行されます。
オプション(PrintCST)として、TreePrinterユーティリティクラスを使用して、処理された式の構文木をログに表示することができます。
注意実際のプログラムでは、木は非常に大きくなります。このオプションは、計算機の場合のように、文法の小さな断片をデバッグする場合、または文法全体が大きくない場合にのみ推奨されます。
式「(10+1)*2」でファイルのテストスクリプトを実行すると、次のツリーが表示されます(忘れずにTestMode=ExpressionおよびPrintCST= trueを選択してください)。
具象構文ツリー: | | |Terminal LEFT_PAREN @ ( | | | | |Terminal CONST_INTEGER @ 10 | | | |NonTerminal value | | | | |Terminal PLUS @ + | | | | | | |Terminal CONST_INTEGER @ 1 | | | | | |NonTerminal value | | | | |NonTerminal expression | | | |NonTerminal operation | | |NonTerminal expression | | |Terminal RIGHT_PAREN @ ) | |NonTerminal value | | |Terminal STAR @ * | | | | |Terminal CONST_INTEGER @ 2 | | | |NonTerminal value | | |NonTerminal expression | |NonTerminal operation |NonTerminal expression
縦線は、文法で明示的に記述されている非終端記号、すなわち名前のついたものの処理のレベルを示しています。スペースは、暗黙的に作成されたHiddenXYZクラスの非終端記号が「展開された」レベルに対応します。このようなノードはすべてデフォルトでログに表示されません。 しかしTreePrinterクラスには、それらを有効にするオプションがあります。
PrintCSTオプションは、メタデータを含む特別な構造体、つまりTreeNodeオブジェクトの木に基づいて機能します。私たちのパーサは、メソッドgetCSTの呼び出しに対する応答として、分析時にオプションで生成することができます。 ツリー配置モードを含めることは、パーサコンストラクターの3番目のパラメータによって設定されることを思い出してください。
エラー処理が存在することを確認するために正しくないものも含め、他の式を試してみることができます。例えば、表現を「10+」にすると以下の通知が表示されます。
Failed First 2 tokens read out of 3 Source: EOF (file:Sources/Include/Layouts/calc.txt; line:1; offset:4) `` Expected: CONST_INTEGER in expression;operation;expression;value; LEFT_PAREN in expression;operation;expression;value;
すべてのクラスが機能しているようです。これで、実用的な部分であるMQLの構文解析に移ります。
演習2: MQL文法
MQL文法を書く準備はエンジニアリング面ではすべて整っていますが、MQL文法は小さな電卓よりはるかに複雑です。最初から作成するのは大仕事です。この問題を解決するために、MQLがC ++に似ているという事実を使用しましょう。
C ++の場合、文法の既成の説明の多くは一般に公開されています。そのうちの1つはファイルcppgrmr.htmとしてここに添付されています。それを私たちの文法に完全に変換することもまた難しいでしょう。まず、多くの構造体はMQLではサポートされていません。次に、記法の中に左再帰があることが多く、そのために規則を変更する必要があります。最後に、3番目に、文法のサイズを制限することは、処理速度に悪影響を及ぼす可能性があるため、望ましいことです。めったに使用されない機能を、必要に応じて追加することは理にかなっています。
最初のトリガーバージョンが後続のチェックを傍受するため、ORの代替手段について言及する順序が重要です。いくつかの条件において、いくつかのオプション要素を飛ばしたためにバージョンが部分的に一致する可能性がある場合は、それらを並べ替えるか、最初に長く特殊な構造を指定し、次に短く一般的な構造を指定する必要があります。
htmファイルの表記法が、規則と終端の文法にどのように変換されるかを説明しましょう。
C++文法では以下のようになります。
assignment-expression: conditional-expression unary-expression assignment-operator assignment-expression assignment-operator: one of = *= /= %= += –= >= <= &= ^= |=
MQL文法では以下のようになります。
_FORWARD(assignment_expression); _FORWARD(unary_expression); ... assignment_expression = R(unary_expression) + R(assignment_operator) + R(assignment_expression) | R(conditional_expression); _DECLARE(assignment_operator) = T(EQUAL) | T(STAR_EQUAL) | T(SLASH_EQUAL) | T(DIV_EQUAL) | T(PLUS_EQUAL) | T(MINUS_EQUAL) | T(GREATER_EQUAL) | T(LESS_EQUAL) | T(BIT_AND_EQUAL) | T(BIT_XOR_EQUAL) | T(BIT_OR_EQUAL);
C++文法では以下のようになります。
unary-expression: postfix-expression ++ unary-expression –– unary-expression unary-operator cast-expression sizeof unary-expression sizeof ( type-name ) allocation-expression deallocation-expression
MQL文法では以下のようになります。
unary_expression = R(postfix_expression) | T(INC) + R(unary_expression) | T(DEC) + R(unary_expression) | R(unary_operator) + R(cast_expression) | T(SIZEOF) + T(LEFT_PAREN) + R(type) + T(RIGHT_PAREN) | R(allocation_expression) | R(deallocation_expression);
C++文法では以下のようになります。
statement: labeled-statement expression-statement compound-statement selection-statement iteration-statement jump-statement declaration-statement asm-statement try-except-statement try-finally-statement
MQL文法では以下のようになります。
statement = R(expression_statement) | R(codeblock) | R(selection_statement) | R(labeled_statement) | R(iteration_statement) | R(jump_statement);
MQL文法にはdeclaration_statementのための規則もあります。しかし、それは転送されます。多くの規則がC ++よりも簡単な方法で記録されています。原則として、この文法は生物、または、英語の用語で言うと「進行中の作業」です。 ソースコード内の特定の構造を解釈すると、エラーが発生する可能性が高くなります。
MQL文法の場合、エントリポイントは1つ以上の「element」からなる規則「program」です。
_DECLARE(element) =
R(class_decl)
| R(declaration_statement) | R(function) | R(sharp) | R(macro);
_DECLARE(program) = R(element)*1;
テストスクリプトでは、提示されたMQL文法はtestMQLgrammar関数に記述されています。
void testMQLgrammar(Parser *p) { // すべての文法規則が最初に来る // ... _DECLARE(program) = R(element)*1;
計算機と同様に、解析はここで開始されます。
while(p.match(&program) && !p.isAtEnd())
...
エラーが発生した場合は、問題のある要素をログで特定し、特定の文法規則をテキストの別の入力フラグメントでデバッグする必要があります(最大で5〜6個のトークンを含むフラグメントを使用することをお勧めします)。言い換えれば、パーサのmatchメソッドは特定の規則に対して呼び出されて、別の言語構造を持つファイルが入力に与えられるべきです。パーサのトレースをログに出力するには、スクリプト内のディレクティブを非コメント化する必要があります。
//#define PRINTX Print
注意表示される情報量は莫大です。
デバッグする前に、規則の異なる要素を異なる行に配置することをお勧めします。これにより、オブジェクトの無名インスタンスに一意の数のソース行がマークされるためです。しかし、パーサが作成されたのは、テキストがMQL構文に準拠していることを確認するためではなくセマンティックデータを抽出するためです。これをやってみましょう。
演習3: クラスメソッドとクラス階層のリスト
MQL解析に基づく最初のアプリケーションタスクとして、クラスのすべてのメソッドを一覧表示しましょう。このために、コールバックインターフェイスを実装するクラスを定義し、関連する「productions」を記録しましょう。
原則として、構文木に基づいて解析する方が論理的です。ただし、これでは、木を格納するためのメモリとその木を反復するための別のアルゴリズムが過負荷になります。しかし、実際には、パーサ自体はすでにテキストの解析中に同じシーケンスで反復します(関連するモードが有効になっている場合は木が構築されるのはこのシーケンスであるため)。したがって、移動している状態で解析する方が簡単です。
MQL文法には次の規則があります。
_DECLARE(method) = R(template_decl)*0 + R(method_specifiers)*0 + R(type) + R(name_with_arg_list) + R(modifiers)*0;
それは他の多くの非終端記号から成り立っていて、それが他の非終端記号を通して明らかにされているので、このメソッドの構文木は高度に分岐しています。プロダクションプロセッサでは、非終端の「メソッド」に関連するすべてのフラグメントを傍受し、それらを1つの共通の文字列にまとめます。次のプロダクションが別の非終端記号向けであることが判明した時点でメソッドの記述が終わったことが意味されるので、結果をログに表示することができます。
class MyCallback: public Callback { virtual void produce(const Parser *parser, const Terminal *p, const int index, const Token *t, const Source *context, const int level) override { static string method = ""; // 「method」非終端記号からすべてのトークンを集める if(p.getName() == "method") { method += t.content(context) + " "; } // 他の[非]終端が検出されて文字列が書き入れられるとすぐに、署名の準備が整う else if(method != "") { Print(method); method = ""; } }
プロセッサをパーサに接続するために、次のようにテストスクリプトを拡張しましょう(OnStartの場合)。
MyCallback myc; Parser parser(tokens, loader.text(), PrintCST); parser.setCallback(&myc);
メソッドのリストに加えて、クラスの宣言に関する情報を集めましょう。メソッドが定義されているコンテキストを識別することが特に必要になりますが、派生階層を構築することもできます。
ランダムなクラスに関するメタデータを保存するために「Class」という名前のクラスを用意しましょう。
class Class { private: BaseArray<Class *> subclasses; Class *superclass; string name; public: Class(const string n): name(n), superclass(NULL) { } ~Class() { subclasses.clear(); } void addSubclass(Class *derived) { derived.superclass = &this; subclasses.add(derived); } bool hasParent() const { return superclass != NULL; } Class *operator[](int i) const { return subclasses[i]; } int size() const { return subclasses.size(); } ... };
それにはサブクラスの配列とスーパークラスへのリンクがあります。addSubclassメソッドには、これらの相互に関連するフィールドに書き入れる責任があります。Classオブジェクトのインスタンスをクラス名として表現された文字列キーと共にマップに書き入れます。
Map<string,Class *> map;
マップはMyCallbackの同じオブジェクト内にあります。
これで、Callbackインターフェイスからproduceメソッドを展開できます。クラスの宣言に関連するトークンを収集するためには、完全な宣言だけではなく、特定のプロパティを強調表示した状態で、もう少し規則を傍受する必要があります。これらは、新しいクラスの名前とそのテンプレートの型(存在する場合)、および基本クラスの名前とそのテンプレートの型(存在する場合)です。
関連する変数を追加してデータを収集しましょう(注意: MQLのクラスはそれほど頻繁にではありませんが入れ子になることがあります。ここでは、単純化のために入れ子は考慮していません。同時に、MQL文法は入れ子をサポートしています)。
static string templName = ""; static string templBaseName = ""; static string className = ""; static string baseName = "";
template_decl非終端記号の文脈では、識別子はテンプレート型です。
if(p.getName() == "template_decl" && t.getType() == IDENTIFIER) { if(templName != "") templName += ","; templName += t.content(context); }
template_declの文法規則は、以下で使用されるオブジェクトと同様に、添付のソースコードで調べることができます。
class_name非終端記号のコンテキストでは、identifierはクラス名です。 その時点でtemplNameが空の文字列ではない場合、これらは説明に追加する必要があるテンプレート型です。
if(p.getName() == "class_name" && t.getType() == IDENTIFIER) { className = t.content(context); if(templName != "") { className += "<" + templName + ">"; templName = ""; } }
「derived_clause」の文脈の最初の識別子があれば、それは基本クラスの名前です。
if(p.getName() == "derived_clause" && t.getType() == IDENTIFIER) { if(baseName == "") baseName = t.content(context); else { if(templBaseName != "") templBaseName += ","; templBaseName += t.content(context); } }
後続のすべての識別子は、基本クラスのテンプレート型です。
クラス宣言が完了するとすぐに、規則「class_decl」が文法内で起動します。その時までに、すべてのデータはすでに収集されており、クラスのマップに追加することができます。
if(p.getName() == "class_decl") // ファイナライズ { if(className != "") { if(map[className] == NULL) { map.put(className, new Class(className)); } else { // クラスはすでにおそらく前方宣言で定義されている } } if(baseName != "") { if(templBaseName != "") { baseName += "<" + templBaseName + ">"; } Class *base = map[baseName]; if(base == NULL) { // 未知のクラス、おそらく含まれていないが、奇妙 base = new Class(baseName); map.put(baseName, base); } if(map[className] == NULL) { Print("Error: base name `", baseName, "` resolved before declaration of the class: ", className); } else { base.addSubclass(map[className]); } baseName = ""; } className = ""; templName = ""; templBaseName = ""; }
最後に、すべての文字列を消去し、次の宣言が現れるのを待ちます。
プログラムテキストの解析が成功すると、残るのはクラスの階層を便利な方法で表示することです。テストスクリプトでは、ログに表示するためのprint関数はMyCallbackクラスで提供されています。これは、Classクラスのオブジェクトでprintメソッドを使用します。これらのヘルパーアルゴリズムはご自分でお読みください。力試しのための小規模なプログラミング問題(このような競争はしばしばmql5.comのフォーラムに自然に現れます)とするのもいいかもしれません。既存の実装は実用的であるだけで、全く最適ではありません。それは単に、ログ内のクラス型オブジェクトのツリー構造の階層をできるだけ厳しく表示するという目標を確実に達成します。ただし、これはより効率的な方法で実行できます。
テストスクリプトがいくつかのMQLプロジェクトを分析するためにどのように機能するかを確認しましょう。以下では、パラメータTestMode = MQLを設定しましょう。
例えば、SourceFile = Sources/Experts/Examples/MACD/MACD Sample.mq5およびLoadIncludes = trueと設定した、標準のMACD Sample.mq5エキスパートアドバイザーの場合、つまりすべての依存関係がある場合、次の結果が得られます (メソッドのリストは大幅に短縮されています)。
Processing Sources/Experts/Examples/MACD/MACD Sample.mq5 Scanning... #include <Trade\Trade.mqh> Including Sources/Include/Trade\Trade.mqh #include <Object.mqh> Including Sources/Include/Object.mqh #include "StdLibErr.mqh" Including Sources/Include/StdLibErr.mqh #include "OrderInfo.mqh" Including Sources/Include/Trade/OrderInfo.mqh #include <Object.mqh> Including Sources/Include/Object.mqh #include "SymbolInfo.mqh" Including Sources/Include/Trade/SymbolInfo.mqh #include <Object.mqh> Including Sources/Include/Object.mqh #include "PositionInfo.mqh" Including Sources/Include/Trade/PositionInfo.mqh #include <Object.mqh> Including Sources/Include/Object.mqh #include "SymbolInfo.mqh" Including Sources/Include/Trade/SymbolInfo.mqh #include <Trade\PositionInfo.mqh> Including Sources/Include/Trade\PositionInfo.mqh #include <Object.mqh> Including Sources/Include/Object.mqh #include "SymbolInfo.mqh" Including Sources/Include/Trade/SymbolInfo.mqh Files processed: 8 Source length: 175860 File map: Sources/Experts/Examples/MACD/MACD Sample.mq5 0 Sources/Include/Trade\Trade.mqh 900 Sources/Include/Object.mqh 1277 Sources/Include/StdLibErr.mqh 1657 Sources/Include/Object.mqh 2330 Sources/Include/Trade\Trade.mqh 3953 Sources/Include/Trade/OrderInfo.mqh 4004 Sources/Include/Trade/SymbolInfo.mqh 4407 Sources/Include/Trade/OrderInfo.mqh 38837 Sources/Include/Trade\Trade.mqh 59925 Sources/Include/Trade/PositionInfo.mqh 59985 Sources/Include/Trade\Trade.mqh 75648 Sources/Experts/Examples/MACD/MACD Sample.mq5 143025 Sources/Include/Trade\PositionInfo.mqh 143091 Sources/Experts/Examples/MACD/MACD Sample.mq5 158754 Lines: 4170 Tokens: 18005 Defining grammar... Parsing... CObject :: CObject * Prev ( void ) const CObject :: void Prev ( CObject * node ) CObject :: CObject * Next ( void ) const CObject :: void Next ( CObject * node ) CObject :: virtual bool Save ( const int file_handle ) CObject :: virtual bool Load ( const int file_handle ) CObject :: virtual int Type ( void ) const CObject :: virtual int Compare ( const CObject * node , const int mode = 0 ) const CSymbolInfo :: string Name ( void ) const CSymbolInfo :: bool Name ( const string name ) CSymbolInfo :: bool Refresh ( void ) CSymbolInfo :: bool RefreshRates ( void ) ... CSampleExpert :: bool Init ( void ) CSampleExpert :: void Deinit ( void ) CSampleExpert :: bool Processing ( void ) CSampleExpert :: bool InitCheckParameters ( const int digits_adjust ) CSampleExpert :: bool InitIndicators ( void ) CSampleExpert :: bool LongClosed ( void ) CSampleExpert :: bool ShortClosed ( void ) CSampleExpert :: bool LongModified ( void ) CSampleExpert :: bool ShortModified ( void ) CSampleExpert :: bool LongOpened ( void ) CSampleExpert :: bool ShortOpened ( void ) Success Class hierarchy: CObject ^ +--CSymbolInfo +--COrderInfo +--CPositionInfo +--CTrade +--CPositionInfo CSampleExpert
それでは、サードパーティのプロジェクトを試してみましょう。この記事のSlidingPuzzle2 EAを使います。「SourceFile = Sources/Experts/Examples/Layouts/SlidingPuzzle2.mq5」に配置しました。すべてのヘッダファイルをインクルードすると(LoadIncludes = true)、次のような結果が得られます(短縮)。
Processing Sources/Experts/Examples/Layouts/SlidingPuzzle2.mq5 Scanning... #include "SlidingPuzzle2.mqh" Including Sources/Experts/Examples/Layouts/SlidingPuzzle2.mqh #include <Layouts\GridTk.mqh> Including Sources/Include/Layouts\GridTk.mqh #include "Grid.mqh" Including Sources/Include/Layouts/Grid.mqh #include "Box.mqh" Including Sources/Include/Layouts/Box.mqh #include <Controls\WndClient.mqh> Including Sources/Include/Controls\WndClient.mqh #include "WndContainer.mqh" Including Sources/Include/Controls/WndContainer.mqh #include "Wnd.mqh" Including Sources/Include/Controls/Wnd.mqh #include "Rect.mqh" Including Sources/Include/Controls/Rect.mqh #include <Object.mqh> Including Sources/Include/Object.mqh #include "StdLibErr.mqh" Including Sources/Include/StdLibErr.mqh #include "Scrolls.mqh" Including Sources/Include/Controls/Scrolls.mqh #include "WndContainer.mqh" Including Sources/Include/Controls/WndContainer.mqh #include "Panel.mqh" Including Sources/Include/Controls/Panel.mqh #include "WndObj.mqh" Including Sources/Include/Controls/WndObj.mqh #include "Wnd.mqh" Including Sources/Include/Controls/Wnd.mqh #include <Controls\Edit.mqh> Including Sources/Include/Controls\Edit.mqh #include "WndObj.mqh" Including Sources/Include/Controls/WndObj.mqh #include <ChartObjects\ChartObjectsTxtControls.mqh> Including Sources/Include/ChartObjects\ChartObjectsTxtControls.mqh #include "ChartObject.mqh" Including Sources/Include/ChartObjects/ChartObject.mqh #include <Object.mqh> Including Sources/Include/Object.mqh Files processed: 17 Source length: 243134 File map: Sources/Experts/Examples/Layouts/SlidingPuzzle2.mq5 0 Sources/Experts/Examples/Layouts/SlidingPuzzle2.mqh 493 Sources/Include/Layouts\GridTk.mqh 957 Sources/Include/Layouts/Grid.mqh 1430 Sources/Include/Layouts/Box.mqh 1900 Sources/Include/Controls\WndClient.mqh 2377 Sources/Include/Controls/WndContainer.mqh 2760 Sources/Include/Controls/Wnd.mqh 3134 Sources/Include/Controls/Rect.mqh 3509 Sources/Include/Controls/Wnd.mqh 14312 Sources/Include/Object.mqh 14357 Sources/Include/StdLibErr.mqh 14737 Sources/Include/Object.mqh 15410 Sources/Include/Controls/Wnd.mqh 17033 Sources/Include/Controls/WndContainer.mqh 46214 Sources/Include/Controls\WndClient.mqh 61689 Sources/Include/Controls/Scrolls.mqh 61733 Sources/Include/Controls/Panel.mqh 62137 Sources/Include/Controls/WndObj.mqh 62514 Sources/Include/Controls/Panel.mqh 72881 Sources/Include/Controls/Scrolls.mqh 78251 Sources/Include/Controls\WndClient.mqh 103907 Sources/Include/Layouts/Box.mqh 115349 Sources/Include/Layouts/Grid.mqh 126741 Sources/Include/Layouts\GridTk.mqh 131057 Sources/Experts/Examples/Layouts/SlidingPuzzle2.mqh 136066 Sources/Include/Controls\Edit.mqh 136126 Sources/Include/ChartObjects\ChartObjectsTxtControls.mqh 136555 Sources/Include/ChartObjects/ChartObject.mqh 137079 Sources/Include/ChartObjects\ChartObjectsTxtControls.mqh 177423 Sources/Include/Controls\Edit.mqh 213551 Sources/Experts/Examples/Layouts/SlidingPuzzle2.mqh 221772 Sources/Experts/Examples/Layouts/SlidingPuzzle2.mq5 241539 Lines: 6102 Tokens: 27248 Defining grammar... Parsing... CRect :: CPoint LeftTop ( void ) const CRect :: void LeftTop ( const int x , const int y ) CRect :: void LeftTop ( const CPoint & point ) ... CSlidingPuzzleDialog :: virtual bool Create ( const long chart , const string name , const int subwin , const int x1 , const int y1 , const int x2 , const int y2 ) CSlidingPuzzleDialog :: virtual bool OnEvent ( const int id , const long & lparam , const double & dparam , const string & sparam ) CSlidingPuzzleDialog :: void Difficulty ( int d ) CSlidingPuzzleDialog :: virtual bool CreateMain ( const long chart , const string name , const int subwin ) CSlidingPuzzleDialog :: virtual bool CreateButton ( const int button_id , const long chart , const string name , const int subwin ) CSlidingPuzzleDialog :: virtual bool CreateButtonNew ( const long chart , const string name , const int subwin ) CSlidingPuzzleDialog :: virtual bool CreateLabel ( const long chart , const string name , const int subwin ) CSlidingPuzzleDialog :: virtual bool IsMovable ( CButton * button ) CSlidingPuzzleDialog :: virtual bool HasNorth ( CButton * button , int id , bool shuffle = false ) CSlidingPuzzleDialog :: virtual bool HasSouth ( CButton * button , int id , bool shuffle = false ) CSlidingPuzzleDialog :: virtual bool HasEast ( CButton * button , int id , bool shuffle = false ) CSlidingPuzzleDialog :: virtual bool HasWest ( CButton * button , int id , bool shuffle = false ) CSlidingPuzzleDialog :: virtual void Swap ( CButton * source ) Success Class hierarchy: CPoint CSize CRect CObject ^ +--CWnd | ^ | +--CDragWnd | +--CWndContainer | | ^ | | +--CScroll | | | ^ | | | +--CScrollV | | | +--CScrollH | | +--CWndClient | | ^ | | +--CBox | | ^ | | +--CGrid | | ^ | | +--CGridTk | +--CWndObj | ^ | +--CPanel | +--CEdit +--CGridConstraints +--CChartObject ^ +--CChartObjectText ^ +--CChartObjectLabel ^ +--CChartObjectEdit | ^ | +--CChartObjectButton +--CChartObjectRectLabel CAppDialog ^ +--CSlidingPuzzleDialog
ここでは、クラスの階層がもっと興味深いです。
さまざまなプロジェクトでパーサをテストしましたが、特定のプログラムでは「つま先をぶつける」可能性があります。まだ解決されていない問題の1つは、マクロの処理に関するものです。上述したように、正しい分析は動的な解釈であり、構文解析が始まる前にマクロを拡張して結果をソースコードに代入することが示唆されています。
現在のMQL文法では、マクロの呼び出しをそれほど厳密ではない関数の呼び出しとして定義しようとしました。しかし、それは常に間違いなくうまくいくわけではありません。
例えば、ライブラリTypeToBytesでは、マクロのパラメータはメタタイプの生成に使用されます。これがその一例です。
#define _C(A, B) CASTING<A>::Casting(B)
さらに、このマクロは次のように使用されます。
Res = _C(STRUCT_TYPE<T1>, Tmp);
このコードでパーサを実行しようとすると、STRUCT_TYPE<T1>を「消化」することができなくなります。これは、実際にはこのパラメータがテンプレート型を表しているのに対し、パーサは値、より一般的に言えば式を意味するからです(特に文字「<」と「>」は比較子として解釈されます。)同様の問題はセミコロンが後に続かないマクロの呼び出しによっても起こりますが、これは処理中のソースコードに「;」を挿入して回避することができます。
望まれる方は第3の実験(最初の2つは本稿の冒頭で述べられました)を行ってください。これは、首尾よくそのような複雑な構文解析ができるように、マクロ規則で現在の文法を修正する繰り返しアルゴリズムを探すことから始まります。終わりに
MQLで書かれたソースコードの解析を含む、一般的かつ技術的に最も単純なデータ解析の方法を考察しました。この目的のために、MQL言語の文法および標準ツール(パーサとスキャナ)の実装が提示されています。それらを使用してソースコード構造を取得すると、統計の計算、品質指標の識別、依存関係の表示、および形式の自動変更が可能になります。
同時に、特にマクロの拡張をサポートするという点で、複雑なMQLプロジェクトとの互換性を100%達成するためにはここで紹介する実装には改良が必要です。
名前の表にあるエンティティに関する情報を保存するなど、よりよい準備が必要な場合は、この方法によってコード生成の実行、一般的なエラーの制御、およびその他のより複雑なタスクの実行も可能になります。
MetaQuotes Ltdによってロシア語から翻訳されました。
元の記事: https://www.mql5.com/ru/articles/5638
- 無料取引アプリ
- 8千を超えるシグナルをコピー
- 金融ニュースで金融マーケットを探索