はじめに
C#最高ですよね!LINQ最高ですよね!!!
さてそんなC#・LINQに対してですが、「LINQにこういうの欲しいな〜。無いからLINQみたいにIEnumerable<TSource>の拡張メソッドとして、IEnumerable<TResult>を返すメソッドを作ろう!」と「オレオレLINQメソッド」を作ったことがある人も結構いると思います。
そんな「オレオレLINQメソッド」ですが、気をつけないと思わぬ落とし穴にはまるかもしれません。
あなたの作った「オレオレLINQメソッド」、ちゃんと正しくArgumentNullException
投げますか?
LINQの多くのメソッドは遅延実行
LINQの多くのメソッドは遅延実行されます。
using System;
using System.Linq;
using System.Collections.Generic;
namespace YieldExample
{
class MainClass
{
static int ShowAndReturn (int num)
{
Console.WriteLine ($" in ShowAndReturn:{num}");
return num;
}
public static void Main (string[] args)
{
int[] intArray = new int[]{ 3, 1, 4, 1, 5, 9, 2 };
IEnumerable<int> intEnumerable = intArray.Select (ShowAndReturn);
Console.WriteLine ("after select");
foreach (int num in intEnumerable) {
Console.WriteLine ($" in foreach loop:{num}");
}
}
}
}
上記のコードの実行結果は次の通りです。
after select
in ShowAndReturn:3
in foreach loop:3
in ShowAndReturn:1
in foreach loop:1
in ShowAndReturn:4
in foreach loop:4
in ShowAndReturn:1
in foreach loop:1
in ShowAndReturn:5
in foreach loop:5
in ShowAndReturn:9
in foreach loop:9
in ShowAndReturn:2
in foreach loop:2
上記の実行結果からわかるように、Select
メソッドを呼び出しても、すぐにintArray
の各要素にShowAndReturn
から作ったデリゲートが適用されるわけではありません。Selectメソッドの結果をforeachメソッドで列挙した時点など、必要になった時点で適用されます。
LINQの多くのメソッドは遅延実行されますね。「オレオレLINQメソッド」を作られる方は、この点は把握されていると思います。(コード・プロジェクトの置き場はこちら)
LINQの遅延実行されるメソッドの多くも、ArgumentNullExceptionはすぐ投げる
さてプログラミングを書く上で良い場合だけ考えてはいけません。例外的な状況も考慮しないといけません。Selectメソッドの引数にnullを渡した場合を考えましょう。
次のNUnitを用いたテストコードをみてください。次のテストは全てパスします。
using NUnit.Framework;
using System;
using System.Linq;
using System.Collections.Generic;
namespace EnumerableArgumentNullExceptionExample
{
[TestFixture ()]
public class TestLinq
{
[Test ()]
public void TestSelect ()
{
IEnumerable<int> intEnumerable = new int[]{ 3, 1, 4, 1, 5, 9, 2 };
Func<int, int> nullSelector = null;
Assert.Catch<ArgumentNullException> (() => {
intEnumerable.Select (nullSelector);
});
Assert.Catch<ArgumentNullException> (() => {
foreach (int num in intEnumerable.Select (nullSelector)) {
;
}
});
}
}
}
特に次の箇所を見てください。
Assert.Catch<ArgumentNullException> (() => {
intEnumerable.Select (nullSelector);
});
Select
メソッドは遅延実行されるのでしたね。上記のコードでは、foreach文などでSelectの結果は列挙されておらず評価されていませんね。しかし、ArgumentNullException
が発生しています。もし引数がnullだった場合、その結果が列挙(評価)されずともメソッド呼び出しをした時点で、ArgumentNullException
を投げることがこのコードからわかりますね。
間違ったオレオレLINQの例
さて、「オレオレLINQ」メソッドを作ってみましょう。今回はSelect
と全く同じ機能を持つメソッドを、Map
というメソッド名で作ってみましょう。
public static class MyEnumerable
{
public static IEnumerable<TResult> Map<TSource, TResult> (this IEnumerable<TSource> source, Func<TSource,TResult> selector)
{
if (source == null)
throw new ArgumentNullException ("source");
if (selector == null)
throw new ArgumentNullException ("predicate");
foreach (TSource element in source) {
yield return selector (element);
}
}
}
}
この投稿のタイトルは『【ちゃんと投げよう】あなたの作ったオレオレLINQメソッドは間違ってるかもしれない!【ArgumentNullException】』です。どうでしょうか?ちゃんと引数がnullだった際の処理も書かれていて、一見ちゃんとArgumentNullExceptionを投げそうですよね。
Selectメソッドは遅延実行されるメソッドでしたが、引数がnullだった場合は評価される時点でなくて、メソッド呼び出した時点でArgumentNullException
を投げたのを思い出してください。Map
メソッドも正しくArgumentNullException
を投げるかテストをみてみましょう。
namespace EnumerableArgumentNullExceptionExample.Bad
{
[TestFixture ()]
public class TestMyLinq
{
[Test ()]
public void TestMap ()
{
IEnumerable<int> intEnumerable = new int[]{ 3, 1, 4, 1, 5, 9, 2 };
Func<int, int> nullSelector = null;
// //Next test cannot pass. Must add null handling exactlly.
// Assert.Catch<ArgumentNullException> (() => {
// intEnumerable.Map (nullSelector);
// });
Assert.Catch<ArgumentNullException> (() => {
foreach (int num in intEnumerable.Map (nullSelector)) {
;
}
});
}
}
}
上記のコードのコメントアウトした部分に注目してください。残念ながら、この箇所のコードはパスしません。
intEnumerable.Map (nullSelector);
Select
メソッドではメソッドが呼ばれた時点でArgumentNullException
が投げられました。しかしMap
メソッドはnullが引数として渡された時点ではArgumentNullException
を投げてくれません。
どうすれば良いでしょうか?
すぐArgumentNullExceptionを投げるように改善する
C#の仕様的に先ほどのMap
のコードでは、評価したタイミングでArgumentNullException
を投げてくれません。それでは、Selectメソッドのようにメソッドを呼び出した時点でArgumentNullException
を投げるように修正しましょう。
修正版Map
メソッドは次の通りです。
public static IEnumerable<TResult> Map<TSource, TResult> (this IEnumerable<TSource> source, Func<TSource,TResult> selector)
{
if (source == null)
throw new ArgumentNullException ("source");
if (selector == null)
throw new ArgumentNullException ("selector");
return source.Map_ (selector);
}
public static IEnumerable<TResult> Map_<TSource, TResult> (this IEnumerable<TSource> source, Func<TSource,TResult> selector)
{
foreach (TSource element in source) {
yield return selector (element);
}
}
null
チエックをする部分と別に、yield
で列挙する箇所を別メソッドとして切り出しました。このように修正したMap
メソッドは次のテストをパスすることができます。
namespace EnumerableArgumentNullExceptionExample
{
[TestFixture ()]
public class TestMyLinq
{
[Test ()]
public void TestMap ()
{
IEnumerable<int> intEnumerable = new int[]{ 3, 1, 4, 1, 5, 9, 2 };
Func<int, int> nullSelector = null;
Assert.Catch<ArgumentNullException> (() => {
intEnumerable.Map (nullSelector);
});
Assert.Catch<ArgumentNullException> (() => {
foreach (int num in intEnumerable.Map (nullSelector)) {
;
}
});
}
}
}
さいごに
「LINQにこういうの欲しいな〜。無いからLINQみたいにIEnumerable<TSource>の拡張メソッドとして、IEnumerable<TResult>を返すメソッドを作ろう!」と、「オレオレLINQメソッド」を作りたくなることがあると思います。
しかし気をつけて実装をしないと、ちゃんと正しくArgumentNullException
投げてくれないかもしれません。Select
やWhere
のように、メソッドを呼び出した時点でArgumentNullException
を投げないと、メソッドが遅延実行されるため、思いがけないタイミングでArgumentNullException
が発生して、デバックが大変になる可能性があります。
Select
やWhere
などのように、正しいタイミングでArgumentNullException
投げてくれないを投げるよう、気をつけて実装しましょう。
本投稿のプロジェクトはこちら
また、余談ですが.NETのSelectはパフォーマンスをよくするために、本投稿のようなシンプルな実装にはなっていません。