C#:イテレータメソッドを作るときはローカル関数を使った方がいい理由

C# のイテレータは遅延評価です。
イテレータメソッドに引数チェックがある場合でも、メソッド呼び出し時にはチェックが行われず、評価時に例外が発生します。

素数を返すイテレータを例に見てみます。

class PrimeService
{
    public IEnumerable<int> Generate(int start, int end)
    {
        if (start <= 1)
            throw new ArgumentException("引数は1以上を指定してください");
        if (end < start)
            throw new ArgumentException($"{nameof(end)}には{nameof(start)}以上の値を指定して下さい");

        for(var i = start; start <= end; end++)
        {
            if (IsPrime(i))
                yield return i;
        }

    }

    private bool IsPrime(int n)
    {
         // 素数判定の中身は省略します。一番下に置いておきます。
    }
}

上記のイテレータメソッドは

  • 開始が 1 以下
  • 開始が終りより大きい

と例外が投げられるはずですが、メソッド呼び出し時に例外は発生しません。

class Program
{
    static void Main(string[] args)
    {
        var primeService = new PrimeService();
        var primes = primeService.Generate(start: 123, end: 32); // <- 引数が不正

        Console.WriteLine("まだ例外がなげられない!");

        foreach (var prime in primes) // <- ここで例外が発生する
            Console.WriteLine(prime);
    }
}

今回のように例外の発生個所と実際に例外をスローするコードが近い場合には問題の発見は簡単です。
しかし、大規模なプログラムになればイテレータを作るコードと列挙を開始するコードが近くにあるとは限らないため、問題の発見が困難になる場合があります。

イテレータの実装部をローカル関数として持とう

イテレータメソッドをローカル関数として内部に持つことでこの問題は解決します。
Generate が呼び出された時に引数が不正である場合、その場で例外がスローされるようになります。

public IEnumerable<int> Generate(int start, int end)
{
    if (start <= 1)
        throw new ArgumentException("引数は1以上を指定してください");
    if (end < start)
        throw new ArgumentException($"{nameof(end)}には{nameof(start)}以上の値を指定して下さい");

    return generateImpl();

    IEnumerable<int> generateImpl()
    {
        for (var i = start; start <= end; end++)
        {
            if (IsPrime(i))
                yield return i;
        }
    }
}

ローカル関数として実装を持つメリットは以下が挙げられます。

  • 実装メソッドへのアクセスをラッパーの Generate メソッド内のスコープに限定できる
    • そのためエラーチェックのない実装メソッドが直接アクセスされる危険がない
  • 実装メソッドはラッパーメソッドのすべての引数とローカル変数にアクセスできるため、実装メソッドの引数として渡す必要がない

More Effective C# 6.0/7.0 を参考にしました。

省略した IsPrime メソッドの中身

下記の書籍を参考にしました。
実戦で役立つ C#プログラミングのイディオム/定石&パターン

private bool IsPrime(int n)
{
    if (n == 1)
        return false;
    if (n == 2)
        return true;
    var boudary = (long)Math.Floor(Math.Sqrt(n));
    for (long i = 2; i <= boudary; ++i)
    {
        if (n % i == 0)
        {
            return false;
        }
    }
    return true;
}
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.