三年半の格闘の末に僕が見たもの、あるいは試行錯誤の覚書、すなわち二番煎じ。
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個以上の場合は配列を返すのが デフォルトの動作 であり、これはこれとして受け入れる必要があり、尊重すべきだ。
ただし、常に配列で欲しい場合もあり、その場合のみ、呼び出し側でその文を @()
で括ってやること。これにて万事よし。
ならば、もう一度見てみよう。先ほどの例を再度吟味する。
$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年まで待つ必要があった。
気を取り直して。文が値を返すということについて、if
や for
での実状を追ってみた形だが、よく考えれば関数定義の本体も文である。ということは、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つのブロックで構成される。begin
、process
、end
とあり、省略時はデフォルトで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
に最後の値が渡る必要があるかどうかよくわからないが。
ちなみに、begin
と end
がなく定義本体が暗黙に process
となる filter
という function
の特殊版のようなものもあるが、混乱するだけなので使わない。使わなくてよい。
PowerShellはインフラ エンジニアには難しすぎる。