2016/12/12
PowerShellのAST入門
この記事はPowerShell Advent Calendar 2016の11日目です。遅刻してごめんなさい!
ASTとは
ASTとはAbstract Syntax Treeの略で、日本語では「抽象構文木」といいます。コードをパーサーが構文解析した結果から、言語の意味に関係のない要素(空白等)を除外し、木構造として構築したものです。
PowerShellでは3.0からASTの仕組みが取り入れられました。スクリプト実行時にはまずパーサーがスクリプトブロックからASTを生成し、コンパイラによってASTが解釈され、実行されるようになっています。
ASTを直接的に扱うのはコンパイラですが、実はPowerShellではパーサーが構築したASTを、PowerShellスクリプトから扱うことができます。
ASTの具体的な使い道としては、構文の静的解析が挙げられますが、その話は後でするとして、今回はまず、ASTの構成要素と構造を見ていきます。
ASTの構成要素
具体的には、{スクリプトブロック}.Astとして、ScriptblockオブジェクトのAstプロパティから、ScriptBlockAstオブジェクトにアクセスできます。このオブジェクトがASTのルートとなるノード(分岐点)を表します。このScriptBlockAstから、スクリプトブロック内部の構文要素が木構造として展開されていきます。
式(Expression)、文(Statement)といった構文要素は、各々対応したAstクラスが対応し、木構造における分岐点を形成します。また、分岐点の末端の葉では、当該の構文要素を構成するデータを示すオブジェクトが格納されます。
すべてのAstクラスは、Ast抽象クラス(System.Management.Automation.Language.Ast)を継承したクラスです。PowerShellでは50個程のAstクラスが存在します。各Astクラスは、抽象クラスで定義されている以下の2つのプロパティを持っています。
- Parent
親ノードを示すAstオブジェクトを返す - Extent
当該のASTノードに含まれるコード文字列や、スクリプト全体から見たコード文字列の位置等の情報を持つ、IScriptExtentインターフェースを実装したクラスのオブジェクトを返す
また各Astクラスは、対象の構文要素に応じて、それぞれ異なったプロパティを持ちます。たとえばScriptBlockAstは以下のプロパティを持ちます。
子の分岐点を返すもの
- UsingStatements
Using節を表す、UsingStatementAstのコレクションを返す - Attributes
スクリプトブロックに付与された属性を表す、AttributeAstのコレクションを返す - ParamBlock
paramブロックを表す、ParamBlockAstを返す - BeginBlock、ProcessBlock、EndBlock、DynamicParamBlock
各々、beginブロック、processブロック、endブロック、DynamicParamブロックを示すNamedBlockAstを返す
葉を返すもの
- ScriptRequirements
#Requires節の内容を表す、ScriptRequirementsを返す
ASTの構造
たとえば、
$scriptBlock = { param([int]$x,[int]$y) end { $out = $x + $y $out | Write-Host -ForegroundColor Red } }
という、二つの整数値の和を赤字で表示するというスクリプトブロックならば、以下のようなASTが構築されます。(一部分岐点、葉は省略しています。また、分岐点のASTクラス名は、末尾の"Ast"を省略表記しています。)
このスクリプトブロックのASTから、例えば「Red」というパラメータ値を表す、StringConstantExpressionAstまで辿るには、
$scriptBlock.Ast.EndBlock.Statements[1].PipelineElements[1].CommandElements[2]
StringConstantType : BareWord Value : Red StaticType : System.String Extent : Red Parent : Write-Host -ForegroundColor Red
のようにします。
基本的なASTの構造が頭に入っていれば、タブ補完を併用することで比較的簡単に目的のノードまで辿れますが、ASTノードの子に対し、ノード検索をかける方法もあります。
例えば、すべてのVariableExpressionAstを列挙するには、
$scriptBlock.Ast.FindAll({ param($ast) $ast -is [System.Management.Automation.Language.VariableExpressionAst] }, $true)
のように、FindAllメソッドを用います。
AST編はあと何回か続く予定です。
2015/09/07
本当は怖いPowerShell その2 コマンド名の"Get-"補完
Twitterでこんな問題を出してみました。
PowerShell検定中級編。以下を実行するとそれぞれ何が起こるか。 @ &{} A &{process} B &{process{}} C &{process{process}} D &{process{process{}}}
? 牟田口大介 (@mutaguchi) September 7, 2015
以下、解答になります。
@ &{}
結果
何も出力されません。
解説
空のスクリプトブロック{}を実行演算子&で実行しています。空なので何も出力はありません。
A &{process}
結果
「'process' の後にステートメント ブロックがありません。」というパーサーのエラーになります。
解説
PowerShellのスクリプトブロックは、beginブロック、processブロック、endブロックを内包します。スクリプトブロック直下にparamブロック、DynamicParamブロック、beginブロック、processブロック、endブロック(他にもあったかも)以外のステートメントを記述すると、Endブロック内に記述されたものと暗黙的に解釈されます。
この場合、スクリプトブロック直下にprocess…と書き始めたので、パーサーはprocessブロックが開始されたと判断しますが、続くステートメントブロック{}(≠スクリプトブロック)の記述がないため、構文エラーとなります。
B &{process{}}
結果
何も出力されません。
解説
パーサーはAのように解釈しますが、今回はステートメントブロック{}がきちんと記述されているので、エラーなく解釈されます。
processブロックは、パイプライン入力がない場合でも1回実行されますが、この場合、中身は空なので、@と同様、何も出力はありません。
C &{process{process}}
結果
Get-Processコマンドレットが実行され、プロセス一覧が表示されます。
解説
・パーサーの挙動
Bまでの解説の通り、&{process{…}}とすると、…の部分が1回実行されます。今回はprocessブロック内に「process」と記述しているので、Aのようなパーサーエラーは発生せず、「process」がステートメントとして実行されます。
さて、PowerShellのステートメント(文)には「For」とか「If」とかと並列して、「パイプライン」が存在します。「パイプライン」には1つの「式」もしくは複数の「コマンド」が含まれます。
たとえば、「Get-ChildItem | Select-Object Name」というパイプラインには「Get-ChildItem」と「Select-Object Name」という2つのコマンドが含まれます。
(ちなみに、「式」とは「$x+1」とかの、値を返すもののことです。PowerShellではパイプラインの最初の要素にのみ、「コマンド」ではなく「式」を記述することができます。)
今回のお題では、「process」はprocessブロック下に記述されており、ForやIf等のステートメントではないのでパイプラインとして扱われます。このパイプラインには1つの要素のみ含まれていますが、式ではないので、コマンドとして解釈されます。
・コマンド探索の挙動
PowerShellの「コマンド」は、関数、コマンドレット、ワークフロー、Configuration、ファイル(実行ファイル、スクリプトファイルを含む)、&演算子で実行するスクリプトブロック等が挙げられます。
コマンドの探索は、まずコマンドへのエイリアスを探します。ない場合は、関数名orコマンドレット名を探します。それでもない場合は、実行ファイルやスクリプトファイルの拡張子(.exe、.ps1等)を付与してパスの通ったディレクトリを探します(ちなみにカレントディレクトリにあったとしても、相対パスor絶対パス表記でない場合は実行しません)。
さて、ここからが「本当は怖い」ところなんですが、ここまで探索してコマンドがなかった場合、与えられたコマンド名に"Get-"を付与してもう一度探索します。
今回のお題では、processという名前のコマンドを探して、もしパスが通ったフォルダにprocess.exeとかがあればそれが実行されますが、ない場合はGet-Processというコマンド名を探します。
もちろん、Get-Processというコマンドレットは標準で存在するので、それが実行されてしまう、というわけでした。
(ちなみにPowerShell 3.0以降なら、Get-付与で見つからない場合、さらにCmdlet Auto Discoveryにより未ロードのモジュールを探します。)
コマンド探索の詳細な挙動は、Trace-Command -Expression {コマンド} -Name CommandDiscovery -PSHost とすると調べられるので、見てみるのもいいかもしれません。
D &{process{process{}}}
結果
「Get-Process : パラメーター 'Name' を評価できません。その引数がスクリプト ブロックとして指定され、入力が存在しないためです。スクリプト ブロックは、入力を使用せずに評価できません。」というParameterBindingExceptionが発生します。
解説
・パーサーの挙動
Get-Processが実行され(ようとす)る理由についてはCまでの理解でOKでしょう。
さて、Get-Processコマンドレットには-Nameという、プロセス名を指定する位置パラメータが存在します。位置パラメータは、パラメータ名を指定せずパラメータ値のみを指定しても、指定順にパラメータにバインドしてくれる機能を持ちます。
たとえば、Get-Process powershell とすると、「Get-Process -Name powershell」が実行されます。
今回のお題「process{}」は、パーサーによってまず、コマンド名「process」と、パラメータ値「{}」(空のスクリプトブロック)に分割されます。
(ちなみにコマンド名に「{}」を含めることができないわけではなく、そういうコマンドを実行したい場合は、`でエスケープするか、&"command{}name"のように&演算子を用いれば可能です。)
今回の場合、パラメータ名の指定はありませんが、位置パラメータ-Nameに空のスクリプトブロックがバインドされることになるわけです。
・コマンドパラメータバインドの挙動
さて、-Nameパラメータの型は、System.String[]であり、scriptblockではありません。もちろんscriptblockからSystem.String[]への暗黙の型変換はありません。でもエラーメッセージ的には、スクリプトブロックを与えたこと自体は咎めていないように思えますね。
実はこれ、スクリプトブロックパラメータと呼ばれてる機能です。詳しくはスクリプトブロックパラメータのススメを見ていただくとして、要はコマンドへのパイプライン入力を、指定のスクリプトブロックで処理し、その出力結果をパラメータ値としてバインドする機能ですね。
今回エラーになった理由は、スクリプトブロックパラメータとして解釈しようとしたが、そもそも入力がなかったから、ということになります。
あまり意味はないですが、以下のように入力を与えてやれば、スクリプトブロックパラメータとして動作します。
"powershell" | Get-Process -Name {$_}
この場合パイプライン入力が追加されるので、-Nameパラメータの指定位置がずれることになるので、パラメータ名が必要になります。また、スクリプトブロックが空だと、「パラメーター 'Name' を評価できません。その引数の入力によって出力が作成されなかったためです。」というエラーをご丁寧に出してくれます。Trace-CommandでParameterBindingソースをトレースしてみるのも一興でしょう。
ちなみにあまり関係ない余談ですが、-NameパラメータにはValueFromPipelineByPropertyName属性が付いているので、実は以下のような指定もできます。
[PSCustomObject]@{Name="PowerShell"} | Get-Process
まとめ
PowerShellパーサーと飲むとき、話の肴にどうですかね。
See also: 本当は怖いPowerShell その1
Copyright © 2005-2018 Daisuke Mutaguchi All rights reserved
mailto: mutaguchi at roy.hi-ho.ne.jp
プライバシーポリシー