2016/12/12

この記事は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"を省略表記しています。)

PowerShell_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編はあと何回か続く予定です。

2016/01/11

注:最後に書きましたが、この記事の内容には未解決問題が残っています。

はじめに

前回は、パイプラインを下流の関数内で打ち切る方法について説明しました。

今回は逆に、下流でパイプラインが打ち切られた場合、上流の関数ではどう対応すべきなのか、特にリソース解放処理を焦点にして考察してみます。

パイプラインが打ち切られるケース

前回も説明しましたが、パイプライン内の関数またはコマンドレットで何らかの例外が発生すると、パイプライン処理がその時点で打ち切られます。パイプライン処理が打ち切られると、後続のprocessブロックのみならず、パイプラインに含まれるすべてのendブロックの実行もスキップされてしまいます。

ところでコマンドレットや関数では何かエラーが発生した場合、コマンドレットではWriteErrorメソッド、関数ではWrite-Errorコマンドレットを使ってエラーストリームに、生の例外オブジェクトをラップしたErrorRecordを出力し、呼び出し側のErrorActionの設定にエラー時の処理を委ねるのが基本です。

呼び出し時のErrorAction指定がContinue、SilentlyContinue、Ignoreの場合は、パイプラインが中断することはありませんが、StopやInquireで中断した場合は例外(ActionPreferenceStopException)がthrowされ、パイプラインは打ち切られます。
(ちなみにv3からはStopで中断した場合は、コマンドレットがエラーストリームに書き出したErrorRecordに含まれるExceptionがthrowされる)

また、継続不能エラーが発生(ThrowTerminatingErrorメソッド)したり、.NETの生の例外がそのままthrowされたり(お行儀が悪いですが)、breakステートメント(FlowControlException)が実行されたり、Select-Object -First(StopUpstreamCommandsException)が実行された場合も、同様にパイプラインは打ち切られます。

つまり、下流のコマンドでパイプラインが打ち切られるケースというのは色々あり得るので、endブロックというのは実行が確約されたものでは全くない、ということに留意しておく必要があります。

関数内のリソース解放処理

確実に実行したい後処理というのは色々あると思いますが、特に確保したリソースの解放というのは確実に実行してもらわないと困ります。

しかし、前述のような背景があるので、何らかのリソースを用いる関数を書く際に、beginブロックでリソースを確保し、processブロックでリソースを利用し、endブロックでリソースを解放する、ということは基本はNGということになります。

この点、コマンドレットクラスであれば、IDisposableインターフェースを実装しておけば、コマンドレット終了時にDisposeメソッドをPowerShellが呼んでくれるので、その中にリソース解放処理を記述しておけばOKです。しかし関数ではこの手法が使えないので、代替案を考える必要があります。

パイプライン処理の後始末をしよう - 鷲ノ巣であえとす氏が考案した、beginブロックに後処理用の関数を定義しておく方法では、同じ関数内のprocessブロックで発生したエラーをcatchしてリソース解放処理を走らせることは可能です。が、残念ながら下流で発生した例外をcatchすることはできず、その場合は後処理がスキップされてしまいます。

リソース解放処理を含めた関数

あえとす氏の方法を若干アレンジして、下流で例外が発生した場合も確実にリソース解放の後処理を走らせる方法を考えてみました。以下にコードを示します。

※クラス構文を使ってますが、これは単に、Disposeできるオブジェクトのサンプルだと思ってください。

class SomeResource : IDisposable
{
    [void]Dispose()
    {
        # ここで何らかのリソース解放処理を行ったものとする
    }
}

function Write-OutputWithResource
{
    [CmdletBinding()]
    param([Parameter(ValueFromPipeline)][psobject[]]$InputObject)
    begin
    {
        $resource = New-Object SomeResource # 後で解放する必要のある何らかのリソース確保

        function Clear-Resource
        {
            # リソースの解放処理
            $resource.Dispose()
        }
    }
    process
    {
        try
        {
            $processing = $true
            Write-Verbose "output_start: $InputObject"
            $InputObject # パイプライン出力
            Write-Verbose "output_end: $InputObject"
            $processing = $false
        }
        catch
        {
            # try内で例外が発生した場合はそのまま再スロー
            throw
        }
        finally
        {
            if($processing)
            {
                Write-Verbose "resource_dispose (on error)"
                Clear-Resource
            }
            Write-Verbose "output_finally: $InputObject"
        }
    }
    end
    {
        Write-Verbose "resource_dispose (at end)"
        Clear-Resource
    }
}

processブロック内のtryブロックでパイプライン下流に値を出力したとき、下流で例外が出てもcatchブロックが実行されないので、代わりに、必ず実行されるfinallyブロックから後処理を呼び出すようにしてみました。

ただし、パイプライン下流で例外が発生しなかった場合には、processブロック内ではリソース解放処理はしたくないので、例外発生の有無を$processingという変数の値を見ることで確認しています。もしパイプライン出力したあと下流で例外が出ていれば、$processingの値は$trueのままになるので判断可能です。

パイプラインが中断することなく、最後まで実行される場合は、endブロック内でリソース解放処理を行います。

同じ関数内で例外が発生したときのリソース解放処理についても、$processing = $trueと$processing  = $falseの間に例外が発生する可能性のある処理を記述した上で、catchブロックで再throwすれば、併せて対応できるのではないかと思います。(それ用のtry..catchを記述してもいいですが)

関数の実行例
PS> 1..3 | Write-OutputWithResource -Verbose | Select-Object -First 2
詳細: output_start: 1
1
詳細: output_end: 1
詳細: output_finally: 1
詳細: output_start: 2
2
詳細: resource_dispose (on error)
詳細: output_finally: 2

このように、下流でパイプライン打ち切りがあるとendブロックは実行されませんが、リソース解放処理は、きちんとprocessブロック内のfinallyブロックから呼び出されています。

PS> 1..3 | Write-OutputWithResource -Verbose
詳細: output_start: 1
1
詳細: output_end: 1
詳細: output_finally: 1
詳細: output_start: 2
2
詳細: output_end: 2
詳細: output_finally: 2
詳細: output_start: 3
3
詳細: output_end: 3
詳細: output_finally: 3
詳細: resource_dispose (at end)

もちろんパイプラインが最後まで正常に実行された場合も、ちゃんと最後にendブロックでリソース解放が行えるようになっています。

おわりに

本当に、こうするしかないんですかね…?

PowerShellにもリソース利用のusingステートメント欲しいです、が、begin, process, endにまたがって機能するusingってどういう構文になるんでしょうね。

(14:22追記)と、ここまで書いておいて、この方法では下流で発生した例外には対処できますが、上流で例外が発生した場合はリソース解放が実行されないという問題に気付きました…。どうすればいいんだ…。

2016/01/03

PowerShellのパイプライン処理

まず、PowerShellのパイプライン処理について軽くおさらいします。

例えば、@、A、Bをそれぞれ何らかのコマンドとしたとき、

@|A|B

というパイプラインがあったら、処理の流れは、

@begin→Abegin→Bbegin→(@process→Aprocess→Bprocess→)×n→@end→Aend→Bend

の順に実行されます。(processブロックで「1入力に対し1出力する」場合以外は必ずしもこうならないですが)

さて、AかBのprocessブロック実行中に、何らかの条件を満たした時、パイプラインのprocessの後続処理を打ち切りたい場合はどうすれば良いでしょうか。

まずはbreakを使った駄目な例

ネットでよく見かける以下のようなコード、すなわち「パイプラインはbreakで処理を打ち切ることができる」というのは実は正しくないのです。

function Select-WhileTest
{
    [CmdletBinding()]             
    param (             
        [parameter(ValueFromPipeline=$true)] 
        [psobject[]]$InputObject,
        [parameter(Position=0)]           
        [scriptblock]$Predicate
    )

    process
    {
        if(!(&$Predicate))
        {
            break
        }
        $InputObject
    }
}

このコードはv2までではそもそも正しく動作しませんが、v3以降では条件によっては正しく動作しているように見えるのが、誤解の元なのかと思います。(というか私も誤解してました。)

例えば、

$result = "初期値"
$result = &{end{foreach($i in (1..5)){$i}}} | Select-WhileTest {$_ -lt 3}
Write-Host "`$resultは $result です。"

のようにすると、

$resultは 1 2 です。

のように、想定した通りの結果が得られます。このように、上流のスクリプトブロックのendブロック内にforeachなどループブロックが存在し、そのループブロック内で下流に値を出力している場合はうまくいきます。(ちなみに、スクリプトブロック直下に記述するのとendブロック内に記述するのは等価。)

しかし、上流にループブロックがない場合、例えば

$result = "初期値"
$result = 1..5 | Select-WhileTest {$_ -lt 3}
Write-Host "`$resultは $result です。"

とすると、コンソールに1と2が改行区切りで表示されますが、ホストに表示されるだけで$resultには値は格納されません。そしてスクリプト化して実行した場合は、Write-Hostが実行されることすらなく、スクリプトが終了してしまいます。

breakだとなぜうまくいかないのか

結局どういうことかというと、パイプライン下流のbreakは、パイプラインを打ち切る処理をするのではなく、単に一つ上流のブロックをbreakする処理に過ぎないのです。

パイプライン上流にループブロックがある場合は、そのループブロックをbreakしますが、それ以外の場合はスクリプトのbegin, process, endのいずれかのブロックがbreakされてしまい、結果としてスクリプトが終了してしまうわけですね。

そして、このSelect-WhileTest関数だと大丈夫ですが、processブロックの中にループブロックを記述し、その中でbreakを書くのは当然ダメです。単にそのループを抜けるだけなので、上流の出力は止まってくれません。

breakではなくcontinueを使う場合も基本は同じ結果です。しかもcontinueは所詮、その名の通り継続処理なので、上流に以下のような無限リストがあると無限ループに陥ってしまいます。

&{begin{$i = 0} process{while($true){$i++; $i}}}|Select-WhileTest {$_ -lt 3}

breakの代わりに、

throw (New-Object System.Management.Automation.PipelineStoppedException)

を実行する方法も見かけますが、これはループブロックがあっても強制的にスクリプトが終了するので余計ダメです。try...catchでエラートラップすればスクリプトの終了は回避できますが、「パイプラインが正常終了せずエラーを出した」扱いであることには代わりないので、やはり出力を変数に格納することができません。

ダミーループを用いる、取りあえずの解決策

前述のbreakを使った方法の問題点のうち、上流にループブロックがないとスクリプトが終了してしまい、出力を変数に代入することもできない問題は、とりあえず解決する方法があります。

以下のように、呼び出す時にパイプライン全体をダミーのループブロックでラップすれば良いのです。

$result = "初期値"
$result = do{
    1..5 | Select-WhileTest {$_ -lt 3}
}until($true)
Write-Host "`$resultは $result です。"

このようにしておけば、breakはパイプラインの外側のdo...untilを抜ける効果になるので、スクリプトが終了する心配も、値を出力しない問題も起こりません。

元々、パイプライン上流にループブロックが存在する場合でも、単にdoループ内の処理が1回走るだけなので、特に問題は起きません。1回だけ処理を実行するダミーループなら、for($i=0; $i -lt 1; $i++){}とかでも良いです。

ただ…この記述を美しいと思う人は多分いないですね。事情を知らないと意味不明ですし。そして、breakを記述する側の関数には、前述の通りのループブロック内では値を出力できないという制限は残ったままになります。

やはりbreakでパイプラインを打ち切るのは、本来想定された動作かと言われるとかなり微妙なところだと思います。(v3で一応動くようになったとはいえ)

この方法についての参考記事:Cancelling a Pipeline - Dreaming in PowerShell - PowerShell.com ? PowerShell Scripts, Tips, Forums, and Resources (ただしv2準拠の内容であることに注意)

ところで、Select-Object -Firstは…

さて、話は変わって、PowerShell 3.0からはSelect-Object -First の処理が変わったことについては、ご存知の方も多いかと思います。

具体的には、v2までは単にパイプライン処理をすべて終了してから、最初のn件を抽出する処理であったn件のパイプライン出力がされた後は、入力をすべてフィルタし出力に流さなくなる動作であったところが、v3からはn件のパイプライン出力がされた時点で、パイプラインの処理を打ち切るようになりました。(1/5修正)

つまり、

$result = 1..5| &{process{Write-Host "Process:$_"; $_}}| Select-Object -First 2
Write-Host "`$resultは $result です。"

というスクリプトは、v2までは

Process:1
Process:2
Process:3
Process:4
Process:5
$resultは 1 2 です。

のようにパイプライン出力は指示通り2件であるものの、上流の処理は結局、全部実行されてしまっています。

一方v3以降では、

Process:1
Process:2
$resultは 1 2 です。

のように、きちんと上流の処理を打ち切ってくれています。

つまり、ここまで述べてきたパイプライン処理の打ち切りは、実はv3以降のSelect-Object -Firstでは実現できているということです。これと同じことを我々も自作関数の中でやりたいわけです。

ではSelect-Object -Firstは具体的にどういう処理をしているかというと、StopUpstreamCommandsExceptionという例外をthrowすることでパイプライン処理の打ち切りを実現しています。この例外はまさに名前の通り、パイプライン上流の処理を中止するための例外です。この例外を自作関数でthrowしてやればうまくいきそうです。

しかし、この例外は非publicな例外クラスであることから、New-Objectコマンドレットなどでインスタンス化することはできません。リフレクションを駆使する必要がでてきます。
参考:PowerShell 3.0からはじめるTakeWhile - めらんこーど地階

(1/5追記)参考2:パイプラインの処理を途中で打ち切る方法のPowerShell版 - tech.guitarrapc.cóm(Add-TypeでC#経由でリフレクションしてます。)

頑張ればできなくはないですが、もっと楽な方法はないものか…と思ってあきらめかけたところ、いい方法を思いついたので紹介します。

Select-Object -Firstを利用する方法

Select-Object -Firstでできることが我々には(簡単には)できない。ならばどうするか。Select-Object -Firstを使えばいいじゃない。という発想です。

function Select-While
{
    [CmdletBinding()]             
    param (             
        [parameter(ValueFromPipeline=$true)] 
        [psobject[]]$InputObject,
        [parameter(Position=0)]           
        [scriptblock]$Predicate
    )

    begin
    {
        $steppablePipeline = {Select-Object -First 1}.GetSteppablePipeline()
        $steppablePipeline.Begin($true)
    }

    process
    {
        if(!(&$Predicate))
        {
            $steppablePipeline.Process($InputObject)
        }
        $InputObject
    }

    end
    {
        $steppablePipeline.End()
    }
}

scriptblockにはGetSteppablePipelineというメソッドが存在し、このメソッドによりSteppablePipelineオブジェクトが取得できます。これは何かというと、要は「スクリプトブロック内のbegin, process, endを個別に実行する」ための機能です。
参考:PowerShell: ◆パイプライン入力・パラメータ入力対応のGridView出力関数を作る(私自身も以前この記事で知りました。)

{Select-Object -First 1}というスクリプトブロックは、1回目に実行するprocessブロック内でStopUpstreamCommandsExceptionを出してくれます。

よって、自作関数のprocessブロック内のパイプライン処理を打ち切りたい箇所で、SteppablePipelineオブジェクトのProcessメソッドを使うことで、{Select-Object -First 1}のprocessブロックの処理を呼び出してやればいいわけです。

このようにして作成したSelect-While関数を以下のように実行してみると、

# 上流にループあり
$result1 = &{end{foreach($i in (1..5)){$i}}} | Select-WhileTest {$_ -lt 2}
Write-Host "`$result1は $result1 です。"

# 上流にループなし
$result2 =  1..5 | Select-While {$_ -lt 3}
Write-Host "`$result2は $result2 です。"

# 上流に無限リスト
$result3 = &{begin{$i = 0} process{while($true){$i++; $i}}} | Select-While {$_ -lt 4}
Write-Host "`$result3は $result3 です。"

結果は

$result1は 1 です。
$result2は 1 2 です。
$result3は 1 2 3 です。

となり、少なくとも今まで述べてきた諸問題はすべて解消していることが分かると思います。

このSelect-While関数は、スクリプトブロックで指定した条件を満たさなくなったときに、パイプライン処理を打ち切ってくれるものですが、この「Steppable Select -First 方式」を使えば他の自作関数でも、割と気楽に呼べるんじゃないかなと思います。ループブロック内で呼び出すことももちろん可能です。

ただし問題点はある

これはSelect-Object -FirstというかStopUpstreamCommandsExceptionあるいはPowerShellのパイプライン機構の仕様に由来する問題であると思われるので、どうにもならないことではあるんですが、一点だけ注意事項があります。

$result = 1..5| &{
    begin
    {
        Write-Host "Begin"
    }
    process
    {
        Write-Host "Process:$_"
        $_
    }
    end
    {
        Write-Host "End"
    }
}| Select-While {$_ -lt 2}

Write-Host "`$resultは $result です。"

これの結果は

Begin
Process:1
Process:2
$resultは 1 です。

となり、なんとendブロックが実行されていません。Select-While {$_ -lt 2} の代わりに Select-Object -Firstを使っても、同様にendは実行されません。

つまり、StopUpstreamCommandsExceptionというのはパイプライン処理を打ち切って、そこまでの出力値を正しくパイプライン出力として出してくれますが、やってくれるのはそこまでで、最後のendブロック処理はしてくれません。

これは十分注意が必要な点で、自作関数内でbeginブロックで確保したリソースをprocessブロックで利用して、endブロックで解放する…という、いかにも書いてしまいそうなパターンは、実はNGなんですね。何も上のようにマニアックなことをしなくても、単に下流でSelect-Object -Firstを使うだけでアウトです。

じゃあ、リソースの取り回しはどうするのが良いの、って話もありますが、それはまたの機会にしましょう。

(1/5追記)あえとすさんの記事が参考になります。:パイプライン処理の後始末をしよう - 鷲ノ巣 ただ、この方法ではパイプライン下流でthrowされた場合はトラップできないぽいですね。コマンドレットクラスの場合はIDisposable実装で良さそうです。

ここからは私見ですが、StopUpstreamCommandsExceptionが後付けかつ非パブリックなところとか、パイプラインを合法的に脱出するステートメントが今に至るまで用意されていないところとか、パイプラインを何とかして途中で打ち切っても、endは実行されないところとかを見ていると、そもそもPowerShellではパイプライン処理の中断というのは、あまり想定してない操作なのかなぁ、という気がしてきています。

上記のような裏技を使って回避するのも一案ではあるとは思いますが、そもそも「パイプライン処理の中断はイレギュラー」と考えて、そういう処理は避けて、必要に応じて別のアプローチを取ることも考えた方がいいのかもしれません。

2015/09/07

Twitterでこんな問題を出してみました。

以下、解答になります。

@ &{}
結果

何も出力されません。

解説

空のスクリプトブロック{}を実行演算子&で実行しています。空なので何も出力はありません。

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

2013/03/29

はじめに

Twitterブログ: 日本の皆さんにも「全ツイート履歴」が使えるようになりました の記事のとおり、自分の全ツイートデータをダウンロードする機能がTwitterで利用可能になっています。

ダウンロードされるzipファイルには、ツイートを表示するためのHTML、JavaScriptファイルのほか、CSV形式のデータ(tweets.csv)も含まれています。CSVファイルの処理といえばPowerShellが得意とするところです。このファイルを読み込んで、PowerShellで自分のツイートを分析してみましょう。

準備

具体的にダウンロードする方法は上記記事を参考にしていただいて、まずはダウンロードしたzipファイルからtweets.csvを解凍し、PowerShellのカレントディレクトリをtweets.csvのあるフォルダに移動させておいてください。

毎回CSVを読み込むと時間がかかるので、まず以下のようにしてImport-CsvコマンドレットによりCSVファイルを読み込み、変数にオブジェクトとして入れておきます。

$tweets = Import-Csv tweets.csv

なお私の総ツイート数は4万ほどで、tweets.csvは10MB程です。これくらいの容量だとそのままでもまずまずまともな速度で分析が可能ですが、何十万ツイートもしていらっしゃるTwitter廃人マニアの方は、適宜ファイルを分割するなどして対処願います。

CSVファイルのヘッダ行は

"tweet_id","in_reply_to_status_id","in_reply_to_user_id","retweeted_status_id","retweeted_status_user_id","timestamp","source","text","expanded_urls"

となっています。Import-Csvコマンドレットはデフォルトでは1行目を出力オブジェクトのプロパティ名とするので、データ行の1行がtweet_idプロパティ等を持つオブジェクトとして読み込まれ、$tweets変数にはそのオブジェクトの配列が格納されることになります。

ツイート抽出/検索
一番最初のツイートを表示
PS> $tweets | select -Last 1

tweet_id                 : 948090786
in_reply_to_status_id    : 
in_reply_to_user_id      : 
retweeted_status_id      : 
retweeted_status_user_id : 
timestamp                : 2008-10-06 10:54:10 +0000
source                   : web
text                     : はぐれメタルがあらわれた!
expanded_urls            : 

Select-Objectコマンドレット(エイリアスselect)はオブジェクトの絞り込みに使います。このCSVファイルではツイートの並び順がタイムスタンプの降順なので、最初のツイートは一番最後の行となります。

直近5ツイート表示
PS> $tweets | select -First 5 | fl timestamp,text

timestamp : 2013-03-21 17:02:23 +0000
text      : Need for Speedがなんか懐かしい。初めて買ったPCに体験版がバンドルさ
            れてた記憶がある。

timestamp : 2013-03-21 17:01:23 +0000
text      : そいえばEAのシムシティ不具合お詫び無料DL特典、何選ぼうかなあ。シム
            シティ4あるけど英語版という噂だし2013やった後につらいもんがありそう
            。

timestamp : 2013-03-21 16:45:09 +0000
text      : というわけでシムシティ大好きなんで、私の街を返してください…

...

Format-Listコマンドレット(エイリアスfl)を使うと必要なプロパティ値のみ抽出してリスト形式で表示できます。

文字列で検索
PS> $tweets | where {$_.text -match "眠い"} | fl timestamp,text

timestamp : 2013-03-05 10:46:39 +0000
text      : 眠いのってもしかしてアレルギールの副作用かも。蕁麻疹がひどいときし
            か飲んでないんだけどねえ

timestamp : 2013-03-05 05:42:18 +0000
text      : なんでこんなに眠いのかな

timestamp : 2013-03-04 07:44:18 +0000
text      : 眠いなあ

...

Where-Objectコマンドレット(エイリアスwhere)を使うとオブジェクト配列のうち特定条件のもののみ抽出できます。ここではツイート本文(textプロパティ)に"眠い"という文字列が含まれているものを抽出しています。どんだけ眠いんですか私は…

2009年のツイートのみ表示
PS> $tweets | select @{L = "timestamp"; E = {Get-Date $_.timestamp}},text | 
    where {$_.timestamp.Year -eq 2009} | sort timestamp |
    fl timestamp,text

timestamp : 2009/01/01 0:01:08
text      : あけおめ!

timestamp : 2009/01/01 0:16:31
text      : 2chとついったー強いなーmixiしんでた

timestamp : 2009/01/01 13:37:50
text      : 家族でおせちをたべた。おいしかった

...

もちろん本文に含まれる文字列以外にも、timestamp(ツイート時刻)で抽出するなどもできます。ここではtimestampがGMTで分かりづらく、かつ文字列のため扱いづらいので、Select-Objectに集計プロパティを指定してDateTime型に変換しています。Format-ListやSelect-Objectに指定する集計プロパティの書式は、@{L="ラベル";E={値を返すスクリプトブロック}}のように連想配列で指定します。LはLabel、EはExpressionのように省略せずに指定してもOKです。

集計プロパティはあんまり解説を見かけないですけども、オブジェクトを処理するコマンドレットの多くで利用可能できわめて重要なので覚えておくと良いと思います。

よるほ成功ツイート
PS> $tweets | where {(Get-Date  $_.timestamp).ToString("HH:mm:ss") -eq "00:00:00"} | 
    fl @{L = "timestamp"; E = {Get-Date $_.timestamp}},text

応用でこんなんもできます。0:00:00ちょうどのツイートを抽出します。私はかつてよるほ成功したことがないので結果は何も返ってきませんけど。

ツイート中のURLリストを作る
PS> $tweets | where {$_.expanded_urls} | select -expand expanded_urls
http://ja.wikipedia.org/wiki/%E5%B2%A1%E7%B4%A0%E4%B8%96
http://htn.to/4oxXDN
http://guitarrapc.wordpress.com
...

whereによる抽出を応用するとこういうこともできます。なお、expanded_urls列は本文中のURLが複数含まれているとそれらは,で区切られるため、可変長の行となります。Import-Csvコマンドレットはこのような可変長なCSVに対応していないので、複数URLがあっても最初の1つのみ取得します。それとexpanded_urlsが追加されたのはt.coによるURL短縮が始まってからなので、昔のツイートにこの値は含まれていません。

ツイート数統計
月別ツイート数表示
PS> $tweets | group @{E = {(Get-Date $_.timestamp).ToString("yyyy/MM")}} -NoElement

Count Name
----- ----
  432 2013/03
  413 2013/02
  248 2013/01
  741 2012/12
  497 2012/11
  791 2012/10
  659 2012/09
...

ツイート分析と言えばやはりツイート数統計を取ることから始まるでしょう。統計を取るにはGroup-Objectコマンドレット(エイリアスgroup)が使えます。ここでもグループ化キーとして集計プロパティを指定してやります。ツイートの「年/月」を文字列化し、それが同じツイートでグループ化することで、月別ツイート数の統計が表示できるわけです。

時間帯別ツイート数表示
PS> $tweets | group @{E = {Get-Date $_.timestamp | 
    select -expand Hour}} -NoElement |
    sort @{E = {[int]$_.Name}}

Count Name
----- ----
 2369 0
 1630 1
 1137 2
 ...
 2270 23

やり方としては先ほどのとほぼ同じです。Select-Object -ExpandPropertyはパイプライン入力でオブジェクトのプロパティ値を取得できるのでよく使います。ちなみにPowerShell 3.0だと「$obj|foreach プロパティ名」でも取れますね。

Sort-Objectコマンドレット(エイリアスsort)でもソートキーとして集計プロパティを指定できます。ここではNameプロパティ(グループ化キーの値)をintに変換したものをキーにソートしています。

曜日別ツイート数表示
PS> $tweets | group @{E = {Get-Date $_.timestamp | 
    select -expand DayOfWeek}} -NoElement |
    sort @{E = {[DayOfWeek]$_.Name}}

Count Name
----- ----
 4939 Sunday
 5164 Monday
 5463 Tuesday
 5164 Wednesday
 5563 Thursday
 5992 Friday
 6331 Saturday

これもやり方としてはほぼ同じ。ソートキーはDayOfWeek列挙体にキャストしてちゃんと曜日順に並ぶようにしてます。

ツイート数計測
総ツイート数
PS> $tweets | measure

Count    : 38616
Average  :
Sum      :
Maximum  :
Minimum  :
Property :

ここからはツイート数の計測をしていきます。単純にツイート総数を取るだけならMeasure-Objectコマンドレット(エイリアスmeasure)を使うだけでOKです。Averageなどは対応するスイッチパラメータ(-Averageなど)を指定すると計測されますが、この場合は元オブジェクトが数値ではないのでエラーになります。

ツイート文字数分析
PS> Add-Type -AssemblyName System.Web
PS> $tweets | where {!$_.retweeted_status_id} | 
    select @{L = "TextLength"; E = { 
        [System.Web.HttpUtility]::HtmlDecode($_.text).Length}} | 
    measure -Sum -Maximum -Minimum -Average -Property TextLength

Count    : 37718
Average  : 48.7322233416406
Sum      : 1838082
Maximum  : 140
Minimum  : 1
Property : TextLength

ツイート文字数を計測するとき、元のオブジェクトにはツイート文字数を返すプロパティはないので、Select-ObjectコマンドレットにTextLengthという集計プロパティを指定して新たに作ってしまいます。

Measure-Objectコマンドレットは-Propertyパラメータにより対象オブジェクトのどのプロパティ値を計測するか指定できます。そしてスイッチパラメータを全部有効にすることで、平均、合計、最大、最小値を計測しています。私の総ツイート文字数は183万です。

なお、リツイートの場合はretweeted_status_idにリツイート元のツイートIDが入るので、このIDがあるものはWhere-Objectで除外してます。またツイート本文の<や&などはHTMLエンコードされたものがtext列に格納されているので、HttpUtilityを使ってデコードしてから文字数をカウントしています。

通常ツイートとRTの比率
PS> $tweets | foreach {
  $TweetCount = 0;
  $RTCount = 0
} {
    if($_.retweeted_status_id){
        $RTCount++
    }else{
        $TweetCount++
    }
} {
    New-Object psobject @{
        AllCount = $tweets.Length;
        TweetCount = $TweetCount;
        RTCount = $RTCount;
        RTRatio = $RTCount/$tweets.Length
    }
}


Name                           Value
----                           -----
RTCount                        898
TweetCount                     37718
AllCount                       38616
RTRatio                        0.023254609488295

Measure-Objectコマンドレットは計測方法を指定することはできないので、独自の計測を行う場合はこんな感じでコードめいたものを書く必要が出てくるかと思います。RT率たったの2%か…ゴミめ…

ForEach-Object(エイリアスforeach)は1個のスクリプトブロックをパラメータに指定するとprocessブロック相当の列挙部分を実行しますが、このように3個指定すると、それぞれbegin(初期化処理)、process、end(終了処理)ブロックに割り振られます。

ここではbeginブロックで変数初期化、processブロックで通常ツイートとリツイートを加算、endブロックで計測値をPSObjectに格納して出力してます。ちなみにPowerShell 3.0ではカスタムオブジェクトを作る場合は「[pscustomobject]@{連想配列}」で書くほうが楽です。

お前は今まで寒いと言った回数を覚えているのか
PS> $tweets | foreach {$count = 0} {
    $count += ($_.text -split "寒い").Length - 1} {$count}
137

覚えてないから数えます。137回か。

数値だけを出力するならこんな感じでシンプルに書けますね。

ランキング
クライアントランキング
PS> $tweets | group @{E = {$_.source -replace "<.+?>"}} -NoElement | 
    sort Count -Descending

Count Name
----- ----
12927 Janetter
11333 web
 5230 Azurea for Windows
 3060 TweetDeck
 1694 Hatena
  866 twicca
  667 twigadge
...

ここからはいろんなランキングを取得してみます。まずはツイートに使ったTwitterクライアントのランキング。ここでもGroup-Objectを使っています。クライアント名はクライアント配布URLがaタグで含まれているのでそれを-replace演算子で削ったものをグループ化キーとしています。ランキングなので最後はCountで降順ソート。

リプライしたユーザーランキング
PS> $tweets | where {$_.in_reply_to_user_id} |
    select @{L = "user"; E = {if($_.text -match "^(@[a-zA-Z0-9_]+)"){$matches[1]}}} |
    group user -NoElement | sort Count -Descending

Count Name
----- ----
  807 @xxxxxxxxxx
  417 @xxxxxxxxxx
  333 @xxxxxxxxxx
...

ランキング系はどれもgroup→sort Countのパターンになるかと思います。リプライツイートはin_reply_to_user_id列にリプライしたユーザーIDが含まれるのでまずはそれでフィルタし、ユーザー名はツイート本文から取ります。ユーザー名は-match演算子を使って正規表現で抽出します。$matches自動変数は連想配列で、[0]にマッチ全体が、[1],[2],...にはサブ式のキャプチャが入ります。ちなみにサブ式に名前を付けてるとキー名が数値ではなくサブ式名となります。

ハッシュタグランキング
PS> $tweets | foreach {[regex]::Matches($_.text, "(#\S+)") | 
    % {$_.Captures} |% {$_.Value}} | 
    group -NoElement | where Count -gt 1 | sort Count -Descending

Count Name
----- ----
  199 #zanmai
   75 #nowplaying
   68 #nhk
   63 #techedj2009
...

ハッシュタグも同様のアプローチで取れますが、ハッシュタグは1ツイートに複数あることがあり、-match演算子だと複数のマッチは取れないので[regex]を使って取得しています。

おわりに

PowerShellのオブジェクト処理用コマンドレットを用いると、CSVデータの分析ができます。普通はログファイル等を解析するのに使うわけですが、こういう身近なデータを扱ってみるのも面白いんじゃないでしょうか。きっとPowerShellの勉強にもなると思います。

2012/12/01

今日から、PowerShell Advent Calendar 2012が始まりました。初日は私が担当させていただきます。お題は旬の話題、PowerShell 3.0の新機能!…ではなく、初心に返って、PowerShellの「関数」ってどう書くのがいいのかというお話をします。PowerShell 3.0どころか、大部分はPowerShell 1.0から変わっていない基本の話です。

これは今までずっと書きたかったネタですがなかなか書く暇がなくて放置してたものです。3.0の話はきっと他の皆さんが書いて下さるはず!私もまた順番が回ってきたら書こうと思います。

PowerShellの関数は従来言語とだいぶ違う

PowerShellを使いこなすようになってくると、他の言語を使う時と同じで、定型処理は関数として一つにまとめたくなってきます。ところが他の言語と同じような感覚で関数を書くと、どうもうまくいかないのです。

たとえば引数にフォルダパスとフォルダ名を指定すると、指定フォルダが存在すればFalseを返し、存在しなければ作成してTrueを返す関数を書いてみました。

function MakeDir($path,$name)
{
    $newDirPath = Join-Path $path $name
    if((Test-Path $newDirPath))
    {
        return $false
    }
    else
    {
        New-Item -ItemType Directory -Path $newDirPath 
        return $true
    }
}

実行は

MakeDir("C:\test","NewFolder")

と、メソッド風に呼び出すことはできないので、コマンドレット風に

MakeDir C:\test NewFolder

と呼び出せばいいんですが(まあ最初はここもつまづきポイントではありますが)、この実行結果は以下のようになります。

    ディレクトリ: C:\test

Mode                LastWriteTime     Length Name 
----                -------------     ------ ----
d----        2012/12/01      7:51            NewFolder
True

フォルダが作成されてTrueが返却されることを想定していたのに、なんか余計な出力が混じってしまっています。なんでしょうこれは?

実はPowerShell関数内で値が出力されると、returnキーワードがついてなくてもすべて呼び出し元に出力されるという仕様なのです。そしてPowerShellにおけるreturnキーワードの効果は「後続処理を打ち切って呼び出し元に戻る。ただしreturnの後に値が指定してあればそれを最後の値として戻す」となります。そのため、呼び出し元に返したくない出力が関数内にある場合は、すべて[void]にキャストしたり|Out-Nullとしてリダイレクトするなどして出力を破棄する必要があるのです。このMakeDir関数の場合はNew-Itemコマンドレットが作成したフォルダのFolderInfoオブジェクトを出力するので、これをNew-Item -ItemType Directory -Path $newDirPath | Out-Null のように破棄してやる必要があるわけです。

パイプラインの動作

先ほどの例を見ると、「いやいやなんでそんな訳のわからない仕様なんだよ、returnあるときだけ値返せよ」とお思いかと思います。しかしこれはPowerShellの特長の一つである、コマンドのパイプラインによる連携を行うための仕様なんです。

ここでコマンドを繋ぐパイプラインがどういう動作をしてるか、おさらいします。

Get-Process | where {$_.Handles -ge 500} | foreach {$_.Path}

これはハンドル数が500以上のプロセスのメインモジュールファイルのパスを取得するというコマンドで、別に何の変哲もありません。ところが、このコマンドがやっている処理を、次のように誤解してませんでしょうか?

@ 稼働中のすべてのプロセスの一覧を配列として取得する。
A @で取得した配列を走査して、Handlesプロパティの値を調べる。Handlesが500以上のオブジェクトだけ抽出した配列を生成する。
B Aで生成した配列を列挙して、{}内のスクリプトをそれぞれ実行する。

しかし、これは間違いです。

正しくは

@ 稼働中の1つのプロセスオブジェクトを取得して次のコマンドへ送る。
A そのプロセスのハンドル数が500以上なら、次のコマンドへ送る。そうでないなら@に戻る。
B そのプロセスに対して{}内のスクリプトを実行する。まだ未取得のプロセスが残っていれば@に戻る。

という動きをしています。つまり、パイプラインの手前で一旦すべての処理を終えてから、出力オブジェクトがまとめて配列という形で次のコマンドに送られるのではなく、オブジェクトがパイプラインの先頭から末尾に向けて1つずつ通過していき、それが先頭コマンドの出力オブジェクト数だけ繰り返される、という動作をしているのです。

これがPowerShellのパイプライン処理が、従来の処理系での関数と決定的に違うところで、パイプラインによって複数のコマンドが、あたかももとからあった単一のコマンドのように密に連携するわけです。

(この処理、.NETのLINQにちょっと似てると思う方もいらっしゃると思います。しかしLINQとは全然違うものです。なんせPowerShellはLINQより先に世に出てますし! しかし類似点も多いのでいずれ比較なんかを書きたいと思ってます)

パイプラインで連携可能な関数の書き方

さて、先ほどのパイプラインの話ではコマンドレットを連携させていました。しかしPowerShellにおいてはコマンドレットも関数も、それが.NETのクラスかPowerShellのスクリプトなのかの違いがあるだけで、基本は同じ「コマンド」です。なので、関数もコマンドレットと同様、適切な記述をおこなえば、パイプラインでコマンド同士を連携させることが可能です。

以下に、Get-Repeatという関数の例を挙げます。この関数は-Textパラメータに文字列を指定し、-Countパラメータに回数を指定すると、指定文字列を指定回数分連結した文字列を出力する、という何の変哲もない関数です。しかしパイプラインからの入力を受け付け、次のパイプラインへ出力することを想定した作りになっています。

function Get-Repeat
{
    param(
        [Parameter(ValueFromPipeline=$true,Mandatory=$true)]
        [string[]]
        $Text,
        
        [int]
        $Count=2
    )

    begin
    {
    }

    process
    {
        foreach($s in $Text)
        {
            $s * $count
        }
    }

    end
    {
    }
}

以下は実行例です。

PS> Get-Repeat -Text ab -Count 2
abab
PS> "ab" | Get-Repeat -Count 2
abab
PS> Get-Repeat -Text ab,cd -Count 2
abab
cdcd
PS> "ab","cd" | Get-Repeat -Count 2
abab
cdcd

このように、パラメータに値を指定してもパイプラインから入力しても、スカラー値(配列ではない単一のオブジェクト)でも配列でも、正しく処理されています。

この関数をポイントごとに見ていきましょう。

PowerShellの正式な関数はparam節、beginブロック、processブロック、endブロックに分かれます。param節にはパラメータを指定します。beginブロックにはパイプラインで連携した際、最初の1回だけ実行される初期化処理、endブロックには最後の1回だけ実行される後始末処理を記述します。beginとendは今回の例では内容を省略しています。processブロックには、パイプラインから入力された1つのオブジェクトに対してその都度実行される処理を記述します。

ちなみに、

コマンド@|コマンドA|コマンドB

とある場合、各コマンドにおけるbegin,process,endブロックは次のような順番で呼び出されます。

コマンド@begin→コマンドAbegin→コマンドBbegin→{コマンド@process→コマンドAprocess→コマンドBprocess→コマンド@process…}→コマンド@end→コマンドAend→コマンドBend

processブロックでの処理は、通常はパイプラインだけではなくパラメータからも値を入力できるようにしておきます。そのためにはparam節に記述するパラメータに「このパラメータはパイプラインから値を入力することもできる」を意味する[Parameter(ValueFromPipeline=$true)]という属性を指定します(この属性はPowerShell 2.0から利用可)。今回のパラメータには「このパラメータは必須である」を意味するMandatory=$trueもあわせて指定しています。

先述の通り、パイプラインから入力される場合は配列ではなくオブジェクトが単体で渡されるのですが、パラメータから入力される場合はスカラー値と配列値、どちらの可能性もあるため、[string[]] のようにパラメータの型を配列型にしておくことで、どちらを指定しても処理できるようにしています。

processブロックではパラメータ経由で配列値が渡された場合に、各要素に対して処理を行うためforeachループを設けています。ちなみにスカラー値が渡された場合もforeachは問題なく処理します。

processブロック内では、returnは記述しません。returnするとその時点で関数が終了してしまうので正しくすべての出力ができなくなってしまいます。

特にこの例の関数のように入力型と出力型が同一の場合は、processブロックでは1オブジェクトの入力に対して、1オブジェクトを出力するようにしておくと、他のコマンドと連携させやすくなります。ただしWhere-Objectコマンドレットのようにフィルタ処理を行う関数の場合は、条件によっては何も出力しないようにします(空の配列とか$nullを返すのではないことに注意)。もちろん入力オブジェクトから何らかの配列値を出力する場合もありえます。

最低限、これらのポイントを押さえて関数を記述すると、他のコマンドとパイプラインで連携しやすい、PowerShellらしい関数を書くことができると思います。

まとめ

PowerShellでは従来言語と同じ感覚で関数を書くと、うまくいかないことが多いです。もっとも単に処理をひとまとめにしたいというニーズだけならばそれでも問題ないのですが、関数同士を組み合わせたいときに問題が顕在化します。

パイプラインの真の動作を理解し、パイプラインの中に組み込んで動作させることを想定した関数を記述すると、他のコマンドレットあるいは自作関数と連携しやすくなり、PowerShellの真の力を解放することができると思います。

PowerShell Advent Calendar 2012の1日目にしてはえらい固いネタかもですが、基本をおさらいするのも大事ですよね。

さて、明日は@jsakamotoさんの番です。よろしくお願いします。



Copyright © 2005-2018 Daisuke Mutaguchi All rights reserved
mailto: mutaguchi at roy.hi-ho.ne.jp
プライバシーポリシー

Twitter

Books