VB のたまご

作成日: 2017/10/01, 更新日: 2017/10/01


IEnumerable と Yield と遅延評価(2017年10月版)

  •  LINQ のクエリ構文(SQL っぽい書き方のデータ編集・加工)やメソッド構文(メソッドをドットでつなげる書き方のデータ編集・加工)を使う際、 戻り値が IEnumerable(Of T) というジェネリックインターフェースになっています。LINQ が登場してからあちこちで見かけるインターフェースになりました。

  •  この IEnumerable(Of T) インターフェースを使い慣れるために、簡単ですが、扱い方を書き直しましたので共有します。

  •  参考
  • Microsoft Docs
    Iterators (Visual Basic)
    https://docs.microsoft.com/en-us/dotnet/visual-basic/programming-guide/concepts/iterators
    


IEnumerable インターフェースの役割

  •  まずは以下を見てみましょう。
  • Public Sub Test1()
    
        ' IEnumerable インターフェースと IEnumerator インターフェースによって、
        ' For Each ループが可能になる
    
        ' ジェネリック版の IEnumerable(Of T) と IEnumerator(Of T) インターフェースは、
        ' IEnumerable インターフェースと IEnumerator インターフェースを継承しているため、
        ' For Each ループが可能になる
    
        ' 配列やリストは IEnumerable(Of T) を継承しているので For Each できる
        Dim sources As String() = New String() {"a", "b", "c"}
        Dim items As IEnumerable(Of String) = sources ' 基底クラスに暗黙的なアップキャスト
    
        For Each item In items
            Console.WriteLine(item)
        Next
    
        ' 実際の処理は、IEnumerator インターフェースが担当している
        Dim enumerator = items.GetEnumerator()
        While enumerator.MoveNext() ' 次の値を確認、次の値があれば Current 更新(するだけ。次の次の値についてはまだ知らない)
            Console.WriteLine(enumerator.Current)
        End While
    
        ' IEnumerator を使っての遅延取得を自作してみる
        enumerator = Me.GetEnumerator()
        While enumerator.MoveNext()
            Console.WriteLine(enumerator.Current)
        End While
    
        ' For Each は IEnumerator インターフェースではできない
        'For Each item In enumerator
        'Next
    
    End Sub
    
    ' Yield を使う時は、
    '   Iterator キーワードを付ける
    '   戻り値は、IEnumerable, または IEnumerator, またはジェネリック版にする
    Private Iterator Function GetEnumerator() As IEnumerator(Of String)
    
        Yield "a" ' "a" を返却したら、ここで中断("b", "c" まで進んでいかない)、後で MoveNext メソッドが呼ばれたら次の行から再開する
        Yield "b" ' "b" を返却したら、ここで中断("c" まで進んでいかない)、後で MoveNext メソッドが呼ばれたら次の行から再開する
        Yield "c" ' "c" を返却したら、終わり
    
        ' ※中断というのは、プログラムの実行がここで停止してしまうのではなく、
        ' 処理が進まないまま戻り値を返すという意味。ただし、ちゃんと次回のスタート位置を覚えている。
    
    
        ' ループして返却する時も同じ。1つ返却したらループ内で中断。後で MoveNext メソッドが呼ばれたら次のループ処理から再開する
        Dim items = New List(Of String) From {"d", "e", "f"}
        For i As Integer = 0 To items.Count - 1
            Yield items(i)
        Next
    
    End Function
    

  •  配列やリストなどの複数データを扱う際、普段 For Each を使ってループ処理をおこなったりしますが、 For Each することができるのは、IEnumerable インターフェースを継承しているからで、For Each は糖衣構文などと呼ばれています。

  •  ただし、具体的な変換処理は見れず、良くある例として、For Each の書き方を変形させた書き方が多くあげられます。 それは、複数データから IEnumerable.GetEnumerator メソッドを抜き出して、MoveNext メソッドと Current プロパティを使った書き方です。

  •  ここで注目したいのは、データの準備の仕方です。 MoveNext メソッドは内部で管理している複数データのうち、次の値があるか無いかしか見ていません。 次の次の値、以降、まだ残っているデータについては知らない(興味を持たない)という特徴があります。

  •  現在のループが終わって次のループに進んだときに、初めて次の値を確認しに行くという動作になっています。 全部一気に準備して渡すのではなく、今回必要となる1つ分のデータだけ準備して渡してあげる。 というのを繰り返す準備の仕方をすることで、必要となるメモリ使用量が減るというエコなメリットがあります。

  •  この For Each 内で値を列挙するという仕事を担当している IEnumerator インターフェースですが、 Iterator キーワードと Yield キーワードを用いて簡潔に定義することができるようになりました。

  •  GetEnumerator メソッドの定義のところです。とてもたくさんの説明コメントを書いたせいでサンプルが汚くなっていますが、 普段のメソッドのイメージで考える、戻り値の戻し方とは別物になります。

  •  【Yield 戻り値】という命令を書くと、そこでいったん指定のデータが返却されて、以降に残る命令は走らずに、そのメソッド処理は終了します。 分かりづらいので、IEnumerator の外側と内側を合わせて見ていきましょう。

  •  IEnumerator を使って MoveNext メソッドと Current プロパティを組み合わせたループ処理を考えてみます。 外側から MoveNext メソッドが呼ばれると、内側の処理が始まります。 最初に登場している【Yield "a"】という命令が実行されると、"a" が返却されて、Current プロパティにセットされます。

  •  ここで、プログラムはいったん中断します。 と言うと絶対誤解させてしまうのですが、言葉通りにプログラムが止まってしまうのではなく、以降の処理は実行せずに、 GetEnumerator メソッドを呼び出した呼び出し元に制御を返してしまいます。

  •  ただしちゃんとどこまで進んだのかはプログラムが覚えていて、次に MoveNext メソッドが呼ばれると、 今度は前回実行した次の行から処理が再開していきます。

  •  これは、言葉で説明しても伝わりづらい動作なので、デバッグしてどう動くのか見てみることをお勧めします。 MoveNext メソッド → Yield Xxx → Current プロパティ、という順で連携して列挙しています。


自作クラスを For Each 対応する

  •  自作クラスを対応させるには、IEnumerable インターフェースを実装することで For Each ができるようになります。
  • ' 自作クラスに For Each できるように継承する
    Class DaysOfWeekList
        Implements IEnumerable(Of String)
    
        Private days As String() = New String() {"日", "月", "火", "水", "木", "金", "土"}
    
        Public Iterator Function GetEnumerator() As IEnumerator(Of String) Implements IEnumerable(Of String).GetEnumerator
            For i As Integer = 0 To days.Count - 1
                Yield days(i)
            Next
        End Function
    
        ' 非ジェネリック版は Private なので非公開。ただしインターフェースなので実装しないといけない
        Private Iterator Function IEnumerable_GetEnumerator() As IEnumerator Implements IEnumerable.GetEnumerator
            Yield GetEnumerator()
        End Function
    
    End Class
    
    Public Sub Test2()
    
        Dim items1 = New DaysOfWeekList
        For Each item In items1
        Next
    
    End Sub
    

  •  ソースコードそのままです。ちょっと煩わしいところが、ジェネリック版となる IEnumerable(Of T) インターフェースは、 非ジェネリック版となる IEnumerable インターフェースを継承しているので、2つ分の GetEnumerator メソッドを実装しなければいけないところですね。


Iterator メソッド内で例外エラーが発生すると、以降の処理が走らなくなる

  •  これは、サンプルを動かした方が手っ取り早いです。
  • ' Iterator メソッド内で例外エラーが発生すると、以降の処理は走らなくなる
    Private Iterator Function GetEnumerable() As IEnumerable(Of Integer)
    
        Try
            Yield 1
            Yield 2
            Throw New Exception("例外エラーが発生すると、以降の Yield は無効になる")
            Yield 3
            Yield 4
        Catch ex As Exception
            Console.WriteLine(ex.Message)
        Finally
            Console.WriteLine("Finally は呼び出せる")
        End Try
    
    End Function
    
    Public Sub Test3()
    
        Dim items = GetEnumerable()
        For Each item In items
            Console.WriteLine(item)
        Next
        Console.WriteLine("完了")
    
        ' 1
        ' 2
        ' 例外エラーが発生すると、以降の Yield は無効になる
        ' Finally は呼び出せる
        ' 完了
    
    End Sub
    

  •  扱う際の注意点として覚えてください。 ただ、例外エラーが発生したら列挙どころではないというのは、For ループでも同じですよね。
  • Dim items2 = New Object() {56, "aaa", True}
    For i As Integer = 0 To items2.Count - 1
        Dim item As Integer = CInt(items2(i))
    Next
    


Yield はラムダ式でも使える

  •  というだけです。ラムダ式は、無名なだけでメソッドに変わりはないので、そりゃそうですよね。としか・・・。
  • Public Sub Test4()
    
        ' Yield はラムダ式でも使える
        Dim getEnumerable1 = Iterator Function() As IEnumerable(Of Integer)
                                 Yield 1
                                 Yield 2
                             End Function
    
        Dim items = getEnumerable1()
        For Each item In items
            Console.WriteLine(item)
        Next
    
    End Sub
    


Enumerable クラスについて

  •  似たような名前で Enumerable クラス(System.Linq 名前空間)があります。 IEnumerable インターフェースを実装して作ったかのように見える Enumerable クラスですが、実装はしていません。

  •  それでは、何をするクラスなのかと言うと、IEnumerable(Of T) インターフェースを実装したクラスの編集や加工を容易におこなえるように、 大量のサポートメソッドを提供しているクラスになります。
  • Public Sub Test5()
    
        ' Enumerable クラス
        ' 継承できない
        ' IEnumerable(Of T) 型のデータを編集するための拡張メソッドがたくさんある
        ' 拡張メソッドは、変数の後ろにつなげられる→戻り値が、流れるように次のメソッドの入力値になる
    
        ' 以下は LINQ のうち、メソッド構文という書き方。
        ' それぞれの拡張メソッドの引数に、ラムダ式を渡している(処理内容を渡している)。
        ' 見た目は直接渡しているが、実際にはジェネリックデリゲートがラムダ式を受け取っていて、内部で実行している
        ' つい型推論に頼って、型を省略してしまう。
        Dim items9 = Enumerable.
                        Range(1, 10).
                        Where(Function(x) x Mod 2 = 0).
                        Take(3).
                        Select(Function(x) x * x)
    
        For Each item In items9
            Console.WriteLine(item)
        Next
    
        ' 以下は LINQ のうち、クエリ構文という書き方。
        Dim items10 = From x In Enumerable.Range(1, 10)
                      Where x Mod 2 = 0
                      Take 3
                      Select x
    
        For Each item In items10
            Console.WriteLine(item)
        Next
    
    End Sub
    


List と IEnumerable のメモリ使用量の比較

  •  List のように一度に全部準備して列挙する系のデータと、Yield を使った1つ分ずつ列挙する系のデータの2つを使って、 速度とメモリ使用量を見てみましょう。
  • Public Sub Test6()
    
        Dim st = New Stopwatch()
    
        Dim useMemory = GC.GetTotalMemory(True)
        st.Start()
    
        Dim items1 = GetList()
        For Each item In items1
    
        Next
    
        st.Stop()
        useMemory = GC.GetTotalMemory(True) - useMemory
        Console.WriteLine($"{useMemory} byte の使用量で、{st.Elapsed.TotalSeconds} 秒かかった")
    
        ' -------------------------------------------------------------
    
        useMemory = GC.GetTotalMemory(True)
        st.Reset()
        st.Start()
    
        Dim items2 = GetEnumerable()
        For Each item In items2
    
        Next
    
        st.Stop()
        useMemory = GC.GetTotalMemory(True) - useMemory
        Console.WriteLine($"{useMemory} byte の使用量で、{st.Elapsed.TotalSeconds} 秒かかった")
    
        ' 9324432 byte の使用量で、0.2662424 秒かかった
        '      24 byte の使用量で、0.2241702 秒かかった
    
    End Sub
    
    Private Function GetList() As List(Of String)
    
        Dim items = New List(Of String)
        For i As Integer = 0 To 100000
            items.Add(Guid.NewGuid().ToString)
        Next
    
        ' 100000 件分確保してから、やっと返却する
        Return items
    
    End Function
    
    Private Iterator Function GetEnumerable() As IEnumerable(Of String)
    
        For i As Integer = 0 To 100000
            ' 1 件ずつ返却しては停止、返却しては停止、を繰り返す
            Yield Guid.NewGuid().ToString
        Next
    
    End Function
    

  •  この結果を見ると、劇的な差となって表れているのがメモリ使用量ですね。速度は同じくらいですね。


まとめ

  •  駆け足で説明してきましたが、いかがだったでしょうか。特殊な扱い方があるとはいえ、ただの複数データを扱うためのインターフェースなだけです。 いまいちよく分からない、ピンと来ないという方は、LINQ して戻ってくる値が IEnumerable(Of T) 型でコレクションデータを扱うものだということだけ覚えてください。