I've got a C# string extension method that should return an IEnumerable<int>
of all the indexes of a substring within a string. It works perfectly for its intended purpose and the expected results are returned (as proven by one of my tests, although not the one below), but another unit test has discovered a problem with it: it can't handle null arguments.
Here's the extension method I'm testing:
public static IEnumerable<int> AllIndexesOf(this string str, string searchText)
{
if (searchText == null)
{
throw new ArgumentNullException("searchText");
}
for (int index = 0; ; index += searchText.Length)
{
index = str.IndexOf(searchText, index);
if (index == -1)
break;
yield return index;
}
}
Here is the test that flagged up the problem:
[TestMethod]
[ExpectedException(typeof(ArgumentNullException))]
public void Extensions_AllIndexesOf_HandlesNullArguments()
{
string test = "a.b.c.d.e";
test.AllIndexesOf(null);
}
When the test runs against my extension method, it fails, with the standard error message that the method "did not throw an exception".
This is confusing: I have clearly passed null
into the function, yet for some reason the comparison null == null
is returning false
. Therefore, no exception is thrown and the code continues.
I have confirmed this is not a bug with the test: when running the method in my main project with a call to Console.WriteLine
in the null-comparison if
block, nothing is shown on the console and no exception is caught by any catch
block I add. Furthermore, using string.IsNullOrEmpty
instead of == null
has the same problem.
Why does this supposedly-simple comparison fail?
Enumerators, as the others have said, aren't evaluated until the time they start getting enumerated (i.e. the
IEnumerable.GetNext
method is called). Thus thisdoesn't get evaluated until you start enumerating, i.e.
You have an iterator block. None of the code in that method is ever run outside of calls to
MoveNext
on the returned iterator. Calling the method does noting but create the state machine, and that won't ever fail (outside of extremes such as out of memory errors, stack overflows, or thread abort exceptions).When you actually attempt to iterate the sequence you'll get the exceptions.
This is why the LINQ methods actually need two methods to have the error handling semantics they desire. They have a private method that is an iterator block, and then a non-iterator block method that does nothing but do the argument validation (so that it can be done eagerly, rather than it being deferred) while still deferring all other functionality.
So this is the general pattern:
You are using
yield return
. When doing so, the compiler will rewrite your method into a function that returns a generated class that implements a state machine.Broadly speaking, it rewrites locals to fields of that class and each part of your algorithm between the
yield return
instructions becomes a state. You can check with a decompiler what this method becomes after compilation (make sure to turn off smart decompilation which would produceyield return
).But the bottom line is: the code of your method won't be executed until you start iterating.
The usual way to check for preconditions is to split your method in two:
This works because the first method will behave just like you expect (immediate execution), and will return the state machine implemented by the second method.
Note that you should also check the
str
parameter fornull
, because extensions methods can be called onnull
values, as they're just syntactic sugar.If you're curious about what the compiler does to your code, here's your method, decompiled with dotPeek using the Show Compiler-generated Code option.
This is invalid C# code, because the compiler is allowed to do things the language doesn't allow, but which are legal in IL - for instance naming the variables in a way you couldn't to avoid name collisions.
But as you can see, the
AllIndexesOf
only constructs and returns an object, whose constructor only initializes some state.GetEnumerator
only copies the object. The real work is done when you start enumerating (by calling theMoveNext
method).