読者です 読者をやめる 読者になる 読者になる

PowerShell で ジェネリック版コレクション IEnumerable<T>を列挙する場合の注意

PowerShell

パイプ経由でのコレクションの受け渡し方などを調べようと思い、以下のようなイテレータクラスを用意しました。

IEnumerable<T> および IEnumerator<T> インターフェースを実装して、インターフェースの各メソッドが実行されるとそのメソッドの名前を出力するようにしています。

まずは、C# で実行してみます。

[c#]
static void Main(string[] args)
{
    var ite = IteratorHelper.CreateIterator(100, 200, 300);
    Console.WriteLine("[foreach]");
    foreach (var s in ite)
    {
        Console.WriteLine("> " + s);
    }
    Console.WriteLine("[count]");
    var count = ite.Count();
    Console.WriteLine("count =" + count);
    Console.Read();
}

/*** Output ***
Constructor()
[foreach]
GetEnumerator()
MoveNext() == True
> 100
MoveNext() == True
> 200
MoveNext() == True
> 300
MoveNext() == False
Dispose()
[count]
GetEnumerator()
Constructor()
MoveNext() == True
MoveNext() == True
MoveNext() == True
MoveNext() == False
Dispose()
count =3
*/

foreach や、Count(),ToArray()などの列挙が走るLinqの拡張メソッドを実行すると、上記の通り
GetEnumerator()、MoveNext()、Dispose() が順に呼ばれるのが確認できます。

では、早速PowerShellで使ってみましょう。
手始めにforeachから

PS C:\> $ite = [IteratorTest.IteratorHelper]::CreateIterator(100,200,300)
Constructor()

PS C:\> foreach($item in $ite){$item}
GetEnumerator()
MoveNext() == True
100
MoveNext() == True
200
MoveNext() == True
300
MoveNext() == False

ん?なにか違和感が... というかC#でforeachした場合と明らかに違う箇所が!

IEnumerator<T>.Dispose() が呼ばれていない!

どうやらPowerShell のforeachは IEnumerator<T>.Dispose() を呼んでくれないようです。

PowerShell 的には、非ジェネリック版のIEnumerable,IEnumerator のみを想定しているという事でしょうか?

ForEach-Object は?

PS C:\> $ite | ForEach-Object {$_}
GetEnumerator()
Constructor()
MoveNext() == True
100
MoveNext() == True
200
MoveNext() == True
300
MoveNext() == False

呼ばれませんね。

.ForEach() メソッド?

PS C:\> $ite.ForEach({$_})
GetEnumerator()
Constructor()
GetEnumerator()
MoveNext() == True
MoveNext() == True
MoveNext() == True
MoveNext() == False
100
200
300

こちらもDispose()されません。
(Dispose()とは関係ありませんが、.ForEach()では列挙が先に走ってからスクリプトブロックに値が渡っているように見えます。 本来の目的としては、こういった挙動の違いを見たかったのです)

Dispose()が呼ばれないと問題になりそうなケース

PowerShellでは、通常であれば非ジェネリック版のコレクションを前提として問題なさそうなのですが、
.Net Framework のクラス等を使ってジェネリックなコレクションを扱う場合は注意しないとハマってしまいそうです。

たとえば、IEnumerable<string> を返す System.IO.File.ReadLines() メソッドでは
以下のように foreachがDispose()呼んでくれるおかげで明示的にファイルを閉じる必要がありません。

[C#]
IEnumerable<string> lines = System.IO.File.ReadLines();
//この時点では読み取り実行されていない。ファイルは開いたまま
foreach(var line in lines)
{
    Console.WriteLine(line);
}
//foreachを抜けたらDispose()が呼ばれてファイルが閉じられる
//途中でBreak;してもDispose()されます

PowerShellで試してみましょう。

$lines = [System.IO.File]::ReadLines("d:\test\test.txt")
foreach($line in $lines){$line}
line1
line2
line3
line4

ここで、Dispose()が呼ばれず、ファイルが開きっぱなしのはずなので、Remove-Item でエラーとなるはず。

PS C:\> Remove-Item D:\test\test.txt -Verbose
詳細: 対象 "D:\test\test.txt" に対して操作 "ファイルの削除" を実行しています。

あれ??普通に出来ちゃいました。どうなっているのでしょう??
ちょっと予想が外れてしまいましたが、気を取り直してMicrosoft Reference Sourceでソースコードを見てみます。

[C#]
internal class ReadLinesIterator : Iterator<string>
{
    //....

    public override bool MoveNext()
    {
        if (this._reader != null)
        {
            this.current = _reader.ReadLine();
            if (this.current != null)
                return true;
            // To maintain 4.0 behavior we Dispose 
            // after reading to the end of the reader.
            Dispose();
        }
        return false;
    }
}

File.ReadLines() が実際に返しているオブジェクトはReadLinesIterator というクラスなのですが、 そのMoveNext()メソッドを見てみると、自らDispose()を呼んでいるじゃないですか。
じゃあ、問題ない?

でも待ってください。これだけでは途中でBreakした場合にDispose()されないのでは?

先ほどのコードにBreakを入れて実行してみましょう。

PS C:\> $lines = [System.IO.File]::ReadLines("d:\test\test.txt")
PS C:\> foreach($line in $lines){$line; break}
line1

PS C:\> Remove-Item D:\test\test.txt -Verbose
詳細: 対象 "D:\test\test.txt" に対して操作 "ファイルの削除" を実行しています。
Remove-Item : 項目 D:\test\test.txt を削除できません: 別のプロセスで使用されているため、プロセスはファイル 'D:\test\test.txt' にアクセスできません。
発生場所 行:1 文字:1
+ Remove-Item D:\test\test.txt -Verbose
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : WriteError: (D:\test\test.txt:FileInfo) [Remove-Item], IOException
    + FullyQualifiedErrorId : RemoveFileSystemItemIOError,Microsoft.PowerShell.Commands.RemoveItemCommand

今度は予想通りの結果になりました。ファイルが開いたままです。

まとめ

ジェネリック版のIEnumerable<T>を列挙する場合は、foreach に任せずに、GetEnumerator()- MoveNext()- Dispose()の一連の処理を自前で書いた方が良さそうです。

※ ただ、これを裏付けるような他の記事、ドキュメント等を見つけることが出来ず、いまいち確信が持てません。
有識者の方々のご意見を伺いたいところです。