50
Help us understand the problem. What are the problem?

More than 5 years have passed since last update.

posted at

updated at

【ちゃんと投げよう】あなたの作ったオレオレLINQメソッドは間違ってるかもしれない!【ArgumentNullException】

はじめに

 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投げてくれないかもしれません。SelectWhereのように、メソッドを呼び出した時点でArgumentNullExceptionを投げないと、メソッドが遅延実行されるため、思いがけないタイミングでArgumentNullExceptionが発生して、デバックが大変になる可能性があります。

 SelectWhereなどのように、正しいタイミングでArgumentNullException投げてくれないを投げるよう、気をつけて実装しましょう。

 本投稿のプロジェクトはこちら

 また、余談ですが.NETのSelectはパフォーマンスをよくするために、本投稿のようなシンプルな実装にはなっていません。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
50
Help us understand the problem. What are the problem?