きっかけ
前記事で述べたとおり、PowerShellで2次元配列が微妙な挙動を示す。
概要
前回の記事抜粋ですが、変数に束縛するとうまく2次元配列になるのに
PS> $foo = @(, @(1,2))
PS> $foo #=> @(@(1,2)) のつもり
1
2
PS> $foo[0] #=> @(1,2) のつもり: OK
1
2
同じジャグ配列を関数に定義してあげると、なぜか1次元配列に戻ってしまう。
PS> function bar { @(, @(1,2)) }
PS> $bar = bar
PS> $bar #=> @(@(1,2)) のつもりだが、実は @(1,2)
1
2
PS> $bar[0] #=> @(1,2) のつもり:NG
1
そんな記事を書いたところ、Write-Output -NoEnumerate
を使うパターン、そもそも変数に束縛せずに処理を続けてしまうパターンを頂きました。(Thanks @stknohg さん、@jca02266 さん!)
そんなお二方の参考記事です。
やりたいことはあらかた実装も説明もして頂いてしまったので…
本記事では色々受けて私が考えうるパターンを検証した結果を載せておこうと思っています。
パターンを考えてみる
入力パターン
今回は、@(, @(1,2,3))
固定。
テストパターン
配列の配列になっているかどうかは、2パターンで判定。
t1: $result[0][1]
がエラーにならない(2になる)こと。
- 外側の@()を剥がされていたら、この結果はExceptionになるはず。
t2: $result | ForEach { $_[1] }
の結果がエラーになる(2になる)であること。
- 外側の@()を剥がされていたら、この結果はExceptionを出すはず。
処理パターン
下記 a × b × c の組み合わせ。
a. Write-Output -NoEnumerate
functionを通して得た結果は、値が一つ@()を剥がしてしまう。
Write-Output -NoEnumerateを入れる/入れないで結果の挙動を見られるはず。
# a1
@(, @(1,2,3))
# a2
Write-Output @(, @(1,2,3)) -NoEnumerate
b. そのまま or function or scriptblock
そのまま or function は前記事でやってみたけど、そういえばscriptblockをやっていなかったのでパターンに追加。
きっとfunctionとおなじになると思うんだけど…
# b1
@(, @(1,2,3))
# b2
function func_b { @(, @(1,2,3)) }
func_b
# b3
$block_c = { @(, @(1,2,3))}
&$block_c
c. 変数に格納するかどうか
変数への代入も含まれるぽいという噂もあったので、ちょっとケース追加
# c1. 結果を取得する前に変数には入れない
@(, @(1,2,3))[0][1]
# c2. 結果を取得する前に変数には入れる
$result = @(, @(1,2,3))
$result[0][1]
検証
コード
Pesterのコードを書いてみました。
https://gist.github.com/basso414f/ec4779fbfee10b8e1243de2d34ca8b10
(2016/10/10追記: gistのコードをこの先の説明用に追記しています。変更箇所はgistの変更履歴を参照ください…)
結果
長くなるのでフィルタして表示
PS > Invoke-Pester ./jugarray.test.ps1 -OutputFile result.xml -OutputFormat NUnitXML
PS > $xml = [xml](cat result.xml)
PS > $xml."test-results"."test-suite".results."test-suite" | where success -ne $true | % { "- " + $_.name; $_.results."test-case" | where success -ne $true | %{ " - " + $_.description }}
というわけで、テストが通らなかったリスト
- a1b2c1: NOT use 'NoEnumerate'/use function/NOT use variable
- t1: index access test
- a1b2c2: NOT use 'NoEnumerate'/use function/use variable
- t1: index access test
- t2: pipe access test
- a1b3c1: NOT use 'NoEnumerate'/use scriptblock/NOT use variable
- t1: index access test
- t2: pipe access test
- a1b3c2: NOT use 'NoEnumerate'/use scriptblock/use variable
- t1: index access test
- t2: pipe access test
- a2b2c1: use 'NoEnumerate'/use function/NOT use variable
- t2: pipe access test
※これより下は2016/10/10に追記した内容になります。
想定と異なる結果への考察
Write-Host -NoEnumerate
を使えば思った通りになった…かと思ったけど、2箇所想定と異なる結果が出た箇所がありました。
Describe "a1b2c1: NOT use 'NoEnumerate'/use function/NOT use variable" {
BeforeEach {
function func_b { @(, @(1,2,3)) }
}
It "t1: index access test" {
(func_b)[0][1] | Should Be 2 #=> Failure
}
It "t2: pipe access test" {
func_b | ForEach { $_[1] } | Should Be 2 #=> Success: t1と結果が異なる
}
}
Describe "a2b2c1: use 'NoEnumerate'/use function/NOT use variable" {
BeforeEach {
function func_b { Write-Output @(, @(1,2,3)) -NoEnumerate }
}
It "t1: index access test" {
(func_b)[0][1] | Should Be 2 #=> Success
}
It "t2: pipe access test" {
func_b | ForEach { $_[1] } | Should Be 2 #=> Failure: t1と結果が異なる
}
}
この状況を見て、始めはfunction/script blockの違いを疑ったため、この記事は当初下記タイトルで投稿していました。
「powershell: 2次元配列を使うときはスクリプトブロックを使おう!?」
まあ、functionとscript blockで挙動が違うなんてことはないのですが…早合点したためいっぱいコメントいただきました。(ありがとうございました。)
その中で鍵になったのは下記の @jca02266 さんのコメントでした。
パイプラインの左辺の()の有無で挙動が変わっているようです。
おお…なんと…ということで再度調査、再検証。
再検証
パイプで繋いだときの処理順序
[PS1] Powershell の基本 (3:パイプライン処理)によれば、パイプでつなぐと、基本は遅延評価の動きをしつつ、()で括られるとそこで結果が確定されます。
PS > filter tap { param($str) ; Write-Host "$str $_"; $_ }
PS > (1..3) | tap 1st | tap 2nd | tap 3rd | Out-Null #=> Out-Nullまで遅延評価される
1st 1
2nd 1
3rd 1
1st 2
2nd 2
3rd 2
1st 3
2nd 3
3rd 3
PS > ((1..3) | tap 1st | tap 2nd) | tap 3rd | Out-Null #=>(1..3)の結果は一度()内で確定、()内は遅延評価される
1st 1
2nd 1
1st 2
2nd 2
1st 3
2nd 3
3rd 1
3rd 2
3rd 3
PS > ((1..3) | tap 1st) | tap 2nd | tap 3rd | Out-Null #=>(1..3)の結果は一度()内で確定されるが、その後は遅延評価される
1st 1
1st 2
1st 3
2nd 1
3rd 1
2nd 2
3rd 2
2nd 3
3rd 3
つまり結果が確定されているかどうかの観点で見ると、適当な関数function hoge { (1..3) }
において、hoge
と(hoge)
は結果が違うし、また、変数に代入しても結果が確定されるので、$hoge = hoge
とすると、$hoge
は(hoge)
と同じになる(hoge
とは異なる)ようです。
※文法的な言葉を使えばもう少しスッキリ説明出来るような気がしますが…
検証コードの見直し
ここまでわかった上で検証コードを眺めると、functionのテスト、scriptblockのテストで、それぞれ()が付与されているかどうかが異なることに気づきます。
# functionテスト用: func_b が()で括られていない=Should実行時まで遅延評価される
func_b | ForEach { $_[0][1] } | Should Be 2
# scriptblockテスト用: & $block_c が()で括られている=ForEach前に結果は確定している
(& $block_c) | ForEach { $_[1] } | Should Be 2
遅延評価の確定タイミングと2次元配列の罠
まずは、次に説明したいコードの結果を先に確認するため、まあ当たり前と思われるコードとその結果を記載しておきます。
- 1次元配列(をパイプに渡す例)
code | result |
---|---|
(1,2,3) | select -First 1 | 1 |
- 2次元配列(をパイプに渡す例)
code | result |
---|---|
,(1,2,3) | select -First 1 | 1 2 3 |
(,(1,2,3)) | select -First 1 | 1 2 3 |
- 3次元配列(をパイプに渡す例)
code | result |
---|---|
,(,(1,2,3)) | select -First 1 | Length : 3 Rank : 1 SyncRoot : {1, 2, 3} ... |
(,(,(1,2,3))) | select -First 1 | Length : 3 Rank : 1 SyncRoot : {1, 2, 3} ... |
これを踏まえて、@stknohg さんのコメントにある下記ですが…
-NoEnumerateは出力ストリーム、パイプラインに配列をどう渡すかを決めるものです。
もう少し詳しく書くと恐らく、配列の一番外側を一つづつyield returnするのが基本動作であり、-NoEnumerate
を設定すると配列そのままをyield returnすることになる、ということなんだと理解しました。
先ほど例示した、結果として配列を返すhoge
関数は、1~3の値をそれぞれ yield return しているような挙動を示すのに対し、Write-Output -NoEnumerate
は(恐らく)、(1,2,3)
という配列そのものを1つだけyield returnしているようです(NoEnumerateという言葉から類推)。
C# っぽく書くとこんなイメージだと思っています。(久々に書くので文法怪しいかもですが…)
// PS > function func_array { (,(1..3)) }
IEnumerable<int> func_array
{
yield return 1;
yield return 2;
yield return 3;
}
// PS > function func_noenumerate { Write-Output (,(1,2,3)) -NoEnumerate }
IEnumerable<int[]> func_noenumerate
{
yield return new int[] {1, 2, 3};
}
事実、上の例と同じ値をWrite-Output -NoEnumerate
すると、先の例より一段深い配列が返ってきます。
code | result |
---|---|
Write-Output (1,2,3) -NoEnumerate | select -First 1 | 1 2 3 |
Write-Output (,(1,2,3)) -NoEnumerate | select -First 1 | Length : 3 Rank : 1 SyncRoot : {1, 2, 3} ... |
ただし、この場合パイプの左側の文を()で括っていないので、Write-Outputの結果はselectが実行されるまで遅延評価されています。
逆にWrite-Outputを()で括るとその場で確定されますが、渡された値はジャグ配列なので、ここでついに@()が剥がされる問題にぶつかるようです。
code | result |
---|---|
(Write-Output (1,2,3) -NoEnumerate) | select -First 1 | 1 |
(Write-Output (,(1,2,3)) -NoEnumerate) | select -First 1 | 1 2 3 |
function/scriptblockも同様に、括弧があるかないかで遅延評価されるか、値が確定されるかが変わります。値が確定されると@()が剥がされることがあるため、こんな結果になります。
scriptblockで例を示します。(functionでも結果は同じだとわかったので…)
()なし:遅延評価される例
code | result |
---|---|
&{ (1,2,3) } | select -First 1 | 1 |
&{ (,(1,2,3)) } | select -First 1 | 1 2 3 |
()あり:パイプに渡す前に値が確定される例
code | result |
---|---|
(&{ (1,2,3) }) | select -First 1 | 1 |
(&{ (,(1,2,3)) }) | select -First 1 | 1 |
(&{ (,(,(1,2,3))) }) | select -First 1 | 1 2 3 |
検証コードの修正
つまりは検証コードはこうすべきでした。(tx-addxxが追加ケース)
Describe "a1b2c1: NOT use 'NoEnumerate'/use function/NOT use variable" {
BeforeEach {
function func_b { @(, @(1,2,3)) }
}
It "t1: index access test" {
(func_b)[0][1] | Should Be 2 #=> Failure
}
It "t2: pipe access test" {
func_b | ForEach { $_[1] } | Should Be 2 #=> Success: t1と結果が異なる
}
It "t2-add01: pipe access test" {
(func_b) | ForEach { $_[1] } | Should Be 2 #=>Failure: t1と結果は同じになる
}
}
Describe "a2b2c1: use 'NoEnumerate'/use function/NOT use variable" {
BeforeEach {
function func_b { Write-Output @(, @(1,2,3)) -NoEnumerate }
}
It "t1: index access test" {
(func_b)[0][1] | Should Be 2 #=>Success
}
It "t2: pipe access test" {
func_b | ForEach { $_[1] } | Should Be 2 #=>Failure: t1と結果が異なる
}
It "t2-add01: pipe access test" {
(func_b) | ForEach { $_[1] } | Should Be 2 #=>Success: t1と結果は同じになる
}
It "t2-add02: pipe access test" {
# 番外編: -NoEnumerateな2次元配列がパイプに渡るため、$_[1]でなく$_[0][1]でアクセスすれば想定どおりの結果が取得可能
func_b | ForEach { $_[0][1] } | Should Be 2 #=>Success
}
}
結論
2次元配列を扱うときは…
- PowerShellの遅延評価に気をつけて実行する必要がある
- ()で括られたところで値が一度確定する
- functionを実行しようと、scriptblockを実行しようと、結果は変わらない(考えてみれば当たり前…)
- ジャグ配列を扱うときは、「パイプに繋いで遅延評価する時」と、「値が確定した」(
()
で括った、変数に代入した)ときで挙動が異なる- 値が確定したときに、@(,@(1,2,3))のようなジャグ配列になっていると、外の@()を剥がして@(1,2,3)になる
- 一度変数などに格納することを想定しているなら
Write-Output -NoEnumerate
を有効活用する
過去の結論も残しつつ、ツッコミを入れておきます
-
**スクリプトブロック+←そんなことないNoEnumerate
**を使おう - functionもscriptblockも、使わなければ特に問題はない←そうだけど…function使いましょう
-
functionを使うと、パイプだったらうまくいく / 変数に入れるとうまくいく など挙動がまちまち←そんなことない
やっとすっきり。