Skip to content

Instantly share code, notes, and snippets.

@igeta
Last active February 4, 2023 03:00
Show Gist options
  • Star 23 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save igeta/e5c0d8e81c732d55a919b97cbdaf459a to your computer and use it in GitHub Desktop.
Save igeta/e5c0d8e81c732d55a919b97cbdaf459a to your computer and use it in GitHub Desktop.

PowerShell Gives You Wrongs

三年半の格闘の末に僕が見たもの、あるいは試行錯誤の覚書、すなわち二番煎じ。

はじめに

PowerShell 3.0以上のバージョンを使用すること。2.0以下のバージョンは、書き捨ては仕方ないとしても、保守対象のスクリプトを書くべきではないし、あらゆる言及に値しない。全力でバージョンアップをしろ。

式と文の常識を超えて

PowerShellで、いわゆる三項演算子いうところの条件演算子を書きたい、と思ったところで、そもそもif文が値を返すことに気づく。

> $answer = if($true){"good"}else{"bad"}
> $answer
good

しかし、if は文であって式でない。代入文の右辺には文が置けるので上記は問題ないが、当然、式を置くべきところに文は置けない。

> $answer = "It's so " + (if($true){"good"}else{"bad"})
if : 用語 'if' は、コマンドレット、関数、スクリプト ファイル、または操作可能なプログラムの名前として認識されません。名前が正しく記述されていることを確認し、パスが含まれている場合はそのパスが正しいことを確認してから、再試行してください。
発生場所 行:1 文字:25
+ $answer = "It's so " + (if($true){"good"}else{"bad"})
+                         ~~
    + CategoryInfo          : ObjectNotFound: (if:String) [], CommandNotFoundException
    + FullyQualifiedErrorId : CommandNotFoundException

ではどうするか。文を括って式にすればよく、それには部分式演算子 $() が使える。

> $answer = "It's so " + $(if($true){"good"}else{"bad"})
> $answer
It's so good

すなわち、PowerShellにおける三項条件演算子は、if$() で括った $(if (bool) { expr1 } else { expr2 }) の形をとる。目標は達せられた。

だがちょっと待ってほしい。そもそも、文が値を返すとはどういうことなのか。式は値を返すもの、文は値を返さないものではなかったのか、という疑問は残るが、まあ置いておこう。知らんし。

配列とスカラーとヌル

それとして、文が値を返すのなら、ループ文も値を返すということになる。なんだそれ。だが、確かにそうなる。

> $num = for ($i=1; $i -le 5; $i++) { $i * 2 }
> $num.GetType().Name
Object[]
> $num
2
4
6
8
10

なるほど、しかしこれはサンプル コードだ。現実には、for のループ回数は任意であり、すなわち評価値の配列要素数は任意となるが、単純なコーナー ケースを突いてみると、また驚きの事実に出会うこととなる。

> $num = for ($i=1; $i -le 1; $i++) { $i * 2 }
> $num.GetType().Name
Int32
> $num
2

このように、ループ文が1つだけ要素を返す時、その評価値は配列でなくスカラー値である。さらに言えば、要素を1つも返さないとき、評価結果は空配列でなくヌルとなる。

> $num = for ($i=1; $i -le 0; $i++) { $i * 2 }
> $num.GetType().Name
null 値の式ではメソッドを呼び出せません。
発生場所 行:1 文字:1
+ $num.GetType().Name
+ ~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) []、RuntimeException
    + FullyQualifiedErrorId : InvokeMethodOnNull

> $null -eq $num
True

まったく奇抜だが、これはPowerShellにおける 通常の動作 である。なんと、PowerShellという言語は、これで大体うまくいくようにできているのだ。

まず、配列列挙の foreach なら、実はin句には配列だけでなくスカラーや $null を置けて、ちゃんとそれっぽく動作するようになっている。

> foreach ($s in @("North", "South", "East", "West")) { "*{0}*" -f $s }
*North*
*South*
*East*
*West*
> foreach ($s in "North") { "*{0}*" -f $s }
*North*
> foreach ($s in $null) { "*{0}*" -f $s }

そして、PowerShellの代名詞、パイプライン処理だって何ら問題ない。

> @("North", "South", "East", "West") | Where-Object { $_.Length -eq 5 }
North
South
> "North" | Where-Object { $_.Length -eq 5 }
North
> $null | Where-Object { $_.Length -eq 5 }

なるほど、大体うまくいきそうだ。

しかし、時にはランダム アクセス、添え字による配列要素アクセスが必要な場合もあり、その場合については常に配列が欲しい。野暮ったくなるので詳細は割愛するが、たとえば下記のような場合。

> $str = for ($i=0; $i -lt 3; $i++) { "{0:000}" -f $i }
> $str
000
001
002

やってみればわかるが、$str が配列かスカラーか $null か不定では、うまく添え字アクセスするのは厳しい。こういう場合は、複数の値を返す文を配列部分式 @() で囲ってやることで、常に配列として評価値を取得できる。

> $str1 = @(for ($i=0; $i -lt 1; $i++) { "{0:000}" -f $i }
> $str1.GetType().Name
Object[]
> $str1.Length
1
> $str0 = @(for ($i=0; $i -lt 0; $i++) { "{0:000}" -f $i }
> $str0.GetType().Name
Object[]
> $str0.Length
0

繰り返しになるが、PowerShellの文が複数の値を返す時、0個の場合はヌルとなり、1個の場合はスカラーを返し、2個以上の場合は配列を返すのが デフォルトの動作 であり、これはこれとして受け入れる必要があり、尊重すべきだ。

ただし、常に配列で欲しい場合もあり、その場合のみ、呼び出し側でその文を @() で括ってやること。これにて万事よし。

2つのヌル

ならば、もう一度見てみよう。先ほどの例を再度吟味する。

$str1 = @(for ($i=0; $i -lt 1; $i++) { "{0:000}" -f $i }) # = @("001")
$str0 = @(for ($i=0; $i -lt 0; $i++) { "{0:000}" -f $i }) # = @()

$str1 は問題ない。でも、$str0 には違和感が残る。文が値を1つも返さない場合はヌルになると言うのであれば、$str0@() でなく @($null) になるのが筋ではないのか。

これは実際トリックだ。改めて言葉にすると、値を1つも返さない文の戻り値は $null と等しいが、それを単独で @() で括った場合には空配列ができる、という奇妙な性質を持つわけだ。ヌルであって $null でない。これはなんだ。

> $num = for ($i=0; $i -lt 0; $i++) { "{0:000}" -f $i }
> $null -eq $num
True
> @($null).Length
1
> @($num).Length
0
> @($num, $num).Length
2

調べてみると、どうもこれは AutomationNull.Value というものであるらしい。つまり驚いたことに、PowerShellにはヌルが2種類あるのだ(DBNull.Value という声が聞こえたが、ここでは触れない)。なんとも、気が遠くなる話だ。

> $autoNull = [System.Management.Automation.Internal.AutomationNull]::Value
> $null -eq $autoNull
True
> @($autoNull).Length
0

2003年にMonadというコードネームで公開され、2006年にそれがPowerShell 1.0としてリリースされた。アントニー・ホーアの「null参照の発明は10億ドル規模の損失をもたらす過ち」との見解を聞くには、2009年まで待つ必要があった。

戻り値は暗黙に

気を取り直して。文が値を返すということについて、iffor での実状を追ってみた形だが、よく考えれば関数定義の本体も文である。ということは、return など書かずとも、値を返す文や式を関数本体に置けば、その値は戻り値に積まれるということになる。

確認しよう。関数を定義するのも冗長なので、ここでは簡便にスクリプト ブロックで代替して例示する。

> $result = &{ "North"; "South"; "East"; "West" }
> $result
North
South
East
West
> $result.GetType().Name
Object[]

興味深いのは、return を明示した場合と違って、そこで呼び出し元に戻らず当該関数内の処理を続行する点だ。続行して、複数の戻り値を配列要素として順に返している。

このため、他の言語では書きがちな下記のようなコードは、PowerShellではアンチ パターンだ。

$badScript = {
    param([int]$Length)

    $num = @()
    for ($i=1; $i -le $Length; $i++) { $num += $i * 2 }
    return $num
}

すでに見てきた通り、これは下記のように書くべきである。わざわざ空配列を初期値に用意してそこに一つずつpushしていく必要はない。return の明示も不要だ。ただそこに値があればいい。

$goodScript = {
    param([int]$Length)

    for ($i=1; $i -le $Length; $i++) { $i * 2 }
}

ここで、注意深く見ると、これら2つは同等コードではないように思えてくる。$badScript は常に配列で返そうとしているように見えるが、$goodScript はそうなっていない。同等とするには、$goodScript の中のfor文を @() で括る必要があるのでは、と。

しかし、ご心配には及ばない。PowerShellは大変気の利く言語であるので、ちゃんと デフォルトの動作 に寄せてくれる。つまり、$badScript は常に配列で返したりはしない。

> (& $badScript -Length 5).GetType().Name
Object[]
> (& $badScript -Length 1).GetType().Name
Int32
> $null -eq (& $badScript -Length 0)
True

ワオ、なんてことだ。すばらしい。ご丁寧にどうも。これはつまり、もっと端的にあらわすと、こういうことだ。

> @(42).GetType().Name
Object[]
> (&{ @(42) }).GetType().Name
Int32
> $null -eq @()
False
> $null -eq (&{ @() })
True

おわかりいただけただろうか。前述したように、それを配列として受け取りたいかどうか、考慮すべきは呼び出し側なのだ。

> @(&{ @(42) }).GetType().Name
Object[]
> $null -eq @(&{ @() })
False

オーケー、PowerShellに俺たちの常識は通用しない。

平坦化の抑止

ここで応用問題である。下記、ジャグ配列が欲しいと思って $pair に代入したが、どうか。

> $pair = for ($i=1; $i -le 3; $i++) { @($i, ($i*10)) }
> $pair.Length
6
> $pair | Select-Object -First 1
1

$pair は、@(@(1,10), @(2,20), @(3,30)) とはならず、@(1,10,2,20,3,30) となっている。つまり、ジャグ配列ではなく、平坦化(flatten)されてただの配列が返されている。よくよく考えれば、前述の $badScript もそうであったが、戻り値の配列は平坦化されるのだ。

ならばこうだ。@(@(...)) のつもりが @(...) になるなら、@(@(@(...))) とすれば @(@(...)) になるじゃない。要は、平坦化を見越して、さらにもう一段配列で持ち上げてやればいい。つまり、@($i, ($i*10))@(@($i, ($i*10))) とすればよい、はずだが。

> @(@(1,10), @(2,20)) | ForEach { $_.GetType().Name }
Object[]
Object[]
> @(@(1, 10)) | ForEach { $_.GetType().Name }
Int32
Int32

要素に単一の配列のみを含む配列部分式も、これまた平坦化されてしまう。こういった場合は、単項のコンマ演算子を使えばよい。,@(...) とすれば @(@(...)) が作れるのだ。

> ,@(1, 10) | ForEach { $_.GetType().Name }
Object[]

というわけで、単項コンマ演算子を用いて $pair を作ればよいが、ここで、先に見たように、要素の配列が1つの場合にも @(1,10) でなく @(@(1,10)) と、確実にジャグ配列で受け取りたいから、for文全体を @() で囲うことも忘れずに。つまり、こう。

> $pair = @(for ($i=1; $i -le 3; $i++) { ,@($i, ($i*10)) })
> $pair.Length
3
> $pair | Select-Object -First 1
1
10

ただ、デフォルトは強い。気を抜いた瞬間にやられるぞ。よく考えずに、ちょっとした処理の追加を行うと、こうなる。

> $pair =
>>   @(for ($i=1; $i -le 3; $i++) { ,($i, ($i*10)) }) |
>>   ForEach-Object {
>>     $_ | ForEach-Object { $_*$_ } # CAUTION!
>>   }
> $pair.Length
6
> $pair | Select-Object -First 1
1

ただの配列に戻ってしまっている。コメントで示した箇所が問題だ。単項コンマ演算子による持ち上げ、リフト アップは、処理の最後まで付き合わなければならない。ジャグ配列の要素となる内側の配列を返す箇所には、その都度、コンマ演算子を置くのだ。

> $pair =
>>   @(for ($i=1; $i -le 3; $i++) { ,($i, ($i*10)) }) |
>>   ForEach-Object {
>>     ,@($_ | ForEach-Object { $_*$_ }) # NICE!
>>   }
> $pair.Length
3
> $pair | Select-Object -First 1
1
100

と、解決策はあるが、PowerShellにとってこれほど特別な配列を入れ子で使い、それを齟齬なく理解の容易なスクリプトに落とし込むなど、到底不可能に思える。基本的なアプローチとして、ジャグ配列は使わず、カスタム オブジェクトの配列を使うべきだ。

> $pair = for ($i=1; $i -le 3; $i++) { [PSCustomObject]@{ Item1=$i; Item2=$i} }

避けられない事例としては、何らかの外部APIに渡すためにピンポイントでジャグ配列が必要になることはあるかもしれない。しかし、それ以外の場面では、ほぼほぼカスタム オブジェクトの配列で事足りるはずだろう。

捨てるは明示

さて、文中の値が暗黙にその文全体の戻り値の配列要素として積まれていくことを確認できたわけだが、この仕様のため、思いがけず不要な値が戻り値に紛れ込んでしまう場面に遭遇することがままある。ありがちなコードを示そう。

> &{
>>     $text = "foo.txt"
>>     $temp = "temp"
>>     if (-not (Test-Path $temp)) {
>>         New-Item $temp -ItemType Directory # CAUTION!
>>     }
>>     Move-Item $text $temp -PassThru # I want this.
>> }


    ディレクトリ: D:\


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
d-----       2018/01/07     13:00                temp


    ディレクトリ: D:\temp


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2018/01/07     13:00            256 foo.txt

tempディレクトリがなければ作成して、そこにfoo.txtを移動する。簡単なスクリプトだが、New-Item-PassThru 指定なしに DirectoryInfo を返すことを失念していたために、それが戻り値に不要に積まれてしまっている。

暗黙に積まれるのなら、積みたくないときには明示する必要がある。New-Item によってディレクトリが作成されるという副作用のみ必要で、戻り値に用がないのであれば、$null にリダイレクトして戻り値を捨てなければならない。

> Remove-Item "temp" -Recurce
> &{
>>     $text = "foo.txt"
>>     $temp = "temp"
>>     if (-not (Test-Path $temp)) {
>>         New-Item $temp -ItemType Directory > $null # NICE!
>>     }
>>     Move-Item $text $temp -PassThru # I want this.
>> }


    ディレクトリ: D:\temp


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2018/01/07     13:01            256 foo.txt

そういえば、PowerShellはシェルだった。不要な標準出力を nul なり /dev/null にリダイレクトするというのは、あるいは極めてふつうの所作であるのだが、いわゆるふつうのプログラミング言語の視点からは、なかなか発想に至らないイディオムであろう。

パイプラインと関数

はいはいPowerShell完全に理解した、と思って独自のコマンドレットを書くわけだが。たとえば、ディレクトリ容量を確認するコマンドレット、端的には du -s のようなものをごく簡易に実装する。まずは素朴に。

function Get-DirectorySize
{
    [CmdletBinding()]
    [OutputType([long])]
    param(
        [Parameter(Mandatory=$true)]
        [string[]]$LiteralPath
    )

    foreach ($_ in $LiteralPath) {
        Get-ChildItem -LiteralPath $_ -Recurse -Force -File -ErrorAction SilentlyContinue |
        Measure-Object -Sum Length |
        ForEach-Object Sum
    }
}

使ってみる。まあイケてそう。

> Get-DirectorySize -LiteralPath "$env:USERPROFILE\Desktop","$env:USERPROFILE\Downloads","$env:USERPROFILE\Documents"
1024
4096
8192

PowerShellと言えばパイプライン処理、なので、引数の -LiteralPath をパイプラインから渡せるようにしたい。単に ValueFromPipeline もしくは ValueFromPipelineByPropertyName 属性を付与すればよさそうだ。通常は前者を使うのが簡便なのだが、ここでは -LiteralPath に限りない怒りと憎しみを込めて後者を使うこととする。下記、変更点のみを示す。

[Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)]
[string[]]$LiteralPath

使ってみる。

> @("$env:USERPROFILE\Desktop", "$env:USERPROFILE\Downloads", "$env:USERPROFILE\Documents") |
>> Get-DirectorySize -LiteralPath {$_}
8192

なんだこれは。パイプラインで渡した引数の最後の値、"$env:USERPROFILE\Documents" に対してしか結果が出てこない。何を見落としたのか。結論から言えば、関数定義の本体をprocessブロックで囲うことで、目的の動作が得られる。

function Get-DirectorySize
{
    [CmdletBinding()]
    [OutputType([long])]
    param(
        [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)]
        [string[]]$LiteralPath
    )

    process {
        foreach ($_ in $LiteralPath) {
            Get-ChildItem -LiteralPath $_ -Recurse -Force -File -ErrorAction SilentlyContinue |
            Measure-Object -Sum Length |
            ForEach-Object Sum
        }
    }
}

PowerShellの function は、実は本体が3つのブロックで構成される。beginprocessend とあり、省略時はデフォルトでendブロックになるのである。各ブロックの役割は下記にコメントで示す。

function Function-Name
{
    param()

    begin { <# 最初に1度だけ実行 #> }
    process { <# パイプラインから値を受け取るごとに逐次実行 #>}
    end { <# 最後に1度だけ実行(デフォルト) #> }
}

要は、process がMapで end がReduce、そして begin は集計用の変数などの下準備、という位置付けだ。

一応、パイプラインで渡した値が、それぞれのブロック内でどう取得できるか確認しておこう。

> 1..3 |
>> &{
>>     begin   { "begin: $_"   }
>>     process { "process: $_" }
>>     end     { "end: $_"     }
>> }
begin: 
process: 1
process: 2
process: 3
end: 3

begin には何も渡されず、process には一つずつすべて渡され、end には最後の値のみ渡される、という動きだ。はて、end に最後の値が渡る必要があるかどうかよくわからないが。

ちなみに、beginend がなく定義本体が暗黙に process となる filter という function の特殊版のようなものもあるが、混乱するだけなので使わない。使わなくてよい。

まとめ

PowerShellはインフラ エンジニアには難しすぎる。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment