2015/12/21

この記事はPowerShell Advent Calendar 2015の21日目の記事です。

PowerShellの属性

PowerShellには2.0から言語機能として「属性」機能が追加されました。PowerShellの属性はC#の属性とほぼ同じものですが、2.0〜4.0の時点では、高度な関数(Advanced Function)を作成するために関数に付与するCmdletBinding属性(記述上ではparamブロックに付与する形になる)、関数のパラメータに付与するParameter属性やAlias属性等、関数のパラメータや変数に付与する各種検証属性(Validate〜、Allow〜系)くらいしか使うことはなかったかと思います。

このうちパラメータ検証属性については、あえとす氏が以前詳しく解説しておられます。:パラメーターの検証属性について/前編 - 鷲ノ巣

PowerShellで属性クラスを作る

さて話は変わって、つい先日、WMF 5.0 / PowerShell 5.0がRTMしました。Windows 7/8.1、Windows Server 2008R2/2012/2012R2用のインストーラーが公開されたので、もう使っておられる方もいると思います。ぎたぱそ氏が一晩で解説記事を書いてくれてますね。

PowerShell 5.0では言語機能としてclass構文がついに追加されました。これでついにPowerShell言語も真の意味でオブジェクト指向言語の要素を完備したと言えるかと思います。

このclass構文、.NET Frameworkの既存のクラスを基底クラスとして継承するなんてことも、普通にできてしまいます。そこで私が考えたのは、もしかしてこれで属性も作れるんじゃない?ということでした。

v5のclass構文の基本的な部分の解説は、また別の機会にということにしまして、今回はいきなり、属性を作る話をしていきます。

ちなみにC#の属性については、属性 - C# によるプログラミング入門 | ++C++; // 未確認飛行 C が参考になります。PowerShellで属性を作る時も基本はだいたい同じかと思います。

属性クラスの例

属性パラメータ(コンストラクタ引数)にDescription、名前付きパラメータとしてNoを指定でき、classに適用可能なTest属性はこんな感じです。

[AttributeUsage([AttributeTargets]::Class)]
class TestAttribute : Attribute
{
    [string]$Description

    [int]$No

    TestAttribute($Description)
    {
        $this.Description = $Description
    }
}

これを利用する際は、まずこのclassを含むスクリプトをドットソースで実行することで、グローバルに読みこむ必要があるようです(同一スクリプト内に属性定義と属性利用をまとめて書くとうまく動作しない)。

呼び出し側では以下のように指定します。

[TestAttribute("クラスの説明", No = 1)]
class Foo
{
    $X = 1
}

通常なら[Test()]のように"Attribute"の部分は省略できるはずなんですが、これも何故かフルネームで指定しないとダメなようでした。

クラスに属性が正しく指定されているかどうかはリフレクションで調べます。

[Attribute]::GetCustomAttributes([Foo])

結果は

Description  No TypeId
-----------  -- ------
クラスの説明  1 TestAttribute

こんな感じです。

パラメータ検証属性を作る

これだけでは面白くないので、少し実用になるかもしれない属性を書いてみましょう。

ところで前述のあえとす氏の記事の後編では、C#でPowerShellのパラメータ検証属性を作る方法について解説されています。ただ、C#でPowerShellの実行空間にアクセスして、操作を行ったり情報を取得したりするのは、少し知識が必要な部分かと思います。

そこで、PowerShellで使う属性なんだからPowerShellで書いたら楽になるんじゃない?という発想で、パラメータ検証属性をクラス構文で書いてみました。

このサンプルは、パラメータ(または変数)値の型が、指定の型セットに含まれているかどうかを検証するものです。複数型を取るようにするためにパラメータの型を[psobject]にすることが良くありますが、この属性を利用して、指定可能な型を絞り込めるようになります。

using namespace System.Management.Automation

[AttributeUsage(([AttributeTargets]::Property) -bor ([AttributeTargets]::Field))]
class ValidateTypeSetAttribute : ValidateEnumeratedArgumentsAttribute
{
    [Type[]]$Types

    ValidateTypeSetAttribute($Types)
    {
        $this.Types = $Types
    }

    [void]ValidateElement([object]$element)
    {
        if(!($element.GetType() -in $this.Types))
        {
            throw New-Object ValidationMetadataException `
                "$($element.GetType().FullName) は許容されない型です。値は $($this.Types) のいずれかの型である必要があります。"
        }
    }
}

使い方は以下の通り。パラメータ検証属性の場合は、定義と同一スクリプト内で呼び出しても、"Attribute"を省略しても問題ないようです。

function test
{
    param(
        [ValidateTypeSet(,([int],[string],[double]))]
        [psobject]
        $obj
    )
}

test "a" #OK
test (Get-Process)[0] #NG

class構文のコンストラクタで可変長引数を定義する方法が分からなかったので、呼び出しがちょっと不格好ですが、そこはご容赦を。

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



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

Twitter

Books