tech.guitarrapc.cóm

Technical updates

PowerShell V4.0 の .Where() Method と .ForEach() Method 利用時の注意

以前、PowerShell V4 の コレクションフィルタリングについてまとめてみる で PowerShell V4 で追加された .Where() を含めて コレクション処理について書きました。

今回は、ちょっと .Where() と .ForEach() の利用において注意が必要だと思われる状況なので記事を書いてみましょう。

目次

.Where() メソッド利用時の注意

まずはコードを見てみましょう。

てきとーに list を生成します。

$list = New-Object -TypeName 'System.Collections.Generic.List[PSCustomObject]'
1,2 | Foreach {
    $object = [PSCustomObject]@{
        ID = "$_"
        Array = @("first","second")
    }

    $list.Add($object)
}
Where-Object での処理

で、Where-Object を使って ID2 の Array に 要素 "third"を追加します。

$entry1 = $list | Where-Object {$_.ID -eq 1}
$entry1.Array += "third"
$entry1

結果です。追加できていますね。

ID Array                 
-- -----                 
1  {first, second, third}

型を確認します。

$entry1.GetType()

問題ないですね。[PSCustomObject]型です。

IsPublic IsSerial Name           BaseType     
-------- -------- ----           --------     
True     False    PSCustomObject System.Object
.Where() メソッドでの処理

では、これを PowerShell 4.0 で追加された .Where メソッドを使って行いましょう。

$entry2 = $list.Where({$_.ID -eq 2})
$entry2.Array += "third"

おっと..... $entry2.Array += "third" で失敗します。

The property 'Array' cannot be found on this object. Verify that the property exists and can be set.
At line:2 char:1
+ $entry2.Array += "third"
+ ~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) [], RuntimeException
    + FullyQualifiedErrorId : PropertyAssignmentException

型を確認します。

$entry2.GetType()

[PSCustomObject]型ではなく、Collection`1 として返ってきました。

IsPublic IsSerial Name         BaseType     
-------- -------- ----         --------     
True     True     Collection`1 System.Object

原因

これは PowerShell がどのように式結果を返すかを考えるとおのずとわかります。ヒントは Method と パイプライン です。

PowerShell は、式結果が複数あった場合はCollection として返します。まずこれが原則です。

パイプライン を介する

パイプライン を通るとき、PowerShellは入力されたオブジェクトを展開、ストリームに渡して、右辺の式を実行、式結果を変数格納時にArray(1オブジェクトの場合はスカラ)としてまとめあげなおします。

つまりWhere-Object の時は、パイプラインを通してオブジェクトの要素を処理、結果を元の型にまとめあげなおしています。なので、型[PSCustomObject]が維持されています。

$entry1 = $list | Where-Object {$_.ID -eq 1}

一方で、.Where() はどうでしょうか。これはメソッド処理されており、各要素が処理されてパイプラインを介さずに順に変数に格納されています。

つまり、式結果要素を順番に新しいコレクションとして保持しています。 PSCustomObjectではないのでArrayというプロパティは存在せずアクセスできません。当然です。

$entry2 = $list.Where({$_.ID -eq 2})

つまり、これとやっていることは一緒ですね。

$array = 1..10

回避方法

簡単です。 パイプラインを通って変数に格納するか、パイプライン同様に式結果を変数格納前にまとめればいいのです。

パイプライン経由で変数に格納する

Set-Variable を使えばokです。 つまり、元のこれを

$entry2 = $list.Where({$_.ID -eq 2})
$entry2.Array += "third"

このように パイプラインを介して Set-Variable で変数に格納します。

$list.Where({$_.ID -eq 2}) | Set-Variable entry2
$entry2.Array += "third"

はい、できました。

$entry2
ID Array          
-- -----          
2  {first, second, third}
式結果を変数格納前にまとめる

PowerShell を触っている人がパッと思いつく方法だと思います。

元を

$entry2 = $list.Where({$_.ID -eq 2})
$entry2.Array += "third"

$() で括ってしまいます。

$entry2 = $($list.Where({$_.ID -eq 2}))
$entry2.Array += "third"

はい、できました。

$entry2
ID Array          
-- -----          
2  {first, second, third}

.ForEach で要素にアクセスできない

問題は、.ForEach()です。おそらくバグと見ており Connect にも挙げています。

さきほどのコードを利用します。

てきとーに list を生成します。

$list = New-Object -TypeName 'System.Collections.Generic.List[PSCustomObject]'
1,2 | Foreach {
    $object = [PSCustomObject]@{
        ID = "$_"
        Array = @("first","second")
    }

    $list.Add($object)
}

Foreach-Object や foreach($l in $list) では問題ありません。

$list | ForEach-Object {$_.GetType()}
IsPublic IsSerial Name           BaseType     
-------- -------- ----           --------     
True     False    PSCustomObject System.Object
True     False    PSCustomObject System.Object

さて、.ForEach() で各PSCustomObject にアクセスを試みると失敗します。

$list.ForEach({$_.GetType()})
You cannot call a method on a null-valued expression.
At line:1 char:16
+ $list.ForEach({$_.GetType()}) | ft -au | clip
+                ~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) [], RuntimeException
    + FullyQualifiedErrorId : InvokeMethodOnNull
 
You cannot call a method on a null-valued expression.
At line:1 char:16
+ $list.ForEach({$_.GetType()}) | ft -au | clip
+                ~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) [], RuntimeException
    + FullyQualifiedErrorId : InvokeMethodOnNull

これは、そもそも コレクション要素にアクセスできず null 判定なので .Where の状況とは違います。 [PSCustomObject]には .ForEach() がないのはいいのですが.... そもそも 要素にアクセスできないんですよね...。

# 単純な Array では問題ない
((1..10).ForEach{$_}).GetType()
$fuga = (1..10).ForEach{$_}
$fuga.ForEach{$_}

# Collectionも問題ない
(1..10).ForEach{$_} | Set-Variable hoge
$hoge.ForEach{$_}
$hoge.GetType()

# PSCustomObjectは .ForEach() を含まないのでエラーとなる
$list[0].ForEach{$_}

まとめ

.Where() は、いいでしょう。 不完全燃焼ですが .ForEach() に関しては確認が必要ですね。