tech.guitarrapc.cóm

Technical updates

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

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

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

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

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

List<PSCustomObject>を生成します。

$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"を追加します。

PS> $entry1 = $list | Where-Object {$_.ID -eq 1}
PS> $entry1.Array += "third"
PS> $entry1
ID Array
-- -----
1  {first, second, third}

[PSCustomObject]型です。

PS> $entry1.GetType()
IsPublic IsSerial Name           BaseType
-------- -------- ----           --------
True     False    PSCustomObject System.Object

.Where() メソッドでの処理

では、これをPowerShell 4.0で追加された .Whereメソッドを使って行うと、$entry2.Array += "third"で失敗します。

PS> $entry2 = $list.Where({$_.ID -eq 2})
PS> $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

型を確認すると、[PSCustomObject]型ではなく、Collectionとして返ってきました。

PS> $entry2.GetType()
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"

期待した結果です。

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

式結果を変数格納前にまとめる

部分実行$()で括ってしまいます。

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

期待した結果になっています。

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

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

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

さきほどのコードを利用します。てきとーにList<PSCustomObject>を生成します。

$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) では問題ありません。

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

.ForEach()で各オブジェクトにアクセスを試みると失敗します。

PS> $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()の挙動確認が必要。