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;
}