イテレータを作成/利用するには?[C#/VB].NET TIPS

「yield」文を利用してイテレータを作成することで、反復処理やLINQで使える便利なメソッドやクラスを作成できる。これを作成/利用する方法を説明する。

» 2018年05月30日 05時00分 公開
[山本康彦BluewaterSoft/Microsoft MVP for Windows Development]
「.NET TIPS」のインデックス

連載「.NET TIPS」

 foreach構文やLINQのメソッドチェーンなどに慣れてくると、自作のクラスやメソッドも同じように扱えるようにしたくなるだろう。いわゆるイテレータ*1である。本稿では、yield構文を使ってそのようなメソッドやクラスを作る方法を解説する。

POINT イテレータメソッドと列挙可能なクラスの作り方

イテレータメソッドと列挙可能なクラスの作り方まとめ イテレータメソッドと列挙可能なクラスの作り方まとめ


 特定のトピックをすぐに知りたいという方は以下のリンクを活用してほしい。

 なお、本稿に掲載したサンプルコードをそのまま試すにはVisual Studio 2015以降が必要である。サンプルコードはコンソールアプリの一部であり、コードの冒頭に以下の宣言が必要となる。

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using static System.Console;

Imports System.Runtime.CompilerServices
Imports System.Console

本稿のサンプルコードに必要な宣言(上:C#、下:VB)

*1 デザインパターンの本などで紹介されているJavaのイテレータパターンには、Iteratorインタフェースの定義やその実装という形でイテレータが登場する。.NET Frameworkでは、IEnumerator<T>インタフェースにイテレータパターンが組み込まれている(JavaのhasNext/nextと異なり.NETではMoveNext/Current)。このIEnumerator<T>インタフェースを実装したクラスを独自に書くことは、yield構文のおかげでほとんどない。そのため、.NET Frameworkでイテレータといえば、イテレータパターンを実装したものというより、後述するイテレータメソッドを指すことが多い。


yield構文の基本

 foreachやLINQで使うには、IEnumerable<T>インタフェースを実装しているオブジェクトをメソッドから返せばよい。IEnumerable<T>インタフェースを実装したクラスを書くのは面倒なのだが、yield(読み方は「イールド」)構文を使うことでコンパイラがそのようなクラスを自動生成してくれる。そのため、実際にはyield構文で値を順番に返していくメソッドを書くだけで済む(次のコード)。このようなメソッドをイテレータメソッドという。

 C#では、yield returnステートメントで値を返していく。

 VBでは、メソッドにIterator修飾子を付けたうえで、Yieldステートメントで値を返していく。

public static class Numbers
{
  public static IEnumerable<int> OneToThree()
  {
    yield return 1;
    yield return 2;
    yield return 3;
  }
}

Public Module Numbers
  Public Iterator Function OneToThree() As IEnumerable(Of Integer)
    Yield 1
    Yield 2
    Yield 3
  End Function
End Module

yield構文の基本(上:C#、下:VB)
yield構文を使うメソッドの返す型は、IEnumerable<T>インタフェースの他に、非ジェネリックなIEnumerableインタフェース、または、IEnumerator<T>/IEnumeratorインタフェースでもよい。

 上記のOneToThreeメソッドは、1から3までの整数を順に返していくものだ。実際にforeachループとLINQで使ってみると、次のコードのようになる。

foreach (var n in Numbers.OneToThree())
  Write($"{n}, ");
WriteLine($"合計:{Numbers.OneToThree().Sum()}");
// 出力:1, 2, 3, 合計:6

For Each n In Numbers.OneToThree()
  Write($"{n}, ")
Next
WriteLine($"合計:{Numbers.OneToThree().Sum()}")
' 出力:1, 2, 3, 合計:6

上記のイテレータメソッドをコンソールアプリから使う例(上:C#、下:VB)
foreachループで列挙すると、確かに整数1/2/3が取り出せている。また、LINQのSum拡張メソッドを使って整数1/2/3の合計(=6)も計算できている。

ループでyieldを使う

 先のサンプルコードでは整数1/2/3をハードコーディングしていた。そこはもちろんループで書いてもよい。次のコードはforループを使い、引数で与えられた範囲の整数を順に返すようにした例だ。

public static class Numbers
{
  public static IEnumerable<int> FromTo(int from, int to)
  {
    for (int n = from; n <= to; n++)
      yield return n;
  }
}

Public Module Numbers
  Public Iterator Function FromTo(from As Integer, [to] As Integer) _
                        As IEnumerable(Of Integer)
    For n As Integer = from To [to]
      Yield n
    Next
  End Function
End Module

forループでyield構文を使う例(上:C#、下:VB)

 上記のFromToメソッドの使用例は、次のコードのようになる。

foreach (var n in Numbers.FromTo(3, 5))
  Write($"{n}, ");
WriteLine($"合計:{Numbers.FromTo(3, 5).Sum()}");
// 出力:3, 4, 5, 合計:12

For Each n In Numbers.FromTo(3, 5)
  Write($"{n}, ")
Next
WriteLine($"合計:{Numbers.FromTo(3, 5).Sum()}")
' 出力:3, 4, 5, 合計:12

上記のイテレータメソッドをコンソールアプリから使う例(上:C#、下:VB)
前の例は同様に、foreachループでの列挙もLINQのSum拡張メソッドによる集計もできている。

 では、ループの途中で条件によって打ち切りたいときはどうしたらよいだろうか? C#ではyield breakステートメントを、VBではReturnステートメントを使う(次のコード)。

public static class Numbers
{
  public static IEnumerable<int> FromAndCount(int from, int count)
  {
    int i = 0;
    while (true)
    {
      if (i < count)
        yield return (from + i);
      else
        yield break;
      i++;
    }
  }
}

Public Module Numbers
  Public Iterator Function FromAndCount(from As Integer, count As Integer) _
                            As IEnumerable(Of Integer)
    Dim i As Integer = 0
    While (True)
      If (i < count) Then
        Yield (from + i)
      Else
        Return
      End If
      i += 1
    End While
  End Function
End Module

whileループでyieldを途中で打ち切る例(上:C#、下:VB)
このFromAndCountメソッドは、第1引数で与えられた整数から列挙を開始し、第2引数で与えられた個数だけ出力したところで列挙を打ち切る。

 上記のFromAndCountメソッドの使用例は、次のコードのようになる。

foreach (var n in Numbers.FromAndCount(5, 3))
  Write($"{n}, ");
WriteLine($"合計:{Numbers.FromAndCount(5, 3).Sum()}");
// 出力:5, 6, 7, 合計:18

For Each n In Numbers.FromAndCount(5, 3)
  Write($"{n}, ")
Next
WriteLine($"合計:{Numbers.FromAndCount(5, 3).Sum()}")
' 出力:5, 6, 7, 合計:18

上記のイテレータメソッドをコンソールアプリから使う例(上:C#、下:VB)
第2引数で指定した個数(=3個)を出力したところで列挙が打ち切られている。

プロパティで使う

 yield構文は、プロパティのgetアクセサでも利用できる。例えば、1から順番に整数を返し続けるプロパティを次のコードのように書ける。

public static class Numbers
{
  public static IEnumerable<int> Infinite
  {
    get
    {
      int i = 1;
      while (true)
        yield return i++;
    }
  }
}

Public Module Numbers
  Public ReadOnly Iterator Property Infinite As IEnumerable(Of Integer)
    Get
      Dim i As Integer = 1
      While (True)
        Yield i
        i = i + 1
      End While
    End Get
  End Property
End Module

yield構文をプロパティで使う例(上:C#、下:VB)
これはどこまでも列挙し続けるので(整数の最大値まで)、次のコードのようにして使う側で列挙を打ち切らなければならない。

 上記のInfiniteプロパティの使用例は、次のコードのようになる。無限に列挙され続けるので、LINQのTake拡張メソッドを使って5個で打ち切っている。

foreach (var n in Numbers.Infinite.Take(5))
  Write($"{n}, ");
WriteLine($"合計:{Numbers.Infinite.Take(5).Sum()}");
// 出力:1, 2, 3, 4, 5, 合計:15

For Each n In Numbers.Infinite.Take(5)
  Write($"{n}, ")
Next
WriteLine($"合計:{Numbers.Infinite.Take(5).Sum()}")
' 出力:1, 2, 3, 4, 5, 合計:15

上記のイテレータプロパティをコンソールアプリから使う例(上:C#、下:VB)

 なお、上の使用例は整数のオーバーフロー例外にはならない。それはすなわち、LINQのTake拡張メソッドが列挙を打ち切った時点で、Infiniteプロパティ内の無限ループも停止したということである。この性質はよく覚えておいてほしい。yieldのループは、実際に列挙されるときに必要な回数だけ回されるのである。

LINQのメソッドチェーンで使えるメソッドを作る

 イテレータメソッドをLINQのメソッドチェーンの途中で使えるようにするには、IEnumerable<T1>型を受け取る拡張メソッドにする(次のコード)。そのイテレータメソッドが返す型IEnumerable<T2>型だとすると、T1とT2は異なった型にしてもよい(例えば、整数の列挙を文字列の列挙に変換するなど)。

public static class Numbers
{
  public static IEnumerable<int> Twice(this IEnumerable<int> input)
  {
    foreach (var n in input)
      yield return n * 2;
  }
}

Public Module Numbers
  <Extension()>
  Public Iterator Function Twice(input As IEnumerable(Of Integer)) _
                                As IEnumerable(Of Integer)
    For Each n In input
      Yield n * 2
    Next
  End Function
End Module

LINQのメソッドチェーンで使えるイテレータメソッドの例(上:C#、下:VB)
これは整数の列挙を、それぞれ2倍にするものだ。
拡張メソッドの書き方については、.NET TIPS「拡張メソッドを作成するには?[C#/VB]」をご覧いただきたい。

 上記のTwiceメソッドの使用例は、次のコードのようになる。

IEnumerable<int> oneToThreeTwice = Numbers.OneToThree().Twice();
foreach (var n in oneToThreeTwice)
  Write($"{n}, ");
WriteLine($"合計:{Numbers.OneToThree().Twice().Sum()}");
// 出力:2, 4, 6, 合計:12

Dim oneToThreeTwice As IEnumerable(Of Integer) = Numbers.OneToThree().Twice()
For Each n In oneToThreeTwice
  Write($"{n}, ")
Next
WriteLine($"合計:{Numbers.OneToThree().Twice().Sum()}")
' 出力:2, 4, 6, 合計:12

上記のイテレータメソッドをコンソールアプリから使う例(上:C#、下:VB)
OneToThreeメソッドは、前述した1から3の整数を返すイテレータメソッドである。Twiceメソッドによって、それぞれ2倍の2から6になっている。合計も2倍だ。

foreachで使えるクラスを作る

 ここまで紹介してきたイテレータメソッドは、メソッドの返値をforeachループやLINQの入力として使えるものだった。インスタンスそのものがforeachループやLINQの入力として使えるようにするにはどうしたらよいだろうか? そのためには、IEnumerable<T>インタフェースを実装する。

 IEnumerable<T>インタフェースは次のコードのようになっている(C#のみ示す)。

namespace System.Collections.Generic
{
  public interface IEnumerable<T>
  {
    IEnumerator<T> GetEnumerator();
    IEnumerator GetEnumerator(); // IEnumerableインタフェースから継承
  }
}

IEnumerable<T>インタフェース(C#)
実際にはIEnumerableインタフェースを継承しているのだが、ここでは合成して記載している。

 IEnumerable<T>インタフェースには、返す型が異なる2つのGetEnumeratorメソッドが定義されている。IEnumerator型を返す非ジェネリックなメソッドは、ジェネリックが登場する以前との互換性のためだ。実際には、IEnumerator<T>型を返すメソッドの方だけを実装して、それをIEnumerator型を返すメソッドから呼び出すようにする(次のコード)。

public class NumbersCollection : IEnumerable<int>
{
  private readonly int _firstNumber; // 開始する整数
  private readonly int _countMax;  // 列挙する整数の数

  // コンストラクタ
  public NumbersCollection(int from, int count)
  {
    _firstNumber = from;
    _countMax = count;
  }

  public IEnumerator<int> GetEnumerator()
  {
    int n = _firstNumber;
    for (int i = 0; i < _countMax; i++)
      yield return n++;

    // 次のように書いてしまうと、列挙するたびに結果が変わってしまうので注意
    //for (int i = 0; i < _countMax; i++)
    //  yield return _firstNumber++;
  }

  IEnumerator IEnumerable.GetEnumerator()
    => this.GetEnumerator();
}

Public Class NumbersCollection
  Implements IEnumerable(Of Integer)

  Private ReadOnly _firstNumber As Integer ' 開始する整数
  Private ReadOnly _countMax As Integer ' 列挙する整数の数

  ' コンストラクタ
  Public Sub New(from As Integer, count As Integer)
    _firstNumber = from
    _countMax = count
  End Sub

  Public Iterator Function GetEnumerator() As IEnumerator(Of Integer) _
                                Implements IEnumerable(Of Integer).GetEnumerator
    Dim n As Integer = _firstNumber 
    For i As Integer = 0 To (_countMax - 1)
      Yield n
      n += 1
    Next
    ' 次のように書いてしまうと、列挙するたびに結果が変わってしまうので注意
    'For i As Integer = 0 To (_countMax - 1)
    '  Yield _firstNumber
    _firstNumber += 1
    'Next
  End Function

  Private Function IEnumerable_GetEnumerator() As IEnumerator _
                                Implements IEnumerable.GetEnumerator
    ' Yieldしていないので、Iterator修飾子は不要
    Return Me.GetEnumerator()
  End Function
End Class

IEnumerable<T>インタフェースを実装するクラスの例(上:C#、下:VB)
前述したFromAndCountイテレータメソッドと同様な列挙を行うクラスである。コンストラクタの第1引数で与えられた整数から列挙を開始し、第2引数で与えられた個数だけ出力したところで列挙を打ち切る。
このGetEnumeratorジェネリックメソッドもイテレータメソッドであるが、ここまでのものとは異なり、IEnumerator<T>型を返している。メソッド内部の書き方は、ここまでのIEnumerable<T>型を返すイテレータメソッドと同じだ。
この例では、コンストラクタで初期化したメンバ変数(_firstNumberと_countMax)を書き換えてはいけない(書き換えられないようにリードオンリーにしてある)。もしも列挙中に書き換えてしまったら、列挙するたびに結果が変わってしまう。

 上記のNumbersCollectionクラスの使用例は、次のコードのようになる。

var nums = new NumbersCollection(3, 5);
foreach (var n in nums)
  Write($"{n}, ");
WriteLine($"合計:{nums.Sum()}");
// 出力:3, 4, 5, 6, 7, 合計:25

Dim nums = New NumbersCollection(3, 5)
For Each n In nums
  Write($"{n}, ")
Next
WriteLine($"合計:{nums.Sum()}")
' 出力:3, 4, 5, 6, 7, 合計:25

上記のNumbersCollectionクラスをコンソールアプリから使う例(上:C#、下:VB)
整数の3から順番に5個を返すNumbersCollectionクラスのインスタンスを作り、foreachとLINQのSum拡張メソッドで合わせて2回の列挙を行っている。

C#で例外を扱う

 C#の場合、try〜catchの中には原則としてyieldを書けない。最小限のスコープで丁寧にtry〜catchするか、あるいは、イテレータメソッドそのものをラップしたメソッドを作ってそこでtry〜catchする。

 次の2つは、例外的にtry〜catchの中にyieldを書ける場合である(次からの2つのサンプルコードも参照)。

  • yield breakは、try句とcatch句に置ける(finally句には置けない)
  • yield returnは、catch句がない場合に限り、try句に置ける

 メソッドの中身を大きくtry句で囲った上でcatch句を持たせてしまうと、yield returnが置けないので、意味のあるイテレータメソッドを書けない(次のコード)。

public static IEnumerable<int> FromToWithException1(int from, int to)
{
  for (int n = from; n <= to; n++)
  {
    try
    {
      if (n == 99)
        yield break; // try句にyield breakは置ける
      //yield return n; // catch句があるとyield returnはコンパイルエラー
    }
    catch
    {
      //yield return 0; // コンパイルエラー
      yield break; // catch句にyield breakは置ける
    }
    finally
    {
      //yield return 0; // コンパイルエラー
      //yield break; // コンパイルエラー
    }
  }
}

catch句があると、意味のあるイテレータメソッドを書けない(C#)

 catch句なしでfinally句だけならyield returnを書けるが、catch句がないのだから例外の送出は止められない(次のコード)。

public static IEnumerable<int> FromToWithException2(int from, int to)
{
  for (int n = from; n <= to; n++)
  {
    try
    {
      try
      {
        if (n == 1)
          throw new System.FormatException();
      }
      catch {/*省略*/} // yieldを含まないtry〜catchは書ける

      if (n == 4)
      {
        WriteLine($"ArgumentException発生(n={n})");
        throw new System.ArgumentException("イテレータメソッド内で発生した例外");
      }
      if (n == 6)
        yield break; // try句にyield breakは置ける

      yield return n; // catch句がなければyield returnも置ける
    }
    finally
    {
      WriteLine($"finally句が実行された(n={n})");
      //yield return 0; // コンパイルエラー
      //yield break; // コンパイルエラー
    }
  }
}

finally句だけならyield returnを書ける(C#)
前述したFromToメソッドと同様に、第1引数の整数から第2引数の整数まで順に列挙する。ただし、4のときはArgumentExceptionが発生する。

 ただし、try〜finallyだけでも意味がある。たとえイテレータメソッドの外で(つまり、列挙する側のforeachループの中で取得した値を処理しているときなどに)例外が発生したとしても、イテレータメソッド内のfinally句は実行されるのである(次のコード)。リソース解放などの後始末をfinally句に書いておけば、やはりきちんと実行されるのだ。

// 列挙中にイテレータメソッド内で例外
try
{
  // n==4のとき、イテレータメソッド内で例外が発生する
  foreach (var n in Numbers.FromToWithException2(1, 7))
    WriteLine($"列挙:{n}");
}
catch
{
  WriteLine("イテレータメソッドの外で例外をキャッチ");
}
// 出力:
// 列挙:1
// finally句が実行された(n=1)
// 列挙:2
// finally句が実行された(n=2)
// 列挙:3
// finally句が実行された(n=3)
// ArgumentException発生(n=4)
// finally句が実行された(n=4)
// イテレータメソッドの外で例外をキャッチ

// 列挙中にイテレータメソッド外で例外
try
{
  foreach (var n in Numbers.FromToWithException2(1, 7))
  {
    WriteLine($"列挙:{n}");
    // n==3のとき、イテレータメソッド外で例外を発生させる
    if (n == 3)
    {
      WriteLine("イテレータメソッドの外でDataException発生");
      throw new System.Data.DataException();
    }
  }
}
catch
{
  WriteLine("イテレータメソッドの外で例外をキャッチ");
}
// 出力:
// 列挙:1
// finally句が実行された(n=1)
// 列挙:2
// finally句が実行された(n=2)
// 列挙:3
// イテレータメソッドの外でDataException発生
// finally句が実行された(n=3)
// イテレータメソッドの外で例外をキャッチ

上記FromToWithException2メソッドをコンソールアプリで呼び出す(C#)
2つの例を載せたが、どちらも例外が出た後でfinally句が実行されている。

VBで例外をトラップする

 VBの場合、Try句にはYieldステートメント/Returnステートメントを置ける。Catch句にはReturnステートメントだけを置ける。Finally句には、どちらも置けない(次のコード)。

 例外をキャッチしてリカバリーできても、もはやYieldステートメントで値を返せないのであるから、あまりキャッチする意味はない(C#では、割り切ってcatch句を置けなくしている)。

Public Iterator Function FromToWIthException(from As Integer, [to] As Integer) _
    As IEnumerable(Of Integer)
  For n As Integer = from To [to]
    Try
      If (n = 2 OrElse n = 4) Then
        Throw New ArgumentException("イテレータメソッド内で発生した例外")
      End If
      If (n = 99) Then
        Return
      End If
      Yield n
    Catch ex As ArgumentException
      WriteLine($"Catch句が実行された:{ex.Message}(n={n})")
      'Yield 0  ' コンパイルエラー
      If (n = 4) Then
        WriteLine("反復を打ち切り")
        Return
      End If
    Finally
      WriteLine($"Finally句が実行された(n={n})")
      'Yield 0 ' コンパイルエラー
      'Return ' コンパイルエラー
    End Try
  Next
End Function

Try〜Catchする例(VB)
前述したFromToメソッドと同様に、第1引数の整数から第2引数の整数まで順に列挙する。ただし、2または4のときはArgumentExceptionが発生する。また、4のときは値を返さずに反復を打ち切る。

 YieldもReturnも書けないFinally句だが、意味はある。たとえイテレータメソッドの外で(つまり、列挙する側のFor Eachループの中で取得した値を使って処理をしているときなどに)例外が発生したとしても、イテレータメソッド内のFinally句が実行される(次のコード)。リソース解放などの後始末をFinally句に書いておけば、やはりきちんと実行されるのである。

' 列挙中にイテレータメソッド内で例外
For Each n In Numbers.FromToWIthException(1, 5)
  WriteLine($"列挙:{n}")
Next
' 出力:
' 列挙:1
' Finally句が実行された(n=1)
' Catch句が実行された:イテレータメソッド内で発生した例外(n=2)
' Finally句が実行された(n=2)
' 列挙:3
' Finally句が実行された(n=3)
' Catch句が実行された:イテレータメソッド内で発生した例外(n=4)
' 反復を打ち切り
' Finally句が実行された(n=4)

' 列挙中にイテレータメソッド外で例外
Try
  For Each n In Numbers.FromToWIthException(1, 3)
    WriteLine($"列挙:{n}")
    If (n = 3) Then
      WriteLine("イテレータメソッドの外でDataException発生")
      Throw New DataException()
    End If
  Next
Catch ex As Exception
  WriteLine("イテレータメソッドの外で例外をキャッチ")
End Try
' 列挙:1
' Finally句が実行された(n=1)
' Catch句が実行された:イテレータメソッド内で発生した例外(n=2)
' Finally句が実行された(n=2)
' 列挙:3
' イテレータメソッドの外でDataException発生
' Finally句が実行された(n=3)
' イテレータメソッドの外で例外をキャッチ

上記FromToWithExceptionメソッドをコンソールアプリで呼び出す(VB)
2つの例を載せたが、どちらも例外が出た後でFinally句が実行されている。
n=2のとき、反復は継続されているが、そのときの値が返されていないことに注意。このメソッドを使う側は、n=2のときの値が抜けていることに気付かないかもしれないし、気付いたとしても理由が分からないかもしれない。

まとめ

 イテレータメソッドやIEnumerable<T>インタフェースを実装したクラスなどを作るときのyieldの使い方を解説した。このようなメソッドやクラスは、foreach構文やLINQのチェーンメソッドで利用できる。

利用可能バージョン:C# 2.0(Visual Studio 2005)以降/Visual Basic 11(Visual Studio 2012)以降
カテゴリ:C# 処理対象:言語構文
カテゴリ:Visual Basic 処理対象:言語構文
使用ライブラリ:IEnumerable<T>インタフェース(System.Collections.Generic名前空間)
使用ライブラリ:IEnumerator<T>インタフェース(System.Collections.Generic名前空間)
関連TIPS:LINQ:数値コレクション内の数値を集計するには?
関連TIPS:拡張メソッドを作成するには?[C#/VB]
関連TIPS:foreachループで現在の繰り返し回数を使うには?[C#/VB]
関連TIPS:C# 7のローカル関数の使いどころとは?
関連TIPS:構文:クラス名を書かずに静的メソッドを呼び出すには?[C# 6.0]
関連TIPS:VB.NETでクラス名を省略してメソッドや定数を利用するには?
関連TIPS:数値を右詰めや0埋めで文字列化するには?[C#、VB]
関連TIPS:構文:メソッドやプロパティをラムダ式で簡潔に実装するには?[C# 6.0/7.0]


「.NET TIPS」のインデックス

.NET TIPS

Copyright© Digital Advantage Corp. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。