パーボイルドについて(パート3)

パート3:データ抽出

この記事では、前に説明した構成ファイル形式のパーサーを作成します。 また、結果のツリーの要素に簡単にアクセスできるように、小さなDSLを実装します。 この記事から、ルールのタイプ、パーサーのアクション、および「暗黒物質」パーボイルド(値スタック)について学習します。

サイクル構造:



バリュースタック


ルールを使用してデータを取得する前に、Parboiledで実装されている概念の1つについて少し話す必要があります。 これは値スタックと呼ばれ、「値スタック」として正しく変換できません。 実際にはパーサーアクションによって変更されるスタックを表し、構文解析ルールの結果がスタックに配置され、そこから抽出されます。 このスタックに対して、再帰ルールを宣言するときにヒントを与える必要があります。 要素をスタックにプッシュするには、明示的にキャプチャする必要があります。これは、ルールの外観に影響します。 ルールタイプには、キャプチャされた要素の数とそのタイプも反映されます。 スタックの要素にはさまざまなタイプがあり、値のスタックのタイプはコンパイル段階でチェックされます。

ルールの種類


Parboiled2には次のルールタイプがあります。


オプションで、Parboiled1の場合のように、型のエイリアスを宣言できます。 そのため、たとえば、Parboiled2コードでは、Rule2が実装されています。

 type Rule2[+A, +B] = RuleN[A :: B :: HNil] 

0から7までの引数の数ごとにParboiled1に個別の型があり、いわゆる「 Rule7問題」を作成しましたRule8クラスはもう存在せず、値スタックに8つの要素を入れても機能しません。 この問題を回避するにはさまざまな方法がありますが、その1つについては次の記事で説明します。

パーサーアクション


パーサーのアクションは、スタック上のアクションと呼ばれる必要があります。これにより、一致したルールからデータを抽出し、それらを変換し、高度な破損の影響を受けて、副作用が発生する可能性があるためです(サイズが取得したデータの量は事前にわかりません)。 電卓を使用した例で行われているように、アクションを使用して、抽象構文ツリー( AST )を作成し、それらを使用して「インプレース」を計算できます。

刺激的なストーリー


データに対していくつかの有用なアクションを実行するには、まずそれをキャプチャする必要があります。 これを行うには、 capture関数があります。データをルールと比較し、成功した場合は値スタックに配置します。

Rule0ようなルールがRule0 、そこから少なくとも何かを取得したいとします:

 def User: Rule0 = rule { FirstName ~ Separator ~ LastName } 

セパレータが芸術的な価値を表していないことは明らかですが、キャプチャするものを正確に決定する必要があります。

 def User: Rule2[String, String] = rule { capture(FirstName) ~ Separator ~ capture(LastName) } 

これ以降、2つの行を取得して値スタックにRule2ため、ルールはRule0ではなくRule2なります。 ただし、型は省略できます。コンパイラはすべてを自分で理解します。

アクション文〜>


...または最も頻繁に使用する必要がある演算子。 正しいパラメーターとして、ラムダを受け取り、そのラムダの入力にスタックからキャプチャされたオブジェクトを送信します。これにより、ラムダがこれらのオブジェクトを操作できるようになります。 次に、必要に応じて、値をスタックに送り返すか、ASTの値からノードを作成できます。好みに合わせて選択します。 いずれにせよ、アクションを実行するには、まずcapture機能を使用してスタック上のデータをキャプチャする必要があります。 戻り値のタイプに応じて、さまざまな形式の~>演算子が使用されます。これにより、この演算子の使用が簡単かつ直感的になります。

Parboiled1では、キャプチャが暗黙的に実行されましたが、これは非常に不便です。

ラムダについてもう少し説明します。 そのシグネチャは、キャプチャされたオブジェクトの数と類型に依存し、一度にラムダは22個以下の引数をキャプチャできます 。 ラムダ引数のタイプは、スタックからプッシュされる値のタイプに対応し、戻り値のタイプは、スタックにプッシュされる値のタイプに対応します。

たとえば、パーサーから少なくとも1つの整数を抽出してみましょう。

 def UnsignedInteger: Rule1[Int] = rule { capture(Digit.+) ~> (numStr => numStr.toInt) } 

このような状況では、独自のSkalovプレースホルダーの使用が推奨されます。

 def UnsignedInteger: Rule1[Int] = rule { capture(Digit.+) ~> (_.toInt) } 

ここで、ラムダにはタイプ(String => Int) 、これがルールのタイプを決定しますRule1[Int] 。 型付きルールに~>演算子を適用することができます。たとえば、次のルールは整数に一致しますが、スタックには配置せず、その二重の値になります。

 def TwoTimesLarger = rule { UnsignedInteger ~> (i => i * 2) } 

ルールタイプTwoTimesLargerRule1[Int]ままで、異なる値のみがスタックに置かれます。

ラムダ関数への引数のタイプを明示的に指定することは(少なくとも執筆時点では)良い考えではありません。 Scalaコンパイラには、コードの正常なコンパイルを妨げる非常に厄介なバグがあります。

1つの引数を見つけましたが、複数ある場合はどうでしょうか? ラムダはどのように動作しますか? シンプルで予測可能:最初のパラメーターはスタックの最高値に対応し、2番目のパラメーターは上から2番目のパラメーターに対応します。 部分式をキャプチャする手順は右から左に実行されるため、ラムダ関数の引数の順序はキャプチャ操作を記録する順序に対応します。

 def UserWithLambda: Rule2[String, String] = rule { capture(FirstName) ~ Separator ~ capture(LastName) ~> ((firstName, lastName) => ...) } 

アクション演算子のおかげで、スタック上の値の数を減らすことができます。

 def UserName = rule { User ~> ((firstName, lastName) => s"$firstName $lastName") } 

指定された例では、 Userルールの初期タイプはRule2[String, String]で、ラムダ関数を適用して、タイプRule1[String]新しいUserFirstNameルールを作成しました。

ラムダは、スタックからすべてのパラメーターを受け入れる必要はありません。最後のN個の値に制限することができます(ラムダはスタックの最後から引数を取ることに注意してください)。

 (foo: Rule2[Int, String]) ~> (_.toDouble) // foo: Rule2[Int, Double]. 

引数のないラムダ関数にルールをフィードしようとすることを妨げるものは何もありません。予測可能な結果が得られます。

 (foo: Rule0) ~> (() => 42) // foo: Rule1[Int]. 

Parboiled2には、より強力なツールがあります。たとえば、ラムダからスタックに値のグループをすぐに返す機能があります。

 (foo: Rule1[Event]) ~> (e => e::DateTime.now()::"localhost"::HNil) // foo: RuleN[Event::DateTime::String::HNil] 

実際、ブランド化された形のないHList設計しています。 結果のルールのタイプはRuleN[Event::DateTime::String::HNil]ます。

同様に、値を返すことなく値スタックから値を取得できます。このために、ラムダは単純にUnit型を「返す」必要があります。 おそらく推測されるように、結果のルールのタイプはRule0になります。

 (foo: rule1[String]) ~> (println(_)) // foo: Rule0 

さらに、アクション演算子は、ケースクラスに特に甘い砂糖を提供します。

 case class Person(name: String, age: Int) (foo: Rule2[String, Int]) ~> Person // foo: Rule1[Person] 

確かに、ケースクラスにコンパニオンオブジェクトが定義されている場合、コンパイラはこの砂糖を消化しない可能性があることに注意してください。 次に、ラムダといくつかの下線を追加して、 ~> (Person(_, _))を記述する必要があります。

ケースクラスのシュガーはASTの構築に理想的です。経験豊富なユーザーは、この場合はParboiled1の~~>演算子とまったく同じように機能することに気付くかもしれません。 ~>を使用する方法は他にもありますが、それらについては私からではなく、ドキュメントから学びます。 演算子~>は非常に重要な方法でParboiled2コードに実装されていることだけに注意しますが、その定義がどれほど難しくても、それを使用するのは楽しいことです。 おそらく、DSLを作成する段階で行われる最良の技術的決定でしょう。

走る


スリルを求める人のためのアクション演算子の特別なバージョン。 プログラマの場合、多くの点で、 run~>とまったく同じように動作しrunが、 runの場合runコンパイラが型を自動的に出力せずrun明示的に指定する必要がある場合のわずかな不便さを除きます。 演算子は、たとえば次のような未確認の副作用を作成するための非常に便利なツールです。

 def RuleWithSideEffect = rule { capture(EmailAddress) ~ run { address: String => send(address, subj, message) } ~ EOI } 

結果のルールのタイプはRule0になり、マッピングされた文字列は誰も必要とせず、値スタックに分類されません。 Parboiled1ユーザーは、おそらく上記のコンテキストでは、 run~%演算子と同じようにrunすることに気付いています。

警告:副作用を使用するときは、値のスタックをいじらないでください。 はい、直接アクセスできますが、いくつかの理由により、これを行わない方が良いでしょう。

押す


対応するルールがマップされている場合、 push関数はデータを値スタックにpushます。 実際には、 ~>演算子がほとんどの作業を実行できるため、頻繁に使用する必要はありませんでしたが、 push単純に輝く例があります。

 sealed trait Bool case object True extends Bool case object False extends Bool def BoolMatch = rule { "true" ~ push(True) | "false" ~ push(False) } 

これはどこにも記載されていませんが、このルールは名前による呼び出しのセマンティクスに従い、毎回評価されます。つまり、その引数は毎回評価されます。 通常、これはパフォーマンスに悪影響を与えるため、 push定数でのみ使用するのpush最適です。

runおよび~>と同様に、 push渡される値のタイプによって、スタックの内容と作成されるルールのタイプが決まります。

ネストされたパーサー


Parboiled2では、ネストされたパーサーのサポートがあります。テキストを取得して~>演算子~>渡すと、ラムダ関数のパラメーターとして文字列型の変数を取得します。 文字列でのいくつかの操作の後、それをサブパーサーなどに送ることができます。 実際には、申請する必要はありませんでしたが、そのような機会があることを知っておく必要があります。

AST生成


構文木を生成する独自のパーサーを作成するために必要なすべての知識があります。 構文ツリーはノードから構築されます。 したがって、私たちはそれらから、またはむしろそれらの説明から始めます:

 sealed trait AstNode case class KeyValueNode(key: String, value: String) extends AstNode case class BlockNode(name: String, nodes: Seq[AstNode]) extends AstNode 

各ケースクラスは特定のタイプのノードに対応しており、すべてが明確で理解しやすいようです。 ただし、上記のノードに共通するものを見つけてみましょう。 誰もが名前を持っています。キーと値のペアの場合、これがキーです。 相互間のノードも何らかの方法で区別する必要があります。

 sealed trait AstNode { def name: String } case class KeyValueNode (override val name: String, value: String) extends AstNode case class BlockNode (override val name: String, nodes: Seq[AstNode]) extends AstNode 

キーと値のペアのノードから始めましょう。 ~>演算子を使用して、キーをキャプチャし、値をキャプチャし、ケースクラスですべて収集する必要があります。 「キーと値のルールで」「インプレース」で行うキャプチャ。 そして、キーから始めます。

 //          def Key: Rule1[String] = rule { capture(oneOrMore(KeySymbol)) } 

captureを追加captureだけで、それで終わりです。 文字列はスタックに送信されます。 しかし、価値の獲得により、状況はより複雑になります。 キーに似た操作を実行すると、引用符付きの文字列が取得されます。 それらが必要ですか? したがって、ラインの領域でキャプチャを実行します。

 def QuotedString: Rule1[String] = rule { '"' ~ capture(QuotedStringContent) ~ '"' } 

Valueルールの場合、何もする必要はありません。自動的にRule1タイプになります(行の本文は以前にキャプチャされているため、スタックのどこにも行かなかったため)。

キャプチャcaptureは1回行う必要があります。 そしてできれば、彼が起こることになっていたルールで

ケースクラスをアセンブルしましょう。

 def KeyValuePair: Rule1[AstNode] = rule { Key ~ MayBeWS ~ "=" ~ MayBeWS ~ Value ~> KeyValueNode } 

構文糖を使用し、結果のキーと値を適切なノードにエレガントにパックします。 もちろん、拡張ラムダ構文を使用して、何らかの変換を実行できます。 しかし、それらは必要ありません。 次に、ノードのリストを扱います。

 //     ,       def Node: Rule1[AstNode] = rule { KeyValuePair | Block } 

各ノードがキャプチャされるため、スタックにプッシュされる値のタイプを指定する価値がない限り、 Nodesルールは変更を必要としません。

 def Nodes: Rule1[Seq[AstNode]] = rule { MayBeWS ~ zeroOrMore(Node).separatedBy(NewLine ~ MayBeWS) ~ MayBeWS } 

ブロックノードを説明するすべてのものがあります。 キーのルールと同様に、名前は適切にキャプチャされます。

 def BlockName: Rule1[String] = rule { capture(oneOrMore(BlockNameSymbol.+)) } 

ノードは既にキャプチャされているため、ケースクラスでデータを収集するだけです。

 def Block: Rule1[AstNode] = rule { BlockName ~ MayBeWS ~ BlockBeginning ~ Nodes ~ BlockEnding ~> BlockNode } 

ツリーのルートを記述するルールもノードで構成されているため、これ以上何もできません。 そして、すべてがうまく機能しているようで、何も変更したくないのですが、結果はあまり良くありません。2種類のノードと、ノードのリストを表すルートがあります。 そして3番目は明らかに余分です。 ルートを特別な名前でブロックとして表すことができます。

 def Root: Rule1[AstNode] = rule { Nodes ~ EOI ~> {nodes: Seq[AstNode] => BlockNode(RootNodeName, nodes)} } 

どの名前を選択しますか? ブロックに完全に意識した名前、たとえばルートを付けることができますが、誰かがルート名を選択したい場合、予想外の驚きが待っています。 BlockNameは多くの文字を許可しない識別子であることがわかっているため、 "$root""!root!"などの名前を試すことができます"!root!" または"%root%" 。 動作します。 私は空の文字列を好むでしょう:

 val RootNodeName = "" 

空行:


これで、キャプチャされたデータができました。 適切なテキストのルートから実行するためだけに残ります。

ノードを操作するためのDSL


構文ツリーをレンダリングできる実用的なパーサーを受け取ったので、何らかの方法でこのツリーを操作する必要があります。 小さなDSLを作成すると、このタスクが大幅に簡素化されます。 たとえば、名前で次のノードに移動する必要があります。 毎回同じコードを書くことも、次のノードを返すことができる小さなメソッド(オーバーロードされた演算子によって複製される)を作成することもできます。 以下は、AstNodeを操作するために必要な基本的な方法です。 これに基づいて、他の多くのものを作成できます(ニーズに最も適しています)。 必要に応じて、シンボル名を付けて、作成されたDSLの美しさを楽しむことができます。

 /** *       parboiled */ trait NodeAccessDsl { this: AstNode => def isRoot = this.name == BkvParser.RootNodeName lazy val isBlockNode = this match { case _: KeyValueNode => false case _ => true } /** *         * - */ def pairs: Seq[KeyValueNode] = this match { case BlockNode(_, nodes) => nodes collect { case node: KeyValueNode => node } case _ => Seq.empty } /** *        *  */ def blocks: Seq[BlockNode] = this match { case BlockNode(_, nodes) => nodes collect { case node: BlockNode => node } case _ => Seq.empty } /** *     "-" */ def getValue: Option[String] = this match { case KeyValueNode(_, value) => Some(value) case _ => None } } 

余分なメソッドはなく、それらが必要とされるたびに、再帰検索、ノードの値を変更する機能(状態の変更、 レンズの使用など )があることに注意してください。 木材を扱うさまざまな補助方法の存在は、生活を大幅に簡素化します。

その結果、Parboiled2を使用して機能的なパーサーを作成し、結果の構文ツリーでの作業を比較的快適にしました。 次の記事では、ライブラリの追加機能とパフォーマンスを最適化するプロセスについて説明します。 以前のバージョンからの移行プロセスも考慮されます。 欠点と、これらの欠点に対処する方法について説明します。

こちらのパーサーコードに慣れることができます

Source: https://habr.com/ru/post/J270609/


All Articles